diff --git a/.azdo/pipelines/azure-dev.yml b/.azdo/pipelines/azure-dev.yml new file mode 100644 index 00000000..48c126c4 --- /dev/null +++ b/.azdo/pipelines/azure-dev.yml @@ -0,0 +1,138 @@ +# Run when commits are pushed to mainline branch (main or master) +# Set this to the mainline branch you are using +trigger: + - main + - master + +# Azure Pipelines workflow to deploy to Azure using azd +# To configure required secrets and service connection for connecting to Azure, simply run `azd pipeline config --provider azdo` +# Task "Install azd" needs to install setup-azd extension for azdo - https://marketplace.visualstudio.com/items?itemName=ms-azuretools.azd +# See below for alternative task to install azd if you can't install above task in your organization + +pool: + vmImage: ubuntu-latest + +steps: + - task: setup-azd@0 + displayName: Install azd + + # If you can't install above task in your organization, you can comment it and uncomment below task to install azd + # - task: Bash@3 + # displayName: Install azd + # inputs: + # targetType: 'inline' + # script: | + # curl -fsSL https://aka.ms/install-azd.sh | bash + + # azd delegate auth to az to use service connection with AzureCLI@2 + - pwsh: | + azd config set auth.useAzCliAuth "true" + displayName: Configure AZD to Use AZ CLI Authentication. + + - task: AzureCLI@2 + displayName: Provision Infrastructure + inputs: + # azconnection is the service connection created by azd. You can change it to any service connection you have in your organization. + azureSubscription: azconnection + scriptType: bash + scriptLocation: inlineScript + inlineScript: | + azd provision --no-prompt + env: + AZURE_SUBSCRIPTION_ID: $(AZURE_SUBSCRIPTION_ID) + AZURE_ENV_NAME: $(AZURE_ENV_NAME) + AZURE_LOCATION: $(AZURE_LOCATION) + AZD_INITIAL_ENVIRONMENT_CONFIG: $(AZD_INITIAL_ENVIRONMENT_CONFIG) + AZURE_OPENAI_SERVICE: $(AZURE_OPENAI_SERVICE) + AZURE_OPENAI_LOCATION: $(AZURE_OPENAI_LOCATION) + AZURE_OPENAI_RESOURCE_GROUP: $(AZURE_OPENAI_RESOURCE_GROUP) + AZURE_DOCUMENTINTELLIGENCE_SERVICE: $(AZURE_DOCUMENTINTELLIGENCE_SERVICE) + AZURE_DOCUMENTINTELLIGENCE_RESOURCE_GROUP: $(AZURE_DOCUMENTINTELLIGENCE_RESOURCE_GROUP) + AZURE_DOCUMENTINTELLIGENCE_SKU: $(AZURE_DOCUMENTINTELLIGENCE_SKU) + AZURE_DOCUMENTINTELLIGENCE_LOCATION: $(AZURE_DOCUMENTINTELLIGENCE_LOCATION) + AZURE_SEARCH_INDEX: $(AZURE_SEARCH_INDEX) + AZURE_SEARCH_SERVICE: $(AZURE_SEARCH_SERVICE) + AZURE_SEARCH_SERVICE_RESOURCE_GROUP: $(AZURE_SEARCH_SERVICE_RESOURCE_GROUP) + AZURE_SEARCH_SERVICE_LOCATION: $(AZURE_SEARCH_SERVICE_LOCATION) + AZURE_SEARCH_SERVICE_SKU: $(AZURE_SEARCH_SERVICE_SKU) + AZURE_SEARCH_QUERY_LANGUAGE: $(AZURE_SEARCH_QUERY_LANGUAGE) + AZURE_SEARCH_QUERY_SPELLER: $(AZURE_SEARCH_QUERY_SPELLER) + AZURE_SEARCH_SEMANTIC_RANKER: $(AZURE_SEARCH_SEMANTIC_RANKER) + AZURE_SEARCH_QUERY_REWRITING: $(AZURE_SEARCH_QUERY_REWRITING) + AZURE_SEARCH_FIELD_NAME_EMBEDDING: $(AZURE_SEARCH_FIELD_NAME_EMBEDDING) + AZURE_STORAGE_ACCOUNT: $(AZURE_STORAGE_ACCOUNT) + AZURE_STORAGE_RESOURCE_GROUP: $(AZURE_STORAGE_RESOURCE_GROUP) + AZURE_STORAGE_SKU: $(AZURE_STORAGE_SKU) + AZURE_APP_SERVICE_SKU: $(AZURE_APP_SERVICE_SKU) + AZURE_OPENAI_CHATGPT_MODEL: $(AZURE_OPENAI_CHATGPT_MODEL) + AZURE_OPENAI_CHATGPT_DEPLOYMENT: $(AZURE_OPENAI_CHATGPT_DEPLOYMENT) + AZURE_OPENAI_CHATGPT_DEPLOYMENT_CAPACITY: $(AZURE_OPENAI_CHATGPT_DEPLOYMENT_CAPACITY) + AZURE_OPENAI_CHATGPT_DEPLOYMENT_VERSION: $(AZURE_OPENAI_CHATGPT_DEPLOYMENT_VERSION) + AZURE_OPENAI_CHATGPT_DEPLOYMENT_SKU: $(AZURE_OPENAI_CHATGPT_DEPLOYMENT_SKU) + AZURE_OPENAI_REASONING_EFFORT: $(AZURE_OPENAI_REASONING_EFFORT) + AGENTIC_KNOWLEDGEBASE_REASONING_EFFORT: $(AGENTIC_KNOWLEDGEBASE_REASONING_EFFORT) + AZURE_OPENAI_EMB_MODEL_NAME: $(AZURE_OPENAI_EMB_MODEL_NAME) + AZURE_OPENAI_EMB_DEPLOYMENT: $(AZURE_OPENAI_EMB_DEPLOYMENT) + AZURE_OPENAI_EMB_DEPLOYMENT_CAPACITY: $(AZURE_OPENAI_EMB_DEPLOYMENT_CAPACITY) + AZURE_OPENAI_EMB_DEPLOYMENT_VERSION: $(AZURE_OPENAI_EMB_DEPLOYMENT_VERSION) + AZURE_OPENAI_EMB_DEPLOYMENT_SKU: $(AZURE_OPENAI_EMB_DEPLOYMENT_SKU) + AZURE_OPENAI_EMB_DIMENSIONS: $(AZURE_OPENAI_EMB_DIMENSIONS) + AZURE_OPENAI_DISABLE_KEYS: $(AZURE_OPENAI_DISABLE_KEYS) + OPENAI_HOST: $(OPENAI_HOST) + OPENAI_API_KEY: $(OPENAI_API_KEY) + OPENAI_ORGANIZATION: $(OPENAI_ORGANIZATION) + AZURE_USE_APPLICATION_INSIGHTS: $(AZURE_USE_APPLICATION_INSIGHTS) + AZURE_APPLICATION_INSIGHTS: $(AZURE_APPLICATION_INSIGHTS) + AZURE_APPLICATION_INSIGHTS_DASHBOARD: $(AZURE_APPLICATION_INSIGHTS_DASHBOARD) + AZURE_LOG_ANALYTICS: $(AZURE_LOG_ANALYTICS) + USE_VECTORS: $(USE_VECTORS) + USE_MULTIMODAL: $(USE_MULTIMODAL) + AZURE_VISION_ENDPOINT: $(AZURE_VISION_ENDPOINT) + VISION_SECRET_NAME: $(VISION_SECRET_NAME) + AZURE_VISION_SERVICE: $(AZURE_VISION_SERVICE) + AZURE_VISION_RESOURCE_GROUP: $(AZURE_VISION_RESOURCE_GROUP) + AZURE_VISION_LOCATION: $(AZURE_VISION_LOCATION) + AZURE_VISION_SKU: $(AZURE_VISION_SKU) + ENABLE_LANGUAGE_PICKER: $(ENABLE_LANGUAGE_PICKER) + USE_SPEECH_INPUT_BROWSER: $(USE_SPEECH_INPUT_BROWSER) + USE_SPEECH_OUTPUT_BROWSER: $(USE_SPEECH_OUTPUT_BROWSER) + USE_SPEECH_OUTPUT_AZURE: $(USE_SPEECH_OUTPUT_AZURE) + AZURE_SPEECH_SERVICE: $(AZURE_SPEECH_SERVICE) + AZURE_SPEECH_SERVICE_RESOURCE_GROUP: $(AZURE_SPEECH_SERVICE_RESOURCE_GROUP) + AZURE_SPEECH_SERVICE_LOCATION: $(AZURE_SPEECH_SERVICE_LOCATION) + AZURE_SPEECH_SERVICE_SKU: $(AZURE_SPEECH_SERVICE_SKU) + AZURE_SPEECH_SERVICE_VOICE: $(AZURE_SPEECH_SERVICE_VOICE) + AZURE_KEY_VAULT_NAME: $(AZURE_KEY_VAULT_NAME) + AZURE_USE_AUTHENTICATION: $(AZURE_USE_AUTHENTICATION) + AZURE_ENFORCE_ACCESS_CONTROL: $(AZURE_ENFORCE_ACCESS_CONTROL) + AZURE_ENABLE_GLOBAL_DOCUMENT_ACCESS: $(AZURE_ENABLE_GLOBAL_DOCUMENT_ACCESS) + AZURE_ENABLE_UNAUTHENTICATED_ACCESS: $(AZURE_ENABLE_UNAUTHENTICATED_ACCESS) + AZURE_TENANT_ID: $(AZURE_TENANT_ID) + AZURE_AUTH_TENANT_ID: $(AZURE_AUTH_TENANT_ID) + AZURE_SERVER_APP_ID: $(AZURE_SERVER_APP_ID) + AZURE_CLIENT_APP_ID: $(AZURE_CLIENT_APP_ID) + ALLOWED_ORIGIN: $(ALLOWED_ORIGIN) + AZURE_SERVER_APP_SECRET: $(AZURE_SERVER_APP_SECRET) + AZURE_CLIENT_APP_SECRET: $(AZURE_CLIENT_APP_SECRET) + AZURE_ADLS_GEN2_STORAGE_ACCOUNT: $(AZURE_ADLS_GEN2_STORAGE_ACCOUNT) + AZURE_ADLS_GEN2_FILESYSTEM_PATH: $(AZURE_ADLS_GEN2_FILESYSTEM_PATH) + AZURE_ADLS_GEN2_FILESYSTEM: $(AZURE_ADLS_GEN2_FILESYSTEM) + DEPLOYMENT_TARGET: $(DEPLOYMENT_TARGET) + AZURE_CONTAINER_APPS_WORKLOAD_PROFILE: $(AZURE_CONTAINER_APPS_WORKLOAD_PROFILE) + USE_CHAT_HISTORY_BROWSER: $(USE_CHAT_HISTORY_BROWSER) + USE_MEDIA_DESCRIBER_AZURE_CU: $(USE_MEDIA_DESCRIBER_AZURE_CU) + ECHOVOICE_SEARCH_TEXT_TARGETS: $(ECHOVOICE_SEARCH_TEXT_TARGETS) + ECHOVOICE_SEARCH_IMAGE_EMBEDDINGS: $(ECHOVOICE_SEARCH_IMAGE_EMBEDDINGS) + ECHOVOICE_SEND_TEXT_SOURCES: $(ECHOVOICE_SEND_TEXT_SOURCES) + ECHOVOICE_SEND_IMAGE_SOURCES: $(ECHOVOICE_SEND_IMAGE_SOURCES) + USE_AGENTIC_KNOWLEDGEBASE: $(USE_AGENTIC_KNOWLEDGEBASE) + USE_WEB_SOURCE: $(USE_WEB_SOURCE) + USE_SHAREPOINT_SOURCE: $(USE_SHAREPOINT_SOURCE) + - task: AzureCLI@2 + displayName: Deploy Application + inputs: + azureSubscription: azconnection + scriptType: bash + scriptLocation: inlineScript + inlineScript: | + azd deploy --no-prompt diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..23842385 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,33 @@ +{ + "name": "Azure Search OpenAI Demo", + "image": "mcr.microsoft.com/devcontainers/python:3.13-bookworm", + "features": { + "ghcr.io/devcontainers/features/node:1": { + // This should match the version of Node.js in Github Actions workflows + "version": "22", + "nodeGypDependencies": false + }, + "ghcr.io/devcontainers/features/azure-cli:1.2.5": {}, + "ghcr.io/devcontainers/features/docker-in-docker:2": {}, + "ghcr.io/azure/azure-dev/azd:latest": {} + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-azuretools.azure-dev", + "ms-azuretools.vscode-bicep", + "ms-python.python", + "esbenp.prettier-vscode", + "DavidAnson.vscode-markdownlint" + ] + } + }, + "forwardPorts": [ + 8000 + ], + "postCreateCommand": "", + "remoteUser": "vscode", + "hostRequirements": { + "memory": "8gb" + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..99f84ac3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.sh text eol=lf +*.jsonlines text eol=lf diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml deleted file mode 100644 index 4c6b9a2b..00000000 --- a/.github/workflows/ci-python.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: CI — Python - -on: - push: - branches: [ develop ] - pull_request: - branches: [ develop ] - -jobs: - test: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r backend/requirements.txt - if [ -f backend/requirements-dev.txt ]; then pip install -r backend/requirements-dev.txt; fi - - - name: Run tests - working-directory: backend - run: | - pytest -q diff --git a/.github/workflows/sync-develop-to-main.yml b/.github/workflows/sync-develop-to-main.yml deleted file mode 100644 index 71c978bd..00000000 --- a/.github/workflows/sync-develop-to-main.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Sync develop -> main - -on: - push: - branches: - - develop - -permissions: - contents: write - pull-requests: write - -jobs: - sync: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Create pull request (develop -> main) - id: create_pr - uses: repo-sync/pull-request@v2 - with: - source_branch: develop - destination_branch: main - pr_title: "chore: sync develop -> main" - pr_body: | - This PR is automatically created by CI to sync `develop` into `main`. - pr_label: chore - github_token: ${{ secrets.PAT_PR }} - - - name: Enable auto-merge on PR - if: steps.create_pr.outputs.pull-request-number != '' - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.PAT_PR }} # OK - script: | - const prNumber = Number("${{ steps.create_pr.outputs.pull-request-number }}"); - if (!prNumber) { - core.warning("No PR number detected — cannot enable auto-merge."); - return; - } - core.info(`Attempting to enable auto-merge for PR #${prNumber}`); diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..87b8af21 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +exclude: '^tests/snapshots/' +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.2 + hooks: + - id: ruff +- repo: https://github.com/psf/black + rev: 25.1.0 + hooks: + - id: black +- repo: https://github.com/pre-commit/mirrors-prettier + rev: v3.1.0 + hooks: + - id: prettier + types_or: [css, javascript, ts, tsx, html] diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..5afef11f --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "ms-azuretools.azure-dev", + "ms-azuretools.vscode-bicep", + "ms-python.python", + "esbenp.prettier-vscode", + "DavidAnson.vscode-markdownlint" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..04656a5b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,53 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Backend (Python)", + "type": "debugpy", + "request": "launch", + "module": "quart", + "cwd": "${workspaceFolder}/app/backend", + "python": "${workspaceFolder}/.venv/bin/python", + "env": { + "QUART_APP": "main:app", + "QUART_ENV": "development", + "QUART_DEBUG": "0", + // Set this to "no-override" if you want env vars here to override AZD env vars + "LOADING_MODE_FOR_AZD_ENV_VARS": "override" + }, + "args": [ + "run", + "--no-reload", + "-p 8000" + ], + "console": "integratedTerminal", + "justMyCode": false + }, + { + "name": "Frontend", + "type": "node-terminal", + "request": "launch", + "command": "npm run dev", + "cwd": "${workspaceFolder}/app/frontend", + }, + { + "name": "Tests (Python)", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "purpose": ["debug-test"], + "console": "integratedTerminal", + "justMyCode": false + } + ], + "compounds": [ + { + "name": "Frontend & Backend", + "configurations": ["Backend (Python)", "Frontend"], + "stopAll": true + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..c7af59bf --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,37 @@ +{ + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "[css]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "files.exclude": { + "**/__pycache__": true, + "**/.coverage": true, + "**/.pytest_cache": true, + "**/.ruff_cache": true, + "**/.mypy_cache": true + }, + "search.exclude": { + "**/node_modules": true, + "static": true + }, + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "python.analysis.extraPaths": [ + "./app/backend" + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..bf096f30 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,85 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Start App", + "type": "shell", + "command": "${workspaceFolder}/app/start.sh", + "windows": { + "command": "pwsh ${workspaceFolder}/app/start.ps1" + }, + "presentation": { + "reveal": "silent" + }, + "options": { + "cwd": "${workspaceFolder}/app" + }, + "problemMatcher": [] + }, + { + "label": "Development", + "dependsOn": [ + "Frontend: npm run dev", + "Backend: uvicorn run" + ], + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "Frontend: npm run dev", + "type": "npm", + "script": "dev", + "isBackground": true, + "options": { + "cwd": "${workspaceFolder}/app/frontend" + }, + "presentation": { + "reveal": "always", + "group": "buildWatchers", + "panel": "dedicated", + "clear": false + }, + "problemMatcher": { + "pattern": { + "regexp": "" + }, + "background": { + "activeOnStart": true, + "beginsPattern": ".*ready - started server on.*", + "endsPattern": ".*(?:Local|Started)[:]?\\s+https?://.*" + } + } + }, + { + "label": "Backend: uvicorn run", + "type": "shell", + "command": "${workspaceFolder}/.venv/bin/python", + "windows": { + "command": "${workspaceFolder}\\.venv\\Scripts\\python.exe" + }, + "args": ["-m", "uvicorn", "api.main:app", "--reload", "--port", "8000"], + "options": { + "cwd": "${workspaceFolder}/app/backend", + "env": { + "LOADING_MODE_FOR_AZD_ENV_VARS": "override" + } + }, + "isBackground": true, + "presentation": { + "reveal": "always", + "group": "buildWatchers", + "panel": "dedicated" + }, + "problemMatcher": { + "pattern": { "regexp": "" }, + "background": { + "activeOnStart": true, + "beginsPattern": ".*Uvicorn running on.*|.*Started server process.*", + "endsPattern": ".*Application startup complete.*|.*\\s*Started server on.*" + } + } + } + ] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..3ac719c7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,188 @@ +# Instructions for Coding Agents + +This file contains instructions for developers working on the Azure Search and OpenAI demo application. It covers the overall code layout, how to add new data, how to add new azd environment variables, how to add new developer settings, and how to add tests for new features. + +Always keep this file up to date with any changes to the codebase or development process. +If necessary, edit this file to ensure it accurately reflects the current state of the project. + +## Overall code layout + +* app: Contains the main application code, including frontend and backend. + * app/backend: Contains the Python backend code, written with Quart framework. + * app/backend/approaches: Contains the different approaches + * app/backend/approaches/approach.py: Base class for all approaches + * app/backend/approaches/retrievethenread.py: Ask approach, just searches and answers + * app/backend/approaches/chatreadretrieveread.py: Chat approach, includes query rewriting step first + * app/backend/approaches/prompts/ask_answer_question.prompty: Prompt used by the Ask approach to answer the question based off sources + * app/backend/approaches/prompts/chat_query_rewrite.prompty: Prompt used to rewrite the query based off search history into a better search query + * app/backend/approaches/prompts/chat_query_rewrite_tools.json: Tools used by the query rewriting prompt + * app/backend/approaches/prompts/chat_answer_question.prompty: Prompt used by the Chat approach to actually answer the question based off sources + * app/backend/prepdocslib: Contains the document ingestion library used by both local and cloud ingestion + * app/backend/prepdocslib/blobmanager.py: Manages uploads to Azure Blob Storage + * app/backend/prepdocslib/cloudingestionstrategy.py: Builds the Azure AI Search indexer and skillset for the cloud ingestion pipeline + * app/backend/prepdocslib/csvparser.py: Parses CSV files + * app/backend/prepdocslib/embeddings.py: Generates embeddings for text and images using Azure OpenAI + * app/backend/prepdocslib/figureprocessor.py: Generates figure descriptions for both local ingestion and the cloud figure-processor skill + * app/backend/prepdocslib/fileprocessor.py: Orchestrates parsing and chunking of individual files + * app/backend/prepdocslib/filestrategy.py: Strategy for uploading and indexing files (local ingestion) + * app/backend/prepdocslib/htmlparser.py: Parses HTML files + * app/backend/prepdocslib/integratedvectorizerstrategy.py: Strategy using Azure AI Search integrated vectorization + * app/backend/prepdocslib/jsonparser.py: Parses JSON files + * app/backend/prepdocslib/listfilestrategy.py: Lists files from local filesystem or Azure Data Lake + * app/backend/prepdocslib/mediadescriber.py: Interfaces for describing images (Azure OpenAI GPT-4o, Content Understanding) + * app/backend/prepdocslib/page.py: Data classes for pages, images, and chunks + * app/backend/prepdocslib/parser.py: Base parser interface + * app/backend/prepdocslib/pdfparser.py: Parses PDFs using Azure Document Intelligence or local parser + * app/backend/prepdocslib/searchmanager.py: Manages Azure AI Search index creation and updates + * app/backend/prepdocslib/servicesetup.py: Shared service setup helpers for OpenAI, embeddings, blob storage, etc. + * app/backend/prepdocslib/strategy.py: Base strategy interface for document ingestion + * app/backend/prepdocslib/textparser.py: Parses plain text and markdown files + * app/backend/prepdocslib/textprocessor.py: Processes text chunks for cloud ingestion (merges figures, generates embeddings) + * app/backend/prepdocslib/textsplitter.py: Splits text into chunks using different strategies + * app/backend/app.py: The main entry point for the backend application. + * app/functions: Azure Functions used for cloud ingestion custom skills (document extraction, figure processing, text processing). Each function bundles a synchronized copy of `prepdocslib`; run `python scripts/copy_prepdocslib.py` to refresh the local copies if you modify the library. + * app/frontend: Contains the React frontend code, built with TypeScript, built with NextJs. + * app/frontend/src/api: Contains the API client code for communicating with the backend. + * app/frontend/src/components: Contains the React components for the frontend. + * app/frontend/src/locales: Contains the translation files for internationalization. + * app/frontend/src/locales/da/translation.json: Danish translations + * app/frontend/src/locales/en/translation.json: English translations + * app/frontend/src/locales/es/translation.json: Spanish translations + * app/frontend/src/locales/fr/translation.json: French translations + * app/frontend/src/locales/it/translation.json: Italian translations + * app/frontend/src/locales/ja/translation.json: Japanese translations + * app/frontend/src/locales/nl/translation.json: Dutch translations + * app/frontend/src/locales/ptBR/translation.json: Portuguese translations + * app/frontend/src/locales/tr/translation.json: Turkish translations + * app/frontend/src/pages: Contains the main pages of the application +* infra: Contains the Bicep templates for provisioning Azure resources. +* tests: Contains the test code, including e2e tests, app integration tests, and unit tests. + +## Adding new data + +New files should be added to the `data` folder, and then either run scripts/prepdocs.sh or scripts/prepdocs.ps1 to ingest the data. + +## Adding a new azd environment variable + +An azd environment variable is stored by the azd CLI for each environment. It is passed to the "azd up" command and can configure both provisioning options and application settings. +When adding new azd environment variables, update: + +1. infra/main.parameters.json : Add the new parameter with a Bicep-friendly variable name and map to the new environment variable +1. infra/main.bicep: Add the new Bicep parameter at the top, and add it to the `appEnvVariables` object +1. .azdo/pipelines/azure-dev.yml: Add the new environment variable under `env` section +1. .github/workflows/azure-dev.yml: Add the new environment variable under `env` section + +You may also need to update: + +1. app/backend/prepdocs.py: If the variable is used in the ingestion script, retrieve it from environment variables here. Not always needed. +1. app/backend/app.py: If the variable is used in the backend application, retrieve it from environment variables in setup_clients() function. Not always needed. + +## Adding a new setting to "Developer Settings" in RAG app + +When adding a new developer setting, update: + +* frontend: + * app/frontend/src/api/models.ts : Add to ChatAppRequestOverrides + * app/frontend/src/components/Settings.tsx : Add a UI element for the setting + * app/frontend/src/locales/*/translations.json: Add a translation for the setting label/tooltip for all languages + * app/frontend/src/pages/chat/Chat.tsx: Add the setting to the component, pass it to Settings + * app/frontend/src/pages/ask/Ask.tsx: Add the setting to the component, pass it to Settings + +* backend: + * app/backend/approaches/chatreadretrieveread.py : Retrieve from overrides parameter + * app/backend/approaches/retrievethenread.py : Retrieve from overrides parameter + * app/backend/app.py: Some settings may need to be sent down in the /config route. + +## When adding tests for a new feature + +All tests are in the `tests` folder and use the pytest framework. +There are three styles of tests: + +* e2e tests: These use playwright to run the app in a browser and test the UI end-to-end. They are in e2e.py and they mock the backend using the snapshots from the app tests. +* app integration tests: Mostly in test_app.py, these test the app's API endpoints and use mocks for services like Azure OpenAI and Azure Search. +* unit tests: The rest of the tests are unit tests that test individual functions and methods. They are in test_*.py files. + +When adding a new feature, add tests for it in the appropriate file. +If the feature is a UI element, add an e2e test for it. +If it is an API endpoint, add an app integration test for it. +If it is a function or method, add a unit test for it. +Use mocks from tests/conftest.py to mock external services. Prefer mocking at the HTTP/requests level when possible. + +When you're running tests, make sure you activate the .venv virtual environment first: + +```shell +source .venv/bin/activate +``` + +To check for coverage, run the following command: + +```shell +pytest --cov --cov-report=annotate:cov_annotate +``` + +Open the cov_annotate directory to view the annotated source code. There will be one file per source file. If a file has 100% source coverage, it means all lines are covered by tests, so you do not need to open the file. + +For each file that has less than 100% test coverage, find the matching file in cov_annotate and review the file. + +If a line starts with a ! (exclamation mark), it means that the line is not covered by tests. Add tests to cover the missing lines. + +## Sending pull requests + +When sending pull requests, make sure to follow the PULL_REQUEST_TEMPLATE.md format. + +## Upgrading dependencies + +To upgrade a particular package in the backend, use the following command, replacing `` with the name of the package you want to upgrade: + +```shell +cd app/backend && uv pip compile requirements.in -o requirements.txt --python-version 3.10 --upgrade-package package-name +``` + +## Checking Python type hints + +To check Python type hints, use the following command: + +```shell +cd app/backend && mypy . --config-file=../../pyproject.toml +``` + +```shell +cd scripts && mypy . --config-file=../pyproject.toml +``` + +Note that we do not currently enforce type hints in the tests folder, as it would require adding a lot of `# type: ignore` comments to the existing tests. +We only enforce type hints in the main application code and scripts. + +## Python code style + +Do not use single underscores in front of "private" methods or variables in Python code. We do not follow that convention in this codebase, since this is an application and not a library. + +## Deploying the application + +To deploy the application, use the `azd` CLI tool. Make sure you have the latest version of the `azd` CLI installed. Then, run the following command from the root of the repository: + +```shell +azd up +``` + +That command will BOTH provision the Azure resources AND deploy the application code. + +If you only changed the Bicep templates and want to re-provision the Azure resources, run: + +```shell +azd provision +``` + +If you only changed the application code and want to re-deploy the code, run: + +```shell +azd deploy +``` + +If you are using cloud ingestion and only want to deploy individual functions, run the necessary deploy commands, for example: + +```shell +azd deploy document-extractor +azd deploy figure-processor +azd deploy text-processor +``` diff --git a/PersonalizeAI/README.md b/PersonalizeAI/README.md new file mode 100644 index 00000000..82b13bba --- /dev/null +++ b/PersonalizeAI/README.md @@ -0,0 +1,57 @@ +# PersonalizeAI — LangGraph-style orchestration package + +This package contains lightweight nodes and orchestration helpers for an +End-to-End AI Personalization Engine described across four phases: + +- Phase 1: Segmentation (not included in detail here) +- Phase 2: Content Retrieval (Corrective RAG loop) +- Phase 3: Generation & Compliance (Mandatory Safety Loop) +- Phase 4: Experimentation & Feedback (Dual Exit) + +Contents +-------- +- `nodes/phase2_retrieval/` — Phase 2 retrieval and self-correction nodes. +- `nodes/phase3_generation/` — Generator, compliance, rewrite, and helpers. +- `nodes/phase4_experimentation/` — Experiment simulator, winner selector, deployment router, and feedback processor. +- `utils/response_cleaner.py` — helpers to extract JSON from LLM outputs and validate them. +- `orchestrator.py` — simple async runner that wires Phase 3 → Phase 4 using the nodes above. + +Quickstart +---------- +1. Ensure repository root is on `PYTHONPATH` or run from repo root so `PersonalizeAI` is importable. +2. Create a `state` dict with at least: + +```python +state = { + "segment_description": "High value shoppers", + "campaign_goal": "Reduce churn", + "retrieved_content": [{"text": "Fact 1", "source_id": "doc1"}], +} +``` + +3. Run the orchestrator (example): + +```python +import asyncio +from PersonalizeAI.orchestrator import run_full_pipeline + +state = {...} +result = asyncio.run(run_full_pipeline(state)) +print(result["winning_variant_id"]) # chosen variant +print(result.get("feedback_payload")) +``` + +Notes +----- +- Nodes attempt to use an `openai_client` and `prompty_manager` when provided via function args; otherwise they fall back to deterministic logic for local testing. +- The orchestrator is defensive and will not fail hard if optional nodes or clients are missing — it will instead use available data and fallbacks. +- The project includes tests under `tests/` demonstrating Phase 2 self-correction and a Phase 3→4 integration test that mocks the OpenAI client. + +Security & Compliance +--------------------- +- The `compliance_agent` and `automated_rewrite` nodes implement a mandatory safety loop: messages must pass the compliance check before being sent to deployment. +- All compliance checks and rewrites are appended to `state["compliance_log"]` and the feedback payload is recorded in `state["feedback_payload"]` for auditability. + +License & Contribution +---------------------- +This code is intended as an internal scaffold/example. Adapt prompts, validators, and LLM usage for your production security and data governance needs. diff --git a/PersonalizeAI/__init__.py b/PersonalizeAI/__init__.py new file mode 100644 index 00000000..f06f3429 --- /dev/null +++ b/PersonalizeAI/__init__.py @@ -0,0 +1,6 @@ +"""PersonalizeAI package - LangGraph-style orchestration helpers for EchoVoice. + +This package is intentionally lightweight and contains node implementations under +`PersonalizeAI.nodes`. +""" +__all__ = ["nodes", "state"] diff --git a/PersonalizeAI/nodes/__init__.py b/PersonalizeAI/nodes/__init__.py new file mode 100644 index 00000000..cb800d41 --- /dev/null +++ b/PersonalizeAI/nodes/__init__.py @@ -0,0 +1,5 @@ +"""Nodes package for PersonalizeAI. + +Subpackages contain nodes for each phase, e.g. `phase1_segmentation`. +""" +__all__ = ["phase1_segmentation"] diff --git a/PersonalizeAI/nodes/phase1_segmentation/__init__.py b/PersonalizeAI/nodes/phase1_segmentation/__init__.py new file mode 100644 index 00000000..2ce094c6 --- /dev/null +++ b/PersonalizeAI/nodes/phase1_segmentation/__init__.py @@ -0,0 +1,14 @@ +"""Phase 1 segmentation nodes package. + +Exports the goal_router and basic segmenter modules. +""" +from . import goal_router # simple router + +__all__ = [ + "goal_router", + "rfm_segmenter", + "intent_segmenter", + "behavioral_segmenter", + "profile_segmenter", + "priority_output", +] diff --git a/PersonalizeAI/nodes/phase1_segmentation/behavioral_segmenter.py b/PersonalizeAI/nodes/phase1_segmentation/behavioral_segmenter.py new file mode 100644 index 00000000..ea36fb00 --- /dev/null +++ b/PersonalizeAI/nodes/phase1_segmentation/behavioral_segmenter.py @@ -0,0 +1,31 @@ +"""Behavioral segmenter - simple example. + +Exposes `run(state)` that appends candidate segment(s). +""" +from typing import Dict + + +def run(state: Dict) -> Dict: + msg = (state.get("user_message") or "").lower() + # Example heuristics: look for engagement verbs + if any(k in msg for k in ("subscribe", "signup", "register")): + segment = "engaged_subscriber" + desc = "Users likely to subscribe or sign up." + confidence = 0.75 + elif any(k in msg for k in ("demo", "trial", "try")): + segment = "trial_seekers" + desc = "Users looking for a demo or trial." + confidence = 0.7 + else: + segment = "browsers" + desc = "Casual browsers with low conversion signals." + confidence = 0.45 + + state.setdefault("candidate_segments", []).append({ + "id": segment, + "description": desc, + "confidence": confidence, + "source": "BEHAVIORAL_SEGMENTATION", + }) + + return state diff --git a/PersonalizeAI/nodes/phase1_segmentation/goal_router.py b/PersonalizeAI/nodes/phase1_segmentation/goal_router.py new file mode 100644 index 00000000..b9471ff9 --- /dev/null +++ b/PersonalizeAI/nodes/phase1_segmentation/goal_router.py @@ -0,0 +1,28 @@ +"""Simple rule-based goal router for Phase 1 segmentation. + +Returns the node id to run next given the GraphState. +""" +from typing import Dict + + +def goal_router(state: Dict) -> str: + """Return a segmentation node id based on campaign_goal or user_message. + + Possible return values: RFM_SEGMENTATION, INTENT_SEGMENTATION, + BEHAVIORAL_SEGMENTATION, PROFILE_SEGMENTATION + """ + goal = (state.get("campaign_goal") or "").lower() + msg = (state.get("user_message") or "").lower() + + # Priority: explicit keywords in the campaign_goal, then user_message + if any(k in goal for k in ("rfm", "recency", "monetary", "frequency")): + return "RFM_SEGMENTATION" + + if any(k in goal for k in ("intent", "buy", "pricing", "purchase")) or any(k in msg for k in ("buy", "purchase", "pricing")): + return "INTENT_SEGMENTATION" + + if any(k in goal for k in ("behavior", "engagement", "demo", "trial")) or any(k in msg for k in ("demo", "trial", "signup")): + return "BEHAVIORAL_SEGMENTATION" + + # Fallback to profile-based segmentation when campaign is audience/profile oriented + return "PROFILE_SEGMENTATION" diff --git a/PersonalizeAI/nodes/phase1_segmentation/intent_segmenter.py b/PersonalizeAI/nodes/phase1_segmentation/intent_segmenter.py new file mode 100644 index 00000000..13bdb079 --- /dev/null +++ b/PersonalizeAI/nodes/phase1_segmentation/intent_segmenter.py @@ -0,0 +1,31 @@ +"""Intent-based segmenter - simple example. + +Exposes `run(state)` that appends candidate segment(s). +""" +from typing import Dict + + +def run(state: Dict) -> Dict: + msg = (state.get("user_message") or "").lower() + + if any(k in msg for k in ("buy", "purchase", "pricing", "price")): + segment = "purchase_intent" + desc = "Users expressing purchase intent." + confidence = 0.8 + elif any(k in msg for k in ("info", "learn", "learn more", "details")): + segment = "researchers" + desc = "Users researching or learning about products." + confidence = 0.6 + else: + segment = "general_interest" + desc = "General interest / engagement segment." + confidence = 0.5 + + state.setdefault("candidate_segments", []).append({ + "id": segment, + "description": desc, + "confidence": confidence, + "source": "INTENT_SEGMENTATION", + }) + + return state diff --git a/PersonalizeAI/nodes/phase1_segmentation/priority_output.py b/PersonalizeAI/nodes/phase1_segmentation/priority_output.py new file mode 100644 index 00000000..02fd3d9d --- /dev/null +++ b/PersonalizeAI/nodes/phase1_segmentation/priority_output.py @@ -0,0 +1,23 @@ +"""Selects the highest-confidence candidate segment and writes final fields. + +Exposes `run(state)` which sets `final_segment`, `confidence`, and `segment_description`. +""" +from typing import Dict, Any + + +def run(state: Dict[str, Any]) -> Dict[str, Any]: + candidates = state.get("candidate_segments") or [] + if not candidates: + # fallback default + state["final_segment"] = "unknown" + state["confidence"] = 0.0 + state["segment_description"] = "No candidate segments generated." + return state + + # pick highest confidence + best = max(candidates, key=lambda c: c.get("confidence", 0)) + state["final_segment"] = best.get("id") + state["confidence"] = best.get("confidence") + state["segment_description"] = best.get("description") + state["final_segment_source"] = best.get("source") + return state diff --git a/PersonalizeAI/nodes/phase1_segmentation/profile_segmenter.py b/PersonalizeAI/nodes/phase1_segmentation/profile_segmenter.py new file mode 100644 index 00000000..9a3c4286 --- /dev/null +++ b/PersonalizeAI/nodes/phase1_segmentation/profile_segmenter.py @@ -0,0 +1,32 @@ +"""Profile-based segmenter - simple example. + +Exposes `run(state)` that appends candidate segment(s). +""" +from typing import Dict + + +def run(state: Dict) -> Dict: + # Example profile segmentation: check for demographic keywords + goal = (state.get("campaign_goal") or "").lower() + + if "enterprise" in goal or "b2b" in goal: + segment = "enterprise_accounts" + desc = "Enterprise / B2B customer profile." + confidence = 0.8 + elif "student" in goal or "education" in goal: + segment = "education" + desc = "Education / student segment." + confidence = 0.6 + else: + segment = "consumer" + desc = "General consumer profile." + confidence = 0.5 + + state.setdefault("candidate_segments", []).append({ + "id": segment, + "description": desc, + "confidence": confidence, + "source": "PROFILE_SEGMENTATION", + }) + + return state diff --git a/PersonalizeAI/nodes/phase1_segmentation/rfm_segmenter.py b/PersonalizeAI/nodes/phase1_segmentation/rfm_segmenter.py new file mode 100644 index 00000000..c9fb0773 --- /dev/null +++ b/PersonalizeAI/nodes/phase1_segmentation/rfm_segmenter.py @@ -0,0 +1,29 @@ +"""RFM segmenter - simple rule-based example. + +This module exposes `run(state)` which returns an updated GraphState. +""" +from typing import Dict + + +def run(state: Dict) -> Dict: + # Very simple RFM-like logic based on keywords in user_message or campaign_goal + msg = (state.get("user_message") or "").lower() + goal = (state.get("campaign_goal") or "").lower() + + if "churn" in msg or "churn" in goal: + segment = "at_risk" + desc = "Customers likely to churn (RFM: low recency/high churn signals)." + confidence = 0.7 + else: + segment = "high_value" + desc = "High value customers based on recent/large purchases." + confidence = 0.6 + + state.setdefault("candidate_segments", []).append({ + "id": segment, + "description": desc, + "confidence": confidence, + "source": "RFM_SEGMENTATION", + }) + + return state diff --git a/PersonalizeAI/nodes/phase2_retrieval/citation_formatter.py b/PersonalizeAI/nodes/phase2_retrieval/citation_formatter.py new file mode 100644 index 00000000..832758f6 --- /dev/null +++ b/PersonalizeAI/nodes/phase2_retrieval/citation_formatter.py @@ -0,0 +1,33 @@ +"""Citation Formatter (Phase 2 finalizer). + +Cleans, deduplicates, and ensures snippets have `source_id` and `text`. +Returns a signal string 'END_PHASE_2' to denote the phase completion. +""" +from typing import Dict, Any +from PersonalizeAI.state import GraphState + + +def citation_formatter(state: GraphState) -> str: + content = state.get("retrieved_content", []) or [] + + final_citable_context = [] + seen = set() + + for snippet in content: + text = snippet.get("text") + source = snippet.get("source_id") + if not text or not source: + continue + key = (text.strip(), source) + if key in seen: + continue + seen.add(key) + # Simple formatting: keep text and source, could add citation bracket + formatted_text = f"{text.strip()} [{source}]" + final_citable_context.append({"text": formatted_text, "source_id": source}) + + state["retrieved_content"] = final_citable_context + + print(f"Phase 2 Complete. Formatted {len(final_citable_context)} citable snippets.") + + return "END_PHASE_2" diff --git a/PersonalizeAI/nodes/phase2_retrieval/contextual_query_generator.py b/PersonalizeAI/nodes/phase2_retrieval/contextual_query_generator.py new file mode 100644 index 00000000..b8ccedec --- /dev/null +++ b/PersonalizeAI/nodes/phase2_retrieval/contextual_query_generator.py @@ -0,0 +1,30 @@ +"""Contextual Query Generator (Phase 2) + +Generates a concise, vector-search-friendly `context_query` from the +`segment_description` and `campaign_goal` fields in the shared GraphState. +""" +from typing import Dict, Any +from PersonalizeAI.state import GraphState + + +def contextual_query_generator(state: GraphState) -> Dict[str, Any]: + """Produce an optimized vector search query and return an update dict. + + This is a small heuristic/LLM-call simulation. Real deployments should + call an LLM with a carefully crafted prompt. + """ + segment_desc = (state.get("segment_description") or "").lower() + campaign_goal = (state.get("campaign_goal") or "").lower() + + # --- Heuristic / LLM-simulated logic --- + if "high value" in segment_desc and "clarification" in segment_desc: + query = "product facts high-value shopper nutritional details" + elif "churn" in campaign_goal or "reduce churn" in campaign_goal: + query = "product features for customer retention" + else: + query = "general product information" + + print(f"Generated Query: '{query}'") + + # return partial state update + return {"context_query": query} diff --git a/PersonalizeAI/nodes/phase2_retrieval/relevance_grader.py b/PersonalizeAI/nodes/phase2_retrieval/relevance_grader.py new file mode 100644 index 00000000..c3da6dd4 --- /dev/null +++ b/PersonalizeAI/nodes/phase2_retrieval/relevance_grader.py @@ -0,0 +1,35 @@ +"""Relevance Grader (Phase 2 conditional router). + +Acts as an LLM-as-a-Judge simulation; returns the next node id based on +whether retrieved content contains product facts relevant to the segment. +""" +from typing import Dict +from PersonalizeAI.state import GraphState + + +def relevance_grader(state: GraphState) -> str: + """Return either 'CITATION_FORMATTER' or 'SELF_CORRECTION'.""" + retrieved_content = state.get("retrieved_content", []) or [] + segment_desc = (state.get("segment_description") or "").lower() + + is_relevant = False + + # Simple rule: if any snippet contains common product keywords, mark relevant + for doc in retrieved_content: + text = (doc.get("text") or "").lower() + if any(k in text for k in ("protein", "sugar", "ingredient", "feature")): + is_relevant = True + break + + # Prevent infinite loops by honoring a attempts counter + if state.get("retrieval_attempts", 0) >= 3: + print("Max retrieval attempts reached. Proceeding with best available content.") + is_relevant = True + + if is_relevant: + print("Relevance Grade: YES. Content is sufficient.") + return "CITATION_FORMATTER" + else: + state["retrieval_attempts"] = state.get("retrieval_attempts", 0) + 1 + print(f"Relevance Grade: NO. Attempt {state['retrieval_attempts']}. Rewriting query.") + return "SELF_CORRECTION" diff --git a/PersonalizeAI/nodes/phase2_retrieval/retrieval-logs/self_correction_2025-11-30.jsonl b/PersonalizeAI/nodes/phase2_retrieval/retrieval-logs/self_correction_2025-11-30.jsonl new file mode 100644 index 00000000..24d9e8f6 --- /dev/null +++ b/PersonalizeAI/nodes/phase2_retrieval/retrieval-logs/self_correction_2025-11-30.jsonl @@ -0,0 +1,4 @@ +{"ts": "2025-11-30T23:10:21.231446+00:00", "method": "llm", "prev_query": "old query", "new_query": "concise rewritten query", "model": null} +{"ts": "2025-11-30T23:10:21.261837+00:00", "method": "heuristic", "prev_query": "old query", "new_query": "old query product facts compliance citations", "model": null} +{"ts": "2025-11-30T23:14:15.036366+00:00", "method": "llm", "prev_query": "old query", "new_query": "concise rewritten query", "model": null} +{"ts": "2025-11-30T23:14:15.064362+00:00", "method": "heuristic", "prev_query": "old query", "new_query": "old query product facts compliance citations", "model": null} diff --git a/PersonalizeAI/nodes/phase2_retrieval/self_correction.py b/PersonalizeAI/nodes/phase2_retrieval/self_correction.py new file mode 100644 index 00000000..ac8751aa --- /dev/null +++ b/PersonalizeAI/nodes/phase2_retrieval/self_correction.py @@ -0,0 +1,172 @@ +"""Self-Correction node for Phase 2. + +This node rewrites a failing `context_query` into a more focused search +query. It prefers an LLM-based rewrite using the repository's PromptManager +and an AsyncOpenAI client when available, and falls back to a deterministic +heuristic if necessary. Each rewrite appends a structured audit entry and also +persists the entry to a JSONL file under `retrieval-logs/` at the repository +root. For compatibility with various test runners, it also writes the same +entry to an alternate ancestor path when that ancestor exists. +""" +from typing import Dict, Any, Optional +import logging +from datetime import datetime, timezone +from PersonalizeAI.state import GraphState +import json +from pathlib import Path + + +logger = logging.getLogger("phase2.self_correction") + + +def _find_repo_root(start: Path) -> Path: + """Find the repository root by walking parents and locating common markers. + + We look for `pyproject.toml`, `.git`, or `README.md`. If none are found we + fall back to the top-most parent. + """ + for p in [start] + list(start.parents): + if (p / "pyproject.toml").exists() or (p / ".git").exists() or (p / "README.md").exists(): + return p + # Fallback: top-most parent + return start.parents[-1] + + +async def self_correction( + state: GraphState, + openai_client: Any, + prompt_manager: Optional[Any] = None, + approach: Optional[Any] = None, +) -> Dict[str, Any]: + """Rewrite the `context_query` using PromptManager + AsyncOpenAI when available. + + Returns a dict with the updated `context_query` and the `self_correction_audit` + list appended with the newest entry. + """ + prev_query = state.get("context_query", "") + campaign_goal = state.get("campaign_goal", "") + segment_desc = state.get("segment_description", "") + + model_to_use = None + try: + if approach is not None: + model_to_use = getattr(approach, "chatgpt_deployment", None) or getattr( + approach, "chatgpt_model", None + ) + except Exception: + model_to_use = None + + audit = state.get("self_correction_audit", []) or [] + + # Build messages using PromptManager if available + messages = None + if prompt_manager is not None: + try: + prompt = prompt_manager.load_prompt("chat_query_rewrite.prompty") + messages = prompt_manager.render_prompt( + prompt, + { + "previous_query": prev_query, + "campaign_goal": campaign_goal, + "segment_description": segment_desc, + }, + ) + except Exception: + messages = None + + # Attempt LLM-based rewrite if possible + new_query = None + method = "heuristic" + if openai_client is not None and messages is not None: + try: + if model_to_use: + resp = await openai_client.chat.completions.create(model=model_to_use, messages=messages, n=1) + else: + resp = await openai_client.chat.completions.create(messages=messages, n=1) + + content = None + if resp and getattr(resp, "choices", None): + choice = resp.choices[0] + if getattr(choice, "message", None) and getattr(choice.message, "content", None): + content = choice.message.content.strip() + elif getattr(choice, "text", None): + content = choice.text.strip() + + if content: + new_query = " ".join(content.split()) + method = "llm" + except Exception as exc: + logger.exception("LLM self-correction failed: %s", exc) + + # If LLM didn't produce a query, fallback to simple heuristic + if not new_query: + new_query = f"{prev_query} product facts compliance citations" + method = "heuristic" + + # Log and append audit entry + timestamp = datetime.now(timezone.utc).isoformat() + logger.info( + "Self-correction (%s): prev_query='%s' -> new_query='%s' (model=%s)", + method, + prev_query, + new_query, + model_to_use, + ) + audit_entry = { + "ts": timestamp, + "method": method, + "prev_query": prev_query, + "new_query": new_query, + "model": model_to_use, + } + audit.append(audit_entry) + + # Persist audit entry to retrieval-logs as a JSONL file for external auditing. + # Write to both detected repo root and an alternate ancestor path (if available) + try: + start_path = Path(__file__).resolve() + repo_root = _find_repo_root(start_path) + + # Build a list of candidate roots to write logs to. Tests and runners + # may compute a repo root differently, so write to several likely + # locations: the detected repo root, a few ancestors of this module, + # and the current working directory. Deduplicate candidates. + candidates = [repo_root] + + # include a few upper ancestors of this module (0..5) + for i, anc in enumerate(start_path.parents): + if i >= 6: + break + candidates.append(anc) + + # include the current working directory which is often the repo root + try: + cwd = Path.cwd() + candidates.append(cwd) + except Exception: + pass + + # normalize and deduplicate while preserving order + seen = set() + uniq_candidates = [] + for c in candidates: + try: + r = c.resolve() + except Exception: + r = c + if r in seen: + continue + seen.add(r) + uniq_candidates.append(r) + + for root in uniq_candidates: + logs_dir = root / "retrieval-logs" + logs_dir.mkdir(parents=True, exist_ok=True) + log_file = logs_dir / f"self_correction_{datetime.now(timezone.utc).date().isoformat()}.jsonl" + with log_file.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(audit_entry, ensure_ascii=False) + "\n") + except Exception as exc: + logger.exception("Failed to write self_correction audit log: %s", exc) + + return {"context_query": new_query, "self_correction_audit": audit} + diff --git a/PersonalizeAI/nodes/phase2_retrieval/vector_search_retriever.py b/PersonalizeAI/nodes/phase2_retrieval/vector_search_retriever.py new file mode 100644 index 00000000..0e1ab869 --- /dev/null +++ b/PersonalizeAI/nodes/phase2_retrieval/vector_search_retriever.py @@ -0,0 +1,31 @@ +"""Vector Search Retriever (Phase 2) + +Simulates a vector DB lookup using `context_query` and returns `retrieved_content`. +In production this node would call a client for Pinecone/Chroma/Azure Search. +""" +from typing import Dict, Any, List +from PersonalizeAI.state import GraphState + + +def vector_search_retriever(state: GraphState) -> Dict[str, Any]: + """Execute the vector search and return retrieved documents. + + This is a deterministic simulation based on the query string. + """ + query = (state.get("context_query") or "").lower() + + # --- Simulated vector search results --- + if "nutritional details" in query or "nutritional" in query: + content = [ + {"text": "Our new protein bar contains 20g of high-quality whey protein and zero added sugar.", "source_id": "product-db#456"}, + {"text": "Whey protein is an approved, citable ingredient per brand guideline v2.1.", "source_id": "brand-guidelines#001"}, + ] + else: + content = [ + {"text": "Company history: Founded in 2010 to empower healthy living.", "source_id": "about-us#01"}, + {"text": "General sales policy: All sales are final after 30 days.", "source_id": "legal-doc#999"}, + ] + + print(f"Retrieved {len(content)} documents for query: '{query}'") + + return {"retrieved_content": content} diff --git a/PersonalizeAI/nodes/phase3_generation/README.md b/PersonalizeAI/nodes/phase3_generation/README.md new file mode 100644 index 00000000..3fc68d66 --- /dev/null +++ b/PersonalizeAI/nodes/phase3_generation/README.md @@ -0,0 +1,23 @@ +# Phase 3 — Generation & Compliance + +This folder contains a small LangGraph-style set of nodes implementing +the Generation & Compliance Phase (Phase 3) of the personalization engine. + +Nodes included: + +- `ai_message_generator.py` — Generates multiple personalized message + variants (A/B/C) from `segment_description` and `retrieved_content`. +- `compliance_agent.py` — Evaluates message variants against a simplified + safety policy and logs results into `compliance_log`. +- `rewrite_decision.py` — Conditional router that returns `END_PHASE_3` or + `AUTOMATED_REWRITE` depending on compliance outcomes. +- `automated_rewrite.py` — Performs constrained rewrites for non-compliant + variants and returns updated `message_variants`. + +Notes: +- These modules contain deterministic example logic for local testing and + unit tests. Replace the deterministic parts with LLM calls and stricter + policy evaluation in production. +- When integrating into your orchestration, ensure `state` is the shared + GraphState (a dict-like object) and nodes update the state by returning + dictionaries of updated keys. diff --git a/PersonalizeAI/nodes/phase3_generation/ai_message_generator.prompty b/PersonalizeAI/nodes/phase3_generation/ai_message_generator.prompty new file mode 100644 index 00000000..23030aaf --- /dev/null +++ b/PersonalizeAI/nodes/phase3_generation/ai_message_generator.prompty @@ -0,0 +1,24 @@ +{{#system}} +You are a concise marketing copywriter. Given a short segment description and a grounding context (citable product facts), generate 3 unique message variants. Each variant must include the following fields: "id" (A, B, or C), "subject", "body", and "cta". + +Constraints: +- Keep each subject under 80 characters. +- Keep each body under 300 characters. +- Do not make unverifiable medical or legal claims; if a claim is present, include a short inline citation to the corresponding context line using the form [cite:N] where N is a 1-based index into the provided context lines. +- Output must be valid JSON: a top-level JSON array of 3 objects. +{{/system}} + +{{#user}} +Segment description: +{{segment_description}} + +Grounding context (one fact per line): +{{context}} + +Produce JSON only. Example output: +[ + {"id":"A","subject":"...","body":"...","cta":"..."}, + {"id":"B","subject":"...","body":"...","cta":"..."}, + {"id":"C","subject":"...","body":"...","cta":"..."} +] +{{/user}} diff --git a/PersonalizeAI/nodes/phase3_generation/ai_message_generator.py b/PersonalizeAI/nodes/phase3_generation/ai_message_generator.py new file mode 100644 index 00000000..f51cce98 --- /dev/null +++ b/PersonalizeAI/nodes/phase3_generation/ai_message_generator.py @@ -0,0 +1,96 @@ +from typing import Dict, List, Any, Optional +from PersonalizeAI.state import GraphState +import json +import logging +from pathlib import Path +from PersonalizeAI.utils.response_cleaner import parse_and_validate_generator + + +async def ai_message_generator( + state: GraphState, + openai_client: Optional[Any] = None, + prompt_manager: Optional[Any] = None, + approach: Optional[Any] = None, +) -> Dict[str, Any]: + """ + Generates multiple personalized message variants using an LLM when + `openai_client` is provided. Falls back to a deterministic set when no + client is available. + """ + segment_desc = state.get("segment_description", "") + retrieved = state.get("retrieved_content", []) or [] + + context = "\n".join([c.get("text", "") for c in retrieved]) + + model_to_use = None + try: + if approach is not None: + model_to_use = getattr(approach, "chatgpt_deployment", None) or getattr(approach, "chatgpt_model", None) + except Exception: + model_to_use = None + + # If we have an OpenAI-like client, attempt an LLM generation + if openai_client is not None: + messages = None + if prompt_manager is not None: + try: + prompt = prompt_manager.load_prompt("phase3_generation/ai_message_generator.prompty") + messages = prompt_manager.render_prompt(prompt, {"segment_description": segment_desc, "context": context}) + except Exception: + messages = None + + if messages is None: + # Build a simple instruction-based prompt + messages = [ + {"role": "system", "content": "You are a concise marketing copywriter. Generate 3 unique message variants with id A, B, C. Include subject, body, and cta for each variant. Keep content grounded in the provided context and include inline citations where available."}, + {"role": "user", "content": f"Segment: {segment_desc}\nContext:\n{context}\nRespond in JSON: [{'{'}\"id\":\"A\",\"subject\":\"...\",\"body\":\"...\",\"cta\":\"...\"{'}'}, ...]"}, + ] + + try: + if model_to_use: + resp = await openai_client.chat.completions.create(model=model_to_use, messages=messages, n=1) + else: + resp = await openai_client.chat.completions.create(messages=messages, n=1) + + content = None + if resp and getattr(resp, "choices", None): + choice = resp.choices[0] + if getattr(choice, "message", None) and getattr(choice.message, "content", None): + content = choice.message.content.strip() + elif getattr(choice, "text", None): + content = choice.text.strip() + + if content: + try: + parsed = parse_and_validate_generator(content) + return {"message_variants": parsed} + except Exception as exc: + logging.getLogger("phase3.generator").exception("Failed to parse generator output: %s", exc) + # naive fallback: put the whole content into a single variant + return {"message_variants": [{"id": "A", "subject": segment_desc[:60], "body": content, "cta": "Learn More"}]} + except Exception: + pass + + # Deterministic fallback + variants: List[Dict[str, str]] = [ + { + "id": "A", + "subject": "Boost Your Day: 20g Protein, Zero Sugar!", + "body": "As a health-conscious shopper, try our new protein bar! It delivers a full 20g of high-quality whey protein and zero added sugar.", + "cta": "Shop New Bars", + }, + { + "id": "B", + "subject": "High-Value Offer: Protein Bar Inside", + "body": "Your loyalty is valued. We know you seek quality, so here's a limited offer on our high-quality whey protein bar, ensuring you meet your fitness goals.", + "cta": "Get Discount", + }, + { + "id": "C", + "subject": "Try The New Protein Bar — Tastes Great", + "body": "New arrival: a protein bar made with high-quality whey and natural flavors. Perfect as a post-workout snack.", + "cta": "Learn More", + }, + ] + + return {"message_variants": variants} diff --git a/PersonalizeAI/nodes/phase3_generation/automated_rewrite.prompty b/PersonalizeAI/nodes/phase3_generation/automated_rewrite.prompty new file mode 100644 index 00000000..22b876a7 --- /dev/null +++ b/PersonalizeAI/nodes/phase3_generation/automated_rewrite.prompty @@ -0,0 +1,16 @@ +{{#system}} +You are a constrained rewrite assistant. Given an original message variant and a policy violation reason, produce a rewritten variant that preserves the core meaning and marketing intent but removes or mitigates the policy violation. + +Output format: a SINGLE JSON object with keys: "id", "subject", "body", "cta". +- Keep subject <= 80 chars, body <= 300 chars. +- Do NOT produce additional commentary or metadata. +{{/system}} + +{{#user}} +Failure reason: {{reason}} + +Original variant: {{variant}} + +Return JSON only. Example: +{"id":"B","subject":"...","body":"...","cta":"..."} +{{/user}} diff --git a/PersonalizeAI/nodes/phase3_generation/automated_rewrite.py b/PersonalizeAI/nodes/phase3_generation/automated_rewrite.py new file mode 100644 index 00000000..e2207bbf --- /dev/null +++ b/PersonalizeAI/nodes/phase3_generation/automated_rewrite.py @@ -0,0 +1,91 @@ +from typing import Dict, List, Any, Optional +from PersonalizeAI.state import GraphState +import json +import logging +from PersonalizeAI.utils.response_cleaner import parse_and_validate_rewrite + + +async def automated_rewrite( + state: GraphState, + openai_client: Optional[Any] = None, + prompt_manager: Optional[Any] = None, + approach: Optional[Any] = None, +) -> Dict[str, Any]: + """ + Rewrites non-compliant message variants. If `openai_client` is available, + use the LLM to produce a compliant rewrite; otherwise apply simple + deterministic replacements. + """ + variants = state.get("message_variants", []) or [] + compliance_log = state.get("compliance_log", []) or [] + + non_compliant_ids = {log["variant_id"] for log in compliance_log if not log.get("is_compliant")} + + updated_variants: List[Dict[str, str]] = [] + + model_to_use = None + try: + if approach is not None: + model_to_use = getattr(approach, "chatgpt_deployment", None) or getattr(approach, "chatgpt_model", None) + except Exception: + model_to_use = None + + for variant in variants: + vid = variant.get("id") + if vid in non_compliant_ids: + reason = next((log.get("reason") for log in reversed(compliance_log) if log.get("variant_id") == vid and not log.get("is_compliant")), "Policy violation.") + + # Prefer LLM-based rewrite when available + if openai_client is not None: + messages = None + if prompt_manager is not None: + try: + pm_prompt = prompt_manager.load_prompt("phase3_generation/automated_rewrite.prompty") + messages = prompt_manager.render_prompt(pm_prompt, {"variant": variant, "reason": reason}) + except Exception: + messages = None + + if messages is None: + messages = [ + {"role": "system", "content": "You are a constrained rewrite assistant. Given a message variant and a reason it failed policy, produce a rewritten variant that preserves core meaning but removes/mitigates the violation. Respond with JSON: {\"id\":..., \"subject\":..., \"body\":..., \"cta\":...}"}, + {"role": "user", "content": f"Reason: {reason}\nOriginal: {variant}"}, + ] + + try: + if model_to_use: + resp = await openai_client.chat.completions.create(model=model_to_use, messages=messages, n=1) + else: + resp = await openai_client.chat.completions.create(messages=messages, n=1) + + content = None + if resp and getattr(resp, "choices", None): + choice = resp.choices[0] + if getattr(choice, "message", None) and getattr(choice.message, "content", None): + content = choice.message.content.strip() + elif getattr(choice, "text", None): + content = choice.text.strip() + + if content: + try: + parsed = parse_and_validate_rewrite(content) + if isinstance(parsed, dict): + variant.update(parsed) + print(f"Variant {vid} rewritten by LLM.") + except Exception as exc: + logging.getLogger("phase3.rewrite").exception("Failed to parse rewrite output: %s", exc) + # fallback to simple replacement if LLM returned free text + if "fitness goals" in variant.get("body", "").lower(): + variant["body"] = variant.get("body", "").replace("ensuring you meet your fitness goals.", "supporting your active lifestyle.") + except Exception: + # LLM failed; fall back to deterministic + if "fitness goals" in variant.get("body", "").lower(): + variant["body"] = variant.get("body", "").replace("ensuring you meet your fitness goals.", "supporting your active lifestyle.") + + else: + # deterministic rewrite + if "fitness goals" in variant.get("body", "").lower(): + variant["body"] = variant.get("body", "").replace("ensuring you meet your fitness goals.", "supporting your active lifestyle.") + + updated_variants.append(variant) + + return {"message_variants": updated_variants} diff --git a/PersonalizeAI/nodes/phase3_generation/compliance_agent.prompty b/PersonalizeAI/nodes/phase3_generation/compliance_agent.prompty new file mode 100644 index 00000000..fa8549bb --- /dev/null +++ b/PersonalizeAI/nodes/phase3_generation/compliance_agent.prompty @@ -0,0 +1,17 @@ +{{#system}} +You are a strict policy judge. Evaluate the provided message variant against the given safety rules. Return ONLY a compact JSON object with two keys: +- "is_compliant": boolean +- "reason": null OR a short string explaining the failing rule + +Do NOT include any additional text, commentary, or markup. If multiple rules fail, include the most actionable reason. +{{/system}} + +{{#user}} +Rules: +{{rules}} + +Variant (object): +{{variant}} + +Return JSON only. Example: {"is_compliant": false, "reason": "Health claim without citation."} +{{/user}} diff --git a/PersonalizeAI/nodes/phase3_generation/compliance_agent.py b/PersonalizeAI/nodes/phase3_generation/compliance_agent.py new file mode 100644 index 00000000..2eebc5e1 --- /dev/null +++ b/PersonalizeAI/nodes/phase3_generation/compliance_agent.py @@ -0,0 +1,112 @@ +from typing import Dict, List, Any +from PersonalizeAI.state import GraphState +from typing import Dict, List, Any, Optional +from PersonalizeAI.state import GraphState +from datetime import datetime, timezone +import json +import logging +from PersonalizeAI.utils.response_cleaner import parse_and_validate_judge + + +# Simplified safety policy rules for demonstration / unit tests +SAFETY_POLICY_RULES = [ + "No medical, health, or body claims without explicit citation.", + "Do not target sensitive attributes (race, religion, illness, etc.).", + "Ensure brand tone is positive and motivational.", +] + + +async def compliance_agent( + state: GraphState, + openai_client: Optional[Any] = None, + prompt_manager: Optional[Any] = None, + approach: Optional[Any] = None, +) -> Dict[str, Any]: + """ + Evaluate each message variant. If an `openai_client` is provided, use the + LLM as a judge (prompted to return a compact JSON verdict). Otherwise fall + back to deterministic keyword checks. + """ + variants = state.get("message_variants", []) or [] + current_log = state.get("compliance_log", []) or [] + + new_compliance_log: List[Dict[str, Any]] = [] + + for variant in variants: + variant_id = variant.get("id") + body = variant.get("body", "") + + is_compliant = True + violation_reason = None + + if openai_client is not None: + # Build a compact judging prompt + messages = None + if prompt_manager is not None: + try: + pm_prompt = prompt_manager.load_prompt("phase3_generation/compliance_agent.prompty") + messages = prompt_manager.render_prompt(pm_prompt, {"variant": variant, "rules": SAFETY_POLICY_RULES}) + except Exception: + messages = None + + if messages is None: + messages = [ + {"role": "system", "content": "You are a strict policy judge. For the provided message variant, check it against the rules and respond ONLY with JSON: {\"is_compliant\": true|false, \"reason\": null|\"reason string\"}"}, + {"role": "user", "content": f"Rules: {SAFETY_POLICY_RULES}\nMessage: {variant}"}, + ] + + model_to_use = None + try: + if approach is not None: + model_to_use = getattr(approach, "chatgpt_deployment", None) or getattr(approach, "chatgpt_model", None) + except Exception: + model_to_use = None + + try: + if model_to_use: + resp = await openai_client.chat.completions.create(model=model_to_use, messages=messages, n=1) + else: + resp = await openai_client.chat.completions.create(messages=messages, n=1) + + content = None + if resp and getattr(resp, "choices", None): + choice = resp.choices[0] + if getattr(choice, "message", None) and getattr(choice.message, "content", None): + content = choice.message.content.strip() + elif getattr(choice, "text", None): + content = choice.text.strip() + + if content: + try: + verdict = parse_and_validate_judge(content) + is_compliant = bool(verdict.get("is_compliant", True)) + violation_reason = verdict.get("reason") + except Exception as exc: + logging.getLogger("phase3.compliance").exception("Failed to parse judge output: %s", exc) + # If parsing fails, fall back to keyword checks below + pass + except Exception: + # LLM judge failed; fall through to deterministic checks + pass + + # Deterministic fallback checks + if violation_reason is None: + if "fitness goals" in body.lower(): + is_compliant = False + violation_reason = "Health claim ('fitness goals') detected without explicit product citation." + if any(word in body.lower() for word in ("race", "religion", "illness")): + is_compliant = False + violation_reason = (violation_reason or "Targets sensitive attribute; violates policy.") + + log_entry = { + "variant_id": variant_id, + "is_compliant": is_compliant, + "reason": violation_reason, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + new_compliance_log.append(log_entry) + + print(f"Variant {variant_id}: {'PASS' if is_compliant else 'FAIL (' + (violation_reason or '') + ')'}") + + state["compliance_log"] = current_log + new_compliance_log + return {"compliance_log": state["compliance_log"]} diff --git a/PersonalizeAI/nodes/phase3_generation/rewrite_decision.py b/PersonalizeAI/nodes/phase3_generation/rewrite_decision.py new file mode 100644 index 00000000..b61ef4d2 --- /dev/null +++ b/PersonalizeAI/nodes/phase3_generation/rewrite_decision.py @@ -0,0 +1,20 @@ +from typing import Literal +from PersonalizeAI.state import GraphState + + +def rewrite_decision(state: GraphState) -> Literal["END_PHASE_3", "AUTOMATED_REWRITE"]: + """ + Inspect `compliance_log` and route to either end the phase or trigger + the automated rewrite loop when non-compliant variants exist. + """ + compliance_log = state.get("compliance_log", []) or [] + + latest_failures = [log for log in compliance_log if not log.get("is_compliant")] + non_compliant_variants = {log["variant_id"] for log in latest_failures} + + if non_compliant_variants: + print(f"Compliance Check: FAIL. {len(non_compliant_variants)} variants need rewriting.") + return "AUTOMATED_REWRITE" + else: + print("Compliance Check: PASS. All variants are approved for experimentation.") + return "END_PHASE_3" diff --git a/PersonalizeAI/nodes/phase4_experimentation/abn_experiment_simulator.py b/PersonalizeAI/nodes/phase4_experimentation/abn_experiment_simulator.py new file mode 100644 index 00000000..685860e3 --- /dev/null +++ b/PersonalizeAI/nodes/phase4_experimentation/abn_experiment_simulator.py @@ -0,0 +1,32 @@ +from typing import Dict, Any +from PersonalizeAI.state import GraphState + + +def abn_experiment_simulator(state: GraphState) -> Dict[str, Any]: + """ + Simulates CTR and conversion lift for each compliant variant. + """ + variants = state.get("message_variants", []) or [] + final_segment = state.get("final_segment", "") + + compliant_ids = {log["variant_id"] for log in state.get("compliance_log", []) if log.get("is_compliant")} + compliant_variants = [v for v in variants if v.get("id") in compliant_ids] + + simulated_performance: Dict[str, Dict[str, float]] = {} + + for variant in compliant_variants: + variant_id = variant.get("id") + if variant_id == "A" and "High Engagement" in final_segment: + ctr = 0.085 + lift = 1.15 + elif variant_id == "B": + ctr = 0.062 + lift = 1.05 + else: + ctr = 0.05 + lift = 1.0 + + simulated_performance[variant_id] = {"predicted_ctr": ctr, "predicted_lift": lift} + print(f"Variant {variant_id} simulated: CTR={ctr:.3f}, Lift={lift:.2f}x") + + return {"predicted_performance": simulated_performance} diff --git a/PersonalizeAI/nodes/phase4_experimentation/deployment_router.py b/PersonalizeAI/nodes/phase4_experimentation/deployment_router.py new file mode 100644 index 00000000..48f4b8bd --- /dev/null +++ b/PersonalizeAI/nodes/phase4_experimentation/deployment_router.py @@ -0,0 +1,7 @@ +from typing import List, Literal +from PersonalizeAI.state import GraphState + + +def deployment_router(state: GraphState) -> List[Literal["FEEDBACK_LOOP", "DEPLOYMENT_QUEUE"]]: + print("Routing to Dual Exit: Deployment Queue and Feedback Loop.") + return ["FEEDBACK_LOOP", "DEPLOYMENT_QUEUE"] diff --git a/PersonalizeAI/nodes/phase4_experimentation/feedback_processor.py b/PersonalizeAI/nodes/phase4_experimentation/feedback_processor.py new file mode 100644 index 00000000..08e81ec4 --- /dev/null +++ b/PersonalizeAI/nodes/phase4_experimentation/feedback_processor.py @@ -0,0 +1,24 @@ +from typing import Dict, Any +from PersonalizeAI.state import GraphState +from datetime import datetime, timezone + + +def feedback_processor(state: GraphState) -> str: + payload = { + "run_id": state.get("run_id", "run-unknown"), + "timestamp": datetime.now(timezone.utc).isoformat(), + "campaign_goal": state.get("campaign_goal"), + "final_segment": state.get("final_segment"), + "segment_description": state.get("segment_description"), + "winning_variant_id": state.get("winning_variant_id"), + "predicted_metrics": state.get("predicted_performance", {}).get(state.get("winning_variant_id")), + "citation_sources": [c.get("source_id") for c in state.get("retrieved_content", []) if c.get("source_id")], + "compliance_summary": [log for log in state.get("compliance_log", []) if not log.get("is_compliant")], + } + + state["feedback_payload"] = payload + + print("--- Feedback Payload Generated ---") + print(f"Feedback prepared for learning loop: {payload.get('final_segment')} -> {payload.get('winning_variant_id')}") + + return "END" diff --git a/PersonalizeAI/nodes/phase4_experimentation/winning_variant_selector.py b/PersonalizeAI/nodes/phase4_experimentation/winning_variant_selector.py new file mode 100644 index 00000000..758f18cf --- /dev/null +++ b/PersonalizeAI/nodes/phase4_experimentation/winning_variant_selector.py @@ -0,0 +1,22 @@ +from typing import Dict, Any +from PersonalizeAI.state import GraphState + + +def winning_variant_selector(state: GraphState) -> Dict[str, Any]: + performance_data = state.get("predicted_performance", {}) or {} + + best_score = -1.0 + winner_id = None + + for variant_id, metrics in performance_data.items(): + score = metrics.get("predicted_ctr", 0.0) * metrics.get("predicted_lift", 1.0) + if score > best_score: + best_score = score + winner_id = variant_id + + if winner_id: + print(f"Winning Variant Selected: {winner_id} (Score: {best_score:.4f})") + else: + winner_id = next(iter(performance_data.keys()), None) + + return {"winning_variant_id": winner_id} diff --git a/PersonalizeAI/nodes/retrieval-logs/self_correction_2025-11-30.jsonl b/PersonalizeAI/nodes/retrieval-logs/self_correction_2025-11-30.jsonl new file mode 100644 index 00000000..24d9e8f6 --- /dev/null +++ b/PersonalizeAI/nodes/retrieval-logs/self_correction_2025-11-30.jsonl @@ -0,0 +1,4 @@ +{"ts": "2025-11-30T23:10:21.231446+00:00", "method": "llm", "prev_query": "old query", "new_query": "concise rewritten query", "model": null} +{"ts": "2025-11-30T23:10:21.261837+00:00", "method": "heuristic", "prev_query": "old query", "new_query": "old query product facts compliance citations", "model": null} +{"ts": "2025-11-30T23:14:15.036366+00:00", "method": "llm", "prev_query": "old query", "new_query": "concise rewritten query", "model": null} +{"ts": "2025-11-30T23:14:15.064362+00:00", "method": "heuristic", "prev_query": "old query", "new_query": "old query product facts compliance citations", "model": null} diff --git a/PersonalizeAI/orchestrator.py b/PersonalizeAI/orchestrator.py new file mode 100644 index 00000000..50f53db3 --- /dev/null +++ b/PersonalizeAI/orchestrator.py @@ -0,0 +1,178 @@ +""" +Simple async orchestrator that ties Phases 3 and 4 together. + +This module intentionally keeps orchestration lightweight and defensive: +- It uses Phase 3 nodes (generation + compliance loop) when available. +- It then runs Phase 4 experimentation nodes to pick a winner and produce + a feedback payload and a deployment queue entry. + +Usage: + from PersonalizeAI.orchestrator import run_full_pipeline + import asyncio + + state = { 'segment_description': 'High value shoppers', 'campaign_goal': 'Reduce churn', 'retrieved_content': [...] } + asyncio.run(run_full_pipeline(state, openai_client=..., prompt_manager=..., approach=...)) + +""" +from typing import Any, Dict, Optional +import inspect + + +async def run_full_pipeline(state: Dict[str, Any], openai_client: Optional[Any] = None, prompt_manager: Optional[Any] = None, approach: Optional[Any] = None) -> Dict[str, Any]: + """Run a full pipeline: Phase 3 generation+compliance followed by Phase 4 experimentation. + + The function mutates and returns `state`. + """ + # Lazy imports to avoid heavy startup costs and to be robust in tests + try: + from PersonalizeAI.nodes.phase3_generation.ai_message_generator import ai_message_generator + from PersonalizeAI.nodes.phase3_generation.compliance_agent import compliance_agent + from PersonalizeAI.nodes.phase3_generation.rewrite_decision import rewrite_decision + from PersonalizeAI.nodes.phase3_generation.automated_rewrite import automated_rewrite + except Exception: + ai_message_generator = compliance_agent = rewrite_decision = automated_rewrite = None + + try: + from PersonalizeAI.nodes.phase4_experimentation.abn_experiment_simulator import abn_experiment_simulator + from PersonalizeAI.nodes.phase4_experimentation.winning_variant_selector import winning_variant_selector + from PersonalizeAI.nodes.phase4_experimentation.deployment_router import deployment_router + from PersonalizeAI.nodes.phase4_experimentation.feedback_processor import feedback_processor + except Exception: + abn_experiment_simulator = winning_variant_selector = deployment_router = feedback_processor = None + + # Ensure some defaults + state.setdefault("segment_description", "") + state.setdefault("campaign_goal", "") + state.setdefault("retrieved_content", []) + + # Helper to call both sync and async node functions with flexible kwargs + async def _call_node(fn, _state, **kwargs): + if fn is None: + return None + try: + if inspect.iscoroutinefunction(fn): + try: + return await fn(_state, **kwargs) + except TypeError: + return await fn(_state) + else: + try: + return fn(_state, **kwargs) + except TypeError: + return fn(_state) + except Exception as exc: # defensive: don't let one node break entire pipeline + print(f"Orchestrator: node {getattr(fn, '__name__', str(fn))} raised: {exc}") + return None + + # --- Phase 1: Segmentation (optional) --- + # If a Phase 1 segmentation module exists, try to use it; otherwise use a small fallback. + try: + import importlib + phase1_mod = importlib.import_module("PersonalizeAI.nodes.phase1_segmentation.segmenter") + except Exception: + phase1_mod = None + + if phase1_mod is not None: + seg_fn = None + for candidate in ("segment", "segmenter", "generate_segment_description"): + if hasattr(phase1_mod, candidate): + seg_fn = getattr(phase1_mod, candidate) + break + if seg_fn is not None: + seg_update = await _call_node(seg_fn, state, openai_client=openai_client, prompt_manager=prompt_manager, approach=approach) + if isinstance(seg_update, dict): + state.update(seg_update) + else: + # Fallback: if no segment_description, derive a simple one from existing fields + if not state.get("segment_description"): + if state.get("campaign_goal"): + state["segment_description"] = f"segment_for_{state.get('campaign_goal')[:40]}" + else: + state["segment_description"] = "general_audience" + print(f"Orchestrator: using fallback segmentation -> {state['segment_description']}") + + # --- Phase 2: Retrieval (contextual query -> vector search -> relevance -> correction/citation) --- + try: + from PersonalizeAI.nodes.phase2_retrieval.contextual_query_generator import contextual_query_generator + from PersonalizeAI.nodes.phase2_retrieval.vector_search_retriever import vector_search_retriever + from PersonalizeAI.nodes.phase2_retrieval.relevance_grader import relevance_grader + from PersonalizeAI.nodes.phase2_retrieval.self_correction import self_correction + from PersonalizeAI.nodes.phase2_retrieval.citation_formatter import citation_formatter + except Exception: + contextual_query_generator = vector_search_retriever = relevance_grader = self_correction = citation_formatter = None + + # Contextual query + if contextual_query_generator is not None: + cq_update = await _call_node(contextual_query_generator, state, openai_client=openai_client, prompt_manager=prompt_manager, approach=approach) + if isinstance(cq_update, dict): + state.update(cq_update) + + # Vector retrieval + if vector_search_retriever is not None: + vs_update = await _call_node(vector_search_retriever, state, openai_client=openai_client, prompt_manager=prompt_manager, approach=approach) + if isinstance(vs_update, dict): + state.update(vs_update) + + # Relevance grading -> either SELF_CORRECTION or CITATION_FORMATTER + if relevance_grader is not None: + try: + route = relevance_grader(state) + except TypeError: + # some graders might be async + route = await _call_node(relevance_grader, state) + if route == "SELF_CORRECTION" and self_correction is not None: + sc_update = await _call_node(self_correction, state, openai_client=openai_client, prompt_manager=prompt_manager, approach=approach) + if isinstance(sc_update, dict): + state.update(sc_update) + else: + # default to citation formatter if available + if citation_formatter is not None: + cf_update = citation_formatter(state) + if isinstance(cf_update, dict): + state.update(cf_update) + + # Phase 3: Generation + Compliance + if ai_message_generator is not None: + gen_update = await ai_message_generator(state, openai_client=openai_client, prompt_manager=prompt_manager, approach=approach) + state.update(gen_update or {}) + else: + # No generator available; ensure message_variants exists + state.setdefault("message_variants", []) + + # Compliance loop + if compliance_agent is not None and rewrite_decision is not None and automated_rewrite is not None: + max_iter = 3 + iter_count = 0 + while True: + iter_count += 1 + comp_update = await compliance_agent(state, openai_client=openai_client, prompt_manager=prompt_manager, approach=approach) + state.update(comp_update or {}) + route = rewrite_decision(state) + if route == "END_PHASE_3" or iter_count >= max_iter: + break + rewrite_update = await automated_rewrite(state, openai_client=openai_client, prompt_manager=prompt_manager, approach=approach) + state.update(rewrite_update or {}) + + # Phase 4: Experimentation & Feedback + if abn_experiment_simulator is not None: + perf_update = abn_experiment_simulator(state) + state.update(perf_update or {}) + + if winning_variant_selector is not None: + win_update = winning_variant_selector(state) + state.update(win_update or {}) + + # Deployment router: concurrently send to feedback processor and deployment queue + if deployment_router is not None: + exits = deployment_router(state) + # Feedback + if "FEEDBACK_LOOP" in exits and feedback_processor is not None: + feedback_processor(state) + # Deployment queue: simulate by appending to state['deployment_queue'] + if "DEPLOYMENT_QUEUE" in exits: + dq = state.setdefault("deployment_queue", []) + winner = state.get("winning_variant_id") + if winner: + dq.append({"variant_id": winner, "timestamp": __import__("datetime").datetime.utcnow().isoformat()}) + + return state diff --git a/PersonalizeAI/retrieval-logs/self_correction_2025-11-30.jsonl b/PersonalizeAI/retrieval-logs/self_correction_2025-11-30.jsonl new file mode 100644 index 00000000..24d9e8f6 --- /dev/null +++ b/PersonalizeAI/retrieval-logs/self_correction_2025-11-30.jsonl @@ -0,0 +1,4 @@ +{"ts": "2025-11-30T23:10:21.231446+00:00", "method": "llm", "prev_query": "old query", "new_query": "concise rewritten query", "model": null} +{"ts": "2025-11-30T23:10:21.261837+00:00", "method": "heuristic", "prev_query": "old query", "new_query": "old query product facts compliance citations", "model": null} +{"ts": "2025-11-30T23:14:15.036366+00:00", "method": "llm", "prev_query": "old query", "new_query": "concise rewritten query", "model": null} +{"ts": "2025-11-30T23:14:15.064362+00:00", "method": "heuristic", "prev_query": "old query", "new_query": "old query product facts compliance citations", "model": null} diff --git a/PersonalizeAI/state.py b/PersonalizeAI/state.py new file mode 100644 index 00000000..7706944f --- /dev/null +++ b/PersonalizeAI/state.py @@ -0,0 +1,27 @@ +"""Shared GraphState TypedDict for PersonalizeAI nodes. + +This file contains a minimal typed representation used across LangGraph nodes. +Fields are optional to keep nodes lightweight for this demo scaffolding. +""" +from typing import TypedDict, Any, Optional + + +class GraphState(TypedDict, total=False): + campaign_goal: str + user_message: str + + # Phase 1 outputs + candidate_segments: list[dict] + final_segment: str + confidence: float + segment_description: str + final_segment_source: str + + # Phase 2/3/4 fields (placeholders) + context_query: Optional[str] + retrieved_content: Optional[Any] + message_variants: Optional[list] + compliance_log: Optional[list] + winning_variant_id: Optional[str] + predicted_performance: Optional[dict] + feedback_payload: Optional[dict] diff --git a/PersonalizeAI/utils/response_cleaner.py b/PersonalizeAI/utils/response_cleaner.py new file mode 100644 index 00000000..bc70728d --- /dev/null +++ b/PersonalizeAI/utils/response_cleaner.py @@ -0,0 +1,174 @@ +"""Utilities to extract and validate JSON responses from LLM text outputs. + +This module provides helpers to: +- strip common fences (```), +- extract the first JSON object or array from free-form text, +- validate parsed JSON against simple Phase-3 schemas (generator, judge, rewrite). + +The extraction routine is tolerant to quoted braces and simple escape sequences. +""" +from __future__ import annotations + +import json +import logging +from typing import Any, Dict, List, Optional, Tuple + +logger = logging.getLogger("PersonalizeAI.response_cleaner") + + +def _strip_fences(text: str) -> str: + s = text.strip() + if s.startswith("```"): + # remove first fence line and last fence line if present + lines = s.splitlines() + if len(lines) >= 2 and lines[0].startswith("```"): + # find last fence + last_idx = None + for i in range(len(lines) - 1, -1, -1): + if lines[i].startswith("```"): + last_idx = i + break + if last_idx is not None and last_idx > 0: + return "\n".join(lines[1:last_idx]) + else: + return "\n".join(lines[1:]) + return s + + +def _extract_json_at(text: str, start_idx: int) -> Optional[str]: + """Extract a JSON substring starting at start_idx by bracket/brace balancing. + + This handles strings (quotes) and escaped characters so braces inside + strings do not confuse bracket counting. + Returns the JSON substring or None if matching end not found. + """ + if start_idx >= len(text): + return None + opening = text[start_idx] + if opening not in "[{": + return None + + pairs = {"[": "]", "{": "}"} + closing = pairs[opening] + + stack = [opening] + in_string = False + escape = False + i = start_idx + 1 + while i < len(text): + ch = text[i] + if escape: + escape = False + elif ch == "\\": + escape = True + elif ch == '"': + in_string = not in_string + elif not in_string: + if ch == opening: + stack.append(ch) + elif ch == closing: + stack.pop() + if not stack: + return text[start_idx : i + 1] + i += 1 + + return None + + +def extract_first_json(text: str) -> Any: + """Find and parse the first JSON object/array in `text`. + + Raises ValueError on parse failure or if no JSON found. + """ + if not isinstance(text, str): + raise ValueError("Input must be a string") + + cleaned = _strip_fences(text) + + # find first brace or bracket + for idx, ch in enumerate(cleaned): + if ch in "[{": + js = _extract_json_at(cleaned, idx) + if js: + try: + return json.loads(js) + except Exception as exc: + logger.debug("Found JSON-like substring but failed to parse: %s", exc) + # try to be resilient: attempt to locate trailing characters that break parsing + raise ValueError(f"Failed to parse JSON: {exc}\nSubstring:\n{js}") from exc + + raise ValueError("No JSON object or array found in text") + + +def validate_generator_output(obj: Any) -> Tuple[bool, Optional[str]]: + """Validate ai_message_generator output: list of variants with required keys. + + Returns (True, None) on success, else (False, error_message). + """ + if not isinstance(obj, list): + return False, "Expected a JSON array of variants" + if len(obj) < 1: + return False, "Expected at least one variant" + + for i, v in enumerate(obj): + if not isinstance(v, dict): + return False, f"Variant at index {i} is not an object" + for key in ("id", "subject", "body", "cta"): + if key not in v: + return False, f"Variant at index {i} missing key '{key}'" + if not isinstance(v[key], str): + return False, f"Variant at index {i} key '{key}' must be a string" + + return True, None + + +def validate_judge_output(obj: Any) -> Tuple[bool, Optional[str]]: + """Validate compliance judge output: object with is_compliant(bool) and reason (null|string).""" + if not isinstance(obj, dict): + return False, "Expected a JSON object" + if "is_compliant" not in obj: + return False, "Missing 'is_compliant' key" + if not isinstance(obj["is_compliant"], bool): + return False, "'is_compliant' must be boolean" + if obj.get("reason") is not None and not isinstance(obj.get("reason"), str): + return False, "'reason' must be null or a string" + return True, None + + +def validate_rewrite_output(obj: Any) -> Tuple[bool, Optional[str]]: + """Validate a single rewritten variant object. + + Expects dict with id, subject, body, cta strings. + """ + if not isinstance(obj, dict): + return False, "Expected a JSON object" + for key in ("id", "subject", "body", "cta"): + if key not in obj: + return False, f"Missing '{key}' in rewrite output" + if not isinstance(obj[key], str): + return False, f"'{key}' must be a string" + return True, None + + +def parse_and_validate_generator(text: str) -> List[Dict[str, Any]]: + parsed = extract_first_json(text) + ok, err = validate_generator_output(parsed) + if not ok: + raise ValueError(f"Invalid generator output: {err}") + return parsed + + +def parse_and_validate_judge(text: str) -> Dict[str, Any]: + parsed = extract_first_json(text) + ok, err = validate_judge_output(parsed) + if not ok: + raise ValueError(f"Invalid judge output: {err}") + return parsed + + +def parse_and_validate_rewrite(text: str) -> Dict[str, Any]: + parsed = extract_first_json(text) + ok, err = validate_rewrite_output(parsed) + if not ok: + raise ValueError(f"Invalid rewrite output: {err}") + return parsed diff --git a/README.md b/README.md index e239b1a2..186476e8 100644 --- a/README.md +++ b/README.md @@ -5,143 +5,307 @@ EchoVoice is a **multi-agent AI personalization platform** designed for regulated industries. It delivers safe, on-brand, traceable customer messaging through a coordinated set of specialized agents working together inside a transparent and auditable orchestration pipeline. -This repository provides a **prototype scaffold** for local development, including an orchestrator, agent suite, mock RAG data, and a frontend stub for auditability. +This repository provides a **prototype scaffold** for local development, including an orchestrator, agent suite, mock retrieval (text-target) data, and a frontend stub for auditability. --- -## ⚙️ Quick Start (Backend) +## Table of Contents + +- [🚀 **EchoVoice: Customer Personalization Orchestrator**](#-echovoice-customer-personalization-orchestrator) + - [Table of Contents](#table-of-contents) + - [Features](#features) + - [Architecture Diagram](#architecture-diagram) + - [🚀 The End-to-End LangGraph Architecture](#-the-end-to-end-langgraph-architecture) + - [Phase 1: Segmentation (Decision \& Context)](#phase-1-segmentation-decision--context) + - [Phase 2: Content Retrieval (Grounded Facts)](#phase-2-content-retrieval-grounded-facts) + - [Phase 3: Generation and Compliance (Safety Barrier)](#phase-3-generation-and-compliance-safety-barrier) + - [Phase 4: Experimentation and Feedback (Closing the Loop)](#phase-4-experimentation-and-feedback-closing-the-loop) + - [Azure account requirements](#azure-account-requirements) + - [Cost estimation](#cost-estimation) + - [Getting Started](#getting-started) + - [GitHub Codespaces](#github-codespaces) + - [VS Code Dev Containers](#vs-code-dev-containers) + - [Local environment](#local-environment) + - [Deploying](#deploying) + - [Running the development server](#running-the-development-server) + - [Using the app](#using-the-app) + - [Clean up](#clean-up) + - [Guidance](#guidance) + +![Chat screen](docs/images/chatscreen.png) +![Chat screen](docs/images/chatscreen2.png) + +[📺 Watch a video overview of the app.](https://youtu.be/g0BRpb4jgIY) + +This repository demonstrates EchoVoice — a compliance-first, multi-agent personalization orchestrator. It illustrates how retrieval-augmented generation (RAG) workflows, model orchestration, and audit trails can be combined to produce safe, on‑brand customer messaging. + +The prototype uses Azure OpenAI Service (example model: `gpt-4.1-mini`) together with Azure AI Search for indexing and retrieval. The repo includes sample data and mocked services so you can run the prototype locally and inspect retrieval sources, model outputs, and the associated audit metadata. + +## Features + +- Chat (multi-turn) and Q&A (single turn) interfaces +- Renders citations and thought process for each answer +- Includes settings directly in the UI to tweak the behavior and experiment with options +- Integrates Azure AI Search for indexing and retrieval of documents, with support for [many document formats](/docs/data_ingestion.md#supported-document-formats) as well as [cloud data ingestion](/docs/data_ingestion.md#cloud-data-ingestion) +- Optional usage of [multimodal models](/docs/multimodal.md) to reason over image-heavy documents +- Optional addition of [speech input/output](/docs/deploy_features.md#enabling-speech-inputoutput) for accessibility +- Optional automation of [user login and data access](/docs/login_and_acl.md) via Microsoft Entra +- Performance tracing and monitoring with Application Insights + +### Architecture Diagram + +![EchoVoice Architecture](docs/images/appcomponents.png) -### **1. Create your environment file** +--- -```bash -cp .env.template .env -``` +## 🚀 The End-to-End LangGraph Architecture -Fill in the required API keys (Azure OpenAI, Azure Search, etc.). +That's the complete, four-phase LangGraph architecture for your **AI Marketing Personalization Engine**! You've successfully mapped the conceptual design into four distinct, interconnected graphs that handle conditional logic, RAG, compliance, and feedback. + +Here is a summary of the complete end-to-end workflow, illustrating how the four phases (represented by your four uploaded diagrams) link together to form a closed-loop system: --- -### **2. Create & activate a Python virtual environment** +### Phase 1: Segmentation (Decision & Context) -```bash -cd backend -python3 -m venv .venv -source .venv/bin/activate -pip install -r requirements.txt -``` +- **Goal:** Determine the single, most relevant segment for the current campaign goal. +- **Starting Point:** The graph begins at the **Goal Router** node, which receives the campaign objective. +- **Key Logic:** **Conditional Routing** based on the goal (e.g., **RFM** for churn, **Intent** for real-time). Only one specialized agent runs. +- **Output:** The **Priority & Output** node provides the final, definitive `prioritized_segment` and its `segment_description` (the explainable reason). --- -### **3. Run the backend orchestrator** +### Phase 2: Content Retrieval (Grounded Facts) -```bash -cd backend -uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 -``` +- **Goal:** Retrieve accurate, citable product information relevant to the segment's needs. +- **Key Logic:** **Corrective RAG Loop**. + - The **Contextual Query Generator** translates the segment description into search terms. + - The **Relevance Grader** checks the retrieved documents. + - **Conditional Loop:** If documents are irrelevant, the flow routes to **Self-Correction** (Rewrite Query) and loops back for a retry. +- **Output:** The **Citation Formatter** provides the final, clean `content_context` and a list of `citation_sources`. --- -### **3. Check health of server** +### Phase 3: Generation and Compliance (Safety Barrier) -GET request: +- **Goal:** Create personalized message variants and ensure 100% adherence to brand safety policy. +- **Key Logic:** **Mandatory Safety Loop**. + - The **AI Message Generator** creates A/B/n variants using the segment and the citable content. + - The **Safety & Compliance Agent** checks the variants against the policy rule engine. + - **Conditional Loop:** If the message is **NOT compliant**, the flow routes to the **Automated Rewrite Node** and **loops back** to the Compliance Agent for a re-check. +- **Output:** An approved list of safe and personalized message variants. -```bash -GET http://localhost:8000/health -``` +--- -Example payload: +### Phase 4: Experimentation and Feedback (Closing the Loop) -```json -{ - "status": "ok", -} -``` +- **Goal:** Simulate performance to select the winning message and feed performance data back for continuous improvement. +- **Key Logic:** **Simulation and Dual Exit**. + - The **A/B/n Experiment Simulator** predicts the CTR and Conversion Lift for each variant. + - The **Winning Variant Selector** chooses the best-performing message. +- **Output & Exit:** The **Feedback Processor** splits the workflow into two paths: + 1. **Deployment Queue:** Sends the winning, approved message to the external system for live use. + 2. **Feedback Loop:** Sends the structured performance data and segment details back to Phase 1 or Phase 3 for model retraining and optimization. -This simulates the full end-to-end personalization flow: +This closed-loop system represents a powerful, enterprise-grade AI solution and maps the LangGraph diagrams into an operational pipeline for safe, auditable personalization. -* Segmentation -* RAG retrieval -* A/B/n generation -* Safety & compliance filtering -* Variant selection -* Experiment logging +## Azure account requirements ---- +**IMPORTANT:** In order to deploy and run this example, you'll need: -## 🛠️ Deployment / Configuration Notes +- **Azure account**. If you're new to Azure, [get an Azure account for free](https://azure.microsoft.com/free/cognitive-search/) and you'll get some free Azure credits to get started. See [guide to deploying with the free trial](docs/deploy_freetrial.md). +- **Azure account permissions**: + - Your Azure account must have `Microsoft.Authorization/roleAssignments/write` permissions, such as [Role Based Access Control Administrator](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#role-based-access-control-administrator-preview), [User Access Administrator](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#user-access-administrator), or [Owner](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#owner). If you don't have subscription-level permissions, you must be granted [RBAC](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#role-based-access-control-administrator-preview) for an existing resource group and [deploy to that existing group](docs/deploy_existing.md#resource-group). + - Your Azure account also needs `Microsoft.Resources/deployments/write` permissions on the subscription level. -Optional Redis-backed store +### Cost estimation -* The backend can persist transient orchestration state in Redis by setting the `REDIS_URL` environment variable (for example `redis://localhost:6379/0`). When `REDIS_URL` is set, the app will attempt to use Redis via the `redis` Python package. If Redis is not available the app falls back to an in-memory `MemoryStore`. +Pricing varies per region and usage, so it isn't possible to predict exact costs for your usage. +However, you can try the [Azure pricing calculator](https://azure.com/e/e3490de2372a4f9b909b0d032560e41b) for the resources below. -Add/enable Redis (example `.env`): +- Azure Container Apps: Default host for app deployment as of 10/28/2024. See more details in [the ACA deployment guide](docs/azure_container_apps.md). Consumption plan with 1 CPU core, 2 GB RAM, minimum of 0 replicas. Pricing with Pay-as-You-Go. [Pricing](https://azure.microsoft.com/pricing/details/container-apps/) +- Azure Container Registry: Basic tier. [Pricing](https://azure.microsoft.com/pricing/details/container-registry/) +- Azure App Service: Only provisioned if you deploy to Azure App Service following [the App Service deployment guide](docs/azure_app_service.md). Basic Tier with 1 CPU core, 1.75 GB RAM. Pricing per hour. [Pricing](https://azure.microsoft.com/pricing/details/app-service/linux/) +- Azure OpenAI: Standard tier, GPT and Ada models. Pricing per 1K tokens used, and at least 1K tokens are used per question. [Pricing](https://azure.microsoft.com/pricing/details/cognitive-services/openai-service/) +- Azure AI Document Intelligence: SO (Standard) tier using pre-built layout. Pricing per document page, sample documents have 261 pages total. [Pricing](https://azure.microsoft.com/pricing/details/form-recognizer/) +- Azure AI Search: Basic tier, 1 replica, free level of semantic search. Pricing per hour. [Pricing](https://azure.microsoft.com/pricing/details/search/) +- Azure Blob Storage: Standard tier with ZRS (Zone-redundant storage). Pricing per storage and read operations. [Pricing](https://azure.microsoft.com/pricing/details/storage/blobs/) +- Azure Cosmos DB: Only provisioned if you enabled [chat history with Cosmos DB](docs/deploy_features.md#enabling-persistent-chat-history-with-azure-cosmos-db). Serverless tier. Pricing per request unit and storage. [Pricing](https://azure.microsoft.com/pricing/details/cosmos-db/) +- Azure AI Vision: Only provisioned if you enabled [multimodal approach](docs/multimodal.md). Pricing per 1K transactions. [Pricing](https://azure.microsoft.com/pricing/details/cognitive-services/computer-vision/) +- Azure AI Content Understanding: Only provisioned if you enabled [media description](docs/deploy_features.md#enabling-media-description-with-azure-content-understanding). Pricing per 1K images. [Pricing](https://azure.microsoft.com/pricing/details/content-understanding/) +- Azure Monitor: Pay-as-you-go tier. Costs based on data ingested. [Pricing](https://azure.microsoft.com/pricing/details/monitor/) -```env -# Optional: enable Redis-backed store for cross-process persistence -REDIS_URL=redis://localhost:6379/0 -``` +To reduce costs, you can switch to free SKUs for various services, but those SKUs have limitations. +See this guide on [deploying with minimal costs](docs/deploy_lowcost.md) for more details. + +⚠️ To avoid unnecessary costs, remember to take down your app if it's no longer in use, +either by deleting the resource group in the Portal or running `azd down`. + +## Getting Started + +You have a few options for setting up this project. +The easiest way to get started is GitHub Codespaces, since it will setup all the tools for you, +but you can also [set it up locally](#local-environment) if desired. + +### GitHub Codespaces + +You can run this repo virtually by using GitHub Codespaces, which will open a web-based VS Code in your browser: + +[![Open in GitHub Codespaces](https://img.shields.io/static/v1?style=for-the-badge&label=GitHub+Codespaces&message=Open&color=brightgreen&logo=github)](https://github.com/codespaces/new?hide_repo_select=true&ref=main&repo=599293758&machine=standardLinux32gb&devcontainer_path=.devcontainer%2Fdevcontainer.json&location=WestUs2) + +Once the codespace opens (this may take several minutes), open a terminal window. + +### VS Code Dev Containers + +A related option is VS Code Dev Containers, which will open the project in your local VS Code using the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers): + +1. Start Docker Desktop (install it if not already installed) +2. Open the project: + [![Open in Dev Containers](https://img.shields.io/static/v1?style=for-the-badge&label=Dev%20Containers&message=Open&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/azure-samples/azure-search-openai-demo) + +3. In the VS Code window that opens, once the project files show up (this may take several minutes), open a terminal window. + +### Local environment + +1. Install the required tools: + + - [Azure Developer CLI](https://aka.ms/azure-dev/install) + - [Python 3.10, 3.11, 3.12, 3.13, or 3.14](https://www.python.org/downloads/) + - **Important**: Python and the pip package manager must be in the path in Windows for the setup scripts to work. + - **Important**: Ensure you can run `python --version` from console. On Ubuntu, you might need to run `sudo apt install python-is-python3` to link `python` to `python3`. + - [Node.js 20+](https://nodejs.org/download/) + - [Git](https://git-scm.com/downloads) + - [Powershell 7+ (pwsh)](https://github.com/powershell/powershell) - For Windows users only. + - **Important**: Ensure you can run `pwsh.exe` from a PowerShell terminal. If this fails, you likely need to upgrade PowerShell. + +2. Create a new folder and switch to it in the terminal. +3. Run this command to download the project code: + + ```shell + azd init -t azure-search-openai-demo + ``` -Dependencies + Note that this command will initialize a git repository, so you do not need to clone this repository. -* The optional Redis adapter requires the `redis` client; it has been added to `backend/requirements.txt`. Install/update dependencies in the backend venv: +## Deploying -```powershell -cd backend -& .\.venv\Scripts\Activate.ps1 -pip install -r requirements.txt +The steps below will provision Azure resources and deploy the application code to Azure Container Apps. To deploy to Azure App Service instead, follow [the app service deployment guide](docs/azure_app_service.md). + +1. Login to your Azure account: + + ```shell + azd auth login + ``` + + For GitHub Codespaces users, if the previous command fails, try: + + ```shell + azd auth login --use-device-code + ``` + +1. Create a new azd environment: + + ```shell + azd env new + ``` + + Enter a name that will be used for the resource group. + This will create a new folder in the `.azure` folder, and set it as the active environment for any calls to `azd` going forward. +1. (Optional) This is the point where you can customize the deployment by setting environment variables, in order to [use existing resources](docs/deploy_existing.md), [enable optional features (such as auth or vision)](docs/deploy_features.md), or [deploy low-cost options](docs/deploy_lowcost.md), or [deploy with the Azure free trial](docs/deploy_freetrial.md). +1. Run `azd up` - This will provision Azure resources and deploy this sample to those resources, including building the search index based on the files found in the `./data` folder. + - **Important**: Beware that the resources created by this command will incur immediate costs, primarily from the AI Search resource. These resources may accrue costs even if you interrupt the command before it is fully executed. You can run `azd down` or delete the resources manually to avoid unnecessary spending. + - You will be prompted to select two locations, one for the majority of resources and one for the OpenAI resource, which is currently a short list. That location list is based on the [OpenAI model availability table](https://learn.microsoft.com/azure/cognitive-services/openai/concepts/models#model-summary-table-and-region-availability) and may become outdated as availability changes. +1. After the application has been successfully deployed you will see a URL printed to the console. Click that URL to interact with the application in your browser. +It will look like the following: + +!['Output from running azd up'](docs/images/endpoint.png) + +> NOTE: It may take 5-10 minutes after you see 'SUCCESS' for the application to be fully deployed. If you see a "Python Developer" welcome screen or an error page, then wait a bit and refresh the page. + +### Deploying again + +If you've only changed the backend/frontend code in the `app` folder, then you don't need to re-provision the Azure resources. You can just run: + +```shell +azd deploy ``` -Notes - -* `MemoryStore` is process-local and thread-safe via a simple lock; it is suitable for single-process development and test runs. -* Use Redis in multi-worker or distributed deployments to share transient orchestration state across processes and machines. -* If you want, I can add a Docker Compose service or a small integration test that runs Redis locally for CI. - -## 📁 Repository Layout - -```bash -EchoVoice-AI/ -├── README.md -├── package.json -├── .env.template -│ -├── data/ # sample KB & events for testing -├── frontend/ # React + Tailwind audit dashboard scaffold -└── backend/ - ├── main.py # FastAPI orchestrator - ├── agents/ # segmentation, RAG, generation, safety, analytics - ├── utils/ # logging, validation, configuration - └── data/ # local mock content for retrieval - ├── requirements.txt +If you've changed the infrastructure files (`infra` folder or `azure.yaml`), then you'll need to re-provision the Azure resources. You can do that by running: + +```shell +azd up ``` -This scaffold includes **mock/minimal agent logic** so you can quickly validate orchestration before integrating full Azure services. +## Running the development server ---- +You can only run a development server locally **after** having successfully run the `azd up` command. If you haven't yet, follow the [deploying](#deploying) steps above. -## 🧱 Architecture Overview +1. Run `azd auth login` if you have not logged in recently. +2. Start the server: -EchoVoice uses a **LangGraph-style multi-agent workflow** coordinated by a central orchestrator. + Windows: -### **Key Components** + ```shell + ./app/start.ps1 + ``` -* **`agents/`** – individual, modular specialist agents: + Linux/Mac: - * **SegmentationAgent** – assigns user segment + explainability - * **RetrievalAgent** – RAG over verified local KB - * **GenerationAgent** – creates A/B/n personalized messages - * **SafetyComplianceAgent** – checks brand, legal, factual grounding - * **DeliveryAgent** – decides auto-send vs. human review - * **AnalyticsAgent** – logs results + tracks uplift -* **`main.py`** – orchestrator that connects all agents into a decision pipeline -* **`data/`** – mock content and synthetic customer events -* **Frontend Stub** – React/Tailwind dashboard (audit log, experiment view) + ```shell + ./app/start.sh + ``` -This architecture allows: + VS Code: Run the "VS Code Task: Start App" task. -* experiment-driven personalization -* full auditability -* safe outbound communication -* transparent decision-making for every step +It's also possible to enable hotloading or the VS Code debugger. +See more tips in [the local development guide](docs/localdev.md). ---- +## Using the app + +- In Azure: navigate to the Azure WebApp deployed by azd. The URL is printed out when azd completes (as "Endpoint"), or you can find it in the Azure portal. +- Running locally: navigate to 127.0.0.1:8000 + +Once in the web app: + +- Try different features of the customer personalization orchestrator, etc. +- Explore citations and sources +- Click on "settings" to try different options, tweak prompts, etc. + +## Clean up + +To clean up all the resources created by this sample: + +1. Run `azd down` +2. When asked if you are sure you want to continue, enter `y` +3. When asked if you want to permanently delete the resources, enter `y` + +The resource group and all the resources will be deleted. + +## Guidance + +You can find extensive documentation in the [docs](docs/README.md) folder: + +- Deploying: + - [Troubleshooting deployment](docs/deploy_troubleshooting.md) + - [Debugging the app on App Service](docs/appservice.md) + - [Deploying with azd: deep dive and CI/CD](docs/azd.md) + - [Deploying with existing Azure resources](docs/deploy_existing.md) + - [Deploying from a free account](docs/deploy_lowcost.md) + - [Enabling optional features](docs/deploy_features.md) + - [All features](docs/deploy_features.md) + - [Login and access control](docs/login_and_acl.md) + - [Multimodal](docs/multimodal.md) + - [Reasoning](docs/reasoning.md) + - [Private endpoints](docs/deploy_private.md) + - [Agentic retrieval](docs/agentic_retrieval.md) + - [Sharing deployment environments](docs/sharing_environments.md) +- [Local development](docs/localdev.md) +- [Customizing the app](docs/customization.md) +- [App architecture](docs/architecture.md) +- [HTTP Protocol](docs/http_protocol.md) +- [Data ingestion](docs/data_ingestion.md) +- [Evaluation](docs/evaluation.md) +- [Safety evaluation](docs/safety_evaluation.md) +- [Monitoring with Application Insights](docs/monitoring.md) +- [Productionizing](docs/productionizing.md) +- [Alternative retrieval chat samples](docs/other_samples.md) diff --git a/app/backend/.dockerignore b/app/backend/.dockerignore new file mode 100644 index 00000000..9008115f --- /dev/null +++ b/app/backend/.dockerignore @@ -0,0 +1,7 @@ +.git +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +env diff --git a/app/backend/Dockerfile b/app/backend/Dockerfile new file mode 100644 index 00000000..647873f5 --- /dev/null +++ b/app/backend/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.13-bookworm + +WORKDIR /app + +COPY ./ /app + +RUN python -m pip install -r requirements.txt + +RUN python -m pip install gunicorn + +CMD ["python3", "-m", "gunicorn", "-b", "0.0.0.0:8000", "main:app"] diff --git a/app/backend/agents/nodes.py b/app/backend/agents/nodes.py new file mode 100644 index 00000000..135af73a --- /dev/null +++ b/app/backend/agents/nodes.py @@ -0,0 +1,12 @@ +# app/agents/nodes.py +from langchain_core.messages import HumanMessage + +async def segmentation_node(state): + # Logic to call LLM and segment customers + # Returns updated state with "segments" + return {"segments": ["High-Protein Shopper", "Budget-Conscious"]} + +async def safety_node(state): + # Logic to check content against policies + # Returns updated state with "is_safe" flag and logs + return {"safety_logs": ["Checked for medical claims: Passed"]} \ No newline at end of file diff --git a/app/backend/agents/workflow.py b/app/backend/agents/workflow.py new file mode 100644 index 00000000..e69de29b diff --git a/app/backend/api/__init__.py b/app/backend/api/__init__.py new file mode 100644 index 00000000..00bae36f --- /dev/null +++ b/app/backend/api/__init__.py @@ -0,0 +1 @@ +"""API package for FastAPI migration.""" diff --git a/app/backend/api/dependencies.py b/app/backend/api/dependencies.py new file mode 100644 index 00000000..3fcd161f --- /dev/null +++ b/app/backend/api/dependencies.py @@ -0,0 +1,79 @@ +"""API dependencies for authentication and authorization.""" +import logging +from typing import Any + +from fastapi import HTTPException, Request + +from config import CONFIG_AUTH_CLIENT, CONFIG_SEARCH_CLIENT +from core.authentication import AuthError +from typing import Any +from fastapi import HTTPException, Request, Depends + +from config import CONFIG_ASK_APPROACH, CONFIG_CHAT_APPROACH + + +async def get_ask_approach(request: Request) -> Any: + cfg = getattr(request.app.state, "config", {}) + approach = cfg.get(CONFIG_ASK_APPROACH) + if approach is None: + raise HTTPException(status_code=503, detail="Ask approach not configured") + return approach + + +async def get_chat_approach(request: Request) -> Any: + cfg = getattr(request.app.state, "config", {}) + approach = cfg.get(CONFIG_CHAT_APPROACH) + if approach is None: + raise HTTPException(status_code=503, detail="Chat approach not configured") + return approach + + +async def get_auth_claims(request: Request) -> dict[str, Any]: + """FastAPI dependency to replace `@authenticated` decorator. + + Returns an `auth_claims` dict (possibly empty) or raises HTTPException(403) + when authentication is required but invalid. + """ + cfg = getattr(request.app.state, "config", {}) + auth_helper = cfg.get(CONFIG_AUTH_CLIENT) + if not auth_helper: + # No auth helper configured -> treat as unauthenticated but allowed + return {} + + try: + # Convert headers to plain dict; the helper expects a dict-like object + headers = dict(request.headers) + auth_claims = await auth_helper.get_auth_claims_if_enabled(headers) + return auth_claims + except AuthError: + # Mirror previous behavior (decorator aborted with 403 on AuthError) + raise HTTPException(status_code=403) + except Exception as exc: + logging.exception("Problem checking auth claims: %s", exc) + # For other errors, return 500 so clients see server error + raise HTTPException(status_code=500, detail=str(exc)) + + +async def require_path_auth(path: str, request: Request) -> dict[str, Any]: + """Dependency used by routes that need to check access to a specific path. + + Returns auth_claims if authorized, raises HTTPException(403) if not. + """ + cfg = getattr(request.app.state, "config", {}) + auth_helper = cfg.get(CONFIG_AUTH_CLIENT) + search_client = cfg.get(CONFIG_SEARCH_CLIENT) + if not auth_helper: + return {} + + try: + headers = dict(request.headers) + auth_claims = await auth_helper.get_auth_claims_if_enabled(headers) + authorized = await auth_helper.check_path_auth(path, auth_claims, search_client) + if not authorized: + raise HTTPException(status_code=403) + return auth_claims + except AuthError: + raise HTTPException(status_code=403) + except Exception as exc: + logging.exception("Problem checking path auth: %s", exc) + raise HTTPException(status_code=500, detail=str(exc)) diff --git a/app/backend/api/main.py b/app/backend/api/main.py new file mode 100644 index 00000000..cbd0c512 --- /dev/null +++ b/app/backend/api/main.py @@ -0,0 +1,35 @@ +"""Main FastAPI application setup.""" +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +# from fastapi.staticfiles import StaticFiles +from .routes import router as api_router +from .startup import register as register_startup + +app = FastAPI( + title="EchoVoice AI Orchestrator", + description="Backend API for Multi-Agent Marketing Personalization", + version="1.0.0" +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:8082"], # Update with your frontend URL + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Mount static files (serves the same static folder used by the Quart blueprint) +# app.mount("/static", StaticFiles(directory="./static"), name="static") + +# Include API routes +app.include_router(api_router) + +# Register startup/shutdown handlers +register_startup(app) + +# Minimal root route to serve index (frontend expects `/`) +@app.get("/") +async def index(): + """Serve a minimal index response.""" + return {"status": "EchoVoice FastAPI migration - index served by static files at /static"} diff --git a/app/backend/api/models.py b/app/backend/api/models.py new file mode 100644 index 00000000..1d442e83 --- /dev/null +++ b/app/backend/api/models.py @@ -0,0 +1,20 @@ +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel + + +class Message(BaseModel): + role: str + content: str + + +class AskRequest(BaseModel): + messages: List[Message] + context: Optional[Dict[str, Any]] = None + session_state: Optional[Any] = None + + +class ChatRequest(BaseModel): + messages: List[Message] + context: Optional[Dict[str, Any]] = None + session_state: Optional[Any] = None diff --git a/app/backend/api/routes/__init__.py b/app/backend/api/routes/__init__.py new file mode 100644 index 00000000..62e40b9f --- /dev/null +++ b/app/backend/api/routes/__init__.py @@ -0,0 +1,19 @@ +"""API routes package. Aggregates smaller route modules into a single router.""" +from fastapi import APIRouter + +router = APIRouter() + +# Import submodules so they register routers below +from . import health, ask_chat, uploads, content, chat_history, auth_setup, segmentation, generation, experimentation, retrieval # noqa: F401 + +# Include sub-routers +router.include_router(health.router) +router.include_router(ask_chat.router) +router.include_router(uploads.router) +router.include_router(content.router) +router.include_router(chat_history.router) +router.include_router(auth_setup.router) +router.include_router(segmentation.router) +router.include_router(generation.router) +router.include_router(experimentation.router) +router.include_router(retrieval.router) \ No newline at end of file diff --git a/app/backend/api/routes/ask_chat.py b/app/backend/api/routes/ask_chat.py new file mode 100644 index 00000000..c08607be --- /dev/null +++ b/app/backend/api/routes/ask_chat.py @@ -0,0 +1,77 @@ +"""Ask and chat endpoints (including NDJSON streaming) using Pydantic models and DI.""" +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import JSONResponse, StreamingResponse + +from ..models import AskRequest, ChatRequest +from .utils import ndjson_bytes +from ..dependencies import get_auth_claims, get_ask_approach, get_chat_approach +from config import CONFIG_CHAT_HISTORY_BROWSER_ENABLED, CONFIG_CHAT_HISTORY_COSMOS_ENABLED + +router = APIRouter() + + +@router.post("/ask") +async def ask( + body: AskRequest, + auth_claims: dict = Depends(get_auth_claims), + approach=Depends(get_ask_approach), +): + context = body.context or {} + context["auth_claims"] = auth_claims + try: + r = await approach.run([m.dict() for m in body.messages], context=context, session_state=body.session_state) + return JSONResponse(r) + except Exception as error: + return JSONResponse({"error": str(error)}, status_code=500) + + +@router.post("/chat/stream") +async def chat_stream( + request: Request, + body: ChatRequest, + auth_claims: dict = Depends(get_auth_claims), + approach=Depends(get_chat_approach), +): + context = body.context or {} + context["auth_claims"] = auth_claims + try: + session_state = body.session_state + if session_state is None: + from core.sessionhelper import create_session_id + + cfg = getattr(request.app.state, "config", {}) + session_state = create_session_id( + cfg.get(CONFIG_CHAT_HISTORY_COSMOS_ENABLED), + cfg.get(CONFIG_CHAT_HISTORY_BROWSER_ENABLED), + ) + + result_gen = await approach.run_stream([m.dict() for m in body.messages], context=context, session_state=session_state) + return StreamingResponse(ndjson_bytes(result_gen), media_type="application/x-ndjson") + except Exception as error: + return JSONResponse({"error": str(error)}, status_code=500) + + +@router.post("/chat") +async def chat( + request: Request, + body: ChatRequest, + auth_claims: dict = Depends(get_auth_claims), + approach=Depends(get_chat_approach), +): + context = body.context or {} + context["auth_claims"] = auth_claims + try: + session_state = body.session_state + if session_state is None: + from core.sessionhelper import create_session_id + + cfg = getattr(request.app.state, "config", {}) + session_state = create_session_id( + cfg.get(CONFIG_CHAT_HISTORY_COSMOS_ENABLED), + cfg.get(CONFIG_CHAT_HISTORY_BROWSER_ENABLED), + ) + + result = await approach.run([m.dict() for m in body.messages], context=context, session_state=session_state) + return JSONResponse(result) + except Exception as error: + return JSONResponse({"error": str(error)}, status_code=500) diff --git a/app/backend/api/routes/auth_setup.py b/app/backend/api/routes/auth_setup.py new file mode 100644 index 00000000..0729eb1f --- /dev/null +++ b/app/backend/api/routes/auth_setup.py @@ -0,0 +1,21 @@ +"""Authentication setup endpoint.""" +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import JSONResponse + +from config import CONFIG_AUTH_CLIENT + +router = APIRouter() + + +@router.get("/auth-setup",tags=["AuthSetup"]) +async def auth_setup(request: Request): + cfg = getattr(request.app.state, "config", None) + if cfg is None: + raise HTTPException(status_code=503, detail="App not initialized") + + auth_client = cfg.get(CONFIG_AUTH_CLIENT) + if auth_client is None: + raise HTTPException(status_code=503, detail="Auth client not configured") + + setup_info = auth_client.get_auth_setup_for_client() + return JSONResponse(setup_info) diff --git a/app/backend/api/routes/chat_history.py b/app/backend/api/routes/chat_history.py new file mode 100644 index 00000000..a34bb74a --- /dev/null +++ b/app/backend/api/routes/chat_history.py @@ -0,0 +1,182 @@ +"""Chat history endpoints backed by Cosmos (optional).""" +import time +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import JSONResponse, Response + +from config import ( + CONFIG_CHAT_HISTORY_COSMOS_ENABLED, + CONFIG_COSMOS_HISTORY_CLIENT, + CONFIG_COSMOS_HISTORY_CONTAINER, + CONFIG_COSMOS_HISTORY_VERSION, + CONFIG_CREDENTIAL, +) +from ..dependencies import get_auth_claims + +router = APIRouter() + + +@router.post("/chat_history",tags=["ChatHistory"]) +async def post_chat_history(request: Request, auth_claims: dict = Depends(get_auth_claims)): + cfg = getattr(request.app.state, "config", None) + if cfg is None: + raise HTTPException(status_code=503, detail="App not initialized") + + if not cfg.get(CONFIG_CHAT_HISTORY_COSMOS_ENABLED): + return JSONResponse({"error": "Chat history not enabled"}, status_code=400) + + container = cfg.get(CONFIG_COSMOS_HISTORY_CONTAINER) + if not container: + return JSONResponse({"error": "Chat history not enabled"}, status_code=400) + + entra_oid = auth_claims.get("oid") + if not entra_oid: + return JSONResponse({"error": "User OID not found"}, status_code=401) + + try: + request_json = await request.json() + session_id = request_json.get("id") + message_pairs = request_json.get("answers") + first_question = message_pairs[0][0] + title = first_question + "..." if len(first_question) > 50 else first_question + timestamp = int(time.time() * 1000) + + session_item = { + "id": session_id, + "version": cfg.get(CONFIG_COSMOS_HISTORY_VERSION), + "session_id": session_id, + "entra_oid": entra_oid, + "type": "session", + "title": title, + "timestamp": timestamp, + } + + message_pair_items = [] + for ind, message_pair in enumerate(message_pairs): + message_pair_items.append( + { + "id": f"{session_id}-{ind}", + "version": cfg.get(CONFIG_COSMOS_HISTORY_VERSION), + "session_id": session_id, + "entra_oid": entra_oid, + "type": "message_pair", + "question": message_pair[0], + "response": message_pair[1], + } + ) + + batch_operations = [("upsert", (session_item,))] + [ + ("upsert", (message_pair_item,)) for message_pair_item in message_pair_items + ] + await container.execute_item_batch(batch_operations=batch_operations, partition_key=[entra_oid, session_id]) + return JSONResponse({}, status_code=201) + except Exception as error: + return JSONResponse({"error": str(error)}, status_code=500) + + +@router.get("/chat_history/sessions",tags=["ChatHistory"]) +async def get_chat_history_sessions(request: Request, auth_claims: dict = Depends(get_auth_claims)): + cfg = getattr(request.app.state, "config", None) + if cfg is None: + raise HTTPException(status_code=503, detail="App not initialized") + if not cfg.get(CONFIG_CHAT_HISTORY_COSMOS_ENABLED): + return JSONResponse({"error": "Chat history not enabled"}, status_code=400) + container = cfg.get(CONFIG_COSMOS_HISTORY_CONTAINER) + if not container: + return JSONResponse({"error": "Chat history not enabled"}, status_code=400) + entra_oid = auth_claims.get("oid") + if not entra_oid: + return JSONResponse({"error": "User OID not found"}, status_code=401) + + try: + count = int(request.query_params.get("count", 10)) + continuation_token = request.query_params.get("continuation_token") + + res = container.query_items( + query="SELECT c.id, c.entra_oid, c.title, c.timestamp FROM c WHERE c.entra_oid = @entra_oid AND c.type = @type ORDER BY c.timestamp DESC", + parameters=[dict(name="@entra_oid", value=entra_oid), dict(name="@type", value="session")], + partition_key=[entra_oid], + max_item_count=count, + ) + + pager = res.by_page(continuation_token) + sessions = [] + try: + page = await pager.__anext__() + continuation_token = pager.continuation_token # type: ignore + async for item in page: + sessions.append({ + "id": item.get("id"), + "entra_oid": item.get("entra_oid"), + "title": item.get("title", "untitled"), + "timestamp": item.get("timestamp"), + }) + except StopAsyncIteration: + continuation_token = None + + return JSONResponse({"sessions": sessions, "continuation_token": continuation_token}) + except Exception as error: + return JSONResponse({"error": str(error)}, status_code=500) + + +@router.get("/chat_history/sessions/{session_id}",tags=["ChatHistory"]) +async def get_chat_history_session(request: Request, session_id: str, auth_claims: dict = Depends(get_auth_claims)): + cfg = getattr(request.app.state, "config", None) + if cfg is None: + raise HTTPException(status_code=503, detail="App not initialized") + if not cfg.get(CONFIG_CHAT_HISTORY_COSMOS_ENABLED): + return JSONResponse({"error": "Chat history not enabled"}, status_code=400) + container = cfg.get(CONFIG_COSMOS_HISTORY_CONTAINER) + if not container: + return JSONResponse({"error": "Chat history not enabled"}, status_code=400) + entra_oid = auth_claims.get("oid") + if not entra_oid: + return JSONResponse({"error": "User OID not found"}, status_code=401) + + try: + res = container.query_items( + query="SELECT * FROM c WHERE c.session_id = @session_id AND c.type = @type", + parameters=[dict(name="@session_id", value=session_id), dict(name="@type", value="message_pair")], + partition_key=[entra_oid, session_id], + ) + + message_pairs = [] + async for page in res.by_page(): + async for item in page: + message_pairs.append([item["question"], item["response"]]) + + return JSONResponse({"id": session_id, "entra_oid": entra_oid, "answers": message_pairs}) + except Exception as error: + return JSONResponse({"error": str(error)}, status_code=500) + + +@router.delete("/chat_history/sessions/{session_id}",tags=["ChatHistory"]) +async def delete_chat_history_session(request: Request, session_id: str, auth_claims: dict = Depends(get_auth_claims)): + cfg = getattr(request.app.state, "config", None) + if cfg is None: + raise HTTPException(status_code=503, detail="App not initialized") + if not cfg.get(CONFIG_CHAT_HISTORY_COSMOS_ENABLED): + return JSONResponse({"error": "Chat history not enabled"}, status_code=400) + container = cfg.get(CONFIG_COSMOS_HISTORY_CONTAINER) + if not container: + return JSONResponse({"error": "Chat history not enabled"}, status_code=400) + entra_oid = auth_claims.get("oid") + if not entra_oid: + return JSONResponse({"error": "User OID not found"}, status_code=401) + + try: + res = container.query_items( + query="SELECT c.id FROM c WHERE c.session_id = @session_id", + parameters=[dict(name="@session_id", value=session_id)], + partition_key=[entra_oid, session_id], + ) + + ids_to_delete = [] + async for page in res.by_page(): + async for item in page: + ids_to_delete.append(item["id"]) + + batch_operations = [("delete", (id,)) for id in ids_to_delete] + await container.execute_item_batch(batch_operations=batch_operations, partition_key=[entra_oid, session_id]) + return Response(status_code=204) + except Exception as error: + return JSONResponse({"error": str(error)}, status_code=500) diff --git a/app/backend/api/routes/content.py b/app/backend/api/routes/content.py new file mode 100644 index 00000000..8db1048a --- /dev/null +++ b/app/backend/api/routes/content.py @@ -0,0 +1,48 @@ +"""Content serving endpoints (files from blob storage).""" +import mimetypes +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import Response + +from config import CONFIG_GLOBAL_BLOB_MANAGER, CONFIG_USER_BLOB_MANAGER, CONFIG_USER_UPLOAD_ENABLED +from ..dependencies import require_path_auth + +router = APIRouter() + + +async def _require_path_dep(request: Request, path: str): + return await require_path_auth(path, request) + + +@router.get("/content/{path:path}",tags=["Content"]) +async def content_file(request: Request, path: str, auth_claims: dict = Depends(_require_path_dep)): + cfg = getattr(request.app.state, "config", None) + if cfg is None: + raise HTTPException(status_code=503, detail="App not initialized") + + if "#page=" in path: + path = path.split("#page=", 1)[0] + + global_blob: object = cfg.get(CONFIG_GLOBAL_BLOB_MANAGER) + if not global_blob: + raise HTTPException(status_code=503, detail="Global blob manager not configured") + + result = await global_blob.download_blob(path) + + if result is None and cfg.get(CONFIG_USER_UPLOAD_ENABLED): + user_oid = auth_claims.get("oid") + user_blob_manager = cfg.get(CONFIG_USER_BLOB_MANAGER) + if user_blob_manager and user_oid: + result = await user_blob_manager.download_blob(path, user_oid=user_oid) + + if not result: + raise HTTPException(status_code=404, detail="File not found") + + content, properties = result + if not properties or "content_settings" not in properties: + raise HTTPException(status_code=404, detail="File metadata missing") + + mime_type = properties["content_settings"].get("content_type") + if mime_type == "application/octet-stream": + mime_type = mimetypes.guess_type(path)[0] or "application/octet-stream" + + return Response(content, media_type=mime_type) diff --git a/app/backend/api/routes/experimentation.py b/app/backend/api/routes/experimentation.py new file mode 100644 index 00000000..e69de29b diff --git a/app/backend/api/routes/generation.py b/app/backend/api/routes/generation.py new file mode 100644 index 00000000..ce81a968 --- /dev/null +++ b/app/backend/api/routes/generation.py @@ -0,0 +1,70 @@ +"""Phase 3 orchestration routes: Generation & Compliance flow.""" +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import JSONResponse +from ..dependencies import get_auth_claims + +from PersonalizeAI.nodes.phase3_generation.ai_message_generator import ai_message_generator +from PersonalizeAI.nodes.phase3_generation.compliance_agent import compliance_agent +from PersonalizeAI.nodes.phase3_generation.rewrite_decision import rewrite_decision +from PersonalizeAI.nodes.phase3_generation.automated_rewrite import automated_rewrite + +router = APIRouter() + + +@router.post("/generation/run", tags=["Generation"]) +async def run_generation(request: Request, auth_claims: dict = Depends(get_auth_claims)): + cfg = getattr(request.app.state, "config", None) + if cfg is None: + raise HTTPException(status_code=503, detail="App not initialized") + + try: + payload = await request.json() + except Exception: + payload = {} + + state = payload.get("state", {}) or {} + + # Discover clients and helpers from app state / config + openai_client = cfg.get("openai_client") or cfg.get("openai") or None + prompt_manager = getattr(request.app.state, "prompty_manager", None) + approach = cfg.get("approach") or cfg.get("ask_approach") or None + + # Step 1: Generate message variants + gen_update = await ai_message_generator(state, openai_client=openai_client, prompt_manager=prompt_manager, approach=approach) + state.update(gen_update) + + # Step 2: Compliance loop + max_iterations = 3 + iteration = 0 + while True: + iteration += 1 + comp_update = await compliance_agent(state, openai_client=openai_client, prompt_manager=prompt_manager, approach=approach) + state.update(comp_update) + + route = rewrite_decision(state) + if route == "END_PHASE_3": + break + + if iteration >= max_iterations: + # Give up after several iterations and return current state + break + + # Perform automated rewrite for non-compliant variants and loop + rewrite_update = await automated_rewrite(state, openai_client=openai_client, prompt_manager=prompt_manager, approach=approach) + state.update(rewrite_update) + + return JSONResponse({"message_variants": state.get("message_variants"), "compliance_log": state.get("compliance_log", [])}) +from fastapi import APIRouter +from pydantic import BaseModel + +router = APIRouter() + +class GenerationRequest(BaseModel): + segment_id: str + goal: str + +@router.post("/create-campaign") +async def create_campaign(request: GenerationRequest): + # Trigger the full workflow: Retrieval -> Generation -> Safety + # Return generated message variants with citations + return {"variants": [], "safety_logs": []} \ No newline at end of file diff --git a/app/backend/api/routes/health.py b/app/backend/api/routes/health.py new file mode 100644 index 00000000..8563ad49 --- /dev/null +++ b/app/backend/api/routes/health.py @@ -0,0 +1,73 @@ +"""Health and config endpoints.""" +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import JSONResponse + +from config import ( + CONFIG_AGENTIC_KNOWLEDGEBASE_ENABLED, + CONFIG_DEFAULT_REASONING_EFFORT, + CONFIG_DEFAULT_RETRIEVAL_REASONING_EFFORT, + CONFIG_ECHOVOICE_SEARCH_IMAGE_EMBEDDINGS, + CONFIG_ECHOVOICE_SEARCH_TEXT_TARGETS, + CONFIG_ECHOVOICE_SEND_IMAGE_SOURCES, + CONFIG_ECHOVOICE_SEND_TEXT_SOURCES, + CONFIG_CHAT_HISTORY_BROWSER_ENABLED, + CONFIG_CHAT_HISTORY_COSMOS_ENABLED, + CONFIG_LANGUAGE_PICKER_ENABLED, + CONFIG_MULTIMODAL_ENABLED, + CONFIG_QUERY_REWRITING_ENABLED, + CONFIG_REASONING_EFFORT_ENABLED, + CONFIG_SEMANTIC_RANKER_DEPLOYED, + CONFIG_SPEECH_INPUT_ENABLED, + CONFIG_SPEECH_OUTPUT_AZURE_ENABLED, + CONFIG_SPEECH_OUTPUT_BROWSER_ENABLED, + CONFIG_STREAMING_ENABLED, + CONFIG_USER_UPLOAD_ENABLED, + CONFIG_VECTOR_SEARCH_ENABLED, + CONFIG_WEB_SOURCE_ENABLED, + CONFIG_SHAREPOINT_SOURCE_ENABLED, + CONFIG_ECHOVOICE_SEARCH_IMAGE_EMBEDDINGS, + CONFIG_ECHOVOICE_SEND_TEXT_SOURCES, +) + +router = APIRouter() + + +@router.get("/health",tags=["Health"]) +async def health(): + return JSONResponse({"status": "ok"}) + + +@router.get("/config",tags=["Health"]) +async def config(request: Request): + cfg = getattr(request.app.state, "config", None) + if cfg is None: + raise HTTPException(status_code=503, detail="App not initialized") + + return JSONResponse({ + "showMultimodalOptions": cfg.get(CONFIG_MULTIMODAL_ENABLED), + "showSemanticRankerOption": cfg.get(CONFIG_SEMANTIC_RANKER_DEPLOYED), + "showQueryRewritingOption": cfg.get(CONFIG_QUERY_REWRITING_ENABLED), + "showReasoningEffortOption": cfg.get(CONFIG_REASONING_EFFORT_ENABLED), + "streamingEnabled": cfg.get(CONFIG_STREAMING_ENABLED), + "defaultReasoningEffort": cfg.get(CONFIG_DEFAULT_REASONING_EFFORT), + "defaultRetrievalReasoningEffort": cfg.get(CONFIG_DEFAULT_RETRIEVAL_REASONING_EFFORT), + "showVectorOption": cfg.get(CONFIG_VECTOR_SEARCH_ENABLED), + "showUserUpload": cfg.get(CONFIG_USER_UPLOAD_ENABLED), + "showLanguagePicker": cfg.get(CONFIG_LANGUAGE_PICKER_ENABLED), + "showSpeechInput": cfg.get(CONFIG_SPEECH_INPUT_ENABLED), + "showSpeechOutputBrowser": cfg.get(CONFIG_SPEECH_OUTPUT_BROWSER_ENABLED), + "showSpeechOutputAzure": cfg.get(CONFIG_SPEECH_OUTPUT_AZURE_ENABLED), + "showChatHistoryBrowser": cfg.get(CONFIG_CHAT_HISTORY_BROWSER_ENABLED), + "showChatHistoryCosmos": cfg.get(CONFIG_CHAT_HISTORY_COSMOS_ENABLED), + "showAgenticRetrievalOption": cfg.get(CONFIG_AGENTIC_KNOWLEDGEBASE_ENABLED), + "textTargetsSearchEnabled": cfg.get(CONFIG_ECHOVOICE_SEARCH_TEXT_TARGETS), + "imageSearchEmbeddingsEnabled": cfg.get(CONFIG_ECHOVOICE_SEARCH_IMAGE_EMBEDDINGS), + "textTargetsSendSources": cfg.get(CONFIG_ECHOVOICE_SEND_TEXT_SOURCES), + "imageSendSources": cfg.get(CONFIG_ECHOVOICE_SEND_IMAGE_SOURCES), + "ragSearchTextEmbeddings": cfg.get(CONFIG_ECHOVOICE_SEARCH_TEXT_TARGETS), + "ragSearchImageEmbeddings": cfg.get(CONFIG_ECHOVOICE_SEARCH_IMAGE_EMBEDDINGS), + "ragSendTextSources": cfg.get(CONFIG_ECHOVOICE_SEND_TEXT_SOURCES), + "ragSendImageSources": cfg.get(CONFIG_ECHOVOICE_SEND_IMAGE_SOURCES), + "webSourceEnabled": cfg.get(CONFIG_WEB_SOURCE_ENABLED), + "sharepointSourceEnabled": cfg.get(CONFIG_SHAREPOINT_SOURCE_ENABLED), + }) diff --git a/app/backend/api/routes/retrieval.py b/app/backend/api/routes/retrieval.py new file mode 100644 index 00000000..ad1b5cb9 --- /dev/null +++ b/app/backend/api/routes/retrieval.py @@ -0,0 +1,116 @@ +from fastapi import APIRouter, Request +from pydantic import BaseModel +from typing import Any, Dict + +from backend.config import CONFIG_ASK_APPROACH, CONFIG_SEARCH_CLIENT, CONFIG_OPENAI_CLIENT + +from PersonalizeAI.nodes.phase2_retrieval import ( + contextual_query_generator, + vector_search_retriever, + relevance_grader, + citation_formatter, + self_correction, +) + +router = APIRouter() + + +class Phase2Request(BaseModel): + campaign_goal: str + segment_description: str + + +@router.post("/retrieval/run", tags=["Retrieval"]) +async def run_retrieval(request: Request, body: Phase2Request) -> Dict[str, Any]: + """Orchestrate Phase 2 retrieval using services from the running FastAPI app. + + Strategy: + - Prefer using the configured `CONFIG_ASK_APPROACH` if present (it exposes + embedding + search helpers). + - Fallback to the simple node implementations in `PersonalizeAI.nodes.phase2_retrieval`. + """ + app_cfg = request.app.state.config + approach = app_cfg.get(CONFIG_ASK_APPROACH) + + # Initialize GraphState-like dict + state: Dict[str, Any] = { + "campaign_goal": body.campaign_goal, + "segment_description": body.segment_description, + } + + # 1) Generate context query + cq_update = contextual_query_generator.contextual_query_generator(state) + state.update(cq_update) + + # 2) Retrieve content (prefer approach utilities) + if approach is not None: + # Use approach to compute embedding and run search so it respects configuration + try: + vec_query = await approach.compute_text_embedding(state["context_query"]) + docs = await approach.search( + top=5, + query_text=state["context_query"], + filter=None, + vectors=[vec_query], + use_text_search=False, + use_vector_search=True, + use_semantic_ranker=False, + use_semantic_captions=False, + ) + # Map Document dataclass to simple retrieved_content shape + retrieved = [] + for d in docs: + retrieved.append({"text": d.content or "", "source_id": d.sourcepage or d.id or ""}) + state["retrieved_content"] = retrieved + except Exception: + # Fallback to simulated retriever + state.update(vector_search_retriever.vector_search_retriever(state)) + else: + # No approach configured; use local simulated retriever node + state.update(vector_search_retriever.vector_search_retriever(state)) + + # 3) Relevance grading and optional self-correction loop + next_node = relevance_grader.relevance_grader(state) + attempts = 0 + while next_node == "SELF_CORRECTION" and attempts < 3: + attempts += 1 + # Run self-correction using the OpenAI client if available + openai_client = request.app.state.config.get(CONFIG_OPENAI_CLIENT) + sc_update = {} + try: + prompt_manager = request.app.state.config.get("PROMPT_MANAGER") + sc_update = await self_correction.self_correction(state, openai_client, prompt_manager=prompt_manager, approach=approach) + except Exception: + # Ensure we always have a conservative fallback + sc_update = {"context_query": (state.get("context_query", "") + " detailed product facts")} + + # Merge returned updates (including audit) + state.update(sc_update) + + # Re-run retriever using approach if available + if approach is not None: + try: + vec_query = await approach.compute_text_embedding(state["context_query"]) + docs = await approach.search( + top=5, + query_text=state["context_query"], + filter=None, + vectors=[vec_query], + use_text_search=False, + use_vector_search=True, + use_semantic_ranker=False, + use_semantic_captions=False, + ) + retrieved = [{"text": d.content or "", "source_id": d.sourcepage or d.id or ""} for d in docs] + state["retrieved_content"] = retrieved + except Exception: + state.update(vector_search_retriever.vector_search_retriever(state)) + else: + state.update(vector_search_retriever.vector_search_retriever(state)) + + next_node = relevance_grader.relevance_grader(state) + + # 4) Citation formatting (finalize) + signal = citation_formatter.citation_formatter(state) + + return {"status": "success", "phase": "phase2", "signal": signal, "state": state} diff --git a/app/backend/api/routes/segmentation.py b/app/backend/api/routes/segmentation.py new file mode 100644 index 00000000..0b7f6faa --- /dev/null +++ b/app/backend/api/routes/segmentation.py @@ -0,0 +1,77 @@ +from fastapi import APIRouter +from pydantic import BaseModel +import sys +from pathlib import Path +from typing import Any + +# Ensure the repository root is on sys.path so PersonalizeAI is importable when +# the backend runs from `app/backend` working directory. +ROOT = Path(__file__).resolve().parents[4] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from PersonalizeAI.state import GraphState # type: ignore +from PersonalizeAI.nodes.phase1_segmentation import goal_router as goal_router_module # type: ignore + +router = APIRouter() + + +class Phase1Request(BaseModel): + campaign_goal: str + user_message: str + + +@router.post("/segmentor/run", tags=["Segmentation"]) +async def run_segmentation(request: Phase1Request) -> Any: + """Run a simple Phase 1 segmentation flow using the PersonalizeAI nodes. + + The flow: + - create initial GraphState with inputs + - call goal_router to pick a segmentation node + - call the chosen segmenter node + - call priority_output to finalize the selection + - return final_segment, confidence, description + """ + # Initialize minimal GraphState + state: GraphState = { + "campaign_goal": request.campaign_goal, + "user_message": request.user_message, + } + + # Determine which segmenter to run + next_node = goal_router_module.goal_router(state) + + # Map node ids to implementation modules + node_map = { + "RFM_SEGMENTATION": "PersonalizeAI.nodes.phase1_segmentation.rfm_segmenter", + "INTENT_SEGMENTATION": "PersonalizeAI.nodes.phase1_segmentation.intent_segmenter", + "BEHAVIORAL_SEGMENTATION": "PersonalizeAI.nodes.phase1_segmentation.behavioral_segmenter", + "PROFILE_SEGMENTATION": "PersonalizeAI.nodes.phase1_segmentation.profile_segmenter", + } + + segment_module_name = node_map.get(next_node) + if not segment_module_name: + return {"status": "error", "message": "No segmenter found for node: %s" % next_node} + + # Import and run the segmenter + try: + seg_mod = __import__(segment_module_name, fromlist=["*"]) + state = seg_mod.run(state) # each module exposes run(state) + except Exception as exc: # pragma: no cover - simple runtime guard + return {"status": "error", "message": f"Segmenter failed: {exc}"} + + # Run priority_output to choose final segment (best-effort) + try: + prio_mod = __import__("PersonalizeAI.nodes.phase1_segmentation.priority_output", fromlist=["*"]) + state = prio_mod.run(state) + except Exception: + # If priority_output is missing or errors, continue with whatever state has + pass + + return { + "status": "success", + "final_segment": state.get("final_segment"), + "confidence": state.get("confidence"), + "segment_description": state.get("segment_description"), + "raw_state": state, + } \ No newline at end of file diff --git a/app/backend/api/routes/uploads.py b/app/backend/api/routes/uploads.py new file mode 100644 index 00000000..f043ea1c --- /dev/null +++ b/app/backend/api/routes/uploads.py @@ -0,0 +1,88 @@ +"""User upload endpoints.""" +import io +from fastapi import APIRouter, Depends, HTTPException, File, Request, UploadFile +from fastapi.responses import JSONResponse + +from config import CONFIG_INGESTER, CONFIG_USER_BLOB_MANAGER, CONFIG_USER_UPLOAD_ENABLED +from prepdocslib.listfilestrategy import File as PrepFile +from ..dependencies import get_auth_claims + +router = APIRouter() + + +@router.post("/upload",tags=["Uploads"]) +async def upload(request: Request, file: UploadFile = File(...), auth_claims: dict = Depends(get_auth_claims)): + cfg = getattr(request.app.state, "config", None) + if cfg is None: + raise HTTPException(status_code=503, detail="App not initialized") + + if not cfg.get(CONFIG_USER_UPLOAD_ENABLED): + raise HTTPException(status_code=403, detail="User uploads are not enabled") + + user_oid = auth_claims.get("oid") + if not user_oid: + raise HTTPException(status_code=403, detail="Missing user identity") + + adls_manager = cfg.get(CONFIG_USER_BLOB_MANAGER) + ingester = cfg.get(CONFIG_INGESTER) + if adls_manager is None or ingester is None: + raise HTTPException(status_code=503, detail="Upload backend not configured") + + try: + content = await file.read() + file_io = io.BytesIO(content) + setattr(file_io, "name", file.filename) + + file_url = await adls_manager.upload_blob(file_io, file.filename, user_oid) + + prep_file = PrepFile(content=io.BytesIO(content), acls={"oids": [user_oid]}, url=file_url) + await ingester.add_file(prep_file, user_oid=user_oid) + + return JSONResponse({"message": "File uploaded successfully"}) + except Exception as error: + return JSONResponse({"message": "Error uploading file, check server logs for details.", "error": str(error)}, status_code=500) + + +@router.post("/delete_uploaded",tags=["Uploads"]) +async def delete_uploaded(request: Request, auth_claims: dict = Depends(get_auth_claims)): + cfg = getattr(request.app.state, "config", None) + if cfg is None: + raise HTTPException(status_code=503, detail="App not initialized") + + request_json = await request.json() + filename = request_json.get("filename") + if not filename: + raise HTTPException(status_code=400, detail="Missing filename") + + user_oid = auth_claims.get("oid") + if not user_oid: + raise HTTPException(status_code=403, detail="Missing user identity") + + adls_manager = cfg.get(CONFIG_USER_BLOB_MANAGER) + ingester = cfg.get(CONFIG_INGESTER) + if adls_manager is None: + raise HTTPException(status_code=503, detail="User blob manager not configured") + + await adls_manager.remove_blob(filename, user_oid) + if ingester: + await ingester.remove_file(filename, user_oid) + + return JSONResponse({"message": f"File {filename} deleted successfully"}) + + +@router.get("/list_uploaded",tags=["Uploads"]) +async def list_uploaded(request: Request, auth_claims: dict = Depends(get_auth_claims)): + cfg = getattr(request.app.state, "config", None) + if cfg is None: + raise HTTPException(status_code=503, detail="App not initialized") + + user_oid = auth_claims.get("oid") + if not user_oid: + raise HTTPException(status_code=403, detail="Missing user identity") + + adls_manager = cfg.get(CONFIG_USER_BLOB_MANAGER) + if adls_manager is None: + raise HTTPException(status_code=503, detail="User blob manager not configured") + + files = await adls_manager.list_blobs(user_oid) + return JSONResponse(files) diff --git a/app/backend/api/routes/utils.py b/app/backend/api/routes/utils.py new file mode 100644 index 00000000..1ee28610 --- /dev/null +++ b/app/backend/api/routes/utils.py @@ -0,0 +1,29 @@ +"""Shared utilities used by route modules (JSON encoder, NDJSON streamer).""" +import json +from collections.abc import AsyncGenerator + + +class JSONEncoder(json.JSONEncoder): + """Custom JSON encoder for dataclasses and other types used by approaches.""" + + def default(self, o): + try: + from dataclasses import asdict, is_dataclass + + if is_dataclass(o) and not isinstance(o, type): + as_dict = asdict(o) + if isinstance(as_dict, dict): + return {k: v for k, v in as_dict.items() if v is not None} + return as_dict + except Exception: + pass + return super().default(o) + + +async def ndjson_bytes(generator: AsyncGenerator[dict, None]): + """Encode events from an async generator as NDJSON bytes.""" + try: + async for event in generator: + yield (json.dumps(event, ensure_ascii=False, cls=JSONEncoder) + "\n").encode("utf-8") + except Exception as exc: + yield (json.dumps({"error": str(exc)}) + "\n").encode("utf-8") diff --git a/app/backend/api/startup.py b/app/backend/api/startup.py new file mode 100644 index 00000000..660214eb --- /dev/null +++ b/app/backend/api/startup.py @@ -0,0 +1,513 @@ +"""Startup and shutdown handlers to initialize app resources (ported from Quart setup).""" +import logging +import mimetypes +import os +import time +from typing import Awaitable, Callable +import sys +from pathlib import Path + +# Ensure the repository root is on sys.path so local packages (e.g. PersonalizeAI) +# are importable regardless of the current working directory when the backend +# starts. `startup.py` is located at `app/backend/api/startup.py`, so parents[3] +# reaches the repository root. +REPO_ROOT = Path(__file__).resolve().parents[3] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from azure.cosmos.aio import CosmosClient +from azure.identity.aio import ( + AzureDeveloperCliCredential, + ManagedIdentityCredential, + get_bearer_token_provider, +) +from azure.monitor.opentelemetry import configure_azure_monitor +from azure.search.documents.aio import SearchClient +from azure.search.documents.indexes.aio import SearchIndexClient +from azure.search.documents.knowledgebases.aio import KnowledgeBaseRetrievalClient +from fastapi import FastAPI +from opentelemetry.instrumentation.aiohttp_client import AioHttpClientInstrumentor +from opentelemetry.instrumentation.asgi import OpenTelemetryMiddleware +from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor +from opentelemetry.instrumentation.openai import OpenAIInstrumentor + +from approaches.approach import Approach +from approaches.chatreadretrieveread import ChatReadRetrieveReadApproach +from approaches.promptmanager import PromptyManager +from approaches.retrievethenread import RetrieveThenReadApproach +from config import ( + CONFIG_AGENTIC_KNOWLEDGEBASE_ENABLED, + CONFIG_ASK_APPROACH, + CONFIG_AUTH_CLIENT, + CONFIG_CHAT_APPROACH, + CONFIG_CHAT_HISTORY_BROWSER_ENABLED, + CONFIG_CHAT_HISTORY_COSMOS_ENABLED, + CONFIG_COSMOS_HISTORY_CLIENT, + CONFIG_COSMOS_HISTORY_CONTAINER, + CONFIG_COSMOS_HISTORY_VERSION, + CONFIG_CREDENTIAL, + CONFIG_DEFAULT_REASONING_EFFORT, + CONFIG_DEFAULT_RETRIEVAL_REASONING_EFFORT, + CONFIG_ECHOVOICE_SEARCH_IMAGE_EMBEDDINGS, + CONFIG_ECHOVOICE_SEARCH_TEXT_TARGETS, + CONFIG_ECHOVOICE_SEND_IMAGE_SOURCES, + CONFIG_ECHOVOICE_SEND_TEXT_SOURCES, + CONFIG_GLOBAL_BLOB_MANAGER, + CONFIG_IMAGE_EMBEDDINGS_CLIENT, + CONFIG_INGESTER, + CONFIG_KNOWLEDGEBASE_CLIENT, + CONFIG_LANGUAGE_PICKER_ENABLED, + CONFIG_MULTIMODAL_ENABLED, + CONFIG_OPENAI_CLIENT, + CONFIG_QUERY_REWRITING_ENABLED, + CONFIG_REASONING_EFFORT_ENABLED, + CONFIG_SEARCH_CLIENT, + CONFIG_SEMANTIC_RANKER_DEPLOYED, + CONFIG_SHAREPOINT_SOURCE_ENABLED, + CONFIG_SPEECH_INPUT_ENABLED, + CONFIG_SPEECH_OUTPUT_AZURE_ENABLED, + CONFIG_SPEECH_OUTPUT_BROWSER_ENABLED, + CONFIG_STREAMING_ENABLED, + CONFIG_USER_BLOB_MANAGER, + CONFIG_USER_UPLOAD_ENABLED, + CONFIG_VECTOR_SEARCH_ENABLED, + CONFIG_WEB_SOURCE_ENABLED, +) +from services.prepdocs import ( + OpenAIHost, + setup_embeddings_service, + setup_file_processors, + setup_image_embeddings_service, + setup_openai_client, + setup_search_info, +) +from prepdocslib.blobmanager import AdlsBlobManager, BlobManager +from prepdocslib.embeddings import ImageEmbeddings +from prepdocslib.filestrategy import UploadUserFileStrategy + +# from .. import app as legacy_app_module # keep reference if needed + + +def register(app: FastAPI) -> None: + """Register startup and shutdown handlers on the FastAPI app.""" + + @app.on_event("startup") + async def _startup() -> None: + await setup_clients(app) + + @app.on_event("shutdown") + async def _shutdown() -> None: + await close_clients(app) + + +async def setup_clients(app: FastAPI) -> None: + """Port of the Quart `setup_clients()` into FastAPI app.state. + + This initializes Azure credentials, search clients, blob managers, OpenAI client, and + config flags — mirroring the original Quart behavior and storing values in `app.state.config`. + """ + # Fix Windows registry issue with mimetypes + mimetypes.add_type("application/javascript", ".js") + mimetypes.add_type("text/css", ".css") + + # Create a config mapping on app.state similar to current_app.config + app.state.config = {} + + # Load environment values (these mirror values set in the original Quart setup) + AZURE_STORAGE_ACCOUNT = os.environ.get("AZURE_STORAGE_ACCOUNT", "") + AZURE_STORAGE_CONTAINER = os.environ.get("AZURE_STORAGE_CONTAINER", "") + AZURE_IMAGESTORAGE_CONTAINER = os.environ.get("AZURE_IMAGESTORAGE_CONTAINER") + AZURE_USERSTORAGE_ACCOUNT = os.environ.get("AZURE_USERSTORAGE_ACCOUNT") + AZURE_USERSTORAGE_CONTAINER = os.environ.get("AZURE_USERSTORAGE_CONTAINER") + AZURE_SEARCH_SERVICE = os.environ.get("AZURE_SEARCH_SERVICE", "") + AZURE_SEARCH_ENDPOINT = f"https://{AZURE_SEARCH_SERVICE}.search.windows.net" if AZURE_SEARCH_SERVICE else "" + AZURE_SEARCH_INDEX = os.environ.get("AZURE_SEARCH_INDEX", "") + AZURE_SEARCH_KNOWLEDGEBASE_NAME = os.getenv("AZURE_SEARCH_KNOWLEDGEBASE_NAME", "") + + OPENAI_HOST = OpenAIHost(os.getenv("OPENAI_HOST", "azure")) + OPENAI_CHATGPT_MODEL = os.environ.get("AZURE_OPENAI_CHATGPT_MODEL", "") + AZURE_OPENAI_KNOWLEDGEBASE_MODEL = os.getenv("AZURE_OPENAI_KNOWLEDGEBASE_MODEL") + AZURE_OPENAI_KNOWLEDGEBASE_DEPLOYMENT = os.getenv("AZURE_OPENAI_KNOWLEDGEBASE_DEPLOYMENT") + OPENAI_EMB_MODEL = os.getenv("AZURE_OPENAI_EMB_MODEL_NAME", "text-embedding-ada-002") + OPENAI_EMB_DIMENSIONS = int(os.getenv("AZURE_OPENAI_EMB_DIMENSIONS") or 1536) + OPENAI_REASONING_EFFORT = os.getenv("AZURE_OPENAI_REASONING_EFFORT") + + AZURE_OPENAI_SERVICE = os.getenv("AZURE_OPENAI_SERVICE") + AZURE_OPENAI_CHATGPT_DEPLOYMENT = ( + os.getenv("AZURE_OPENAI_CHATGPT_DEPLOYMENT") if OPENAI_HOST in [OpenAIHost.AZURE, OpenAIHost.AZURE_CUSTOM] else None + ) + AZURE_OPENAI_EMB_DEPLOYMENT = ( + os.getenv("AZURE_OPENAI_EMB_DEPLOYMENT") if OPENAI_HOST in [OpenAIHost.AZURE, OpenAIHost.AZURE_CUSTOM] else None + ) + AZURE_OPENAI_CUSTOM_URL = os.getenv("AZURE_OPENAI_CUSTOM_URL") + AZURE_VISION_ENDPOINT = os.getenv("AZURE_VISION_ENDPOINT", "") + AZURE_OPENAI_API_KEY_OVERRIDE = os.getenv("AZURE_OPENAI_API_KEY_OVERRIDE") + OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") + OPENAI_ORGANIZATION = os.getenv("OPENAI_ORGANIZATION") + + AZURE_TENANT_ID = os.getenv("AZURE_TENANT_ID") + AZURE_USE_AUTHENTICATION = os.getenv("AZURE_USE_AUTHENTICATION", "").lower() == "true" + AZURE_ENFORCE_ACCESS_CONTROL = os.getenv("AZURE_ENFORCE_ACCESS_CONTROL", "").lower() == "true" + AZURE_ENABLE_UNAUTHENTICATED_ACCESS = os.getenv("AZURE_ENABLE_UNAUTHENTICATED_ACCESS", "").lower() == "true" + AZURE_SERVER_APP_ID = os.getenv("AZURE_SERVER_APP_ID") + AZURE_SERVER_APP_SECRET = os.getenv("AZURE_SERVER_APP_SECRET") + AZURE_CLIENT_APP_ID = os.getenv("AZURE_CLIENT_APP_ID") + AZURE_AUTH_TENANT_ID = os.getenv("AZURE_AUTH_TENANT_ID", AZURE_TENANT_ID) + + KB_FIELDS_CONTENT = os.getenv("KB_FIELDS_CONTENT", "content") + KB_FIELDS_SOURCEPAGE = os.getenv("KB_FIELDS_SOURCEPAGE", "sourcepage") + + AZURE_SEARCH_QUERY_LANGUAGE = os.getenv("AZURE_SEARCH_QUERY_LANGUAGE") or "en-us" + AZURE_SEARCH_QUERY_SPELLER = os.getenv("AZURE_SEARCH_QUERY_SPELLER") or "lexicon" + AZURE_SEARCH_SEMANTIC_RANKER = os.getenv("AZURE_SEARCH_SEMANTIC_RANKER", "free").lower() + AZURE_SEARCH_QUERY_REWRITING = os.getenv("AZURE_SEARCH_QUERY_REWRITING", "false").lower() + AZURE_SEARCH_FIELD_NAME_EMBEDDING = os.getenv("AZURE_SEARCH_FIELD_NAME_EMBEDDING", "embedding") + + AZURE_SPEECH_SERVICE_ID = os.getenv("AZURE_SPEECH_SERVICE_ID") + AZURE_SPEECH_SERVICE_LOCATION = os.getenv("AZURE_SPEECH_SERVICE_LOCATION") + AZURE_SPEECH_SERVICE_VOICE = os.getenv("AZURE_SPEECH_SERVICE_VOICE") or "en-US-AndrewMultilingualNeural" + + USE_MULTIMODAL = os.getenv("USE_MULTIMODAL", "").lower() == "true" + ECHOVOICE_SEARCH_TEXT_TARGETS = os.getenv("ECHOVOICE_SEARCH_TEXT_TARGETS", "true").lower() == "true" + ECHOVOICE_SEARCH_IMAGE_EMBEDDINGS = os.getenv("ECHOVOICE_SEARCH_IMAGE_EMBEDDINGS", "true").lower() == "true" + ECHOVOICE_SEND_TEXT_SOURCES = os.getenv("ECHOVOICE_SEND_TEXT_SOURCES", "true").lower() == "true" + ECHOVOICE_SEND_IMAGE_SOURCES = os.getenv("ECHOVOICE_SEND_IMAGE_SOURCES", "true").lower() == "true" + USE_USER_UPLOAD = os.getenv("USE_USER_UPLOAD", "").lower() == "true" + ENABLE_LANGUAGE_PICKER = os.getenv("ENABLE_LANGUAGE_PICKER", "").lower() == "true" + USE_SPEECH_INPUT_BROWSER = os.getenv("USE_SPEECH_INPUT_BROWSER", "").lower() == "true" + USE_SPEECH_OUTPUT_BROWSER = os.getenv("USE_SPEECH_OUTPUT_BROWSER", "").lower() == "true" + USE_SPEECH_OUTPUT_AZURE = os.getenv("USE_SPEECH_OUTPUT_AZURE", "").lower() == "true" + USE_CHAT_HISTORY_BROWSER = os.getenv("USE_CHAT_HISTORY_BROWSER", "").lower() == "true" + USE_CHAT_HISTORY_COSMOS = os.getenv("USE_CHAT_HISTORY_COSMOS", "").lower() == "true" + USE_AGENTIC_KNOWLEDGEBASE = os.getenv("USE_AGENTIC_KNOWLEDGEBASE", "").lower() == "true" + USE_WEB_SOURCE = os.getenv("USE_WEB_SOURCE", "").lower() == "true" + USE_SHAREPOINT_SOURCE = os.getenv("USE_SHAREPOINT_SOURCE", "").lower() == "true" + AGENTIC_KNOWLEDGEBASE_REASONING_EFFORT = os.getenv("AGENTIC_KNOWLEDGEBASE_REASONING_EFFORT", "low") + USE_VECTORS = os.getenv("USE_VECTORS", "").lower() != "false" + + RUNNING_ON_AZURE = os.getenv("WEBSITE_HOSTNAME") is not None or os.getenv("RUNNING_IN_PRODUCTION") is not None + + # Initialize Azure credential + if RUNNING_ON_AZURE: + logging.getLogger("uvicorn").info("Setting up Azure credential using ManagedIdentityCredential") + if AZURE_CLIENT_ID := os.getenv("AZURE_CLIENT_ID"): + azure_credential = ManagedIdentityCredential(client_id=AZURE_CLIENT_ID) + else: + azure_credential = ManagedIdentityCredential() + elif AZURE_TENANT_ID: + logging.getLogger("uvicorn").info("Setting up Azure credential using AzureDeveloperCliCredential with tenant_id %s", AZURE_TENANT_ID) + azure_credential = AzureDeveloperCliCredential(tenant_id=AZURE_TENANT_ID, process_timeout=60) + else: + logging.getLogger("uvicorn").info("Setting up Azure credential using AzureDeveloperCliCredential for home tenant") + azure_credential = AzureDeveloperCliCredential(process_timeout=60) + + azure_ai_token_provider: Callable[[], Awaitable[str]] = get_bearer_token_provider( + azure_credential, "https://cognitiveservices.azure.com/.default" + ) + + # store credential + app.state.config[CONFIG_CREDENTIAL] = azure_credential + + # Setup search clients + search_client = None + if AZURE_SEARCH_ENDPOINT and AZURE_SEARCH_INDEX: + search_client = SearchClient(endpoint=AZURE_SEARCH_ENDPOINT, index_name=AZURE_SEARCH_INDEX, credential=azure_credential) + app.state.config[CONFIG_SEARCH_CLIENT] = search_client + + knowledgebase_client = None + if AZURE_SEARCH_ENDPOINT and AZURE_SEARCH_KNOWLEDGEBASE_NAME: + knowledgebase_client = KnowledgeBaseRetrievalClient(endpoint=AZURE_SEARCH_ENDPOINT, knowledge_base_name=AZURE_SEARCH_KNOWLEDGEBASE_NAME, credential=azure_credential) + app.state.config[CONFIG_KNOWLEDGEBASE_CLIENT] = knowledgebase_client + + # Set up authentication helper + search_index = None + if AZURE_USE_AUTHENTICATION and AZURE_SEARCH_ENDPOINT and AZURE_SEARCH_INDEX: + search_index_client = SearchIndexClient(endpoint=AZURE_SEARCH_ENDPOINT, credential=azure_credential) + try: + search_index = await search_index_client.get_index(AZURE_SEARCH_INDEX) + finally: + await search_index_client.close() + + from core.authentication import AuthenticationHelper + + auth_helper = AuthenticationHelper( + search_index=search_index, + use_authentication=AZURE_USE_AUTHENTICATION, + server_app_id=AZURE_SERVER_APP_ID, + server_app_secret=AZURE_SERVER_APP_SECRET, + client_app_id=AZURE_CLIENT_APP_ID, + tenant_id=AZURE_AUTH_TENANT_ID, + enforce_access_control=AZURE_ENFORCE_ACCESS_CONTROL, + enable_unauthenticated_access=AZURE_ENABLE_UNAUTHENTICATED_ACCESS, + ) + app.state.config[CONFIG_AUTH_CLIENT] = auth_helper + + # Global blob manager + global_blob_manager = None + if AZURE_STORAGE_ACCOUNT and AZURE_STORAGE_CONTAINER: + global_blob_manager = BlobManager( + endpoint=f"https://{AZURE_STORAGE_ACCOUNT}.blob.core.windows.net", + credential=azure_credential, + container=AZURE_STORAGE_CONTAINER, + image_container=AZURE_IMAGESTORAGE_CONTAINER, + ) + app.state.config[CONFIG_GLOBAL_BLOB_MANAGER] = global_blob_manager + + # Setup OpenAI client wrapper + # Initialize OpenAI client wrapper. In development environments some env vars + # (e.g. AZURE_OPENAI_SERVICE) may be intentionally missing — don't fail startup + # in that case. Log a warning and continue with a None client so the app can + # still run for local development. Production deployments should ensure these + # variables are set. + try: + openai_client, azure_openai_endpoint = setup_openai_client( + openai_host=OPENAI_HOST, + azure_credential=azure_credential, + azure_openai_service=AZURE_OPENAI_SERVICE, + azure_openai_custom_url=AZURE_OPENAI_CUSTOM_URL, + azure_openai_api_key=AZURE_OPENAI_API_KEY_OVERRIDE, + openai_api_key=OPENAI_API_KEY, + openai_organization=OPENAI_ORGANIZATION, + + ) + except Exception as exc: # broad catch so missing envs or misconfiguration don't crash startup + logging.getLogger("uvicorn").warning( + "OpenAI client initialization failed during startup: %s. Continuing without OpenAI client." + " Set proper env vars for full functionality.", + exc, + ) + openai_client = None + azure_openai_endpoint = None + + app.state.config[CONFIG_OPENAI_CLIENT] = openai_client + + # Optional user blob manager + ingester + if USE_USER_UPLOAD: + user_blob_manager = AdlsBlobManager( + endpoint=f"https://{AZURE_USERSTORAGE_ACCOUNT}.dfs.core.windows.net", + container=AZURE_USERSTORAGE_CONTAINER, + credential=azure_credential, + ) + app.state.config[CONFIG_USER_BLOB_MANAGER] = user_blob_manager + + file_processors, figure_processor = setup_file_processors( + azure_credential=azure_credential, + document_intelligence_service=os.getenv("AZURE_DOCUMENTINTELLIGENCE_SERVICE"), + local_pdf_parser=os.getenv("USE_LOCAL_PDF_PARSER", "").lower() == "true", + local_html_parser=os.getenv("USE_LOCAL_HTML_PARSER", "").lower() == "true", + use_content_understanding=os.getenv("USE_CONTENT_UNDERSTANDING", "").lower() == "true", + content_understanding_endpoint=os.getenv("AZURE_CONTENTUNDERSTANDING_ENDPOINT"), + use_multimodal=USE_MULTIMODAL, + openai_client=openai_client, + openai_model=OPENAI_CHATGPT_MODEL, + openai_deployment=AZURE_OPENAI_CHATGPT_DEPLOYMENT if OPENAI_HOST == OpenAIHost.AZURE else None, + ) + + search_info = setup_search_info( + search_service=AZURE_SEARCH_SERVICE, + index_name=AZURE_SEARCH_INDEX, + azure_credential=azure_credential, + use_agentic_knowledgebase=USE_AGENTIC_KNOWLEDGEBASE, + azure_openai_endpoint=azure_openai_endpoint, + knowledgebase_name=AZURE_SEARCH_KNOWLEDGEBASE_NAME, + azure_openai_knowledgebase_deployment=AZURE_OPENAI_KNOWLEDGEBASE_DEPLOYMENT, + azure_openai_knowledgebase_model=AZURE_OPENAI_KNOWLEDGEBASE_MODEL, + ) + + text_embeddings_service = None + if USE_VECTORS: + text_embeddings_service = setup_embeddings_service( + open_ai_client=openai_client, + openai_host=OPENAI_HOST, + emb_model_name=OPENAI_EMB_MODEL, + emb_model_dimensions=OPENAI_EMB_DIMENSIONS, + azure_openai_deployment=AZURE_OPENAI_EMB_DEPLOYMENT, + azure_openai_endpoint=azure_openai_endpoint, + ) + + image_embeddings_service = setup_image_embeddings_service( + azure_credential=azure_credential, vision_endpoint=AZURE_VISION_ENDPOINT, use_multimodal=USE_MULTIMODAL + ) + + ingester = UploadUserFileStrategy( + search_info=search_info, + file_processors=file_processors, + embeddings=text_embeddings_service, + image_embeddings=image_embeddings_service, + search_field_name_embedding=AZURE_SEARCH_FIELD_NAME_EMBEDDING, + blob_manager=user_blob_manager, + figure_processor=figure_processor, + ) + app.state.config[CONFIG_INGESTER] = ingester + + # Image embeddings client + if USE_MULTIMODAL: + image_embeddings_client = ImageEmbeddings(AZURE_VISION_ENDPOINT, azure_ai_token_provider) + else: + image_embeddings_client = None + app.state.config[CONFIG_IMAGE_EMBEDDINGS_CLIENT] = image_embeddings_client + + # Store feature flags and computed config values + app.state.config[CONFIG_SEMANTIC_RANKER_DEPLOYED] = AZURE_SEARCH_SEMANTIC_RANKER != "disabled" + app.state.config[CONFIG_QUERY_REWRITING_ENABLED] = ( + AZURE_SEARCH_QUERY_REWRITING == "true" and AZURE_SEARCH_SEMANTIC_RANKER != "disabled" + ) + app.state.config[CONFIG_DEFAULT_REASONING_EFFORT] = OPENAI_REASONING_EFFORT + app.state.config[CONFIG_DEFAULT_RETRIEVAL_REASONING_EFFORT] = AGENTIC_KNOWLEDGEBASE_REASONING_EFFORT + app.state.config[CONFIG_REASONING_EFFORT_ENABLED] = OPENAI_CHATGPT_MODEL in Approach.GPT_REASONING_MODELS + app.state.config[CONFIG_STREAMING_ENABLED] = ( + OPENAI_CHATGPT_MODEL not in Approach.GPT_REASONING_MODELS + or Approach.GPT_REASONING_MODELS[OPENAI_CHATGPT_MODEL].streaming + ) + app.state.config[CONFIG_VECTOR_SEARCH_ENABLED] = bool(USE_VECTORS) + app.state.config[CONFIG_USER_UPLOAD_ENABLED] = bool(USE_USER_UPLOAD) + app.state.config[CONFIG_LANGUAGE_PICKER_ENABLED] = ENABLE_LANGUAGE_PICKER + app.state.config[CONFIG_SPEECH_INPUT_ENABLED] = USE_SPEECH_INPUT_BROWSER + app.state.config[CONFIG_SPEECH_OUTPUT_BROWSER_ENABLED] = USE_SPEECH_OUTPUT_BROWSER + app.state.config[CONFIG_SPEECH_OUTPUT_AZURE_ENABLED] = USE_SPEECH_OUTPUT_AZURE + app.state.config[CONFIG_CHAT_HISTORY_BROWSER_ENABLED] = USE_CHAT_HISTORY_BROWSER + app.state.config[CONFIG_CHAT_HISTORY_COSMOS_ENABLED] = USE_CHAT_HISTORY_COSMOS + app.state.config[CONFIG_AGENTIC_KNOWLEDGEBASE_ENABLED] = USE_AGENTIC_KNOWLEDGEBASE + app.state.config[CONFIG_MULTIMODAL_ENABLED] = USE_MULTIMODAL + app.state.config[CONFIG_ECHOVOICE_SEARCH_TEXT_TARGETS] = ECHOVOICE_SEARCH_TEXT_TARGETS + app.state.config[CONFIG_ECHOVOICE_SEARCH_IMAGE_EMBEDDINGS] = ECHOVOICE_SEARCH_IMAGE_EMBEDDINGS + app.state.config[CONFIG_ECHOVOICE_SEND_TEXT_SOURCES] = ECHOVOICE_SEND_TEXT_SOURCES + app.state.config[CONFIG_ECHOVOICE_SEND_IMAGE_SOURCES] = ECHOVOICE_SEND_IMAGE_SOURCES + app.state.config[CONFIG_WEB_SOURCE_ENABLED] = USE_WEB_SOURCE + app.state.config[CONFIG_SHAREPOINT_SOURCE_ENABLED] = USE_SHAREPOINT_SOURCE + + # Prompt manager + app.state.config["PROMPT_MANAGER"] = PromptyManager() + + # Optional CosmosDB chat history setup + if USE_CHAT_HISTORY_COSMOS: + AZURE_COSMOSDB_ACCOUNT = os.getenv("AZURE_COSMOSDB_ACCOUNT") + AZURE_CHAT_HISTORY_DATABASE = os.getenv("AZURE_CHAT_HISTORY_DATABASE") + AZURE_CHAT_HISTORY_CONTAINER = os.getenv("AZURE_CHAT_HISTORY_CONTAINER") + if not AZURE_COSMOSDB_ACCOUNT or not AZURE_CHAT_HISTORY_DATABASE or not AZURE_CHAT_HISTORY_CONTAINER: + logging.getLogger("uvicorn").warning( + "USE_CHAT_HISTORY_COSMOS is true but AZURE_COSMOSDB_ACCOUNT/AZURE_CHAT_HISTORY_DATABASE/AZURE_CHAT_HISTORY_CONTAINER not set; skipping Cosmos setup" + ) + else: + cosmos_client = CosmosClient( + url=f"https://{AZURE_COSMOSDB_ACCOUNT}.documents.azure.com:443/", credential=azure_credential + ) + cosmos_db = cosmos_client.get_database_client(AZURE_CHAT_HISTORY_DATABASE) + cosmos_container = cosmos_db.get_container_client(AZURE_CHAT_HISTORY_CONTAINER) + + app.state.config[CONFIG_COSMOS_HISTORY_CLIENT] = cosmos_client + app.state.config[CONFIG_COSMOS_HISTORY_CONTAINER] = cosmos_container + app.state.config[CONFIG_COSMOS_HISTORY_VERSION] = os.getenv("AZURE_CHAT_HISTORY_VERSION") + + # Set up approaches similar to Quart app + app.state.config[CONFIG_ASK_APPROACH] = RetrieveThenReadApproach( + search_client=search_client, + search_index_name=AZURE_SEARCH_INDEX, + knowledgebase_model=AZURE_OPENAI_KNOWLEDGEBASE_MODEL, + knowledgebase_deployment=AZURE_OPENAI_KNOWLEDGEBASE_DEPLOYMENT, + knowledgebase_client=knowledgebase_client, + knowledgebase_client_with_web=None, + knowledgebase_client_with_sharepoint=None, + knowledgebase_client_with_web_and_sharepoint=None, + openai_client=openai_client, + chatgpt_model=OPENAI_CHATGPT_MODEL, + chatgpt_deployment=AZURE_OPENAI_CHATGPT_DEPLOYMENT, + embedding_model=OPENAI_EMB_MODEL, + embedding_deployment=AZURE_OPENAI_EMB_DEPLOYMENT, + embedding_dimensions=OPENAI_EMB_DIMENSIONS, + embedding_field=AZURE_SEARCH_FIELD_NAME_EMBEDDING, + sourcepage_field=KB_FIELDS_SOURCEPAGE, + content_field=KB_FIELDS_CONTENT, + query_language=AZURE_SEARCH_QUERY_LANGUAGE, + query_speller=AZURE_SEARCH_QUERY_SPELLER, + prompt_manager=app.state.config["PROMPT_MANAGER"], + reasoning_effort=OPENAI_REASONING_EFFORT, + multimodal_enabled=USE_MULTIMODAL, + image_embeddings_client=image_embeddings_client, + global_blob_manager=global_blob_manager, + user_blob_manager=app.state.config.get(CONFIG_USER_BLOB_MANAGER), + use_web_source=app.state.config.get(CONFIG_WEB_SOURCE_ENABLED, False), + use_sharepoint_source=app.state.config.get(CONFIG_SHAREPOINT_SOURCE_ENABLED, False), + retrieval_reasoning_effort=AGENTIC_KNOWLEDGEBASE_REASONING_EFFORT, + ) + + app.state.config[CONFIG_CHAT_APPROACH] = ChatReadRetrieveReadApproach( + search_client=search_client, + search_index_name=AZURE_SEARCH_INDEX, + knowledgebase_model=AZURE_OPENAI_KNOWLEDGEBASE_MODEL, + knowledgebase_deployment=AZURE_OPENAI_KNOWLEDGEBASE_DEPLOYMENT, + knowledgebase_client=knowledgebase_client, + knowledgebase_client_with_web=None, + knowledgebase_client_with_sharepoint=None, + knowledgebase_client_with_web_and_sharepoint=None, + openai_client=openai_client, + chatgpt_model=OPENAI_CHATGPT_MODEL, + chatgpt_deployment=AZURE_OPENAI_CHATGPT_DEPLOYMENT, + embedding_model=OPENAI_EMB_MODEL, + embedding_deployment=AZURE_OPENAI_EMB_DEPLOYMENT, + embedding_dimensions=OPENAI_EMB_DIMENSIONS, + embedding_field=AZURE_SEARCH_FIELD_NAME_EMBEDDING, + sourcepage_field=KB_FIELDS_SOURCEPAGE, + content_field=KB_FIELDS_CONTENT, + query_language=AZURE_SEARCH_QUERY_LANGUAGE, + query_speller=AZURE_SEARCH_QUERY_SPELLER, + prompt_manager=app.state.config["PROMPT_MANAGER"], + reasoning_effort=OPENAI_REASONING_EFFORT, + multimodal_enabled=USE_MULTIMODAL, + image_embeddings_client=image_embeddings_client, + global_blob_manager=global_blob_manager, + user_blob_manager=app.state.config.get(CONFIG_USER_BLOB_MANAGER), + use_web_source=app.state.config.get(CONFIG_WEB_SOURCE_ENABLED, False), + use_sharepoint_source=app.state.config.get(CONFIG_SHAREPOINT_SOURCE_ENABLED, False), + retrieval_reasoning_effort=AGENTIC_KNOWLEDGEBASE_REASONING_EFFORT, + ) + + # Instrumentation and telemetry if Application Insights configured + if os.getenv("APPLICATIONINSIGHTS_CONNECTION_STRING"): + logging.getLogger("uvicorn").info("APPLICATIONINSIGHTS_CONNECTION_STRING is set, enabling Azure Monitor") + configure_azure_monitor( + instrumentation_options={ + "django": {"enabled": False}, + "psycopg2": {"enabled": False}, + "fastapi": {"enabled": False}, + } + ) + AioHttpClientInstrumentor().instrument() + HTTPXClientInstrumentor().instrument() + OpenAIInstrumentor().instrument() + # Wrap app with OpenTelemetry middleware + app.add_middleware(OpenTelemetryMiddleware) # type: ignore[arg-type] + + +async def close_clients(app: FastAPI) -> None: + """Close persistent clients on shutdown.""" + cfg = getattr(app.state, "config", {}) + try: + if search_client := cfg.get(CONFIG_SEARCH_CLIENT): + await search_client.close() + except Exception: + logging.exception("Exception while closing search client") + + try: + if global_blob := cfg.get(CONFIG_GLOBAL_BLOB_MANAGER): + await global_blob.close_clients() + except Exception: + logging.exception("Exception while closing global blob manager") + + try: + if user_blob := cfg.get(CONFIG_USER_BLOB_MANAGER): + await user_blob.close_clients() + except Exception: + logging.exception("Exception while closing user blob manager") + + try: + if cred := cfg.get(CONFIG_CREDENTIAL): + await cred.close() + except Exception: + logging.exception("Exception while closing credential") + + try: + if cosmos_client := cfg.get(CONFIG_COSMOS_HISTORY_CLIENT): + await cosmos_client.close() + except Exception: + logging.exception("Exception while closing cosmos client") diff --git a/app/backend/approaches/__init__.py b/app/backend/approaches/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/backend/approaches/approach.py b/app/backend/approaches/approach.py new file mode 100644 index 00000000..8b3c9c2e --- /dev/null +++ b/app/backend/approaches/approach.py @@ -0,0 +1,1040 @@ +"""Approach base class and related data structures for handling search and OpenAI interactions.""" +import base64 +import json +import re +from abc import ABC +from collections.abc import AsyncGenerator, Awaitable +from dataclasses import asdict, dataclass, field +from typing import Any, TypedDict, cast + +from azure.search.documents.aio import SearchClient +from azure.search.documents.knowledgebases.aio import KnowledgeBaseRetrievalClient +from azure.search.documents.knowledgebases.models import ( + KnowledgeBaseMessage, + KnowledgeBaseMessageTextContent, + KnowledgeBaseRemoteSharePointActivityRecord, + KnowledgeBaseRemoteSharePointReference, + KnowledgeBaseRetrievalRequest, + KnowledgeBaseRetrievalResponse, + KnowledgeBaseSearchIndexActivityRecord, + KnowledgeBaseSearchIndexReference, + KnowledgeBaseWebActivityRecord, + KnowledgeBaseWebReference, + KnowledgeRetrievalLowReasoningEffort, + KnowledgeRetrievalMediumReasoningEffort, + KnowledgeRetrievalMinimalReasoningEffort, + KnowledgeRetrievalSemanticIntent, + KnowledgeSourceParams, + RemoteSharePointKnowledgeSourceParams, + SearchIndexKnowledgeSourceParams, + WebKnowledgeSourceParams, +) +from azure.search.documents.models import ( + QueryCaptionResult, + QueryType, + VectorizedQuery, + VectorQuery, +) +from openai import AsyncOpenAI, AsyncStream +from openai.types import CompletionUsage +from openai.types.chat import ( + ChatCompletion, + ChatCompletionChunk, + ChatCompletionMessageParam, + ChatCompletionReasoningEffort, + ChatCompletionToolParam, +) + +from approaches.promptmanager import PromptManager +from prepdocslib.blobmanager import AdlsBlobManager, BlobManager +from prepdocslib.embeddings import ImageEmbeddings + + +@dataclass +class ActivityDetail: + """Activity detail for a retrieved document.""" + id: int + number: int + type: str + source: str + query: str + + +@dataclass +class Document: + """Document result from search index.""" + id: str | None = None + ref_id: str | None = None # Reference id from agentic retrieval (if applicable) + content: str | None = None + category: str | None = None + sourcepage: str | None = None + sourcefile: str | None = None + oids: list[str] | None = None + groups: list[str] | None = None + captions: list[QueryCaptionResult] | None = None + score: float | None = None + reranker_score: float | None = None + activity: ActivityDetail | None = None + images: list[dict[str, Any]] | None = None + + def serialize_for_results(self) -> dict[str, Any]: + """Serialize the document for results display.""" + result_dict = { + "type": "searchIndex", + "id": self.id, + "content": self.content, + "category": self.category, + "sourcepage": self.sourcepage, + "sourcefile": self.sourcefile, + "oids": self.oids, + "groups": self.groups, + "captions": ( + [ + { + "additional_properties": caption.additional_properties, + "text": caption.text, + "highlights": caption.highlights, + } + for caption in self.captions + ] + if self.captions + else [] + ), + "score": self.score, + "reranker_score": self.reranker_score, + "activity": asdict(self.activity) if self.activity else None, + "images": self.images, + } + return result_dict + + +@dataclass +class WebResult: + """Result from web remote knowledge source.""" + id: str | None = None + title: str | None = None + url: str | None = None + activity: ActivityDetail | None = None + + def serialize_for_results(self) -> dict[str, Any]: + """Results from web remote knowledge source.""" + return { + "type": "web", + "id": self.id, + "ref_id": str(self.id), + "title": self.title, + "url": self.url, + "activity": asdict(self.activity) if self.activity else None, + } + + +@dataclass +class SharePointResult: + """Result from SharePoint remote knowledge source.""" + id: str | None = None + web_url: str | None = None + content: str | None = None + title: str | None = None + reranker_score: float | None = None + activity: ActivityDetail | None = None + + def serialize_for_results(self) -> dict[str, Any]: + """Results from SharePoint remote knowledge source.""" + return { + "type": "remoteSharePoint", + "id": self.id, + "ref_id": str(self.id), + "web_url": self.web_url, + "content": self.content, + "title": self.title, + "reranker_score": self.reranker_score, + "activity": asdict(self.activity) if self.activity else None, + } + + +@dataclass +class RewriteQueryResult: + """Result from rewriting a query.""" + query: str + messages: list[ChatCompletionMessageParam] + completion: ChatCompletion + reasoning_effort: ChatCompletionReasoningEffort + + +@dataclass +class ThoughtStep: + """A single thought step in an agentic approach.""" + title: str + description: Any | None + props: dict[str, Any] | None = None + + def update_token_usage(self, usage: CompletionUsage) -> None: + """Update the token usage in the thought step's props.""" + if self.props: + self.props["token_usage"] = TokenUsageProps.from_completion_usage(usage) + + +@dataclass +class AgenticRetrievalResults: + """Results from agentic retrieval including activities, documents, web results, SharePoint results, and optional answer.""" + + response: KnowledgeBaseRetrievalResponse + documents: list[Document] + web_results: list[WebResult] + sharepoint_results: list[SharePointResult] = field(default_factory=list) + answer: str | None = None # Synthesized answer when web knowledge source is used + rewrite_result: RewriteQueryResult | None = None + activity_details_by_id: dict[int, ActivityDetail] | None = None + thoughts: list[ThoughtStep] = field(default_factory=list) + + +@dataclass +class DataPoints: + """Data points including text, images, citations, external results metadata, and citation activity details.""" + text: list[str] | None = None + images: list | None = None + citations: list[str] | None = None + external_results_metadata: list[dict[str, Any]] | None = None + citation_activity_details: dict[str, dict[str, Any]] | None = None + + +@dataclass +class ExtraInfo: + """Extra information including data points, thoughts, follow-up questions, and answer.""" + data_points: DataPoints + thoughts: list[ThoughtStep] = field(default_factory=list) + followup_questions: list[Any] | None = None + answer: str | None = None # Only when web knowledge source is used + + +@dataclass +class TokenUsageProps: + """Token usage properties including prompt, completion, reasoning, and total tokens.""" + prompt_tokens: int + completion_tokens: int + reasoning_tokens: int | None + total_tokens: int + + @classmethod + def from_completion_usage(cls, usage: CompletionUsage) -> "TokenUsageProps": + """Create TokenUsageProps from CompletionUsage.""" + return cls( + prompt_tokens=usage.prompt_tokens, + completion_tokens=usage.completion_tokens, + reasoning_tokens=( + usage.completion_tokens_details.reasoning_tokens if usage.completion_tokens_details else None + ), + total_tokens=usage.total_tokens, + ) + + +# GPT reasoning models don't support the same set of parameters as other models +# https://learn.microsoft.com/azure/ai-services/openai/how-to/reasoning +@dataclass +class GPTReasoningModelSupport: + """Support details for GPT reasoning models including streaming and minimal effort capabilities.""" + streaming: bool + minimal_effort: bool + + +class Approach(ABC): + """Base class for different approaches to handle search and OpenAI interactions.""" + # List of GPT reasoning models support + GPT_REASONING_MODELS = { + "o1": GPTReasoningModelSupport(streaming=False, minimal_effort=False), + "o3": GPTReasoningModelSupport(streaming=True, minimal_effort=False), + "o3-mini": GPTReasoningModelSupport(streaming=True, minimal_effort=False), + "o4-mini": GPTReasoningModelSupport(streaming=True, minimal_effort=False), + "gpt-5": GPTReasoningModelSupport(streaming=True, minimal_effort=True), + "gpt-5-nano": GPTReasoningModelSupport(streaming=True, minimal_effort=True), + "gpt-5-mini": GPTReasoningModelSupport(streaming=True, minimal_effort=True), + } + # Set a higher token limit for GPT reasoning models + RESPONSE_DEFAULT_TOKEN_LIMIT = 1024 + RESPONSE_REASONING_DEFAULT_TOKEN_LIMIT = 8192 + QUERY_REWRITE_NO_RESPONSE = "0" + + def __init__( + self, + search_client: SearchClient, + openai_client: AsyncOpenAI, + knowledgebase_model: str |None, + knowledgebase_deployment: str |None, + query_language: str |None, + query_speller: str |None, + embedding_deployment: str |None, # Not needed for non-Azure OpenAI or for retrieval_mode="text" + embedding_model: str, + embedding_dimensions: int, + embedding_field: str, + openai_host: str, + chatgpt_model: str, + chatgpt_deployment: str |None, # Not needed for non-Azure OpenAI + prompt_manager: PromptManager, + reasoning_effort: str | None = None, + multimodal_enabled: bool = False, + image_embeddings_client: ImageEmbeddings | None = None, + global_blob_manager: BlobManager | None = None, + user_blob_manager: AdlsBlobManager | None = None, + ): + """Initialize the approach with necessary clients and configurations.""" + self.search_client = search_client + self.openai_client = openai_client + self.query_language = query_language + self.query_speller = query_speller + self.knowledgebase_model = knowledgebase_model + self.knowledgebase_deployment = knowledgebase_deployment + self.embedding_deployment = embedding_deployment + self.embedding_model = embedding_model + self.embedding_dimensions = embedding_dimensions + self.embedding_field = embedding_field + self.openai_host = openai_host + self.chatgpt_model = chatgpt_model + self.chatgpt_deployment = chatgpt_deployment + self.prompt_manager = prompt_manager + self.query_rewrite_prompt = self.prompt_manager.load_prompt("chat_query_rewrite.prompty") + self.query_rewrite_tools = self.prompt_manager.load_tools("chat_query_rewrite_tools.json") + self.reasoning_effort = reasoning_effort + self.include_token_usage = True + self.multimodal_enabled = multimodal_enabled + self.image_embeddings_client = image_embeddings_client + self.global_blob_manager = global_blob_manager + self.user_blob_manager = user_blob_manager + + def build_filter(self, overrides: dict[str, Any]) -> str |None: + """Build a filter string based on include and exclude category overrides.""" + include_category = overrides.get("include_category") + exclude_category = overrides.get("exclude_category") + filters = [] + if include_category: + filters.append("category eq '{}'".format(include_category.replace("'", "''"))) + if exclude_category: + filters.append("category ne '{}'".format(exclude_category.replace("'", "''"))) + return None if not filters else " and ".join(filters) + + async def search( + self, + top: int, + query_text: str |None, + filter: str |None, + vectors: list[VectorQuery], + use_text_search: bool, + use_vector_search: bool, + use_semantic_ranker: bool, + use_semantic_captions: bool, + minimum_search_score: float | None = None, + minimum_reranker_score: float | None = None, + use_query_rewriting: bool | None = None, + access_token: str | None = None, + ) -> list[Document]: + """Perform a search with various options including text, vector, and semantic search.""" + search_text = query_text if use_text_search else "" + search_vectors = vectors if use_vector_search else [] + if use_semantic_ranker: + results = await self.search_client.search( + search_text=search_text, + filter=filter, + top=top, + query_caption="extractive|highlight-false" if use_semantic_captions else None, + query_rewrites="generative" if use_query_rewriting else None, + vector_queries=search_vectors, + query_type=QueryType.SEMANTIC, + query_language=self.query_language, + query_speller=self.query_speller, + semantic_configuration_name="default", + semantic_query=query_text, + x_ms_query_source_authorization=access_token, + ) + else: + results = await self.search_client.search( + search_text=search_text, + filter=filter, + top=top, + vector_queries=search_vectors, + x_ms_query_source_authorization=access_token, + ) + + documents: list[Document] = [] + async for page in results.by_page(): + async for document in page: + documents.append( + Document( + id=document.get("id"), + content=document.get("content"), + category=document.get("category"), + sourcepage=document.get("sourcepage"), + sourcefile=document.get("sourcefile"), + oids=document.get("oids"), + groups=document.get("groups"), + captions=cast(list[QueryCaptionResult], document.get("@search.captions")), + score=document.get("@search.score"), + reranker_score=document.get("@search.reranker_score"), + images=document.get("images"), + ) + ) + + qualified_documents = [ + doc + for doc in documents + if ( + (doc.score or 0) >= (minimum_search_score or 0) + and (doc.reranker_score or 0) >= (minimum_reranker_score or 0) + ) + ] + + return qualified_documents + + def extract_rewritten_query( + self, + chat_completion: ChatCompletion, + user_query: str, + no_response_token: str | None = None, + ) -> str: + """Extract the rewritten query from a chat completion response.""" + response_message = chat_completion.choices[0].message + + if response_message.tool_calls: + for tool_call in response_message.tool_calls: + if tool_call.type != "function": + continue + arguments_payload = tool_call.function.arguments or "{}" + try: + parsed_arguments = json.loads(arguments_payload) + except json.JSONDecodeError: + continue + search_query = parsed_arguments.get("search_query") + if search_query and (no_response_token is None or search_query != no_response_token): + return search_query + + if response_message.content: + candidate = response_message.content.strip() + if candidate and (no_response_token is None or candidate != no_response_token): + return candidate + + return user_query + + async def rewrite_query( + self, + *, + prompt_template: Any, + prompt_variables: dict[str, Any], + overrides: dict[str, Any], + chatgpt_model: str, + chatgpt_deployment: str |None, + user_query: str, + response_token_limit: int, + tools: list[ChatCompletionToolParam] | None = None, + temperature: float = 0.0, + no_response_token: str | None = None, + ) -> RewriteQueryResult: + """Rewrite the user query using a chat completion model.""" + query_messages = self.prompt_manager.render_prompt(prompt_template, prompt_variables) + rewrite_reasoning_effort = self.get_lowest_reasoning_effort(self.chatgpt_model) + + chat_completion = cast( + ChatCompletion, + await self.create_chat_completion( + chatgpt_deployment, + chatgpt_model, + messages=query_messages, + overrides=overrides, + response_token_limit=response_token_limit, + temperature=temperature, + tools=tools, + reasoning_effort=rewrite_reasoning_effort, + ), + ) + + rewritten_query = self.extract_rewritten_query( + chat_completion, + user_query, + no_response_token=no_response_token, + ) + + return RewriteQueryResult( + query=rewritten_query, + messages=query_messages, + completion=chat_completion, + reasoning_effort=rewrite_reasoning_effort, + ) + + async def run_agentic_retrieval( + self, + messages: list[ChatCompletionMessageParam], + knowledgebase_client: KnowledgeBaseRetrievalClient, + search_index_name: str, + filter_add_on: str | None = None, + minimum_reranker_score: float | None = None, + access_token: str | None = None, + use_web_source: bool = False, + use_sharepoint_source: bool = False, + retrieval_reasoning_effort: str | None = None, + should_rewrite_query: bool = True, + ) -> AgenticRetrievalResults: + """Perform agentic retrieval using the knowledgebase client.""" + # STEP 1: Invoke agentic retrieval + thoughts = [] + + knowledge_source_params = [ + SearchIndexKnowledgeSourceParams( + knowledge_source_name=search_index_name, + filter_add_on=filter_add_on, + include_references=True, + include_reference_source_data=True, + always_query_source=False, + reranker_threshold=minimum_reranker_score, + ) + ] + # Build list as KnowledgeSourceParams for type variance + knowledge_source_params_list: list[KnowledgeSourceParams] = cast( + list[KnowledgeSourceParams], knowledge_source_params + ) + + if use_web_source: + knowledge_source_params_list.append( + WebKnowledgeSourceParams( + knowledge_source_name="web", + include_references=True, + include_reference_source_data=True, + always_query_source=False, + ) + ) + + if use_sharepoint_source: + knowledge_source_params_list.append( + RemoteSharePointKnowledgeSourceParams( + knowledge_source_name="sharepoint", + include_references=True, + include_reference_source_data=True, + always_query_source=False, + ) + ) + + agentic_retrieval_input: dict[str, Any] = {} + rewrite_result = None + if retrieval_reasoning_effort == "minimal" and should_rewrite_query: + original_user_query = messages[-1]["content"] + if not isinstance(original_user_query, str): + raise ValueError("The most recent message content must be a string.") + + rewrite_result = await self.rewrite_query( + prompt_template=self.query_rewrite_prompt, + prompt_variables={"user_query": original_user_query, "past_messages": messages[:-1]}, + overrides={}, + chatgpt_model=self.chatgpt_model, + chatgpt_deployment=self.chatgpt_deployment, + user_query=original_user_query, + response_token_limit=self.get_response_token_limit( + self.chatgpt_model, 100 + ), # Setting too low risks malformed JSON, setting too high may affect performance + tools=self.query_rewrite_tools, + temperature=0.0, # Minimize creativity for search query generation + no_response_token=self.QUERY_REWRITE_NO_RESPONSE, + ) + thoughts.append( + self.format_thought_step_for_chatcompletion( + title="Prompt to generate search query", + messages=rewrite_result.messages, + overrides={}, + model=self.chatgpt_model, + deployment=self.chatgpt_deployment, + usage=rewrite_result.completion.usage, + reasoning_effort=rewrite_result.reasoning_effort, + ) + ) + agentic_retrieval_input["intents"] = [KnowledgeRetrievalSemanticIntent(search=rewrite_result.query)] + elif retrieval_reasoning_effort == "minimal": + last_content = messages[-1]["content"] + if not isinstance(last_content, str): + raise ValueError("The most recent message content must be a string.") + agentic_retrieval_input["intents"] = [KnowledgeRetrievalSemanticIntent(search=last_content)] + else: + kb_messages: list[KnowledgeBaseMessage] = [ + KnowledgeBaseMessage( + role=str(msg["role"]), content=[KnowledgeBaseMessageTextContent(text=str(msg["content"]))] + ) + for msg in messages + if msg["role"] != "system" + ] + agentic_retrieval_input["messages"] = kb_messages + # When we're not using a web source, set output mode to extractiveData to avoid synthesized answer + if not use_web_source: + agentic_retrieval_input["output_mode"] = "extractiveData" + + retrieval_effort: KnowledgeRetrievalMinimalReasoningEffort | KnowledgeRetrievalLowReasoningEffort | KnowledgeRetrievalMediumReasoningEffort | None = None + if retrieval_reasoning_effort == "minimal": + retrieval_effort = KnowledgeRetrievalMinimalReasoningEffort() + elif retrieval_reasoning_effort == "low": + retrieval_effort = KnowledgeRetrievalLowReasoningEffort() + elif retrieval_reasoning_effort == "medium": + retrieval_effort = KnowledgeRetrievalMediumReasoningEffort() + + request_kwargs: dict[str, Any] = { + "knowledge_source_params": knowledge_source_params_list, + "include_activity": True, + "retrieval_reasoning_effort": retrieval_effort, + } + request_kwargs.update(agentic_retrieval_input) + + response = await knowledgebase_client.retrieve( + retrieval_request=KnowledgeBaseRetrievalRequest(**request_kwargs), + x_ms_query_source_authorization=access_token, + ) + + # Map activity id -> agent's internal search query and citation + activities = response.activity or [] + activity_details_by_id: dict[int, ActivityDetail] = {} + + for index, activity in enumerate(activities): + search_query = None + if isinstance(activity, KnowledgeBaseSearchIndexActivityRecord): + if activity.search_index_arguments: + search_query = activity.search_index_arguments.search + elif isinstance(activity, KnowledgeBaseWebActivityRecord): + if activity.web_arguments: + search_query = activity.web_arguments.search + elif isinstance(activity, KnowledgeBaseRemoteSharePointActivityRecord): + if activity.remote_share_point_arguments: + search_query = activity.remote_share_point_arguments.search + + activity_details_by_id[activity.id] = ActivityDetail( + id=activity.id, + number=index + 1, + type=activity.type or "", + source=getattr(activity, "knowledge_source_name", "") + or "", # Not all activity types have knowledge_source_name + query=search_query or "", + ) + + # Extract references + references = response.references or [] + + document_refs = [ + r for r in references if isinstance(r, KnowledgeBaseSearchIndexReference) or hasattr(r, "doc_key") + ] + document_results: list[Document] = [] + # Create documents from reference source data + for ref in document_refs: + if ref.source_data and ref.doc_key: + # Note that ref.doc_key is the same as source_data["id"] + document_results.append( + Document( + id=ref.doc_key, + ref_id=ref.id, + content=ref.source_data.get("content"), + category=ref.source_data.get("category"), + sourcepage=ref.source_data.get("sourcepage"), + sourcefile=ref.source_data.get("sourcefile"), + oids=ref.source_data.get("oids"), + groups=ref.source_data.get("groups"), + reranker_score=getattr(ref, "reranker_score", None), + images=ref.source_data.get("images"), + activity=activity_details_by_id[ref.activity_source], + ) + ) + + # We need to handle KnowledgeBaseWebReference separately if web knowledge source is used + web_refs = [r for r in references if isinstance(r, KnowledgeBaseWebReference)] + web_results: list[WebResult] = [] + for ref in web_refs: + web_result = WebResult( + id=ref.id, title=ref.title, url=ref.url, activity=activity_details_by_id[ref.activity_source] + ) + web_results.append(web_result) + + # Handle KnowledgeBaseRemoteSharePointReference if SharePoint knowledge source is used + sharepoint_refs = [r for r in references if isinstance(r, KnowledgeBaseRemoteSharePointReference)] + sharepoint_results: list[SharePointResult] = [] + for ref in sharepoint_refs: + # Extract content from all sourceData.extracts[].text and concatenate + content = None + if ref.source_data and "extracts" in ref.source_data and len(ref.source_data["extracts"]) > 0: + extracts = [extract.get("text", "") for extract in ref.source_data["extracts"]] + content = "\n\n".join(extracts) if extracts else None + + # Extract title from sourceData.resourceMetadata.title + title = None + if ref.source_data and "resourceMetadata" in ref.source_data: + title = ref.source_data["resourceMetadata"].get("title") + + sharepoint_result = SharePointResult( + id=ref.id, + web_url=ref.web_url, + content=content, + title=title, + reranker_score=getattr(ref, "reranker_score", None), + activity=activity_details_by_id[ref.activity_source], + ) + sharepoint_results.append(sharepoint_result) + + # Extract answer from response if web knowledge source provided one + answer: str | None = None + if ( + use_web_source + and response.response + and len(response.response) > 0 + and len(response.response[0].content) > 0 + ): + message_content = response.response[0].content[0] + if isinstance(message_content, KnowledgeBaseMessageTextContent): + raw_answer: str | None = message_content.text + # Replace all ref_id tokens (web -> URL, documents -> sourcepage, SharePoint -> web_url) + if raw_answer: + answer = self.replace_all_ref_ids(raw_answer, document_results, web_results, sharepoint_results) + + thoughts.append( + ThoughtStep( + "Agentic retrieval response", + [result.serialize_for_results() for result in document_results + web_results + sharepoint_results], + { + "query_plan": ( + [activity.as_dict() for activity in response.activity] if response.activity else None + ), + "model": self.knowledgebase_model, + "deployment": self.knowledgebase_deployment, + "reranker_threshold": minimum_reranker_score, + "filter": filter_add_on, + }, + ) + ) + + return AgenticRetrievalResults( + response=response, + documents=document_results, + web_results=web_results, + sharepoint_results=sharepoint_results, + answer=answer, + rewrite_result=rewrite_result, + activity_details_by_id=activity_details_by_id, + thoughts=thoughts, + ) + + def replace_all_ref_ids( + self, + answer: str, + documents: list[Document], + web_results: list[WebResult], + sharepoint_results: list[SharePointResult] | None = None, + ) -> str: + """Replace [ref_id:] tokens with document sourcepage, web URL, or SharePoint web_url. + + Priority: web result -> SharePoint result -> document. + Unknown ids left untouched. + """ + doc_map = {d.ref_id: d.sourcepage for d in documents if d.ref_id and d.sourcepage} + web_map = {str(w.id): w.url for w in web_results if w.id and w.url} + sharepoint_entries = sharepoint_results or [] + sharepoint_map = {str(sp.id): sp.web_url.split("/")[-1] for sp in sharepoint_entries if sp.id and sp.web_url} + + def _sub(match: re.Match) -> str: + ref_id = match.group(1) + if ref_id in web_map and web_map[ref_id]: + return f"[{web_map[ref_id]}]" + if ref_id in sharepoint_map and sharepoint_map[ref_id]: + return f"[{sharepoint_map[ref_id]}]" + if ref_id in doc_map and doc_map[ref_id]: + return f"[{doc_map[ref_id]}]" + return match.group(0) + + return re.sub(r"\[ref_id:([^\]]+)\]", _sub, answer) + + async def get_sources_content( + self, + results: list[Document], + use_semantic_captions: bool, + include_text_sources: bool, + download_image_sources: bool, + user_oid: str | None = None, + web_results: list[WebResult] | None = None, + sharepoint_results: list[SharePointResult] | None = None, + ) -> DataPoints: + """Extract text/image sources & citations from documents. + + Args: + results: List of retrieved Document objects. + use_semantic_captions: Whether to use semantic captions instead of full content text. + download_image_sources: Whether to attempt downloading & base64 encoding referenced images. + user_oid: Optional user object id for per-user storage access (ADLS scenarios). + web_results: Optional list of web retrieval results to expose to clients. + sharepoint_results: Optional list of SharePoint retrieval results to expose to clients. + + Returns: + DataPoints: with text (list[str]), images (list[str - base64 data URI]), citations (list[str]). + """ + + def clean_source(s: str) -> str: + s = s.replace("\n", " ").replace("\r", " ") # normalize newlines to spaces + s = s.replace(":::", ":::") # escape DocFX/markdown triple colons + return s + + citations = [] + text_sources = [] + image_sources = [] + seen_urls = set() + external_results_metadata: list[dict[str, Any]] = [] + citation_activity_details: dict[str, dict[str, Any]] = {} + + for doc in results: + # Get the citation for the source page + citation = self.get_citation(doc.sourcepage) + if citation not in citations: + citations.append(citation) + # Add activity details if available + if doc.activity: + citation_activity_details[citation] = asdict(doc.activity) + + # If semantic captions are used, extract captions; otherwise, use content + if include_text_sources: + if use_semantic_captions and doc.captions: + cleaned = clean_source(" . ".join([cast(str, c.text) for c in doc.captions])) + else: + cleaned = clean_source(doc.content or "") + text_sources.append(f"{citation}: {cleaned}") + + if download_image_sources and hasattr(doc, "images") and doc.images: + for img in doc.images: + # Skip if we've already processed this URL + if img["url"] in seen_urls or not img["url"]: + continue + seen_urls.add(img["url"]) + url = await self.download_blob_as_base64(img["url"], user_oid=user_oid) + if url: + image_sources.append(url) + image_citation = self.get_image_citation(doc.sourcepage or "", img["url"]) + citations.append(image_citation) + if web_results: + for web in web_results: + citation = self.get_citation(web.url) + if citation and citation not in citations: + citations.append(citation) + # Add activity details if available + if web.activity: + citation_activity_details[citation] = asdict(web.activity) + external_results_metadata.append( + { + "id": web.id, + "title": web.title, + "url": web.url, + "activity": asdict(web.activity) if web.activity else None, + } + ) + if sharepoint_results: + for sp in sharepoint_results: + # Extract filename from web_url for citation + filename = sp.web_url.split("/")[-1] if sp.web_url else "" + citation = self.get_citation(filename) + if citation and citation not in citations: + citations.append(citation) + # Add activity details if available + if sp.activity: + citation_activity_details[citation] = asdict(sp.activity) + if include_text_sources and sp.content: + text_sources.append(f"{citation}: {clean_source(sp.content)}") + external_results_metadata.append( + { + "id": sp.id, + "title": sp.title or "", + "url": sp.web_url or "", + "snippet": clean_source(sp.content or ""), + "activity": asdict(sp.activity) if sp.activity else None, + } + ) + + return DataPoints( + text=text_sources, + images=image_sources, + citations=citations, + external_results_metadata=external_results_metadata, + citation_activity_details=citation_activity_details if citation_activity_details else None, + ) + + def get_citation(self, sourcepage: str |None): + """Generate a citation string based on the source page.""" + return sourcepage or "" + + def get_image_citation(self, sourcepage: str |None, image_url: str): + """Generate a citation string for an image based on the source page and image URL.""" + sourcepage_citation = self.get_citation(sourcepage) + image_filename = image_url.split("/")[-1] + return f"{sourcepage_citation}({image_filename})" + + async def download_blob_as_base64(self, blob_url: str, user_oid: str | None = None) -> str |None: + """Download a blob from either Azure Blob Storage or Azure Data Lake Storage and returns it as a base64 encoded string. + + Args: + blob_url: The URL or path to the blob to download + user_oid: The user's object ID, required for Data Lake Storage operations and access control + + Returns: + str |: The base64 encoded image data with data URI scheme prefix, or None if the blob cannot be downloaded + """ + # Handle full URLs for both Blob Storage and Data Lake Storage + if blob_url.startswith("http"): + url_parts = blob_url.split("/") + # Skip the domain parts and container/filesystem name to get the blob path + # For blob: https://{account}.blob.core.windows.net/{container}/{blob_path} + # For dfs: https://{account}.dfs.core.windows.net/{filesystem}/{path} + blob_path = "/".join(url_parts[4:]) + # If %20 in URL, replace it with a space + blob_path = blob_path.replace("%20", " ") + else: + # Treat as a direct blob path + blob_path = blob_url + + # Download the blob using the appropriate client + result = None + if ".dfs.core.windows.net" in blob_url and self.user_blob_manager: + result = await self.user_blob_manager.download_blob(blob_path, user_oid=user_oid) + elif self.global_blob_manager: + result = await self.global_blob_manager.download_blob(blob_path) + + if result: + content, _ = result # Unpack the tuple, ignoring properties + img = base64.b64encode(content).decode("utf-8") + return f"data:image/png;base64,{img}" + return None + + async def compute_text_embedding(self, q: str): + """Compute the text embedding for a given query string.""" + SUPPORTED_DIMENSIONS_MODEL = { + "text-embedding-ada-002": False, + "text-embedding-3-small": True, + "text-embedding-3-large": True, + } + + class ExtraArgs(TypedDict, total=False): + dimensions: int + + dimensions_args: ExtraArgs = ( + {"dimensions": self.embedding_dimensions} if SUPPORTED_DIMENSIONS_MODEL[self.embedding_model] else {} + ) + embedding = await self.openai_client.embeddings.create( + # Azure OpenAI takes the deployment name as the model name + model=self.embedding_deployment if self.embedding_deployment else self.embedding_model, + input=q, + **dimensions_args, + ) + query_vector = embedding.data[0].embedding + # This performs an oversampling due to how the search index was setup, + # so we do not need to explicitly pass in an oversampling parameter here + return VectorizedQuery(vector=query_vector, k=50, fields=self.embedding_field) + + async def compute_multimodal_embedding(self, q: str): + """Compute the multimodal embedding for a given query string.""" + if not self.image_embeddings_client: + raise ValueError("Approach is missing an image embeddings client for multimodal queries") + multimodal_query_vector = await self.image_embeddings_client.create_embedding_for_text(q) + return VectorizedQuery(vector=multimodal_query_vector, k=50, fields="images/embedding") + + def get_system_prompt_variables(self, override_prompt: str |None) -> dict[str, str]: + """Get variables for system prompt based on override prompt.""" + # Allows client to replace the entire prompt, or to inject into the existing prompt using >>> + if override_prompt is None: + return {} + elif override_prompt.startswith(">>>"): + return {"injected_prompt": override_prompt[3:]} + else: + return {"override_prompt": override_prompt} + + def get_response_token_limit(self, model: str, default_limit: int) -> int: + """Get the response token limit based on the model.""" + if model in self.GPT_REASONING_MODELS: + return self.RESPONSE_REASONING_DEFAULT_TOKEN_LIMIT + + return default_limit + + def get_lowest_reasoning_effort(self, model: str) -> ChatCompletionReasoningEffort: + """Return the lowest valid reasoning_effort for the given model.""" + if model not in self.GPT_REASONING_MODELS: + return None + if self.GPT_REASONING_MODELS[model].minimal_effort: + return "minimal" + return "low" + + def create_chat_completion( + self, + chatgpt_deployment: str |None, + chatgpt_model: str, + messages: list[ChatCompletionMessageParam], + overrides: dict[str, Any], + response_token_limit: int, + should_stream: bool = False, + tools: list[ChatCompletionToolParam] | None = None, + temperature: float | None = None, + n: int | None = None, + reasoning_effort: ChatCompletionReasoningEffort | None = None, + ) -> Awaitable[ChatCompletion] | Awaitable[AsyncStream[ChatCompletionChunk]]: + """Create a chat completion with appropriate parameters based on model capabilities.""" + if chatgpt_model in self.GPT_REASONING_MODELS: + params: dict[str, Any] = { + # max_tokens is not supported + "max_completion_tokens": response_token_limit + } + + # Adjust parameters for reasoning models + supported_features = self.GPT_REASONING_MODELS[chatgpt_model] + if supported_features.streaming and should_stream: + params["stream"] = True + params["stream_options"] = {"include_usage": True} + params["reasoning_effort"] = reasoning_effort or overrides.get("reasoning_effort") or self.reasoning_effort + + else: + # Include parameters that may not be supported for reasoning models + params = { + "max_tokens": response_token_limit, + "temperature": temperature or overrides.get("temperature", 0.3), + } + if should_stream: + params["stream"] = True + params["stream_options"] = {"include_usage": True} + + params["tools"] = tools + + # Azure OpenAI takes the deployment name as the model name + return self.openai_client.chat.completions.create( + model=chatgpt_deployment if chatgpt_deployment else chatgpt_model, + messages=messages, + seed=overrides.get("seed", None), + n=n or 1, + **params, + ) + + def format_thought_step_for_chatcompletion( + self, + title: str, + messages: list[ChatCompletionMessageParam], + overrides: dict[str, Any], + model: str, + deployment: str |None, + usage: CompletionUsage | None = None, + reasoning_effort: ChatCompletionReasoningEffort | None = None, + ) -> ThoughtStep: + """Format a ThoughtStep for a chat completion response.""" + properties: dict[str, Any] = {"model": model} + if deployment: + properties["deployment"] = deployment + # Only add reasoning_effort setting if the model supports it + if model in self.GPT_REASONING_MODELS: + properties["reasoning_effort"] = reasoning_effort or overrides.get( + "reasoning_effort", self.reasoning_effort + ) + if usage: + properties["token_usage"] = TokenUsageProps.from_completion_usage(usage) + return ThoughtStep(title, messages, properties) + + async def run( + self, + messages: list[ChatCompletionMessageParam], + session_state: Any = None, + context: dict[str, Any] = {}, + ) -> dict[str, Any]: + """Run the approach with the given messages and context.""" + raise NotImplementedError + + async def run_stream( + self, + messages: list[ChatCompletionMessageParam], + session_state: Any = None, + context: dict[str, Any] = {}, + ) -> AsyncGenerator[dict[str, Any], None]: + """Run the approach in streaming mode with the given messages and context.""" + raise NotImplementedError diff --git a/app/backend/approaches/chatreadretrieveread.py b/app/backend/approaches/chatreadretrieveread.py new file mode 100644 index 00000000..63332f52 --- /dev/null +++ b/app/backend/approaches/chatreadretrieveread.py @@ -0,0 +1,525 @@ +import re +from collections.abc import AsyncGenerator, Awaitable +from dataclasses import asdict +from typing import Any, Optional, cast + +from azure.search.documents.aio import SearchClient +from azure.search.documents.knowledgebases.aio import KnowledgeBaseRetrievalClient +from azure.search.documents.models import VectorQuery +from openai import AsyncOpenAI, AsyncStream +from openai.types.chat import ( + ChatCompletion, + ChatCompletionChunk, + ChatCompletionMessageParam, +) +from openai.types.chat.chat_completion import Choice +from openai.types.chat.chat_completion_message import ChatCompletionMessage + +from approaches.approach import ( + Approach, + ExtraInfo, + ThoughtStep, +) +from approaches.promptmanager import PromptManager +from prepdocslib.blobmanager import AdlsBlobManager, BlobManager +from prepdocslib.embeddings import ImageEmbeddings + + +class ChatReadRetrieveReadApproach(Approach): + """ + A multi-step approach that first uses OpenAI to turn the user's question into a search query, + then uses Azure AI Search to retrieve relevant documents, and then sends the conversation history, + original user question, and search results to OpenAI to generate a response. + """ + + NO_RESPONSE = Approach.QUERY_REWRITE_NO_RESPONSE + + def __init__( + self, + *, + search_client: SearchClient, + search_index_name: str, + knowledgebase_model: Optional[str], + knowledgebase_deployment: Optional[str], + knowledgebase_client: Optional[KnowledgeBaseRetrievalClient], + knowledgebase_client_with_web: Optional[KnowledgeBaseRetrievalClient] = None, + knowledgebase_client_with_sharepoint: Optional[KnowledgeBaseRetrievalClient] = None, + knowledgebase_client_with_web_and_sharepoint: Optional[KnowledgeBaseRetrievalClient] = None, + openai_client: AsyncOpenAI, + chatgpt_model: str, + chatgpt_deployment: Optional[str], # Not needed for non-Azure OpenAI + embedding_deployment: Optional[str], # Not needed for non-Azure OpenAI or for retrieval_mode="text" + embedding_model: str, + embedding_dimensions: int, + embedding_field: str, + sourcepage_field: str, + content_field: str, + query_language: str, + query_speller: str, + prompt_manager: PromptManager, + reasoning_effort: Optional[str] = None, + multimodal_enabled: bool = False, + image_embeddings_client: Optional[ImageEmbeddings] = None, + global_blob_manager: Optional[BlobManager] = None, + user_blob_manager: Optional[AdlsBlobManager] = None, + use_web_source: bool = False, + use_sharepoint_source: bool = False, + retrieval_reasoning_effort: Optional[str] = None, + ): + self.search_client = search_client + self.search_index_name = search_index_name + self.knowledgebase_model = knowledgebase_model + self.knowledgebase_deployment = knowledgebase_deployment + self.knowledgebase_client = knowledgebase_client + self.knowledgebase_client_with_web = knowledgebase_client_with_web + self.knowledgebase_client_with_sharepoint = knowledgebase_client_with_sharepoint + self.knowledgebase_client_with_web_and_sharepoint = knowledgebase_client_with_web_and_sharepoint + self.openai_client = openai_client + self.chatgpt_model = chatgpt_model + self.chatgpt_deployment = chatgpt_deployment + self.embedding_deployment = embedding_deployment + self.embedding_model = embedding_model + self.embedding_dimensions = embedding_dimensions + self.embedding_field = embedding_field + self.sourcepage_field = sourcepage_field + self.content_field = content_field + self.query_language = query_language + self.query_speller = query_speller + self.prompt_manager = prompt_manager + self.query_rewrite_prompt = self.prompt_manager.load_prompt("chat_query_rewrite.prompty") + self.query_rewrite_tools = self.prompt_manager.load_tools("chat_query_rewrite_tools.json") + self.answer_prompt = self.prompt_manager.load_prompt("chat_answer_question.prompty") + self.reasoning_effort = reasoning_effort + self.include_token_usage = True + self.multimodal_enabled = multimodal_enabled + self.image_embeddings_client = image_embeddings_client + self.global_blob_manager = global_blob_manager + self.user_blob_manager = user_blob_manager + # Track whether web source retrieval is enabled for this deployment; overrides may only disable it. + self.web_source_enabled = use_web_source + self.use_sharepoint_source = use_sharepoint_source + self.retrieval_reasoning_effort = retrieval_reasoning_effort + + def extract_followup_questions(self, content: Optional[str]): + if content is None: + return content, [] + return content.split("<<")[0], re.findall(r"<<([^>>]+)>>", content) + + def get_search_query(self, chat_completion: ChatCompletion, default_query: str) -> str: + """Read the optimized search query from a chat completion tool call.""" + try: + return self.extract_rewritten_query(chat_completion, default_query, no_response_token=self.NO_RESPONSE) + except Exception: + return default_query + + async def run_without_streaming( + self, + messages: list[ChatCompletionMessageParam], + overrides: dict[str, Any], + auth_claims: dict[str, Any], + session_state: Any = None, + ) -> dict[str, Any]: + extra_info, chat_coroutine = await self.run_until_final_call( + messages, overrides, auth_claims, should_stream=False + ) + chat_completion_response: ChatCompletion = await cast(Awaitable[ChatCompletion], chat_coroutine) + content = chat_completion_response.choices[0].message.content + role = chat_completion_response.choices[0].message.role + if overrides.get("suggest_followup_questions"): + content, followup_questions = self.extract_followup_questions(content) + extra_info.followup_questions = followup_questions + # Assume last thought is for generating answer + # TODO: Update for agentic? This isn't still true? + if self.include_token_usage and extra_info.thoughts and chat_completion_response.usage: + extra_info.thoughts[-1].update_token_usage(chat_completion_response.usage) + chat_app_response = { + "message": {"content": content, "role": role}, + "context": { + "thoughts": extra_info.thoughts, + "data_points": { + key: value for key, value in asdict(extra_info.data_points).items() if value is not None + }, + "followup_questions": extra_info.followup_questions, + }, + "session_state": session_state, + } + return chat_app_response + + async def run_with_streaming( + self, + messages: list[ChatCompletionMessageParam], + overrides: dict[str, Any], + auth_claims: dict[str, Any], + session_state: Any = None, + ) -> AsyncGenerator[dict, None]: + extra_info, chat_coroutine = await self.run_until_final_call( + messages, overrides, auth_claims, should_stream=True + ) + yield {"delta": {"role": "assistant"}, "context": extra_info, "session_state": session_state} + + followup_questions_started = False + followup_content = "" + chat_result = await chat_coroutine + + if isinstance(chat_result, ChatCompletion): + message = chat_result.choices[0].message + content = message.content or "" + role = message.role or "assistant" + + followup_questions: list[str] = [] + if overrides.get("suggest_followup_questions"): + content, followup_questions = self.extract_followup_questions(content) + extra_info.followup_questions = followup_questions + + if self.include_token_usage and extra_info.thoughts and chat_result.usage: + extra_info.thoughts[-1].update_token_usage(chat_result.usage) + + delta_payload: dict[str, Any] = {"role": role} + if content: + delta_payload["content"] = content + yield {"delta": delta_payload} + + yield {"delta": {"role": "assistant"}, "context": extra_info, "session_state": session_state} + + if followup_questions: + yield { + "delta": {"role": "assistant"}, + "context": {"context": extra_info, "followup_questions": followup_questions}, + } + return + + chat_result = cast(AsyncStream[ChatCompletionChunk], chat_result) + + async for event_chunk in chat_result: + # "2023-07-01-preview" API version has a bug where first response has empty choices + event = event_chunk.model_dump() # Convert pydantic model to dict + if event["choices"]: + # No usage during streaming + completion = { + "delta": { + "content": event["choices"][0]["delta"].get("content"), + "role": event["choices"][0]["delta"]["role"], + } + } + # if event contains << and not >>, it is start of follow-up question, truncate + delta_content_raw = completion["delta"].get("content") + delta_content: str = ( + delta_content_raw or "" + ) # content may either not exist in delta, or explicitly be None + if overrides.get("suggest_followup_questions") and "<<" in delta_content: + followup_questions_started = True + earlier_content = delta_content[: delta_content.index("<<")] + if earlier_content: + completion["delta"]["content"] = earlier_content + yield completion + followup_content += delta_content[delta_content.index("<<") :] + elif followup_questions_started: + followup_content += delta_content + else: + yield completion + else: + # Final chunk at end of streaming should contain usage + # https://cookbook.openai.com/examples/how_to_stream_completions#4-how-to-get-token-usage-data-for-streamed-chat-completion-response + if event_chunk.usage and extra_info.thoughts and self.include_token_usage: + extra_info.thoughts[-1].update_token_usage(event_chunk.usage) + yield {"delta": {"role": "assistant"}, "context": extra_info, "session_state": session_state} + + if followup_content: + _, followup_questions = self.extract_followup_questions(followup_content) + yield { + "delta": {"role": "assistant"}, + "context": {"context": extra_info, "followup_questions": followup_questions}, + } + + async def run( + self, + messages: list[ChatCompletionMessageParam], + session_state: Any = None, + context: dict[str, Any] = {}, + ) -> dict[str, Any]: + overrides = context.get("overrides", {}) + auth_claims = context.get("auth_claims", {}) + return await self.run_without_streaming(messages, overrides, auth_claims, session_state) + + async def run_stream( + self, + messages: list[ChatCompletionMessageParam], + session_state: Any = None, + context: dict[str, Any] = {}, + ) -> AsyncGenerator[dict[str, Any], None]: + overrides = context.get("overrides", {}) + auth_claims = context.get("auth_claims", {}) + return self.run_with_streaming(messages, overrides, auth_claims, session_state) + + async def run_until_final_call( + self, + messages: list[ChatCompletionMessageParam], + overrides: dict[str, Any], + auth_claims: dict[str, Any], + should_stream: bool = False, + ) -> tuple[ExtraInfo, Awaitable[ChatCompletion] | Awaitable[AsyncStream[ChatCompletionChunk]]]: + use_agentic_knowledgebase = True if overrides.get("use_agentic_knowledgebase") else False + original_user_query = messages[-1]["content"] + + reasoning_model_support = self.GPT_REASONING_MODELS.get(self.chatgpt_model) + if reasoning_model_support and (not reasoning_model_support.streaming and should_stream): + raise Exception( + f"{self.chatgpt_model} does not support streaming. Please use a different model or disable streaming." + ) + if use_agentic_knowledgebase: + if should_stream and overrides.get("use_web_source"): + raise Exception( + "Streaming is not supported with agentic retrieval when web source is enabled. Please disable streaming or web source." + ) + extra_info = await self.run_agentic_retrieval_approach(messages, overrides, auth_claims) + else: + extra_info = await self.run_search_approach(messages, overrides, auth_claims) + + if extra_info.answer: + # If agentic retrieval already provided an answer, skip final call to LLM + async def return_answer() -> ChatCompletion: + return ChatCompletion( + id="no-final-call", + object="chat.completion", + created=0, + model=self.chatgpt_model, + choices=[ + Choice( + message=ChatCompletionMessage( + role="assistant", + content=extra_info.answer, + ), + finish_reason="stop", + index=0, + ) + ], + ) + + return (extra_info, return_answer()) + + messages = self.prompt_manager.render_prompt( + self.answer_prompt, + self.get_system_prompt_variables(overrides.get("prompt_template")) + | { + "include_follow_up_questions": bool(overrides.get("suggest_followup_questions")), + "past_messages": messages[:-1], + "user_query": original_user_query, + "text_sources": extra_info.data_points.text, + "image_sources": extra_info.data_points.images, + "citations": extra_info.data_points.citations, + }, + ) + + chat_coroutine = cast( + Awaitable[ChatCompletion] | Awaitable[AsyncStream[ChatCompletionChunk]], + self.create_chat_completion( + self.chatgpt_deployment, + self.chatgpt_model, + messages, + overrides, + self.get_response_token_limit(self.chatgpt_model, 1024), + should_stream, + ), + ) + extra_info.thoughts.append( + self.format_thought_step_for_chatcompletion( + title="Prompt to generate answer", + messages=messages, + overrides=overrides, + model=self.chatgpt_model, + deployment=self.chatgpt_deployment, + usage=None, + ) + ) + return (extra_info, chat_coroutine) + + async def run_search_approach( + self, messages: list[ChatCompletionMessageParam], overrides: dict[str, Any], auth_claims: dict[str, Any] + ): + use_text_search = overrides.get("retrieval_mode") in ["text", "hybrid", None] + use_vector_search = overrides.get("retrieval_mode") in ["vectors", "hybrid", None] + use_semantic_ranker = True if overrides.get("semantic_ranker") else False + use_semantic_captions = True if overrides.get("semantic_captions") else False + use_query_rewriting = True if overrides.get("query_rewriting") else False + top = overrides.get("top", 3) + minimum_search_score = overrides.get("minimum_search_score", 0.0) + minimum_reranker_score = overrides.get("minimum_reranker_score", 0.0) + search_index_filter = self.build_filter(overrides) + access_token = auth_claims.get("access_token") + send_text_sources = overrides.get("send_text_sources", True) + send_image_sources = overrides.get("send_image_sources", self.multimodal_enabled) and self.multimodal_enabled + search_text_embeddings = overrides.get("search_text_embeddings", True) + search_image_embeddings = ( + overrides.get("search_image_embeddings", self.multimodal_enabled) and self.multimodal_enabled + ) + + original_user_query = messages[-1]["content"] + if not isinstance(original_user_query, str): + raise ValueError("The most recent message content must be a string.") + + # STEP 1: Generate an optimized keyword search query based on the chat history and the last question + + rewrite_result = await self.rewrite_query( + prompt_template=self.query_rewrite_prompt, + prompt_variables={"user_query": original_user_query, "past_messages": messages[:-1]}, + overrides=overrides, + chatgpt_model=self.chatgpt_model, + chatgpt_deployment=self.chatgpt_deployment, + user_query=original_user_query, + response_token_limit=self.get_response_token_limit( + self.chatgpt_model, 100 + ), # Setting too low risks malformed JSON, setting too high may affect performance + tools=self.query_rewrite_tools, + temperature=0.0, # Minimize creativity for search query generation + no_response_token=self.NO_RESPONSE, + ) + + query_text = rewrite_result.query + + # STEP 2: Retrieve relevant documents from the search index with the GPT optimized query + + vectors: list[VectorQuery] = [] + if use_vector_search: + if search_text_embeddings: + vectors.append(await self.compute_text_embedding(query_text)) + if search_image_embeddings: + vectors.append(await self.compute_multimodal_embedding(query_text)) + + results = await self.search( + top, + query_text, + search_index_filter, + vectors, + use_text_search, + use_vector_search, + use_semantic_ranker, + use_semantic_captions, + minimum_search_score, + minimum_reranker_score, + use_query_rewriting, + access_token, + ) + + # STEP 3: Generate a contextual and content specific answer using the search results and chat history + data_points = await self.get_sources_content( + results, + use_semantic_captions, + include_text_sources=send_text_sources, + download_image_sources=send_image_sources, + user_oid=auth_claims.get("oid"), + ) + extra_info = ExtraInfo( + data_points, + thoughts=[ + self.format_thought_step_for_chatcompletion( + title="Prompt to generate search query", + messages=rewrite_result.messages, + overrides=overrides, + model=self.chatgpt_model, + deployment=self.chatgpt_deployment, + usage=rewrite_result.completion.usage, + reasoning_effort=rewrite_result.reasoning_effort, + ), + ThoughtStep( + "Search using generated search query", + query_text, + { + "use_semantic_captions": use_semantic_captions, + "use_semantic_ranker": use_semantic_ranker, + "use_query_rewriting": use_query_rewriting, + "top": top, + "filter": search_index_filter, + "use_vector_search": use_vector_search, + "use_text_search": use_text_search, + "search_text_embeddings": search_text_embeddings, + "search_image_embeddings": search_image_embeddings, + }, + ), + ThoughtStep( + "Search results", + [result.serialize_for_results() for result in results], + ), + ], + ) + return extra_info + + async def run_agentic_retrieval_approach( + self, + messages: list[ChatCompletionMessageParam], + overrides: dict[str, Any], + auth_claims: dict[str, Any], + ): + search_index_filter = self.build_filter(overrides) + access_token = auth_claims.get("access_token") + minimum_reranker_score = overrides.get("minimum_reranker_score", 0) + send_text_sources = overrides.get("send_text_sources", True) + send_image_sources = overrides.get("send_image_sources", self.multimodal_enabled) and self.multimodal_enabled + retrieval_reasoning_effort = overrides.get("retrieval_reasoning_effort", self.retrieval_reasoning_effort) + # Overrides can only disable web source support configured at construction time. + use_web_source = self.web_source_enabled + override_use_web_source = overrides.get("use_web_source") + if isinstance(override_use_web_source, bool): + use_web_source = use_web_source and override_use_web_source + # Overrides can only disable sharepoint source support configured at construction time. + use_sharepoint_source = self.use_sharepoint_source + override_use_sharepoint_source = overrides.get("use_sharepoint_source") + if isinstance(override_use_sharepoint_source, bool): + use_sharepoint_source = use_sharepoint_source and override_use_sharepoint_source + if use_web_source and retrieval_reasoning_effort == "minimal": + raise Exception("Web source cannot be used with minimal retrieval reasoning effort.") + + selected_client, effective_web_source, effective_sharepoint_source = self._select_knowledgebase_client( + use_web_source, + use_sharepoint_source, + ) + + agentic_results = await self.run_agentic_retrieval( + messages=messages, + knowledgebase_client=selected_client, + search_index_name=self.search_index_name, + filter_add_on=search_index_filter, + minimum_reranker_score=minimum_reranker_score, + access_token=access_token, + use_web_source=effective_web_source, + use_sharepoint_source=effective_sharepoint_source, + retrieval_reasoning_effort=retrieval_reasoning_effort, + ) + + data_points = await self.get_sources_content( + agentic_results.documents, + use_semantic_captions=False, + include_text_sources=send_text_sources, + download_image_sources=send_image_sources, + user_oid=auth_claims.get("oid"), + web_results=agentic_results.web_results, + sharepoint_results=agentic_results.sharepoint_results, + ) + + return ExtraInfo( + data_points, + thoughts=agentic_results.thoughts, + answer=agentic_results.answer, + ) + + def _select_knowledgebase_client( + self, + use_web_source: bool, + use_sharepoint_source: bool, + ) -> tuple[KnowledgeBaseRetrievalClient, bool, bool]: + if use_web_source and use_sharepoint_source: + if self.knowledgebase_client_with_web_and_sharepoint: + return self.knowledgebase_client_with_web_and_sharepoint, True, True + if self.knowledgebase_client_with_web: + return self.knowledgebase_client_with_web, True, False + if self.knowledgebase_client_with_sharepoint: + return self.knowledgebase_client_with_sharepoint, False, True + + if use_web_source and self.knowledgebase_client_with_web: + return self.knowledgebase_client_with_web, True, False + + if use_sharepoint_source and self.knowledgebase_client_with_sharepoint: + return self.knowledgebase_client_with_sharepoint, False, True + + if self.knowledgebase_client: + return self.knowledgebase_client, False, False + raise ValueError("Agentic retrieval requested but no knowledge base is configured") diff --git a/app/backend/approaches/promptmanager.py b/app/backend/approaches/promptmanager.py new file mode 100644 index 00000000..82941b41 --- /dev/null +++ b/app/backend/approaches/promptmanager.py @@ -0,0 +1,31 @@ +import json +import pathlib + +import prompty +from openai.types.chat import ChatCompletionMessageParam + + +class PromptManager: + + def load_prompt(self, path: str): + raise NotImplementedError + + def load_tools(self, path: str): + raise NotImplementedError + + def render_prompt(self, prompt, data) -> list[ChatCompletionMessageParam]: + raise NotImplementedError + + +class PromptyManager(PromptManager): + + PROMPTS_DIRECTORY = pathlib.Path(__file__).parent / "prompts" + + def load_prompt(self, path: str): + return prompty.load(self.PROMPTS_DIRECTORY / path) + + def load_tools(self, path: str): + return json.loads(open(self.PROMPTS_DIRECTORY / path).read()) + + def render_prompt(self, prompt, data) -> list[ChatCompletionMessageParam]: + return prompty.prepare(prompt, data) diff --git a/app/backend/approaches/prompts/ask_answer_question.prompty b/app/backend/approaches/prompts/ask_answer_question.prompty new file mode 100644 index 00000000..b9e87b12 --- /dev/null +++ b/app/backend/approaches/prompts/ask_answer_question.prompty @@ -0,0 +1,47 @@ +--- +name: Ask +description: Answer a single question (with no chat history) using solely text sources. +model: + api: chat +--- +system: +{% if override_prompt %} +{{ override_prompt }} +{% else %} +Assistant helps the company employees with their questions about internal documents. Be brief in your answers. +Answer ONLY with the facts listed in the list of sources below. If there isn't enough information below, say you don't know. Do not generate answers that don't use the sources below. +You CANNOT ask clarifying questions to the user, since the user will have no way to reply. +If the question is not in English, answer in the language used in the question. +Each source has a name followed by colon and the actual information, always include the source name for each fact you use in the response. Use square brackets to reference the source, for example [info1.txt]. Don't combine sources, list each source separately, for example [info1.txt][info2.pdf]. +{% if image_sources %} +Each image source has the document file name in the top left corner of the image with coordinates (10,10) pixels with format , +and the image figure name is right-aligned in the top right corner of the image. +The filename of the actual image is in the top right corner of the image and is in the format . +Each text source starts in a new line and has the file name followed by colon and the actual information. +Always include the source document filename for each fact you use in the response in the format: [document_name.ext#page=N]. +If you are referencing an image, add the image filename in the format: [document_name.ext#page=N(image_name.png)]. +{% endif %} +Possible citations for current question: {% for citation in citations %} [{{ citation }}] {% endfor %} +{{ injected_prompt }} +{% endif %} + +user: +What is the deductible for the employee plan for a visit to Overlake in Bellevue? + +Sources: +info1.txt: deductibles depend on whether you are in-network or out-of-network. In-network deductibles are $500 for employee and $1000 for family. Out-of-network deductibles are $1000 for employee and $2000 for family. +info2.pdf: Overlake is in-network for the employee plan. +info3.pdf: Overlake is the name of the area that includes a park and ride near Bellevue. +info4.pdf: In-network institutions include Overlake, Swedish and others in the region. + +assistant: +In-network deductibles are $500 for employee and $1000 for family [info1.txt] and Overlake is in-network for the employee plan [info2.pdf][info4.pdf]. + +user: +{{ user_query }} +{% if image_sources %}{% for image_source in image_sources %} +![Image]({{image_source}}) +{% endfor %}{% endif %} +{% if text_sources is defined %}Sources:{% for text_source in text_sources %} +{{ text_source }} +{% endfor %}{% endif %} diff --git a/app/backend/approaches/prompts/chat_answer_question.prompty b/app/backend/approaches/prompts/chat_answer_question.prompty new file mode 100644 index 00000000..2e32f2e6 --- /dev/null +++ b/app/backend/approaches/prompts/chat_answer_question.prompty @@ -0,0 +1,53 @@ +--- +name: Chat +description: Answer a question (with chat history) using solely text sources. +model: + api: chat +--- +system: +{% if override_prompt %} +{{ override_prompt }} +{% else %} +Assistant helps the company employees with their questions about internal documents. Be brief in your answers. +Answer ONLY with the facts listed in the list of sources below. If there isn't enough information below, say you don't know. Do not generate answers that don't use the sources below. +If asking a clarifying question to the user would help, ask the question. +If the question is not in English, answer in the language used in the question. +Each source has a name followed by colon and the actual information, always include the source name for each fact you use in the response. Use square brackets to reference the source, for example [info1.txt]. Don't combine sources, list each source separately, for example [info1.txt][info2.pdf]. +{% if image_sources %} +Each image source has the document file name in the top left corner of the image with coordinates (10,10) pixels with format , +and the image figure name is right-aligned in the top right corner of the image. +The filename of the actual image is in the top right corner of the image and is in the format . +Each text source starts in a new line and has the file name followed by colon and the actual information +Always include the source document filename for each fact you use in the response in the format: [document_name.ext#page=N]. +If you are referencing an image, add the image filename in the format: [document_name.ext#page=N(image_name.png)]. +{% endif %} +Possible citations for current question: {% for citation in citations %} [{{ citation }}] {% endfor %} +{{ injected_prompt }} +{% endif %} + +{% if include_follow_up_questions %} +Generate 3 very brief follow-up questions that the user would likely ask next. +Enclose the follow-up questions in double angle brackets. Example: +<> +<> +<> +Do not repeat questions that have already been asked. +Make sure the last question ends with ">>". +{% endif %} + +{% for message in past_messages %} +{{ message["role"] }}: +{{ message["content"] }} +{% endfor %} + +user: +{{ user_query }} +{% if image_sources %}{% for image_source in image_sources %} +![Image]({{image_source}}) +{% endfor %}{% endif %} +{% if text_sources is defined %} +Sources: +{% for text_source in text_sources %} +{{ text_source }} +{% endfor %} +{% endif %} diff --git a/app/backend/approaches/prompts/chat_query_rewrite.prompty b/app/backend/approaches/prompts/chat_query_rewrite.prompty new file mode 100644 index 00000000..545b3f5b --- /dev/null +++ b/app/backend/approaches/prompts/chat_query_rewrite.prompty @@ -0,0 +1,44 @@ +--- +name: Rewrite RAG query +description: Suggest the optimal search query based on the user's query, examples, and chat history. +model: + api: chat + parameters: + tools: ${file:chat_query_rewrite_tools.json} +sample: + user_query: Does it include hearing? + past_messages: + - role: user + content: "What is included in my Northwind Health Plus plan that is not in standard?" + - role: assistant + content: "The Northwind Health Plus plan includes coverage for emergency services, mental health and substance abuse coverage, and out-of-network services, which are not included in the Northwind Standard plan. [Benefit_Options.pdf#page=3]" +--- +system: +Below is a history of the conversation so far, and a new question asked by the user that needs to be answered by searching in a knowledge base. +You have access to Azure AI Search index with 100's of documents. +Generate a search query based on the conversation and the new question. +Do not include cited source filenames and document names e.g. info.txt or doc.pdf in the search query terms. +Do not include any text inside [] or <<>> in the search query terms. +Do not include any special characters like '+'. +If the question is not in English, translate the question to English before generating the search query. +If you cannot generate a search query, return just the number 0. + +user: +How did crypto do last year? + +assistant: +Summarize Cryptocurrency Market Dynamics from last year + +user: +What are my health plans? + +assistant: +Show available health plans + +{% for message in past_messages %} +{{ message["role"] }}: +{{ message["content"] }} +{% endfor %} + +user: +Generate search query for: {{ user_query }} diff --git a/app/backend/approaches/prompts/chat_query_rewrite_tools.json b/app/backend/approaches/prompts/chat_query_rewrite_tools.json new file mode 100644 index 00000000..cf174348 --- /dev/null +++ b/app/backend/approaches/prompts/chat_query_rewrite_tools.json @@ -0,0 +1,17 @@ +[{ + "type": "function", + "function": { + "name": "search_sources", + "description": "Retrieve sources from the Azure AI Search index", + "parameters": { + "type": "object", + "properties": { + "search_query": { + "type": "string", + "description": "Query string to retrieve documents from azure search eg: 'Health care plan'" + } + }, + "required": ["search_query"] + } + } +}] diff --git a/app/backend/approaches/retrievethenread.py b/app/backend/approaches/retrievethenread.py new file mode 100644 index 00000000..886014ed --- /dev/null +++ b/app/backend/approaches/retrievethenread.py @@ -0,0 +1,317 @@ +from dataclasses import asdict +from typing import Any, Optional, cast + +from azure.search.documents.aio import SearchClient +from azure.search.documents.knowledgebases.aio import KnowledgeBaseRetrievalClient +from azure.search.documents.models import VectorQuery +from openai import AsyncOpenAI +from openai.types.chat import ChatCompletion, ChatCompletionMessageParam + +from approaches.approach import ( + Approach, + ExtraInfo, + ThoughtStep, +) +from approaches.promptmanager import PromptManager +from prepdocslib.blobmanager import AdlsBlobManager, BlobManager +from prepdocslib.embeddings import ImageEmbeddings + + +class RetrieveThenReadApproach(Approach): + """ + Simple retrieve-then-read implementation, using the AI Search and OpenAI APIs directly. It first retrieves + top documents from search, then constructs a prompt with them, and then uses OpenAI to generate an completion + (answer) with that prompt. + """ + + def __init__( + self, + *, + search_client: SearchClient, + search_index_name: str, + knowledgebase_model: Optional[str], + knowledgebase_deployment: Optional[str], + knowledgebase_client: Optional[KnowledgeBaseRetrievalClient], + knowledgebase_client_with_web: Optional[KnowledgeBaseRetrievalClient] = None, + knowledgebase_client_with_sharepoint: Optional[KnowledgeBaseRetrievalClient] = None, + knowledgebase_client_with_web_and_sharepoint: Optional[KnowledgeBaseRetrievalClient] = None, + openai_client: AsyncOpenAI, + chatgpt_model: str, + chatgpt_deployment: Optional[str], # Not needed for non-Azure OpenAI + embedding_model: str, + embedding_deployment: Optional[str], # Not needed for non-Azure OpenAI or for retrieval_mode="text" + embedding_dimensions: int, + embedding_field: str, + sourcepage_field: str, + content_field: str, + query_language: str, + query_speller: str, + prompt_manager: PromptManager, + reasoning_effort: Optional[str] = None, + multimodal_enabled: bool = False, + image_embeddings_client: Optional[ImageEmbeddings] = None, + global_blob_manager: Optional[BlobManager] = None, + user_blob_manager: Optional[AdlsBlobManager] = None, + use_web_source: bool = False, + use_sharepoint_source: bool = False, + retrieval_reasoning_effort: Optional[str] = None, + ): + self.search_client = search_client + self.search_index_name = search_index_name + self.knowledgebase_model = knowledgebase_model + self.knowledgebase_deployment = knowledgebase_deployment + self.knowledgebase_client = knowledgebase_client + self.knowledgebase_client_with_web = knowledgebase_client_with_web + self.knowledgebase_client_with_sharepoint = knowledgebase_client_with_sharepoint + self.knowledgebase_client_with_web_and_sharepoint = knowledgebase_client_with_web_and_sharepoint + self.chatgpt_deployment = chatgpt_deployment + self.openai_client = openai_client + self.chatgpt_model = chatgpt_model + self.embedding_model = embedding_model + self.embedding_dimensions = embedding_dimensions + self.chatgpt_deployment = chatgpt_deployment + self.embedding_deployment = embedding_deployment + self.embedding_field = embedding_field + self.sourcepage_field = sourcepage_field + self.content_field = content_field + self.query_language = query_language + self.query_speller = query_speller + self.prompt_manager = prompt_manager + self.answer_prompt = self.prompt_manager.load_prompt("ask_answer_question.prompty") + self.reasoning_effort = reasoning_effort + self.include_token_usage = True + self.multimodal_enabled = multimodal_enabled + self.image_embeddings_client = image_embeddings_client + self.global_blob_manager = global_blob_manager + self.user_blob_manager = user_blob_manager + # Track whether web source retrieval is enabled; overrides may only turn it off. + self.web_source_enabled = use_web_source + self.use_sharepoint_source = use_sharepoint_source + self.retrieval_reasoning_effort = retrieval_reasoning_effort + + async def run( + self, + messages: list[ChatCompletionMessageParam], + session_state: Any = None, + context: dict[str, Any] = {}, + ) -> dict[str, Any]: + overrides = context.get("overrides", {}) + auth_claims = context.get("auth_claims", {}) + use_agentic_knowledgebase = True if overrides.get("use_agentic_knowledgebase") else False + q = messages[-1]["content"] + if not isinstance(q, str): + raise ValueError("The most recent message content must be a string.") + + if use_agentic_knowledgebase: + extra_info = await self.run_agentic_retrieval_approach(messages, overrides, auth_claims) + else: + extra_info = await self.run_search_approach(messages, overrides, auth_claims) + + if extra_info.answer: + answer = extra_info.answer + else: + # Process results + messages = self.prompt_manager.render_prompt( + self.answer_prompt, + self.get_system_prompt_variables(overrides.get("prompt_template")) + | { + "user_query": q, + "text_sources": extra_info.data_points.text, + "image_sources": extra_info.data_points.images or [], + "citations": extra_info.data_points.citations or [], + }, + ) + + chat_completion = cast( + ChatCompletion, + await self.create_chat_completion( + self.chatgpt_deployment, + self.chatgpt_model, + messages=messages, + overrides=overrides, + response_token_limit=self.get_response_token_limit(self.chatgpt_model, 1024), + ), + ) + extra_info.thoughts.append( + self.format_thought_step_for_chatcompletion( + title="Prompt to generate answer", + messages=messages, + overrides=overrides, + model=self.chatgpt_model, + deployment=self.chatgpt_deployment, + usage=chat_completion.usage, + ) + ) + answer = chat_completion.choices[0].message.content or "" + + return { + "message": { + "content": answer, + "role": "assistant", + }, + "context": { + "thoughts": extra_info.thoughts, + "data_points": { + key: value for key, value in asdict(extra_info.data_points).items() if value is not None + }, + }, + "session_state": session_state, + } + + async def run_search_approach( + self, messages: list[ChatCompletionMessageParam], overrides: dict[str, Any], auth_claims: dict[str, Any] + ) -> ExtraInfo: + use_text_search = overrides.get("retrieval_mode") in ["text", "hybrid", None] + use_vector_search = overrides.get("retrieval_mode") in ["vectors", "hybrid", None] + use_semantic_ranker = True if overrides.get("semantic_ranker") else False + use_query_rewriting = True if overrides.get("query_rewriting") else False + use_semantic_captions = True if overrides.get("semantic_captions") else False + top = overrides.get("top", 3) + minimum_search_score = overrides.get("minimum_search_score", 0.0) + minimum_reranker_score = overrides.get("minimum_reranker_score", 0.0) + filter = self.build_filter(overrides) + access_token = auth_claims.get("access_token") + q = str(messages[-1]["content"]) + send_text_sources = overrides.get("send_text_sources", True) + send_image_sources = overrides.get("send_image_sources", self.multimodal_enabled) and self.multimodal_enabled + search_text_embeddings = overrides.get("search_text_embeddings", True) + search_image_embeddings = ( + overrides.get("search_image_embeddings", self.multimodal_enabled) and self.multimodal_enabled + ) + + vectors: list[VectorQuery] = [] + if use_vector_search: + if search_text_embeddings: + vectors.append(await self.compute_text_embedding(q)) + if search_image_embeddings: + vectors.append(await self.compute_multimodal_embedding(q)) + + results = await self.search( + top, + q, + filter, + vectors, + use_text_search, + use_vector_search, + use_semantic_ranker, + use_semantic_captions, + minimum_search_score, + minimum_reranker_score, + use_query_rewriting, + access_token, + ) + + data_points = await self.get_sources_content( + results, + use_semantic_captions, + include_text_sources=send_text_sources, + download_image_sources=send_image_sources, + user_oid=auth_claims.get("oid"), + ) + + return ExtraInfo( + data_points, + thoughts=[ + ThoughtStep( + "Search using user query", + q, + { + "use_semantic_captions": use_semantic_captions, + "use_semantic_ranker": use_semantic_ranker, + "use_query_rewriting": use_query_rewriting, + "top": top, + "filter": filter, + "use_vector_search": use_vector_search, + "use_text_search": use_text_search, + "search_text_embeddings": search_text_embeddings, + "search_image_embeddings": search_image_embeddings, + }, + ), + ThoughtStep( + "Search results", + [result.serialize_for_results() for result in results], + ), + ], + ) + + async def run_agentic_retrieval_approach( + self, + messages: list[ChatCompletionMessageParam], + overrides: dict[str, Any], + auth_claims: dict[str, Any], + ) -> ExtraInfo: + minimum_reranker_score = overrides.get("minimum_reranker_score", 0) + search_index_filter = self.build_filter(overrides) + access_token = auth_claims.get("access_token") + send_text_sources = overrides.get("send_text_sources", True) + send_image_sources = overrides.get("send_image_sources", self.multimodal_enabled) and self.multimodal_enabled + retrieval_reasoning_effort = overrides.get("retrieval_reasoning_effort", self.retrieval_reasoning_effort) + # Overrides can only disable web source support configured at construction time. + use_web_source = self.web_source_enabled + override_use_web_source = overrides.get("use_web_source") + if isinstance(override_use_web_source, bool): + use_web_source = use_web_source and override_use_web_source + # Overrides can only disable sharepoint source support configured at construction time. + use_sharepoint_source = self.use_sharepoint_source + override_use_sharepoint_source = overrides.get("use_sharepoint_source") + if isinstance(override_use_sharepoint_source, bool): + use_sharepoint_source = use_sharepoint_source and override_use_sharepoint_source + if use_web_source and retrieval_reasoning_effort == "minimal": + raise Exception("Web source cannot be used with minimal retrieval reasoning effort.") + + selected_client, effective_web_source, effective_sharepoint_source = self._select_knowledgebase_client( + use_web_source, + use_sharepoint_source, + ) + + agentic_results = await self.run_agentic_retrieval( + messages, + selected_client, + search_index_name=self.search_index_name, + filter_add_on=search_index_filter, + minimum_reranker_score=minimum_reranker_score, + access_token=access_token, + use_web_source=effective_web_source, + use_sharepoint_source=effective_sharepoint_source, + retrieval_reasoning_effort=retrieval_reasoning_effort, + should_rewrite_query=False, + ) + + data_points = await self.get_sources_content( + agentic_results.documents, + use_semantic_captions=False, + include_text_sources=send_text_sources, + download_image_sources=send_image_sources, + user_oid=auth_claims.get("oid"), + web_results=agentic_results.web_results, + sharepoint_results=agentic_results.sharepoint_results, + ) + return ExtraInfo( + data_points, + thoughts=agentic_results.thoughts, + answer=agentic_results.answer, + ) + + def _select_knowledgebase_client( + self, + use_web_source: bool, + use_sharepoint_source: bool, + ) -> tuple[KnowledgeBaseRetrievalClient, bool, bool]: + if use_web_source and use_sharepoint_source: + if self.knowledgebase_client_with_web_and_sharepoint: + return self.knowledgebase_client_with_web_and_sharepoint, True, True + if self.knowledgebase_client_with_web: + return self.knowledgebase_client_with_web, True, False + if self.knowledgebase_client_with_sharepoint: + return self.knowledgebase_client_with_sharepoint, False, True + + if use_web_source and self.knowledgebase_client_with_web: + return self.knowledgebase_client_with_web, True, False + + if use_sharepoint_source and self.knowledgebase_client_with_sharepoint: + return self.knowledgebase_client_with_sharepoint, False, True + + if self.knowledgebase_client: + return self.knowledgebase_client, False, False + + raise ValueError("Agentic retrieval requested but no agent client is configured") diff --git a/app/backend/config.py b/app/backend/config.py new file mode 100644 index 00000000..fec14924 --- /dev/null +++ b/app/backend/config.py @@ -0,0 +1,45 @@ +"""Docstring for config.""" +CONFIG_OPENAI_TOKEN = "openai_token" +CONFIG_CREDENTIAL = "azure_credential" +CONFIG_ASK_APPROACH = "ask_approach" +CONFIG_CHAT_APPROACH = "chat_approach" +CONFIG_GLOBAL_BLOB_MANAGER = "global_blob_manager" +CONFIG_USER_BLOB_MANAGER = "user_blob_manager" +CONFIG_USER_UPLOAD_ENABLED = "user_upload_enabled" +CONFIG_AUTH_CLIENT = "auth_client" +CONFIG_SEMANTIC_RANKER_DEPLOYED = "semantic_ranker_deployed" +CONFIG_QUERY_REWRITING_ENABLED = "query_rewriting_enabled" +CONFIG_REASONING_EFFORT_ENABLED = "reasoning_effort_enabled" +CONFIG_DEFAULT_REASONING_EFFORT = "default_reasoning_effort" +CONFIG_DEFAULT_RETRIEVAL_REASONING_EFFORT = "default_retrieval_reasoning_effort" +CONFIG_VECTOR_SEARCH_ENABLED = "vector_search_enabled" +CONFIG_SEARCH_CLIENT = "search_client" +CONFIG_OPENAI_CLIENT = "openai_client" +CONFIG_KNOWLEDGEBASE_CLIENT = "knowledgebase_client" +CONFIG_KNOWLEDGEBASE_CLIENT_WITH_WEB = "knowledgebase_client_with_web" +CONFIG_KNOWLEDGEBASE_CLIENT_WITH_SHAREPOINT = "knowledgebase_client_with_sharepoint" +CONFIG_KNOWLEDGEBASE_CLIENT_WITH_WEB_AND_SHAREPOINT = "knowledgebase_client_with_web_and_sharepoint" +CONFIG_INGESTER = "ingester" +CONFIG_LANGUAGE_PICKER_ENABLED = "language_picker_enabled" +CONFIG_SPEECH_INPUT_ENABLED = "speech_input_enabled" +CONFIG_SPEECH_OUTPUT_BROWSER_ENABLED = "speech_output_browser_enabled" +CONFIG_SPEECH_OUTPUT_AZURE_ENABLED = "speech_output_azure_enabled" +CONFIG_SPEECH_SERVICE_ID = "speech_service_id" +CONFIG_SPEECH_SERVICE_LOCATION = "speech_service_location" +CONFIG_SPEECH_SERVICE_TOKEN = "speech_service_token" +CONFIG_SPEECH_SERVICE_VOICE = "speech_service_voice" +CONFIG_STREAMING_ENABLED = "streaming_enabled" +CONFIG_CHAT_HISTORY_BROWSER_ENABLED = "chat_history_browser_enabled" +CONFIG_CHAT_HISTORY_COSMOS_ENABLED = "chat_history_cosmos_enabled" +CONFIG_AGENTIC_KNOWLEDGEBASE_ENABLED = "agentic_knowledgebase_enabled" +CONFIG_COSMOS_HISTORY_CLIENT = "cosmos_history_client" +CONFIG_COSMOS_HISTORY_CONTAINER = "cosmos_history_container" +CONFIG_COSMOS_HISTORY_VERSION = "cosmos_history_version" +CONFIG_MULTIMODAL_ENABLED = "multimodal_enabled" +CONFIG_ECHOVOICE_SEARCH_TEXT_TARGETS = "echovoice_search_text_targets" +CONFIG_ECHOVOICE_SEARCH_IMAGE_EMBEDDINGS = "echovoice_search_image_embeddings" +CONFIG_ECHOVOICE_SEND_TEXT_SOURCES = "echovoice_send_text_sources" +CONFIG_ECHOVOICE_SEND_IMAGE_SOURCES = "echovoice_send_image_sources" +CONFIG_WEB_SOURCE_ENABLED = "web_source_enabled" +CONFIG_SHAREPOINT_SOURCE_ENABLED = "sharepoint_source_enabled" +CONFIG_IMAGE_EMBEDDINGS_CLIENT = "image_embeddings_client" diff --git a/app/backend/core/__init__.py b/app/backend/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/backend/core/authentication.py b/app/backend/core/authentication.py new file mode 100644 index 00000000..61effb57 --- /dev/null +++ b/app/backend/core/authentication.py @@ -0,0 +1,279 @@ +# Refactored from https://github.com/Azure-Samples/ms-identity-python-on-behalf-of + +import base64 +import json +import logging +from typing import Any, Optional + +import aiohttp +import jwt +from azure.search.documents.aio import SearchClient +from azure.search.documents.indexes.models import SearchIndex +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from msal import ConfidentialClientApplication +from msal.token_cache import TokenCache +from tenacity import ( + AsyncRetrying, + retry_if_exception_type, + stop_after_attempt, + wait_random_exponential, +) + + +# AuthError is raised when the authentication token sent by the client UI cannot be parsed or there is an authentication error accessing the graph API +class AuthError(Exception): + def __init__(self, error, status_code): + self.error = error + self.status_code = status_code + + def __str__(self) -> str: + return self.error or "" + + +class AuthenticationHelper: + scope: str = "https://search.azure.com/.default" + + def __init__( + self, + search_index: Optional[SearchIndex], + use_authentication: bool, + server_app_id: Optional[str], + server_app_secret: Optional[str], + client_app_id: Optional[str], + tenant_id: Optional[str], + enforce_access_control: bool = False, + enable_unauthenticated_access: bool = False, + ): + self.use_authentication = use_authentication + self.server_app_id = server_app_id + self.server_app_secret = server_app_secret + self.client_app_id = client_app_id + self.tenant_id = tenant_id + self.authority = f"https://login.microsoftonline.com/{tenant_id}" + # Depending on if requestedAccessTokenVersion is 1 or 2, the issuer and audience of the token may be different + # See https://learn.microsoft.com/graph/api/resources/apiapplication + self.valid_issuers = [ + f"https://sts.windows.net/{tenant_id}/", + f"https://login.microsoftonline.com/{tenant_id}/v2.0", + ] + self.valid_audiences = [f"api://{server_app_id}", str(server_app_id)] + # See https://learn.microsoft.com/entra/identity-platform/access-tokens#validate-the-issuer for more information on token validation + self.key_url = f"{self.authority}/discovery/v2.0/keys" + + if self.use_authentication: + field_names = [field.name for field in search_index.fields] if search_index else [] + self.has_auth_fields = "oids" in field_names and "groups" in field_names + self.enforce_access_control = enforce_access_control + self.enable_unauthenticated_access = enable_unauthenticated_access + self.confidential_client = ConfidentialClientApplication( + server_app_id, authority=self.authority, client_credential=server_app_secret, token_cache=TokenCache() + ) + else: + self.has_auth_fields = False + self.enforce_access_control = False + self.enable_unauthenticated_access = True + + def get_auth_setup_for_client(self) -> dict[str, Any]: + # returns MSAL.js settings used by the client app + return { + "useLogin": self.use_authentication, # Whether or not login elements are enabled on the UI + "requireAccessControl": self.enforce_access_control, # Whether or not access control is required to access documents with access control lists + "enableUnauthenticatedAccess": self.enable_unauthenticated_access, # Whether or not the user can access the app without login + "msalConfig": { + "auth": { + "clientId": self.client_app_id, # Client app id used for login + "authority": self.authority, # Directory to use for login https://learn.microsoft.com/entra/identity-platform/msal-client-application-configuration#authority + "redirectUri": "/redirect", # Points to window.location.origin. You must register this URI on Azure Portal/App Registration. + "postLogoutRedirectUri": "/", # Indicates the page to navigate after logout. + "navigateToLoginRequestUrl": False, # If "true", will navigate back to the original request location before processing the auth code response. + }, + "cache": { + # Configures cache location. "sessionStorage" is more secure, but "localStorage" gives you SSO between tabs. + "cacheLocation": "localStorage", + # Set this to "true" if you are having issues on IE11 or Edge + "storeAuthStateInCookie": False, + }, + }, + "loginRequest": { + # Scopes you add here will be prompted for user consent during sign-in. + # By default, MSAL.js will add OIDC scopes (openid, profile, email) to any login request. + # For more information about OIDC scopes, visit: + # https://learn.microsoft.com/entra/identity-platform/permissions-consent-overview#openid-connect-scopes + "scopes": [".default"], + # Uncomment the following line to cause a consent dialog to appear on every login + # For more information, please visit https://learn.microsoft.com/entra/identity-platform/v2-oauth2-auth-code-flow#request-an-authorization-code + # "prompt": "consent" + }, + "tokenRequest": { + "scopes": [f"api://{self.server_app_id}/access_as_user"], + }, + } + + @staticmethod + def get_token_auth_header(headers: dict) -> str: + # Obtains the Access Token from the Authorization Header + auth = headers.get("Authorization") + if auth: + parts = auth.split() + + if parts[0].lower() != "bearer": + raise AuthError(error="Authorization header must start with Bearer", status_code=401) + elif len(parts) == 1: + raise AuthError(error="Token not found", status_code=401) + elif len(parts) > 2: + raise AuthError(error="Authorization header must be Bearer token", status_code=401) + + token = parts[1] + return token + + # App services built-in authentication passes the access token directly as a header + # To learn more, please visit https://learn.microsoft.com/azure/app-service/configure-authentication-oauth-tokens + token = headers.get("x-ms-token-aad-access-token") + if token: + return token + + raise AuthError(error="Authorization header is expected", status_code=401) + + async def get_auth_claims_if_enabled(self, headers: dict) -> dict[str, Any]: + if not self.use_authentication: + return {} + try: + # Read the authentication token from the authorization header and exchange it using the On Behalf Of Flow + # The scope is set to Azure Search for authentication + # https://learn.microsoft.com/entra/identity-platform/v2-oauth2-on-behalf-of-flow + auth_token = AuthenticationHelper.get_token_auth_header(headers) + # Validate the token before use + await self.validate_access_token(auth_token) + + # Use the on-behalf-of-flow to acquire another token for use with Azure Search + # See https://learn.microsoft.com/entra/identity-platform/v2-oauth2-on-behalf-of-flow for more information + search_resource_access_token = self.confidential_client.acquire_token_on_behalf_of( + user_assertion=auth_token, scopes=[self.scope] + ) + if "error" in search_resource_access_token: + raise AuthError(error=str(search_resource_access_token), status_code=401) + + id_token_claims = search_resource_access_token["id_token_claims"] + auth_claims = {"oid": id_token_claims["oid"]} + # Only pass on the access token if access control is required + # See https://learn.microsoft.com/azure/search/search-query-access-control-rbac-enforcement for more information + if self.enforce_access_control: + access_token = search_resource_access_token["access_token"] + auth_claims["access_token"] = access_token + return auth_claims + except AuthError as e: + logging.exception("Exception getting authorization information - " + json.dumps(e.error)) + if not self.enable_unauthenticated_access: + raise + return {} + except Exception: + logging.exception("Exception getting authorization information") + if not self.enable_unauthenticated_access: + raise + return {} + + async def check_path_auth(self, path: str, auth_claims: dict[str, Any], search_client: SearchClient) -> bool: + # If there was no access control or no path, then the path is allowed + if not self.enforce_access_control or len(path) == 0: + return True + + # Remove any fragment string from the path before checking + fragment_index = path.find("#") + if fragment_index != -1: + path = path[:fragment_index] + + # Filter down to only chunks that are from the specific source file + # Sourcepage is used for GPT-4V + # Replace ' with '' to escape the single quote for the filter + # https://learn.microsoft.com/azure/search/query-odata-filter-orderby-syntax#escaping-special-characters-in-string-constants + path_for_filter = path.replace("'", "''") + filter = f"(sourcefile eq '{path_for_filter}') or (sourcepage eq '{path_for_filter}')" + + # If the filter returns any results, the user is allowed to access the document + # Otherwise, access is denied + results = await search_client.search( + search_text="*", top=1, filter=filter, x_ms_query_source_authorization=auth_claims["access_token"] + ) + allowed = False + async for _ in results: + allowed = True + break + + return allowed + + async def create_pem_format(self, jwks, token): + unverified_header = jwt.get_unverified_header(token) + for key in jwks["keys"]: + if key["kid"] == unverified_header["kid"]: + # Construct the RSA public key + public_numbers = rsa.RSAPublicNumbers( + e=int.from_bytes(base64.urlsafe_b64decode(key["e"] + "=="), byteorder="big"), + n=int.from_bytes(base64.urlsafe_b64decode(key["n"] + "=="), byteorder="big"), + ) + public_key = public_numbers.public_key() + + # Convert to PEM format + pem_key = public_key.public_bytes( + encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo + ) + rsa_key = pem_key + return rsa_key + + # See https://github.com/Azure-Samples/ms-identity-python-on-behalf-of/blob/939be02b11f1604814532fdacc2c2eccd198b755/FlaskAPI/helpers/authorization.py#L44 + async def validate_access_token(self, token: str): + """ + Validate an access token is issued by Entra + """ + jwks = None + async for attempt in AsyncRetrying( + retry=retry_if_exception_type(AuthError), + wait=wait_random_exponential(min=15, max=60), + stop=stop_after_attempt(5), + ): + with attempt: + async with aiohttp.ClientSession() as session: + async with session.get(url=self.key_url) as resp: + resp_status = resp.status + if resp_status in [500, 502, 503, 504]: + raise AuthError( + error=f"Failed to get keys info: {await resp.text()}", status_code=resp_status + ) + jwks = await resp.json() + + if not jwks or "keys" not in jwks: + raise AuthError("Unable to get keys to validate auth token.", 401) + + rsa_key = None + issuer = None + audience = None + try: + unverified_claims = jwt.decode(token, options={"verify_signature": False}) + issuer = unverified_claims.get("iss") + audience = unverified_claims.get("aud") + rsa_key = await self.create_pem_format(jwks, token) + except jwt.PyJWTError as exc: + raise AuthError("Unable to parse authorization token.", 401) from exc + if not rsa_key: + raise AuthError("Unable to find appropriate key", 401) + + if issuer not in self.valid_issuers: + raise AuthError(f"Issuer {issuer} not in {','.join(self.valid_issuers)}", 401) + + if audience not in self.valid_audiences: + raise AuthError( + f"Audience {audience} not in {','.join(self.valid_audiences)}", + 401, + ) + + try: + jwt.decode(token, rsa_key, algorithms=["RS256"], audience=audience, issuer=issuer) + except jwt.ExpiredSignatureError as jwt_expired_exc: + raise AuthError("Token is expired", 401) from jwt_expired_exc + except (jwt.InvalidAudienceError, jwt.InvalidIssuerError) as jwt_claims_exc: + raise AuthError( + "Incorrect claims: please check the audience and issuer", + 401, + ) from jwt_claims_exc + except Exception as exc: + raise AuthError("Unable to parse authorization token.", 401) from exc diff --git a/app/backend/core/sessionhelper.py b/app/backend/core/sessionhelper.py new file mode 100644 index 00000000..ca3042a8 --- /dev/null +++ b/app/backend/core/sessionhelper.py @@ -0,0 +1,12 @@ +import uuid +from typing import Optional + + +def create_session_id( + config_chat_history_cosmos_enabled: bool, config_chat_history_browser_enabled: bool +) -> Optional[str]: + if config_chat_history_cosmos_enabled: + return str(uuid.uuid4()) + if config_chat_history_browser_enabled: + return str(uuid.uuid4()) + return None diff --git a/app/backend/custom_uvicorn_worker.py b/app/backend/custom_uvicorn_worker.py new file mode 100644 index 00000000..851be982 --- /dev/null +++ b/app/backend/custom_uvicorn_worker.py @@ -0,0 +1,47 @@ +from uvicorn.workers import UvicornWorker + +logconfig_dict = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "default": { + "()": "uvicorn.logging.DefaultFormatter", + "format": "%(asctime)s - %(levelname)s - %(message)s", + }, + "access": { + "()": "uvicorn.logging.AccessFormatter", + "format": "%(asctime)s - %(message)s", + }, + }, + "handlers": { + "default": { + "formatter": "default", + "class": "logging.StreamHandler", + "stream": "ext://sys.stderr", + }, + "access": { + "formatter": "access", + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", + }, + }, + "loggers": { + "root": {"handlers": ["default"]}, + "uvicorn.error": { + "level": "INFO", + "handlers": ["default"], + "propagate": False, + }, + "uvicorn.access": { + "level": "INFO", + "handlers": ["access"], + "propagate": False, + }, + }, +} + + +class CustomUvicornWorker(UvicornWorker): + CONFIG_KWARGS = { + "log_config": logconfig_dict, + } diff --git a/app/backend/error.py b/app/backend/error.py new file mode 100644 index 00000000..7718e9ab --- /dev/null +++ b/app/backend/error.py @@ -0,0 +1,27 @@ +import logging + +from openai import APIError +from quart import jsonify + +ERROR_MESSAGE = """The app encountered an error processing your request. +If you are an administrator of the app, check the application logs for a full traceback. +Error type: {error_type} +""" +ERROR_MESSAGE_FILTER = """Your message contains content that was flagged by the OpenAI content filter.""" + +ERROR_MESSAGE_LENGTH = """Your message exceeded the context length limit for this OpenAI model. Please shorten your message or change your settings to retrieve fewer search results.""" + + +def error_dict(error: Exception) -> dict: + if isinstance(error, APIError) and error.code == "content_filter": + return {"error": ERROR_MESSAGE_FILTER} + if isinstance(error, APIError) and error.code == "context_length_exceeded": + return {"error": ERROR_MESSAGE_LENGTH} + return {"error": ERROR_MESSAGE.format(error_type=type(error))} + + +def error_response(error: Exception, route: str, status_code: int = 500): + logging.exception("Exception in %s: %s", route, error) + if isinstance(error, APIError) and error.code == "content_filter": + status_code = 400 + return jsonify(error_dict(error)), status_code diff --git a/app/backend/gunicorn.conf.py b/app/backend/gunicorn.conf.py new file mode 100644 index 00000000..9144e3cc --- /dev/null +++ b/app/backend/gunicorn.conf.py @@ -0,0 +1,18 @@ +import multiprocessing +import os + +max_requests = 1000 +max_requests_jitter = 50 +log_file = "-" +bind = "0.0.0.0" + +timeout = 230 +# https://learn.microsoft.com/troubleshoot/azure/app-service/web-apps-performance-faqs#why-does-my-request-time-out-after-230-seconds + +num_cpus = multiprocessing.cpu_count() +if os.getenv("WEBSITE_SKU") == "LinuxFree": + # Free tier reports 2 CPUs but can't handle multiple workers + workers = 1 +else: + workers = (num_cpus * 2) + 1 +worker_class = "custom_uvicorn_worker.CustomUvicornWorker" diff --git a/app/backend/main.py b/app/backend/main.py new file mode 100644 index 00000000..7af999ea --- /dev/null +++ b/app/backend/main.py @@ -0,0 +1,13 @@ +import os + +from services.load_azd_env import load_azd_env + +# WEBSITE_HOSTNAME is always set by App Service, RUNNING_IN_PRODUCTION is set in main.bicep +RUNNING_ON_AZURE = os.getenv("WEBSITE_HOSTNAME") is not None or os.getenv("RUNNING_IN_PRODUCTION") is not None + +if not RUNNING_ON_AZURE: + load_azd_env() + +# Use FastAPI app as the single entrypoint for this repository's backend. +# If the import fails, surface the error so the developer can fix the migration. +from api.main import app # type: ignore diff --git a/app/backend/prepdocslib/Jupiteroid-Regular.ttf b/app/backend/prepdocslib/Jupiteroid-Regular.ttf new file mode 100644 index 00000000..28926992 Binary files /dev/null and b/app/backend/prepdocslib/Jupiteroid-Regular.ttf differ diff --git a/app/backend/prepdocslib/__init__.py b/app/backend/prepdocslib/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/backend/prepdocslib/blobmanager.py b/app/backend/prepdocslib/blobmanager.py new file mode 100644 index 00000000..e02695b8 --- /dev/null +++ b/app/backend/prepdocslib/blobmanager.py @@ -0,0 +1,541 @@ +import io +import logging +import os +import re +from pathlib import Path +from typing import IO, Any, Optional, TypedDict +from urllib.parse import unquote + +from azure.core.credentials_async import AsyncTokenCredential +from azure.core.exceptions import ResourceNotFoundError +from azure.storage.blob.aio import BlobServiceClient +from azure.storage.filedatalake.aio import ( + DataLakeDirectoryClient, + FileSystemClient, +) +from PIL import Image, ImageDraw, ImageFont + +from .listfilestrategy import File + +logger = logging.getLogger("scripts") + + +class BlobProperties(TypedDict, total=False): + """Properties of a blob, with optional fields for content settings""" + + content_settings: dict[str, Any] + + +class BaseBlobManager: + """ + Base class for Azure Storage operations, providing common file naming and path utilities + """ + + @classmethod + def sourcepage_from_file_page(cls, filename, page=0) -> str: + if os.path.splitext(filename)[1].lower() == ".pdf": + return f"{os.path.basename(filename)}#page={page+1}" + else: + return os.path.basename(filename) + + @classmethod + def blob_name_from_file_name(cls, filename) -> str: + return os.path.basename(filename) + + @classmethod + def add_image_citation( + cls, image_bytes: bytes, document_filename: str, image_filename: str, page_num: int + ) -> bytes: + """ + Adds citation text to an image from a document. + Args: + image_bytes: The original image bytes + document_filename: The name of the document containing the image + image_filename: The name of the image file + page_num: The page number where the image appears + Returns: + A tuple containing (BytesIO of the modified image, format of the image) + """ + # Load and modify the image to add text + image = Image.open(io.BytesIO(image_bytes)) + line_height = 30 + text_height = line_height * 2 # Two lines of text + new_img = Image.new("RGB", (image.width, image.height + text_height), "white") + new_img.paste(image, (0, text_height)) + + # Add text + draw = ImageDraw.Draw(new_img) + sourcepage = cls.sourcepage_from_file_page(document_filename, page=page_num) + text = sourcepage + figure_text = image_filename + + # Load the Jupiteroid font which is included in the repo + font_path = Path(__file__).parent / "Jupiteroid-Regular.ttf" + font = ImageFont.truetype(str(font_path), 20) # Slightly smaller font for better fit + + # Calculate text widths for right alignment + fig_width = draw.textlength(figure_text, font=font) + + # Left align document name, right align figure name + padding = 20 # Padding from edges + draw.text((padding, 5), text, font=font, fill="black") # Left aligned + draw.text( + (new_img.width - fig_width - padding, line_height + 5), figure_text, font=font, fill="black" + ) # Right aligned + + # Convert back to bytes + output = io.BytesIO() + format = image.format or "PNG" + new_img.save(output, format=format) + + return output.getvalue() + + async def upload_document_image( + self, + document_filename: str, + image_bytes: bytes, + image_filename: str, + image_page_num: int, + user_oid: Optional[str] = None, + ) -> Optional[str]: + raise NotImplementedError("Subclasses must implement this method") + + async def download_blob( + self, blob_path: str, user_oid: Optional[str] = None + ) -> Optional[tuple[bytes, BlobProperties]]: + """ + Downloads a blob from Azure Storage. + If user_oid is provided, it checks if the blob belongs to the user. + + Args: + blob_path: The path to the blob in the storage + user_oid: The user's object ID (optional) + + Returns: + Optional[tuple[bytes, BlobProperties]]: + - A tuple containing the blob content as bytes and the blob properties + - None if blob not found or access denied + """ + raise NotImplementedError("Subclasses must implement this method") + + +class AdlsBlobManager(BaseBlobManager): + """ + Manager for Azure Data Lake Storage blob operations, particularly for user-specific file operations. + Documents are stored directly in the user's directory for backwards compatibility. + Images are stored in a separate images subdirectory for better organization. + """ + + def __init__(self, endpoint: str, container: str, credential: AsyncTokenCredential): + """ + Initializes the AdlsBlobManager with the necessary parameters. + + Args: + endpoint: The ADLS endpoint URL + container: The name of the container (file system) + credential: The credential for accessing ADLS + """ + self.endpoint = endpoint + self.container = container + self.credential = credential + self.file_system_client = FileSystemClient( + account_url=self.endpoint, + file_system_name=self.container, + credential=self.credential, + ) + + async def close_clients(self): + await self.file_system_client.close() + + async def _ensure_directory(self, directory_path: str, user_oid: str) -> DataLakeDirectoryClient: + """ + Ensures that a directory path exists and has proper permissions. + Creates the entire path in a single operation if it doesn't exist. + + Args: + directory_path: Full path of directory to create (e.g., 'user123/images/mydoc') + user_oid: The owner to set for all created directories + """ + directory_client = self.file_system_client.get_directory_client(directory_path) + try: + await directory_client.get_directory_properties() + # Check directory properties to ensure it has the correct owner + props = await directory_client.get_access_control() + if props.get("owner") != user_oid: + raise PermissionError(f"User {user_oid} does not have permission to access {directory_path}") + except ResourceNotFoundError: + logger.info("Creating directory path %s", directory_path) + await directory_client.create_directory() + await directory_client.set_access_control(owner=user_oid) + return directory_client + + async def upload_blob(self, file: File | IO, filename: str, user_oid: str) -> str: + """ + Uploads a file directly to the user's directory in ADLS (no subdirectory). + + Args: + file: Either a File object or an IO object to upload + filename: The name of the file to upload + user_oid: The user's object ID + + Returns: + str: The URL of the uploaded file, with forward slashes (not URL-encoded) + """ + # Ensure user directory exists but don't create a subdirectory + user_directory_client = await self._ensure_directory(directory_path=user_oid, user_oid=user_oid) + + # Create file directly in user directory + file_client = user_directory_client.get_file_client(filename) + + # Handle both File and IO objects + if isinstance(file, File): + file_io = file.content + else: + file_io = file + + # Ensure the file is at the beginning + file_io.seek(0) + + await file_client.upload_data(file_io, overwrite=True) + + # Reset the file position for any subsequent reads + file_io.seek(0) + + # Decode the URL to convert %2F back to / and other escaped characters + return unquote(file_client.url) + + def _get_image_directory_path(self, document_filename: str, user_oid: str, page_num: Optional[int] = None) -> str: + """ + Returns the standardized path for storing document images. + + Args: + document_filename: The name of the document + user_oid: The user's object ID + page_num: Optional page number. If provided, includes a page-specific subdirectory + + Returns: + str: Full path to the image directory + """ + if page_num is not None: + return f"{user_oid}/images/{document_filename}/page_{page_num}" + return f"{user_oid}/images/{document_filename}" + + async def upload_document_image( + self, + document_filename: str, + image_bytes: bytes, + image_filename: str, + image_page_num: int, + user_oid: Optional[str] = None, + ) -> Optional[str]: + """ + Uploads an image from a document to ADLS in a directory structure: + {user_oid}/{document_name}/images/{image_name} + This structure allows for easy cleanup when documents are deleted. + + Args: + document_filename: The name of the document containing the image + image_bytes: The image data to upload + image_filename: The name to give the image file + image_page_num: The page number where the image appears in the document + user_oid: The user's object ID + + Returns: + str: The URL of the uploaded file, with forward slashes (not URL-encoded) + """ + if user_oid is None: + raise ValueError("user_oid must be provided for user-specific operations.") + await self._ensure_directory(directory_path=user_oid, user_oid=user_oid) + image_directory_path = self._get_image_directory_path(document_filename, user_oid, image_page_num) + image_directory_client = await self._ensure_directory(directory_path=image_directory_path, user_oid=user_oid) + file_client = image_directory_client.get_file_client(image_filename) + image_bytes = BaseBlobManager.add_image_citation(image_bytes, document_filename, image_filename, image_page_num) + logger.info("Uploading document image '%s' to '%s'", image_filename, image_directory_path) + await file_client.upload_data(image_bytes, overwrite=True, metadata={"UploadedBy": user_oid}) + return unquote(file_client.url) + + async def download_blob( + self, blob_path: str, user_oid: Optional[str] = None + ) -> Optional[tuple[bytes, BlobProperties]]: + """ + Downloads a blob from Azure Data Lake Storage. + + Args: + blob_path: The path to the blob in the format {user_oid}/{document_name}/images/{image_name} + user_oid: The user's object ID + + Returns: + Optional[tuple[bytes, BlobProperties]]: + - A tuple containing the blob content as bytes and the blob properties as a dictionary + - None if blob not found or access denied + """ + if user_oid is None: + logger.warning("user_oid must be provided for Data Lake Storage operations.") + return None + + # Get the directory path and file name from the blob path + path_parts = blob_path.split("/") + if len(path_parts) < 2: + # If no slashes in path, we assume it's a file in the user's root directory + filename = blob_path + directory_path = user_oid + else: + # First verify that the root directory matches the user_oid + root_dir = path_parts[0] + if root_dir != user_oid: + logger.warning(f"User {user_oid} does not have permission to access {blob_path}") + return None + + # Get the directory client for the full path except the filename + directory_path = "/".join(path_parts[:-1]) + filename = path_parts[-1] + + try: + user_directory_client = await self._ensure_directory(directory_path=directory_path, user_oid=user_oid) + file_client = user_directory_client.get_file_client(filename) + download_response = await file_client.download_file() + content = await download_response.readall() + + # Convert FileProperties to our BlobProperties format + properties: BlobProperties = { + "content_settings": { + "content_type": download_response.properties.get("content_type", "application/octet-stream") + } + } + + return content, properties + except ResourceNotFoundError: + logger.warning(f"Directory or file not found: {directory_path}/{filename}") + return None + except Exception as e: + logging.error(f"Error accessing directory {directory_path}: {str(e)}") + return None + + async def remove_blob(self, filename: str, user_oid: str) -> None: + """ + Deletes a file from the user's directory in ADLS and any associated image directories. + The following will be deleted: + - {user_oid}/{filename} + - {user_oid}/images/{filename}/* (recursively) + + Args: + filename: The name of the file to delete + user_oid: The user's object ID + + Raises: + ResourceNotFoundError: If the file does not exist + """ + # Ensure the user directory exists + user_directory_client = await self._ensure_directory(directory_path=user_oid, user_oid=user_oid) + # Delete the main document file + file_client = user_directory_client.get_file_client(filename) + await file_client.delete_file() + + # Try to delete any associated image directories + image_directory_path = self._get_image_directory_path(filename, user_oid) + try: + image_directory_client = await self._ensure_directory( + directory_path=image_directory_path, user_oid=user_oid + ) + await image_directory_client.delete_directory() + logger.info(f"Deleted associated image directory: {image_directory_path}") + except ResourceNotFoundError: + # It's okay if there was no image directory + logger.debug(f"No image directory found at {image_directory_path}") + pass + + async def list_blobs(self, user_oid: str) -> list[str]: + """ + Lists the uploaded documents for the given user. + Only returns files directly in the user's directory, not in subdirectories. + Excludes image files and the images directory. + + Args: + user_oid: The user's object ID + + Returns: + list[str]: List of filenames that belong to the user + """ + await self._ensure_directory(directory_path=user_oid, user_oid=user_oid) + files = [] + try: + all_paths = self.file_system_client.get_paths(path=user_oid, recursive=True) + async for path in all_paths: + # Split path into parts (user_oid/filename or user_oid/directory/files) + path_parts = path.name.split("/", 1) + if len(path_parts) != 2: + continue + + filename = path_parts[1] + # Only include files that are: + # 1. Directly in the user's directory (no additional slashes) + # 2. Not image files + # 3. Not in a directory containing 'images' + if ( + "/" not in filename + and not any(filename.lower().endswith(ext) for ext in [".png", ".jpg", ".jpeg", ".gif", ".bmp"]) + and "images" not in filename + ): + files.append(filename) + except ResourceNotFoundError as error: + if error.status_code != 404: + logger.exception("Error listing uploaded files", error) + # Return empty list for 404 (no directory) as this is expected for new users + return files + + +class BlobManager(BaseBlobManager): + """ + Class to manage uploading and deleting blobs containing citation information from a blob storage account + """ + + def __init__( + self, + endpoint: str, + container: str, + credential: AsyncTokenCredential | str, + image_container: Optional[str] = None, + account: Optional[str] = None, + resource_group: Optional[str] = None, + subscription_id: Optional[str] = None, + ): + self.endpoint = endpoint + self.credential = credential + self.account = account + self.container = container + self.resource_group = resource_group + self.subscription_id = subscription_id + self.image_container = image_container + self.blob_service_client = BlobServiceClient( + account_url=self.endpoint, credential=self.credential, max_single_put_size=4 * 1024 * 1024 + ) + + async def close_clients(self): + await self.blob_service_client.close() + + def get_managedidentity_connectionstring(self): + if not self.account or not self.resource_group or not self.subscription_id: + raise ValueError("Account, resource group, and subscription ID must be set to generate connection string.") + return f"ResourceId=/subscriptions/{self.subscription_id}/resourceGroups/{self.resource_group}/providers/Microsoft.Storage/storageAccounts/{self.account};" + + async def upload_blob(self, file: File) -> str: + container_client = self.blob_service_client.get_container_client(self.container) + if not await container_client.exists(): + await container_client.create_container() + + # Re-open and upload the original file + # URL may be a path to a local file or already set to a blob URL + if file.url is None or os.path.exists(file.url): + with open(file.content.name, "rb") as reopened_file: + blob_name = self.blob_name_from_file_name(file.content.name) + logger.info("Uploading blob for document '%s'", blob_name) + blob_client = await container_client.upload_blob(blob_name, reopened_file, overwrite=True) + file.url = blob_client.url + + if file.url is None: + raise ValueError("file.url must be set after upload") + return unquote(file.url) + + async def upload_document_image( + self, + document_filename: str, + image_bytes: bytes, + image_filename: str, + image_page_num: int, + user_oid: Optional[str] = None, + ) -> Optional[str]: + if self.image_container is None: + raise ValueError( + "Image container name is not set. Re-run `azd provision` to automatically set up the images container." + ) + if user_oid is not None: + raise ValueError( + "user_oid is not supported for BlobManager. Use AdlsBlobManager for user-specific operations." + ) + container_client = self.blob_service_client.get_container_client(self.image_container) + if not await container_client.exists(): + await container_client.create_container() + image_bytes = self.add_image_citation(image_bytes, document_filename, image_filename, image_page_num) + blob_name = f"{self.blob_name_from_file_name(document_filename)}/page{image_page_num}/{image_filename}" + logger.info("Uploading blob for document image '%s'", blob_name) + blob_client = await container_client.upload_blob(blob_name, image_bytes, overwrite=True) + return blob_client.url + + async def download_blob( + self, blob_path: str, user_oid: Optional[str] = None + ) -> Optional[tuple[bytes, BlobProperties]]: + """ + Downloads a blob from Azure Blob Storage. + + Args: + blob_path: The path to the blob in the storage + user_oid: Not used in BlobManager, but included for API compatibility + + Returns: + Optional[tuple[bytes, BlobProperties]]: + - A tuple containing the blob content as bytes and the blob properties + - None if blob not found + + Raises: + ValueError: If user_oid is provided (not supported for BlobManager) + """ + if user_oid is not None: + raise ValueError( + "user_oid is not supported for BlobManager. Use AdlsBlobManager for user-specific operations." + ) + container_client = self.blob_service_client.get_container_client(self.container) + if not await container_client.exists(): + return None + if len(blob_path) == 0: + logger.warning("Blob path is empty") + return None + + blob_client = container_client.get_blob_client(blob_path) + try: + download_response = await blob_client.download_blob() + if not download_response.properties: + logger.warning(f"No blob exists for {blob_path}") + return None + + # Get the content as bytes + content = await download_response.readall() + + # Convert BlobProperties to our internal BlobProperties format + properties: BlobProperties = { + "content_settings": { + "content_type": ( + download_response.properties.content_settings.content_type + if ( + hasattr(download_response.properties, "content_settings") + and download_response.properties.content_settings + and hasattr(download_response.properties.content_settings, "content_type") + ) + else "application/octet-stream" + ) + } + } + + return content, properties + except ResourceNotFoundError: + logger.warning("Blob not found: %s", blob_path) + return None + + async def remove_blob(self, path: Optional[str] = None): + container_client = self.blob_service_client.get_container_client(self.container) + if not await container_client.exists(): + return + if path is None: + prefix = None + blobs = container_client.list_blob_names() + else: + prefix = os.path.splitext(os.path.basename(path))[0] + blobs = container_client.list_blob_names(name_starts_with=os.path.splitext(os.path.basename(prefix))[0]) + async for blob_path in blobs: + # This still supports PDFs split into individual pages, but we could remove in future to simplify code + if ( + prefix is not None + and (not re.match(rf"{prefix}-\d+\.pdf", blob_path) or not re.match(rf"{prefix}-\d+\.png", blob_path)) + ) or (path is not None and blob_path == os.path.basename(path)): + continue + logger.info("Removing blob %s", blob_path) + await container_client.delete_blob(blob_path) diff --git a/app/backend/prepdocslib/cloudingestionstrategy.py b/app/backend/prepdocslib/cloudingestionstrategy.py new file mode 100644 index 00000000..2da9c367 --- /dev/null +++ b/app/backend/prepdocslib/cloudingestionstrategy.py @@ -0,0 +1,330 @@ +"""Cloud ingestion strategy using Azure AI Search custom skills.""" + +import logging +from dataclasses import dataclass +from datetime import timedelta + +from azure.search.documents.indexes._generated.models import ( + NativeBlobSoftDeleteDeletionDetectionPolicy, +) +from azure.search.documents.indexes.models import ( + IndexingParameters, + IndexingParametersConfiguration, + IndexProjectionMode, + InputFieldMappingEntry, + OutputFieldMappingEntry, + SearchIndexer, + SearchIndexerDataContainer, + SearchIndexerDataSourceConnection, + SearchIndexerDataSourceType, + SearchIndexerDataUserAssignedIdentity, + SearchIndexerIndexProjection, + SearchIndexerIndexProjectionSelector, + SearchIndexerIndexProjectionsParameters, + SearchIndexerSkillset, + ShaperSkill, + WebApiSkill, +) + +from .blobmanager import BlobManager +from .embeddings import OpenAIEmbeddings +from .listfilestrategy import ListFileStrategy +from .searchmanager import SearchManager +from .strategy import DocumentAction, SearchInfo, Strategy + +logger = logging.getLogger("scripts") + +DEFAULT_SKILL_TIMEOUT = timedelta(seconds=230) +DEFAULT_BATCH_SIZE = 1 + + +@dataclass(slots=True) +class SkillConfig: + """Configuration for a custom Web API skill.""" + + name: str + description: str + uri: str + auth_resource_id: str + + +class CloudIngestionStrategy(Strategy): # pragma: no cover + """Ingestion strategy that wires Azure Function custom skills into an indexer.""" + + def __init__( + self, + *, + list_file_strategy: ListFileStrategy, + blob_manager: BlobManager, + search_info: SearchInfo, + embeddings: OpenAIEmbeddings, + search_field_name_embedding: str, + document_extractor_uri: str, + document_extractor_auth_resource_id: str, + figure_processor_uri: str, + figure_processor_auth_resource_id: str, + text_processor_uri: str, + text_processor_auth_resource_id: str, + subscription_id: str, + document_action: DocumentAction = DocumentAction.Add, + search_analyzer_name: str | None = None, + use_acls: bool = False, + use_multimodal: bool = False, + enforce_access_control: bool = False, + use_web_source: bool = False, + search_user_assigned_identity_resource_id: str, + ) -> None: + self.list_file_strategy = list_file_strategy + self.blob_manager = blob_manager + self.document_action = document_action + self.embeddings = embeddings + self.search_field_name_embedding = search_field_name_embedding + self.search_info = search_info + self.search_analyzer_name = search_analyzer_name + self.use_acls = use_acls + self.use_multimodal = use_multimodal + self.enforce_access_control = enforce_access_control + self.use_web_source = use_web_source + self.subscription_id = subscription_id + + prefix = f"{self.search_info.index_name}-cloud" + self.skillset_name = f"{prefix}-skillset" + self.indexer_name = f"{prefix}-indexer" + self.data_source_name = f"{prefix}-blob" + + self.document_extractor = SkillConfig( + name=f"{prefix}-document-extractor-skill", + description="Custom skill that downloads and parses source documents", + uri=document_extractor_uri, + auth_resource_id=document_extractor_auth_resource_id, + ) + self.figure_processor = SkillConfig( + name=f"{prefix}-figure-processor-skill", + description="Custom skill that enriches individual figures", + uri=figure_processor_uri, + auth_resource_id=figure_processor_auth_resource_id, + ) + self.text_processor = SkillConfig( + name=f"{prefix}-text-processor-skill", + description="Custom skill that merges figures, chunks text, and generates embeddings", + uri=text_processor_uri, + auth_resource_id=text_processor_auth_resource_id, + ) + + self._search_manager: SearchManager | None = None + self.search_user_assigned_identity_resource_id = search_user_assigned_identity_resource_id + + def _build_skillset(self) -> SearchIndexerSkillset: + prefix = f"{self.search_info.index_name}-cloud" + + # NOTE: Do NOT map the chunk id directly to the index key field. Azure AI Search + # index projections forbid mapping an input field onto the target index key when + # using parent/child projections. The service will generate keys for projected + # child documents automatically. Removing the explicit 'id' mapping resolves + # HttpResponseError: "Input 'id' cannot map to the key field". + mappings = [ + InputFieldMappingEntry(name="content", source="/document/chunks/*/content"), + InputFieldMappingEntry(name="sourcepage", source="/document/chunks/*/sourcepage"), + InputFieldMappingEntry(name="sourcefile", source="/document/chunks/*/sourcefile"), + InputFieldMappingEntry(name=self.search_field_name_embedding, source="/document/chunks/*/embedding"), + InputFieldMappingEntry(name="storageUrl", source="/document/metadata_storage_path"), + ] + if self.use_multimodal: + mappings.append(InputFieldMappingEntry(name="images", source="/document/chunks/*/images")) + + index_projection = SearchIndexerIndexProjection( + selectors=[ + SearchIndexerIndexProjectionSelector( + target_index_name=self.search_info.index_name, + parent_key_field_name="parent_id", + source_context="/document/chunks/*", + mappings=mappings, + ) + ], + parameters=SearchIndexerIndexProjectionsParameters( + projection_mode=IndexProjectionMode.SKIP_INDEXING_PARENT_DOCUMENTS, + ), + ) + + document_extractor_skill = WebApiSkill( + name=self.document_extractor.name, + description=self.document_extractor.description, + context="/document", + uri=self.document_extractor.uri, + http_method="POST", + timeout=DEFAULT_SKILL_TIMEOUT, + batch_size=DEFAULT_BATCH_SIZE, + degree_of_parallelism=1, + # Managed identity: Search service authenticates against the function app using this resource ID. + auth_resource_id=self.document_extractor.auth_resource_id, + auth_identity=SearchIndexerDataUserAssignedIdentity( + resource_id=self.search_user_assigned_identity_resource_id + ), + inputs=[ + # Provide the binary payload expected by the document extractor custom skill. + InputFieldMappingEntry(name="file_data", source="/document/file_data"), + InputFieldMappingEntry(name="file_name", source="/document/metadata_storage_name"), + InputFieldMappingEntry(name="content_type", source="/document/metadata_storage_content_type"), + ], + outputs=[ + OutputFieldMappingEntry(name="pages", target_name="pages"), + OutputFieldMappingEntry(name="figures", target_name="figures"), + ], + ) + + figure_processor_skill = WebApiSkill( + name=self.figure_processor.name, + description=self.figure_processor.description, + context="/document/figures/*", + uri=self.figure_processor.uri, + http_method="POST", + timeout=DEFAULT_SKILL_TIMEOUT, + batch_size=DEFAULT_BATCH_SIZE, + degree_of_parallelism=1, + # Managed identity: Search service authenticates against the function app using this resource ID. + auth_resource_id=self.figure_processor.auth_resource_id, + auth_identity=SearchIndexerDataUserAssignedIdentity( + resource_id=self.search_user_assigned_identity_resource_id + ), + inputs=[ + InputFieldMappingEntry(name="figure_id", source="/document/figures/*/figure_id"), + InputFieldMappingEntry(name="document_file_name", source="/document/figures/*/document_file_name"), + InputFieldMappingEntry(name="filename", source="/document/figures/*/filename"), + InputFieldMappingEntry(name="mime_type", source="/document/figures/*/mime_type"), + InputFieldMappingEntry(name="bytes_base64", source="/document/figures/*/bytes_base64"), + InputFieldMappingEntry(name="page_num", source="/document/figures/*/page_num"), + InputFieldMappingEntry(name="bbox", source="/document/figures/*/bbox"), + InputFieldMappingEntry(name="placeholder", source="/document/figures/*/placeholder"), + InputFieldMappingEntry(name="title", source="/document/figures/*/title"), + ], + outputs=[ + # Only output the enriched fields to avoid cyclic dependency + OutputFieldMappingEntry(name="description", target_name="description"), + OutputFieldMappingEntry(name="url", target_name="url"), + OutputFieldMappingEntry(name="embedding", target_name="embedding"), + ], + ) + + # Shaper skill to consolidate pages and enriched figures into a single object + shaper_skill = ShaperSkill( + name=f"{prefix}-document-shaper-skill", + description="Consolidates pages and enriched figures into a single document object", + context="/document", + inputs=[ + InputFieldMappingEntry(name="pages", source="/document/pages"), + InputFieldMappingEntry( + name="figures", + source_context="/document/figures/*", + inputs=[ + InputFieldMappingEntry(name="figure_id", source="/document/figures/*/figure_id"), + InputFieldMappingEntry( + name="document_file_name", source="/document/figures/*/document_file_name" + ), + InputFieldMappingEntry(name="filename", source="/document/figures/*/filename"), + InputFieldMappingEntry(name="mime_type", source="/document/figures/*/mime_type"), + InputFieldMappingEntry(name="page_num", source="/document/figures/*/page_num"), + InputFieldMappingEntry(name="bbox", source="/document/figures/*/bbox"), + InputFieldMappingEntry(name="placeholder", source="/document/figures/*/placeholder"), + InputFieldMappingEntry(name="title", source="/document/figures/*/title"), + InputFieldMappingEntry(name="description", source="/document/figures/*/description"), + InputFieldMappingEntry(name="url", source="/document/figures/*/url"), + InputFieldMappingEntry(name="embedding", source="/document/figures/*/embedding"), + ], + ), + InputFieldMappingEntry(name="file_name", source="/document/metadata_storage_name"), + InputFieldMappingEntry(name="storageUrl", source="/document/metadata_storage_path"), + ], + outputs=[OutputFieldMappingEntry(name="output", target_name="consolidated_document")], + ) + + text_processor_skill = WebApiSkill( + name=self.text_processor.name, + description=self.text_processor.description, + context="/document", + uri=self.text_processor.uri, + http_method="POST", + timeout=DEFAULT_SKILL_TIMEOUT, + batch_size=DEFAULT_BATCH_SIZE, + degree_of_parallelism=1, + # Managed identity: Search service authenticates against the function app using this resource ID. + auth_resource_id=self.text_processor.auth_resource_id, + auth_identity=SearchIndexerDataUserAssignedIdentity( + resource_id=self.search_user_assigned_identity_resource_id + ), + inputs=[ + InputFieldMappingEntry(name="consolidated_document", source="/document/consolidated_document"), + ], + outputs=[OutputFieldMappingEntry(name="chunks", target_name="chunks")], + ) + + return SearchIndexerSkillset( + name=self.skillset_name, + description="Skillset linking document extraction, figure enrichment, and text processing functions", + skills=[document_extractor_skill, figure_processor_skill, shaper_skill, text_processor_skill], + index_projection=index_projection, + ) + + async def setup(self) -> None: + logger.info("Setting up search index and skillset for cloud ingestion") + + if not self.embeddings.azure_endpoint or not self.embeddings.azure_deployment_name: + raise ValueError("Cloud ingestion requires Azure OpenAI endpoint and deployment") + + if not isinstance(self.embeddings, OpenAIEmbeddings): + raise TypeError("Cloud ingestion requires Azure OpenAI embeddings to configure the search index.") + + self._search_manager = SearchManager( + search_info=self.search_info, + search_analyzer_name=self.search_analyzer_name, + use_acls=self.use_acls, + use_parent_index_projection=True, + embeddings=self.embeddings, + field_name_embedding=self.search_field_name_embedding, + search_images=self.use_multimodal, + enforce_access_control=self.enforce_access_control, + use_web_source=self.use_web_source, + ) + + await self._search_manager.create_index() + + async with self.search_info.create_search_indexer_client() as indexer_client: + data_source_connection = SearchIndexerDataSourceConnection( + name=self.data_source_name, + type=SearchIndexerDataSourceType.AZURE_BLOB, + connection_string=self.blob_manager.get_managedidentity_connectionstring(), + container=SearchIndexerDataContainer(name=self.blob_manager.container), + data_deletion_detection_policy=NativeBlobSoftDeleteDeletionDetectionPolicy(), + ) + await indexer_client.create_or_update_data_source_connection(data_source_connection) + + skillset = self._build_skillset() + await indexer_client.create_or_update_skillset(skillset) + + indexer = SearchIndexer( + name=self.indexer_name, + description="Indexer orchestrating cloud ingestion pipeline", + data_source_name=self.data_source_name, + target_index_name=self.search_info.index_name, + skillset_name=self.skillset_name, + parameters=IndexingParameters( + configuration=IndexingParametersConfiguration( + query_timeout=None, # type: ignore + data_to_extract="storageMetadata", + allow_skillset_to_read_file_data=True, + ) + ), + ) + await indexer_client.create_or_update_indexer(indexer) + + async def run(self) -> None: + files = self.list_file_strategy.list() + async for file in files: + try: + await self.blob_manager.upload_blob(file) + finally: + if file: + file.close() + + async with self.search_info.create_search_indexer_client() as indexer_client: + await indexer_client.run_indexer(self.indexer_name) + logger.info("Triggered indexer '%s' for cloud ingestion", self.indexer_name) diff --git a/app/backend/prepdocslib/csvparser.py b/app/backend/prepdocslib/csvparser.py new file mode 100644 index 00000000..7bf5e1ad --- /dev/null +++ b/app/backend/prepdocslib/csvparser.py @@ -0,0 +1,32 @@ +import csv +from collections.abc import AsyncGenerator +from typing import IO + +from .page import Page +from .parser import Parser + + +class CsvParser(Parser): + """ + Concrete parser that can parse CSV into Page objects. Each row becomes a Page object. + """ + + async def parse(self, content: IO) -> AsyncGenerator[Page, None]: + # Check if content is in bytes (binary file) and decode to string + content_str: str + if isinstance(content, (bytes, bytearray)): + content_str = content.decode("utf-8") + elif hasattr(content, "read"): # Handle BufferedReader + content_str = content.read().decode("utf-8") + + # Create a CSV reader from the text content + reader = csv.reader(content_str.splitlines()) + offset = 0 + + # Skip the header row + next(reader, None) + + for i, row in enumerate(reader): + page_text = ",".join(row) + yield Page(i, offset, page_text) + offset += len(page_text) + 1 # Account for newline character diff --git a/app/backend/prepdocslib/embeddings.py b/app/backend/prepdocslib/embeddings.py new file mode 100644 index 00000000..ba7a60fc --- /dev/null +++ b/app/backend/prepdocslib/embeddings.py @@ -0,0 +1,201 @@ +import logging +from abc import ABC +from collections.abc import Awaitable, Callable +from urllib.parse import urljoin + +import aiohttp +import tiktoken +from openai import AsyncOpenAI, RateLimitError +from tenacity import ( + AsyncRetrying, + retry_if_exception_type, + stop_after_attempt, + wait_random_exponential, +) +from typing_extensions import TypedDict + +logger = logging.getLogger("scripts") + + +class EmbeddingBatch: + """Represents a batch of text that is going to be embedded.""" + + def __init__(self, texts: list[str], token_length: int): + self.texts = texts + self.token_length = token_length + + +class ExtraArgs(TypedDict, total=False): + dimensions: int + + +class OpenAIEmbeddings(ABC): + """Client wrapper that handles batching, retries, and token accounting.""" + + SUPPORTED_BATCH_MODEL = { + "text-embedding-ada-002": {"token_limit": 8100, "max_batch_size": 16}, + "text-embedding-3-small": {"token_limit": 8100, "max_batch_size": 16}, + "text-embedding-3-large": {"token_limit": 8100, "max_batch_size": 16}, + } + SUPPORTED_DIMENSIONS_MODEL = { + "text-embedding-ada-002": False, + "text-embedding-3-small": True, + "text-embedding-3-large": True, + } + + def __init__( + self, + open_ai_client: AsyncOpenAI, + open_ai_model_name: str, + open_ai_dimensions: int, + *, + disable_batch: bool = False, + azure_deployment_name: str | None = None, + azure_endpoint: str | None = None, + ): + self.open_ai_client = open_ai_client + self.open_ai_model_name = open_ai_model_name + self.open_ai_dimensions = open_ai_dimensions + self.disable_batch = disable_batch + self.azure_deployment_name = azure_deployment_name + self.azure_endpoint = azure_endpoint.rstrip("/") if azure_endpoint else None + + @property + def _api_model(self) -> str: + return self.azure_deployment_name or self.open_ai_model_name + + def before_retry_sleep(self, retry_state): + logger.info("Rate limited on the OpenAI embeddings API, sleeping before retrying...") + + def calculate_token_length(self, text: str): + encoding = tiktoken.encoding_for_model(self.open_ai_model_name) + return len(encoding.encode(text)) + + def split_text_into_batches(self, texts: list[str]) -> list[EmbeddingBatch]: + batch_info = OpenAIEmbeddings.SUPPORTED_BATCH_MODEL.get(self.open_ai_model_name) + if not batch_info: + raise NotImplementedError( + f"Model {self.open_ai_model_name} is not supported with batch embedding operations" + ) + + batch_token_limit = batch_info["token_limit"] + batch_max_size = batch_info["max_batch_size"] + batches: list[EmbeddingBatch] = [] + batch: list[str] = [] + batch_token_length = 0 + for text in texts: + text_token_length = self.calculate_token_length(text) + if batch_token_length + text_token_length >= batch_token_limit and len(batch) > 0: + batches.append(EmbeddingBatch(batch, batch_token_length)) + batch = [] + batch_token_length = 0 + + batch.append(text) + batch_token_length = batch_token_length + text_token_length + if len(batch) == batch_max_size: + batches.append(EmbeddingBatch(batch, batch_token_length)) + batch = [] + batch_token_length = 0 + + if len(batch) > 0: + batches.append(EmbeddingBatch(batch, batch_token_length)) + + return batches + + async def create_embedding_batch(self, texts: list[str], dimensions_args: ExtraArgs) -> list[list[float]]: + batches = self.split_text_into_batches(texts) + embeddings = [] + for batch in batches: + async for attempt in AsyncRetrying( + retry=retry_if_exception_type(RateLimitError), + wait=wait_random_exponential(min=15, max=60), + stop=stop_after_attempt(15), + before_sleep=self.before_retry_sleep, + ): + with attempt: + emb_response = await self.open_ai_client.embeddings.create( + model=self._api_model, input=batch.texts, **dimensions_args + ) + embeddings.extend([data.embedding for data in emb_response.data]) + logger.info( + "Computed embeddings in batch. Batch size: %d, Token count: %d", + len(batch.texts), + batch.token_length, + ) + + return embeddings + + async def create_embedding_single(self, text: str, dimensions_args: ExtraArgs) -> list[float]: + async for attempt in AsyncRetrying( + retry=retry_if_exception_type(RateLimitError), + wait=wait_random_exponential(min=15, max=60), + stop=stop_after_attempt(15), + before_sleep=self.before_retry_sleep, + ): + with attempt: + emb_response = await self.open_ai_client.embeddings.create( + model=self._api_model, input=text, **dimensions_args + ) + logger.info("Computed embedding for text section. Character count: %d", len(text)) + + return emb_response.data[0].embedding + + async def create_embeddings(self, texts: list[str]) -> list[list[float]]: + + dimensions_args: ExtraArgs = ( + {"dimensions": self.open_ai_dimensions} + if OpenAIEmbeddings.SUPPORTED_DIMENSIONS_MODEL.get(self.open_ai_model_name) + else {} + ) + + if not self.disable_batch and self.open_ai_model_name in OpenAIEmbeddings.SUPPORTED_BATCH_MODEL: + return await self.create_embedding_batch(texts, dimensions_args) + + return [await self.create_embedding_single(text, dimensions_args) for text in texts] + + +class ImageEmbeddings: + """ + Class for using image embeddings from Azure AI Vision + To learn more, please visit https://learn.microsoft.com/azure/ai-services/computer-vision/how-to/image-retrieval#call-the-vectorize-image-api + """ + + def __init__(self, endpoint: str, token_provider: Callable[[], Awaitable[str]]): + self.token_provider = token_provider + self.endpoint = endpoint + + async def create_embedding_for_image(self, image_bytes: bytes) -> list[float]: + endpoint = urljoin(self.endpoint, "computervision/retrieval:vectorizeImage") + params = {"api-version": "2024-02-01", "model-version": "2023-04-15"} + headers = {"Authorization": "Bearer " + await self.token_provider()} + + async with aiohttp.ClientSession(headers=headers) as session: + async for attempt in AsyncRetrying( + retry=retry_if_exception_type(Exception), + wait=wait_random_exponential(min=15, max=60), + stop=stop_after_attempt(15), + before_sleep=self.before_retry_sleep, + ): + with attempt: + async with session.post(url=endpoint, params=params, data=image_bytes) as resp: + resp_json = await resp.json() + return resp_json["vector"] + raise ValueError("Failed to get image embedding after multiple retries.") + + async def create_embedding_for_text(self, q: str): + endpoint = urljoin(self.endpoint, "computervision/retrieval:vectorizeText") + headers = {"Content-Type": "application/json"} + params = {"api-version": "2024-02-01", "model-version": "2023-04-15"} + data = {"text": q} + headers["Authorization"] = "Bearer " + await self.token_provider() + + async with aiohttp.ClientSession() as session: + async with session.post( + url=endpoint, params=params, headers=headers, json=data, raise_for_status=True + ) as response: + json = await response.json() + return json["vector"] + raise ValueError("Failed to get text embedding after multiple retries.") + + def before_retry_sleep(self, retry_state): + logger.info("Rate limited on the Vision embeddings API, sleeping before retrying...") diff --git a/app/backend/prepdocslib/figureprocessor.py b/app/backend/prepdocslib/figureprocessor.py new file mode 100644 index 00000000..b1e77ca6 --- /dev/null +++ b/app/backend/prepdocslib/figureprocessor.py @@ -0,0 +1,146 @@ +"""Utilities for describing and enriching figures extracted from documents.""" + +import logging +from enum import Enum +from typing import Any, Optional + +from azure.core.credentials import AzureKeyCredential +from azure.core.credentials_async import AsyncTokenCredential + +from .blobmanager import BaseBlobManager +from .embeddings import ImageEmbeddings +from .mediadescriber import ( + ContentUnderstandingDescriber, + MediaDescriber, + MultimodalModelDescriber, +) +from .page import ImageOnPage + +logger = logging.getLogger("scripts") + + +class MediaDescriptionStrategy(Enum): + """Supported mechanisms for describing images extracted from documents.""" + + NONE = "none" + OPENAI = "openai" + CONTENTUNDERSTANDING = "content_understanding" + + +class FigureProcessor: + """Helper that lazily creates a media describer and captions figures on demand.""" + + def __init__( + self, + *, + credential: AsyncTokenCredential | AzureKeyCredential | None = None, + strategy: MediaDescriptionStrategy = MediaDescriptionStrategy.NONE, + openai_client: Any | None = None, + openai_model: str | None = None, + openai_deployment: str | None = None, + content_understanding_endpoint: str | None = None, + ) -> None: + self.credential = credential + self.strategy = strategy + self.openai_client = openai_client + self.openai_model = openai_model + self.openai_deployment = openai_deployment + self.content_understanding_endpoint = content_understanding_endpoint + self.media_describer: MediaDescriber | None = None + self.content_understanding_ready = False + + async def get_media_describer(self) -> MediaDescriber | None: + """Return (and lazily create) the media describer for this processor.""" + + if self.strategy == MediaDescriptionStrategy.NONE: + return None + + if self.media_describer is not None: + return self.media_describer + + if self.strategy == MediaDescriptionStrategy.CONTENTUNDERSTANDING: + if self.content_understanding_endpoint is None: + raise ValueError("Content Understanding strategy requires an endpoint") + if self.credential is None: + raise ValueError("Content Understanding strategy requires a credential") + if isinstance(self.credential, AzureKeyCredential): + raise ValueError( + "Content Understanding does not support key credentials; provide a token credential instead" + ) + self.media_describer = ContentUnderstandingDescriber(self.content_understanding_endpoint, self.credential) + return self.media_describer + + if self.strategy == MediaDescriptionStrategy.OPENAI: + if self.openai_client is None or self.openai_model is None: + raise ValueError("OpenAI strategy requires both a client and a model name") + self.media_describer = MultimodalModelDescriber( + self.openai_client, model=self.openai_model, deployment=self.openai_deployment + ) + return self.media_describer + + logger.warning("Unknown media description strategy '%s'; skipping description", self.strategy) + return None + + def mark_content_understanding_ready(self) -> None: + """Record that the Content Understanding analyzer exists to avoid recreating it.""" + + self.content_understanding_ready = True + + async def describe(self, image_bytes: bytes) -> str | None: + """Generate a description for the provided image bytes if a describer is available.""" + + describer = await self.get_media_describer() + if describer is None: + return None + if isinstance(describer, ContentUnderstandingDescriber) and not self.content_understanding_ready: + await describer.create_analyzer() + self.content_understanding_ready = True + return await describer.describe_image(image_bytes) + + +def build_figure_markup(image: "ImageOnPage", description: Optional[str] = None) -> str: + """Create consistent HTML markup for a figure description on demand.""" + + caption_parts = [image.figure_id] + if image.title: + caption_parts.append(image.title) + caption = " ".join(part for part in caption_parts if part) + if description: + return f"
{caption}
{description}
" + return f"
{caption}
" + + +async def process_page_image( + *, + image: "ImageOnPage", + document_filename: str, + blob_manager: Optional[BaseBlobManager], + image_embeddings_client: Optional[ImageEmbeddings], + figure_processor: Optional[FigureProcessor] = None, + user_oid: Optional[str] = None, +) -> "ImageOnPage": + """Generate description, upload image, and optionally compute embedding for a figure.""" + + if blob_manager is None: + raise ValueError("BlobManager must be provided to process images.") + + # Generate plain (model) description text only; do not wrap in HTML markup here. + description_text: str | None = None + if figure_processor is not None: + description_text = await figure_processor.describe(image.bytes) + + # Store plain descriptive text (can be None). HTML rendering is deferred to build_figure_markup. + image.description = description_text + + if image.url is None: + image.url = await blob_manager.upload_document_image( + document_filename, image.bytes, image.filename, image.page_num, user_oid=user_oid + ) + + if image_embeddings_client is not None: + try: + image.embedding = await image_embeddings_client.create_embedding_for_image(image.bytes) + except Exception: # pragma: no cover - embedding failures shouldn't abort figure processing + logger.warning("Image embedding generation failed for figure %s", image.figure_id, exc_info=True) + + return image diff --git a/app/backend/prepdocslib/fileprocessor.py b/app/backend/prepdocslib/fileprocessor.py new file mode 100644 index 00000000..3b58130d --- /dev/null +++ b/app/backend/prepdocslib/fileprocessor.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + +from .parser import Parser +from .textsplitter import TextSplitter + + +@dataclass(frozen=True) +class FileProcessor: + parser: Parser + splitter: TextSplitter diff --git a/app/backend/prepdocslib/filestrategy.py b/app/backend/prepdocslib/filestrategy.py new file mode 100644 index 00000000..acf6af07 --- /dev/null +++ b/app/backend/prepdocslib/filestrategy.py @@ -0,0 +1,200 @@ +import logging +from typing import Optional + +from .blobmanager import AdlsBlobManager, BaseBlobManager, BlobManager +from .embeddings import ImageEmbeddings, OpenAIEmbeddings +from .figureprocessor import ( + FigureProcessor, + MediaDescriptionStrategy, + process_page_image, +) +from .fileprocessor import FileProcessor +from .listfilestrategy import File, ListFileStrategy +from .mediadescriber import ContentUnderstandingDescriber +from .searchmanager import SearchManager, Section +from .strategy import DocumentAction, SearchInfo, Strategy +from .textprocessor import process_text + +logger = logging.getLogger("scripts") + + +async def parse_file( + file: File, + file_processors: dict[str, FileProcessor], + category: Optional[str] = None, + blob_manager: Optional[BaseBlobManager] = None, + image_embeddings_client: Optional[ImageEmbeddings] = None, + figure_processor: Optional[FigureProcessor] = None, + user_oid: Optional[str] = None, +) -> list[Section]: + + key = file.file_extension().lower() + processor = file_processors.get(key) + if processor is None: + logger.info("Skipping '%s', no parser found.", file.filename()) + return [] + logger.info("Ingesting '%s'", file.filename()) + pages = [page async for page in processor.parser.parse(content=file.content)] + for page in pages: + for image in page.images: + logger.info("Processing image '%s' on page %d", image.filename, page.page_num) + await process_page_image( + image=image, + document_filename=file.filename(), + blob_manager=blob_manager, + image_embeddings_client=image_embeddings_client, + figure_processor=figure_processor, + user_oid=user_oid, + ) + sections = process_text(pages, file, processor.splitter, category) + return sections + + +class FileStrategy(Strategy): + """ + Strategy for ingesting documents into a search service from files stored either locally or in a data lake storage account + """ + + def __init__( + self, + list_file_strategy: ListFileStrategy, + blob_manager: BlobManager, + search_info: SearchInfo, + file_processors: dict[str, FileProcessor], + document_action: DocumentAction = DocumentAction.Add, + embeddings: Optional[OpenAIEmbeddings] = None, + image_embeddings: Optional[ImageEmbeddings] = None, + search_analyzer_name: Optional[str] = None, + search_field_name_embedding: Optional[str] = None, + use_acls: bool = False, + category: Optional[str] = None, + figure_processor: Optional[FigureProcessor] = None, + enforce_access_control: bool = False, + use_web_source: bool = False, + use_sharepoint_source: bool = False, + ): + self.list_file_strategy = list_file_strategy + self.blob_manager = blob_manager + self.file_processors = file_processors + self.document_action = document_action + self.embeddings = embeddings + self.image_embeddings = image_embeddings + self.search_analyzer_name = search_analyzer_name + self.search_field_name_embedding = search_field_name_embedding + self.search_info = search_info + self.use_acls = use_acls + self.category = category + self.figure_processor = figure_processor + self.enforce_access_control = enforce_access_control + self.use_web_source = use_web_source + self.use_sharepoint_source = use_sharepoint_source + + def setup_search_manager(self): + self.search_manager = SearchManager( + self.search_info, + self.search_analyzer_name, + self.use_acls, + False, # use_parent_index_projection disabled for file-based ingestion + self.embeddings, + field_name_embedding=self.search_field_name_embedding, + search_images=self.image_embeddings is not None, + enforce_access_control=self.enforce_access_control, + use_web_source=self.use_web_source, + use_sharepoint_source=self.use_sharepoint_source, + ) + + async def setup(self): + self.setup_search_manager() + await self.search_manager.create_index() + + if ( + self.figure_processor is not None + and self.figure_processor.strategy == MediaDescriptionStrategy.CONTENTUNDERSTANDING + ): + media_describer = await self.figure_processor.get_media_describer() + if isinstance(media_describer, ContentUnderstandingDescriber): + await media_describer.create_analyzer() + self.figure_processor.mark_content_understanding_ready() + + async def run(self): + self.setup_search_manager() + if self.document_action == DocumentAction.Add: + files = self.list_file_strategy.list() + async for file in files: + try: + blob_url = await self.blob_manager.upload_blob(file) + sections = await parse_file( + file, + self.file_processors, + self.category, + self.blob_manager, + self.image_embeddings, + figure_processor=self.figure_processor, + ) + if sections: + await self.search_manager.update_content(sections, url=blob_url) + finally: + if file: + file.close() + elif self.document_action == DocumentAction.Remove: + paths = self.list_file_strategy.list_paths() + async for path in paths: + await self.blob_manager.remove_blob(path) + await self.search_manager.remove_content(path) + elif self.document_action == DocumentAction.RemoveAll: + await self.blob_manager.remove_blob() + await self.search_manager.remove_content() + + +class UploadUserFileStrategy: + """ + Strategy for ingesting a file that has already been uploaded to a ADLS2 storage account + """ + + def __init__( + self, + search_info: SearchInfo, + file_processors: dict[str, FileProcessor], + blob_manager: AdlsBlobManager, + search_field_name_embedding: Optional[str] = None, + embeddings: Optional[OpenAIEmbeddings] = None, + image_embeddings: Optional[ImageEmbeddings] = None, + enforce_access_control: bool = False, + figure_processor: Optional[FigureProcessor] = None, + ): + self.file_processors = file_processors + self.embeddings = embeddings + self.image_embeddings = image_embeddings + self.search_info = search_info + self.blob_manager = blob_manager + self.figure_processor = figure_processor + self.search_manager = SearchManager( + search_info=self.search_info, + search_analyzer_name=None, + use_acls=True, + use_parent_index_projection=False, + embeddings=self.embeddings, + field_name_embedding=search_field_name_embedding, + search_images=image_embeddings is not None, + enforce_access_control=enforce_access_control, + ) + self.search_field_name_embedding = search_field_name_embedding + + async def add_file(self, file: File, user_oid: str): + sections = await parse_file( + file, + self.file_processors, + None, + self.blob_manager, + self.image_embeddings, + figure_processor=self.figure_processor, + user_oid=user_oid, + ) + if sections: + await self.search_manager.update_content(sections, url=file.url) + + async def remove_file(self, filename: str, oid: str): + if filename is None or filename == "": + logging.warning("Filename is required to remove a file") + return + await self.search_manager.remove_content(filename, oid) diff --git a/app/backend/prepdocslib/htmlparser.py b/app/backend/prepdocslib/htmlparser.py new file mode 100644 index 00000000..719045b3 --- /dev/null +++ b/app/backend/prepdocslib/htmlparser.py @@ -0,0 +1,50 @@ +import logging +import re +from collections.abc import AsyncGenerator +from typing import IO + +from bs4 import BeautifulSoup + +from .page import Page +from .parser import Parser + +logger = logging.getLogger("scripts") + + +def cleanup_data(data: str) -> str: + """Cleans up the given content using regexes + Args: + data: (str): The data to clean up. + Returns: + str: The cleaned up data. + """ + # match two or more newlines and replace them with one new line + output = re.sub(r"\n{2,}", "\n", data) + # match two or more spaces that are not newlines and replace them with one space + output = re.sub(r"[^\S\n]{2,}", " ", output) + # match two or more hyphens and replace them with two hyphens + output = re.sub(r"-{2,}", "--", output) + + return output.strip() + + +class LocalHTMLParser(Parser): + """Parses HTML text into Page objects.""" + + async def parse(self, content: IO) -> AsyncGenerator[Page, None]: + """Parses the given content. + To learn more, please visit https://pypi.org/project/beautifulsoup4/ + Args: + content (IO): The content to parse. + Returns: + Page: The parsed html Page. + """ + logger.info("Extracting text from '%s' using local HTML parser (BeautifulSoup)", content.name) + + data = content.read() + soup = BeautifulSoup(data, "html.parser") + + # Get text only from html file + result = soup.get_text() + + yield Page(0, 0, text=cleanup_data(result)) diff --git a/app/backend/prepdocslib/integratedvectorizerstrategy.py b/app/backend/prepdocslib/integratedvectorizerstrategy.py new file mode 100644 index 00000000..42f06d47 --- /dev/null +++ b/app/backend/prepdocslib/integratedvectorizerstrategy.py @@ -0,0 +1,202 @@ +import logging +from typing import Optional + +from azure.search.documents.indexes._generated.models import ( + NativeBlobSoftDeleteDeletionDetectionPolicy, +) +from azure.search.documents.indexes.models import ( + AzureOpenAIEmbeddingSkill, + IndexProjectionMode, + InputFieldMappingEntry, + OutputFieldMappingEntry, + SearchIndexer, + SearchIndexerDataContainer, + SearchIndexerDataSourceConnection, + SearchIndexerDataSourceType, + SearchIndexerIndexProjection, + SearchIndexerIndexProjectionSelector, + SearchIndexerIndexProjectionsParameters, + SearchIndexerSkillset, + SplitSkill, +) + +from .blobmanager import BlobManager +from .embeddings import OpenAIEmbeddings +from .listfilestrategy import ListFileStrategy +from .searchmanager import SearchManager +from .strategy import DocumentAction, SearchInfo, Strategy + +logger = logging.getLogger("scripts") + + +class IntegratedVectorizerStrategy(Strategy): # pragma: no cover + """ + Strategy for ingesting and vectorizing documents into a search service from files stored storage account + """ + + def __init__( + self, + list_file_strategy: ListFileStrategy, + blob_manager: BlobManager, + search_info: SearchInfo, + embeddings: OpenAIEmbeddings, + search_field_name_embedding: str, + subscription_id: str, + document_action: DocumentAction = DocumentAction.Add, + search_analyzer_name: Optional[str] = None, + use_acls: bool = False, + category: Optional[str] = None, + enforce_access_control: bool = False, + use_web_source: bool = False, + ): + + self.list_file_strategy = list_file_strategy + self.blob_manager = blob_manager + self.document_action = document_action + self.embeddings = embeddings + self.search_field_name_embedding = search_field_name_embedding + self.subscription_id = subscription_id + self.search_analyzer_name = search_analyzer_name + self.use_acls = use_acls + self.category = category + self.search_info = search_info + prefix = f"{self.search_info.index_name}-{self.search_field_name_embedding}" + self.skillset_name = f"{prefix}-skillset" + self.indexer_name = f"{prefix}-indexer" + self.data_source_name = f"{prefix}-blob" + self.enforce_access_control = enforce_access_control + self.use_web_source = use_web_source + + async def create_embedding_skill(self, index_name: str) -> SearchIndexerSkillset: + """ + Create a skillset for the indexer to chunk documents and generate embeddings + """ + + split_skill = SplitSkill( + name="split-skill", + description="Split skill to chunk documents", + text_split_mode="pages", + context="/document", + maximum_page_length=2048, + page_overlap_length=20, + inputs=[ + InputFieldMappingEntry(name="text", source="/document/content"), + ], + outputs=[OutputFieldMappingEntry(name="textItems", target_name="pages")], + ) + + if not self.embeddings.azure_endpoint or not self.embeddings.azure_deployment_name: + raise ValueError("Integrated vectorization requires Azure OpenAI endpoint and deployment") + + embedding_skill = AzureOpenAIEmbeddingSkill( + name="embedding-skill", + description="Skill to generate embeddings via Azure OpenAI", + context="/document/pages/*", + resource_url=self.embeddings.azure_endpoint, + deployment_name=self.embeddings.azure_deployment_name, + model_name=self.embeddings.open_ai_model_name, + dimensions=self.embeddings.open_ai_dimensions, + inputs=[ + InputFieldMappingEntry(name="text", source="/document/pages/*"), + ], + outputs=[OutputFieldMappingEntry(name="embedding", target_name="vector")], + ) + + index_projection = SearchIndexerIndexProjection( + selectors=[ + SearchIndexerIndexProjectionSelector( + target_index_name=index_name, + parent_key_field_name="parent_id", + source_context="/document/pages/*", + mappings=[ + InputFieldMappingEntry(name="content", source="/document/pages/*"), + InputFieldMappingEntry(name="sourcepage", source="/document/metadata_storage_name"), + InputFieldMappingEntry(name="sourcefile", source="/document/metadata_storage_name"), + InputFieldMappingEntry(name="storageUrl", source="/document/metadata_storage_path"), + InputFieldMappingEntry( + name=self.search_field_name_embedding, source="/document/pages/*/vector" + ), + ], + ), + ], + parameters=SearchIndexerIndexProjectionsParameters( + projection_mode=IndexProjectionMode.SKIP_INDEXING_PARENT_DOCUMENTS + ), + ) + + skillset = SearchIndexerSkillset( + name=self.skillset_name, + description="Skillset to chunk documents and generate embeddings", + skills=[split_skill, embedding_skill], + index_projection=index_projection, + ) + + return skillset + + async def setup(self): + logger.info("Setting up search index using integrated vectorization...") + search_manager = SearchManager( + search_info=self.search_info, + search_analyzer_name=self.search_analyzer_name, + use_acls=self.use_acls, + use_parent_index_projection=True, + embeddings=self.embeddings, + field_name_embedding=self.search_field_name_embedding, + search_images=False, + enforce_access_control=self.enforce_access_control, + use_web_source=self.use_web_source, + ) + + await search_manager.create_index() + + ds_client = self.search_info.create_search_indexer_client() + ds_container = SearchIndexerDataContainer(name=self.blob_manager.container) + data_source_connection = SearchIndexerDataSourceConnection( + name=self.data_source_name, + type=SearchIndexerDataSourceType.AZURE_BLOB, + connection_string=self.blob_manager.get_managedidentity_connectionstring(), + container=ds_container, + data_deletion_detection_policy=NativeBlobSoftDeleteDeletionDetectionPolicy(), + ) + + await ds_client.create_or_update_data_source_connection(data_source_connection) + + embedding_skillset = await self.create_embedding_skill(self.search_info.index_name) + await ds_client.create_or_update_skillset(embedding_skillset) + await ds_client.close() + + async def run(self): + if self.document_action == DocumentAction.Add: + files = self.list_file_strategy.list() + async for file in files: + try: + await self.blob_manager.upload_blob(file) + finally: + if file: + file.close() + elif self.document_action == DocumentAction.Remove: + paths = self.list_file_strategy.list_paths() + async for path in paths: + await self.blob_manager.remove_blob(path) + elif self.document_action == DocumentAction.RemoveAll: + await self.blob_manager.remove_blob() + + # Create an indexer + indexer = SearchIndexer( + name=self.indexer_name, + description="Indexer to index documents and generate embeddings", + skillset_name=self.skillset_name, + target_index_name=self.search_info.index_name, + data_source_name=self.data_source_name, + ) + + indexer_client = self.search_info.create_search_indexer_client() + indexer_result = await indexer_client.create_or_update_indexer(indexer) + + # Run the indexer + await indexer_client.run_indexer(self.indexer_name) + await indexer_client.close() + + logger.info( + f"Successfully created index, indexer: {indexer_result.name}, and skillset. Please navigate to search service in Azure Portal to view the status of the indexer." + ) diff --git a/app/backend/prepdocslib/jsonparser.py b/app/backend/prepdocslib/jsonparser.py new file mode 100644 index 00000000..bc17c7ce --- /dev/null +++ b/app/backend/prepdocslib/jsonparser.py @@ -0,0 +1,24 @@ +import json +from collections.abc import AsyncGenerator +from typing import IO + +from .page import Page +from .parser import Parser + + +class JsonParser(Parser): + """ + Concrete parser that can parse JSON into Page objects. A top-level object becomes a single Page, while a top-level array becomes multiple Page objects. + """ + + async def parse(self, content: IO) -> AsyncGenerator[Page, None]: + offset = 0 + data = json.loads(content.read()) + if isinstance(data, list): + for i, obj in enumerate(data): + offset += 1 # For opening bracket or comma before object + page_text = json.dumps(obj) + yield Page(i, offset, page_text) + offset += len(page_text) + elif isinstance(data, dict): + yield Page(0, 0, json.dumps(data)) diff --git a/app/backend/prepdocslib/listfilestrategy.py b/app/backend/prepdocslib/listfilestrategy.py new file mode 100644 index 00000000..7302bc7e --- /dev/null +++ b/app/backend/prepdocslib/listfilestrategy.py @@ -0,0 +1,210 @@ +import base64 +import hashlib +import logging +import os +import re +import tempfile +from abc import ABC +from collections.abc import AsyncGenerator +from glob import glob +from typing import IO, Optional + +from azure.core.credentials_async import AsyncTokenCredential +from azure.storage.filedatalake.aio import ( + DataLakeServiceClient, +) + +logger = logging.getLogger("scripts") + + +class File: + """ + Represents a file stored either locally or in a data lake storage account + This file might contain access control information about which users or groups can access it + """ + + def __init__(self, content: IO, acls: Optional[dict[str, list]] = None, url: Optional[str] = None): + self.content = content + self.acls = acls or {} + self.url = url + + def filename(self) -> str: + """ + Get the filename from the content object. + + Different file-like objects store the filename in different attributes: + - File objects from open() have a .name attribute + - HTTP uploaded files (werkzeug.datastructures.FileStorage) have a .filename attribute + + Returns: + str: The basename of the file + """ + content_name = None + + # Try to get filename attribute (common for HTTP uploaded files) + if hasattr(self.content, "filename"): + content_name = getattr(self.content, "filename") + if content_name: + return os.path.basename(content_name) + + # Try to get name attribute (common for file objects from open()) + if hasattr(self.content, "name"): + content_name = getattr(self.content, "name") + if content_name and content_name != "file": + return os.path.basename(content_name) + + raise ValueError("The content object does not have a filename or name attribute. ") + + def file_extension(self): + return os.path.splitext(self.filename())[1] + + def filename_to_id(self): + filename_ascii = re.sub("[^0-9a-zA-Z_-]", "_", self.filename()) + filename_hash = base64.b16encode(self.filename().encode("utf-8")).decode("ascii") + acls_hash = "" + if self.acls: + acls_hash = base64.b16encode(str(self.acls).encode("utf-8")).decode("ascii") + return f"file-{filename_ascii}-{filename_hash}{acls_hash}" + + def close(self): + if self.content: + self.content.close() + + +class ListFileStrategy(ABC): + """ + Abstract strategy for listing files that are located somewhere. For example, on a local computer or remotely in a storage account + """ + + async def list(self) -> AsyncGenerator[File, None]: + if False: # pragma: no cover - this is necessary for mypy to type check + yield + + async def list_paths(self) -> AsyncGenerator[str, None]: + if False: # pragma: no cover - this is necessary for mypy to type check + yield + + +class LocalListFileStrategy(ListFileStrategy): + """ + Concrete strategy for listing files that are located in a local filesystem + """ + + def __init__(self, path_pattern: str, enable_global_documents: bool = False): + self.path_pattern = path_pattern + self.enable_global_documents = enable_global_documents + + async def list_paths(self) -> AsyncGenerator[str, None]: + async for p in self._list_paths(self.path_pattern): + yield p + + async def _list_paths(self, path_pattern: str) -> AsyncGenerator[str, None]: + for path in glob(path_pattern): + if os.path.isdir(path): + async for p in self._list_paths(f"{path}/*"): + yield p + else: + # Only list files, not directories + yield path + + async def list(self) -> AsyncGenerator[File, None]: + acls = {"oids": ["all"], "groups": ["all"]} if self.enable_global_documents else {} + async for path in self.list_paths(): + if not self.check_md5(path): + yield File(content=open(path, mode="rb"), acls=acls, url=path) + + def check_md5(self, path: str) -> bool: + # if filename ends in .md5 skip + if path.endswith(".md5"): + return True + + # if there is a file called .md5 in this directory, see if its updated + stored_hash = None + with open(path, "rb") as file: + existing_hash = hashlib.md5(file.read()).hexdigest() + hash_path = f"{path}.md5" + if os.path.exists(hash_path): + with open(hash_path, encoding="utf-8") as md5_f: + stored_hash = md5_f.read() + + if stored_hash and stored_hash.strip() == existing_hash.strip(): + logger.info("Skipping '%s', no changes detected.", path) + return True + + # Write the hash + with open(hash_path, "w", encoding="utf-8") as md5_f: + md5_f.write(existing_hash) + + return False + + +class ADLSGen2ListFileStrategy(ListFileStrategy): + """ + Concrete strategy for listing files that are located in a data lake storage account + """ + + def __init__( + self, + data_lake_storage_account: str, + data_lake_filesystem: str, + data_lake_path: str, + credential: AsyncTokenCredential | str, + enable_global_documents: bool = False, + ): + self.data_lake_storage_account = data_lake_storage_account + self.data_lake_filesystem = data_lake_filesystem + self.data_lake_path = data_lake_path + self.credential = credential + self.enable_global_documents = enable_global_documents + + async def list_paths(self) -> AsyncGenerator[str, None]: + async with DataLakeServiceClient( + account_url=f"https://{self.data_lake_storage_account}.dfs.core.windows.net", credential=self.credential + ) as service_client, service_client.get_file_system_client(self.data_lake_filesystem) as filesystem_client: + async for path in filesystem_client.get_paths(path=self.data_lake_path, recursive=True): + if path.is_directory: + continue + + yield path.name + + async def list(self) -> AsyncGenerator[File, None]: + async with DataLakeServiceClient( + account_url=f"https://{self.data_lake_storage_account}.dfs.core.windows.net", credential=self.credential + ) as service_client, service_client.get_file_system_client(self.data_lake_filesystem) as filesystem_client: + async for path in self.list_paths(): + temp_file_path = os.path.join(tempfile.gettempdir(), os.path.basename(path)) + try: + async with filesystem_client.get_file_client(path) as file_client: + with open(temp_file_path, "wb") as temp_file: + downloader = await file_client.download_file() + await downloader.readinto(temp_file) + # Parse out user ids and group ids + acls: dict[str, list[str]] = {"oids": [], "groups": []} + # https://learn.microsoft.com/python/api/azure-storage-file-datalake/azure.storage.filedatalake.datalakefileclient?view=azure-python#azure-storage-filedatalake-datalakefileclient-get-access-control + # Request ACLs as GUIDs + access_control = await file_client.get_access_control(upn=False) + acl_list = access_control["acl"] + # https://learn.microsoft.com/azure/storage/blobs/data-lake-storage-access-control + # ACL Format: user::rwx,group::r-x,other::r--,user:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx:r-- + acl_list = acl_list.split(",") + for acl in acl_list: + acl_parts: list = acl.split(":") + if len(acl_parts) != 3: + continue + if len(acl_parts[1]) == 0: + continue + if acl_parts[0] == "user" and "r" in acl_parts[2]: + acls["oids"].append(acl_parts[1]) + if acl_parts[0] == "group" and "r" in acl_parts[2]: + acls["groups"].append(acl_parts[1]) + + if self.enable_global_documents and len(acls["oids"]) == 0 and len(acls["groups"]) == 0: + acls = {"oids": ["all"], "groups": ["all"]} + + yield File(content=open(temp_file_path, "rb"), acls=acls, url=file_client.url) + except Exception as data_lake_exception: + logger.error(f"\tGot an error while reading {path} -> {data_lake_exception} --> skipping file") + try: + os.remove(temp_file_path) + except Exception as file_delete_exception: + logger.error(f"\tGot an error while deleting {temp_file_path} -> {file_delete_exception}") diff --git a/app/backend/prepdocslib/mediadescriber.py b/app/backend/prepdocslib/mediadescriber.py new file mode 100644 index 00000000..154569b3 --- /dev/null +++ b/app/backend/prepdocslib/mediadescriber.py @@ -0,0 +1,163 @@ +import base64 +import logging +from abc import ABC +from typing import Optional + +import aiohttp +from azure.core.credentials_async import AsyncTokenCredential +from azure.identity.aio import get_bearer_token_provider +from openai import AsyncOpenAI, RateLimitError +from rich.progress import Progress +from tenacity import ( + AsyncRetrying, + retry, + retry_if_exception_type, + stop_after_attempt, + wait_fixed, + wait_random_exponential, +) + +logger = logging.getLogger("scripts") + + +class MediaDescriber(ABC): + + async def describe_image(self, image_bytes) -> str: + raise NotImplementedError # pragma: no cover + + +class ContentUnderstandingDescriber(MediaDescriber): + CU_API_VERSION = "2024-12-01-preview" + + analyzer_schema = { + "analyzerId": "image_analyzer", + "name": "Image understanding", + "description": "Extract detailed structured information from images extracted from documents.", + "baseAnalyzerId": "prebuilt-image", + "scenario": "image", + "config": {"returnDetails": False}, + "fieldSchema": { + "name": "ImageInformation", + "descriptions": "Description of image.", + "fields": { + "Description": { + "type": "string", + "description": "Description of the image. If the image has a title, start with the title. Include a 2-sentence summary. If the image is a chart, diagram, or table, include the underlying data in an HTML table tag, with accurate numbers. If the image is a chart, describe any axis or legends. The only allowed HTML tags are the table/thead/tr/td/tbody tags.", + }, + }, + }, + } + + def __init__(self, endpoint: str, credential: AsyncTokenCredential): + self.endpoint = endpoint + self.credential = credential + + async def poll_api(self, session, poll_url, headers): + + @retry(stop=stop_after_attempt(60), wait=wait_fixed(2), retry=retry_if_exception_type(ValueError)) + async def poll(): + async with session.get(poll_url, headers=headers) as response: + response.raise_for_status() + response_json = await response.json() + if response_json["status"] == "Failed": + raise Exception("Failed") + if response_json["status"] == "Running": + raise ValueError("Running") + return response_json + + return await poll() + + async def create_analyzer(self): + logger.info("Creating analyzer '%s'...", self.analyzer_schema["analyzerId"]) + + token_provider = get_bearer_token_provider(self.credential, "https://cognitiveservices.azure.com/.default") + token = await token_provider() + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + params = {"api-version": self.CU_API_VERSION} + analyzer_id = self.analyzer_schema["analyzerId"] + cu_endpoint = f"{self.endpoint}/contentunderstanding/analyzers/{analyzer_id}" + async with aiohttp.ClientSession() as session: + async with session.put( + url=cu_endpoint, params=params, headers=headers, json=self.analyzer_schema + ) as response: + if response.status == 409: + logger.info("Analyzer '%s' already exists.", analyzer_id) + return + elif response.status != 201: + data = await response.text() + raise Exception("Error creating analyzer", data) + else: + poll_url = response.headers.get("Operation-Location") + + with Progress() as progress: + progress.add_task("Creating analyzer...", total=None, start=False) + await self.poll_api(session, poll_url, headers) + + async def describe_image(self, image_bytes: bytes) -> str: + async with aiohttp.ClientSession() as session: + token = await self.credential.get_token("https://cognitiveservices.azure.com/.default") + headers = {"Authorization": "Bearer " + token.token} + params = {"api-version": self.CU_API_VERSION} + analyzer_name = self.analyzer_schema["analyzerId"] + async with session.post( + url=f"{self.endpoint}/contentunderstanding/analyzers/{analyzer_name}:analyze", + params=params, + headers=headers, + data=image_bytes, + ) as response: + response.raise_for_status() + poll_url = response.headers["Operation-Location"] + + with Progress() as progress: + progress.add_task("Processing...", total=None, start=False) + results = await self.poll_api(session, poll_url, headers) + + fields = results["result"]["contents"][0]["fields"] + return fields["Description"]["valueString"] + + +class MultimodalModelDescriber(MediaDescriber): + def __init__(self, openai_client: AsyncOpenAI, model: str, deployment: Optional[str] = None): + self.openai_client = openai_client + self.model = model + self.deployment = deployment + + async def describe_image(self, image_bytes: bytes) -> str: + def before_retry_sleep(retry_state): + logger.info("Rate limited on the OpenAI chat completions API, sleeping before retrying...") + + image_base64 = base64.b64encode(image_bytes).decode("utf-8") + image_datauri = f"data:image/png;base64,{image_base64}" + + async for attempt in AsyncRetrying( + retry=retry_if_exception_type(RateLimitError), + wait=wait_random_exponential(min=15, max=60), + stop=stop_after_attempt(15), + before_sleep=before_retry_sleep, + ): + with attempt: + response = await self.openai_client.chat.completions.create( + model=self.model if self.deployment is None else self.deployment, + max_tokens=500, + seed=42, # Keep responses more consistent across runs + messages=[ + { + "role": "system", + "content": "You are a helpful assistant that describes images from organizational documents.", + }, + { + "role": "user", + "content": [ + { + "text": "Describe image with no more than 5 sentences. Do not speculate about anything you don't know.", + "type": "text", + }, + {"image_url": {"url": image_datauri, "detail": "auto"}, "type": "image_url"}, + ], + }, + ], + ) + description = "" + if response.choices and response.choices[0].message.content: + description = response.choices[0].message.content.strip() + return description diff --git a/app/backend/prepdocslib/page.py b/app/backend/prepdocslib/page.py new file mode 100644 index 00000000..cece557c --- /dev/null +++ b/app/backend/prepdocslib/page.py @@ -0,0 +1,124 @@ +import base64 +from dataclasses import asdict, dataclass, field +from typing import Any, Optional + + +@dataclass +class ImageOnPage: + bytes: bytes + bbox: tuple[float, float, float, float] # Pixels + filename: str + figure_id: str + page_num: int # 0-indexed + placeholder: str # HTML placeholder in page text, e.g. '
' + mime_type: str = "image/png" # Set by parser; default assumes PNG rendering + url: Optional[str] = None + title: str = "" + embedding: Optional[list[float]] = None + description: Optional[str] = None + + def to_skill_payload( + self, + file_name: str, + *, + include_bytes_base64: bool = True, + ) -> dict[str, Any]: + data = asdict(self) + + # Remove raw bytes to keep payload lean (and JSON-friendly without extra handling). + data.pop("bytes", None) + + # Optionally include base64-encoded bytes for skills that need it + if include_bytes_base64: + b = self.bytes if isinstance(self.bytes, (bytes, bytearray)) else b"" + data["bytes_base64"] = base64.b64encode(b).decode("utf-8") + + data["document_file_name"] = file_name + return data + + @classmethod + def from_skill_payload(cls, data: dict[str, Any]) -> tuple["ImageOnPage", str]: + # Decode base64 image data (optional - may be omitted if already persisted to blob) + bytes_base64 = data.get("bytes_base64") + if bytes_base64: + try: + raw_bytes = base64.b64decode(bytes_base64) + except Exception as exc: # pragma: no cover - defensive + raise ValueError("Invalid bytes_base64 image data") from exc + else: + raw_bytes = b"" # Empty bytes if not provided (already uploaded to blob) + + # page_num may arrive as str; coerce + try: + page_num = int(data.get("page_num") or 0) + except Exception: + page_num = 0 + + # bbox may arrive as list; coerce into tuple + bbox_val = data.get("bbox") + if isinstance(bbox_val, list) and len(bbox_val) == 4: + bbox = tuple(bbox_val) # type: ignore[assignment] + else: + bbox = (0, 0, 0, 0) + + filename = data.get("filename") + figure_id = data.get("figure_id") + placeholder = data.get("placeholder") + if filename is None: + raise ValueError("filename is required") + if figure_id is None: + raise ValueError("figure_id is required for ImageOnPage deserialization") + + # Generate placeholder if not provided + if placeholder is None: + placeholder = f'
' + + image = cls( + bytes=raw_bytes, + bbox=bbox, + page_num=page_num, + filename=filename, + figure_id=figure_id, + placeholder=placeholder, + mime_type=data.get("mime_type") or "image/png", + title=data.get("title") or "", + description=data.get("description"), + url=data.get("url"), + ) + return image, data.get("document_file_name", "") + + +@dataclass +class Page: + """ + A single page from a document + + Attributes: + page_num (int): Page number (0-indexed) + offset (int): If the text of the entire Document was concatenated into a single string, the index of the first character on the page. For example, if page 1 had the text "hello" and page 2 had the text "world", the offset of page 2 is 5 ("hellow") + text (str): The text of the page + """ + + page_num: int + offset: int + text: str + images: list[ImageOnPage] = field(default_factory=list) + tables: list[str] = field(default_factory=list) + + +@dataclass +class Chunk: + """Semantic chunk emitted by the splitter (may originate wholly within one page + or be the result of a cross-page merge / trailing fragment carry-forward). + + Attributes: + page_num (int): Logical source page number (0-indexed) for the originating + portion of content. For merged content spanning pages we keep the earliest + contributing page number for stable attribution. + text (str): Textual content of the chunk. + images (list[ImageOnPage]): Images associated with this chunk, if any. + """ + + page_num: int + text: str + images: list[ImageOnPage] = field(default_factory=list) diff --git a/app/backend/prepdocslib/parser.py b/app/backend/prepdocslib/parser.py new file mode 100644 index 00000000..1552c00f --- /dev/null +++ b/app/backend/prepdocslib/parser.py @@ -0,0 +1,15 @@ +from abc import ABC +from collections.abc import AsyncGenerator +from typing import IO + +from .page import Page + + +class Parser(ABC): + """ + Abstract parser that parses content into Page objects + """ + + async def parse(self, content: IO) -> AsyncGenerator[Page, None]: + if False: + yield # pragma: no cover - this is necessary for mypy to type check diff --git a/app/backend/prepdocslib/pdfparser.py b/app/backend/prepdocslib/pdfparser.py new file mode 100644 index 00000000..d8c69d00 --- /dev/null +++ b/app/backend/prepdocslib/pdfparser.py @@ -0,0 +1,284 @@ +import html +import io +import logging +import uuid +from collections.abc import AsyncGenerator +from enum import Enum +from typing import IO, Optional + +import pymupdf +from azure.ai.documentintelligence.aio import DocumentIntelligenceClient +from azure.ai.documentintelligence.models import ( + AnalyzeDocumentRequest, + AnalyzeResult, + DocumentFigure, + DocumentTable, +) +from azure.core.credentials import AzureKeyCredential +from azure.core.credentials_async import AsyncTokenCredential +from azure.core.exceptions import HttpResponseError +from PIL import Image +from pypdf import PdfReader + +from .page import ImageOnPage, Page +from .parser import Parser + +logger = logging.getLogger("scripts") + + +class LocalPdfParser(Parser): + """ + Concrete parser backed by PyPDF that can parse PDFs into pages + To learn more, please visit https://pypi.org/project/pypdf/ + """ + + async def parse(self, content: IO) -> AsyncGenerator[Page, None]: + logger.info("Extracting text from '%s' using local PDF parser (pypdf)", content.name) + + reader = PdfReader(content) + pages = reader.pages + offset = 0 + for page_num, p in enumerate(pages): + page_text = p.extract_text() + yield Page(page_num=page_num, offset=offset, text=page_text) + offset += len(page_text) + + +class DocumentAnalysisParser(Parser): + """ + Concrete parser backed by Azure AI Document Intelligence that can parse many document formats into pages + To learn more, please visit https://learn.microsoft.com/azure/ai-services/document-intelligence/overview + """ + + def __init__( + self, + endpoint: str, + credential: AsyncTokenCredential | AzureKeyCredential, + model_id: str = "prebuilt-layout", + process_figures: bool = False, + ) -> None: + self.model_id = model_id + self.endpoint = endpoint + self.credential = credential + self.process_figures = process_figures + + async def parse(self, content: IO) -> AsyncGenerator[Page, None]: + logger.info("Extracting text from '%s' using Azure Document Intelligence", content.name) + + async with DocumentIntelligenceClient( + endpoint=self.endpoint, credential=self.credential + ) as document_intelligence_client: + # Always convert to bytes up front to avoid passing a FileStorage/stream object + try: + content.seek(0) + except Exception: + pass + content_bytes = content.read() + + poller = None + doc_for_pymupdf = None + + if self.process_figures: + try: + poller = await document_intelligence_client.begin_analyze_document( + model_id="prebuilt-layout", + body=AnalyzeDocumentRequest(bytes_source=content_bytes), + output=["figures"], + features=["ocrHighResolution"], + output_content_format="markdown", + ) + doc_for_pymupdf = pymupdf.open(stream=io.BytesIO(content_bytes)) + except HttpResponseError as e: + if e.error and e.error.code == "InvalidArgument": + logger.error( + "This document type does not support media description. Proceeding with standard analysis." + ) + else: + logger.error( + "Unexpected error analyzing document for media description: %s. Proceeding with standard analysis.", + e, + ) + poller = None + + if poller is None: + poller = await document_intelligence_client.begin_analyze_document( + model_id=self.model_id, + body=AnalyzeDocumentRequest(bytes_source=content_bytes), + ) + analyze_result: AnalyzeResult = await poller.result() + + offset = 0 + + for page in analyze_result.pages: + tables_on_page = [ + table + for table in (analyze_result.tables or []) + if table.bounding_regions and table.bounding_regions[0].page_number == page.page_number + ] + figures_on_page = [] + if self.process_figures: + figures_on_page = [ + figure + for figure in (analyze_result.figures or []) + if figure.bounding_regions and figure.bounding_regions[0].page_number == page.page_number + ] + page_images: list[ImageOnPage] = [] + page_tables: list[str] = [] + + class ObjectType(Enum): + NONE = -1 + TABLE = 0 + FIGURE = 1 + + MaskEntry = tuple[ObjectType, Optional[int]] + + page_offset = page.spans[0].offset + page_length = page.spans[0].length + mask_chars: list[MaskEntry] = [(ObjectType.NONE, None)] * page_length + # mark all positions of the table spans in the page + for table_idx, table in enumerate(tables_on_page): + for span in table.spans: + # replace all table spans with "table_id" in table_chars array + for i in range(span.length): + idx = span.offset - page_offset + i + if idx >= 0 and idx < page_length: + mask_chars[idx] = (ObjectType.TABLE, table_idx) + # mark all positions of the figure spans in the page + for figure_idx, figure in enumerate(figures_on_page): + for span in figure.spans: + # replace all figure spans with "figure_id" in figure_chars array + for i in range(span.length): + idx = span.offset - page_offset + i + if idx >= 0 and idx < page_length: + mask_chars[idx] = (ObjectType.FIGURE, figure_idx) + + # build page text by replacing characters in table spans with table html + page_text = "" + added_objects: set[MaskEntry] = set() + for idx, mask_char in enumerate(mask_chars): + object_type, object_idx = mask_char + if object_type == ObjectType.NONE: + page_text += analyze_result.content[page_offset + idx] + elif object_type == ObjectType.TABLE: + if object_idx is None: + raise ValueError("Expected object_idx to be set") + if mask_char not in added_objects: + table_html = DocumentAnalysisParser.table_to_html(tables_on_page[object_idx]) + page_tables.append(table_html) + page_text += table_html + added_objects.add(mask_char) + elif object_type == ObjectType.FIGURE: + if object_idx is None: + raise ValueError("Expected object_idx to be set") + if mask_char not in added_objects: + image_on_page = await DocumentAnalysisParser.figure_to_image( + doc_for_pymupdf, figures_on_page[object_idx] + ) + page_images.append(image_on_page) + page_text += image_on_page.placeholder + added_objects.add(mask_char) + + # We remove these comments since they are not needed and skew the page numbers + page_text = page_text.replace("", "") + # We remove excess newlines at the beginning and end of the page + page_text = page_text.strip() + yield Page( + page_num=page.page_number - 1, + offset=offset, + text=page_text, + images=page_images, + tables=page_tables, + ) + offset += len(page_text) + + @staticmethod + async def figure_to_image(doc: pymupdf.Document, figure: DocumentFigure) -> ImageOnPage: + figure_title = (figure.caption and figure.caption.content) or "" + # Generate a random UUID if figure.id is None + figure_id = figure.id or f"fig_{uuid.uuid4().hex[:8]}" + figure_filename = f"figure{figure_id.replace('.', '_')}.png" + logger.info("Cropping figure %s with title '%s'", figure_id, figure_title) + placeholder = f'
' + if not figure.bounding_regions: + return ImageOnPage( + bytes=b"", + page_num=0, # 0-indexed + figure_id=figure_id, + bbox=(0, 0, 0, 0), + filename=figure_filename, + title=figure_title, + placeholder=placeholder, + mime_type="image/png", + ) + if len(figure.bounding_regions) > 1: + logger.warning("Figure %s has more than one bounding region, using the first one", figure_id) + first_region = figure.bounding_regions[0] + # To learn more about bounding regions, see https://aka.ms/bounding-region + bounding_box = ( + first_region.polygon[0], # x0 (left) + first_region.polygon[1], # y0 (top + first_region.polygon[4], # x1 (right) + first_region.polygon[5], # y1 (bottom) + ) + page_number = first_region["pageNumber"] # 1-indexed + cropped_img, bbox_pixels = DocumentAnalysisParser.crop_image_from_pdf_page(doc, page_number - 1, bounding_box) + return ImageOnPage( + bytes=cropped_img, + page_num=page_number - 1, # Convert to 0-indexed + figure_id=figure_id, + bbox=bbox_pixels, + filename=figure_filename, + title=figure_title, + placeholder=placeholder, + mime_type="image/png", + ) + + @staticmethod + def table_to_html(table: DocumentTable): + table_html = "
" + rows = [ + sorted([cell for cell in table.cells if cell.row_index == i], key=lambda cell: cell.column_index) + for i in range(table.row_count) + ] + for row_cells in rows: + table_html += "" + for cell in row_cells: + tag = "th" if (cell.kind == "columnHeader" or cell.kind == "rowHeader") else "td" + cell_spans = "" + if cell.column_span is not None and cell.column_span > 1: + cell_spans += f" colSpan={cell.column_span}" + if cell.row_span is not None and cell.row_span > 1: + cell_spans += f" rowSpan={cell.row_span}" + table_html += f"<{tag}{cell_spans}>{html.escape(cell.content)}" + table_html += "" + table_html += "
" + return table_html + + @staticmethod + def crop_image_from_pdf_page( + doc: pymupdf.Document, page_number: int, bbox_inches: tuple[float, float, float, float] + ) -> tuple[bytes, tuple[float, float, float, float]]: + """ + Crops a region from a given page in a PDF and returns it as an image. + + :param pdf_path: Path to the PDF file. + :param page_number: The page number to crop from (0-indexed). + :param bbox_inches: A tuple of (x0, y0, x1, y1) coordinates for the bounding box, in inches. + :return: A tuple of (image_bytes, bbox_pixels). + """ + # Scale the bounding box to 72 DPI + bbox_dpi = 72 + # We multiply using unpacking to ensure the resulting tuple has the correct number of elements + x0, y0, x1, y1 = (round(x * bbox_dpi, 2) for x in bbox_inches) + bbox_pixels = (x0, y0, x1, y1) + rect = pymupdf.Rect(bbox_pixels) + # Assume that the PDF has 300 DPI, + # and use the matrix to convert between the 2 DPIs + page_dpi = 300 + page = doc.load_page(page_number) + pix = page.get_pixmap(matrix=pymupdf.Matrix(page_dpi / bbox_dpi, page_dpi / bbox_dpi), clip=rect) + + img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples) + bytes_io = io.BytesIO() + img.save(bytes_io, format="PNG") + return bytes_io.getvalue(), bbox_pixels diff --git a/app/backend/prepdocslib/searchmanager.py b/app/backend/prepdocslib/searchmanager.py new file mode 100644 index 00000000..1af5ce1f --- /dev/null +++ b/app/backend/prepdocslib/searchmanager.py @@ -0,0 +1,679 @@ +import asyncio +import logging +import os +from typing import Optional + +from azure.search.documents.indexes.models import ( + AIServicesVisionParameters, + AIServicesVisionVectorizer, + AzureOpenAIVectorizer, + AzureOpenAIVectorizerParameters, + BinaryQuantizationCompression, + HnswAlgorithmConfiguration, + HnswParameters, + KnowledgeBase, + KnowledgeBaseAzureOpenAIModel, + KnowledgeRetrievalOutputMode, + KnowledgeSourceReference, + PermissionFilter, + RemoteSharePointKnowledgeSource, + RemoteSharePointKnowledgeSourceParameters, + RescoringOptions, + SearchableField, + SearchField, + SearchFieldDataType, + SearchIndex, + SearchIndexFieldReference, + SearchIndexKnowledgeSource, + SearchIndexKnowledgeSourceParameters, + SearchIndexPermissionFilterOption, + SemanticConfiguration, + SemanticField, + SemanticPrioritizedFields, + SemanticSearch, + SimpleField, + VectorSearch, + VectorSearchAlgorithmConfiguration, + VectorSearchCompression, + VectorSearchCompressionRescoreStorageMethod, + VectorSearchProfile, + VectorSearchVectorizer, + WebKnowledgeSource, +) + +from .blobmanager import BlobManager +from .embeddings import OpenAIEmbeddings +from .listfilestrategy import File +from .strategy import SearchInfo +from .textsplitter import Chunk + +logger = logging.getLogger("scripts") + + +class Section: + """ + A section of a page that is stored in a search service. These sections are used as context by Azure OpenAI service + """ + + def __init__(self, chunk: Chunk, content: File, category: Optional[str] = None): + self.chunk = chunk # content comes from here + self.content = content # sourcepage and sourcefile come from here + self.category = category + # this also needs images which will become the images field + + +class SearchManager: + """ + Class to manage a search service. It can create indexes, and update or remove sections stored in these indexes + To learn more, please visit https://learn.microsoft.com/azure/search/search-what-is-azure-search + """ + + def __init__( + self, + search_info: SearchInfo, + search_analyzer_name: Optional[str] = None, + use_acls: bool = False, + use_parent_index_projection: bool = False, + embeddings: Optional[OpenAIEmbeddings] = None, + field_name_embedding: Optional[str] = None, + search_images: bool = False, + enforce_access_control: bool = False, + use_web_source: bool = False, + use_sharepoint_source: bool = False, + ): + self.search_info = search_info + self.search_analyzer_name = search_analyzer_name + self.use_acls = use_acls + self.use_parent_index_projection = use_parent_index_projection + self.embeddings = embeddings + self.embedding_dimensions = self.embeddings.open_ai_dimensions if self.embeddings else None + self.field_name_embedding = field_name_embedding + self.search_images = search_images + self.enforce_access_control = enforce_access_control + self.use_web_source = use_web_source + self.use_sharepoint_source = use_sharepoint_source + + async def create_index(self): + logger.info("Checking whether search index %s exists...", self.search_info.index_name) + + async with self.search_info.create_search_index_client() as search_index_client: + embedding_field = None + images_field = None + text_vector_search_profile = None + text_vector_algorithm = None + text_vector_compression = None + image_vector_search_profile = None + image_vector_algorithm = None + permission_filter_option = None + + if self.embeddings: + if self.embedding_dimensions is None: + raise ValueError( + "Embedding dimensions must be set in order to add an embedding field to the search index" + ) + if self.field_name_embedding is None: + raise ValueError( + "Embedding field must be set in order to add an embedding field to the search index" + ) + + text_vectorizer = None + if self.embeddings.azure_endpoint and self.embeddings.azure_deployment_name: + text_vectorizer = AzureOpenAIVectorizer( + vectorizer_name=f"{self.embeddings.open_ai_model_name}-vectorizer", + parameters=AzureOpenAIVectorizerParameters( + resource_url=self.embeddings.azure_endpoint, + deployment_name=self.embeddings.azure_deployment_name, + model_name=self.embeddings.open_ai_model_name, + ), + ) + + text_vector_algorithm = HnswAlgorithmConfiguration( + name="hnsw_config", + parameters=HnswParameters(metric="cosine"), + ) + text_vector_compression = BinaryQuantizationCompression( + compression_name=f"{self.field_name_embedding}-compression", + truncation_dimension=1024, # should this be a parameter? maybe not yet? + rescoring_options=RescoringOptions( + enable_rescoring=True, + default_oversampling=10, + rescore_storage_method=VectorSearchCompressionRescoreStorageMethod.PRESERVE_ORIGINALS, + ), + ) + text_vector_search_profile = VectorSearchProfile( + name=f"{self.field_name_embedding}-profile", + algorithm_configuration_name=text_vector_algorithm.name, + compression_name=text_vector_compression.compression_name, + **({"vectorizer_name": text_vectorizer.vectorizer_name if text_vectorizer else None}), + ) + + embedding_field = SearchField( + name=self.field_name_embedding, + type=SearchFieldDataType.Collection(SearchFieldDataType.Single), + hidden=True, + searchable=True, + filterable=False, + sortable=False, + facetable=False, + vector_search_dimensions=self.embedding_dimensions, + vector_search_profile_name=f"{self.field_name_embedding}-profile", + stored=False, + ) + + if self.search_images: + if not self.search_info.azure_vision_endpoint: + raise ValueError("Azure AI Vision endpoint must be provided to use image embeddings") + image_vector_algorithm = HnswAlgorithmConfiguration( + name="images_hnsw_config", + parameters=HnswParameters(metric="cosine"), + ) + + # Create the AI Vision vectorizer for image embeddings + image_vectorizer = AIServicesVisionVectorizer( + vectorizer_name="images-vision-vectorizer", + ai_services_vision_parameters=AIServicesVisionParameters( + resource_uri=self.search_info.azure_vision_endpoint, + model_version="2023-04-15", + ), + ) + + image_vector_search_profile = VectorSearchProfile( + name="images_embedding_profile", + algorithm_configuration_name=image_vector_algorithm.name, + vectorizer_name=image_vectorizer.vectorizer_name, + ) + images_field = SearchField( + name="images", + type=SearchFieldDataType.Collection(SearchFieldDataType.ComplexType), + fields=[ + SearchField( + name="embedding", + type=SearchFieldDataType.Collection(SearchFieldDataType.Single), + searchable=True, + stored=False, + vector_search_dimensions=1024, + vector_search_profile_name=image_vector_search_profile.name, + ), + SearchField( + name="url", + type=SearchFieldDataType.String, + searchable=False, + filterable=True, + sortable=False, + facetable=True, + ), + SearchField( + name="description", + type=SearchFieldDataType.String, + searchable=True, + filterable=False, + sortable=False, + facetable=False, + ), + SearchField( + name="boundingbox", + type=SearchFieldDataType.Collection(SearchFieldDataType.Double), + searchable=False, + filterable=False, + sortable=False, + facetable=False, + ), + ], + ) + + if self.use_acls: + oids_field = SearchField( + name="oids", + type=SearchFieldDataType.Collection(SearchFieldDataType.String), + filterable=True, + permission_filter=PermissionFilter.USER_IDS, + ) + groups_field = SearchField( + name="groups", + type=SearchFieldDataType.Collection(SearchFieldDataType.String), + filterable=True, + permission_filter=PermissionFilter.GROUP_IDS, + ) + + if self.search_info.index_name not in [name async for name in search_index_client.list_index_names()]: + logger.info("Creating new search index %s", self.search_info.index_name) + fields = [ + ( + SimpleField(name="id", type="Edm.String", key=True) + if not self.use_parent_index_projection + else SearchField( + name="id", + type="Edm.String", + key=True, + sortable=True, + filterable=True, + facetable=True, + analyzer_name="keyword", + ) + ), + SearchableField( + name="content", + type="Edm.String", + analyzer_name=self.search_analyzer_name, + ), + SimpleField(name="category", type="Edm.String", filterable=True, facetable=True), + SimpleField( + name="sourcepage", + type="Edm.String", + filterable=True, + facetable=True, + ), + SimpleField( + name="sourcefile", + type="Edm.String", + filterable=True, + facetable=True, + ), + SimpleField( + name="storageUrl", + type="Edm.String", + filterable=True, + facetable=False, + ), + ] + if self.use_acls: + fields.append(oids_field) + fields.append(groups_field) + permission_filter_option = ( + SearchIndexPermissionFilterOption.ENABLED + if self.enforce_access_control + else SearchIndexPermissionFilterOption.DISABLED + ) + + if self.use_parent_index_projection: + logger.info("Including parent_id field for parent/child index projection support in new index") + fields.append(SearchableField(name="parent_id", type="Edm.String", filterable=True)) + + vectorizers: list[VectorSearchVectorizer] = [] + vector_search_profiles = [] + vector_algorithms: list[VectorSearchAlgorithmConfiguration] = [] + vector_compressions: list[VectorSearchCompression] = [] + if embedding_field: + logger.info("Including %s field for text vectors in new index", embedding_field.name) + fields.append(embedding_field) + if text_vectorizer is not None: + vectorizers.append(text_vectorizer) + if ( + text_vector_search_profile is None + or text_vector_algorithm is None + or text_vector_compression is None + ): + raise ValueError("Text vector search profile, algorithm and compression must be set") + vector_search_profiles.append(text_vector_search_profile) + vector_algorithms.append(text_vector_algorithm) + vector_compressions.append(text_vector_compression) + + if images_field: + logger.info("Including %s field for image descriptions and vectors in new index", images_field.name) + fields.append(images_field) + if image_vector_search_profile is None or image_vector_algorithm is None: + raise ValueError("Image search profile and algorithm must be set") + vector_search_profiles.append(image_vector_search_profile) + vector_algorithms.append(image_vector_algorithm) + # Add image vectorizer to vectorizers list + vectorizers.append(image_vectorizer) + + index = SearchIndex( + name=self.search_info.index_name, + fields=fields, + semantic_search=SemanticSearch( + default_configuration_name="default", + configurations=[ + SemanticConfiguration( + name="default", + prioritized_fields=SemanticPrioritizedFields( + title_field=SemanticField(field_name="sourcepage"), + content_fields=[SemanticField(field_name="content")], + ), + ) + ], + ), + vector_search=VectorSearch( + profiles=vector_search_profiles, + algorithms=vector_algorithms, + compressions=vector_compressions, + vectorizers=vectorizers, + ), + permission_filter_option=permission_filter_option, + ) + + await search_index_client.create_index(index) + else: + logger.info("Search index %s already exists", self.search_info.index_name) + existing_index = await search_index_client.get_index(self.search_info.index_name) + if not any(field.name == "storageUrl" for field in existing_index.fields): + logger.info("Adding storageUrl field to index %s", self.search_info.index_name) + existing_index.fields.append( + SimpleField( + name="storageUrl", + type="Edm.String", + filterable=True, + facetable=False, + ), + ) + await search_index_client.create_or_update_index(existing_index) + + if embedding_field and not any( + field.name == self.field_name_embedding for field in existing_index.fields + ): + logger.info("Adding %s field for text embeddings", self.field_name_embedding) + embedding_field.stored = True + existing_index.fields.append(embedding_field) + if existing_index.vector_search is None: + raise ValueError("Vector search is not enabled for the existing index") + if text_vectorizer is not None: + if existing_index.vector_search.vectorizers is None: + existing_index.vector_search.vectorizers = [] + existing_index.vector_search.vectorizers.append(text_vectorizer) + if ( + text_vector_search_profile is None + or text_vector_algorithm is None + or text_vector_compression is None + ): + raise ValueError("Text vector search profile, algorithm and compression must be set") + if existing_index.vector_search.profiles is None: + existing_index.vector_search.profiles = [] + existing_index.vector_search.profiles.append(text_vector_search_profile) + if existing_index.vector_search.algorithms is None: + existing_index.vector_search.algorithms = [] + # existing_index.vector_search.algorithms.append(text_vector_algorithm) + if existing_index.vector_search.compressions is None: + existing_index.vector_search.compressions = [] + existing_index.vector_search.compressions.append(text_vector_compression) + await search_index_client.create_or_update_index(existing_index) + + if ( + images_field + and images_field.fields + and not any(field.name == "images" for field in existing_index.fields) + ): + logger.info("Adding %s field for image embeddings", images_field.name) + images_field.fields[0].stored = True + existing_index.fields.append(images_field) + if image_vector_search_profile is None or image_vector_algorithm is None: + raise ValueError("Image vector search profile and algorithm must be set") + if existing_index.vector_search is None: + raise ValueError("Image vector search is not enabled for the existing index") + if existing_index.vector_search.profiles is None: + existing_index.vector_search.profiles = [] + existing_index.vector_search.profiles.append(image_vector_search_profile) + if existing_index.vector_search.algorithms is None: + existing_index.vector_search.algorithms = [] + existing_index.vector_search.algorithms.append(image_vector_algorithm) + if existing_index.vector_search.vectorizers is None: + existing_index.vector_search.vectorizers = [] + existing_index.vector_search.vectorizers.append(image_vectorizer) + await search_index_client.create_or_update_index(existing_index) + + if existing_index.semantic_search: + if not existing_index.semantic_search.default_configuration_name: + logger.info("Adding default semantic configuration to index %s", self.search_info.index_name) + existing_index.semantic_search.default_configuration_name = "default" + + if existing_index.semantic_search.configurations: + existing_semantic_config = existing_index.semantic_search.configurations[0] + if ( + existing_semantic_config.prioritized_fields + and existing_semantic_config.prioritized_fields.title_field + and not existing_semantic_config.prioritized_fields.title_field.field_name == "sourcepage" + ): + logger.info("Updating semantic configuration for index %s", self.search_info.index_name) + existing_semantic_config.prioritized_fields.title_field = SemanticField( + field_name="sourcepage" + ) + + if existing_index.vector_search is not None and ( + existing_index.vector_search.vectorizers is None + or len(existing_index.vector_search.vectorizers) == 0 + ): + if ( + self.embeddings is not None + and self.embeddings.azure_endpoint + and self.embeddings.azure_deployment_name + ): + logger.info("Adding vectorizer to search index %s", self.search_info.index_name) + existing_index.vector_search.vectorizers = [ + AzureOpenAIVectorizer( + vectorizer_name=f"{self.search_info.index_name}-vectorizer", + parameters=AzureOpenAIVectorizerParameters( + resource_url=self.embeddings.azure_endpoint, + deployment_name=self.embeddings.azure_deployment_name, + model_name=self.embeddings.open_ai_model_name, + ), + ) + ] + await search_index_client.create_or_update_index(existing_index) + + else: + logger.info( + "Can't add vectorizer to search index %s since no Azure OpenAI embeddings service is defined", + self.search_info, + ) + + if self.use_acls: + if self.enforce_access_control: + logger.info("Enabling permission filtering on index %s", self.search_info.index_name) + existing_index.permission_filter_option = SearchIndexPermissionFilterOption.ENABLED + else: + logger.info("Disabling permission filtering on index %s", self.search_info.index_name) + existing_index.permission_filter_option = SearchIndexPermissionFilterOption.DISABLED + + existing_oids_field = next((field for field in existing_index.fields if field.name == "oids"), None) + if existing_oids_field: + existing_oids_field.permission_filter = PermissionFilter.USER_IDS + else: + existing_index.fields.append(oids_field) + existing_groups_field = next( + (field for field in existing_index.fields if field.name == "groups"), None + ) + if existing_groups_field: + existing_groups_field.permission_filter = PermissionFilter.GROUP_IDS + else: + existing_index.fields.append(groups_field) + + await search_index_client.create_or_update_index(existing_index) + + if self.search_info.use_agentic_knowledgebase and self.search_info.knowledgebase_name: + await self.create_knowledgebase() + + async def create_knowledgebase(self): + """Creates one or more Knowledge Bases in the search index based on desired knowledge sources.""" + if self.search_info.knowledgebase_name: + field_names = ["id", "sourcepage", "sourcefile", "content", "category"] + if self.use_acls: + field_names.extend(["oids", "groups"]) + if self.search_images: + field_names.append("images/url") + + # Create field references using the new SDK pattern + source_data_fields = [SearchIndexFieldReference(name=field) for field in field_names] + + async with self.search_info.create_search_index_client() as search_index_client: + search_index_knowledge_source = SearchIndexKnowledgeSource( + name=self.search_info.index_name, # Use the same name for convenience + description="Default knowledge source using the main search index", + search_index_parameters=SearchIndexKnowledgeSourceParameters( + search_index_name=self.search_info.index_name, + source_data_fields=source_data_fields, + ), + ) + await search_index_client.create_or_update_knowledge_source( + knowledge_source=search_index_knowledge_source + ) + + knowledge_source_refs: dict[str, KnowledgeSourceReference] = { + "index": KnowledgeSourceReference(name=search_index_knowledge_source.name) + } + + if self.use_web_source: + logger.info("Adding web knowledge source to the knowledge base") + web_knowledge_source = WebKnowledgeSource( + name="web" + # We do not specify a description here, since the default description is quite detailed already + ) + await search_index_client.create_or_update_knowledge_source(knowledge_source=web_knowledge_source) + knowledge_source_refs["web"] = KnowledgeSourceReference(name=web_knowledge_source.name) + + if self.use_sharepoint_source: + logger.info("Adding SharePoint knowledge source to the knowledge base") + sharepoint_knowledge_source = RemoteSharePointKnowledgeSource( + name="sharepoint", + description="SharePoint knowledge source", + remote_share_point_parameters=RemoteSharePointKnowledgeSourceParameters(), + ) + await search_index_client.create_or_update_knowledge_source( + knowledge_source=sharepoint_knowledge_source + ) + knowledge_source_refs["sharepoint"] = KnowledgeSourceReference( + name=sharepoint_knowledge_source.name + ) + + # Build the set of knowledge bases that should exist based on optional sources + base_knowledgebase_name = self.search_info.knowledgebase_name + knowledge_bases_to_upsert: list[tuple[str, list[KnowledgeSourceReference]]] = [ + (base_knowledgebase_name, [knowledge_source_refs["index"]]) + ] + + if "web" in knowledge_source_refs: + knowledge_bases_to_upsert.append( + ( + f"{base_knowledgebase_name}-with-web", + [knowledge_source_refs["index"], knowledge_source_refs["web"]], + ) + ) + if "sharepoint" in knowledge_source_refs: + knowledge_bases_to_upsert.append( + ( + f"{base_knowledgebase_name}-with-sp", + [knowledge_source_refs["index"], knowledge_source_refs["sharepoint"]], + ) + ) + if "web" in knowledge_source_refs and "sharepoint" in knowledge_source_refs: + knowledge_bases_to_upsert.append( + ( + f"{base_knowledgebase_name}-with-web-and-sp", + [ + knowledge_source_refs["index"], + knowledge_source_refs["web"], + knowledge_source_refs["sharepoint"], + ], + ) + ) + + created_kb_names: list[str] = [] + for kb_name, sources in knowledge_bases_to_upsert: + logger.info("Creating (or updating) knowledge base '%s'...", kb_name) + await search_index_client.create_or_update_knowledge_base( + knowledge_base=KnowledgeBase( + name=kb_name, + knowledge_sources=sources, + models=[ + KnowledgeBaseAzureOpenAIModel( + azure_open_ai_parameters=AzureOpenAIVectorizerParameters( + resource_url=self.search_info.azure_openai_endpoint, + deployment_name=self.search_info.azure_openai_knowledgebase_deployment, + model_name=self.search_info.azure_openai_knowledgebase_model, + ) + ) + ], + output_mode=KnowledgeRetrievalOutputMode.ANSWER_SYNTHESIS, + ) + ) + created_kb_names.append(kb_name) + + if created_kb_names: + logger.info( + "Knowledge bases created successfully: %s", + ", ".join(created_kb_names), + ) + + async def update_content(self, sections: list[Section], url: Optional[str] = None): + MAX_BATCH_SIZE = 1000 + section_batches = [sections[i : i + MAX_BATCH_SIZE] for i in range(0, len(sections), MAX_BATCH_SIZE)] + + async with self.search_info.create_search_client() as search_client: + for batch_index, batch in enumerate(section_batches): + documents = [] + for section_index, section in enumerate(batch): + image_fields = {} + if self.search_images: + image_fields = { + "images": [ + { + "url": image.url, + "description": image.description, + "boundingbox": image.bbox, + "embedding": image.embedding, + } + for image in section.chunk.images + ] + } + document = { + "id": f"{section.content.filename_to_id()}-page-{section_index + batch_index * MAX_BATCH_SIZE}", + "content": section.chunk.text, + "category": section.category, + "sourcepage": BlobManager.sourcepage_from_file_page( + filename=section.content.filename(), page=section.chunk.page_num + ), + "sourcefile": section.content.filename(), + **image_fields, + **section.content.acls, + } + documents.append(document) + if url: + for document in documents: + document["storageUrl"] = url + if self.embeddings: + if self.field_name_embedding is None: + raise ValueError("Embedding field name must be set") + embeddings = await self.embeddings.create_embeddings( + texts=[section.chunk.text for section in batch] + ) + for i, document in enumerate(documents): + document[self.field_name_embedding] = embeddings[i] + logger.info( + "Uploading batch %d with %d sections to search index '%s'", + batch_index + 1, + len(documents), + self.search_info.index_name, + ) + await search_client.upload_documents(documents) + + async def remove_content(self, path: Optional[str] = None, only_oid: Optional[str] = None): + logger.info( + "Removing sections from '{%s or ''}' from search index '%s'", path, self.search_info.index_name + ) + async with self.search_info.create_search_client() as search_client: + while True: + filter = None + if path is not None: + # Replace ' with '' to escape the single quote for the filter + # https://learn.microsoft.com/azure/search/query-odata-filter-orderby-syntax#escaping-special-characters-in-string-constants + path_for_filter = os.path.basename(path).replace("'", "''") + filter = f"sourcefile eq '{path_for_filter}'" + max_results = 1000 + result = await search_client.search( + search_text="", filter=filter, top=max_results, include_total_count=True + ) + result_count = await result.get_count() + if result_count == 0: + break + documents_to_remove = [] + async for document in result: + # If only_oid is set, only remove documents that have only this oid + if not only_oid or document.get("oids") == [only_oid]: + documents_to_remove.append({"id": document["id"]}) + if len(documents_to_remove) == 0: + if result_count < max_results: + break + else: + continue + removed_docs = await search_client.delete_documents(documents_to_remove) + logger.info("Removed %d sections from index", len(removed_docs)) + # It can take a few seconds for search results to reflect changes, so wait a bit + await asyncio.sleep(2) diff --git a/app/backend/prepdocslib/servicesetup.py b/app/backend/prepdocslib/servicesetup.py new file mode 100644 index 00000000..50a8ea6e --- /dev/null +++ b/app/backend/prepdocslib/servicesetup.py @@ -0,0 +1,337 @@ +"""Shared service setup helpers.""" + +import logging +import os +from collections.abc import Awaitable, Callable +from enum import Enum + +from azure.core.credentials import AzureKeyCredential +from azure.core.credentials_async import AsyncTokenCredential +from azure.identity.aio import get_bearer_token_provider +from openai import AsyncOpenAI + +from .blobmanager import BlobManager +from .csvparser import CsvParser +from .embeddings import ImageEmbeddings, OpenAIEmbeddings +from .figureprocessor import FigureProcessor, MediaDescriptionStrategy +from .fileprocessor import FileProcessor +from .htmlparser import LocalHTMLParser +from .jsonparser import JsonParser +from .parser import Parser +from .pdfparser import DocumentAnalysisParser, LocalPdfParser +from .strategy import SearchInfo +from .textparser import TextParser +from .textsplitter import SentenceTextSplitter, SimpleTextSplitter + +logger = logging.getLogger("scripts") + + +def clean_key_if_exists(key: str | None) -> str | None: + """Remove leading and trailing whitespace from a key if it exists. If the key is empty, return None.""" + if key is not None and key.strip() != "": + return key.strip() + return None + + +class OpenAIHost(str, Enum): + """Supported OpenAI hosting styles. + + OPENAI: Public OpenAI API. + AZURE: Standard Azure OpenAI (service name becomes endpoint). + AZURE_CUSTOM: A fully custom endpoint URL (for Network Isolation / APIM). + LOCAL: A locally hosted OpenAI-compatible endpoint (no key required). + """ + + OPENAI = "openai" + AZURE = "azure" + AZURE_CUSTOM = "azure_custom" + LOCAL = "local" + + +def setup_search_info( + search_service: str, + index_name: str, + azure_credential: AsyncTokenCredential, + use_agentic_knowledgebase: bool | None = None, + azure_openai_endpoint: str | None = None, + knowledgebase_name: str | None = None, + azure_openai_knowledgebase_deployment: str | None = None, + azure_openai_knowledgebase_model: str | None = None, + search_key: str | None = None, + azure_vision_endpoint: str | None = None, +) -> SearchInfo: + """Set search service information.""" + search_creds: AsyncTokenCredential | AzureKeyCredential = ( + azure_credential if search_key is None else AzureKeyCredential(search_key) + ) + if use_agentic_knowledgebase and azure_openai_knowledgebase_deployment is None: + raise ValueError("Azure OpenAI deployment for Knowledge Base must be specified for agentic retrieval.") + + return SearchInfo( + endpoint=f"https://{search_service}.search.windows.net/", + credential=search_creds, + index_name=index_name, + knowledgebase_name=knowledgebase_name, + use_agentic_knowledgebase=use_agentic_knowledgebase, + azure_openai_endpoint=azure_openai_endpoint, + azure_openai_knowledgebase_model=azure_openai_knowledgebase_model, + azure_openai_knowledgebase_deployment=azure_openai_knowledgebase_deployment, + azure_vision_endpoint=azure_vision_endpoint, + ) + + +def setup_openai_client( + openai_host: OpenAIHost, + azure_credential: AsyncTokenCredential, + azure_openai_api_key: str | None = None, + azure_openai_service: str | None = None, + azure_openai_custom_url: str | None = None, + openai_api_key: str | None = None, + openai_organization: str | None = None, +) -> tuple[AsyncOpenAI, str | None]: + """Create and return an AsyncOpenAI client based on hosting configuration.""" + openai_client: AsyncOpenAI + azure_openai_endpoint: str | None = None + + if openai_host in [OpenAIHost.AZURE, OpenAIHost.AZURE_CUSTOM]: + base_url: str | None = None + api_key_or_token: str | Callable[[], Awaitable[str]] | None = None + if openai_host == OpenAIHost.AZURE_CUSTOM: + logger.info("OPENAI_HOST is azure_custom, setting up Azure OpenAI custom client") + if not azure_openai_custom_url: + raise ValueError("AZURE_OPENAI_CUSTOM_URL must be set when OPENAI_HOST is azure_custom") + base_url = azure_openai_custom_url + else: + logger.info("OPENAI_HOST is azure, setting up Azure OpenAI client") + if not azure_openai_service: + raise ValueError("AZURE_OPENAI_SERVICE must be set when OPENAI_HOST is azure") + azure_openai_endpoint = f"https://{azure_openai_service}.openai.azure.com" + base_url = f"{azure_openai_endpoint}/openai/v1" + if azure_openai_api_key: + logger.info("AZURE_OPENAI_API_KEY_OVERRIDE found, using as api_key for Azure OpenAI client") + api_key_or_token = azure_openai_api_key + else: + logger.info("Using Azure credential (passwordless authentication) for Azure OpenAI client") + api_key_or_token = get_bearer_token_provider( + azure_credential, "https://cognitiveservices.azure.com/.default" + ) + openai_client = AsyncOpenAI( + base_url=base_url, + api_key=api_key_or_token, # type: ignore[arg-type] + ) + elif openai_host == OpenAIHost.LOCAL: + logger.info("OPENAI_HOST is local, setting up local OpenAI client for OPENAI_BASE_URL with no key") + openai_client = AsyncOpenAI( + base_url=os.environ["OPENAI_BASE_URL"], + api_key="no-key-required", + ) + else: + logger.info( + "OPENAI_HOST is not azure, setting up OpenAI client using OPENAI_API_KEY and OPENAI_ORGANIZATION environment variables" + ) + if openai_api_key is None: + raise ValueError("OpenAI key is required when using the non-Azure OpenAI API") + openai_client = AsyncOpenAI( + api_key=openai_api_key, + organization=openai_organization, + ) + return openai_client, azure_openai_endpoint + + +def setup_image_embeddings_service( + azure_credential: AsyncTokenCredential, + vision_endpoint: str | None, + use_multimodal: bool, +) -> ImageEmbeddings | None: + """Create an ImageEmbeddings service if multimodal features are enabled.""" + image_embeddings_service: ImageEmbeddings | None = None + if use_multimodal: + if vision_endpoint is None: + raise ValueError("An Azure AI Vision endpoint must be provided to use multimodal features.") + image_embeddings_service = ImageEmbeddings( + endpoint=vision_endpoint, + token_provider=get_bearer_token_provider(azure_credential, "https://cognitiveservices.azure.com/.default"), + ) + return image_embeddings_service + + +def setup_embeddings_service( + openai_host: OpenAIHost, + open_ai_client: AsyncOpenAI, + emb_model_name: str, + emb_model_dimensions: int, + azure_openai_deployment: str | None = None, + azure_openai_endpoint: str | None = None, + disable_batch: bool = False, +) -> OpenAIEmbeddings: + """Create an OpenAIEmbeddings service based on hosting configuration.""" + if openai_host in [OpenAIHost.AZURE, OpenAIHost.AZURE_CUSTOM]: + if azure_openai_endpoint is None: + raise ValueError("Azure OpenAI endpoint must be provided when using Azure OpenAI embeddings") + if azure_openai_deployment is None: + raise ValueError("Azure OpenAI deployment must be provided when using Azure OpenAI embeddings") + + return OpenAIEmbeddings( + open_ai_client=open_ai_client, + open_ai_model_name=emb_model_name, + open_ai_dimensions=emb_model_dimensions, + disable_batch=disable_batch, + azure_deployment_name=azure_openai_deployment, + azure_endpoint=azure_openai_endpoint, + ) + + +def setup_blob_manager( + azure_credential: AsyncTokenCredential | str, + storage_account: str, + storage_container: str, + storage_resource_group: str | None = None, + subscription_id: str | None = None, + storage_key: str | None = None, + image_storage_container: str | None = None, +) -> BlobManager: + """Create a BlobManager instance for document or figure storage. + + The optional resource group and subscription are retained for parity with + local ingestion (used for diagnostic operations) but not required by + Azure Functions. + The optional image storage container is used for the multimodal ingestion feature. + """ + endpoint = f"https://{storage_account}.blob.core.windows.net" + storage_credential: AsyncTokenCredential | str = azure_credential if storage_key is None else storage_key + + return BlobManager( + endpoint=endpoint, + container=storage_container, + account=storage_account, + credential=storage_credential, + resource_group=storage_resource_group, + subscription_id=subscription_id, + image_container=image_storage_container, + ) + + +def setup_figure_processor( + *, + credential: AsyncTokenCredential | None, + use_multimodal: bool, + use_content_understanding: bool, + content_understanding_endpoint: str | None, + openai_client: object | None, + openai_model: str | None, + openai_deployment: str | None, +) -> FigureProcessor | None: + """Create a FigureProcessor based on feature flags. + + Priority order: + 1. use_multimodal -> MediaDescriptionStrategy.OPENAI + 2. else if use_content_understanding and endpoint -> CONTENTUNDERSTANDING + 3. else -> return None (no figure description) + """ + if use_multimodal: + return FigureProcessor( + credential=credential, + strategy=MediaDescriptionStrategy.OPENAI, + openai_client=openai_client, + openai_model=openai_model, + openai_deployment=openai_deployment, + ) + if use_content_understanding and content_understanding_endpoint: + return FigureProcessor( + credential=credential, + strategy=MediaDescriptionStrategy.CONTENTUNDERSTANDING, + content_understanding_endpoint=content_understanding_endpoint, + ) + return None + + +def build_file_processors( + *, + azure_credential: AsyncTokenCredential, + document_intelligence_service: str | None, + document_intelligence_key: str | None = None, + use_local_pdf_parser: bool = False, + use_local_html_parser: bool = False, + process_figures: bool = False, +) -> dict[str, FileProcessor]: + """Build a dictionary of file processors for supported file types.""" + sentence_text_splitter = SentenceTextSplitter() + + doc_int_parser: DocumentAnalysisParser | None = None + # check if Azure Document Intelligence credentials are provided + if document_intelligence_service: + credential: AsyncTokenCredential | AzureKeyCredential + if document_intelligence_key: + credential = AzureKeyCredential(document_intelligence_key) + else: + credential = azure_credential + doc_int_parser = DocumentAnalysisParser( + endpoint=f"https://{document_intelligence_service}.cognitiveservices.azure.com/", + credential=credential, + process_figures=process_figures, + ) + + pdf_parser: Parser | None = None + if use_local_pdf_parser or document_intelligence_service is None: + pdf_parser = LocalPdfParser() + elif doc_int_parser is not None: + pdf_parser = doc_int_parser + else: + logger.warning("No PDF parser available") + + html_parser: Parser | None = None + if use_local_html_parser or document_intelligence_service is None: + html_parser = LocalHTMLParser() + elif doc_int_parser is not None: + html_parser = doc_int_parser + else: + logger.warning("No HTML parser available") + + # These file formats can always be parsed: + file_processors = { + ".json": FileProcessor(JsonParser(), SimpleTextSplitter()), + ".md": FileProcessor(TextParser(), sentence_text_splitter), + ".txt": FileProcessor(TextParser(), sentence_text_splitter), + ".csv": FileProcessor(CsvParser(), sentence_text_splitter), + } + # These require either a Python package or Document Intelligence + if pdf_parser is not None: + file_processors.update({".pdf": FileProcessor(pdf_parser, sentence_text_splitter)}) + if html_parser is not None: + file_processors.update({".html": FileProcessor(html_parser, sentence_text_splitter)}) + # These file formats require Document Intelligence + if doc_int_parser is not None: + file_processors.update( + { + ".docx": FileProcessor(doc_int_parser, sentence_text_splitter), + ".pptx": FileProcessor(doc_int_parser, sentence_text_splitter), + ".xlsx": FileProcessor(doc_int_parser, sentence_text_splitter), + ".png": FileProcessor(doc_int_parser, sentence_text_splitter), + ".jpg": FileProcessor(doc_int_parser, sentence_text_splitter), + ".jpeg": FileProcessor(doc_int_parser, sentence_text_splitter), + ".tiff": FileProcessor(doc_int_parser, sentence_text_splitter), + ".bmp": FileProcessor(doc_int_parser, sentence_text_splitter), + ".heic": FileProcessor(doc_int_parser, sentence_text_splitter), + } + ) + return file_processors + + +def select_processor_for_filename(file_name: str, file_processors: dict[str, FileProcessor]) -> FileProcessor: + """Select the appropriate file processor for a given filename. + + Args: + file_name: Name of the file to process + file_processors: Dictionary mapping file extensions to FileProcessor instances + + Returns: + FileProcessor instance for the file + + Raises: + ValueError: If the file extension is not supported + """ + file_ext = os.path.splitext(file_name)[1].lower() + file_processor = file_processors.get(file_ext) + if not file_processor: + raise ValueError(f"Unsupported file type: {file_name}") + return file_processor diff --git a/app/backend/prepdocslib/strategy.py b/app/backend/prepdocslib/strategy.py new file mode 100644 index 00000000..9312f9f6 --- /dev/null +++ b/app/backend/prepdocslib/strategy.py @@ -0,0 +1,66 @@ +from abc import ABC +from enum import Enum +from typing import Optional + +from azure.core.credentials import AzureKeyCredential +from azure.core.credentials_async import AsyncTokenCredential +from azure.search.documents.aio import SearchClient +from azure.search.documents.indexes.aio import SearchIndexClient, SearchIndexerClient + +USER_AGENT = "azure-search-chat-demo/1.0.0" + + +class SearchInfo: + """ + Class representing a connection to a search service + To learn more, please visit https://learn.microsoft.com/azure/search/search-what-is-azure-search + """ + + def __init__( + self, + endpoint: str, + credential: AsyncTokenCredential | AzureKeyCredential, + index_name: str, + use_agentic_knowledgebase: Optional[bool] = False, + knowledgebase_name: Optional[str] = None, + azure_openai_knowledgebase_model: Optional[str] = None, + azure_openai_knowledgebase_deployment: Optional[str] = None, + azure_openai_endpoint: Optional[str] = None, + azure_vision_endpoint: Optional[str] = None, + ): + self.endpoint = endpoint + self.credential = credential + self.index_name = index_name + self.knowledgebase_name = knowledgebase_name + self.use_agentic_knowledgebase = use_agentic_knowledgebase + self.azure_openai_knowledgebase_model = azure_openai_knowledgebase_model + self.azure_openai_knowledgebase_deployment = azure_openai_knowledgebase_deployment + self.azure_openai_endpoint = azure_openai_endpoint + self.azure_vision_endpoint = azure_vision_endpoint + + def create_search_client(self) -> SearchClient: + return SearchClient(endpoint=self.endpoint, index_name=self.index_name, credential=self.credential) + + def create_search_index_client(self) -> SearchIndexClient: + return SearchIndexClient(endpoint=self.endpoint, credential=self.credential) + + def create_search_indexer_client(self) -> SearchIndexerClient: + return SearchIndexerClient(endpoint=self.endpoint, credential=self.credential) + + +class DocumentAction(Enum): + Add = 0 + Remove = 1 + RemoveAll = 2 + + +class Strategy(ABC): + """ + Abstract strategy for ingesting documents into a search service. It has a single setup step to perform any required initialization, and then a run step that actually ingests documents into the search service. + """ + + async def setup(self): + raise NotImplementedError + + async def run(self): + raise NotImplementedError diff --git a/app/backend/prepdocslib/textparser.py b/app/backend/prepdocslib/textparser.py new file mode 100644 index 00000000..2ffea49c --- /dev/null +++ b/app/backend/prepdocslib/textparser.py @@ -0,0 +1,31 @@ +import re +from collections.abc import AsyncGenerator +from typing import IO + +from .page import Page +from .parser import Parser + + +def cleanup_data(data: str) -> str: + """Cleans up the given content using regexes + Args: + data: (str): The data to clean up. + Returns: + str: The cleaned up data. + """ + # match two or more newlines and replace them with one new line + output = re.sub(r"\n{2,}", "\n", data) + # match two or more spaces that are not newlines and replace them with one space + output = re.sub(r"[^\S\n]{2,}", " ", output) + + return output.strip() + + +class TextParser(Parser): + """Parses simple text into a Page object.""" + + async def parse(self, content: IO) -> AsyncGenerator[Page, None]: + data = content.read() + decoded_data = data.decode("utf-8") + text = cleanup_data(decoded_data) + yield Page(0, 0, text=text) diff --git a/app/backend/prepdocslib/textprocessor.py b/app/backend/prepdocslib/textprocessor.py new file mode 100644 index 00000000..2895a805 --- /dev/null +++ b/app/backend/prepdocslib/textprocessor.py @@ -0,0 +1,51 @@ +"""Utilities for processing document text and combining it with figure descriptions.""" + +import logging + +from .figureprocessor import build_figure_markup +from .listfilestrategy import File +from .page import Page +from .searchmanager import Section +from .textsplitter import TextSplitter + +logger = logging.getLogger("scripts") + + +def combine_text_with_figures(page: "Page") -> None: + """Replace figure placeholders in page text with full description markup.""" + for image in page.images: + if image.description and image.placeholder in page.text: + figure_markup = build_figure_markup(image, image.description) + page.text = page.text.replace(image.placeholder, figure_markup) + logger.info("Replaced placeholder for figure %s with description markup", image.figure_id) + elif not image.description: + logger.debug("No description for figure %s; keeping placeholder", image.figure_id) + elif image.placeholder not in page.text: + logger.warning("Placeholder not found for figure %s in page %d", image.figure_id, page.page_num) + + +def process_text( + pages: list["Page"], + file: "File", + splitter: "TextSplitter", + category: str | None = None, +) -> list["Section"]: + """Process document text and figures into searchable sections. + Combines text with figure descriptions, splits into chunks, and + associates figures with their containing sections. + """ + # Step 1: Combine text with figures on each page + for page in pages: + combine_text_with_figures(page) + + # Step 2: Split combined text into chunks + logger.info("Splitting '%s' into sections", file.filename()) + sections = [Section(chunk, content=file, category=category) for chunk in splitter.split_pages(pages)] + + # Step 3: Add images back to each section based on page number + for section in sections: + section.chunk.images = [ + image for page in pages if page.page_num == section.chunk.page_num for image in page.images + ] + + return sections diff --git a/app/backend/prepdocslib/textsplitter.py b/app/backend/prepdocslib/textsplitter.py new file mode 100644 index 00000000..4f0e3c6e --- /dev/null +++ b/app/backend/prepdocslib/textsplitter.py @@ -0,0 +1,608 @@ +import logging +import re +from abc import ABC +from collections.abc import Generator +from dataclasses import dataclass, field +from typing import Optional + +import tiktoken + +from .page import Chunk, Page + +logger = logging.getLogger("scripts") + + +class TextSplitter(ABC): + """ + Splits a list of pages into smaller chunks. + :param pages: The pages to split + :return: A generator of Chunk + """ + + def split_pages(self, pages: list[Page]) -> Generator[Chunk, None, None]: + if False: # pragma: no cover - this is necessary for mypy to type check + yield + + +ENCODING_MODEL = "text-embedding-ada-002" + +STANDARD_WORD_BREAKS = [",", ";", ":", " ", "(", ")", "[", "]", "{", "}", "\t", "\n"] + +# See W3C document https://www.w3.org/TR/jlreq/#cl-01 +CJK_WORD_BREAKS = [ + "、", + ",", + ";", + ":", + "(", + ")", + "【", + "】", + "「", + "」", + "『", + "』", + "〔", + "〕", + "〈", + "〉", + "《", + "》", + "〖", + "〗", + "〘", + "〙", + "〚", + "〛", + "〝", + "〞", + "〟", + "〰", + "–", + "—", + "‘", + "’", + "‚", + "‛", + "“", + "”", + "„", + "‟", + "‹", + "›", +] + +STANDARD_SENTENCE_ENDINGS = [".", "!", "?"] + +# See CL05 and CL06, based on JIS X 4051:2004 +# https://www.w3.org/TR/jlreq/#cl-04 +CJK_SENTENCE_ENDINGS = ["。", "!", "?", "‼", "⁇", "⁈", "⁉"] + +# NB: text-embedding-3-XX is the same BPE as text-embedding-ada-002 +bpe = tiktoken.encoding_for_model(ENCODING_MODEL) + +DEFAULT_OVERLAP_PERCENT = 10 # See semantic search article for 10% overlap performance +DEFAULT_SECTION_LENGTH = 1000 # Roughly 400-500 tokens for English + + +def _safe_concat(a: str, b: str) -> str: + """Concatenate two non-empty segments, inserting a space only when both sides + end/start with alphanumerics and no natural boundary exists. + + Rules: + - Both input strings are expected to be non-empty + - Preserve existing whitespace if either side already provides a boundary. + - Do not insert a space after a closing HTML tag marker '>'. + - If both boundary characters are alphanumeric, insert a single space. + - Otherwise concatenate directly. + """ + assert a and b, "_safe_concat expects non-empty strings" + a_last = a[-1] + b_first = b[0] + if a_last.isspace() or b_first.isspace(): # pre-existing boundary + return a + b + if a_last == ">": # HTML tag end acts as a boundary + return a + b + if a_last.isalnum() and b_first.isalnum(): # need explicit separator + return a + " " + b + return a + b + + +def _normalize_chunk(text: str, max_chars: int) -> str: + """Normalize a non-figure chunk that may slightly exceed max_chars. + + Allows overflow for any chunk containing a
tag (figures are atomic), + trims leading spaces if they alone cause minor overflow, and as a final step + removes a trailing space/newline when within a small tolerance (<=3 chars over). + """ + lower = text.lower() + if " max_chars: + trimmed = trimmed[1:] + if len(trimmed) > max_chars and len(trimmed) <= max_chars + 3: + if trimmed.endswith(" ") or trimmed.endswith("\n"): + trimmed = trimmed.rstrip() + return trimmed + + +@dataclass +class _ChunkBuilder: + """Accumulates sentence-like spans for a single page until size limits are reached. + + Responsibilities: + - Track appended text fragments and their approximate token length. + - Decide if a new span can be added without exceeding character or token thresholds. + - Flush accumulated content into an output list as a `Chunk`. + - Allow a figure block to be force-appended (even if it overflows) so that headings + figure stay together. + + Notes: + - Character limit is soft (exact enforcement + later normalization); token limit is hard. + - Token counts are computed by the caller and passed to `add`; this class stays agnostic of the encoder. + """ + + page_num: int + max_chars: int + max_tokens: int + parts: list[str] = field(default_factory=list) + token_len: int = 0 + + def can_fit(self, text: str, token_count: int) -> bool: + if not self.parts: # always allow first span + return token_count <= self.max_tokens and len(text) <= self.max_chars + # Character + token constraints + return (len("".join(self.parts)) + len(text) <= self.max_chars) and ( + self.token_len + token_count <= self.max_tokens + ) + + def add(self, text: str, token_count: int) -> bool: + if not self.can_fit(text, token_count): + return False + self.parts.append(text) + self.token_len += token_count + return True + + def force_append(self, text: str): + self.parts.append(text) + + def flush_into(self, out: list[Chunk]): + if self.parts: + chunk = "".join(self.parts) + if chunk.strip(): + out.append(Chunk(page_num=self.page_num, text=chunk)) + self.parts.clear() + self.token_len = 0 + + # Convenience helpers for readability at call sites + def has_content(self) -> bool: + return bool(self.parts) + + def append_figure_and_flush(self, figure_text: str, out: list[Chunk]): + """Append a figure (allowed to overflow) to current accumulation and flush in one step.""" + self.force_append(figure_text) + self.flush_into(out) + + +class SentenceTextSplitter(TextSplitter): + """ + Class that splits pages into smaller chunks. This is required because embedding models may not be able to analyze an entire page at once + """ + + def __init__(self, max_tokens_per_section: int = 500): + self.sentence_endings = STANDARD_SENTENCE_ENDINGS + CJK_SENTENCE_ENDINGS + self.word_breaks = STANDARD_WORD_BREAKS + CJK_WORD_BREAKS + self.max_section_length = DEFAULT_SECTION_LENGTH + self.sentence_search_limit = 100 + self.max_tokens_per_section = max_tokens_per_section + self.section_overlap = int(self.max_section_length * DEFAULT_OVERLAP_PERCENT / 100) + # Always-on semantic overlap percent (duplicated suffix of previous chunk), applied: + # - Between chunks on the same page. + # - Across page boundary ONLY if semantic continuation heuristics pass. + self.semantic_overlap_percent = 10 + + def _find_split_pos(self, text: str) -> tuple[int, bool]: + """Find a good split position near midpoint. + + Returns (index, use_overlap_fallback). + + Priority: + 1. Sentence-ending punctuation near midpoint (scan outward within central third). + 2. Word-break character near midpoint (space / punctuation) within same window. + 3. Fallback: caller should use midpoint + overlap strategy. + """ + length = len(text) + if length < 4: + return -1, True + mid = length // 2 + window_limit = length // 3 # defines central region scan boundary + + # 1. Sentence endings + pos = 0 + while mid - pos > window_limit: + left = mid - pos + right = mid + pos + if left >= 0 and text[left] in self.sentence_endings: + return left, False + if right < length and text[right] in self.sentence_endings: + return right, False + pos += 1 + + # 2. Word breaks + pos = 0 + while mid - pos > window_limit: + left = mid - pos + right = mid + pos + if left >= 0 and text[left] in self.word_breaks: + return left, False + if right < length and text[right] in self.word_breaks: + return right, False + pos += 1 + + # 3. Fallback + return -1, True + + def split_page_by_max_tokens(self, page_num: int, text: str) -> Generator[Chunk, None, None]: + """Recursively split plain text by token count. + + Boundary preference order when an oversized span is encountered: + 1. Sentence-ending punctuation near midpoint. + 2. Word-break character near midpoint (space/punctuation) to avoid mid-word cuts. + 3. Midpoint split with symmetric overlap (DEFAULT_OVERLAP_PERCENT). + """ + tokens = bpe.encode(text) + if len(tokens) <= self.max_tokens_per_section: + yield Chunk(page_num=page_num, text=text) + return + + split_pos, use_overlap = self._find_split_pos(text) + if not use_overlap and split_pos > 0: + first_half = text[: split_pos + 1] + second_half = text[split_pos + 1 :] + else: + middle = len(text) // 2 + overlap = int(len(text) * (DEFAULT_OVERLAP_PERCENT / 100)) + first_half = text[: middle + overlap] + second_half = text[middle - overlap :] + + yield from self.split_page_by_max_tokens(page_num, first_half) + yield from self.split_page_by_max_tokens(page_num, second_half) + + def _is_heading_like(self, line: str) -> bool: + """Heuristic heading detector used to suppress cross-page semantic overlap when a new section starts.""" + line_str = line.strip() + if not line_str: + return False + if line_str.startswith("#"): + return True + # Short Title Case or ALL CAPS lines (limited word count) often represent headings + if len(line_str) <= 80 and (line_str.isupper() or (line_str.istitle() and len(line_str.split()) <= 12)): + return True + import re as _re + + # Numbered / roman numeral list or section forms: '1. ', 'II) ', 'III. ' + if _re.match(r"^(?:\d+|[IVXLCM]+)[.)]\s", line_str): + return True + if line_str.startswith(("- ", "* ", "• ")): + return True + return False + + def _should_cross_page_overlap(self, prev: Chunk, nxt: Chunk) -> bool: + if not prev or not nxt: + return False + if " Chunk: + """Return a modified copy of prev_chunk whose text has an appended semantic + overlap prefix from next_chunk; next_chunk itself is left unchanged so it + continues to start at its natural sentence boundary. + + Strategy: + - Take ~semantic_overlap_percent tail size (in chars) from the START of next_chunk. + - Extend that region forward to the first sentence-ending (preferred) or word break + so we end on a natural boundary (avoid chopping mid-word/mid-sentence). + - Refuse overlap if either chunk contains a
to avoid duplicating figures. + - Enforce hard token + soft char limits; shrink overlap if necessary. + """ + if not prev_chunk or not next_chunk: + return prev_chunk + if " 20: # fallback boundary after some progress + boundary_found = True + break + if not boundary_found: + # Trim trailing partial word if we stopped without boundary + while prefix and prefix[-1].isalnum() and len(prefix) > target: + prefix = prefix[:-1] + + # Avoid appending text that already exists at end (rare but possible due to prior operations) + if prev_chunk.text.endswith(prefix): + return prev_chunk + + candidate = prev_chunk.text + prefix + max_chars = int(self.max_section_length * 1.2) + if len(candidate) > max_chars or len(bpe.encode(candidate)) > self.max_tokens_per_section: + # Attempt to shrink prefix at word / sentence boundaries from its start + shrink = prefix + while shrink and ( + len(prev_chunk.text + shrink) > max_chars + or len(bpe.encode(prev_chunk.text + shrink)) > self.max_tokens_per_section + ): + cut_index = 1 + for i, ch in enumerate(shrink): + if ch in self.word_breaks or ch in self.sentence_endings: + cut_index = i + 1 + break + shrink = shrink[:-cut_index] if cut_index < len(shrink) else "" + if not shrink: + return prev_chunk + candidate = prev_chunk.text + shrink + if len(candidate) > max_chars or len(bpe.encode(candidate)) > self.max_tokens_per_section: + return prev_chunk + return Chunk(page_num=prev_chunk.page_num, text=candidate) + + def split_pages(self, pages: list[Page]) -> Generator[Chunk, None, None]: + """Split each page into semantic chunks using token-aware accumulation with atomic figures. + + Strategy (per page): + 1. Extract balanced
...
blocks as atomic "figure" blocks. + 2. Treat intervening text as "text" blocks. + 3. For text blocks, break into sentence-ish spans (using sentence ending chars) and accumulate + until adding the next span would exceed either character or token limit. Flush when needed. + 4. When a figure block arrives: + - If there is accumulated text (builder), append the figure even if this exceeds token limit and flush. + - If no accumulated text, emit the figure as its own chunk. + 5. Ignore token limits for any chunk that contains a figure (never split figures). + This avoids partial/duplicated figures and keeps headings with their following figure when space permits. + """ + figure_regex = re.compile(r"", re.IGNORECASE | re.DOTALL) + previous_chunk: Optional[Chunk] = None + + for page in pages: + raw = page.text or "" + if not raw.strip(): + continue + + # Build ordered list of blocks: (type, text) + blocks: list[tuple[str, str]] = [] + last = 0 + for m in figure_regex.finditer(raw): + if m.start() > last: + blocks.append(("text", raw[last : m.start()])) + blocks.append(("figure", m.group())) + last = m.end() + if last < len(raw): + blocks.append(("text", raw[last:])) + + page_chunks: list[Chunk] = [] + builder = _ChunkBuilder( + page_num=page.page_num, + max_chars=self.max_section_length, + max_tokens=self.max_tokens_per_section, + ) + + for btype, btext in blocks: + if btype == "figure": + if builder.has_content(): + # Append figure to existing text (allow overflow) and flush + builder.append_figure_and_flush(btext, page_chunks) + else: + # Emit figure standalone + if btext.strip(): + page_chunks.append(Chunk(page_num=page.page_num, text=btext)) + continue + + # Process text block: split into sentence-like spans + spans: list[str] = [] + current_chars: list[str] = [] + for ch in btext: + current_chars.append(ch) + if ch in self.sentence_endings: + spans.append("".join(current_chars)) + current_chars = [] + if current_chars: # remaining tail + spans.append("".join(current_chars)) + + for span in spans: + span_tokens = len(bpe.encode(span)) + # If a single span itself exceeds token limit (rare, very long sentence), split it directly + if span_tokens > self.max_tokens_per_section: + builder.flush_into(page_chunks) + for chunk in self.split_page_by_max_tokens(page.page_num, span): + page_chunks.append(chunk) + continue + if not builder.add(span, span_tokens): + # Flush and retry (guaranteed to fit because span_tokens <= limit) + builder.flush_into(page_chunks) + if not builder.add(span, span_tokens): + page_chunks.append(Chunk(page_num=page.page_num, text=span)) + + # Flush any trailing builder content + builder.flush_into(page_chunks) + + # Attempt cross-page merge with previous_chunk (look-behind) if semantic continuation + if previous_chunk and page_chunks: + prev_last_char = previous_chunk.text.rstrip()[-1:] if previous_chunk.text.rstrip() else "" + first_new = page_chunks[0] + first_new_stripped = first_new.text.lstrip() + first_char = first_new_stripped[:1] + if ( + prev_last_char + and prev_last_char not in self.sentence_endings + and not first_new_stripped.startswith("#") + and first_char + and first_char.islower() + and " bool: + combined = candidate + first_new_text + if len(combined) > max_chars: + return False + if len(bpe.encode(combined)) > self.max_tokens_per_section: + return False + return True + + move_fragment = fragment_full + if not fits(move_fragment): + # Hard trim path: fragment begins after the last sentence-ending punctuation + # of the previous chunk. Reduce to remaining character budget, then iteratively + # shrink until token constraints are satisfied. + remaining_chars = max_chars - len(first_new_text) # always > 0 given builder invariants + move_fragment = move_fragment[:remaining_chars] + while ( + move_fragment + and len(bpe.encode(move_fragment + first_new_text)) > self.max_tokens_per_section + ): + move_fragment = ( + move_fragment[:-50] if len(move_fragment) > 50 else move_fragment[:-1] + ) + leftover_fragment = fragment_full[len(move_fragment) :] + # Prepend the allowed fragment + if move_fragment: + page_chunks[0] = Chunk( + page_num=page_chunks[0].page_num, + text=_safe_concat(move_fragment, first_new_text), + ) + # Adjust previous_chunk retained portion + if retained.strip(): + previous_chunk = Chunk(page_num=previous_chunk.page_num, text=retained) + else: + previous_chunk = None + # Insert leftover fragment as its own chunk (split if needed) BEFORE modified first_new + if leftover_fragment.strip(): + # Ensure leftover respects limits by splitting if needed + leftover_pages = list( + self.split_page_by_max_tokens(page_chunks[0].page_num, leftover_fragment) + ) + # Insert these before current first chunk + page_chunks = leftover_pages + page_chunks + + # Normalize chunks (non-figure) that barely exceed char limit due to added boundary space + max_chars = int(self.max_section_length * 1.2) + if previous_chunk: + previous_chunk = Chunk( + page_num=previous_chunk.page_num, text=_normalize_chunk(previous_chunk.text, max_chars) + ) + if page_chunks: + page_chunks = [ + Chunk(page_num=chunk.page_num, text=_normalize_chunk(chunk.text, max_chars)) + for chunk in page_chunks + ] + + # Apply semantic overlap duplication (append style). We append a small + # prefix of the NEXT chunk onto the PREVIOUS chunk, keeping natural starts. + if self.semantic_overlap_percent > 0: + # Cross-page overlap: modify previous_chunk (look-ahead to first new chunk) + if previous_chunk and page_chunks and self._should_cross_page_overlap(previous_chunk, page_chunks[0]): + previous_chunk = self._append_overlap(previous_chunk, page_chunks[0]) + + # Intra-page overlaps + if len(page_chunks) > 1: + for i in range(1, len(page_chunks)): + prev_c = page_chunks[i - 1] + curr_c = page_chunks[i] + if " Generator[Chunk, None, None]: + all_text = "".join(page.text for page in pages) + if len(all_text.strip()) == 0: + return + + length = len(all_text) + if length <= self.max_object_length: + yield Chunk(page_num=0, text=all_text) + return + + # its too big, so we need to split it + for i in range(0, length, self.max_object_length): + yield Chunk(page_num=i // self.max_object_length, text=all_text[i : i + self.max_object_length]) + return diff --git a/app/backend/requirements.in b/app/backend/requirements.in new file mode 100644 index 00000000..ba28ab5e --- /dev/null +++ b/app/backend/requirements.in @@ -0,0 +1,33 @@ +azure-identity +quart +quart-cors +openai>=1.109.1 +tiktoken +tenacity +azure-ai-documentintelligence==1.0.2 +azure-cognitiveservices-speech +azure-cosmos +azure-search-documents==11.7.0b2 +azure-storage-blob +azure-storage-file-datalake +uvicorn +aiohttp +azure-monitor-opentelemetry +opentelemetry-instrumentation-asgi +opentelemetry-instrumentation-httpx +opentelemetry-instrumentation-aiohttp-client +opentelemetry-instrumentation-openai +msal +cryptography +PyJWT +Pillow +types-Pillow +pypdf +PyMuPDF +beautifulsoup4 +types-beautifulsoup4 +msgraph-sdk +python-dotenv +prompty +rich +typing-extensions diff --git a/app/backend/requirements.txt b/app/backend/requirements.txt new file mode 100644 index 00000000..560acb1d --- /dev/null +++ b/app/backend/requirements.txt @@ -0,0 +1,456 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile requirements.in -o requirements.txt --python-version 3.10 +aiofiles==24.1.0 + # via + # prompty + # quart +aiohappyeyeballs==2.6.1 + # via aiohttp +aiohttp==3.12.14 + # via + # -r requirements.in + # microsoft-kiota-authentication-azure +aiosignal==1.4.0 + # via aiohttp +annotated-types==0.7.0 + # via pydantic +anyio==4.4.0 + # via + # httpx + # openai +asgiref==3.10.0 + # via opentelemetry-instrumentation-asgi +async-timeout==5.0.1 + # via aiohttp +attrs==25.3.0 + # via aiohttp +azure-ai-documentintelligence==1.0.2 + # via -r requirements.in +azure-cognitiveservices-speech==1.40.0 + # via -r requirements.in +azure-common==1.1.28 + # via azure-search-documents +azure-core==1.35.0 + # via + # azure-ai-documentintelligence + # azure-core-tracing-opentelemetry + # azure-cosmos + # azure-identity + # azure-monitor-opentelemetry + # azure-monitor-opentelemetry-exporter + # azure-search-documents + # azure-storage-blob + # azure-storage-file-datalake + # microsoft-kiota-authentication-azure + # msrest +azure-core-tracing-opentelemetry==1.0.0b11 + # via azure-monitor-opentelemetry +azure-cosmos==4.9.0 + # via -r requirements.in +azure-functions==1.24.0 + # via -r requirements.in +azure-identity==1.17.1 + # via + # -r requirements.in + # azure-monitor-opentelemetry-exporter + # msgraph-sdk +azure-monitor-opentelemetry==1.8.1 + # via -r requirements.in +azure-monitor-opentelemetry-exporter==1.0.0b44 + # via azure-monitor-opentelemetry +azure-search-documents==11.7.0b2 + # via -r requirements.in +azure-storage-blob==12.22.0 + # via + # -r requirements.in + # azure-storage-file-datalake +azure-storage-file-datalake==12.16.0 + # via -r requirements.in +beautifulsoup4==4.12.3 + # via -r requirements.in +blinker==1.9.0 + # via + # flask + # quart +certifi==2024.7.4 + # via + # httpcore + # httpx + # msrest + # requests +cffi==1.17.0 + # via cryptography +charset-normalizer==3.3.2 + # via requests +click==8.3.0 + # via + # flask + # prompty + # quart + # uvicorn +cryptography==44.0.1 + # via + # -r requirements.in + # azure-identity + # azure-storage-blob + # msal + # pyjwt +distro==1.9.0 + # via openai +exceptiongroup==1.3.0 + # via + # anyio + # hypercorn + # taskgroup +fixedint==0.1.6 + # via azure-monitor-opentelemetry-exporter +flask==3.1.2 + # via quart +frozenlist==1.4.1 + # via + # aiohttp + # aiosignal +h11==0.16.0 + # via + # httpcore + # hypercorn + # uvicorn + # wsproto +h2==4.3.0 + # via + # httpx + # hypercorn +hpack==4.1.0 + # via h2 +httpcore==1.0.9 + # via httpx +httpx==0.28.1 + # via + # microsoft-kiota-http + # msgraph-core + # openai +hypercorn==0.17.3 + # via quart +hyperframe==6.1.0 + # via h2 +idna==3.10 + # via + # anyio + # httpx + # requests + # yarl +importlib-metadata==8.0.0 + # via opentelemetry-api +isodate==0.6.1 + # via + # azure-ai-documentintelligence + # azure-search-documents + # azure-storage-blob + # azure-storage-file-datalake + # msrest +itsdangerous==2.2.0 + # via + # flask + # quart +jinja2==3.1.6 + # via + # flask + # prompty + # quart +jiter==0.11.0 + # via openai +markdown-it-py==3.0.0 + # via rich +markupsafe==3.0.3 + # via + # flask + # jinja2 + # quart + # werkzeug +mdurl==0.1.2 + # via markdown-it-py +microsoft-kiota-abstractions==1.9.3 + # via + # microsoft-kiota-authentication-azure + # microsoft-kiota-http + # microsoft-kiota-serialization-form + # microsoft-kiota-serialization-json + # microsoft-kiota-serialization-multipart + # microsoft-kiota-serialization-text + # msgraph-core +microsoft-kiota-authentication-azure==1.9.3 + # via msgraph-core +microsoft-kiota-http==1.9.3 + # via msgraph-core +microsoft-kiota-serialization-form==1.9.3 + # via msgraph-sdk +microsoft-kiota-serialization-json==1.9.3 + # via msgraph-sdk +microsoft-kiota-serialization-multipart==1.9.3 + # via msgraph-sdk +microsoft-kiota-serialization-text==1.9.3 + # via msgraph-sdk +msal==1.33.0 + # via + # -r requirements.in + # azure-identity + # msal-extensions +msal-extensions==1.3.1 + # via azure-identity +msgraph-core==1.3.3 + # via msgraph-sdk +msgraph-sdk==1.45.0 + # via -r requirements.in +msrest==0.7.1 + # via azure-monitor-opentelemetry-exporter +multidict==6.7.0 + # via + # aiohttp + # yarl +oauthlib==3.3.1 + # via requests-oauthlib +openai==2.6.1 + # via -r requirements.in +opentelemetry-api==1.38.0 + # via + # azure-core-tracing-opentelemetry + # azure-monitor-opentelemetry-exporter + # microsoft-kiota-abstractions + # microsoft-kiota-authentication-azure + # microsoft-kiota-http + # opentelemetry-instrumentation + # opentelemetry-instrumentation-aiohttp-client + # opentelemetry-instrumentation-asgi + # opentelemetry-instrumentation-dbapi + # opentelemetry-instrumentation-django + # opentelemetry-instrumentation-fastapi + # opentelemetry-instrumentation-flask + # opentelemetry-instrumentation-httpx + # opentelemetry-instrumentation-openai + # opentelemetry-instrumentation-psycopg2 + # opentelemetry-instrumentation-requests + # opentelemetry-instrumentation-urllib + # opentelemetry-instrumentation-urllib3 + # opentelemetry-instrumentation-wsgi + # opentelemetry-sdk + # opentelemetry-semantic-conventions +opentelemetry-instrumentation==0.59b0 + # via + # opentelemetry-instrumentation-aiohttp-client + # opentelemetry-instrumentation-asgi + # opentelemetry-instrumentation-dbapi + # opentelemetry-instrumentation-django + # opentelemetry-instrumentation-fastapi + # opentelemetry-instrumentation-flask + # opentelemetry-instrumentation-httpx + # opentelemetry-instrumentation-openai + # opentelemetry-instrumentation-psycopg2 + # opentelemetry-instrumentation-requests + # opentelemetry-instrumentation-urllib + # opentelemetry-instrumentation-urllib3 + # opentelemetry-instrumentation-wsgi +opentelemetry-instrumentation-aiohttp-client==0.59b0 + # via -r requirements.in +opentelemetry-instrumentation-asgi==0.59b0 + # via + # -r requirements.in + # opentelemetry-instrumentation-fastapi +opentelemetry-instrumentation-dbapi==0.59b0 + # via opentelemetry-instrumentation-psycopg2 +opentelemetry-instrumentation-django==0.59b0 + # via azure-monitor-opentelemetry +opentelemetry-instrumentation-fastapi==0.59b0 + # via azure-monitor-opentelemetry +opentelemetry-instrumentation-flask==0.59b0 + # via azure-monitor-opentelemetry +opentelemetry-instrumentation-httpx==0.59b0 + # via -r requirements.in +opentelemetry-instrumentation-openai==0.47.5 + # via -r requirements.in +opentelemetry-instrumentation-psycopg2==0.59b0 + # via azure-monitor-opentelemetry +opentelemetry-instrumentation-requests==0.59b0 + # via azure-monitor-opentelemetry +opentelemetry-instrumentation-urllib==0.59b0 + # via azure-monitor-opentelemetry +opentelemetry-instrumentation-urllib3==0.59b0 + # via azure-monitor-opentelemetry +opentelemetry-instrumentation-wsgi==0.59b0 + # via + # opentelemetry-instrumentation-django + # opentelemetry-instrumentation-flask +opentelemetry-resource-detector-azure==0.1.5 + # via azure-monitor-opentelemetry +opentelemetry-sdk==1.38.0 + # via + # azure-monitor-opentelemetry + # azure-monitor-opentelemetry-exporter + # microsoft-kiota-abstractions + # microsoft-kiota-authentication-azure + # microsoft-kiota-http + # opentelemetry-resource-detector-azure +opentelemetry-semantic-conventions==0.59b0 + # via + # opentelemetry-instrumentation + # opentelemetry-instrumentation-aiohttp-client + # opentelemetry-instrumentation-asgi + # opentelemetry-instrumentation-dbapi + # opentelemetry-instrumentation-django + # opentelemetry-instrumentation-fastapi + # opentelemetry-instrumentation-flask + # opentelemetry-instrumentation-httpx + # opentelemetry-instrumentation-openai + # opentelemetry-instrumentation-requests + # opentelemetry-instrumentation-urllib + # opentelemetry-instrumentation-urllib3 + # opentelemetry-instrumentation-wsgi + # opentelemetry-sdk +opentelemetry-semantic-conventions-ai==0.4.13 + # via opentelemetry-instrumentation-openai +opentelemetry-util-http==0.59b0 + # via + # opentelemetry-instrumentation-aiohttp-client + # opentelemetry-instrumentation-asgi + # opentelemetry-instrumentation-django + # opentelemetry-instrumentation-fastapi + # opentelemetry-instrumentation-flask + # opentelemetry-instrumentation-httpx + # opentelemetry-instrumentation-requests + # opentelemetry-instrumentation-urllib + # opentelemetry-instrumentation-urllib3 + # opentelemetry-instrumentation-wsgi +packaging==24.1 + # via + # opentelemetry-instrumentation + # opentelemetry-instrumentation-flask +pillow==12.0.0 + # via -r requirements.in +priority==2.0.0 + # via hypercorn +prompty==0.1.50 + # via -r requirements.in +propcache==0.2.0 + # via + # aiohttp + # yarl +psutil==7.1.2 + # via azure-monitor-opentelemetry-exporter +pycparser==2.22 + # via cffi +pydantic==2.12.3 + # via openai +pydantic-core==2.41.4 + # via pydantic +pygments==2.19.2 + # via rich +pyjwt==2.10.1 + # via + # -r requirements.in + # msal +pymupdf==1.26.0 + # via -r requirements.in +pypdf==6.1.3 + # via -r requirements.in +python-dotenv==1.1.1 + # via + # -r requirements.in + # prompty +pyyaml==6.0.2 + # via prompty +quart==0.20.0 + # via + # -r requirements.in + # quart-cors +quart-cors==0.7.0 + # via -r requirements.in +regex==2025.7.34 + # via tiktoken +requests==2.32.4 + # via + # azure-core + # msal + # msrest + # requests-oauthlib + # tiktoken +requests-oauthlib==2.0.0 + # via msrest +rich==14.1.0 + # via -r requirements.in +six==1.16.0 + # via + # azure-core + # isodate +sniffio==1.3.1 + # via + # anyio + # openai +soupsieve==2.7 + # via beautifulsoup4 +std-uritemplate==2.0.5 + # via microsoft-kiota-abstractions +taskgroup==0.2.2 + # via hypercorn +tenacity==9.1.2 + # via -r requirements.in +tiktoken==0.12.0 + # via -r requirements.in +tomli==2.2.1 + # via hypercorn +tqdm==4.66.5 + # via openai +types-beautifulsoup4==4.12.0.20240511 + # via -r requirements.in +types-html5lib==1.1.11.20241018 + # via types-beautifulsoup4 +types-pillow==10.2.0.20240822 + # via -r requirements.in +typing-extensions==4.15.0 + # via + # -r requirements.in + # aiosignal + # anyio + # asgiref + # azure-ai-documentintelligence + # azure-core + # azure-cosmos + # azure-identity + # azure-search-documents + # azure-storage-blob + # azure-storage-file-datalake + # exceptiongroup + # hypercorn + # multidict + # openai + # opentelemetry-api + # opentelemetry-sdk + # opentelemetry-semantic-conventions + # pydantic + # pydantic-core + # pypdf + # taskgroup + # typing-inspection + # uvicorn +typing-inspection==0.4.2 + # via pydantic +urllib3==2.5.0 + # via requests +uvicorn==0.30.6 + # via -r requirements.in +werkzeug==3.1.3 + # via + # azure-functions + # flask + # quart +wrapt==1.16.0 + # via + # opentelemetry-instrumentation + # opentelemetry-instrumentation-aiohttp-client + # opentelemetry-instrumentation-dbapi + # opentelemetry-instrumentation-httpx + # opentelemetry-instrumentation-urllib3 +wsproto==1.2.0 + # via hypercorn +yarl==1.17.2 + # via aiohttp +zipp==3.21.0 + # via importlib-metadata \ No newline at end of file diff --git a/app/backend/services/load_azd_env.py b/app/backend/services/load_azd_env.py new file mode 100644 index 00000000..2f2db6aa --- /dev/null +++ b/app/backend/services/load_azd_env.py @@ -0,0 +1,29 @@ +import json +import logging +import os +import subprocess + +from dotenv import load_dotenv + +logger = logging.getLogger("scripts") + + +def load_azd_env(): + """Get path to current azd env file and load file using python-dotenv""" + result = subprocess.run("azd env list -o json", shell=True, capture_output=True, text=True) + if result.returncode != 0: + raise Exception("Error loading azd env") + env_json = json.loads(result.stdout) + env_file_path = None + for entry in env_json: + if entry["IsDefault"]: + env_file_path = entry["DotEnvPath"] + if not env_file_path: + raise Exception("No default azd env file found") + loading_mode = os.getenv("LOADING_MODE_FOR_AZD_ENV_VARS") or "override" + if loading_mode == "no-override": + logger.info("Loading azd env from %s, but not overriding existing environment variables", env_file_path) + load_dotenv(env_file_path, override=False) + else: + logger.info("Loading azd env from %s, which may override existing environment variables", env_file_path) + load_dotenv(env_file_path, override=True) diff --git a/app/backend/services/prepdocs.py b/app/backend/services/prepdocs.py new file mode 100644 index 00000000..8e36ca43 --- /dev/null +++ b/app/backend/services/prepdocs.py @@ -0,0 +1,378 @@ +import argparse +import asyncio +import logging +import os +from typing import Optional + +import aiohttp +from azure.core.credentials_async import AsyncTokenCredential +from azure.identity.aio import AzureDeveloperCliCredential +from openai import AsyncOpenAI +from rich.logging import RichHandler + +from services.load_azd_env import load_azd_env +from prepdocslib.filestrategy import FileStrategy +from prepdocslib.integratedvectorizerstrategy import ( + IntegratedVectorizerStrategy, +) +from prepdocslib.listfilestrategy import ( + ADLSGen2ListFileStrategy, + ListFileStrategy, + LocalListFileStrategy, +) +from prepdocslib.servicesetup import ( + OpenAIHost, + build_file_processors, + clean_key_if_exists, + setup_blob_manager, + setup_embeddings_service, + setup_figure_processor, + setup_image_embeddings_service, + setup_openai_client, + setup_search_info, +) +from prepdocslib.strategy import DocumentAction, Strategy + +logger = logging.getLogger("scripts") + + +async def check_search_service_connectivity(search_service: str) -> bool: + """Check if the search service is accessible by hitting the /ping endpoint.""" + ping_url = f"https://{search_service}.search.windows.net/ping" + + try: + async with aiohttp.ClientSession() as session: + async with session.get(ping_url, timeout=aiohttp.ClientTimeout(total=10)) as response: + return response.status == 200 + except Exception as e: + logger.debug(f"Search service ping failed: {e}") + return False + + +def setup_list_file_strategy( + azure_credential: AsyncTokenCredential, + local_files: Optional[str], + datalake_storage_account: Optional[str], + datalake_filesystem: Optional[str], + datalake_path: Optional[str], + datalake_key: Optional[str], + enable_global_documents: bool = False, +): + list_file_strategy: ListFileStrategy + if datalake_storage_account: + if datalake_filesystem is None or datalake_path is None: + raise ValueError("DataLake file system and path are required when using Azure Data Lake Gen2") + adls_gen2_creds: AsyncTokenCredential | str = azure_credential if datalake_key is None else datalake_key + logger.info("Using Data Lake Gen2 Storage Account: %s", datalake_storage_account) + list_file_strategy = ADLSGen2ListFileStrategy( + data_lake_storage_account=datalake_storage_account, + data_lake_filesystem=datalake_filesystem, + data_lake_path=datalake_path, + credential=adls_gen2_creds, + enable_global_documents=enable_global_documents, + ) + elif local_files: + logger.info("Using local files: %s", local_files) + list_file_strategy = LocalListFileStrategy( + path_pattern=local_files, enable_global_documents=enable_global_documents + ) + else: + raise ValueError("Either local_files or datalake_storage_account must be provided.") + return list_file_strategy + + +def setup_file_processors( + azure_credential: AsyncTokenCredential, + document_intelligence_service: Optional[str], + document_intelligence_key: Optional[str] = None, + local_pdf_parser: bool = False, + local_html_parser: bool = False, + use_content_understanding: bool = False, + use_multimodal: bool = False, + openai_client: Optional[AsyncOpenAI] = None, + openai_model: Optional[str] = None, + openai_deployment: Optional[str] = None, + content_understanding_endpoint: Optional[str] = None, +): + """Setup file processors and figure processor for document ingestion. + + Uses build_file_processors from servicesetup to ensure consistent parser/splitter + selection logic with the Azure Functions cloud ingestion pipeline. + """ + file_processors = build_file_processors( + azure_credential=azure_credential, + document_intelligence_service=document_intelligence_service, + document_intelligence_key=document_intelligence_key, + use_local_pdf_parser=local_pdf_parser, + use_local_html_parser=local_html_parser, + process_figures=use_multimodal, + ) + + figure_processor = setup_figure_processor( + credential=azure_credential, + use_multimodal=use_multimodal, + use_content_understanding=use_content_understanding, + content_understanding_endpoint=content_understanding_endpoint, + openai_client=openai_client, + openai_model=openai_model, + openai_deployment=openai_deployment, + ) + + return file_processors, figure_processor + + +async def main(strategy: Strategy, setup_index: bool = True): + if setup_index: + await strategy.setup() + + await strategy.run() + + +if __name__ == "__main__": # pragma: no cover + parser = argparse.ArgumentParser( + description="Prepare documents by extracting content from PDFs, splitting content into sections, uploading to blob storage, and indexing in a search index." + ) + parser.add_argument("files", nargs="?", help="Files to be processed") + + parser.add_argument( + "--category", help="Value for the category field in the search index for all sections indexed in this run" + ) + parser.add_argument( + "--skipblobs", action="store_true", help="Skip uploading individual pages to Azure Blob Storage" + ) + parser.add_argument( + "--disablebatchvectors", action="store_true", help="Don't compute embeddings in batch for the sections" + ) + parser.add_argument( + "--remove", + action="store_true", + help="Remove references to this document from blob storage and the search index", + ) + parser.add_argument( + "--removeall", + action="store_true", + help="Remove all blobs from blob storage and documents from the search index", + ) + + # Optional key specification: + parser.add_argument( + "--searchkey", + required=False, + help="Optional. Use this Azure AI Search account key instead of the current user identity to login (use az login to set current user for Azure)", + ) + parser.add_argument( + "--storagekey", + required=False, + help="Optional. Use this Azure Blob Storage account key instead of the current user identity to login (use az login to set current user for Azure)", + ) + parser.add_argument( + "--datalakekey", required=False, help="Optional. Use this key when authenticating to Azure Data Lake Gen2" + ) + parser.add_argument( + "--documentintelligencekey", + required=False, + help="Optional. Use this Azure Document Intelligence account key instead of the current user identity to login (use az login to set current user for Azure)", + ) + + parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output") + args = parser.parse_args() + + if args.verbose: + logging.basicConfig(format="%(message)s", datefmt="[%X]", handlers=[RichHandler(rich_tracebacks=True)]) + # We only set the level to INFO for our logger, + # to avoid seeing the noisy INFO level logs from the Azure SDKs + logger.setLevel(logging.DEBUG) + + load_azd_env() + + if os.getenv("USE_CLOUD_INGESTION", "").lower() == "true": + logger.warning( + "Cloud ingestion is enabled. Please use setup_cloud_ingestion.py instead of prepdocs.py. Exiting." + ) + exit(0) + + if ( + os.getenv("AZURE_PUBLIC_NETWORK_ACCESS") == "Disabled" + and os.getenv("AZURE_USE_VPN_GATEWAY", "").lower() != "true" + ): + logger.error("AZURE_PUBLIC_NETWORK_ACCESS is set to Disabled. Exiting.") + exit(0) + + use_int_vectorization = os.getenv("USE_FEATURE_INT_VECTORIZATION", "").lower() == "true" + use_multimodal = os.getenv("USE_MULTIMODAL", "").lower() == "true" + use_acls = os.getenv("AZURE_USE_AUTHENTICATION", "").lower() == "true" + enforce_access_control = os.getenv("AZURE_ENFORCE_ACCESS_CONTROL", "").lower() == "true" + enable_global_documents = os.getenv("AZURE_ENABLE_GLOBAL_DOCUMENT_ACCESS", "").lower() == "true" + dont_use_vectors = os.getenv("USE_VECTORS", "").lower() == "false" + use_agentic_knowledgebase = os.getenv("USE_AGENTIC_KNOWLEDGEBASE", "").lower() == "true" + use_content_understanding = os.getenv("USE_MEDIA_DESCRIBER_AZURE_CU", "").lower() == "true" + use_web_source = os.getenv("USE_WEB_SOURCE", "").lower() == "true" + use_sharepoint_source = os.getenv("USE_SHAREPOINT_SOURCE", "").lower() == "true" + + # Use the current user identity to connect to Azure services. See infra/main.bicep for role assignments. + if tenant_id := os.getenv("AZURE_TENANT_ID"): + logger.info("Connecting to Azure services using the azd credential for tenant %s", tenant_id) + azd_credential = AzureDeveloperCliCredential(tenant_id=tenant_id, process_timeout=60) + else: + logger.info("Connecting to Azure services using the azd credential for home tenant") + azd_credential = AzureDeveloperCliCredential(process_timeout=60) + + if args.removeall: + document_action = DocumentAction.RemoveAll + elif args.remove: + document_action = DocumentAction.Remove + else: + document_action = DocumentAction.Add + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + OPENAI_HOST = OpenAIHost(os.environ["OPENAI_HOST"]) + # Check for incompatibility + # if openai host is not azure + if use_agentic_knowledgebase and OPENAI_HOST not in [OpenAIHost.AZURE, OpenAIHost.AZURE_CUSTOM]: + raise Exception("Agentic retrieval requires an Azure OpenAI chat completion service") + + search_info = setup_search_info( + search_service=os.environ["AZURE_SEARCH_SERVICE"], + index_name=os.environ["AZURE_SEARCH_INDEX"], + use_agentic_knowledgebase=use_agentic_knowledgebase, + knowledgebase_name=os.getenv("AZURE_SEARCH_KNOWLEDGEBASE_NAME"), + azure_openai_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"], + azure_openai_knowledgebase_deployment=os.getenv("AZURE_OPENAI_KNOWLEDGEBASE_DEPLOYMENT"), + azure_openai_knowledgebase_model=os.getenv("AZURE_OPENAI_KNOWLEDGEBASE_MODEL"), + azure_credential=azd_credential, + search_key=clean_key_if_exists(args.searchkey), + azure_vision_endpoint=os.getenv("AZURE_VISION_ENDPOINT"), + ) + + # Check search service connectivity + search_service = os.environ["AZURE_SEARCH_SERVICE"] + is_connected = loop.run_until_complete(check_search_service_connectivity(search_service)) + + if not is_connected: + if os.getenv("AZURE_USE_PRIVATE_ENDPOINT"): + logger.error( + "Unable to connect to Azure AI Search service, which indicates either a network issue or a misconfiguration. You have AZURE_USE_PRIVATE_ENDPOINT enabled. Perhaps you're not yet connected to the VPN? Download the VPN configuration from the Azure portal here: %s", + os.getenv("AZURE_VPN_CONFIG_DOWNLOAD_LINK"), + ) + else: + logger.error( + "Unable to connect to Azure AI Search service, which indicates either a network issue or a misconfiguration." + ) + exit(1) + + blob_manager = setup_blob_manager( + azure_credential=azd_credential, + storage_account=os.environ["AZURE_STORAGE_ACCOUNT"], + storage_container=os.environ["AZURE_STORAGE_CONTAINER"], + storage_resource_group=os.environ["AZURE_STORAGE_RESOURCE_GROUP"], + subscription_id=os.environ["AZURE_SUBSCRIPTION_ID"], + storage_key=clean_key_if_exists(args.storagekey), + image_storage_container=os.environ.get("AZURE_IMAGESTORAGE_CONTAINER"), # Pass the image container + ) + list_file_strategy = setup_list_file_strategy( + azure_credential=azd_credential, + local_files=args.files, + datalake_storage_account=os.getenv("AZURE_ADLS_GEN2_STORAGE_ACCOUNT"), + datalake_filesystem=os.getenv("AZURE_ADLS_GEN2_FILESYSTEM"), + datalake_path=os.getenv("AZURE_ADLS_GEN2_FILESYSTEM_PATH"), + datalake_key=clean_key_if_exists(args.datalakekey), + enable_global_documents=enable_global_documents, + ) + + emb_model_dimensions = 1536 + if os.getenv("AZURE_OPENAI_EMB_DIMENSIONS"): + emb_model_dimensions = int(os.environ["AZURE_OPENAI_EMB_DIMENSIONS"]) + + openai_client, azure_openai_endpoint = setup_openai_client( + openai_host=OPENAI_HOST, + azure_credential=azd_credential, + azure_openai_service=os.getenv("AZURE_OPENAI_SERVICE"), + azure_openai_custom_url=os.getenv("AZURE_OPENAI_CUSTOM_URL"), + azure_openai_api_key=os.getenv("AZURE_OPENAI_API_KEY_OVERRIDE"), + openai_api_key=clean_key_if_exists(os.getenv("OPENAI_API_KEY")), + openai_organization=os.getenv("OPENAI_ORGANIZATION"), + ) + openai_embeddings_service = None + if not dont_use_vectors: + openai_embeddings_service = setup_embeddings_service( + OPENAI_HOST, + openai_client, + emb_model_name=os.environ["AZURE_OPENAI_EMB_MODEL_NAME"], + emb_model_dimensions=emb_model_dimensions, + azure_openai_deployment=os.getenv("AZURE_OPENAI_EMB_DEPLOYMENT"), + azure_openai_endpoint=azure_openai_endpoint, + disable_batch=args.disablebatchvectors, + ) + + ingestion_strategy: Strategy + if use_int_vectorization: + + if not openai_embeddings_service or OPENAI_HOST not in [OpenAIHost.AZURE, OpenAIHost.AZURE_CUSTOM]: + raise Exception("Integrated vectorization strategy requires an Azure OpenAI embeddings service") + + ingestion_strategy = IntegratedVectorizerStrategy( + search_info=search_info, + list_file_strategy=list_file_strategy, + blob_manager=blob_manager, + document_action=document_action, + embeddings=openai_embeddings_service, + search_field_name_embedding=os.environ["AZURE_SEARCH_FIELD_NAME_EMBEDDING"], + subscription_id=os.environ["AZURE_SUBSCRIPTION_ID"], + search_analyzer_name=os.getenv("AZURE_SEARCH_ANALYZER_NAME"), + use_acls=use_acls, + category=args.category, + enforce_access_control=enforce_access_control, + ) + else: + file_processors, figure_processor = setup_file_processors( + azure_credential=azd_credential, + document_intelligence_service=os.getenv("AZURE_DOCUMENTINTELLIGENCE_SERVICE"), + document_intelligence_key=clean_key_if_exists(args.documentintelligencekey), + local_pdf_parser=os.getenv("USE_LOCAL_PDF_PARSER") == "true", + local_html_parser=os.getenv("USE_LOCAL_HTML_PARSER") == "true", + use_content_understanding=use_content_understanding, + use_multimodal=use_multimodal, + content_understanding_endpoint=os.getenv("AZURE_CONTENTUNDERSTANDING_ENDPOINT"), + openai_client=openai_client, + openai_model=os.getenv("AZURE_OPENAI_CHATGPT_MODEL"), + openai_deployment=os.getenv("AZURE_OPENAI_CHATGPT_DEPLOYMENT") if OPENAI_HOST == OpenAIHost.AZURE else None, + ) + + image_embeddings_service = setup_image_embeddings_service( + azure_credential=azd_credential, + vision_endpoint=os.getenv("AZURE_VISION_ENDPOINT"), + use_multimodal=use_multimodal, + ) + + ingestion_strategy = FileStrategy( + search_info=search_info, + list_file_strategy=list_file_strategy, + blob_manager=blob_manager, + file_processors=file_processors, + document_action=document_action, + embeddings=openai_embeddings_service, + image_embeddings=image_embeddings_service, + search_analyzer_name=os.getenv("AZURE_SEARCH_ANALYZER_NAME"), + # Default to the previous field names for backward compatibility + search_field_name_embedding=os.getenv("AZURE_SEARCH_FIELD_NAME_EMBEDDING", "embedding"), + use_acls=use_acls, + category=args.category, + figure_processor=figure_processor, + enforce_access_control=enforce_access_control, + use_web_source=use_web_source, + use_sharepoint_source=use_sharepoint_source, + ) + + try: + loop.run_until_complete(main(ingestion_strategy, setup_index=not args.remove and not args.removeall)) + finally: + # Gracefully close any async clients/credentials to avoid noisy destructor warnings + try: + loop.run_until_complete(blob_manager.close_clients()) + loop.run_until_complete(openai_client.close()) + loop.run_until_complete(azd_credential.close()) + except Exception as e: + logger.debug(f"Failed to close async clients cleanly: {e}") + loop.close() diff --git a/app/backend/services/setup_cloud_ingestion.py b/app/backend/services/setup_cloud_ingestion.py new file mode 100644 index 00000000..9ac4ea82 --- /dev/null +++ b/app/backend/services/setup_cloud_ingestion.py @@ -0,0 +1,177 @@ +"""Script to setup cloud ingestion for Azure AI Search.""" + +import asyncio +import logging +import os + +from azure.core.credentials_async import AsyncTokenCredential +from azure.identity.aio import AzureDeveloperCliCredential +from openai import AsyncOpenAI +from rich.logging import RichHandler + +from services.load_azd_env import load_azd_env +from prepdocslib.blobmanager import BlobManager +from prepdocslib.cloudingestionstrategy import CloudIngestionStrategy +from prepdocslib.listfilestrategy import LocalListFileStrategy +from prepdocslib.servicesetup import ( + OpenAIHost, + clean_key_if_exists, + setup_blob_manager, + setup_embeddings_service, + setup_openai_client, + setup_search_info, +) +from prepdocslib.strategy import DocumentAction + +logger = logging.getLogger("scripts") + + +async def setup_cloud_ingestion_strategy( + azure_credential: AsyncTokenCredential, + document_action: DocumentAction = DocumentAction.Add, +) -> tuple[CloudIngestionStrategy, AsyncOpenAI, AsyncTokenCredential, BlobManager]: + """Setup the cloud ingestion strategy with all required services.""" + + # Get environment variables + search_service = os.environ["AZURE_SEARCH_SERVICE"] + index_name = os.environ["AZURE_SEARCH_INDEX"] + search_user_assigned_identity_resource_id = os.environ["AZURE_SEARCH_USER_ASSIGNED_IDENTITY_RESOURCE_ID"] + storage_account = os.environ["AZURE_STORAGE_ACCOUNT"] + storage_container = os.environ["AZURE_STORAGE_CONTAINER"] + storage_resource_group = os.environ["AZURE_STORAGE_RESOURCE_GROUP"] + subscription_id = os.environ["AZURE_SUBSCRIPTION_ID"] + image_storage_container = os.environ.get("AZURE_IMAGESTORAGE_CONTAINER") + search_embedding_field = os.environ["AZURE_SEARCH_FIELD_NAME_EMBEDDING"] + + # Cloud ingestion specific endpoints + document_extractor_uri = os.environ["DOCUMENT_EXTRACTOR_SKILL_ENDPOINT"] + document_extractor_resource_id = os.environ["DOCUMENT_EXTRACTOR_SKILL_AUTH_RESOURCE_ID"] + figure_processor_uri = os.environ["FIGURE_PROCESSOR_SKILL_ENDPOINT"] + figure_processor_resource_id = os.environ["FIGURE_PROCESSOR_SKILL_AUTH_RESOURCE_ID"] + text_processor_uri = os.environ["TEXT_PROCESSOR_SKILL_ENDPOINT"] + text_processor_resource_id = os.environ["TEXT_PROCESSOR_SKILL_AUTH_RESOURCE_ID"] + + # Feature flags + use_multimodal = os.getenv("USE_MULTIMODAL", "").lower() == "true" + use_acls = os.getenv("AZURE_USE_AUTHENTICATION", "").lower() == "true" + enforce_access_control = os.getenv("AZURE_ENFORCE_ACCESS_CONTROL", "").lower() == "true" + use_web_source = os.getenv("USE_WEB_SOURCE", "").lower() == "true" + + # Setup search info + search_info = setup_search_info( + search_service=search_service, + index_name=index_name, + azure_credential=azure_credential, + azure_vision_endpoint=os.getenv("AZURE_VISION_ENDPOINT"), + ) + + # Setup blob manager + blob_manager = setup_blob_manager( + azure_credential=azure_credential, + storage_account=storage_account, + storage_container=storage_container, + storage_resource_group=storage_resource_group, + subscription_id=subscription_id, + storage_key=None, + image_storage_container=image_storage_container, + ) + + # Setup OpenAI embeddings + OPENAI_HOST = OpenAIHost(os.environ["OPENAI_HOST"]) + openai_client, azure_openai_endpoint = setup_openai_client( + openai_host=OPENAI_HOST, + azure_credential=azure_credential, + azure_openai_service=os.getenv("AZURE_OPENAI_SERVICE"), + azure_openai_custom_url=os.getenv("AZURE_OPENAI_CUSTOM_URL"), + azure_openai_api_key=os.getenv("AZURE_OPENAI_API_KEY_OVERRIDE"), + openai_api_key=clean_key_if_exists(os.getenv("OPENAI_API_KEY")), + openai_organization=os.getenv("OPENAI_ORGANIZATION"), + ) + + emb_model_dimensions = 1536 + if os.getenv("AZURE_OPENAI_EMB_DIMENSIONS"): + emb_model_dimensions = int(os.environ["AZURE_OPENAI_EMB_DIMENSIONS"]) + + openai_embeddings_service = setup_embeddings_service( + OPENAI_HOST, + openai_client, + emb_model_name=os.environ["AZURE_OPENAI_EMB_MODEL_NAME"], + emb_model_dimensions=emb_model_dimensions, + azure_openai_deployment=os.getenv("AZURE_OPENAI_EMB_DEPLOYMENT"), + azure_openai_endpoint=azure_openai_endpoint, + disable_batch=False, + ) + + # Create a list file strategy for uploading files from the data folder + list_file_strategy = LocalListFileStrategy(path_pattern="data/*", enable_global_documents=False) + + # Create the cloud ingestion strategy + ingestion_strategy = CloudIngestionStrategy( + list_file_strategy=list_file_strategy, + blob_manager=blob_manager, + search_info=search_info, + embeddings=openai_embeddings_service, + search_field_name_embedding=search_embedding_field, + document_extractor_uri=document_extractor_uri, + document_extractor_auth_resource_id=document_extractor_resource_id, + figure_processor_uri=figure_processor_uri, + figure_processor_auth_resource_id=figure_processor_resource_id, + text_processor_uri=text_processor_uri, + text_processor_auth_resource_id=text_processor_resource_id, + subscription_id=subscription_id, + document_action=document_action, + search_analyzer_name=os.getenv("AZURE_SEARCH_ANALYZER_NAME"), + use_acls=use_acls, + use_multimodal=use_multimodal, + enforce_access_control=enforce_access_control, + use_web_source=use_web_source, + search_user_assigned_identity_resource_id=search_user_assigned_identity_resource_id, + ) + + return ingestion_strategy, openai_client, azure_credential, blob_manager + + +async def main(): + """Main function to setup cloud ingestion.""" + load_azd_env() + + # Check if cloud ingestion is enabled + use_cloud_ingestion = os.getenv("USE_CLOUD_INGESTION", "").lower() == "true" + if not use_cloud_ingestion: + logger.info("Cloud ingestion is not enabled. Skipping setup.") + return + + # Setup logging + logging.basicConfig(format="%(message)s", datefmt="[%X]", handlers=[RichHandler(rich_tracebacks=True)]) + logger.setLevel(logging.INFO) + + logger.info("Setting up cloud ingestion...") + + # Use the current user identity to connect to Azure services + if tenant_id := os.getenv("AZURE_TENANT_ID"): + logger.info("Connecting to Azure services using the azd credential for tenant %s", tenant_id) + azd_credential = AzureDeveloperCliCredential(tenant_id=tenant_id, process_timeout=60) + else: + logger.info("Connecting to Azure services using the azd credential for home tenant") + azd_credential = AzureDeveloperCliCredential(process_timeout=60) + + try: + ingestion_strategy, openai_client, credential, blob_manager = await setup_cloud_ingestion_strategy( + azure_credential=azd_credential, + document_action=DocumentAction.Add, + ) + + # Setup the indexer, skillset, and data source + logger.info("Setting up indexer, skillset, and data source...") + await ingestion_strategy.setup() + logger.info("Triggering initial indexing run...") + await ingestion_strategy.run() + + finally: + await blob_manager.close_clients() + await openai_client.close() + await azd_credential.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/app/frontend/.editorconfig b/app/frontend/.editorconfig new file mode 100644 index 00000000..181aeebb --- /dev/null +++ b/app/frontend/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +indent_size = 2 +end_of_line = lf +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/app/frontend/.gitignore b/app/frontend/.gitignore new file mode 100644 index 00000000..5f0eeb6d --- /dev/null +++ b/app/frontend/.gitignore @@ -0,0 +1,37 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# dependencies +node_modules +.pnp +.pnp.js + +# testing +coverage + +# production +.next +.swc +_static +out +dist +build + +# environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# misc +.DS_Store +.vercel +.netlify +.vscode +tsconfig.tsbuildinfo +.ncurc.js diff --git a/app/frontend/.prettierignore b/app/frontend/.prettierignore new file mode 100644 index 00000000..34d65be4 --- /dev/null +++ b/app/frontend/.prettierignore @@ -0,0 +1,11 @@ +# Build directories +build/* +dist/* +public/* +**/out/* +**/.next/* +**/node_modules/* + +yarn.lock +package-lock.json +jsconfig.json diff --git a/app/frontend/README.md b/app/frontend/README.md new file mode 100644 index 00000000..77b61374 --- /dev/null +++ b/app/frontend/README.md @@ -0,0 +1,54 @@ +## Prerequisites + +- Node.js 20.x (Recommended) + +## Installation + +**Using Yarn (Recommended)** + +```sh +yarn install +yarn dev +``` + +**Using Npm** + +```sh +npm i +npm run dev +``` + +## Build + +```sh +yarn build +# or +npm run build +``` + +## Mock server + +By default we provide demo data from : `https://api-dev-minimal-[version].vercel.app` + +To set up your local server: + +- **Guide:** [https://docs.minimals.cc/mock-server](https://docs.minimals.cc/mock-server). + +- **Resource:** [Download](https://www.dropbox.com/sh/6ojn099upi105tf/AACpmlqrNUacwbBfVdtt2t6va?dl=0). + +## Full version + +- Create React App ([migrate to CRA](https://docs.minimals.cc/migrate-to-cra/)). +- Next.js +- Vite.js + +## Starter version + +- To remove unnecessary components. This is a simplified version ([https://starter.minimals.cc/](https://starter.minimals.cc/)) +- Good to start a new project. You can copy components from the full version. +- Make sure to install the dependencies exactly as compared to the full version. + +--- + +**NOTE:** +_When copying folders remember to also copy hidden files like .env. This is important because .env files often contain environment variables that are crucial for the application to run correctly._ diff --git a/app/frontend/eslint.config.mjs b/app/frontend/eslint.config.mjs new file mode 100644 index 00000000..f088d814 --- /dev/null +++ b/app/frontend/eslint.config.mjs @@ -0,0 +1,194 @@ +import globals from 'globals'; +import eslintJs from '@eslint/js'; +import eslintTs from 'typescript-eslint'; +import reactPlugin from 'eslint-plugin-react'; +import importPlugin from 'eslint-plugin-import'; +import reactHooksPlugin from 'eslint-plugin-react-hooks'; +import perfectionistPlugin from 'eslint-plugin-perfectionist'; +import unusedImportsPlugin from 'eslint-plugin-unused-imports'; + +// ---------------------------------------------------------------------- + +/** + * @rules common + * from 'react', 'eslint-plugin-react-hooks'... + */ +const commonRules = () => ({ + ...reactHooksPlugin.configs.recommended.rules, + 'func-names': 1, + 'no-bitwise': 2, + 'no-unused-vars': 0, + 'object-shorthand': 1, + 'no-useless-rename': 1, + 'default-case-last': 2, + 'consistent-return': 2, + 'no-constant-condition': 1, + 'default-case': [2, { commentPattern: '^no default$' }], + 'lines-around-directive': [2, { before: 'always', after: 'always' }], + 'arrow-body-style': [2, 'as-needed', { requireReturnForObjectLiteral: false }], + // react + 'react/jsx-key': 0, + 'react/prop-types': 0, + 'react/display-name': 0, + 'react/no-children-prop': 0, + 'react/jsx-boolean-value': 2, + 'react/self-closing-comp': 2, + 'react/react-in-jsx-scope': 0, + 'react/jsx-no-useless-fragment': [1, { allowExpressions: true }], + 'react/jsx-curly-brace-presence': [2, { props: 'never', children: 'never' }], + // typescript + '@typescript-eslint/no-shadow': 2, + '@typescript-eslint/no-explicit-any': 0, + '@typescript-eslint/no-empty-object-type': 0, + '@typescript-eslint/consistent-type-imports': 1, + '@typescript-eslint/no-unused-vars': [1, { args: 'none' }], +}); + +/** + * @rules import + * from 'eslint-plugin-import'. + */ +const importRules = () => ({ + ...importPlugin.configs.recommended.rules, + 'import/named': 0, + 'import/export': 0, + 'import/default': 0, + 'import/namespace': 0, + 'import/no-named-as-default': 0, + 'import/newline-after-import': 2, + 'import/no-named-as-default-member': 0, + 'import/no-cycle': [ + 0, // disabled if slow + { maxDepth: '∞', ignoreExternal: false, allowUnsafeDynamicCyclicDependency: false }, + ], +}); + +/** + * @rules unused imports + * from 'eslint-plugin-unused-imports'. + */ +const unusedImportsRules = () => ({ + 'unused-imports/no-unused-imports': 1, + 'unused-imports/no-unused-vars': [ + 0, + { vars: 'all', varsIgnorePattern: '^_', args: 'after-used', argsIgnorePattern: '^_' }, + ], +}); + +/** + * @rules sort or imports/exports + * from 'eslint-plugin-perfectionist'. + */ +const sortImportsRules = () => { + const customGroups = { + mui: ['custom-mui'], + auth: ['custom-auth'], + hooks: ['custom-hooks'], + utils: ['custom-utils'], + types: ['custom-types'], + routes: ['custom-routes'], + sections: ['custom-sections'], + components: ['custom-components'], + }; + + return { + 'perfectionist/sort-named-imports': [1, { type: 'line-length', order: 'asc' }], + 'perfectionist/sort-named-exports': [1, { type: 'line-length', order: 'asc' }], + 'perfectionist/sort-exports': [ + 1, + { + order: 'asc', + type: 'line-length', + groupKind: 'values-first', + }, + ], + 'perfectionist/sort-imports': [ + 2, + { + order: 'asc', + ignoreCase: true, + type: 'line-length', + environment: 'node', + maxLineLength: undefined, + newlinesBetween: 'always', + internalPattern: ['^src/.+'], + groups: [ + 'style', + 'side-effect', + 'type', + ['builtin', 'external'], + customGroups.mui, + customGroups.routes, + customGroups.hooks, + customGroups.utils, + 'internal', + customGroups.components, + customGroups.sections, + customGroups.auth, + customGroups.types, + ['parent', 'sibling', 'index'], + ['parent-type', 'sibling-type', 'index-type'], + 'object', + 'unknown', + ], + customGroups: { + value: { + [customGroups.mui]: ['^@mui/.+'], + [customGroups.auth]: ['^src/auth/.+'], + [customGroups.hooks]: ['^src/hooks/.+'], + [customGroups.utils]: ['^src/utils/.+'], + [customGroups.types]: ['^src/types/.+'], + [customGroups.routes]: ['^src/routes/.+'], + [customGroups.sections]: ['^src/sections/.+'], + [customGroups.components]: ['^src/components/.+'], + }, + }, + }, + ], + }; +}; + +/** + * Custom ESLint configuration. + */ +export const customConfig = { + plugins: { + 'react-hooks': reactHooksPlugin, + 'unused-imports': unusedImportsPlugin, + perfectionist: perfectionistPlugin, + import: importPlugin, + }, + settings: { + // https://www.npmjs.com/package/eslint-import-resolver-typescript + ...importPlugin.configs.typescript.settings, + 'import/resolver': { + ...importPlugin.configs.typescript.settings['import/resolver'], + typescript: { + project: './tsconfig.json', + }, + }, + }, + rules: { + ...commonRules(), + ...importRules(), + ...unusedImportsRules(), + ...sortImportsRules(), + }, +}; + +// ---------------------------------------------------------------------- + +export default [ + { files: ['**/*.{js,mjs,cjs,ts,jsx,tsx}'] }, + { ignores: ['*', '!src/', 'eslint.config.*'] }, + { + languageOptions: { + globals: { ...globals.browser, ...globals.node }, + }, + settings: { react: { version: 'detect' } }, + }, + eslintJs.configs.recommended, + ...eslintTs.configs.recommended, + reactPlugin.configs.flat.recommended, + customConfig, +]; diff --git a/app/frontend/next-env.d.ts b/app/frontend/next-env.d.ts new file mode 100644 index 00000000..40c3d680 --- /dev/null +++ b/app/frontend/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/app/frontend/next.config.mjs b/app/frontend/next.config.mjs new file mode 100644 index 00000000..7818d391 --- /dev/null +++ b/app/frontend/next.config.mjs @@ -0,0 +1,36 @@ +/** + * @type {import('next').NextConfig} + */ + +const isStaticExport = 'false'; + +const nextConfig = { + trailingSlash: true, + env: { + BUILD_STATIC_EXPORT: isStaticExport, + }, + modularizeImports: { + '@mui/icons-material': { + transform: '@mui/icons-material/{{member}}', + }, + '@mui/material': { + transform: '@mui/material/{{member}}', + }, + '@mui/lab': { + transform: '@mui/lab/{{member}}', + }, + }, + webpack(config) { + config.module.rules.push({ + test: /\.svg$/, + use: ['@svgr/webpack'], + }); + + return config; + }, + ...(isStaticExport === 'true' && { + output: 'export', + }), +}; + +export default nextConfig; diff --git a/app/frontend/package-lock.json b/app/frontend/package-lock.json new file mode 100644 index 00000000..bed0679f --- /dev/null +++ b/app/frontend/package-lock.json @@ -0,0 +1,15954 @@ +{ + "name": "@minimal-kit/next-ts", + "version": "6.3.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@minimal-kit/next-ts", + "version": "6.3.0", + "dependencies": { + "@auth0/auth0-react": "^2.2.4", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@emotion/cache": "^11.14.0", + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@fontsource-variable/dm-sans": "^5.1.1", + "@fontsource-variable/inter": "^5.1.1", + "@fontsource-variable/nunito-sans": "^5.1.1", + "@fontsource-variable/public-sans": "^5.1.2", + "@fontsource/barlow": "^5.1.1", + "@fullcalendar/core": "^6.1.15", + "@fullcalendar/daygrid": "^6.1.15", + "@fullcalendar/interaction": "^6.1.15", + "@fullcalendar/list": "^6.1.15", + "@fullcalendar/react": "^6.1.15", + "@fullcalendar/timegrid": "^6.1.15", + "@fullcalendar/timeline": "^6.1.15", + "@hookform/resolvers": "^3.9.1", + "@iconify/react": "^5.1.0", + "@mui/lab": "^6.0.0-beta.21", + "@mui/material": "^6.3.0", + "@mui/material-nextjs": "^6.3.0", + "@mui/x-data-grid": "^7.23.5", + "@mui/x-date-pickers": "^7.23.3", + "@mui/x-tree-view": "^7.23.2", + "@react-pdf/renderer": "^4.1.6", + "@supabase/supabase-js": "^2.47.10", + "@tiptap/core": "^2.11.0", + "@tiptap/extension-code-block": "^2.11.0", + "@tiptap/extension-code-block-lowlight": "^2.11.0", + "@tiptap/extension-image": "^2.11.0", + "@tiptap/extension-link": "^2.11.0", + "@tiptap/extension-placeholder": "^2.11.0", + "@tiptap/extension-text-align": "^2.11.0", + "@tiptap/extension-underline": "^2.11.0", + "@tiptap/pm": "^2.11.0", + "@tiptap/react": "^2.11.0", + "@tiptap/starter-kit": "^2.11.0", + "apexcharts": "^4.3.0", + "autosuggest-highlight": "^3.3.4", + "aws-amplify": "^6.11.0", + "axios": "^1.7.9", + "dayjs": "^1.11.13", + "embla-carousel": "^8.5.1", + "embla-carousel-auto-height": "^8.5.1", + "embla-carousel-auto-scroll": "^8.5.1", + "embla-carousel-autoplay": "^8.5.1", + "embla-carousel-fade": "^8.5.1", + "embla-carousel-react": "^8.5.1", + "es-toolkit": "^1.31.0", + "firebase": "^11.1.0", + "framer-motion": "^11.15.0", + "i18next": "^24.2.0", + "i18next-browser-languagedetector": "^8.0.2", + "i18next-resources-to-backend": "^1.2.1", + "lowlight": "^3.3.0", + "mapbox-gl": "^3.4.0", + "minimal-shared": "^1.0.5", + "mui-one-time-password-input": "^3.0.2", + "next": "^14.2.22", + "nprogress": "^0.2.0", + "react": "^18.3.1", + "react-apexcharts": "^1.7.0", + "react-dom": "^18.3.1", + "react-dropzone": "^14.3.5", + "react-hook-form": "^7.54.2", + "react-i18next": "^15.4.0", + "react-joyride": "^2.9.3", + "react-map-gl": "^7.1.8", + "react-markdown": "^9.0.1", + "react-organizational-chart": "^2.2.1", + "react-phone-number-input": "^3.4.10", + "rehype-highlight": "^7.0.1", + "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.0", + "simplebar-react": "^3.3.0", + "sonner": "^1.7.1", + "stylis": "^4.3.4", + "stylis-plugin-rtl": "^2.1.1", + "swr": "^2.3.0", + "turndown": "^7.2.0", + "yet-another-react-lightbox": "^3.21.7", + "zod": "^3.24.1", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "@svgr/webpack": "^8.1.0", + "@types/autosuggest-highlight": "^3.2.3", + "@types/node": "^22.10.3", + "@types/nprogress": "^0.2.3", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@types/stylis": "^4.2.7", + "@types/turndown": "^5.0.5", + "@typescript-eslint/parser": "^8.19.0", + "eslint": "^9.17.0", + "eslint-import-resolver-typescript": "^3.7.0", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-perfectionist": "^4.4.0", + "eslint-plugin-react": "^7.37.3", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-unused-imports": "^4.1.4", + "globals": "^15.14.0", + "prettier": "^3.4.2", + "typescript": "^5.7.2", + "typescript-eslint": "^8.19.0" + }, + "engines": { + "node": "20.x" + }, + "peerDependencies": { + "@types/mapbox-gl": "3.1.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@auth0/auth0-react": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@auth0/auth0-react/-/auth0-react-2.2.4.tgz", + "integrity": "sha512-l29PQC0WdgkCoOc6WeMAY26gsy/yXJICW0jHfj0nz8rZZphYKrLNqTRWFFCMJY+sagza9tSgB1kG/UvQYgGh9A==", + "license": "MIT", + "dependencies": { + "@auth0/auth0-spa-js": "^2.1.3" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17 || ^18", + "react-dom": "^16.11.0 || ^17 || ^18" + } + }, + "node_modules/@auth0/auth0-spa-js": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.1.3.tgz", + "integrity": "sha512-NMTBNuuG4g3rame1aCnNS5qFYIzsTUV5qTFPRfTyYFS1feS6jsCBR+eTq9YkxCp1yuoM2UIcjunPaoPl77U9xQ==", + "license": "MIT" + }, + "node_modules/@aws-amplify/analytics": { + "version": "7.0.64", + "resolved": "https://registry.npmjs.org/@aws-amplify/analytics/-/analytics-7.0.64.tgz", + "integrity": "sha512-cc/0r1v6cswsj+QWYYlm5AaZ+7mnFpuUygwoovtKqeJRQQMRwiYmBQ2gPSYoBAyXwcJeDfhSJtRUDNcDAsaGcw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-firehose": "3.621.0", + "@aws-sdk/client-kinesis": "3.621.0", + "@aws-sdk/client-personalize-events": "3.621.0", + "@smithy/util-utf8": "2.0.0", + "tslib": "^2.5.0" + }, + "peerDependencies": { + "@aws-amplify/core": "^6.1.0" + } + }, + "node_modules/@aws-amplify/api": { + "version": "6.1.9", + "resolved": "https://registry.npmjs.org/@aws-amplify/api/-/api-6.1.9.tgz", + "integrity": "sha512-/iY/7Rkxjd1j066UCa5qLj4GssJZ6Tb4c+ra06yekhnrLygU34bs76vGmFCpYk+xLKLIm5uy8tvcwccSYZ2IpA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-amplify/api-graphql": "4.6.7", + "@aws-amplify/api-rest": "4.0.64", + "tslib": "^2.5.0" + } + }, + "node_modules/@aws-amplify/api-graphql": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/@aws-amplify/api-graphql/-/api-graphql-4.6.7.tgz", + "integrity": "sha512-PG6JwnPXxlMPXjMM924of5IO9xEmnJsSoDLdDZWz0p4Je6SjycRqWMezvc32jo4bY/niZUOH1hMa/Ux+w+YkWA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-amplify/api-rest": "4.0.64", + "@aws-amplify/core": "6.8.0", + "@aws-amplify/data-schema": "^1.7.0", + "@aws-sdk/types": "3.387.0", + "graphql": "15.8.0", + "rxjs": "^7.8.1", + "tslib": "^2.5.0", + "uuid": "^9.0.0" + } + }, + "node_modules/@aws-amplify/api-rest": { + "version": "4.0.64", + "resolved": "https://registry.npmjs.org/@aws-amplify/api-rest/-/api-rest-4.0.64.tgz", + "integrity": "sha512-PrJbx1FEjf3UIOf8fcptwBGhCBRhk8R38Cut5eflbdlDdPbOPjvogGDs5KMJg6/dBkWYLftGSyBLOqOoMk6F6A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.5.0" + }, + "peerDependencies": { + "@aws-amplify/core": "^6.1.0" + } + }, + "node_modules/@aws-amplify/auth": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@aws-amplify/auth/-/auth-6.9.0.tgz", + "integrity": "sha512-zJkscexjrANUr8FcC7rGKcZ/XivAE8E8b/0jlNVwz029stTvRV/paFYHIKsw4Lil0A0jsogJX94RthSJVZ3PZg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.5.0" + }, + "peerDependencies": { + "@aws-amplify/core": "^6.1.0" + } + }, + "node_modules/@aws-amplify/core": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@aws-amplify/core/-/core-6.8.0.tgz", + "integrity": "sha512-1gnemNLMN0on0cxoIfqlICL2ebPLa4OGbFb2mhkdJC9Jx6xCHUV1lhvQcrHRqxAkIsQnKe8viHV2QotrTKzzkw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/types": "3.398.0", + "@smithy/util-hex-encoding": "2.0.0", + "@types/uuid": "^9.0.0", + "js-cookie": "^3.0.5", + "rxjs": "^7.8.1", + "tslib": "^2.5.0", + "uuid": "^9.0.0" + } + }, + "node_modules/@aws-amplify/core/node_modules/@aws-sdk/types": { + "version": "3.398.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.398.0.tgz", + "integrity": "sha512-r44fkS+vsEgKCuEuTV+TIk0t0m5ZlXHNjSDYEUvzLStbbfUFiNus/YG4UCa0wOk9R7VuQI67badsvvPeVPCGDQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-amplify/core/node_modules/@smithy/types": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-2.12.0.tgz", + "integrity": "sha512-QwYgloJ0sVNBeBuBs65cIkTbfzV/Q6ZNPCJ99EICFEdJYG50nGIY/uYXp+TbsdJReIuPr0a0kXmCvren3MbRRw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-amplify/data-schema": { + "version": "1.17.2", + "resolved": "https://registry.npmjs.org/@aws-amplify/data-schema/-/data-schema-1.17.2.tgz", + "integrity": "sha512-6BCO4JKIo+UZPVucrUVLzqGq6MpQwmba+EyaEbAWig3MIVIlaL6QDbJ54uhSKDANNYiTc+wLdklYzix8FITA6Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-amplify/data-schema-types": "*", + "@smithy/util-base64": "^3.0.0", + "@types/aws-lambda": "^8.10.134", + "@types/json-schema": "^7.0.15", + "rxjs": "^7.8.1" + } + }, + "node_modules/@aws-amplify/data-schema-types": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@aws-amplify/data-schema-types/-/data-schema-types-1.2.0.tgz", + "integrity": "sha512-1hy2r7jl3hQ5J/CGjhmPhFPcdGSakfme1ZLjlTMJZILfYifZLSlGRKNCelMb3J5N9203hyeT5XDi5yR47JL1TQ==", + "license": "Apache-2.0", + "dependencies": { + "graphql": "15.8.0", + "rxjs": "^7.8.1" + } + }, + "node_modules/@aws-amplify/datastore": { + "version": "5.0.66", + "resolved": "https://registry.npmjs.org/@aws-amplify/datastore/-/datastore-5.0.66.tgz", + "integrity": "sha512-2d0lD//erzg+c2DsttWbi50gT1jWZmseKXoXr0ZIjaexEz/jpgBj5ZwTrQkMYDdmr3UUVkOvdbr2sLS0vZpjQw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-amplify/api": "6.1.9", + "buffer": "4.9.2", + "idb": "5.0.6", + "immer": "9.0.6", + "rxjs": "^7.8.1", + "ulid": "^2.3.0" + }, + "peerDependencies": { + "@aws-amplify/core": "^6.1.0" + } + }, + "node_modules/@aws-amplify/notifications": { + "version": "2.0.64", + "resolved": "https://registry.npmjs.org/@aws-amplify/notifications/-/notifications-2.0.64.tgz", + "integrity": "sha512-pSR8GX4xSM9FiNL6XhJT3AKUlyzmUJVOTLnXb7FD+j4jC1RaTSKJbLlLIFtOIqMCqSeA7P76OMmFqE4+lujtvQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash": "^4.17.21", + "tslib": "^2.5.0" + }, + "peerDependencies": { + "@aws-amplify/core": "^6.1.0" + } + }, + "node_modules/@aws-amplify/storage": { + "version": "6.7.5", + "resolved": "https://registry.npmjs.org/@aws-amplify/storage/-/storage-6.7.5.tgz", + "integrity": "sha512-eN0io+mwfDDEqd29EeoCKivvEt0tH/rjF8WBKqWzX1PEQr4ledt5MA2gz6yFwxwGRYNY2KAr2lpzMSa4RCIqKQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.398.0", + "@smithy/md5-js": "2.0.7", + "buffer": "4.9.2", + "crc-32": "1.2.2", + "fast-xml-parser": "^4.4.1", + "tslib": "^2.5.0" + }, + "peerDependencies": { + "@aws-amplify/core": "^6.1.0" + } + }, + "node_modules/@aws-amplify/storage/node_modules/@aws-sdk/types": { + "version": "3.398.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.398.0.tgz", + "integrity": "sha512-r44fkS+vsEgKCuEuTV+TIk0t0m5ZlXHNjSDYEUvzLStbbfUFiNus/YG4UCa0wOk9R7VuQI67badsvvPeVPCGDQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^2.2.2", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-amplify/storage/node_modules/@smithy/types": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-2.12.0.tgz", + "integrity": "sha512-QwYgloJ0sVNBeBuBs65cIkTbfzV/Q6ZNPCJ99EICFEdJYG50nGIY/uYXp+TbsdJReIuPr0a0kXmCvren3MbRRw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-firehose": { + "version": "3.621.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-firehose/-/client-firehose-3.621.0.tgz", + "integrity": "sha512-XAjAkXdb35PDvBYph609Fxn4g00HYH/U6N4+KjF9gLQrdTU+wkjf3D9YD02DZNbApJVcu4eIxWh/8M25YkW02A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/client-sso-oidc": "3.621.0", + "@aws-sdk/client-sts": "3.621.0", + "@aws-sdk/core": "3.621.0", + "@aws-sdk/credential-provider-node": "3.621.0", + "@aws-sdk/middleware-host-header": "3.620.0", + "@aws-sdk/middleware-logger": "3.609.0", + "@aws-sdk/middleware-recursion-detection": "3.620.0", + "@aws-sdk/middleware-user-agent": "3.620.0", + "@aws-sdk/region-config-resolver": "3.614.0", + "@aws-sdk/types": "3.609.0", + "@aws-sdk/util-endpoints": "3.614.0", + "@aws-sdk/util-user-agent-browser": "3.609.0", + "@aws-sdk/util-user-agent-node": "3.614.0", + "@smithy/config-resolver": "^3.0.5", + "@smithy/core": "^2.3.1", + "@smithy/fetch-http-handler": "^3.2.4", + "@smithy/hash-node": "^3.0.3", + "@smithy/invalid-dependency": "^3.0.3", + "@smithy/middleware-content-length": "^3.0.5", + "@smithy/middleware-endpoint": "^3.1.0", + "@smithy/middleware-retry": "^3.0.13", + "@smithy/middleware-serde": "^3.0.3", + "@smithy/middleware-stack": "^3.0.3", + "@smithy/node-config-provider": "^3.1.4", + "@smithy/node-http-handler": "^3.1.4", + "@smithy/protocol-http": "^4.1.0", + "@smithy/smithy-client": "^3.1.11", + "@smithy/types": "^3.3.0", + "@smithy/url-parser": "^3.0.3", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.13", + "@smithy/util-defaults-mode-node": "^3.0.13", + "@smithy/util-endpoints": "^2.0.5", + "@smithy/util-middleware": "^3.0.3", + "@smithy/util-retry": "^3.0.3", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-firehose/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-firehose/node_modules/@smithy/util-utf8": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-3.0.0.tgz", + "integrity": "sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-kinesis": { + "version": "3.621.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-kinesis/-/client-kinesis-3.621.0.tgz", + "integrity": "sha512-53Omt/beFmTQPjQNpMuPMk5nMzYVsXCRiO+MeqygZEKYG1fWw/UGluCWVbi7WjClOHacsW8lQcsqIRvkPDFNag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/client-sso-oidc": "3.621.0", + "@aws-sdk/client-sts": "3.621.0", + "@aws-sdk/core": "3.621.0", + "@aws-sdk/credential-provider-node": "3.621.0", + "@aws-sdk/middleware-host-header": "3.620.0", + "@aws-sdk/middleware-logger": "3.609.0", + "@aws-sdk/middleware-recursion-detection": "3.620.0", + "@aws-sdk/middleware-user-agent": "3.620.0", + "@aws-sdk/region-config-resolver": "3.614.0", + "@aws-sdk/types": "3.609.0", + "@aws-sdk/util-endpoints": "3.614.0", + "@aws-sdk/util-user-agent-browser": "3.609.0", + "@aws-sdk/util-user-agent-node": "3.614.0", + "@smithy/config-resolver": "^3.0.5", + "@smithy/core": "^2.3.1", + "@smithy/eventstream-serde-browser": "^3.0.5", + "@smithy/eventstream-serde-config-resolver": "^3.0.3", + "@smithy/eventstream-serde-node": "^3.0.4", + "@smithy/fetch-http-handler": "^3.2.4", + "@smithy/hash-node": "^3.0.3", + "@smithy/invalid-dependency": "^3.0.3", + "@smithy/middleware-content-length": "^3.0.5", + "@smithy/middleware-endpoint": "^3.1.0", + "@smithy/middleware-retry": "^3.0.13", + "@smithy/middleware-serde": "^3.0.3", + "@smithy/middleware-stack": "^3.0.3", + "@smithy/node-config-provider": "^3.1.4", + "@smithy/node-http-handler": "^3.1.4", + "@smithy/protocol-http": "^4.1.0", + "@smithy/smithy-client": "^3.1.11", + "@smithy/types": "^3.3.0", + "@smithy/url-parser": "^3.0.3", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.13", + "@smithy/util-defaults-mode-node": "^3.0.13", + "@smithy/util-endpoints": "^2.0.5", + "@smithy/util-middleware": "^3.0.3", + "@smithy/util-retry": "^3.0.3", + "@smithy/util-utf8": "^3.0.0", + "@smithy/util-waiter": "^3.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-kinesis/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-kinesis/node_modules/@smithy/util-utf8": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-3.0.0.tgz", + "integrity": "sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-personalize-events": { + "version": "3.621.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-personalize-events/-/client-personalize-events-3.621.0.tgz", + "integrity": "sha512-qkVkqYvOe3WVuVNL/gRITGYFfHJCx2ijGFK7H3hNUJH3P4AwskmouAd1pWf+3cbGedRnj2is7iw7E602LeJIHA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/client-sso-oidc": "3.621.0", + "@aws-sdk/client-sts": "3.621.0", + "@aws-sdk/core": "3.621.0", + "@aws-sdk/credential-provider-node": "3.621.0", + "@aws-sdk/middleware-host-header": "3.620.0", + "@aws-sdk/middleware-logger": "3.609.0", + "@aws-sdk/middleware-recursion-detection": "3.620.0", + "@aws-sdk/middleware-user-agent": "3.620.0", + "@aws-sdk/region-config-resolver": "3.614.0", + "@aws-sdk/types": "3.609.0", + "@aws-sdk/util-endpoints": "3.614.0", + "@aws-sdk/util-user-agent-browser": "3.609.0", + "@aws-sdk/util-user-agent-node": "3.614.0", + "@smithy/config-resolver": "^3.0.5", + "@smithy/core": "^2.3.1", + "@smithy/fetch-http-handler": "^3.2.4", + "@smithy/hash-node": "^3.0.3", + "@smithy/invalid-dependency": "^3.0.3", + "@smithy/middleware-content-length": "^3.0.5", + "@smithy/middleware-endpoint": "^3.1.0", + "@smithy/middleware-retry": "^3.0.13", + "@smithy/middleware-serde": "^3.0.3", + "@smithy/middleware-stack": "^3.0.3", + "@smithy/node-config-provider": "^3.1.4", + "@smithy/node-http-handler": "^3.1.4", + "@smithy/protocol-http": "^4.1.0", + "@smithy/smithy-client": "^3.1.11", + "@smithy/types": "^3.3.0", + "@smithy/url-parser": "^3.0.3", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.13", + "@smithy/util-defaults-mode-node": "^3.0.13", + "@smithy/util-endpoints": "^2.0.5", + "@smithy/util-middleware": "^3.0.3", + "@smithy/util-retry": "^3.0.3", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-personalize-events/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-personalize-events/node_modules/@smithy/util-utf8": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-3.0.0.tgz", + "integrity": "sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.621.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.621.0.tgz", + "integrity": "sha512-xpKfikN4u0BaUYZA9FGUMkkDmfoIP0Q03+A86WjqDWhcOoqNA1DkHsE4kZ+r064ifkPUfcNuUvlkVTEoBZoFjA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.621.0", + "@aws-sdk/middleware-host-header": "3.620.0", + "@aws-sdk/middleware-logger": "3.609.0", + "@aws-sdk/middleware-recursion-detection": "3.620.0", + "@aws-sdk/middleware-user-agent": "3.620.0", + "@aws-sdk/region-config-resolver": "3.614.0", + "@aws-sdk/types": "3.609.0", + "@aws-sdk/util-endpoints": "3.614.0", + "@aws-sdk/util-user-agent-browser": "3.609.0", + "@aws-sdk/util-user-agent-node": "3.614.0", + "@smithy/config-resolver": "^3.0.5", + "@smithy/core": "^2.3.1", + "@smithy/fetch-http-handler": "^3.2.4", + "@smithy/hash-node": "^3.0.3", + "@smithy/invalid-dependency": "^3.0.3", + "@smithy/middleware-content-length": "^3.0.5", + "@smithy/middleware-endpoint": "^3.1.0", + "@smithy/middleware-retry": "^3.0.13", + "@smithy/middleware-serde": "^3.0.3", + "@smithy/middleware-stack": "^3.0.3", + "@smithy/node-config-provider": "^3.1.4", + "@smithy/node-http-handler": "^3.1.4", + "@smithy/protocol-http": "^4.1.0", + "@smithy/smithy-client": "^3.1.11", + "@smithy/types": "^3.3.0", + "@smithy/url-parser": "^3.0.3", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.13", + "@smithy/util-defaults-mode-node": "^3.0.13", + "@smithy/util-endpoints": "^2.0.5", + "@smithy/util-middleware": "^3.0.3", + "@smithy/util-retry": "^3.0.3", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-sso-oidc": { + "version": "3.621.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.621.0.tgz", + "integrity": "sha512-mMjk3mFUwV2Y68POf1BQMTF+F6qxt5tPu6daEUCNGC9Cenk3h2YXQQoS4/eSyYzuBiYk3vx49VgleRvdvkg8rg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.621.0", + "@aws-sdk/credential-provider-node": "3.621.0", + "@aws-sdk/middleware-host-header": "3.620.0", + "@aws-sdk/middleware-logger": "3.609.0", + "@aws-sdk/middleware-recursion-detection": "3.620.0", + "@aws-sdk/middleware-user-agent": "3.620.0", + "@aws-sdk/region-config-resolver": "3.614.0", + "@aws-sdk/types": "3.609.0", + "@aws-sdk/util-endpoints": "3.614.0", + "@aws-sdk/util-user-agent-browser": "3.609.0", + "@aws-sdk/util-user-agent-node": "3.614.0", + "@smithy/config-resolver": "^3.0.5", + "@smithy/core": "^2.3.1", + "@smithy/fetch-http-handler": "^3.2.4", + "@smithy/hash-node": "^3.0.3", + "@smithy/invalid-dependency": "^3.0.3", + "@smithy/middleware-content-length": "^3.0.5", + "@smithy/middleware-endpoint": "^3.1.0", + "@smithy/middleware-retry": "^3.0.13", + "@smithy/middleware-serde": "^3.0.3", + "@smithy/middleware-stack": "^3.0.3", + "@smithy/node-config-provider": "^3.1.4", + "@smithy/node-http-handler": "^3.1.4", + "@smithy/protocol-http": "^4.1.0", + "@smithy/smithy-client": "^3.1.11", + "@smithy/types": "^3.3.0", + "@smithy/url-parser": "^3.0.3", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.13", + "@smithy/util-defaults-mode-node": "^3.0.13", + "@smithy/util-endpoints": "^2.0.5", + "@smithy/util-middleware": "^3.0.3", + "@smithy/util-retry": "^3.0.3", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sts": "^3.621.0" + } + }, + "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-sso-oidc/node_modules/@smithy/util-utf8": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-3.0.0.tgz", + "integrity": "sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-sso/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-sso/node_modules/@smithy/util-utf8": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-3.0.0.tgz", + "integrity": "sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-sts": { + "version": "3.621.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.621.0.tgz", + "integrity": "sha512-707uiuReSt+nAx6d0c21xLjLm2lxeKc7padxjv92CIrIocnQSlJPxSCM7r5zBhwiahJA6MNQwmTl2xznU67KgA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/client-sso-oidc": "3.621.0", + "@aws-sdk/core": "3.621.0", + "@aws-sdk/credential-provider-node": "3.621.0", + "@aws-sdk/middleware-host-header": "3.620.0", + "@aws-sdk/middleware-logger": "3.609.0", + "@aws-sdk/middleware-recursion-detection": "3.620.0", + "@aws-sdk/middleware-user-agent": "3.620.0", + "@aws-sdk/region-config-resolver": "3.614.0", + "@aws-sdk/types": "3.609.0", + "@aws-sdk/util-endpoints": "3.614.0", + "@aws-sdk/util-user-agent-browser": "3.609.0", + "@aws-sdk/util-user-agent-node": "3.614.0", + "@smithy/config-resolver": "^3.0.5", + "@smithy/core": "^2.3.1", + "@smithy/fetch-http-handler": "^3.2.4", + "@smithy/hash-node": "^3.0.3", + "@smithy/invalid-dependency": "^3.0.3", + "@smithy/middleware-content-length": "^3.0.5", + "@smithy/middleware-endpoint": "^3.1.0", + "@smithy/middleware-retry": "^3.0.13", + "@smithy/middleware-serde": "^3.0.3", + "@smithy/middleware-stack": "^3.0.3", + "@smithy/node-config-provider": "^3.1.4", + "@smithy/node-http-handler": "^3.1.4", + "@smithy/protocol-http": "^4.1.0", + "@smithy/smithy-client": "^3.1.11", + "@smithy/types": "^3.3.0", + "@smithy/url-parser": "^3.0.3", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.13", + "@smithy/util-defaults-mode-node": "^3.0.13", + "@smithy/util-endpoints": "^2.0.5", + "@smithy/util-middleware": "^3.0.3", + "@smithy/util-retry": "^3.0.3", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-sts/node_modules/@smithy/util-utf8": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-3.0.0.tgz", + "integrity": "sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.621.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.621.0.tgz", + "integrity": "sha512-CtOwWmDdEiINkGXD93iGfXjN0WmCp9l45cDWHHGa8lRgEDyhuL7bwd/pH5aSzj0j8SiQBG2k0S7DHbd5RaqvbQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^2.3.1", + "@smithy/node-config-provider": "^3.1.4", + "@smithy/protocol-http": "^4.1.0", + "@smithy/signature-v4": "^4.1.0", + "@smithy/smithy-client": "^3.1.11", + "@smithy/types": "^3.3.0", + "@smithy/util-middleware": "^3.0.3", + "fast-xml-parser": "4.4.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/core/node_modules/fast-xml-parser": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz", + "integrity": "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + }, + { + "type": "paypal", + "url": "https://paypal.me/naturalintelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.620.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.620.1.tgz", + "integrity": "sha512-ExuILJ2qLW5ZO+rgkNRj0xiAipKT16Rk77buvPP8csR7kkCflT/gXTyzRe/uzIiETTxM7tr8xuO9MP/DQXqkfg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.609.0", + "@smithy/property-provider": "^3.1.3", + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.621.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.621.0.tgz", + "integrity": "sha512-/jc2tEsdkT1QQAI5Dvoci50DbSxtJrevemwFsm0B73pwCcOQZ5ZwwSdVqGsPutzYzUVx3bcXg3LRL7jLACqRIg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.609.0", + "@smithy/fetch-http-handler": "^3.2.4", + "@smithy/node-http-handler": "^3.1.4", + "@smithy/property-provider": "^3.1.3", + "@smithy/protocol-http": "^4.1.0", + "@smithy/smithy-client": "^3.1.11", + "@smithy/types": "^3.3.0", + "@smithy/util-stream": "^3.1.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.621.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.621.0.tgz", + "integrity": "sha512-0EWVnSc+JQn5HLnF5Xv405M8n4zfdx9gyGdpnCmAmFqEDHA8LmBdxJdpUk1Ovp/I5oPANhjojxabIW5f1uU0RA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.620.1", + "@aws-sdk/credential-provider-http": "3.621.0", + "@aws-sdk/credential-provider-process": "3.620.1", + "@aws-sdk/credential-provider-sso": "3.621.0", + "@aws-sdk/credential-provider-web-identity": "3.621.0", + "@aws-sdk/types": "3.609.0", + "@smithy/credential-provider-imds": "^3.2.0", + "@smithy/property-provider": "^3.1.3", + "@smithy/shared-ini-file-loader": "^3.1.4", + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sts": "^3.621.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.621.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.621.0.tgz", + "integrity": "sha512-4JqpccUgz5Snanpt2+53hbOBbJQrSFq7E1sAAbgY6BKVQUsW5qyXqnjvSF32kDeKa5JpBl3bBWLZl04IadcPHw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.620.1", + "@aws-sdk/credential-provider-http": "3.621.0", + "@aws-sdk/credential-provider-ini": "3.621.0", + "@aws-sdk/credential-provider-process": "3.620.1", + "@aws-sdk/credential-provider-sso": "3.621.0", + "@aws-sdk/credential-provider-web-identity": "3.621.0", + "@aws-sdk/types": "3.609.0", + "@smithy/credential-provider-imds": "^3.2.0", + "@smithy/property-provider": "^3.1.3", + "@smithy/shared-ini-file-loader": "^3.1.4", + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.620.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.620.1.tgz", + "integrity": "sha512-hWqFMidqLAkaV9G460+1at6qa9vySbjQKKc04p59OT7lZ5cO5VH5S4aI05e+m4j364MBROjjk2ugNvfNf/8ILg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.609.0", + "@smithy/property-provider": "^3.1.3", + "@smithy/shared-ini-file-loader": "^3.1.4", + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.621.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.621.0.tgz", + "integrity": "sha512-Kza0jcFeA/GEL6xJlzR2KFf1PfZKMFnxfGzJzl5yN7EjoGdMijl34KaRyVnfRjnCWcsUpBWKNIDk9WZVMY9yiw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.621.0", + "@aws-sdk/token-providers": "3.614.0", + "@aws-sdk/types": "3.609.0", + "@smithy/property-provider": "^3.1.3", + "@smithy/shared-ini-file-loader": "^3.1.4", + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.621.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.621.0.tgz", + "integrity": "sha512-w7ASSyfNvcx7+bYGep3VBgC3K6vEdLmlpjT7nSIHxxQf+WSdvy+HynwJosrpZax0sK5q0D1Jpn/5q+r5lwwW6w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.609.0", + "@smithy/property-provider": "^3.1.3", + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sts": "^3.621.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.620.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.620.0.tgz", + "integrity": "sha512-VMtPEZwqYrII/oUkffYsNWY9PZ9xpNJpMgmyU0rlDQ25O1c0Hk3fJmZRe6pEkAJ0omD7kLrqGl1DUjQVxpd/Rg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.609.0", + "@smithy/protocol-http": "^4.1.0", + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.609.0.tgz", + "integrity": "sha512-S62U2dy4jMDhDFDK5gZ4VxFdWzCtLzwbYyFZx2uvPYTECkepLUfzLic2BHg2Qvtu4QjX+oGE3P/7fwaGIsGNuQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.609.0", + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.620.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.620.0.tgz", + "integrity": "sha512-nh91S7aGK3e/o1ck64sA/CyoFw+gAYj2BDOnoNa6ouyCrVJED96ZXWbhye/fz9SgmNUZR2g7GdVpiLpMKZoI5w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.609.0", + "@smithy/protocol-http": "^4.1.0", + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.620.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.620.0.tgz", + "integrity": "sha512-bvS6etn+KsuL32ubY5D3xNof1qkenpbJXf/ugGXbg0n98DvDFQ/F+SMLxHgbnER5dsKYchNnhmtI6/FC3HFu/A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.609.0", + "@aws-sdk/util-endpoints": "3.614.0", + "@smithy/protocol-http": "^4.1.0", + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.614.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.614.0.tgz", + "integrity": "sha512-vDCeMXvic/LU0KFIUjpC3RiSTIkkvESsEfbVHiHH0YINfl8HnEqR5rj+L8+phsCeVg2+LmYwYxd5NRz4PHxt5g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.609.0", + "@smithy/node-config-provider": "^3.1.4", + "@smithy/types": "^3.3.0", + "@smithy/util-config-provider": "^3.0.0", + "@smithy/util-middleware": "^3.0.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.614.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.614.0.tgz", + "integrity": "sha512-okItqyY6L9IHdxqs+Z116y5/nda7rHxLvROxtAJdLavWTYDydxrZstImNgGWTeVdmc0xX2gJCI77UYUTQWnhRw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.609.0", + "@smithy/property-provider": "^3.1.3", + "@smithy/shared-ini-file-loader": "^3.1.4", + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sso-oidc": "^3.614.0" + } + }, + "node_modules/@aws-sdk/token-providers/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.387.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.387.0.tgz", + "integrity": "sha512-YTjFabNwjTF+6yl88f0/tWff018qmmgMmjlw45s6sdVKueWxdxV68U7gepNLF2nhaQPZa6FDOBoA51NaviVs0Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^2.1.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/types/node_modules/@smithy/types": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-2.12.0.tgz", + "integrity": "sha512-QwYgloJ0sVNBeBuBs65cIkTbfzV/Q6ZNPCJ99EICFEdJYG50nGIY/uYXp+TbsdJReIuPr0a0kXmCvren3MbRRw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.614.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.614.0.tgz", + "integrity": "sha512-wK2cdrXHH4oz4IomV/yrGkftU9A+ITB6nFL+rxxyO78is2ifHJpFdV4aqk4LSkXYPi6CXWNru/Dqc7yiKXgJPw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.609.0", + "@smithy/types": "^3.3.0", + "@smithy/util-endpoints": "^2.0.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.693.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.693.0.tgz", + "integrity": "sha512-ttrag6haJLWABhLqtg1Uf+4LgHWIMOVSYL+VYZmAp2v4PUGOwWmWQH0Zk8RM7YuQcLfH/EoR72/Yxz6A4FKcuw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.609.0.tgz", + "integrity": "sha512-fojPU+mNahzQ0YHYBsx0ZIhmMA96H+ZIZ665ObU9tl+SGdbLneVZVikGve+NmHTQwHzwkFsZYYnVKAkreJLAtA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.609.0", + "@smithy/types": "^3.3.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.614.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.614.0.tgz", + "integrity": "sha512-15ElZT88peoHnq5TEoEtZwoXTXRxNrk60TZNdpl/TUBJ5oNJ9Dqb5Z4ryb8ofN6nm9aFf59GVAerFDz8iUoHBA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.609.0", + "@smithy/node-config-provider": "^3.1.4", + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/util-user-agent-node/node_modules/@aws-sdk/types": { + "version": "3.609.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.609.0.tgz", + "integrity": "sha512-+Tqnh9w0h2LcrUsdXyT1F8mNhXz+tVYBtP19LpeEGntmvHwa2XzvLUCWpoIAIVsHp5+HdB2X9Sn0KAtmbFXc2Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.2.tgz", + "integrity": "sha512-Z0WgzSEa+aUcdiJuCIqgujCshpMWgUpgOxXotrYPSA53hA3qopNaqcJpyr0hVb1FeWdnqFA35/fUtXgBK8srQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", + "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.26.0", + "@babel/generator": "^7.26.0", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helpers": "^7.26.0", + "@babel/parser": "^7.26.0", + "@babel/template": "^7.25.9", + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.26.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/generator": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.2.tgz", + "integrity": "sha512-zevQbhbau95nkoxSq3f/DC/SC+EEOUZd3DYqfSkMhY2/wfSeaHV1Ew4vk8e+x8lja31IbyuUa2uQ3JONqKbysw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.26.2", + "@babel/types": "^7.26.0", + "@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-annotate-as-pure": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", + "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.25.9.tgz", + "integrity": "sha512-C47lC7LIDCnz0h4vai/tpNOI95tCd5ZT3iBt/DBH5lXKHZsyNQv18yf1wIIg2ntiQNgmAvA+DgZ82iW8Qdym8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.9.tgz", + "integrity": "sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.25.9", + "@babel/helper-validator-option": "^7.25.9", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.9.tgz", + "integrity": "sha512-UTZQMvt0d/rSz6KI+qdu7GQze5TIajwTS++GUozlw8VBJDEOAqSXwm1WvmYEZwqdqSGQshRocPDqrt4HBZB3fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/traverse": "^7.25.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.25.9.tgz", + "integrity": "sha512-ORPNZ3h6ZRkOyAa/SaHU+XsLZr0UQzRwuDQ0cczIA17nAzZ+85G5cVkOJIj7QavLZGSe8QXUmNFxSZzjcZF9bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "regexpu-core": "^6.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.3.tgz", + "integrity": "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.22.6", + "@babel/helper-plugin-utils": "^7.22.5", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz", + "integrity": "sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "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-module-transforms": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", + "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz", + "integrity": "sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz", + "integrity": "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.9.tgz", + "integrity": "sha512-IZtukuUeBbhgOcaW2s06OXTzVNJR0ybm4W5xC1opWFFJMZbwRj5LCk+ByYH7WdZPZTt8KnFwA8pvjN2yqcPlgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-wrap-function": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.25.9.tgz", + "integrity": "sha512-IiDqTOTBQy0sWyeXyGSC5TBJpGFXBkRynjBeXsvbhQFKj2viwJC76Epz35YLU1fpe/Am6Vppb7W7zM4fPQzLsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.25.9.tgz", + "integrity": "sha512-c6WHXuiaRsJTyHYLJV75t9IqsmTbItYfdj99PnzYGQZkYKvan5/2jKJ7gu31J3/BJ/A18grImSPModuyG/Eo0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz", + "integrity": "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==", + "dev": true, + "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.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", + "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.9.tgz", + "integrity": "sha512-ETzz9UTjQSTmw39GboatdymDq4XIQbR8ySgVrylRhPOFpsd+JrKHIuF0de7GCWmem+T4uC5z7EZguod7Wj4A4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.25.9", + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.9.tgz", + "integrity": "sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.9.tgz", + "integrity": "sha512-MrGRLZxLD/Zjj0gdU15dfs+HH/OXvnw/U4jJD8vpcP2CJQapPEv1IWwjc/qMg7ItBlPwSv1hRBbb7LeuANdcnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.9.tgz", + "integrity": "sha512-2qUwwfAFpJLZqxd02YW9btUCZHl+RFvdDkNfZwaIJrvB8Tesjsk8pEQkTvGwZXLqXUx/2oyY3ySRhm6HOXuCug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.25.9.tgz", + "integrity": "sha512-6xWgLZTJXwilVjlnV7ospI3xi+sl8lN8rXXbBD6vYn3UYDlGsag8wrZkKcSI8G6KgqKP7vNFaDgeDnfAABq61g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/plugin-transform-optional-chaining": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.9.tgz", + "integrity": "sha512-aLnMXYPnzwwqhYSCyXfKkIkYgJ8zv9RK+roo9DkTXz38ynIhd9XCbN08s3MGvqL2MYGVUGdRQLL/JqBIeJhJBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.26.0.tgz", + "integrity": "sha512-QCWT5Hh830hK5EQa7XzuqIkQU9tT/whqbDz7kuaZMHFl1inRRg7JnuAEOQ0Ur0QUl0NufCk1msK2BeY79Aj/eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", + "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.9.tgz", + "integrity": "sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.9.tgz", + "integrity": "sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.25.9.tgz", + "integrity": "sha512-6jmooXYIwn9ca5/RylZADJ+EnSxVUS5sjeJ9UPk6RWRzXCmOJCy6dqItPJFpw2cuCangPK4OYr5uhGKcmrm5Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.25.9.tgz", + "integrity": "sha512-RXV6QAzTBbhDMO9fWwOmwwTuYaiPbggWQ9INdZqAYeSHyG7FzQ+nOZaUUjNwKv9pV3aE4WFqFm1Hnbci5tBCAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-remap-async-to-generator": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.25.9.tgz", + "integrity": "sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-remap-async-to-generator": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.25.9.tgz", + "integrity": "sha512-toHc9fzab0ZfenFpsyYinOX0J/5dgJVA2fm64xPewu7CoYHWEivIWKxkK2rMi4r3yQqLnVmheMXRdG+k239CgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.9.tgz", + "integrity": "sha512-1F05O7AYjymAtqbsFETboN1NvBdcnzMerO+zlMyJBEz6WkMdejvGWw9p05iTSjC85RLlBseHHQpYaM4gzJkBGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.25.9.tgz", + "integrity": "sha512-bbMAII8GRSkcd0h0b4X+36GksxuheLFjP65ul9w6C3KgAamI3JqErNgSrosX6ZPj+Mpim5VvEbawXxJCyEUV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.26.0.tgz", + "integrity": "sha512-6J2APTs7BDDm+UMqP1useWqhcRAXo0WIoVj26N7kPFB6S73Lgvyka4KTZYIxtgYXiN5HTyRObA72N2iu628iTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.9.tgz", + "integrity": "sha512-mD8APIXmseE7oZvZgGABDyM34GUmK45Um2TXiBUt7PnuAxrgoSVf123qUzPxEr/+/BHrRn5NMZCdE2m/1F8DGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9", + "@babel/traverse": "^7.25.9", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-classes/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.25.9.tgz", + "integrity": "sha512-HnBegGqXZR12xbcTHlJ9HGxw1OniltT26J5YpfruGqtUHlz/xKf/G2ak9e+t0rVqrjXa9WOhvYPz1ERfMj23AA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/template": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.25.9.tgz", + "integrity": "sha512-WkCGb/3ZxXepmMiX101nnGiU+1CAdut8oHyEOHxkKuS1qKpU2SMXE2uSvfz8PBuLd49V6LEsbtyPhWC7fnkgvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.25.9.tgz", + "integrity": "sha512-t7ZQ7g5trIgSRYhI9pIJtRl64KHotutUJsh4Eze5l7olJv+mRSg4/MmbZ0tv1eeqRbdvo/+trvJD/Oc5DmW2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.25.9.tgz", + "integrity": "sha512-LZxhJ6dvBb/f3x8xwWIuyiAHy56nrRG3PeYTpBkkzkYRRQ6tJLu68lEF5VIqMUZiAV7a8+Tb78nEoMCMcqjXBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.9.tgz", + "integrity": "sha512-0UfuJS0EsXbRvKnwcLjFtJy/Sxc5J5jhLHnFhy7u4zih97Hz6tJkLU+O+FMMrNZrosUPxDi6sYxJ/EA8jDiAog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.25.9.tgz", + "integrity": "sha512-GCggjexbmSLaFhqsojeugBpeaRIgWNTcgKVq/0qIteFEqY2A+b9QidYadrWlnbWQUrW5fn+mCvf3tr7OeBFTyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.25.9.tgz", + "integrity": "sha512-KRhdhlVk2nObA5AYa7QMgTMTVJdfHprfpAk4DjZVtllqRg9qarilstTKEhpVjyt+Npi8ThRyiV8176Am3CodPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.25.9.tgz", + "integrity": "sha512-2NsEz+CxzJIVOPx2o9UsW1rXLqtChtLoVnwYHHiB04wS5sgn7mrV45fWMBX0Kk+ub9uXytVYfNP2HjbVbCB3Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.25.9.tgz", + "integrity": "sha512-LqHxduHoaGELJl2uhImHwRQudhCM50pT46rIBNvtT/Oql3nqiS3wOwP+5ten7NpYSXrrVLgtZU3DZmPtWZo16A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.9.tgz", + "integrity": "sha512-8lP+Yxjv14Vc5MuWBpJsoUCd3hD6V9DgBon2FVYL4jJgbnVQ9fTgYmonchzZJOVNgzEgbxp4OwAf6xz6M/14XA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.25.9.tgz", + "integrity": "sha512-xoTMk0WXceiiIvsaquQQUaLLXSW1KJ159KP87VilruQm0LNNGxWzahxSS6T6i4Zg3ezp4vA4zuwiNUR53qmQAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.9.tgz", + "integrity": "sha512-9N7+2lFziW8W9pBl2TzaNht3+pgMIRP74zizeCSrtnSKVdUl8mAjjOP2OOVQAfZ881P2cNjDj1uAMEdeD50nuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.25.9.tgz", + "integrity": "sha512-wI4wRAzGko551Y8eVf6iOY9EouIDTtPb0ByZx+ktDGHwv6bHFimrgJM/2T021txPZ2s4c7bqvHbd+vXG6K948Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.25.9.tgz", + "integrity": "sha512-PYazBVfofCQkkMzh2P6IdIUaCEWni3iYEerAsRWuVd8+jlM1S9S9cz1dF9hIzyoZ8IA3+OwVYIp9v9e+GbgZhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.25.9.tgz", + "integrity": "sha512-g5T11tnI36jVClQlMlt4qKDLlWnG5pP9CSM4GhdRciTNMRgkfpo5cR6b4rGIOYPgRRuFAvwjPQ/Yk+ql4dyhbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.25.9.tgz", + "integrity": "sha512-dwh2Ol1jWwL2MgkCzUSOvfmKElqQcuswAZypBSUsScMXvgdT8Ekq5YA6TtqpTVWH+4903NmboMuH1o9i8Rxlyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-simple-access": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.9.tgz", + "integrity": "sha512-hyss7iIlH/zLHaehT+xwiymtPOpsiwIIRlCAOwBB04ta5Tt+lNItADdlXw3jAWZ96VJ2jlhl/c+PNIQPKNfvcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.25.9.tgz", + "integrity": "sha512-bS9MVObUgE7ww36HEfwe6g9WakQ0KF07mQF74uuXdkoziUPfKyu/nIm663kz//e5O1nPInPFx36z7WJmJ4yNEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.25.9.tgz", + "integrity": "sha512-oqB6WHdKTGl3q/ItQhpLSnWWOpjUJLsOCLVyeFgeTktkBSCiurvPOsyt93gibI9CmuKvTUEtWmG5VhZD+5T/KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.25.9.tgz", + "integrity": "sha512-U/3p8X1yCSoKyUj2eOBIx3FOn6pElFOKvAAGf8HTtItuPyB+ZeOqfn+mvTtg9ZlOAjsPdK3ayQEjqHjU/yLeVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.25.9.tgz", + "integrity": "sha512-ENfftpLZw5EItALAD4WsY/KUWvhUlZndm5GC7G3evUsVeSJB6p0pBeLQUnRnBCBx7zV0RKQjR9kCuwrsIrjWog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.25.9.tgz", + "integrity": "sha512-TlprrJ1GBZ3r6s96Yq8gEQv82s8/5HnCVHtEJScUj90thHQbwe+E5MLhi2bbNHBEJuzrvltXSru+BUxHDoog7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.25.9.tgz", + "integrity": "sha512-fSaXafEE9CVHPweLYw4J0emp1t8zYTXyzN3UuG+lylqkvYd7RMrsOQ8TYx5RF231be0vqtFC6jnx3UmpJmKBYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/plugin-transform-parameters": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.25.9.tgz", + "integrity": "sha512-Kj/Gh+Rw2RNLbCK1VAWj2U48yxxqL2x0k10nPtSdRa0O2xnHXalD0s+o1A6a0W43gJ00ANo38jxkQreckOzv5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.25.9.tgz", + "integrity": "sha512-qM/6m6hQZzDcZF3onzIhZeDHDO43bkNNlOX0i8n3lR6zLbu0GN2d8qfM/IERJZYauhAHSLHy39NF0Ctdvcid7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.25.9.tgz", + "integrity": "sha512-6AvV0FsLULbpnXeBjrY4dmWF8F7gf8QnvTEoO/wX/5xm/xE1Xo8oPuD3MPS+KS9f9XBEAWN7X1aWr4z9HdOr7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.25.9.tgz", + "integrity": "sha512-wzz6MKwpnshBAiRmn4jR8LYz/g8Ksg0o80XmwZDlordjwEk9SxBzTWC7F5ef1jhbrbOW2DJ5J6ayRukrJmnr0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.25.9.tgz", + "integrity": "sha512-D/JUozNpQLAPUVusvqMxyvjzllRaF8/nSrP1s2YGQT/W4LHK4xxsMcHjhOGTS01mp9Hda8nswb+FblLdJornQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.9.tgz", + "integrity": "sha512-Evf3kcMqzXA3xfYJmZ9Pg1OvKdtqsDMSWBDzZOPLvHiTt36E75jLDQo5w1gtRU95Q4E5PDttrTf25Fw8d/uWLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.25.9.tgz", + "integrity": "sha512-IvIUeV5KrS/VPavfSM/Iu+RE6llrHrYIKY1yfCzyO/lMXHQ+p7uGhonmGVisv6tSBSVgWzMBohTcvkC9vQcQFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-constant-elements": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.25.9.tgz", + "integrity": "sha512-Ncw2JFsJVuvfRsa2lSHiC55kETQVLSnsYGQ1JDDwkUeWGTL/8Tom8aLTnlqgoeuopWrbbGndrc9AlLYrIosrow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.25.9.tgz", + "integrity": "sha512-KJfMlYIUxQB1CJfO3e0+h0ZHWOTLCPP115Awhaz8U0Zpq36Gl/cXlpoyMRnUWlhNUBAzldnCiAZNvCDj7CrKxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.25.9.tgz", + "integrity": "sha512-s5XwpQYCqGerXl+Pu6VDL3x0j2d82eiV77UJ8a2mDHAW7j9SWRqQ2y1fNo1Z74CdcYipl5Z41zvjj4Nfzq36rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/plugin-syntax-jsx": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.25.9.tgz", + "integrity": "sha512-9mj6rm7XVYs4mdLIpbZnHOYdpW42uoiBCTVowg7sP1thUOiANgMb4UtpRivR0pp5iL+ocvUv7X4mZgFRpJEzGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.25.9.tgz", + "integrity": "sha512-KQ/Takk3T8Qzj5TppkS1be588lkbTp5uj7w6a0LeQaTMSckU/wK0oJ/pih+T690tkgI5jfmg2TqDJvd41Sj1Cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.9.tgz", + "integrity": "sha512-vwDcDNsgMPDGP0nMqzahDWE5/MLcX8sv96+wfX7as7LoF/kr97Bo/7fI00lXY4wUXYfVmwIIyG80fGZ1uvt2qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "regenerator-transform": "^0.15.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.26.0.tgz", + "integrity": "sha512-vN6saax7lrA2yA/Pak3sCxuD6F5InBjn9IcrIKQPjpsLvuHYLVroTxjdlVRHjjBWxKOqIwpTXDkOssYT4BFdRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.25.9.tgz", + "integrity": "sha512-7DL7DKYjn5Su++4RXu8puKZm2XBPHyjWLUidaPEkCUBbE7IPcsrkRHggAOOKydH1dASWdcUBxrkOGNxUv5P3Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.25.9.tgz", + "integrity": "sha512-MUv6t0FhO5qHnS/W8XCbHmiRWOphNufpE1IVxhK5kuN3Td9FT1x4rx4K42s3RYdMXCXpfWkGSbCSd0Z64xA7Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.25.9.tgz", + "integrity": "sha512-oNknIB0TbURU5pqJFVbOOFspVlrpVwo2H1+HUIsVDvp5VauGGDP1ZEvO8Nn5xyMEs3dakajOxlmkNW7kNgSm6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.25.9.tgz", + "integrity": "sha512-WqBUSgeVwucYDP9U/xNRQam7xV8W5Zf+6Eo7T2SRVUFlhRiMNFdFz58u0KZmCVVqs2i7SHgpRnAhzRNmKfi2uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.25.9.tgz", + "integrity": "sha512-o97AE4syN71M/lxrCtQByzphAdlYluKPDBzDVzMmfCobUjjhAryZV0AIpRPrxN0eAkxXO6ZLEScmt+PNhj2OTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.25.9.tgz", + "integrity": "sha512-v61XqUMiueJROUv66BVIOi0Fv/CUuZuZMl5NkRoCVxLAnMexZ0A3kMe7vvZ0nulxMuMp0Mk6S5hNh48yki08ZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.25.9.tgz", + "integrity": "sha512-7PbZQZP50tzv2KGGnhh82GSyMB01yKY9scIjf1a+GfZCtInOWqUH5+1EBU4t9fyR5Oykkkc9vFTs4OHrhHXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/plugin-syntax-typescript": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.25.9.tgz", + "integrity": "sha512-s5EDrE6bW97LtxOcGj1Khcx5AaXwiMmi4toFWRDP9/y0Woo6pXC+iyPu/KuhKtfSrNFd7jJB+/fkOtZy6aIC6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.25.9.tgz", + "integrity": "sha512-Jt2d8Ga+QwRluxRQ307Vlxa6dMrYEMZCgGxoPR8V52rxPyldHu3hdlHspxaqYmE7oID5+kB+UKUB/eWS+DkkWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.25.9.tgz", + "integrity": "sha512-yoxstj7Rg9dlNn9UQxzk4fcNivwv4nUYz7fYXBaKxvw/lnmPuOm/ikoELygbYq68Bls3D/D+NBPHiLwZdZZ4HA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.9.tgz", + "integrity": "sha512-8BYqO3GeVNHtx69fdPshN3fnzUNLrWdHhk/icSwigksJGczKSizZ+Z6SBCxTs723Fr5VSNorTIK7a+R2tISvwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.0.tgz", + "integrity": "sha512-H84Fxq0CQJNdPFT2DrfnylZ3cf5K43rGfWK4LJGPpjKHiZlk0/RzwEus3PDDZZg+/Er7lCA03MVacueUuXdzfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.26.0", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-validator-option": "^7.25.9", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.9", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.9", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.9", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.25.9", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.9", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.26.0", + "@babel/plugin-syntax-import-attributes": "^7.26.0", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.25.9", + "@babel/plugin-transform-async-generator-functions": "^7.25.9", + "@babel/plugin-transform-async-to-generator": "^7.25.9", + "@babel/plugin-transform-block-scoped-functions": "^7.25.9", + "@babel/plugin-transform-block-scoping": "^7.25.9", + "@babel/plugin-transform-class-properties": "^7.25.9", + "@babel/plugin-transform-class-static-block": "^7.26.0", + "@babel/plugin-transform-classes": "^7.25.9", + "@babel/plugin-transform-computed-properties": "^7.25.9", + "@babel/plugin-transform-destructuring": "^7.25.9", + "@babel/plugin-transform-dotall-regex": "^7.25.9", + "@babel/plugin-transform-duplicate-keys": "^7.25.9", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.9", + "@babel/plugin-transform-dynamic-import": "^7.25.9", + "@babel/plugin-transform-exponentiation-operator": "^7.25.9", + "@babel/plugin-transform-export-namespace-from": "^7.25.9", + "@babel/plugin-transform-for-of": "^7.25.9", + "@babel/plugin-transform-function-name": "^7.25.9", + "@babel/plugin-transform-json-strings": "^7.25.9", + "@babel/plugin-transform-literals": "^7.25.9", + "@babel/plugin-transform-logical-assignment-operators": "^7.25.9", + "@babel/plugin-transform-member-expression-literals": "^7.25.9", + "@babel/plugin-transform-modules-amd": "^7.25.9", + "@babel/plugin-transform-modules-commonjs": "^7.25.9", + "@babel/plugin-transform-modules-systemjs": "^7.25.9", + "@babel/plugin-transform-modules-umd": "^7.25.9", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.25.9", + "@babel/plugin-transform-new-target": "^7.25.9", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.25.9", + "@babel/plugin-transform-numeric-separator": "^7.25.9", + "@babel/plugin-transform-object-rest-spread": "^7.25.9", + "@babel/plugin-transform-object-super": "^7.25.9", + "@babel/plugin-transform-optional-catch-binding": "^7.25.9", + "@babel/plugin-transform-optional-chaining": "^7.25.9", + "@babel/plugin-transform-parameters": "^7.25.9", + "@babel/plugin-transform-private-methods": "^7.25.9", + "@babel/plugin-transform-private-property-in-object": "^7.25.9", + "@babel/plugin-transform-property-literals": "^7.25.9", + "@babel/plugin-transform-regenerator": "^7.25.9", + "@babel/plugin-transform-regexp-modifiers": "^7.26.0", + "@babel/plugin-transform-reserved-words": "^7.25.9", + "@babel/plugin-transform-shorthand-properties": "^7.25.9", + "@babel/plugin-transform-spread": "^7.25.9", + "@babel/plugin-transform-sticky-regex": "^7.25.9", + "@babel/plugin-transform-template-literals": "^7.25.9", + "@babel/plugin-transform-typeof-symbol": "^7.25.9", + "@babel/plugin-transform-unicode-escapes": "^7.25.9", + "@babel/plugin-transform-unicode-property-regex": "^7.25.9", + "@babel/plugin-transform-unicode-regex": "^7.25.9", + "@babel/plugin-transform-unicode-sets-regex": "^7.25.9", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.10", + "babel-plugin-polyfill-corejs3": "^0.10.6", + "babel-plugin-polyfill-regenerator": "^0.6.1", + "core-js-compat": "^3.38.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/preset-react": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.25.9.tgz", + "integrity": "sha512-D3to0uSPiWE7rBrdIICCd0tJSIGpLaaGptna2+w7Pft5xMqLpA1sz99DK5TZ1TjGbdQ/VI1eCSZ06dv3lT4JOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-validator-option": "^7.25.9", + "@babel/plugin-transform-react-display-name": "^7.25.9", + "@babel/plugin-transform-react-jsx": "^7.25.9", + "@babel/plugin-transform-react-jsx-development": "^7.25.9", + "@babel/plugin-transform-react-pure-annotations": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.26.0.tgz", + "integrity": "sha512-NMk1IGZ5I/oHhoXEElcm+xUnL/szL6xflkFZmoEU9xj1qSJXpiS7rsspYo92B4DRCDvZn2erT5LdsCeXAKNCkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-validator-option": "^7.25.9", + "@babel/plugin-syntax-jsx": "^7.25.9", + "@babel/plugin-transform-modules-commonjs": "^7.25.9", + "@babel/plugin-transform-typescript": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.9.tgz", + "integrity": "sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.25.9", + "@babel/generator": "^7.25.9", + "@babel/parser": "^7.25.9", + "@babel/template": "^7.25.9", + "@babel/types": "^7.25.9", + "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.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.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": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.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/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "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/cache/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/@emotion/css": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/css/-/css-11.13.5.tgz", + "integrity": "sha512-wQdD0Xhkn3Qy2VNcIzbLP9MR8TafI0MJb7BEAXKp+w4+XqErksWR4OXomuDzPsN4InLdGhVe6EYcn2ZIUCpB8w==", + "license": "MIT", + "dependencies": { + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2" + } + }, + "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/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "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.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "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/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/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, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@firebase/analytics": { + "version": "0.10.10", + "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.10.tgz", + "integrity": "sha512-Psdo7c9g2SLAYh6u1XRA+RZ7ab2JfBVuAt/kLzXkhKZL/gS2cQUCMsOW5p0RIlDPRKqpdNSmvujd2TeRWLKOkQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/installations": "0.6.11", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/analytics-compat": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.16.tgz", + "integrity": "sha512-Q/s+u/TEMSb2EDJFQMGsOzpSosybBl8HuoSEMyGZ99+0Pu7SIR9MPDGUjc8PKiCFQWDJ3QXxgqh1d/rujyAMbA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/analytics": "0.10.10", + "@firebase/analytics-types": "0.8.3", + "@firebase/component": "0.6.11", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/analytics-types": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.3.tgz", + "integrity": "sha512-VrIp/d8iq2g501qO46uGz3hjbDb8xzYMrbu8Tp0ovzIzrvJZ2fvmj649gTjge/b7cCCcjT0H37g1gVtlNhnkbg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app": { + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.10.17.tgz", + "integrity": "sha512-53sIYyAnYEPIZdaxuyq5OST7j4KBc2pqmktz+tEb1BIUSbXh8Gp4k/o6qzLelLpm4ngrBz7SRN0PZJqNRAyPog==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.10.2", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/app-check": { + "version": "0.8.10", + "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.8.10.tgz", + "integrity": "sha512-DWFfxxif/t+Ow4MmRUevDX+A3hVxm1rUf6y5ZP4sIomfnVCO1NNahqtsv9rb1/tKGkTeoVT40weiTS/WjQG1mA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/app-check-compat": { + "version": "0.3.17", + "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.3.17.tgz", + "integrity": "sha512-a/eadrGsY0MVCBPhrNbKUhoYpms4UKTYLKO7nswwSFVsm3Rw6NslQQCNLfvljcDqP4E7alQDRGJXjkxd/5gJ+Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check": "0.8.10", + "@firebase/app-check-types": "0.5.3", + "@firebase/component": "0.6.11", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", + "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-check-types": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.3.tgz", + "integrity": "sha512-hyl5rKSj0QmwPdsAxrI5x1otDlByQ7bvNvVt8G/XPO2CSwE++rmSVf3VEhaeOR4J8ZFaF0Z0NDSmLejPweZ3ng==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-compat": { + "version": "0.2.47", + "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.2.47.tgz", + "integrity": "sha512-TdEWGDp6kSwuO1mxiM2Fe39eLWygfyzqTZcoU3aPV0viqqphPCbBBnVjPbFJErZ4+yaS7uCWXEbFEP9m5/COKA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app": "0.10.17", + "@firebase/component": "0.6.11", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/app-types": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", + "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app/node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "license": "ISC" + }, + "node_modules/@firebase/auth": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.8.1.tgz", + "integrity": "sha512-LX9N/Cf5Z35r5yqm2+5M3+2bRRe/+RFaa/+u4HDni7TA27C/Xm4XHLKcWcLg1BzjrS4zngSaBEOSODvp6RFOqQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@react-native-async-storage/async-storage": "^1.18.1" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@firebase/auth-compat": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.5.16.tgz", + "integrity": "sha512-YlYwJMBqAyv0ESy3jDUyshMhZlbUiwAm6B6+uUmigNDHU+uq7j4SFiDJEZlFFIz397yBzKn06SUdqutdQzGnCA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth": "1.8.1", + "@firebase/auth-types": "0.12.3", + "@firebase/component": "0.6.11", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", + "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-types": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.12.3.tgz", + "integrity": "sha512-Zq9zI0o5hqXDtKg6yDkSnvMCMuLU6qAVS51PANQx+ZZX5xnzyNLEBO3GZgBUPsV5qIMFhjhqmLDxUqCbnAYy2A==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/component": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.6.11.tgz", + "integrity": "sha512-eQbeCgPukLgsKD0Kw5wQgsMDX5LeoI1MIrziNDjmc6XDq5ZQnuUymANQgAb2wp1tSF9zDSXyxJmIUXaKgN58Ug==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/data-connect": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.1.3.tgz", + "integrity": "sha512-FbAQpWNHownJx1VTCQI4ydbWGOZmSWXoFlirQn3ItHqsLJYSywqxSgDafzvyooifFh3J/2WqaM8y9hInnPcsTw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.6.11", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/database": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.0.10.tgz", + "integrity": "sha512-sWp2g92u7xT4BojGbTXZ80iaSIaL6GAL0pwvM0CO/hb0nHSnABAqsH7AhnWGsGvXuEvbPr7blZylPaR9J+GSuQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.6.11", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.10.2", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.0.1.tgz", + "integrity": "sha512-IsFivOjdE1GrjTeKoBU/ZMenESKDXidFDzZzHBPQ/4P20ptGdrl3oLlWrV/QJqJ9lND4IidE3z4Xr5JyfUW1vg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/database": "1.0.10", + "@firebase/database-types": "1.0.7", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.7.tgz", + "integrity": "sha512-I7zcLfJXrM0WM+ksFmFdAMdlq/DFmpeMNa+/GNsLyFo5u/lX5zzkPzGe3srVWqaBQBY5KprylDGxOsP6ETfL0A==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.3", + "@firebase/util": "1.10.2" + } + }, + "node_modules/@firebase/firestore": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.7.5.tgz", + "integrity": "sha512-OO3rHvjC07jL2ITN255xH/UzCVSvh6xG8oTzQdFScQvFbcm1fjCL1hgAdpDZcx3vVcKMV+6ktr8wbllkB8r+FQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.10.2", + "@firebase/webchannel-wrapper": "1.0.3", + "@grpc/grpc-js": "~1.9.0", + "@grpc/proto-loader": "^0.7.8", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/firestore-compat": { + "version": "0.3.40", + "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.3.40.tgz", + "integrity": "sha512-18HopMN811KYBc9Ptpr1Rewwio0XF09FF3jc5wtV6rGyAs815SlFFw5vW7ZeLd43zv9tlEc2FzM0H+5Vr9ZRxw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/firestore": "4.7.5", + "@firebase/firestore-types": "3.0.3", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/firestore-types": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.3.tgz", + "integrity": "sha512-hD2jGdiWRxB/eZWF89xcK9gF8wvENDJkzpVFb4aGkzfEaKxVRD1kjz1t1Wj8VZEp2LCB53Yx1zD8mrhQu87R6Q==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/functions": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.12.0.tgz", + "integrity": "sha512-plTtzY/nT0jOgHzT0vB9qch4FpHFOhCnR8HhYBqqdArG6GOQMIruKZbiTyLybO8bcaaNgQ6kSm9yohGUwxHcIw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.6.11", + "@firebase/messaging-interop-types": "0.2.3", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/functions-compat": { + "version": "0.3.17", + "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.3.17.tgz", + "integrity": "sha512-oj2XV8YsJYutyPCRYUfbN6swmfrL6zar0/qtqZsKT7P7btOiYRl+lD6fxtQaT+pKE5YgOBGZW//kLPZfY0jWhw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/functions": "0.12.0", + "@firebase/functions-types": "0.6.3", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/functions-types": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.3.tgz", + "integrity": "sha512-EZoDKQLUHFKNx6VLipQwrSMh01A1SaL3Wg6Hpi//x6/fJ6Ee4hrAeswK99I5Ht8roiniKHw4iO0B1Oxj5I4plg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/installations": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.11.tgz", + "integrity": "sha512-w8fY8mw6fxJzsZM2ufmTtomopXl1+bn/syYon+Gpn+0p0nO1cIUEVEFrFazTLaaL9q1CaVhc3HmseRTsI3igAA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/util": "1.10.2", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/installations-compat": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.11.tgz", + "integrity": "sha512-SHRgw5LTa6v8LubmJZxcOCwEd1MfWQPUtKdiuCx2VMWnapX54skZd1PkQg0K4l3k+4ujbI2cn7FE6Li9hbChBw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/installations": "0.6.11", + "@firebase/installations-types": "0.5.3", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/installations-types": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.3.tgz", + "integrity": "sha512-2FJI7gkLqIE0iYsNQ1P751lO3hER+Umykel+TkLwHj6plzWVxqvfclPUZhcKFVQObqloEBTmpi2Ozn7EkCABAA==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/installations/node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "license": "ISC" + }, + "node_modules/@firebase/logger": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.4.4.tgz", + "integrity": "sha512-mH0PEh1zoXGnaR8gD1DeGeNZtWFKbnz9hDO91dIml3iou1gpOnLqXQ2dJfB71dj6dpmUjcQ6phY3ZZJbjErr9g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/messaging": { + "version": "0.12.15", + "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.12.15.tgz", + "integrity": "sha512-Bz+qvWNEwEWAbYtG4An8hgcNco6NWNoNLuLbGVwPL2fAoCF1zz+dcaBp+iTR2+K199JyRyDT9yDPAXhNHNDaKQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/installations": "0.6.11", + "@firebase/messaging-interop-types": "0.2.3", + "@firebase/util": "1.10.2", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/messaging-compat": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.15.tgz", + "integrity": "sha512-mEKKASRvRWq1aBNHgioGsOYR2c5nBZpO7k90K794zjKe0WkGNf0k7PLs5SlCf8FKnzumEkhTAp/SjYxovuxa8A==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/messaging": "0.12.15", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/messaging-interop-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.3.tgz", + "integrity": "sha512-xfzFaJpzcmtDjycpDeCUj0Ge10ATFi/VHVIvEEjDNc3hodVBQADZ7BWQU7CuFpjSHE+eLuBI13z5F/9xOoGX8Q==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/messaging/node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "license": "ISC" + }, + "node_modules/@firebase/performance": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.6.11.tgz", + "integrity": "sha512-FlkJFeqLlIeh5T4Am3uE38HVzggliDIEFy/fErEc1faINOUFCb6vQBEoNZGaXvRnTR8lh3X/hP7tv37C7BsK9g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/installations": "0.6.11", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/performance-compat": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.11.tgz", + "integrity": "sha512-DqeNBy51W2xzlklyC7Ht9JQ94HhTA08PCcM4MDeyG/ol3fqum/+YgtHWQ2IQuduqH9afETthZqLwCZiSgY7hiA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/logger": "0.4.4", + "@firebase/performance": "0.6.11", + "@firebase/performance-types": "0.2.3", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/performance-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.3.tgz", + "integrity": "sha512-IgkyTz6QZVPAq8GSkLYJvwSLr3LS9+V6vNPQr0x4YozZJiLF5jYixj0amDtATf1X0EtYHqoPO48a9ija8GocxQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/remote-config": { + "version": "0.4.11", + "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.4.11.tgz", + "integrity": "sha512-9z0rgKuws2nj+7cdiqF+NY1QR4na6KnuOvP+jQvgilDOhGtKOcCMq5XHiu66i73A9kFhyU6QQ2pHXxcmaq1pBw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/installations": "0.6.11", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/remote-config-compat": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.11.tgz", + "integrity": "sha512-zfIjpwPrGuIOZDmduukN086qjhZ1LnbJi/iYzgua+2qeTlO0XdlE1v66gJPwygGB3TOhT0yb9EiUZ3nBNttMqg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/logger": "0.4.4", + "@firebase/remote-config": "0.4.11", + "@firebase/remote-config-types": "0.3.3", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/remote-config-types": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.3.3.tgz", + "integrity": "sha512-YlRI9CHxrk3lpQuFup9N1eohpwdWayKZUNZ/YeQ0PZoncJ66P32UsKUKqVXOaieTjJIOh7yH8JEzRdht5s+d6g==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/storage": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.13.4.tgz", + "integrity": "sha512-b1KaTTRiMupFurIhpGIbReaWev0k5O3ouTHkAPcEssT+FvU3q/1JwzvkX4+ZdB60Fc43Mbp8qQ1gWfT0Z2FP9Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/storage-compat": { + "version": "0.3.14", + "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.3.14.tgz", + "integrity": "sha512-Ok5FmXJiapaNAOQ8W8qppnfwgP8540jw2B8M0c4TFZqF4BD+CoKBxW0dRtOuLNGadLhzqqkDZZZtkexxrveQqA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.11", + "@firebase/storage": "0.13.4", + "@firebase/storage-types": "0.8.3", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/storage-types": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.3.tgz", + "integrity": "sha512-+Muk7g9uwngTpd8xn9OdF/D48uiQ7I1Fae7ULsWPuKoCH3HU7bfFPhxtJYzyhjdniowhuDpQcfPmuNRAqZEfvg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/util": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.10.2.tgz", + "integrity": "sha512-qnSHIoE9FK+HYnNhTI8q14evyqbc/vHRivfB4TgCIUOl4tosmKSQlp7ltymOlMP4xVIJTg5wrkfcZ60X4nUf7Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/vertexai": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@firebase/vertexai/-/vertexai-1.0.2.tgz", + "integrity": "sha512-4dC9m2nD0tkfKJT5v+i27tELrmUePjFXW3CDAxhVHUEv647B2R7kqpGQnyPkNEeaXkCr76THe7GGg35EWn4lDw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/component": "0.6.11", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.10.2", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/webchannel-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.3.tgz", + "integrity": "sha512-2xCRM9q9FlzGZCdgDMJwc0gyUkWFtkosy7Xxr6sFgQwn+wMNIWd7xIvYNauU1r64B5L5rsGKy/n9TKJ0aAFeqQ==", + "license": "Apache-2.0" + }, + "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/dm-sans": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@fontsource-variable/dm-sans/-/dm-sans-5.1.1.tgz", + "integrity": "sha512-oOroo1rQurZR8giuNLF64W+EqvZt6prsdJS6gGCAZgD/AMIHKmL6RzVjznGb8ZGXCeiJpVWO/xd4DPgMqr7ayQ==", + "license": "OFL-1.1" + }, + "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/nunito-sans": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@fontsource-variable/nunito-sans/-/nunito-sans-5.1.1.tgz", + "integrity": "sha512-BjtLqBKa+ZwxkZf7mAwZSEmX6VOC9Bvri1hXAAHPjkrwq6rXBG9THX3kl4hZQE+U0rT3febuSxSa84zAvzZUQg==", + "license": "OFL-1.1" + }, + "node_modules/@fontsource-variable/public-sans": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@fontsource-variable/public-sans/-/public-sans-5.1.2.tgz", + "integrity": "sha512-D4BaUhRY52xoVJw24gOVJymDBHoezdv3X2Rot9iYsHieEuvu8mwBNTSqBJvS6wlMZo2i834azXKllxRYE0C6fg==", + "license": "OFL-1.1" + }, + "node_modules/@fontsource/barlow": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@fontsource/barlow/-/barlow-5.1.1.tgz", + "integrity": "sha512-41BWOY3bZg0Hc7eQkeMEFIPUIt2wkVYHhfhlV8VVVaiRBdQw5JY8NT9qpB3Z/IKZ9dQuhHzUjuQJu9ALmjEwig==", + "license": "OFL-1.1" + }, + "node_modules/@fullcalendar/core": { + "version": "6.1.15", + "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.15.tgz", + "integrity": "sha512-BuX7o6ALpLb84cMw1FCB9/cSgF4JbVO894cjJZ6kP74jzbUZNjtwffwRdA+Id8rrLjT30d/7TrkW90k4zbXB5Q==", + "license": "MIT", + "dependencies": { + "preact": "~10.12.1" + } + }, + "node_modules/@fullcalendar/daygrid": { + "version": "6.1.15", + "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.15.tgz", + "integrity": "sha512-j8tL0HhfiVsdtOCLfzK2J0RtSkiad3BYYemwQKq512cx6btz6ZZ2RNc/hVnIxluuWFyvx5sXZwoeTJsFSFTEFA==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.15" + } + }, + "node_modules/@fullcalendar/interaction": { + "version": "6.1.15", + "resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.15.tgz", + "integrity": "sha512-DOTSkofizM7QItjgu7W68TvKKvN9PSEEvDJceyMbQDvlXHa7pm/WAVtAc6xSDZ9xmB1QramYoWGLHkCYbTW1rQ==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.15" + } + }, + "node_modules/@fullcalendar/list": { + "version": "6.1.15", + "resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.15.tgz", + "integrity": "sha512-U1bce04tYDwkFnuVImJSy2XalYIIQr6YusOWRPM/5ivHcJh67Gm8CIMSWpi3KdRSNKFkqBxLPkfZGBMaOcJYug==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.15" + } + }, + "node_modules/@fullcalendar/premium-common": { + "version": "6.1.15", + "resolved": "https://registry.npmjs.org/@fullcalendar/premium-common/-/premium-common-6.1.15.tgz", + "integrity": "sha512-IwUHptHNzWDOgAsXLoBntGxmbfCKvUy6iYFMCP3F3ahbG58E0PJMYsXvgw+NaA7Cz4gcQoVHioGjIFgQqs3Keg==", + "license": "SEE LICENSE IN LICENSE.md", + "peerDependencies": { + "@fullcalendar/core": "~6.1.15" + } + }, + "node_modules/@fullcalendar/react": { + "version": "6.1.15", + "resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.15.tgz", + "integrity": "sha512-L0b9hybS2J4e7lq6G2CD4nqriyLEqOH1tE8iI6JQjAMTVh5JicOo5Mqw+fhU5bJ7hLfMw2K3fksxX3Ul1ssw5w==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.15", + "react": "^16.7.0 || ^17 || ^18 || ^19", + "react-dom": "^16.7.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/@fullcalendar/scrollgrid": { + "version": "6.1.15", + "resolved": "https://registry.npmjs.org/@fullcalendar/scrollgrid/-/scrollgrid-6.1.15.tgz", + "integrity": "sha512-z7q6eh9bUSQ60YwgztPlJzQAMr6XQgky6bxNQR9cMqfzcwyo/ww11G7OZUp840VIXyqT3g8XPziEDbS5zB4VVQ==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@fullcalendar/premium-common": "~6.1.15" + }, + "peerDependencies": { + "@fullcalendar/core": "~6.1.15" + } + }, + "node_modules/@fullcalendar/timegrid": { + "version": "6.1.15", + "resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.15.tgz", + "integrity": "sha512-61ORr3A148RtxQ2FNG7JKvacyA/TEVZ7z6I+3E9Oeu3dqTf6M928bFcpehRTIK6zIA6Yifs7BeWHgOE9dFnpbw==", + "license": "MIT", + "dependencies": { + "@fullcalendar/daygrid": "~6.1.15" + }, + "peerDependencies": { + "@fullcalendar/core": "~6.1.15" + } + }, + "node_modules/@fullcalendar/timeline": { + "version": "6.1.15", + "resolved": "https://registry.npmjs.org/@fullcalendar/timeline/-/timeline-6.1.15.tgz", + "integrity": "sha512-VWylStpFFS8lZVUqu0c1b0MF5gkuVH2lzyCK/gopMsbrppqr97sHDTfWEYDHaQXCeO7cd4gKXSliQ0dc9GMlUw==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@fullcalendar/premium-common": "~6.1.15", + "@fullcalendar/scrollgrid": "~6.1.15" + }, + "peerDependencies": { + "@fullcalendar/core": "~6.1.15" + } + }, + "node_modules/@gilbarbara/deep-equal": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@gilbarbara/deep-equal/-/deep-equal-0.3.1.tgz", + "integrity": "sha512-I7xWjLs2YSVMc5gGx1Z3ZG1lgFpITPndpi8Ku55GeEIKpACCPQNS/OTqQbxgTCfq0Ncvcc+CrFov96itVh6Qvw==", + "license": "MIT" + }, + "node_modules/@grpc/grpc-js": { + "version": "1.9.15", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz", + "integrity": "sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.7.8", + "@types/node": ">=12.12.47" + }, + "engines": { + "node": "^8.13.0 || >=10.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.13", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.13.tgz", + "integrity": "sha512-AiXO/bfe9bmxBjxxtYxFAXGZvMaN5s8kO+jBHAJCON8rJoB5YS/D6X7ZNc6XQkuHNmyl4CYaMI1fJ/Gn27RGGw==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@hookform/resolvers": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.9.1.tgz", + "integrity": "sha512-ud2HqmGBM0P0IABqoskKWI6PEf6ZDDBZkFqe2Vnl+mTHCEHzr3ISjjZyCwTjC/qpL25JC9aIDkloQejvMeq0ug==", + "license": "MIT", + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, + "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.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@iconify/react": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@iconify/react/-/react-5.1.0.tgz", + "integrity": "sha512-vj2wzalywy23DR37AnsogMPIkDa1nKEqITjxpH4z44tiLV869Mh7VyydD4/t0yJLEs9tsxlrPWtXvMOe1Lcd5g==", + "license": "MIT", + "dependencies": { + "@iconify/types": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/cyberalien" + }, + "peerDependencies": { + "react": ">=16" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "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/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@mapbox/mapbox-gl-supported": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-3.0.0.tgz", + "integrity": "sha512-2XghOwu16ZwPJLOFVuIOaLbN0iKMn867evzXFyf0P22dqugezfJwLmdanAgU25ITvz1TvOfVP4jsDImlDJzcWg==", + "license": "BSD-3-Clause" + }, + "node_modules/@mapbox/point-geometry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", + "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.6.tgz", + "integrity": "sha512-qMqa27TLw+ZQz5Jk+RcwZGH7BQf5G/TrutJhspsca/3SHwmgKQ1iq+d3Jxz5oysPVYTGP6aXxCo5Lk9Er6YBAA==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", + "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~0.1.0" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "19.3.3", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-19.3.3.tgz", + "integrity": "sha512-cOZZOVhDSulgK0meTsTkmNXb1ahVvmTmWmfx9gRBwc6hq98wS9JP35ESIoNq3xqEan+UN+gn8187Z6E4NKhLsw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^3.0.0", + "minimist": "^1.2.8", + "rw": "^1.3.3", + "sort-object": "^3.0.3" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@mixmark-io/domino": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz", + "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mui/base": { + "version": "5.0.0-beta.68", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.68.tgz", + "integrity": "sha512-F1JMNeLS9Qhjj3wN86JUQYBtJoXyQvknxlzwNl6eS0ZABo1MiohMONj3/WQzYPSXIKC2bS/ZbyBzdHhi2GnEpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@floating-ui/react-dom": "^2.1.1", + "@mui/types": "^7.2.20", + "@mui/utils": "^6.3.0", + "@popperjs/core": "^2.11.8", + "clsx": "^2.1.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", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.3.0.tgz", + "integrity": "sha512-/d8NwSuC3rMwCjswmGB3oXC4sdDuhIUJ8inVQAxGrADJhf0eq/kmy+foFKvpYhHl2siOZR+MLdFttw6/Bzqtqg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/lab": { + "version": "6.0.0-beta.21", + "resolved": "https://registry.npmjs.org/@mui/lab/-/lab-6.0.0-beta.21.tgz", + "integrity": "sha512-hiFZgTwBNhJMUlEhmqfW4+5wy3C8UF9KFuzSOux6x4kgc9hsC0l+motXcF1Vyh+jhJYGeZ6yUoImqCf9RWzEvw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/base": "5.0.0-beta.68", + "@mui/system": "^6.3.0", + "@mui/types": "^7.2.20", + "@mui/utils": "^6.3.0", + "clsx": "^2.1.1", + "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", + "@mui/material": "^6.3.0", + "@mui/material-pigment-css": "^6.3.0", + "@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": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.3.0.tgz", + "integrity": "sha512-qhlTFyRMxfoVPxUtA5e8IvqxP0dWo2Ij7cvot7Orag+etUlZH+3UwD8gZGt+3irOoy7Ms3UNBflYjwEikUXtAQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/core-downloads-tracker": "^6.3.0", + "@mui/system": "^6.3.0", + "@mui/types": "^7.2.20", + "@mui/utils": "^6.3.0", + "@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.0", + "@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-nextjs": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@mui/material-nextjs/-/material-nextjs-6.3.0.tgz", + "integrity": "sha512-+qpdioehnw1eV35/pNHT3WfxolM4OMqP19LdFUIkZLFPXVAGfIr+MtivNlH7Rmtej4cpXN89jPm6JfzRyrZ5+A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/cache": "^11.11.0", + "@emotion/react": "^11.11.4", + "@emotion/server": "^11.11.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "next": "^13.0.0 || ^14.0.0 || ^15.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/cache": { + "optional": true + }, + "@emotion/server": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.3.0.tgz", + "integrity": "sha512-tdS8jvqMokltNTXg6ioRCCbVdDmZUJZa/T9VtTnX2Lwww3FTgCakst9tWLZSxm1fEE9Xp0m7onZJmgeUmWQYVw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/utils": "^6.3.0", + "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.0", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.3.0.tgz", + "integrity": "sha512-iWA6eyiPkO07AlHxRUvI7dwVRSc+84zV54kLmjUms67GJeOWVuXlu8ZO+UhCnwJxHacghxnabsMEqet5PYQmHg==", + "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.0", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.3.0.tgz", + "integrity": "sha512-L+8hUHMNlfReKSqcnVslFrVhoNfz/jw7Fe9NfDE85R3KarvZ4O3MU9daF/lZeqEAvnYxEilkkTfDwQ7qCgJdFg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/private-theming": "^6.3.0", + "@mui/styled-engine": "^6.3.0", + "@mui/types": "^7.2.20", + "@mui/utils": "^6.3.0", + "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.20", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.20.tgz", + "integrity": "sha512-straFHD7L8v05l/N5vcWk+y7eL9JF0C2mtph/y4BPm3gn2Eh61dDwDB65pa8DLss3WJfDXYC7Kx5yjP0EmXpgw==", + "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.0", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.3.0.tgz", + "integrity": "sha512-MkDBF08OPVwXhAjedyMykRojgvmf0y/jxkBWjystpfI/pasyTYrfdv4jic6s7j3y2+a+SJzS9qrD6X8ZYj/8AQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/types": "^7.2.20", + "@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/x-data-grid": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.23.5.tgz", + "integrity": "sha512-JmwdfaegpwO9Ei3PYCKy1FFip9AcdMGzZ0VTqzWE93pvDBVGxs/MZKT0g/8PYHJ6yzA5sBHHBxFN8sKfs7kVsg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0", + "@mui/x-internals": "7.23.5", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "reselect": "^5.1.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0", + "@mui/system": "^5.15.14 || ^6.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 + } + } + }, + "node_modules/@mui/x-data-grid/node_modules/@mui/x-internals": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.23.5.tgz", + "integrity": "sha512-PS6p9qL7otbQ2edSF83GgTicssE0Q84Ta+X/5tSwoCnToEKClka1Wc/cXlsjhRVLmoqz8uTqaiNcZAgnyQWNYQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@mui/x-date-pickers": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.23.3.tgz", + "integrity": "sha512-bjTYX/QzD5ZhVZNNnastMUS3j2Hy4p4IXmJgPJ0vKvQBvUdfEO+ZF42r3PJNNde0FVT1MmTzkmdTlz0JZ6ukdw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0", + "@mui/x-internals": "7.23.0", + "@types/react-transition-group": "^4.4.11", + "clsx": "^2.1.1", + "prop-types": "^15.8.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.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0", + "@mui/system": "^5.15.14 || ^6.0.0", + "date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0", + "date-fns-jalali": "^2.13.0-0 || ^3.2.0-0", + "dayjs": "^1.10.7", + "luxon": "^3.0.2", + "moment": "^2.29.4", + "moment-hijri": "^2.1.2 || ^3.0.0", + "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.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 + }, + "date-fns": { + "optional": true + }, + "date-fns-jalali": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + }, + "moment-hijri": { + "optional": true + }, + "moment-jalaali": { + "optional": true + } + } + }, + "node_modules/@mui/x-internals": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.23.0.tgz", + "integrity": "sha512-bPclKpqUiJYIHqmTxSzMVZi6MH51cQsn5U+8jskaTlo3J4QiMeCYJn/gn7YbeR9GOZFp8hetyHjoQoVHKRXCig==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@mui/x-tree-view": { + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@mui/x-tree-view/-/x-tree-view-7.23.2.tgz", + "integrity": "sha512-/R/9/GSF311fVLOUCg7r+a/+AScYZezL0SJZPfsTOquL1RDPAFRZei7BZEivUzOSEELJc0cxLGapJyM6QCA7Zg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0", + "@mui/x-internals": "7.23.0", + "@types/react-transition-group": "^4.4.11", + "clsx": "^2.1.1", + "prop-types": "^15.8.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.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0", + "@mui/system": "^5.15.14 || ^6.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 + } + } + }, + "node_modules/@next/env": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.33.tgz", + "integrity": "sha512-CgVHNZ1fRIlxkLhIX22flAZI/HmpDaZ8vwyJ/B0SDPTBuLZ1PJ+DWMjCHhqnExfmSQzA/PbZi8OAc7PAq2w9IA==" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz", + "integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz", + "integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz", + "integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz", + "integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz", + "integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz", + "integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz", + "integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz", + "integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz", + "integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "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/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@react-pdf/fns": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-pdf/fns/-/fns-3.0.0.tgz", + "integrity": "sha512-ICbIWR93PE6+xf2Xd/fXYO1dAuiOAJaszEuGGv3wp5lLSeeelDXlEYLh6R05okxh28YqMzc0Qd85x6n6MtaLUQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13" + } + }, + "node_modules/@react-pdf/font": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@react-pdf/font/-/font-3.0.1.tgz", + "integrity": "sha512-s+0xrQabGoYDDZwVpz8PXp1ylwabqiMhzfyetvxBqjDuQ15PuoSkmUkKUOkfDzauuAqs0MLMvt+Pcv+NioLfzw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@react-pdf/types": "^2.7.0", + "fontkit": "^2.0.2", + "is-url": "^1.2.4" + } + }, + "node_modules/@react-pdf/image": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@react-pdf/image/-/image-3.0.1.tgz", + "integrity": "sha512-Hd5F1LzjuzG4bL/ytaOYxwN/5ip8oFBYDHdpccOfYY87J/Ca7AL31SsuneLk9DtnwNM1BSAKXtBo/WDFY3r57A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@react-pdf/png-js": "^3.0.0", + "jay-peg": "^1.1.0" + } + }, + "node_modules/@react-pdf/layout": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@react-pdf/layout/-/layout-4.2.0.tgz", + "integrity": "sha512-/0jMhDKwZH0lQs3umNsOduaPtkK0IUpaBRUEv4udHVD9lB2VzYoSNeYsCu+MJMPJyByXj70OSWV7IMjWTCKwWw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@react-pdf/fns": "3.0.0", + "@react-pdf/image": "^3.0.1", + "@react-pdf/pdfkit": "^4.0.0", + "@react-pdf/primitives": "^4.0.0", + "@react-pdf/stylesheet": "^5.2.0", + "@react-pdf/textkit": "^5.0.1", + "@react-pdf/types": "^2.7.0", + "emoji-regex": "^10.3.0", + "queue": "^6.0.1", + "yoga-layout": "^3.1.0" + } + }, + "node_modules/@react-pdf/pdfkit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@react-pdf/pdfkit/-/pdfkit-4.0.0.tgz", + "integrity": "sha512-HaaAoBpoRGJ6c1ZOANNQZ3q6Ehmagqa8n40x+OZ5s9HcmUviZ34SCm+QBa42s1o4299M+Lgw3UoqpW7sHv3/Hg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@react-pdf/png-js": "^3.0.0", + "browserify-zlib": "^0.2.0", + "crypto-js": "^4.2.0", + "fontkit": "^2.0.2", + "jay-peg": "^1.1.0", + "vite-compatible-readable-stream": "^3.6.1" + } + }, + "node_modules/@react-pdf/png-js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-pdf/png-js/-/png-js-3.0.0.tgz", + "integrity": "sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA==", + "license": "MIT", + "dependencies": { + "browserify-zlib": "^0.2.0" + } + }, + "node_modules/@react-pdf/primitives": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@react-pdf/primitives/-/primitives-4.0.0.tgz", + "integrity": "sha512-yp4E0rDL03NaUp/CnDBz3HQNfH2Mzdlgku57yhTMGNzetwB0NJusXcjYg5XsTGIXnR7Tv80JKI4O4ajj+oaLeQ==", + "license": "MIT" + }, + "node_modules/@react-pdf/reconciler": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@react-pdf/reconciler/-/reconciler-1.1.3.tgz", + "integrity": "sha512-4vqY0klmUH32kTFvuqdAszkOpwfZYKMLO4VpJ5xZWTsoUOLQSyhC2QM2QCj9eaxpB2Nd5Kl9uW+KfyutvZnMzQ==", + "license": "MIT", + "dependencies": { + "object-assign": "^4.1.1", + "scheduler": "0.25.0-rc-603e6108-20241029" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-pdf/render": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@react-pdf/render/-/render-4.0.2.tgz", + "integrity": "sha512-5QJB9sS0uU5ALTLxrtT073VT1imZhrzuOun+7kvo0nykeAr9I4lv0Shmy8rS4QhpmXn8ASmhd17WjCVm4DcJlw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@react-pdf/fns": "3.0.0", + "@react-pdf/primitives": "^4.0.0", + "@react-pdf/textkit": "^5.0.1", + "@react-pdf/types": "^2.7.0", + "abs-svg-path": "^0.1.1", + "color-string": "^1.9.1", + "normalize-svg-path": "^1.1.0", + "parse-svg-path": "^0.1.2", + "svg-arc-to-cubic-bezier": "^3.2.0" + } + }, + "node_modules/@react-pdf/renderer": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@react-pdf/renderer/-/renderer-4.1.6.tgz", + "integrity": "sha512-hfQ0PsuVqfoYxkYgmkj+HFkylbB1QTpXY1rnlgnzJlrlSoNXjzPrCa/ty8jcHOwYA2lNoazIAoDatBIsc8K5pw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@react-pdf/font": "^3.0.1", + "@react-pdf/layout": "^4.2.0", + "@react-pdf/pdfkit": "^4.0.0", + "@react-pdf/primitives": "^4.0.0", + "@react-pdf/reconciler": "^1.1.3", + "@react-pdf/render": "^4.0.2", + "@react-pdf/types": "^2.7.0", + "events": "^3.3.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "queue": "^6.0.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-pdf/stylesheet": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@react-pdf/stylesheet/-/stylesheet-5.2.0.tgz", + "integrity": "sha512-ST19VumM9iRG0z8EjDJnyQCG+NhPFtYUCAh5B8HY237MrsRGvMgzcwrpyyqcyuLwHHYy7S4iw8EY0mK9+Qa2XQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@react-pdf/fns": "3.0.0", + "@react-pdf/types": "^2.7.0", + "color-string": "^1.9.1", + "hsl-to-hex": "^1.0.0", + "media-engine": "^1.0.3", + "postcss-value-parser": "^4.1.0" + } + }, + "node_modules/@react-pdf/textkit": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@react-pdf/textkit/-/textkit-5.0.1.tgz", + "integrity": "sha512-4GdDiPA9l+If203hkh48slvRQmcmM3ecPLFTpXNMPrep/3retgvxUEXKMxI+xKclpw8tMzK/W6Z4hN9DgnxWMg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@react-pdf/fns": "3.0.0", + "bidi-js": "^1.0.2", + "hyphen": "^1.6.4", + "unicode-properties": "^1.4.1" + } + }, + "node_modules/@react-pdf/types": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@react-pdf/types/-/types-2.7.0.tgz", + "integrity": "sha512-7KrPPCpgRPKR+g+T127PE4bpw9Q84ZiY07EYRwXKVtTEVW9wJ5BZiF9smT9IvH19s+MQaDLmYRgjESsnqlyH0Q==", + "license": "MIT" + }, + "node_modules/@remirror/core-constants": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", + "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", + "license": "MIT" + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@smithy/abort-controller": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-3.1.9.tgz", + "integrity": "sha512-yiW0WI30zj8ZKoSYNx90no7ugVn3khlyH/z5W8qtKBtVE6awRALbhSG+2SAHA1r6bO/6M9utxYKVZ3PCJ1rWxw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "3.0.13", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-3.0.13.tgz", + "integrity": "sha512-Gr/qwzyPaTL1tZcq8WQyHhTZREER5R1Wytmz4WnVGL4onA3dNk6Btll55c8Vr58pLdvWZmtG8oZxJTw3t3q7Jg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^3.1.12", + "@smithy/types": "^3.7.2", + "@smithy/util-config-provider": "^3.0.0", + "@smithy/util-middleware": "^3.0.11", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-2.5.5.tgz", + "integrity": "sha512-G8G/sDDhXA7o0bOvkc7bgai6POuSld/+XhNnWAbpQTpLv2OZPvyqQ58tLPPlz0bSNsXktldDDREIv1LczFeNEw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^3.0.11", + "@smithy/protocol-http": "^4.1.8", + "@smithy/types": "^3.7.2", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-middleware": "^3.0.11", + "@smithy/util-stream": "^3.3.2", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/core/node_modules/@smithy/util-utf8": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-3.0.0.tgz", + "integrity": "sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-3.2.8.tgz", + "integrity": "sha512-ZCY2yD0BY+K9iMXkkbnjo+08T2h8/34oHd0Jmh6BZUSZwaaGlGCyBT/3wnS7u7Xl33/EEfN4B6nQr3Gx5bYxgw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^3.1.12", + "@smithy/property-provider": "^3.1.11", + "@smithy/types": "^3.7.2", + "@smithy/url-parser": "^3.0.11", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-3.1.10.tgz", + "integrity": "sha512-323B8YckSbUH0nMIpXn7HZsAVKHYHFUODa8gG9cHo0ySvA1fr5iWaNT+iIL0UCqUzG6QPHA3BSsBtRQou4mMqQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^3.7.2", + "@smithy/util-hex-encoding": "^3.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@smithy/eventstream-codec/node_modules/@smithy/util-hex-encoding": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-3.0.0.tgz", + "integrity": "sha512-eFndh1WEK5YMUYvy3lPlVmYY/fZcQE1D8oSf41Id2vCeIkKJXPcYDCZD+4+xViI6b1XSd7tE+s5AmXzz5ilabQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "3.0.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-3.0.14.tgz", + "integrity": "sha512-kbrt0vjOIihW3V7Cqj1SXQvAI5BR8SnyQYsandva0AOR307cXAc+IhPngxIPslxTLfxwDpNu0HzCAq6g42kCPg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^3.0.13", + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-3.0.11.tgz", + "integrity": "sha512-P2pnEp4n75O+QHjyO7cbw/vsw5l93K/8EWyjNCAAybYwUmj3M+hjSQZ9P5TVdUgEG08ueMAP5R4FkuSkElZ5tQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "3.0.13", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-3.0.13.tgz", + "integrity": "sha512-zqy/9iwbj8Wysmvi7Lq7XFLeDgjRpTbCfwBhJa8WbrylTAHiAu6oQTwdY7iu2lxigbc9YYr9vPv5SzYny5tCXQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^3.0.13", + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "3.0.13", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-3.0.13.tgz", + "integrity": "sha512-L1Ib66+gg9uTnqp/18Gz4MDpJPKRE44geOjOQ2SVc0eiaO5l255ADziATZgjQjqumC7yPtp1XnjHlF1srcwjKw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^3.1.10", + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-3.2.9.tgz", + "integrity": "sha512-hYNVQOqhFQ6vOpenifFME546f0GfJn2OiQ3M0FDmuUu8V/Uiwy2wej7ZXxFBNqdx0R5DZAqWM1l6VRhGz8oE6A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^4.1.4", + "@smithy/querystring-builder": "^3.0.7", + "@smithy/types": "^3.5.0", + "@smithy/util-base64": "^3.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@smithy/hash-node": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-3.0.11.tgz", + "integrity": "sha512-emP23rwYyZhQBvklqTtwetkQlqbNYirDiEEwXl2v0GYWMnCzxst7ZaRAnWuy28njp5kAH54lvkdG37MblZzaHA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.7.2", + "@smithy/util-buffer-from": "^3.0.0", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/hash-node/node_modules/@smithy/util-utf8": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-3.0.0.tgz", + "integrity": "sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-3.0.11.tgz", + "integrity": "sha512-NuQmVPEJjUX6c+UELyVz8kUx8Q539EDeNwbRyu4IIF8MeV7hUtq1FB3SHVyki2u++5XLMFqngeMKk7ccspnNyQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-3.0.0.tgz", + "integrity": "sha512-+Fsu6Q6C4RSJiy81Y8eApjEB5gVtM+oFKTffg+jSuwtvomJJrhUJBu2zS8wjXSgH/g1MKEWrzyChTBe6clb5FQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/md5-js": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-2.0.7.tgz", + "integrity": "sha512-2i2BpXF9pI5D1xekqUsgQ/ohv5+H//G9FlawJrkOJskV18PgJ8LiNbLiskMeYt07yAsSTZR7qtlcAaa/GQLWww==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^2.3.1", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.5.0" + } + }, + "node_modules/@smithy/md5-js/node_modules/@smithy/types": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-2.12.0.tgz", + "integrity": "sha512-QwYgloJ0sVNBeBuBs65cIkTbfzV/Q6ZNPCJ99EICFEdJYG50nGIY/uYXp+TbsdJReIuPr0a0kXmCvren3MbRRw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "3.0.13", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-3.0.13.tgz", + "integrity": "sha512-zfMhzojhFpIX3P5ug7jxTjfUcIPcGjcQYzB9t+rv0g1TX7B0QdwONW+ATouaLoD7h7LOw/ZlXfkq4xJ/g2TrIw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^4.1.8", + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-3.2.6.tgz", + "integrity": "sha512-WAqzyulvvSKrT5c6VrQelgNVNNO7BlTQW9Z+s9tcG6G5CaBS1YBpPtT3VuhXLQbewSiGi7oXQROwpw26EG9PLQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^2.5.5", + "@smithy/middleware-serde": "^3.0.11", + "@smithy/node-config-provider": "^3.1.12", + "@smithy/shared-ini-file-loader": "^3.1.12", + "@smithy/types": "^3.7.2", + "@smithy/url-parser": "^3.0.11", + "@smithy/util-middleware": "^3.0.11", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "3.0.31", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-3.0.31.tgz", + "integrity": "sha512-yq9wawrJLYHAYFpChLujxRN4My+SiKXvZk9Ml/CvTdRSA8ew+hvuR5LT+mjSlSBv3c4XJrkN8CWegkBaeD0Vrg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^3.1.12", + "@smithy/protocol-http": "^4.1.8", + "@smithy/service-error-classification": "^3.0.11", + "@smithy/smithy-client": "^3.5.1", + "@smithy/types": "^3.7.2", + "@smithy/util-middleware": "^3.0.11", + "@smithy/util-retry": "^3.0.11", + "tslib": "^2.6.2", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-3.0.11.tgz", + "integrity": "sha512-KzPAeySp/fOoQA82TpnwItvX8BBURecpx6ZMu75EZDkAcnPtO6vf7q4aH5QHs/F1s3/snQaSFbbUMcFFZ086Mw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-3.0.11.tgz", + "integrity": "sha512-1HGo9a6/ikgOMrTrWL/WiN9N8GSVYpuRQO5kjstAq4CvV59bjqnh7TbdXGQ4vxLD3xlSjfBjq5t1SOELePsLnA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "3.1.12", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-3.1.12.tgz", + "integrity": "sha512-O9LVEu5J/u/FuNlZs+L7Ikn3lz7VB9hb0GtPT9MQeiBmtK8RSY3ULmsZgXhe6VAlgTw0YO+paQx4p8xdbs43vQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^3.1.11", + "@smithy/shared-ini-file-loader": "^3.1.12", + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-3.3.2.tgz", + "integrity": "sha512-t4ng1DAd527vlxvOfKFYEe6/QFBcsj7WpNlWTyjorwXXcKw3XlltBGbyHfSJ24QT84nF+agDha9tNYpzmSRZPA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^3.1.9", + "@smithy/protocol-http": "^4.1.8", + "@smithy/querystring-builder": "^3.0.11", + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "3.1.11", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-3.1.11.tgz", + "integrity": "sha512-I/+TMc4XTQ3QAjXfOcUWbSS073oOEAxgx4aZy8jHaf8JQnRkq2SZWw8+PfDtBvLUjcGMdxl+YwtzWe6i5uhL/A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-4.1.8.tgz", + "integrity": "sha512-hmgIAVyxw1LySOwkgMIUN0kjN8TG9Nc85LJeEmEE/cNEe2rkHDUWhnJf2gxcSRFLWsyqWsrZGw40ROjUogg+Iw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-3.0.11.tgz", + "integrity": "sha512-u+5HV/9uJaeLj5XTb6+IEF/dokWWkEqJ0XiaRRogyREmKGUgZnNecLucADLdauWFKUNbQfulHFEZEdjwEBjXRg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.7.2", + "@smithy/util-uri-escape": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-3.0.11.tgz", + "integrity": "sha512-Je3kFvCsFMnso1ilPwA7GtlbPaTixa3WwC+K21kmMZHsBEOZYQaqxcMqeFFoU7/slFjKDIpiiPydvdJm8Q/MCw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-3.0.11.tgz", + "integrity": "sha512-QnYDPkyewrJzCyaeI2Rmp7pDwbUETe+hU8ADkXmgNusO1bgHBH7ovXJiYmba8t0fNfJx75fE8dlM6SEmZxheog==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.7.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "3.1.12", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-3.1.12.tgz", + "integrity": "sha512-1xKSGI+U9KKdbG2qDvIR9dGrw3CNx+baqJfyr0igKEpjbHL5stsqAesYBzHChYHlelWtb87VnLWlhvfCz13H8Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-4.2.4.tgz", + "integrity": "sha512-5JWeMQYg81TgU4cG+OexAWdvDTs5JDdbEZx+Qr1iPbvo91QFGzjy0IkXAKaXUHqmKUJgSHK0ZxnCkgZpzkeNTA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^3.0.0", + "@smithy/protocol-http": "^4.1.8", + "@smithy/types": "^3.7.2", + "@smithy/util-hex-encoding": "^3.0.0", + "@smithy/util-middleware": "^3.0.11", + "@smithy/util-uri-escape": "^3.0.0", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/signature-v4/node_modules/@smithy/util-hex-encoding": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-3.0.0.tgz", + "integrity": "sha512-eFndh1WEK5YMUYvy3lPlVmYY/fZcQE1D8oSf41Id2vCeIkKJXPcYDCZD+4+xViI6b1XSd7tE+s5AmXzz5ilabQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/signature-v4/node_modules/@smithy/util-utf8": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-3.0.0.tgz", + "integrity": "sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-3.5.1.tgz", + "integrity": "sha512-PmjskH4Os1Eh3rd5vSsa5uVelZ4DRu+N5CBEgb9AT96hQSJGWSEb6pGxKV/PtKQSIp9ft3+KvnT8ViMKaguzgA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^2.5.5", + "@smithy/middleware-endpoint": "^3.2.6", + "@smithy/middleware-stack": "^3.0.11", + "@smithy/protocol-http": "^4.1.8", + "@smithy/types": "^3.7.2", + "@smithy/util-stream": "^3.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-3.7.2.tgz", + "integrity": "sha512-bNwBYYmN8Eh9RyjS1p2gW6MIhSO2rl7X9QeLM8iTdcGRP+eDiIWDt66c9IysCc22gefKszZv+ubV9qZc7hdESg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-3.0.11.tgz", + "integrity": "sha512-TmlqXkSk8ZPhfc+SQutjmFr5FjC0av3GZP4B/10caK1SbRwe/v+Wzu/R6xEKxoNqL+8nY18s1byiy6HqPG37Aw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^3.0.11", + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + } + }, + "node_modules/@smithy/util-base64": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-3.0.0.tgz", + "integrity": "sha512-Kxvoh5Qtt0CDsfajiZOCpJxgtPHXOKwmM+Zy4waD43UoEMA+qPxxa98aE/7ZhdnBFZFXMOiBR5xbcaMhLtznQQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^3.0.0", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/util-base64/node_modules/@smithy/util-utf8": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-3.0.0.tgz", + "integrity": "sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-3.0.0.tgz", + "integrity": "sha512-cbjJs2A1mLYmqmyVl80uoLTJhAcfzMOyPgjwAYusWKMdLeNtzmMz9YxNl3/jRLoxSS3wkqkf0jwNdtXWtyEBaQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-3.0.0.tgz", + "integrity": "sha512-Tj7pZ4bUloNUP6PzwhN7K386tmSmEET9QtQg0TgdNOnxhZvCssHji+oZTUIuzxECRfG8rdm2PMw2WCFs6eIYkA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-3.0.0.tgz", + "integrity": "sha512-aEOHCgq5RWFbP+UDPvPot26EJHjOC+bRgse5A8V3FSShqd5E5UN4qc7zkwsvJPPAVsf73QwYcHN1/gt/rtLwQA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-3.0.0.tgz", + "integrity": "sha512-pbjk4s0fwq3Di/ANL+rCvJMKM5bzAQdE5S/6RL5NXgMExFAi6UgQMPOm5yPaIWPpr+EOXKXRonJ3FoxKf4mCJQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "3.0.31", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-3.0.31.tgz", + "integrity": "sha512-eO+zkbqrPnmsagqzrmF7IJrCoU2wTQXWVYxMPqA9Oue55kw9WEvhyuw2XQzTVTCRcYsg6KgmV3YYhLlWQJfK1A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^3.1.11", + "@smithy/smithy-client": "^3.5.1", + "@smithy/types": "^3.7.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "3.0.31", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-3.0.31.tgz", + "integrity": "sha512-0/nJfpSpbGZOs6qs42wCe2TdjobbnnD4a3YUUlvTXSQqLy4qa63luDaV04hGvqSHP7wQ7/WGehbvHkDhMZd1MQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^3.0.13", + "@smithy/credential-provider-imds": "^3.2.8", + "@smithy/node-config-provider": "^3.1.12", + "@smithy/property-provider": "^3.1.11", + "@smithy/smithy-client": "^3.5.1", + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-2.1.7.tgz", + "integrity": "sha512-tSfcqKcN/Oo2STEYCABVuKgJ76nyyr6skGl9t15hs+YaiU06sgMkN7QYjo0BbVw+KT26zok3IzbdSOksQ4YzVw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^3.1.12", + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-2.0.0.tgz", + "integrity": "sha512-c5xY+NUnFqG6d7HFh1IFfrm3mGl29lC+vF+geHv4ToiuJCBmIfzx6IeHLg+OgRdPFKDXIw6pvi+p3CsscaMcMA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-3.0.11.tgz", + "integrity": "sha512-dWpyc1e1R6VoXrwLoLDd57U1z6CwNSdkM69Ie4+6uYh2GC7Vg51Qtan7ITzczuVpqezdDTKJGJB95fFvvjU/ow==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-3.0.11.tgz", + "integrity": "sha512-hJUC6W7A3DQgaee3Hp9ZFcOxVDZzmBIRBPlUAk8/fSOEl7pE/aX7Dci0JycNOnm9Mfr0KV2XjIlUOcGWXQUdVQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^3.0.11", + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-3.3.2.tgz", + "integrity": "sha512-sInAqdiVeisUGYAv/FrXpmJ0b4WTFmciTRqzhb7wVuem9BHvhIG7tpiYHLDWrl2stOokNZpTTGqz3mzB2qFwXg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^4.1.2", + "@smithy/node-http-handler": "^3.3.2", + "@smithy/types": "^3.7.2", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-buffer-from": "^3.0.0", + "@smithy/util-hex-encoding": "^3.0.0", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/util-stream/node_modules/@smithy/fetch-http-handler": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-4.1.2.tgz", + "integrity": "sha512-R7rU7Ae3ItU4rC0c5mB2sP5mJNbCfoDc8I5XlYjIZnquyUwec7fEo78F6DA3SmgJgkU1qTMcZJuGblxZsl10ZA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^4.1.8", + "@smithy/querystring-builder": "^3.0.11", + "@smithy/types": "^3.7.2", + "@smithy/util-base64": "^3.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@smithy/util-stream/node_modules/@smithy/util-hex-encoding": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-3.0.0.tgz", + "integrity": "sha512-eFndh1WEK5YMUYvy3lPlVmYY/fZcQE1D8oSf41Id2vCeIkKJXPcYDCZD+4+xViI6b1XSd7tE+s5AmXzz5ilabQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/util-stream/node_modules/@smithy/util-utf8": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-3.0.0.tgz", + "integrity": "sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-3.0.0.tgz", + "integrity": "sha512-LqR7qYLgZTD7nWLBecUi4aqolw8Mhza9ArpNEQ881MJJIU2sE5iHCK6TdyqqzcDLy0OPe10IY4T8ctVdtynubg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.0.0.tgz", + "integrity": "sha512-rctU1VkziY84n5OXe3bPNpKR001ZCME2JCaBBFgtiM2hfKbHFudc/BkMuPab8hRbLd0j3vbnBTTZ1igBf0wgiQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.0.0", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-3.2.0.tgz", + "integrity": "sha512-PpjSboaDUE6yl+1qlg3Si57++e84oXdWGbuFUSAciXsVfEZJJJupR2Nb0QuXHiunt2vGR+1PTizOMvnUPaG2Qg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^3.1.9", + "@smithy/types": "^3.7.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@supabase/auth-js": { + "version": "2.84.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.84.0.tgz", + "integrity": "sha512-J6XKbqqg1HQPMfYkAT9BrC8anPpAiifl7qoVLsYhQq5B/dnu/lxab1pabnxtJEsvYG5rwI5HEVEGXMjoQ6Wz2Q==", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.84.0", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.84.0.tgz", + "integrity": "sha512-2oY5QBV4py/s64zMlhPEz+4RTdlwxzmfhM1k2xftD2v1DruRZKfoe7Yn9DCz1VondxX8evcvpc2udEIGzHI+VA==", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.84.0", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.84.0.tgz", + "integrity": "sha512-oplc/3jfJeVW4F0J8wqywHkjIZvOVHtqzF0RESijepDAv5Dn/LThlGW1ftysoP4+PXVIrnghAbzPHo88fNomPQ==", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.84.0", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.84.0.tgz", + "integrity": "sha512-ThqjxiCwWiZAroHnYPmnNl6tZk6jxGcG2a7Hp/3kcolPcMj89kWjUTA3cHmhdIWYsP84fHp8MAQjYWMLf7HEUg==", + "dependencies": { + "@types/phoenix": "^1.6.6", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.84.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.84.0.tgz", + "integrity": "sha512-vXvAJ1euCuhryOhC6j60dG8ky+lk0V06ubNo+CbhuoUv+sl39PyY0lc+k+qpQhTk/VcI6SiM0OECLN83+nyJ5A==", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.84.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.84.0.tgz", + "integrity": "sha512-byMqYBvb91sx2jcZsdp0qLpmd4Dioe80e4OU/UexXftCkpTcgrkoENXHf5dO8FCSai8SgNeq16BKg10QiDI6xg==", + "dependencies": { + "@supabase/auth-js": "2.84.0", + "@supabase/functions-js": "2.84.0", + "@supabase/postgrest-js": "2.84.0", + "@supabase/realtime-js": "2.84.0", + "@supabase/storage-js": "2.84.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@svgdotjs/svg.draggable.js": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@svgdotjs/svg.draggable.js/-/svg.draggable.js-3.0.4.tgz", + "integrity": "sha512-vWi/Col5Szo74HJVBgMHz23kLVljt3jvngmh0DzST45iO2ubIZ487uUAHIxSZH2tVRyiaaTL+Phaasgp4gUD2g==", + "license": "MIT", + "peerDependencies": { + "@svgdotjs/svg.js": "^3.2.4" + } + }, + "node_modules/@svgdotjs/svg.filter.js": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@svgdotjs/svg.filter.js/-/svg.filter.js-3.0.8.tgz", + "integrity": "sha512-YshF2YDaeRA2StyzAs5nUPrev7npQ38oWD0eTRwnsciSL2KrRPMoUw8BzjIXItb3+dccKGTX3IQOd2NFzmHkog==", + "license": "MIT", + "dependencies": { + "@svgdotjs/svg.js": "^3.1.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@svgdotjs/svg.js": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@svgdotjs/svg.js/-/svg.js-3.2.4.tgz", + "integrity": "sha512-BjJ/7vWNowlX3Z8O4ywT58DqbNRyYlkk6Yz/D13aB7hGmfQTvGX4Tkgtm/ApYlu9M7lCQi15xUEidqMUmdMYwg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Fuzzyma" + } + }, + "node_modules/@svgdotjs/svg.resize.js": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@svgdotjs/svg.resize.js/-/svg.resize.js-2.0.5.tgz", + "integrity": "sha512-4heRW4B1QrJeENfi7326lUPYBCevj78FJs8kfeDxn5st0IYPIRXoTtOSYvTzFWgaWWXd3YCDE6ao4fmv91RthA==", + "license": "MIT", + "engines": { + "node": ">= 14.18" + }, + "peerDependencies": { + "@svgdotjs/svg.js": "^3.2.4", + "@svgdotjs/svg.select.js": "^4.0.1" + } + }, + "node_modules/@svgdotjs/svg.select.js": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@svgdotjs/svg.select.js/-/svg.select.js-4.0.2.tgz", + "integrity": "sha512-5gWdrvoQX3keo03SCmgaBbD+kFftq0F/f2bzCbNnpkkvW6tk4rl4MakORzFuNjvXPWwB4az9GwuvVxQVnjaK2g==", + "license": "MIT", + "engines": { + "node": ">= 14.18" + }, + "peerDependencies": { + "@svgdotjs/svg.js": "^3.2.4" + } + }, + "node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", + "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", + "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-dynamic-title": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz", + "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-em-dimensions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz", + "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-react-native-svg": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz", + "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-svg-component": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz", + "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-preset": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz", + "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0", + "@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0", + "@svgr/babel-plugin-svg-dynamic-title": "8.0.0", + "@svgr/babel-plugin-svg-em-dimensions": "8.0.0", + "@svgr/babel-plugin-transform-react-native-svg": "8.1.0", + "@svgr/babel-plugin-transform-svg-component": "8.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/core": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", + "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "camelcase": "^6.2.0", + "cosmiconfig": "^8.1.3", + "snake-case": "^3.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/hast-util-to-babel-ast": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", + "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.21.3", + "entities": "^4.4.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/plugin-jsx": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz", + "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "@svgr/hast-util-to-babel-ast": "8.0.0", + "svg-parser": "^2.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@svgr/core": "*" + } + }, + "node_modules/@svgr/plugin-svgo": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-8.1.0.tgz", + "integrity": "sha512-Ywtl837OGO9pTLIN/onoWLmDQ4zFUycI1g76vuKGEz6evR/ZTJlJuz3G/fIkb6OVBJ2g0o6CGJzaEjfmEo3AHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cosmiconfig": "^8.1.3", + "deepmerge": "^4.3.1", + "svgo": "^3.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@svgr/core": "*" + } + }, + "node_modules/@svgr/webpack": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/webpack/-/webpack-8.1.0.tgz", + "integrity": "sha512-LnhVjMWyMQV9ZmeEy26maJk+8HTIbd59cH4F2MJ439k9DqejRisfFNGAPvRYlKETuh9LrImlS8aKsBgKjMA8WA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@babel/plugin-transform-react-constant-elements": "^7.21.3", + "@babel/preset-env": "^7.20.2", + "@babel/preset-react": "^7.18.6", + "@babel/preset-typescript": "^7.21.0", + "@svgr/core": "8.1.0", + "@svgr/plugin-jsx": "8.1.0", + "@svgr/plugin-svgo": "8.1.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tiptap/core": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.11.0.tgz", + "integrity": "sha512-0S3AWx6E2QqwdQqb6z0/q6zq2u9lA9oL3BLyAaITGSC9zt8OwjloS2k1zN6wLa9hp2rO0c0vDnWsTPeFaEaMdw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.11.0.tgz", + "integrity": "sha512-DBjWbgmbAAR879WAsk0+5xxgqpOTweWNnY7kEqWv3EJtLUvECXN63smiv3o4fREwwbEJqgihBu5/YugRC5z1dg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.11.0.tgz", + "integrity": "sha512-3x9BQZHYD5xFA0pCEneEMHZyIoxYo4NKcbhR4CLxGad1Xd+5g109nr1+eZ1JgvnChkeVf1eD6SaQE2A28lxR5g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.11.0.tgz", + "integrity": "sha512-21KyB7+QSQjw72Oxzs3Duw9WErAUrigFZCyoCZNjp24wP7mFVsy1jAcnRiAi8pBVwlwHBZ29IW1PeavqCSFFVA==", + "license": "MIT", + "dependencies": { + "tippy.js": "^6.3.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.11.0.tgz", + "integrity": "sha512-UALypJvO+cPSk/nC1HhkX/ImS9FxbKe2Pr0iDofakvZU1U1msumLVn2M/iq+ax1Mm9thodpvJv0hGDtFRwm7lQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.11.0.tgz", + "integrity": "sha512-2roNZxcny1bGjyZ8x6VmGTuKbwfJyTZ1hiqPc/CRTQ1u42yOhbjF4ziA5kfyUoQlzygZrWH9LR5IMYGzPQ1N3w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.11.0.tgz", + "integrity": "sha512-8of3qTOLjpveHBrrk8KVliSUVd6R2i2TNrBj0f/21HcFVAy0fP++02p6vI6UPOhwM3+p3CprGdSM48DFCu1rqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-code-block-lowlight": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-2.11.0.tgz", + "integrity": "sha512-B9UeQhcy5lQCOQWRFMruLXd1ghwUwTXCcDkYAX3yTPjC7blVHPJaocFSkq5LFsKQLV0Y0VXSpX9oNztp5lI7+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/extension-code-block": "^2.7.0", + "@tiptap/pm": "^2.7.0", + "highlight.js": "^11", + "lowlight": "^2 || ^3" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.11.0.tgz", + "integrity": "sha512-9YI0AT3mxyUZD7NHECHyV1uAjQ8KwxOS5ACwvrK1MU8TqY084LmodYNTXPKwpqbr51yvt3qZq1R7UIVu4/22Cg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.11.0.tgz", + "integrity": "sha512-p7tUtlz7KzBa+06+7W2LJ8AEiHG5chdnUIapojZ7SqQCrFRVw70R+orpkzkoictxNNHsun0A9FCUy4rz8L0+nQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.11.0.tgz", + "integrity": "sha512-dexhhUJm0x9OolbeVCa7RpxuALU3bJZC7dFpu/rPG3ZetXKhVw8hTrqUQD5w1DjXpczBzScnLgLrvnjxbG66pw==", + "license": "MIT", + "dependencies": { + "tippy.js": "^6.3.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.11.0.tgz", + "integrity": "sha512-1TVOthPkUYwTQnQwP0BzuIHVz09epOiXJQ3GqgNZsmTehwcMzz2vGCpx1JXhZ5DoMaREHNLCdraXb1n2FdhDNA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.11.0.tgz", + "integrity": "sha512-7pMgPNk2FnPT0LcWaWNNxOLK3LQnRSYFgrdBGMXec3sy+y3Lit3hM+EZhbZcHpTIQTbWWs+eskh1waRMIt0ZaQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.11.0.tgz", + "integrity": "sha512-vrYvxibsY7/Sd2wYQDZ8AfIORfFi/UHZAWI7JmaMtDkILuMLYQ+jXb7p4K2FFW/1nN7C8QqgLLFI5AfjZUusgw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-history": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.11.0.tgz", + "integrity": "sha512-eEUEDoOtS17AHVEPbGfZ+x2L5A87SiIsppWYTkpfIH/8EnVQmzu+3i1tcT9cWvHC31d9JTG7TDptVuuHr30TJw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.11.0.tgz", + "integrity": "sha512-ZbkILwmcccmwQB2VTA/dzHRMB+xoJQ8UJdafcUiaAUlQfvDgl898+AYMa2GRTZkLPvzCKjXMC9hybSyy54Lz3Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-image": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-2.11.0.tgz", + "integrity": "sha512-R+JkK5ocX35ag1c42aAw6rcb9QlLUBB0ju8A7b+8qZXN5yWKE0yO/oixYFmnZN7WSnBYtzuCVDX8cvRG+BPbgA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.11.0.tgz", + "integrity": "sha512-T+jjS0gOsvNzQXVTSArmUp/kt2R9OikPQaV1DI60bfjO0rknOgtG0tbwZmfbugzwc07RbpxOYFy3vBxMLDsksA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.11.0.tgz", + "integrity": "sha512-hvJSj0Ul4h8uxivtFtqaSy08s9G3smaW0He0ybYJ7rcJIsZ1zSrxQLGvIr/J8/yUq8VoVNspNR5cGUoyQaaw4A==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.11.0.tgz", + "integrity": "sha512-Jikcg0fccpM13a3hAFLtguMcpVg4eMWI8NnC0aUULD9rFhvWZQYQYQuoK3fO6vQrAQpNhsV4oa0dfSq1btu9kg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.11.0.tgz", + "integrity": "sha512-i6pNsDHA2QvBAebwjAuvhHKwz+bZVJ929PCIJaN8mxg0ldiAmFbAsf+rwIIFHWogMp+5xEX2RBzux20usNVZ9w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.11.0.tgz", + "integrity": "sha512-xLNC05An3SQq0bVHJtOTLa8As5r6NxDZFpK0NZqO2hTq/fAIRL/9VPeZ8E0tziXULwIvIPp+L0Taw3TvaUkRUg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-placeholder": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.11.0.tgz", + "integrity": "sha512-ee8vz51pW6H+1rEDMFg2FnBs2Tj5rUHlJ1JgD7Dcp3+89SVHGB3UILGfbNpAnHZvhmsTY3NcfPAcZZ80QfQFMQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.11.0.tgz", + "integrity": "sha512-71i2IZT58kY2ohlhyO+ucyAioNNCkNkuPkrVERc9lXhmcCKOff5y6ekDHQHO2jNjnejkVE5ibyDO3Z7RUXjh1A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.11.0.tgz", + "integrity": "sha512-LcyrP+7ZEVx3YaKzjMAeujq+4xRt4mZ3ITGph2CQ4vOKFaMI8bzSR909q18t7Qyyvek0a9VydEU1NHSaq4G5jw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text-align": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-2.11.0.tgz", + "integrity": "sha512-VRXBqO17po6ddqhoWLBa2aCX/tqHdzdKPLfjnBy1fF8hjQKbidzjMWhb4CMm31ApvJjKK/DTkM3EnyYS/XDhng==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text-style": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.11.0.tgz", + "integrity": "sha512-vuA16wMZ6J3fboL7FObwV2f5uN9Vg0WYmqU7971vxzJyaRj9VE1eeH8Kh5fq4RgwDzc13MZGvZZV4HcE1R8o8A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-2.11.0.tgz", + "integrity": "sha512-DE1piq441y1+9Aj1pvvuq1dcc5B2HZ2d1SPtO4DTMjCxrhok12biTkMxxq0q1dzA5/BouLlUW6WTPpinhmrUWA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/pm": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.11.0.tgz", + "integrity": "sha512-4RU6bpODkMY+ZshzdRFcuUc5jWlMW82LWXR6UOsHK/X/Mav41ZFS0Cyf+hQM6gxxTB09YFIICmGpEpULb+/CuA==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.2.1", + "prosemirror-collab": "^1.3.1", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.2", + "prosemirror-markdown": "^1.13.1", + "prosemirror-menu": "^1.2.4", + "prosemirror-model": "^1.23.0", + "prosemirror-schema-basic": "^1.2.3", + "prosemirror-schema-list": "^1.4.1", + "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.6.1", + "prosemirror-trailing-node": "^3.0.0", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.37.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.11.0.tgz", + "integrity": "sha512-AALzHbqNq/gerJpkbXmN2OXFmHAs2bQENH7rXbnH70bpxVdIfQVtvjK4dIb+cQQvAuTWZvhsISnTrFY2BesT3Q==", + "license": "MIT", + "dependencies": { + "@tiptap/extension-bubble-menu": "^2.11.0", + "@tiptap/extension-floating-menu": "^2.11.0", + "@types/use-sync-external-store": "^0.0.6", + "fast-deep-equal": "^3", + "use-sync-external-store": "^1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.11.0.tgz", + "integrity": "sha512-lrYmkeaAFiuUjN5nGnCowdjponrsR7eRmeTf/15/5oZsNrMN7t/fvPb014AqhG/anNasa0ism4CKZns3D+4pKQ==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^2.11.0", + "@tiptap/extension-blockquote": "^2.11.0", + "@tiptap/extension-bold": "^2.11.0", + "@tiptap/extension-bullet-list": "^2.11.0", + "@tiptap/extension-code": "^2.11.0", + "@tiptap/extension-code-block": "^2.11.0", + "@tiptap/extension-document": "^2.11.0", + "@tiptap/extension-dropcursor": "^2.11.0", + "@tiptap/extension-gapcursor": "^2.11.0", + "@tiptap/extension-hard-break": "^2.11.0", + "@tiptap/extension-heading": "^2.11.0", + "@tiptap/extension-history": "^2.11.0", + "@tiptap/extension-horizontal-rule": "^2.11.0", + "@tiptap/extension-italic": "^2.11.0", + "@tiptap/extension-list-item": "^2.11.0", + "@tiptap/extension-ordered-list": "^2.11.0", + "@tiptap/extension-paragraph": "^2.11.0", + "@tiptap/extension-strike": "^2.11.0", + "@tiptap/extension-text": "^2.11.0", + "@tiptap/extension-text-style": "^2.11.0", + "@tiptap/pm": "^2.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@trysound/sax": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", + "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/@types/autosuggest-highlight": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/@types/autosuggest-highlight/-/autosuggest-highlight-3.2.3.tgz", + "integrity": "sha512-8Mb21KWtpn6PvRQXjsKhrXIcxbSloGqNH50RntwGeJsGPW4xvNhfml+3kKulaKpO/7pgZfOmzsJz7VbepArlGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/aws-lambda": { + "version": "8.10.146", + "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.146.tgz", + "integrity": "sha512-3BaDXYTh0e6UCJYL/jwV/3+GRslSc08toAiZSmleYtkAUyV5rtvdPYxrG/88uqvTuT6sb27WE9OS90ZNTIuQ0g==", + "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/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/geojson": { + "version": "7946.0.14", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz", + "integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==", + "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/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==", + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "license": "MIT" + }, + "node_modules/@types/mapbox-gl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/mapbox-gl/-/mapbox-gl-3.1.0.tgz", + "integrity": "sha512-hI6cQDjw1bkJw7MC/eHMqq5TWUamLwsujnUUeiIX2KDRjxRNSYMjnHz07+LATz9I9XIsKumOtUz4gRYnZOJ/FA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "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/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "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/node": { + "version": "22.10.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.3.tgz", + "integrity": "sha512-DifAyw4BkrufCILvD3ucnuN8eydUfc/C1GlyrnI+LK6543w5/L3VeVgf05o3B4fqSXP1dKYLOZsKfutpxPzZrw==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/nprogress": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@types/nprogress/-/nprogress-0.2.3.tgz", + "integrity": "sha512-k7kRA033QNtC+gLc4VPlfnue58CM1iQLgn1IMAU8VPHGOj7oIHPp9UlhedEnD/Gl8evoCjwkZjlBORtZ3JByUA==", + "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/phoenix": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", + "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==" + }, + "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/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==", + "dev": 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/stylis": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.7.tgz", + "integrity": "sha512-VgDNokpBoKF+wrdvhAAfS55OMQpL6QRglwTwNC3kIgBrzZxA4WsFj+2eLfEA/uMUDzBcEhYmjSbwQakn/i3ajA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/turndown": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.5.tgz", + "integrity": "sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w==", + "dev": true, + "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/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "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/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "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.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "license": "ISC" + }, + "node_modules/@yr/monotone-cubic-spline": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz", + "integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==", + "license": "MIT" + }, + "node_modules/abs-svg-path": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz", + "integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "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, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "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, + "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/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/apexcharts": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-4.3.0.tgz", + "integrity": "sha512-PfvZQpv91T68hzry9l5zP3Gip7sQvF0nFK91uCBrswIKX7rbIdbVNS4fOks9m9yP3Ppgs6LHgU2M/mjoG4NM0A==", + "license": "MIT", + "dependencies": { + "@svgdotjs/svg.draggable.js": "^3.0.4", + "@svgdotjs/svg.filter.js": "^3.0.8", + "@svgdotjs/svg.js": "^3.2.4", + "@svgdotjs/svg.resize.js": "^2.0.2", + "@svgdotjs/svg.select.js": "^4.0.1", + "@yr/monotone-cubic-spline": "^1.0.3" + } + }, + "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/arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", + "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.4", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz", + "integrity": "sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", + "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/assign-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/autosuggest-highlight": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/autosuggest-highlight/-/autosuggest-highlight-3.3.4.tgz", + "integrity": "sha512-j6RETBD2xYnrVcoV1S5R4t3WxOlWZKyDQjkwnggDPSjF5L4jV98ZltBpvPvbkM1HtoSe5o+bNrTHyjPbieGeYA==", + "license": "MIT", + "dependencies": { + "remove-accents": "^0.4.2" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/aws-amplify": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/aws-amplify/-/aws-amplify-6.11.0.tgz", + "integrity": "sha512-CdX31Drrbk1i3QhpqCAAguf6OwPg2OHTa5g0wnxksRTjGdGKNLGaJCl65fcy3qG+LMh5Sjr55JJ6aO8wXYbvQw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-amplify/analytics": "7.0.64", + "@aws-amplify/api": "6.1.9", + "@aws-amplify/auth": "6.9.0", + "@aws-amplify/core": "6.8.0", + "@aws-amplify/datastore": "5.0.66", + "@aws-amplify/notifications": "2.0.64", + "@aws-amplify/storage": "6.7.5", + "tslib": "^2.5.0" + } + }, + "node_modules/axios": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.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-polyfill-corejs2": { + "version": "0.4.12", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.12.tgz", + "integrity": "sha512-CPWT6BwvhrTO2d8QVorhTCQw9Y43zOu7G9HigcfxvepOU6b8o3tcWad6oVgZIsZCTt42FFv97aA7ZJsbM4+8og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.22.6", + "@babel/helper-define-polyfill-provider": "^0.6.3", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz", + "integrity": "sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.2", + "core-js-compat": "^3.38.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.3.tgz", + "integrity": "sha512-LiWSbl4CRSIa5x/JAU6jZiG9eit9w6mz+yVMFwDE83LAWvt0AfGBoZ7HS/mkhrKuh2ZlzfVZYKoLjXdqw6Yt7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.3" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "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==", + "dev": true, + "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==", + "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/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/bowser": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", + "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==", + "license": "MIT" + }, + "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/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } + }, + "node_modules/browserslist": { + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz", + "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==", + "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.30001669", + "electron-to-chromium": "^1.5.41", + "node-releases": "^2.0.18", + "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": "4.9.2", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", + "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4", + "isarray": "^1.0.0" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytewise": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bytewise/-/bytewise-1.1.0.tgz", + "integrity": "sha512-rHuuseJ9iQ0na6UDhnrRVDh8YnWVlU6xM3VH6q/+yHDeUH2zIhUzP+2/h3LIrhLDBtTqzWpE3p3tP/boefskKQ==", + "license": "MIT", + "dependencies": { + "bytewise-core": "^1.2.2", + "typewise": "^1.0.3" + } + }, + "node_modules/bytewise-core": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/bytewise-core/-/bytewise-core-1.2.3.tgz", + "integrity": "sha512-nZD//kc78OOxeYtRlVk8/zXqTB4gf/nlguL1ggWA8FuchMyOxcyHR4QPQZMUmA7czC+YnaBrPUCubqAWe50DaA==", + "license": "MIT", + "dependencies": { + "typewise-core": "^1.2" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001684", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001684.tgz", + "integrity": "sha512-G1LRwLIQjBQoyq0ZJGqGIJUXzJ8irpbjHLpVRXDvBEScFJ9b17sgK6vlx0GAJFE21okD7zXl08rRRUfq6HdoEQ==", + "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/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/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/cheap-ruler": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/cheap-ruler/-/cheap-ruler-3.0.2.tgz", + "integrity": "sha512-02T332h1/HTN6cDSufLP8x4JzDs2+VC+8qZ/N0kWIVPyc2xUkWwWh3B2fJxR7raXkL4Mq7k554mfuM9ofv/vGg==", + "license": "ISC" + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "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==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "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/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/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "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/core-js-compat": { + "version": "3.39.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.39.0.tgz", + "integrity": "sha512-VgEUx3VwlExr5no0tXlBt+silBvhTryPwCXRI2Id1PN8WTKu7MreethvddqOubrYxkFdv/RnYrqlv1sFNAUelw==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/country-flag-icons": { + "version": "1.5.13", + "resolved": "https://registry.npmjs.org/country-flag-icons/-/country-flag-icons-1.5.13.tgz", + "integrity": "sha512-4JwHNqaKZ19doQoNcBjsoYA+I7NqCH/mC/6f5cBWvdKzcK5TMmzLpq3Z/syVHMHJuDGFwJ+rPpGizvrqJybJow==", + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, + "node_modules/css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", + "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.30", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/csscolorparser": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", + "integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==", + "license": "MIT" + }, + "node_modules/cssjanus": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cssjanus/-/cssjanus-2.3.0.tgz", + "integrity": "sha512-ZZXXn51SnxRxAZ6fdY7mBDPmA4OZd83q/J9Gdqz3YmE9TUq+9tZl+tdOnCi7PpNygI6PEkehj9rgifv5+W8a5A==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "dev": true, + "license": "CC0-1.0" + }, + "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/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, + "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/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/deep-diff": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-1.0.2.tgz", + "integrity": "sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==", + "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/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "dev": true, + "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-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "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/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "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/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/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "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/earcut": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", + "license": "ISC" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.67", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.67.tgz", + "integrity": "sha512-nz88NNBsD7kQSAGGJyp8hS6xSPtWwqNogA0mjtc2nUYeEf3nURK9qpV18TuBdDmEDgVWotS8Wkzf+V52dSQ/LQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/embla-carousel": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.5.1.tgz", + "integrity": "sha512-JUb5+FOHobSiWQ2EJNaueCNT/cQU9L6XWBbWmorWPQT9bkbk+fhsuLr8wWrzXKagO3oWszBO7MSx+GfaRk4E6A==", + "license": "MIT" + }, + "node_modules/embla-carousel-auto-height": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/embla-carousel-auto-height/-/embla-carousel-auto-height-8.5.1.tgz", + "integrity": "sha512-pH0LlCEX6D2uNf0zuEHPL14YCnlJK+xIlhjcWNy53TG+9qDPgUUwBLBoAdbWro+8/MzqzVf+kHDgsy25jkzu4g==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.5.1" + } + }, + "node_modules/embla-carousel-auto-scroll": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/embla-carousel-auto-scroll/-/embla-carousel-auto-scroll-8.5.1.tgz", + "integrity": "sha512-fbkZ5+kPHJnJ0aVhRClodnBuaWp8RvV/AW4ex+YhXtvkTld9ApAxmyKQsZzycQc24uz15kzyRjSTNfEvzXPYuQ==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.5.1" + } + }, + "node_modules/embla-carousel-autoplay": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/embla-carousel-autoplay/-/embla-carousel-autoplay-8.5.1.tgz", + "integrity": "sha512-FnZklFpePfp8wbj177UwVaGFehgs+ASVcJvYLWTtHuYKURynCc3IdDn2qrn0E5Qpa3g9yeGwCS4p8QkrZmO8xg==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.5.1" + } + }, + "node_modules/embla-carousel-fade": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/embla-carousel-fade/-/embla-carousel-fade-8.5.1.tgz", + "integrity": "sha512-n7vRe2tsTW0vc0Xxtk3APoxhUSXIGh/lGRKYtBJS/SWDeXf9E3qVUst4MfHhwXaHlfu5PLqG3xIEDAr2gwbbNA==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.5.1" + } + }, + "node_modules/embla-carousel-react": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.5.1.tgz", + "integrity": "sha512-z9Y0K84BJvhChXgqn2CFYbfEi6AwEr+FFVVKm/MqbTQ2zIzO1VQri6w67LcfpVF0AjbhwVMywDZqY4alYkjW5w==", + "license": "MIT", + "dependencies": { + "embla-carousel": "8.5.1", + "embla-carousel-reactive-utils": "8.5.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.5.1.tgz", + "integrity": "sha512-n7VSoGIiiDIc4MfXF3ZRTO59KDp820QDuyBDGlt5/65+lumPHxX2JLz0EZ23hZ4eg4vZGUXwMkYv02fw2JVo/A==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.5.1" + } + }, + "node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", + "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "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/es-abstract": { + "version": "1.23.7", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.7.tgz", + "integrity": "sha512-OygGC8kIcDhXX+6yAZRGLqwi2CmEXCbLQixeGUgYeR+Qwlppqmo7DIDr8XibtEBZp+fJcoYpoatp5qwLMEdcqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-set-tostringtag": "^2.0.3", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.2.6", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-regex": "^1.2.1", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.0", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.3", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "regexp.prototype.flags": "^1.5.3", + "safe-array-concat": "^1.1.3", + "safe-regex-test": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.18" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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-iterator-helpers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", + "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.6", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.4", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", + "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.0" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-toolkit": { + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.31.0.tgz", + "integrity": "sha512-vwS0lv/tzjM2/t4aZZRAgN9I9TP0MSkWuvt6By+hEXfG/uLs8yg2S1/ayRXH/x3pinbLgVJYT+eppueg3cM6tg==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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.39.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", + "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.1", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "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.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.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-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.7.0.tgz", + "integrity": "sha512-Vrwyi8HHxY97K5ebydMtffsWAn1SCR9eol49eCd5fJS4O1WV7PaAjbcjmbfJJSMz/t4Mal212Uz/fQZrOB8mow==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.3.7", + "enhanced-resolve": "^5.15.0", + "fast-glob": "^3.3.2", + "get-tsconfig": "^4.7.5", + "is-bun-module": "^1.0.2", + "is-glob": "^4.0.3", + "stable-hash": "^0.0.4" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts/projects/eslint-import-resolver-ts" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz", + "integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", + "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.8", + "array.prototype.findlastindex": "^1.2.5", + "array.prototype.flat": "^1.3.2", + "array.prototype.flatmap": "^1.3.2", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.0", + "hasown": "^2.0.2", + "is-core-module": "^2.15.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.0", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.8", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-perfectionist": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-4.4.0.tgz", + "integrity": "sha512-B78pWxCsA2sClourpWEmWziCcjEsAEyxsNV5G6cxxteu/NI0/2en9XZUONf5e/+O+dgoLZsEPHQEhnIxJcnUvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "^8.18.1", + "@typescript-eslint/utils": "^8.18.1", + "natural-orderby": "^5.0.0" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "eslint": ">=8.0.0" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.3.tgz", + "integrity": "sha512-DomWuTQPFYZwF/7c9W2fkKkStqZmBd3uugfqBYLdkZ3Hii23WzZuOLUskGxB8qkSKqftxEeGL1TB2kMhrce0jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.8", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "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/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-unused-imports": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.1.4.tgz", + "integrity": "sha512-YptD6IzQjDardkl0POxnnRBhU1OEePMV0nd6siHaRBbd+lyh6NAhFEobiznKU7kTsSsDeSD62Pe7kAM1b7dAZQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", + "eslint": "^9.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "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.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "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, + "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/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/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "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/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "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.4" + }, + "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==", + "dev": true, + "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 + }, + "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-xml-parser": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.1.tgz", + "integrity": "sha512-y655CeyUQ+jj7KBbYMc4FG01V8ZQqjN+gDYGJ50RtfsUB8iG9AmwmwoAgeKLJdmueKKMrH1RJ7yXHTSoczdv5w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + }, + { + "type": "paypal", + "url": "https://paypal.me/naturalintelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, + "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-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "license": "MIT", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/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": "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/firebase": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/firebase/-/firebase-11.1.0.tgz", + "integrity": "sha512-3OoNW3vBXmBLYJvcwbPCwfluptbDVp2zZYjrfHPVFAXfPgmyy/LWjidt+Sw2WNvRelsG0v++WN2Wor6J3OwDRg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/analytics": "0.10.10", + "@firebase/analytics-compat": "0.2.16", + "@firebase/app": "0.10.17", + "@firebase/app-check": "0.8.10", + "@firebase/app-check-compat": "0.3.17", + "@firebase/app-compat": "0.2.47", + "@firebase/app-types": "0.9.3", + "@firebase/auth": "1.8.1", + "@firebase/auth-compat": "0.5.16", + "@firebase/data-connect": "0.1.3", + "@firebase/database": "1.0.10", + "@firebase/database-compat": "2.0.1", + "@firebase/firestore": "4.7.5", + "@firebase/firestore-compat": "0.3.40", + "@firebase/functions": "0.12.0", + "@firebase/functions-compat": "0.3.17", + "@firebase/installations": "0.6.11", + "@firebase/installations-compat": "0.2.11", + "@firebase/messaging": "0.12.15", + "@firebase/messaging-compat": "0.2.15", + "@firebase/performance": "0.6.11", + "@firebase/performance-compat": "0.2.11", + "@firebase/remote-config": "0.4.11", + "@firebase/remote-config-compat": "0.2.11", + "@firebase/storage": "0.13.4", + "@firebase/storage-compat": "0.3.14", + "@firebase/util": "1.10.2", + "@firebase/vertexai": "1.0.2" + } + }, + "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/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.1.3" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/framer-motion": { + "version": "11.15.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.15.0.tgz", + "integrity": "sha512-MLk8IvZntxOMg7lDBLw2qgTHHv664bYoYmnFTmE0Gm/FW67aOJk0WM3ctMcG+Xhcv+vh5uyyXwxvxhSeJzSe+w==", + "license": "MIT", + "dependencies": { + "motion-dom": "^11.14.3", + "motion-utils": "^11.14.3", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "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/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/geojson-vt": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz", + "integrity": "sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==", + "license": "ISC" + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.6.tgz", + "integrity": "sha512-qxsEs+9A+u85HhllWJJFicJfPDhRmjzoYdl64aMWW9yRIJmSyxdn8IEkuIM530/7T+lv0TIHd8L6Q/ra0tEoeA==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "dunder-proto": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "function-bind": "^1.1.2", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.8.1.tgz", + "integrity": "sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/get-value": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", + "integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gl-matrix": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.3.tgz", + "integrity": "sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA==", + "license": "MIT" + }, + "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==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "15.14.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.14.0.tgz", + "integrity": "sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/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/graphql": { + "version": "15.8.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-15.8.0.tgz", + "integrity": "sha512-5gghUc24tP9HRznNpV2+FIoq3xKkj5dTQqf4v0CpdPbFVwFkWoxOM+o+2OC9ZSvjEMTjfmG9QT+gcvggTwW1zw==", + "license": "MIT", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/grid-index": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz", + "integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==", + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "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-from-parse5": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.2.tgz", + "integrity": "sha512-SfMzfdAi/zAoZ1KkFEyyeXBn7u/ShQrfd675ZEE9M3qj+PMFX05xubzRyF76CCSJu8au9jgVxDV1+okFvgZU4A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^6.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "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-to-parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz", + "integrity": "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^6.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.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/hastscript": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.0.tgz", + "integrity": "sha512-jzaLBGavEDKHrc5EfFImKN7nZKKBdSLIdGvCwDZ9TfzbF2ffXiov8CKE445L2Z1Ek2t/m4SKQ2j6Ipv7NyUolw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^6.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/highlight.js": { + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.0.tgz", + "integrity": "sha512-6ErL7JlGu2CNFHyRQEuDogOyGPNiqcuWdt4iSSFUPyferNTGlNTPFqeV36Y/XwA4V/TJ8l0sxp6FTnxud/mf8g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "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/hoist-non-react-statics/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/hsl-to-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/hsl-to-hex/-/hsl-to-hex-1.0.0.tgz", + "integrity": "sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==", + "license": "MIT", + "dependencies": { + "hsl-to-rgb-for-reals": "^1.1.0" + } + }, + "node_modules/hsl-to-rgb-for-reals": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/hsl-to-rgb-for-reals/-/hsl-to-rgb-for-reals-1.1.1.tgz", + "integrity": "sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==", + "license": "ISC" + }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.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/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz", + "integrity": "sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==", + "license": "MIT" + }, + "node_modules/hyphen": { + "version": "1.10.6", + "resolved": "https://registry.npmjs.org/hyphen/-/hyphen-1.10.6.tgz", + "integrity": "sha512-fXHXcGFTXOvZTSkPJuGOQf5Lv5T/R2itiiCVPg9LxAje5D00O0pP83yJShFq5V89Ly//Gt6acj7z8pbBr34stw==", + "license": "ISC" + }, + "node_modules/i18next": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.0.tgz", + "integrity": "sha512-ArJJTS1lV6lgKH7yEf4EpgNZ7+THl7bsGxxougPYiXRTJ/Fe1j08/TBpV9QsXCIYVfdE/HWG/xLezJ5DOlfBOA==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.0.2.tgz", + "integrity": "sha512-shBvPmnIyZeD2VU5jVGIOWP7u9qNG3Lj7mpaiPFpbJ3LVfHZJvVzKR4v1Cb91wAOFpNw442N+LGPzHOHsten2g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-resources-to-backend": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/i18next-resources-to-backend/-/i18next-resources-to-backend-1.2.1.tgz", + "integrity": "sha512-okHbVA+HZ7n1/76MsfhPqDou0fptl2dAlhRDu2ideXloRRduzHsqDOznJBef+R3DFZnbvWoBW+KxJ7fnFjd6Yw==", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/idb": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/idb/-/idb-5.0.6.tgz", + "integrity": "sha512-/PFvOWPzRcEPmlDt5jEvzVZVs0wyd/EvGvkDIcbBpGuMMLQKrTPG0TxvE2UJtgZtCQCmOtM2QD7yQJBVEjKGOw==", + "license": "ISC" + }, + "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/immer": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.6.tgz", + "integrity": "sha512-G95ivKpy+EvVAnAab4fVa4YGYn24J1SpEktnJX7JJ45Bd7xqME/SCplFzYFmTbrkwZbQ4xJK1xMTUYBkN6pWsQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "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/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/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/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/input-format": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/input-format/-/input-format-0.3.11.tgz", + "integrity": "sha512-q24+iW10ZMb7KIRDlVUl3GvFcadf1ttE/QA2waINkDMdjsPXStQSOvdTyHwO8p+4Mq433ILQJZRL8YKtPjNk4g==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": "^18.1.0", + "react-dom": "^18.1.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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-async-function": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", + "integrity": "sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.1.tgz", + "integrity": "sha512-l9qO6eFlUETHtuihLcYOaLKByJ1f+N4kthcU9YjHy3N+B3hWv0y/2Nd0mu/7lTFnRQHTrSdXF50HQ3bl5fEnng==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-1.3.0.tgz", + "integrity": "sha512-DgXeu5UWI0IsMQundYb5UAOzm6G2eVnarJ0byP6Tm55iZNKceD59LNPA2L4VvsScTtHcw0yEkVwSf7PC+QoLSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.6.3" + } + }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", + "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.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-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-tostringtag": "^1.0.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==", + "dev": true, + "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-lite": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-lite/-/is-lite-1.2.1.tgz", + "integrity": "sha512-pgF+L5bxC+10hLBgf6R2P4ZZUBOQIIacbdo8YvuCP8/JvsWxG7aZ9p10DYuLtifFci4l3VITphhMlMV4Y+urPw==", + "license": "MIT" + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "dev": true, + "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-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "license": "MIT" + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.0.tgz", + "integrity": "sha512-SXM8Nwyys6nT5WP6pltOwKytLV7FqQ4UiibxVmW+EIosHcmCqkkjViTb5SNssDlkCiEYRP1/pdWUKVvZBmsR2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/iterator.prototype": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.4.tgz", + "integrity": "sha512-x4WH0BWmrMmg4oHHl+duwubhrvczGlyuGAZu3nvrf0UXOfPu8IhZObFEr7DE/iv01YgVZrsOiRcqw2srkKEDIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "reflect.getprototypeof": "^1.0.8", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jay-peg": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/jay-peg/-/jay-peg-1.1.1.tgz", + "integrity": "sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==", + "license": "MIT", + "dependencies": { + "restructure": "^3.0.0" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "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==", + "dev": true, + "license": "MIT" + }, + "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/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 + }, + "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-pretty-compact": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-3.0.0.tgz", + "integrity": "sha512-Rc2suX5meI0S3bfdZuA7JMFBGkJ875ApfVyq2WHELjBiiG22My/l7/8zPpH/CfFVQHuVLd8NLR0nv6vi0BYYKA==", + "license": "MIT" + }, + "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/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "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/libphonenumber-js": { + "version": "1.11.16", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.11.16.tgz", + "integrity": "sha512-Noyazmt0yOvnG0OeRY45Cd1ur8G7Z0HWVkuCuKe+yysGNxPQwBAODBQQ40j0AIagi9ZWurfmmZWNlpg4h4W+XQ==", + "license": "MIT" + }, + "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/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/linkifyjs": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", + "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==" + }, + "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/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.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==", + "license": "MIT" + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "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==", + "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==", + "dev": true, + "license": "MIT" + }, + "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/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lowlight": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "highlight.js": "~11.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/mapbox-gl": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.4.0.tgz", + "integrity": "sha512-QWgL28zg/zuIOHeF8DXPvHy1UHTgO5p4Oy6ifCAHwI9/hoI9/Fruya0yI4HkDtX1OgzTLO6SHO13A781BGJvyw==", + "license": "SEE LICENSE IN LICENSE.txt", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/mapbox-gl-supported": "^3.0.0", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^2.0.6", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "cheap-ruler": "^3.0.1", + "csscolorparser": "~1.0.3", + "earcut": "^2.2.4", + "fflate": "^0.8.1", + "geojson-vt": "^3.2.1", + "gl-matrix": "^3.4.3", + "grid-index": "^1.1.0", + "kdbush": "^4.0.1", + "lodash.clonedeep": "^4.5.0", + "murmurhash-js": "^1.0.0", + "pbf": "^3.2.1", + "potpack": "^2.0.0", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "serialize-to-js": "^3.1.2", + "supercluster": "^8.0.0", + "tiny-lru": "^11.2.6", + "tinyqueue": "^2.0.3", + "tweakpane": "^4.0.3", + "vt-pbf": "^3.1.3" + } + }, + "node_modules/markdown-it": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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-find-and-replace": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.1.tgz", + "integrity": "sha512-SG21kZHGC3XRTSUhtofZkBzZTJNM5ecCi0SK2IMKmSXR8vO3peL+kb1O0z7Zl83jKtutG4k5Wv/W7V3/YHvzPA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.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/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.0.0.tgz", + "integrity": "sha512-dgQEX5Amaq+DuUqf26jJqSK9qgixgd6rYDHAv4aTBuA92cTknZlKpPfa86Z/s8Dj8xsAQpFfBmPUHWJBWqS4Bw==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.0.0.tgz", + "integrity": "sha512-5jOT2boTSVkMnQ7LTrd6n/18kqwjmuYqo7JUPe+tRCY6O7dAuTFMtTPauYYrMPpox9hlN0uOx/FL8XvEfG9/mQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.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-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.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-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@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-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/mdn-data": { + "version": "2.0.30", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, + "node_modules/media-engine": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/media-engine/-/media-engine-1.0.3.tgz", + "integrity": "sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==", + "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==", + "dev": true, + "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-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.0.tgz", + "integrity": "sha512-Ub2ncQv+fwD70/l4ou27b4YzfNaCJOvyX4HxXU15m7mpYY+rjuWzsLIPZHJL253Z643RpbcP1oeIJlQ/SKW67g==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimal-shared": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minimal-shared/-/minimal-shared-1.0.5.tgz", + "integrity": "sha512-eHcXB5OcIILIdpe1jP8gd57VDL19kb4nVJBMhD1DRoFhi/bZcebqTu7hxnUQvLrWNJLWAaTsqXpFR/+vQeidsg==", + "license": "MIT", + "dependencies": { + "es-toolkit": "^1.31.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } + }, + "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/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/motion-dom": { + "version": "11.14.3", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.14.3.tgz", + "integrity": "sha512-lW+D2wBy5vxLJi6aCP0xyxTxlTfiu+b+zcpVbGVFUxotwThqhdpPRSmX8xztAgtZMPMeU0WGVn/k1w4I+TbPqA==", + "license": "MIT" + }, + "node_modules/motion-utils": { + "version": "11.14.3", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.14.3.tgz", + "integrity": "sha512-Xg+8xnqIJTpr0L/cidfTTBFkvRw26ZtGGuIhA94J9PQ2p4mEa06Xx7QVYZH0BP+EpMSaDlu+q0I0mmvwADPsaQ==", + "license": "MIT" + }, + "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/mui-one-time-password-input": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mui-one-time-password-input/-/mui-one-time-password-input-3.0.2.tgz", + "integrity": "sha512-JK/DlPOZ9tr7B7CmXXzTaLJtRN2IVfF1iGQpDvloXwPa3LoxT8WWIbUgKoSyF7fO2Lvuo9QRJMFoHgomK0CcIQ==", + "license": "MIT", + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@mui/material": "^6.2.0", + "@types/react": "^18.3.12", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "peerDependencies": { + "@emotion/react": "^11.13.0", + "@emotion/styled": "^11.13.0", + "@mui/material": "^6.0.0", + "@types/react": "^18.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, + "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/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/natural-orderby": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-5.0.0.tgz", + "integrity": "sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/next": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.33.tgz", + "integrity": "sha512-GiKHLsD00t4ACm1p00VgrI0rUFAC9cRDGReKyERlM57aeEZkOQGcZTpIbsGn0b562FTPJWmYfKwplfO9EaT6ng==", + "dependencies": { + "@next/env": "14.2.33", + "@swc/helpers": "0.5.5", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "graceful-fs": "^4.2.11", + "postcss": "8.4.31", + "styled-jsx": "5.1.1" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=18.17.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "14.2.33", + "@next/swc-darwin-x64": "14.2.33", + "@next/swc-linux-arm64-gnu": "14.2.33", + "@next/swc-linux-arm64-musl": "14.2.33", + "@next/swc-linux-x64-gnu": "14.2.33", + "@next/swc-linux-x64-musl": "14.2.33", + "@next/swc-win32-arm64-msvc": "14.2.33", + "@next/swc-win32-ia32-msvc": "14.2.33", + "@next/swc-win32-x64-msvc": "14.2.33" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/@swc/helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", + "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "tslib": "^2.4.0" + } + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/node-releases": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-svg-path": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz", + "integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==", + "license": "MIT", + "dependencies": { + "svg-arc-to-cubic-bezier": "^3.0.0" + } + }, + "node_modules/nprogress": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz", + "integrity": "sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==", + "license": "MIT" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", + "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "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/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, + "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/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/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "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-entities": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.1.tgz", + "integrity": "sha512-SWzvYcSJh4d/SGLIOQfZ/CoNv6BTlI6YEQ7Nj82oDVnRpwe/Z/F1EMx42x3JAOwGBlCjeCH0BRJQbQ/opHL17w==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities": "^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-svg-path": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz", + "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz", + "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==", + "license": "MIT", + "dependencies": { + "entities": "^4.5.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "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/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "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-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/pbf": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", + "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "ieee754": "^1.1.12", + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "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": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/popper.js": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", + "integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==", + "deprecated": "You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", + "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "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.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "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/potpack": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.0.0.tgz", + "integrity": "sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw==", + "license": "ISC" + }, + "node_modules/preact": { + "version": "10.12.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", + "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "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/prettier": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", + "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "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/prop-types/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/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/prosemirror-changeset": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.2.1.tgz", + "integrity": "sha512-J7msc6wbxB4ekDFj+n9gTW/jav/p53kdlivvuppHsrZXCaQdVgRghoZbSS3kwrRyAstRVQ4/+u5k7YfLgkkQvQ==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-collab": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", + "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.6.2.tgz", + "integrity": "sha512-0nDHH++qcf/BuPLYvmqZTUUsPJUCPBUXt0J1ErTcDIS369CTp773itzLGIgIXG4LJXOlwYCr44+Mh4ii6MP1QA==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.1.tgz", + "integrity": "sha512-M30WJdJZLyXHi3N8vxN6Zh5O8ZBbQCz0gURTfPmTIBNQ5pxrdU7A58QkNqfa98YEjSAL1HUyyU34f6Pm5xBSGw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.3.2.tgz", + "integrity": "sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.4.1.tgz", + "integrity": "sha512-2JZD8z2JviJrboD9cPuX/Sv/1ChFng+xh2tChQ2X4bB2HeK+rra/bmJ3xGntCcjhOqIzSDG6Id7e8RJ9QPXLEQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.4.0.tgz", + "integrity": "sha512-6ygpPRuTJ2lcOXs9JkefieMst63wVJBgHZGl5QOytN7oSZs3Co/BYbc3Yx9zm9H37Bxw8kVzCnDsihsVsL4yEg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.2.tgz", + "integrity": "sha512-EAlXoksqC6Vbocqc0GtzCruZEzYgrn+iiGnNjsJsH4mrnIGex4qbLdWWNza3AW5W36ZRrlBID0eM6bdKH4OStQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-markdown": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.1.tgz", + "integrity": "sha512-Sl+oMfMtAjWtlcZoj/5L/Q39MpEnVZ840Xo330WJWUvgyhNmLBLN7MsHn07s53nG/KImevWHSE6fEj4q/GihHw==", + "license": "MIT", + "dependencies": { + "@types/markdown-it": "^14.0.0", + "markdown-it": "^14.0.0", + "prosemirror-model": "^1.20.0" + } + }, + "node_modules/prosemirror-menu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.4.tgz", + "integrity": "sha512-S/bXlc0ODQup6aiBbWVsX/eM+xJgCTAfMq/nLqaO5ID/am4wS0tTCIkzwytmao7ypEtjj39i7YbJjAgO20mIqA==", + "license": "MIT", + "dependencies": { + "crelt": "^1.0.0", + "prosemirror-commands": "^1.0.0", + "prosemirror-history": "^1.0.0", + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.24.0.tgz", + "integrity": "sha512-Ft7epNnycoQSM+2ObF35SBbBX+5WY39v8amVlrtlAcpglhlHs2tCTnWl7RX5tbp/PsMKcRcWV9cXPuoBWq0AIQ==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-basic": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.3.tgz", + "integrity": "sha512-h+H0OQwZVqMon1PNn0AG9cTfx513zgIG2DY00eJ00Yvgb3UD+GQ/VlWW5rcaxacpCGT1Yx8nuhwXk4+QbXUfJA==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.19.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.4.1.tgz", + "integrity": "sha512-jbDyaP/6AFfDfu70VzySsD75Om2t3sXTOdl5+31Wlxlg62td1haUpty/ybajSfJ1pkGadlOfwQq9kgW5IMo1Rg==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.3.tgz", + "integrity": "sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.6.1.tgz", + "integrity": "sha512-p8WRJNA96jaNQjhJolmbxTzd6M4huRE5xQ8OxjvMhQUP0Nzpo4zz6TztEiwk6aoqGBhz9lxRWR1yRZLlpQN98w==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.1.2", + "prosemirror-model": "^1.8.1", + "prosemirror-state": "^1.3.1", + "prosemirror-transform": "^1.2.1", + "prosemirror-view": "^1.13.3" + } + }, + "node_modules/prosemirror-trailing-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", + "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", + "license": "MIT", + "dependencies": { + "@remirror/core-constants": "3.0.0", + "escape-string-regexp": "^4.0.0" + }, + "peerDependencies": { + "prosemirror-model": "^1.22.1", + "prosemirror-state": "^1.4.2", + "prosemirror-view": "^1.33.8" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.2.tgz", + "integrity": "sha512-2iUq0wv2iRoJO/zj5mv8uDUriOHWzXRnOTVgCzSXnktS/2iQRa3UUQwVlkBlYZFtygw6Nh1+X4mGqoYBINn5KQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.37.0", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.37.0.tgz", + "integrity": "sha512-z2nkKI1sJzyi7T47Ji/ewBPuIma1RNvQCCYVdV+MqWBV7o4Sa1n94UJCJJ1aQRF/xRkFfyqLGlGFWitIcCOtbg==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.20.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, + "node_modules/protobufjs": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz", + "integrity": "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", + "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "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, + "engines": { + "node": ">=6" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.3" + } + }, + "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==", + "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/quickselect": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", + "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", + "license": "ISC" + }, + "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-apexcharts": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/react-apexcharts/-/react-apexcharts-1.7.0.tgz", + "integrity": "sha512-03oScKJyNLRf0Oe+ihJxFZliBQM9vW3UWwomVn4YVRTN1jsIR58dLWt0v1sb8RwJVHDMbeHiKQueM0KGpn7nOA==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "apexcharts": ">=4.0.0", + "react": ">=0.13" + } + }, + "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-dom/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/react-dropzone": { + "version": "14.3.5", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.5.tgz", + "integrity": "sha512-9nDUaEEpqZLOz5v5SUcFA0CjM4vq8YbqO0WRls+EYT7+DvxUdzDPKNCPLqGfj3YL9MsniCLCD4RFA6M95V6KMQ==", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, + "node_modules/react-floater": { + "version": "0.7.9", + "resolved": "https://registry.npmjs.org/react-floater/-/react-floater-0.7.9.tgz", + "integrity": "sha512-NXqyp9o8FAXOATOEo0ZpyaQ2KPb4cmPMXGWkx377QtJkIXHlHRAGer7ai0r0C1kG5gf+KJ6Gy+gdNIiosvSicg==", + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "is-lite": "^0.8.2", + "popper.js": "^1.16.0", + "prop-types": "^15.8.1", + "tree-changes": "^0.9.1" + }, + "peerDependencies": { + "react": "15 - 18", + "react-dom": "15 - 18" + } + }, + "node_modules/react-floater/node_modules/@gilbarbara/deep-equal": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@gilbarbara/deep-equal/-/deep-equal-0.1.2.tgz", + "integrity": "sha512-jk+qzItoEb0D0xSSmrKDDzf9sheQj/BAPxlgNxgmOaA3mxpUa6ndJLYGZKsJnIVEQSD8zcTbyILz7I0HcnBCRA==", + "license": "MIT" + }, + "node_modules/react-floater/node_modules/is-lite": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/is-lite/-/is-lite-0.8.2.tgz", + "integrity": "sha512-JZfH47qTsslwaAsqbMI3Q6HNNjUuq6Cmzzww50TdP5Esb6e1y2sK2UAaZZuzfAzpoI2AkxoPQapZdlDuP6Vlsw==", + "license": "MIT" + }, + "node_modules/react-floater/node_modules/tree-changes": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/tree-changes/-/tree-changes-0.9.3.tgz", + "integrity": "sha512-vvvS+O6kEeGRzMglTKbc19ltLWNtmNt1cpBoSYLj/iEcPVvpJasemKOlxBrmZaCtDJoF+4bwv3m01UKYi8mukQ==", + "license": "MIT", + "dependencies": { + "@gilbarbara/deep-equal": "^0.1.1", + "is-lite": "^0.8.2" + } + }, + "node_modules/react-hook-form": { + "version": "7.54.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.2.tgz", + "integrity": "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-i18next": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.4.0.tgz", + "integrity": "sha512-Py6UkX3zV08RTvL6ZANRoBh9sL/ne6rQq79XlkHEdd82cZr2H9usbWpUNVadJntIZP2pu3M2rL1CN+5rQYfYFw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.0", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.2.3", + "react": ">= 16.8.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/react-innertext": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/react-innertext/-/react-innertext-1.1.5.tgz", + "integrity": "sha512-PWAqdqhxhHIv80dT9znP2KvS+hfkbRovFp4zFYHFFlOoQLRiawIic81gKb3U1wEyJZgMwgs3JoLtwryASRWP3Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": ">=0.0.0 <=99", + "react": ">=0.0.0 <=99" + } + }, + "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/react-joyride": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/react-joyride/-/react-joyride-2.9.3.tgz", + "integrity": "sha512-1+Mg34XK5zaqJ63eeBhqdbk7dlGCFp36FXwsEvgpjqrtyywX2C6h9vr3jgxP0bGHCw8Ilsp/nRDzNVq6HJ3rNw==", + "license": "MIT", + "dependencies": { + "@gilbarbara/deep-equal": "^0.3.1", + "deep-diff": "^1.0.2", + "deepmerge": "^4.3.1", + "is-lite": "^1.2.1", + "react-floater": "^0.7.9", + "react-innertext": "^1.1.5", + "react-is": "^16.13.1", + "scroll": "^3.0.1", + "scrollparent": "^2.1.0", + "tree-changes": "^0.11.2", + "type-fest": "^4.27.0" + }, + "peerDependencies": { + "react": "15 - 18", + "react-dom": "15 - 18" + } + }, + "node_modules/react-joyride/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-map-gl": { + "version": "7.1.8", + "resolved": "https://registry.npmjs.org/react-map-gl/-/react-map-gl-7.1.8.tgz", + "integrity": "sha512-zwF16XMOdOKH4py5ehS1bgQIChqW8UN3b1bXps+JnADbYLSbOoUPQ3tNw0EZ2OTBWArR5aaQlhlEqg4lE47T8A==", + "license": "MIT", + "dependencies": { + "@maplibre/maplibre-gl-style-spec": "^19.2.1", + "@types/mapbox-gl": ">=1.0.0" + }, + "peerDependencies": { + "mapbox-gl": ">=1.13.0", + "maplibre-gl": ">=1.13.0", + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + }, + "peerDependenciesMeta": { + "mapbox-gl": { + "optional": true + }, + "maplibre-gl": { + "optional": true + } + } + }, + "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-organizational-chart": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/react-organizational-chart/-/react-organizational-chart-2.2.1.tgz", + "integrity": "sha512-JORmpLeYzCVtztdqCHsnKL8H3WiLRPHjohgh/PxQoszLuaQ+l3F8YefKSfpcBPZJhHwy3SlqjFjPC28a3Hh3QQ==", + "license": "MIT", + "dependencies": { + "@emotion/css": "^11.7.1" + }, + "engines": { + "node": ">=16", + "npm": ">=8" + }, + "peerDependencies": { + "react": ">= 16.12.0", + "react-dom": ">= 16.12.0" + } + }, + "node_modules/react-phone-number-input": { + "version": "3.4.10", + "resolved": "https://registry.npmjs.org/react-phone-number-input/-/react-phone-number-input-3.4.10.tgz", + "integrity": "sha512-9kwr6R1PwvW9YGant3VqEY+CMBjqSr8v3wjEIXSPxDw7KLvHMDDFDglEpCjDy20/54PFY6RTKgN73WCvvFByCA==", + "license": "MIT", + "dependencies": { + "classnames": "^2.5.1", + "country-flag-icons": "^1.5.11", + "input-format": "^0.3.10", + "libphonenumber-js": "^1.11.16", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "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/reflect.getprototypeof": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.9.tgz", + "integrity": "sha512-r0Ay04Snci87djAsI4U+WNRcSw5S4pOH7qFjd/veA5gC7TbqESR3tcj28ia95L/fYUDw11JKP7uqUKUAfVvV5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "dunder-proto": "^1.0.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz", + "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-transform": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", + "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.4" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz", + "integrity": "sha512-vqlC04+RQoFalODCbCumG2xIOvapzVMHwsyIGM/SIE8fRhFFsXeH8/QQ+s0T0kDAhKc4k30s73/0ydkHQz6HlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexpu-core": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz", + "integrity": "sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.0", + "regjsgen": "^0.8.0", + "regjsparser": "^0.12.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.12.0.tgz", + "integrity": "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.0.2" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/rehype-highlight": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-7.0.1.tgz", + "integrity": "sha512-dB/vVGFsbm7xPglqnYbg0ABg6rAuIWKycTvuXaOO27SgLoOFNoTlniTBtAxp3n5ZyMioW1a3KwiNqgjkb6Skjg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-text": "^4.0.0", + "lowlight": "^3.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.0.tgz", + "integrity": "sha512-U92vJgBPkbw4Zfu/IiW2oTZLSL3Zpv+uI7My2eq8JxKgqraFdU8YUGicEJCEgSbeaG+QDFqIcwwfMTOEelPxuA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "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/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remove-accents": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.4.tgz", + "integrity": "sha512-EpFcOa/ISetVHEXqu+VwI96KZBmq+a8LJnGkaeFw45epGlxIZz5dhEEnNZMsQXgORu3qaMoLX4qJCzOik6ytAg==", + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-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==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, + "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==", + "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": { + "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/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-array-concat/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "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==", + "dev": true, + "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/scheduler": { + "version": "0.25.0-rc-603e6108-20241029", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0-rc-603e6108-20241029.tgz", + "integrity": "sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==", + "license": "MIT" + }, + "node_modules/scroll": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/scroll/-/scroll-3.0.1.tgz", + "integrity": "sha512-pz7y517OVls1maEzlirKO5nPYle9AXsFzTMNJrRGmT951mzpIBy7sNHOg5o/0MQd/NqliCiWnAi0kZneMPFLcg==", + "license": "MIT" + }, + "node_modules/scrollparent": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz", + "integrity": "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA==", + "license": "ISC" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/serialize-to-js": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/serialize-to-js/-/serialize-to-js-3.1.2.tgz", + "integrity": "sha512-owllqNuDDEimQat7EPG0tH7JjO090xKNzUtYz6X+Sk2BXDnOCilDdNLwjWeFywG9xkJul1ULvtUQa9O4pUaY0w==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "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==", + "dev": true, + "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/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-value": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", + "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.3", + "split-string": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "license": "MIT" + }, + "node_modules/simplebar-core": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/simplebar-core/-/simplebar-core-1.3.0.tgz", + "integrity": "sha512-LpWl3w0caz0bl322E68qsrRPpIn+rWBGAaEJ0lUJA7Xpr2sw92AkIhg6VWj988IefLXYh50ILatfAnbNoCFrlA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + } + }, + "node_modules/simplebar-react": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/simplebar-react/-/simplebar-react-3.3.0.tgz", + "integrity": "sha512-sxzy+xRuU41He4tT4QLGYutchtOuye/xxVeq7xhyOiwMiHNK1ZpvbOTyy+7P0i7gfpXLGTJ8Bep8+4Mhdgtz/g==", + "license": "MIT", + "dependencies": { + "simplebar-core": "^1.3.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/snake-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", + "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/sonner": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.1.tgz", + "integrity": "sha512-b6LHBfH32SoVasRFECrdY8p8s7hXPDn3OHUFbZZbiB1ctLS9Gdh6rpX2dVrpQA0kiL5jcRzDDldwwLkSKk3+QQ==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/sort-asc": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/sort-asc/-/sort-asc-0.2.0.tgz", + "integrity": "sha512-umMGhjPeHAI6YjABoSTrFp2zaBtXBej1a0yKkuMUyjjqu6FJsTF+JYwCswWDg+zJfk/5npWUUbd33HH/WLzpaA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sort-desc": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/sort-desc/-/sort-desc-0.2.0.tgz", + "integrity": "sha512-NqZqyvL4VPW+RAxxXnB8gvE1kyikh8+pR+T+CXLksVRN9eiQqkQlPwqWYU0mF9Jm7UnctShlxLyAt1CaBOTL1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sort-object": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sort-object/-/sort-object-3.0.3.tgz", + "integrity": "sha512-nK7WOY8jik6zaG9CRwZTaD5O7ETWDLZYMM12pqY8htll+7dYeqGfEUPcUBHOpSJg2vJOrvFIY2Dl5cX2ih1hAQ==", + "license": "MIT", + "dependencies": { + "bytewise": "^1.1.0", + "get-value": "^2.0.2", + "is-extendable": "^0.1.1", + "sort-asc": "^0.2.0", + "sort-desc": "^0.2.0", + "union-value": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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/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/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/split-string": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", + "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-string/node_modules/extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", + "license": "MIT", + "dependencies": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-string/node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stable-hash": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.4.tgz", + "integrity": "sha512-LjdcbuBeLcdETCrPn9i8AYAZ1eCtu4ECAWtP7UleOiZ9LzVxRzzUZEoZ8zB24nhkQnDWyET0I+3sWokSDS3E7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "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/string-width/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.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "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, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strnum": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", + "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==", + "license": "MIT" + }, + "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/styled-jsx": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", + "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/stylis": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.4.tgz", + "integrity": "sha512-osIBl6BGUmSfDkyH2mB7EFvCJntXDrLhKjHTRj/rK6xLH0yuPrHULDRQzKokSOD4VoorhtKpfcfW1GAntu8now==", + "license": "MIT" + }, + "node_modules/stylis-plugin-rtl": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/stylis-plugin-rtl/-/stylis-plugin-rtl-2.1.1.tgz", + "integrity": "sha512-q6xIkri6fBufIO/sV55md2CbgS5c6gg9EhSVATtHHCdOnbN/jcI0u3lYhNVeuI65c4lQPo67g8xmq5jrREvzlg==", + "license": "MIT", + "dependencies": { + "cssjanus": "^2.0.1" + }, + "peerDependencies": { + "stylis": "4.x" + } + }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, + "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==", + "dev": true, + "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/svg-arc-to-cubic-bezier": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz", + "integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==", + "license": "ISC" + }, + "node_modules/svg-parser": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", + "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/svgo": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz", + "integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@trysound/sax": "0.2.0", + "commander": "^7.2.0", + "css-select": "^5.1.0", + "css-tree": "^2.3.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.0.0" + }, + "bin": { + "svgo": "bin/svgo" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "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/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "node_modules/tiny-lru": { + "version": "11.2.11", + "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.2.11.tgz", + "integrity": "sha512-27BIW0dIWTYYoWNnqSmoNMKe5WIbkXsc0xaCQHd3/3xT2XMuMJrzHdrO9QBFR14emBz1Bu0dOAs2sCBBrvgPQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/tinyqueue": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz", + "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==", + "license": "ISC" + }, + "node_modules/tippy.js": { + "version": "6.3.7", + "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", + "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==", + "license": "MIT", + "dependencies": { + "@popperjs/core": "^2.9.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==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tree-changes": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/tree-changes/-/tree-changes-0.11.2.tgz", + "integrity": "sha512-4gXlUthrl+RabZw6lLvcCDl6KfJOCmrC16BC5CRdut1EAH509Omgg0BfKLY+ViRlzrvYOTWR0FMS2SQTwzumrw==", + "license": "MIT", + "dependencies": { + "@gilbarbara/deep-equal": "^0.3.1", + "is-lite": "^1.2.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/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "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/turndown": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.0.tgz", + "integrity": "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A==", + "license": "MIT", + "dependencies": { + "@mixmark-io/domino": "^2.2.0" + } + }, + "node_modules/tweakpane": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/tweakpane/-/tweakpane-4.0.5.tgz", + "integrity": "sha512-rxEXdSI+ArlG1RyO6FghC4ZUX8JkEfz8F3v1JuteXSV0pEtHJzyo07fcDG+NsJfN5L39kSbCYbB9cBGHyuI/tQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/cocopon" + } + }, + "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": "4.29.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.29.0.tgz", + "integrity": "sha512-RPYt6dKyemXJe7I6oNstcH24myUGSReicxcHTvCLgzm4e0n8y05dGvcGB15/SoPRBmhlMthWQ9pvKyL81ko8nQ==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/typescript-eslint": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.19.0.tgz", + "integrity": "sha512-Ni8sUkVWYK4KAcTtPjQ/UTiRk6jcsuDhPpxULapUDi8A/l8TSBk+t1GtJA1RsCzIJg0q6+J7bf35AwQigENWRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.19.0", + "@typescript-eslint/parser": "8.19.0", + "@typescript-eslint/utils": "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/typewise": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typewise/-/typewise-1.0.3.tgz", + "integrity": "sha512-aXofE06xGhaQSPzt8hlTY+/YWQhm9P0jYUp1f2XtmW/3Bk0qzXcyFWAtPoo2uTGQj1ZwbDuSyuxicq+aDo8lCQ==", + "license": "MIT", + "dependencies": { + "typewise-core": "^1.2.0" + } + }, + "node_modules/typewise-core": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/typewise-core/-/typewise-core-1.2.0.tgz", + "integrity": "sha512-2SCC/WLzj2SbUwzFOzqMCkz5amXLlxtJqDKTICqg30x+2DZxcfZN2MvQZmGfXWKNWaKK9pBPsvkcwv8bF/gxKg==", + "license": "MIT" + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, + "node_modules/ulid": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ulid/-/ulid-2.3.0.tgz", + "integrity": "sha512-keqHubrlpvT6G2wH0OEfSW4mquYRcbe/J8NMmveoQOjUqmo+hXtO+ORCpWhdbZ7k72UtY61BL7haGxW6enBnjw==", + "license": "MIT", + "bin": { + "ulid": "bin/cli.js" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", + "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", + "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, + "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/union-value": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", + "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", + "license": "MIT", + "dependencies": { + "arr-union": "^3.1.0", + "get-value": "^2.0.6", + "is-extendable": "^0.1.1", + "set-value": "^2.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "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/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, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "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/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/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "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-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.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-compatible-readable-stream": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz", + "integrity": "sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vt-pbf": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", + "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "0.1.0", + "@mapbox/vector-tile": "^1.3.1", + "pbf": "^3.2.1" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.18.tgz", + "integrity": "sha512-qEcY+KJYlWyLH9vNbsr6/5j59AXk5ni5aakf8ldzBvGde6Iz4sxZGkJyWSAueTG7QhOvNRYb1lDdFmL5Td0QKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/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/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "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/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "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/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "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==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yet-another-react-lightbox": { + "version": "3.21.7", + "resolved": "https://registry.npmjs.org/yet-another-react-lightbox/-/yet-another-react-lightbox-3.21.7.tgz", + "integrity": "sha512-dcdokNuCIl92f0Vl+uzeKULnQhztIGpoZFUMvtVNUPmtwsQWpqWufeieDPeg9JtFyVCcbj4vYw3V00DS0QNoWA==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "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/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "license": "MIT" + }, + "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/zustand": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.3.tgz", + "integrity": "sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, + "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" + } + } + } +} diff --git a/app/frontend/package.json b/app/frontend/package.json new file mode 100644 index 00000000..a5927566 --- /dev/null +++ b/app/frontend/package.json @@ -0,0 +1,143 @@ +{ + "name": "@minimal-kit/next-ts", + "author": "Minimals", + "version": "6.3.0", + "description": "Next & TypeScript", + "private": true, + "scripts": { + "dev": "next dev -p 8082", + "start": "next start -p 8082", + "build": "next build", + "lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"", + "lint:fix": "eslint --fix \"src/**/*.{js,jsx,ts,tsx}\"", + "lint:print": "npx eslint --print-config eslint.config.mjs > eslint-show-config.json", + "fm:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx}\"", + "fm:fix": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"", + "fix:all": "npm run lint:fix && npm run fm:fix", + "clean": "rm -rf node_modules .next out dist build", + "re:dev": "yarn clean && yarn install && yarn dev", + "re:build": "yarn clean && yarn install && yarn build", + "re:build-npm": "npm run clean && npm install && npm run build", + "tsc:dev": "yarn dev & yarn tsc:watch", + "tsc:watch": "tsc --noEmit --watch", + "tsc:print": "npx tsc --showConfig" + }, + "engines": { + "node": "20.x" + }, + "packageManager": "yarn@1.22.22", + "dependencies": { + "@auth0/auth0-react": "^2.2.4", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@emotion/cache": "^11.14.0", + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@fontsource-variable/dm-sans": "^5.1.1", + "@fontsource-variable/inter": "^5.1.1", + "@fontsource-variable/nunito-sans": "^5.1.1", + "@fontsource-variable/public-sans": "^5.1.2", + "@fontsource/barlow": "^5.1.1", + "@fullcalendar/core": "^6.1.15", + "@fullcalendar/daygrid": "^6.1.15", + "@fullcalendar/interaction": "^6.1.15", + "@fullcalendar/list": "^6.1.15", + "@fullcalendar/react": "^6.1.15", + "@fullcalendar/timegrid": "^6.1.15", + "@fullcalendar/timeline": "^6.1.15", + "@hookform/resolvers": "^3.9.1", + "@iconify/react": "^5.1.0", + "@mui/lab": "^6.0.0-beta.21", + "@mui/material": "^6.3.0", + "@mui/material-nextjs": "^6.3.0", + "@mui/x-data-grid": "^7.23.5", + "@mui/x-date-pickers": "^7.23.3", + "@mui/x-tree-view": "^7.23.2", + "@react-pdf/renderer": "^4.1.6", + "@supabase/supabase-js": "^2.47.10", + "@tiptap/core": "^2.11.0", + "@tiptap/extension-code-block": "^2.11.0", + "@tiptap/extension-code-block-lowlight": "^2.11.0", + "@tiptap/extension-image": "^2.11.0", + "@tiptap/extension-link": "^2.11.0", + "@tiptap/extension-placeholder": "^2.11.0", + "@tiptap/extension-text-align": "^2.11.0", + "@tiptap/extension-underline": "^2.11.0", + "@tiptap/pm": "^2.11.0", + "@tiptap/react": "^2.11.0", + "@tiptap/starter-kit": "^2.11.0", + "apexcharts": "^4.3.0", + "autosuggest-highlight": "^3.3.4", + "aws-amplify": "^6.11.0", + "axios": "^1.7.9", + "dayjs": "^1.11.13", + "embla-carousel": "^8.5.1", + "embla-carousel-auto-height": "^8.5.1", + "embla-carousel-auto-scroll": "^8.5.1", + "embla-carousel-autoplay": "^8.5.1", + "embla-carousel-fade": "^8.5.1", + "embla-carousel-react": "^8.5.1", + "es-toolkit": "^1.31.0", + "firebase": "^11.1.0", + "framer-motion": "^11.15.0", + "i18next": "^24.2.0", + "i18next-browser-languagedetector": "^8.0.2", + "i18next-resources-to-backend": "^1.2.1", + "lowlight": "^3.3.0", + "mapbox-gl": "^3.4.0", + "minimal-shared": "^1.0.5", + "mui-one-time-password-input": "^3.0.2", + "next": "^14.2.22", + "nprogress": "^0.2.0", + "react": "^18.3.1", + "react-apexcharts": "^1.7.0", + "react-dom": "^18.3.1", + "react-dropzone": "^14.3.5", + "react-hook-form": "^7.54.2", + "react-i18next": "^15.4.0", + "react-joyride": "^2.9.3", + "react-map-gl": "^7.1.8", + "react-markdown": "^9.0.1", + "react-organizational-chart": "^2.2.1", + "react-phone-number-input": "^3.4.10", + "rehype-highlight": "^7.0.1", + "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.0", + "simplebar-react": "^3.3.0", + "sonner": "^1.7.1", + "stylis": "^4.3.4", + "stylis-plugin-rtl": "^2.1.1", + "swr": "^2.3.0", + "turndown": "^7.2.0", + "yet-another-react-lightbox": "^3.21.7", + "zod": "^3.24.1", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "@svgr/webpack": "^8.1.0", + "@types/autosuggest-highlight": "^3.2.3", + "@types/node": "^22.10.3", + "@types/nprogress": "^0.2.3", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@types/stylis": "^4.2.7", + "@types/turndown": "^5.0.5", + "@typescript-eslint/parser": "^8.19.0", + "eslint": "^9.17.0", + "eslint-import-resolver-typescript": "^3.7.0", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-perfectionist": "^4.4.0", + "eslint-plugin-react": "^7.37.3", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-unused-imports": "^4.1.4", + "globals": "^15.14.0", + "prettier": "^3.4.2", + "typescript": "^5.7.2", + "typescript-eslint": "^8.19.0" + }, + "peerDependencies": { + "@types/mapbox-gl": "3.1.0" + } +} diff --git a/app/frontend/prettier.config.mjs b/app/frontend/prettier.config.mjs new file mode 100644 index 00000000..a3b4e4ef --- /dev/null +++ b/app/frontend/prettier.config.mjs @@ -0,0 +1,15 @@ +/** + * @type {import("prettier").Config} + * Need to restart IDE when changing configuration + * Open the command palette (Ctrl + Shift + P) and execute the command > Reload Window. + */ +const config = { + semi: true, + tabWidth: 2, + endOfLine: 'lf', + printWidth: 100, + singleQuote: true, + trailingComma: 'es5', +}; + +export default config; diff --git a/app/frontend/public/assets/background/background-3-blur.webp b/app/frontend/public/assets/background/background-3-blur.webp new file mode 100644 index 00000000..106fdca9 Binary files /dev/null and b/app/frontend/public/assets/background/background-3-blur.webp differ diff --git a/app/frontend/public/assets/background/background-3.webp b/app/frontend/public/assets/background/background-3.webp new file mode 100644 index 00000000..f6d41685 Binary files /dev/null and b/app/frontend/public/assets/background/background-3.webp differ diff --git a/app/frontend/public/assets/background/background-4.jpg b/app/frontend/public/assets/background/background-4.jpg new file mode 100644 index 00000000..7a26aad7 Binary files /dev/null and b/app/frontend/public/assets/background/background-4.jpg differ diff --git a/app/frontend/public/assets/background/background-5.webp b/app/frontend/public/assets/background/background-5.webp new file mode 100644 index 00000000..6bee4775 Binary files /dev/null and b/app/frontend/public/assets/background/background-5.webp differ diff --git a/app/frontend/public/assets/background/background-6.webp b/app/frontend/public/assets/background/background-6.webp new file mode 100644 index 00000000..c81c5028 Binary files /dev/null and b/app/frontend/public/assets/background/background-6.webp differ diff --git a/app/frontend/public/assets/background/background-7.webp b/app/frontend/public/assets/background/background-7.webp new file mode 100644 index 00000000..d755765e Binary files /dev/null and b/app/frontend/public/assets/background/background-7.webp differ diff --git a/app/frontend/public/assets/background/overlay.svg b/app/frontend/public/assets/background/overlay.svg new file mode 100644 index 00000000..2729ddc7 --- /dev/null +++ b/app/frontend/public/assets/background/overlay.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/background/shape-circle-1.svg b/app/frontend/public/assets/background/shape-circle-1.svg new file mode 100644 index 00000000..43294293 --- /dev/null +++ b/app/frontend/public/assets/background/shape-circle-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/background/shape-circle-3.svg b/app/frontend/public/assets/background/shape-circle-3.svg new file mode 100644 index 00000000..1c1035b2 --- /dev/null +++ b/app/frontend/public/assets/background/shape-circle-3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/background/shape-square.svg b/app/frontend/public/assets/background/shape-square.svg new file mode 100644 index 00000000..b62e4303 --- /dev/null +++ b/app/frontend/public/assets/background/shape-square.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/apps/ic-app-1.webp b/app/frontend/public/assets/icons/apps/ic-app-1.webp new file mode 100644 index 00000000..23a4b9f9 Binary files /dev/null and b/app/frontend/public/assets/icons/apps/ic-app-1.webp differ diff --git a/app/frontend/public/assets/icons/apps/ic-app-2.webp b/app/frontend/public/assets/icons/apps/ic-app-2.webp new file mode 100644 index 00000000..88558443 Binary files /dev/null and b/app/frontend/public/assets/icons/apps/ic-app-2.webp differ diff --git a/app/frontend/public/assets/icons/apps/ic-app-3.webp b/app/frontend/public/assets/icons/apps/ic-app-3.webp new file mode 100644 index 00000000..81f85163 Binary files /dev/null and b/app/frontend/public/assets/icons/apps/ic-app-3.webp differ diff --git a/app/frontend/public/assets/icons/apps/ic-app-4.webp b/app/frontend/public/assets/icons/apps/ic-app-4.webp new file mode 100644 index 00000000..08a17794 Binary files /dev/null and b/app/frontend/public/assets/icons/apps/ic-app-4.webp differ diff --git a/app/frontend/public/assets/icons/apps/ic-app-5.webp b/app/frontend/public/assets/icons/apps/ic-app-5.webp new file mode 100644 index 00000000..84b64d41 Binary files /dev/null and b/app/frontend/public/assets/icons/apps/ic-app-5.webp differ diff --git a/app/frontend/public/assets/icons/apps/ic-app-drive.svg b/app/frontend/public/assets/icons/apps/ic-app-drive.svg new file mode 100644 index 00000000..36a42e4e --- /dev/null +++ b/app/frontend/public/assets/icons/apps/ic-app-drive.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/frontend/public/assets/icons/apps/ic-app-dropbox.svg b/app/frontend/public/assets/icons/apps/ic-app-dropbox.svg new file mode 100644 index 00000000..9e090ae0 --- /dev/null +++ b/app/frontend/public/assets/icons/apps/ic-app-dropbox.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/frontend/public/assets/icons/apps/ic-app-onedrive.svg b/app/frontend/public/assets/icons/apps/ic-app-onedrive.svg new file mode 100644 index 00000000..572c776f --- /dev/null +++ b/app/frontend/public/assets/icons/apps/ic-app-onedrive.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/frontend/public/assets/icons/brands/ic-brand-amazon.svg b/app/frontend/public/assets/icons/brands/ic-brand-amazon.svg new file mode 100644 index 00000000..2e036eca --- /dev/null +++ b/app/frontend/public/assets/icons/brands/ic-brand-amazon.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/frontend/public/assets/icons/brands/ic-brand-hbo.svg b/app/frontend/public/assets/icons/brands/ic-brand-hbo.svg new file mode 100644 index 00000000..4c17c937 --- /dev/null +++ b/app/frontend/public/assets/icons/brands/ic-brand-hbo.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/frontend/public/assets/icons/brands/ic-brand-ibm.svg b/app/frontend/public/assets/icons/brands/ic-brand-ibm.svg new file mode 100644 index 00000000..31efd84f --- /dev/null +++ b/app/frontend/public/assets/icons/brands/ic-brand-ibm.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/frontend/public/assets/icons/brands/ic-brand-lya.svg b/app/frontend/public/assets/icons/brands/ic-brand-lya.svg new file mode 100644 index 00000000..483aea20 --- /dev/null +++ b/app/frontend/public/assets/icons/brands/ic-brand-lya.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/frontend/public/assets/icons/brands/ic-brand-netflix.svg b/app/frontend/public/assets/icons/brands/ic-brand-netflix.svg new file mode 100644 index 00000000..b78771ce --- /dev/null +++ b/app/frontend/public/assets/icons/brands/ic-brand-netflix.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/frontend/public/assets/icons/brands/ic-brand-spotify.svg b/app/frontend/public/assets/icons/brands/ic-brand-spotify.svg new file mode 100644 index 00000000..fd18db24 --- /dev/null +++ b/app/frontend/public/assets/icons/brands/ic-brand-spotify.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/frontend/public/assets/icons/components/ic-accordion.svg b/app/frontend/public/assets/icons/components/ic-accordion.svg new file mode 100644 index 00000000..28aceb4d --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-accordion.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-alert.svg b/app/frontend/public/assets/icons/components/ic-alert.svg new file mode 100644 index 00000000..dd9293dc --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-alert.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-autocomplete.svg b/app/frontend/public/assets/icons/components/ic-autocomplete.svg new file mode 100644 index 00000000..bdca6b87 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-autocomplete.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-avatar.svg b/app/frontend/public/assets/icons/components/ic-avatar.svg new file mode 100644 index 00000000..58383b21 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-avatar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-badge.svg b/app/frontend/public/assets/icons/components/ic-badge.svg new file mode 100644 index 00000000..facf08b4 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-badge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-breadcrumbs.svg b/app/frontend/public/assets/icons/components/ic-breadcrumbs.svg new file mode 100644 index 00000000..169b414f --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-breadcrumbs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-buttons.svg b/app/frontend/public/assets/icons/components/ic-buttons.svg new file mode 100644 index 00000000..d89e1204 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-buttons.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-checkbox.svg b/app/frontend/public/assets/icons/components/ic-checkbox.svg new file mode 100644 index 00000000..77101ec4 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-checkbox.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-chip.svg b/app/frontend/public/assets/icons/components/ic-chip.svg new file mode 100644 index 00000000..d8f7666c --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-chip.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-colors.svg b/app/frontend/public/assets/icons/components/ic-colors.svg new file mode 100644 index 00000000..07f08bbb --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-colors.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-data-grid.svg b/app/frontend/public/assets/icons/components/ic-data-grid.svg new file mode 100644 index 00000000..f33bad86 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-data-grid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-date-pickers.svg b/app/frontend/public/assets/icons/components/ic-date-pickers.svg new file mode 100644 index 00000000..d9c0e333 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-date-pickers.svg @@ -0,0 +1 @@ + diff --git a/app/frontend/public/assets/icons/components/ic-dialog.svg b/app/frontend/public/assets/icons/components/ic-dialog.svg new file mode 100644 index 00000000..a5f18ecb --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-dialog.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-drawer.svg b/app/frontend/public/assets/icons/components/ic-drawer.svg new file mode 100644 index 00000000..67146ff5 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-drawer.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/frontend/public/assets/icons/components/ic-extra-animate.svg b/app/frontend/public/assets/icons/components/ic-extra-animate.svg new file mode 100644 index 00000000..146b15cb --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-extra-animate.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-extra-carousel.svg b/app/frontend/public/assets/icons/components/ic-extra-carousel.svg new file mode 100644 index 00000000..78fa295d --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-extra-carousel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-extra-chart.svg b/app/frontend/public/assets/icons/components/ic-extra-chart.svg new file mode 100644 index 00000000..cd5d678e --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-extra-chart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-extra-dnd.svg b/app/frontend/public/assets/icons/components/ic-extra-dnd.svg new file mode 100644 index 00000000..35d60370 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-extra-dnd.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-extra-editor.svg b/app/frontend/public/assets/icons/components/ic-extra-editor.svg new file mode 100644 index 00000000..355c02fd --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-extra-editor.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-extra-form-validation.svg b/app/frontend/public/assets/icons/components/ic-extra-form-validation.svg new file mode 100644 index 00000000..dd37949e --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-extra-form-validation.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-extra-form-wizard.svg b/app/frontend/public/assets/icons/components/ic-extra-form-wizard.svg new file mode 100644 index 00000000..781e26ce --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-extra-form-wizard.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/components/ic-extra-image.svg b/app/frontend/public/assets/icons/components/ic-extra-image.svg new file mode 100644 index 00000000..5145bc00 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-extra-image.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-extra-label.svg b/app/frontend/public/assets/icons/components/ic-extra-label.svg new file mode 100644 index 00000000..37c0c122 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-extra-label.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-extra-layout.svg b/app/frontend/public/assets/icons/components/ic-extra-layout.svg new file mode 100644 index 00000000..2ccbf887 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-extra-layout.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/components/ic-extra-lightbox.svg b/app/frontend/public/assets/icons/components/ic-extra-lightbox.svg new file mode 100644 index 00000000..b0b61406 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-extra-lightbox.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-extra-map.svg b/app/frontend/public/assets/icons/components/ic-extra-map.svg new file mode 100644 index 00000000..684acf09 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-extra-map.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-extra-markdown.svg b/app/frontend/public/assets/icons/components/ic-extra-markdown.svg new file mode 100644 index 00000000..2e95c947 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-extra-markdown.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-extra-mega-menu.svg b/app/frontend/public/assets/icons/components/ic-extra-mega-menu.svg new file mode 100644 index 00000000..fa83f3f5 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-extra-mega-menu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-extra-multi-language.svg b/app/frontend/public/assets/icons/components/ic-extra-multi-language.svg new file mode 100644 index 00000000..fa34fed1 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-extra-multi-language.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-extra-navigation-bar.svg b/app/frontend/public/assets/icons/components/ic-extra-navigation-bar.svg new file mode 100644 index 00000000..be234fde --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-extra-navigation-bar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-extra-organization-chart.svg b/app/frontend/public/assets/icons/components/ic-extra-organization-chart.svg new file mode 100644 index 00000000..0b6f30a6 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-extra-organization-chart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-extra-scroll-progress.svg b/app/frontend/public/assets/icons/components/ic-extra-scroll-progress.svg new file mode 100644 index 00000000..0d3ca1d4 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-extra-scroll-progress.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-extra-scroll.svg b/app/frontend/public/assets/icons/components/ic-extra-scroll.svg new file mode 100644 index 00000000..a88d886a --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-extra-scroll.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-extra-snackbar.svg b/app/frontend/public/assets/icons/components/ic-extra-snackbar.svg new file mode 100644 index 00000000..269bba89 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-extra-snackbar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-extra-upload.svg b/app/frontend/public/assets/icons/components/ic-extra-upload.svg new file mode 100644 index 00000000..105cc34e --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-extra-upload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-extra-utilities.svg b/app/frontend/public/assets/icons/components/ic-extra-utilities.svg new file mode 100644 index 00000000..0bd84d27 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-extra-utilities.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-extra-walktour.svg b/app/frontend/public/assets/icons/components/ic-extra-walktour.svg new file mode 100644 index 00000000..05973eef --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-extra-walktour.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-grid.svg b/app/frontend/public/assets/icons/components/ic-grid.svg new file mode 100644 index 00000000..9c7a38bb --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-grid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-icons.svg b/app/frontend/public/assets/icons/components/ic-icons.svg new file mode 100644 index 00000000..7c70ed35 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-icons.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-list.svg b/app/frontend/public/assets/icons/components/ic-list.svg new file mode 100644 index 00000000..01854b46 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-list.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-menu.svg b/app/frontend/public/assets/icons/components/ic-menu.svg new file mode 100644 index 00000000..df9f13ff --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-menu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-pagination.svg b/app/frontend/public/assets/icons/components/ic-pagination.svg new file mode 100644 index 00000000..0176fa99 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-pagination.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-popover.svg b/app/frontend/public/assets/icons/components/ic-popover.svg new file mode 100644 index 00000000..8e8bda11 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-popover.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-progress.svg b/app/frontend/public/assets/icons/components/ic-progress.svg new file mode 100644 index 00000000..e736c5a3 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-progress.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-radio-button.svg b/app/frontend/public/assets/icons/components/ic-radio-button.svg new file mode 100644 index 00000000..155cce3d --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-radio-button.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-rating.svg b/app/frontend/public/assets/icons/components/ic-rating.svg new file mode 100644 index 00000000..7891a50d --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-rating.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-shadows.svg b/app/frontend/public/assets/icons/components/ic-shadows.svg new file mode 100644 index 00000000..8fb65c83 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-shadows.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-slider.svg b/app/frontend/public/assets/icons/components/ic-slider.svg new file mode 100644 index 00000000..6ded8915 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-slider.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-stepper.svg b/app/frontend/public/assets/icons/components/ic-stepper.svg new file mode 100644 index 00000000..d26e70d5 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-stepper.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-switch.svg b/app/frontend/public/assets/icons/components/ic-switch.svg new file mode 100644 index 00000000..acc9fd1e --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-switch.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-table.svg b/app/frontend/public/assets/icons/components/ic-table.svg new file mode 100644 index 00000000..b11b4ce9 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-table.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-tabs.svg b/app/frontend/public/assets/icons/components/ic-tabs.svg new file mode 100644 index 00000000..1eb82085 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-tabs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-textfield.svg b/app/frontend/public/assets/icons/components/ic-textfield.svg new file mode 100644 index 00000000..9f6522c4 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-textfield.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-timeline.svg b/app/frontend/public/assets/icons/components/ic-timeline.svg new file mode 100644 index 00000000..e0a459fc --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-timeline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-tooltip.svg b/app/frontend/public/assets/icons/components/ic-tooltip.svg new file mode 100644 index 00000000..2210cfa2 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-tooltip.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-transfer-list.svg b/app/frontend/public/assets/icons/components/ic-transfer-list.svg new file mode 100644 index 00000000..0164eb8d --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-transfer-list.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-tree-view.svg b/app/frontend/public/assets/icons/components/ic-tree-view.svg new file mode 100644 index 00000000..3e3b3595 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-tree-view.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/components/ic-typography.svg b/app/frontend/public/assets/icons/components/ic-typography.svg new file mode 100644 index 00000000..db2cc1d0 --- /dev/null +++ b/app/frontend/public/assets/icons/components/ic-typography.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/assets/icons/courses/ic-courses-certificates.svg b/app/frontend/public/assets/icons/courses/ic-courses-certificates.svg new file mode 100644 index 00000000..d34389a6 --- /dev/null +++ b/app/frontend/public/assets/icons/courses/ic-courses-certificates.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/courses/ic-courses-completed.svg b/app/frontend/public/assets/icons/courses/ic-courses-completed.svg new file mode 100644 index 00000000..f44c8416 --- /dev/null +++ b/app/frontend/public/assets/icons/courses/ic-courses-completed.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/courses/ic-courses-progress.svg b/app/frontend/public/assets/icons/courses/ic-courses-progress.svg new file mode 100644 index 00000000..12610732 --- /dev/null +++ b/app/frontend/public/assets/icons/courses/ic-courses-progress.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/empty/ic-cart.svg b/app/frontend/public/assets/icons/empty/ic-cart.svg new file mode 100644 index 00000000..8bac4b41 --- /dev/null +++ b/app/frontend/public/assets/icons/empty/ic-cart.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/empty/ic-chat-active.svg b/app/frontend/public/assets/icons/empty/ic-chat-active.svg new file mode 100644 index 00000000..a0abc909 --- /dev/null +++ b/app/frontend/public/assets/icons/empty/ic-chat-active.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/empty/ic-chat-empty.svg b/app/frontend/public/assets/icons/empty/ic-chat-empty.svg new file mode 100644 index 00000000..69dbe92c --- /dev/null +++ b/app/frontend/public/assets/icons/empty/ic-chat-empty.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/empty/ic-content.svg b/app/frontend/public/assets/icons/empty/ic-content.svg new file mode 100644 index 00000000..8a420edd --- /dev/null +++ b/app/frontend/public/assets/icons/empty/ic-content.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/empty/ic-email-disabled.svg b/app/frontend/public/assets/icons/empty/ic-email-disabled.svg new file mode 100644 index 00000000..09bc1758 --- /dev/null +++ b/app/frontend/public/assets/icons/empty/ic-email-disabled.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/empty/ic-email-selected.svg b/app/frontend/public/assets/icons/empty/ic-email-selected.svg new file mode 100644 index 00000000..eb26670a --- /dev/null +++ b/app/frontend/public/assets/icons/empty/ic-email-selected.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/empty/ic-folder-empty.svg b/app/frontend/public/assets/icons/empty/ic-folder-empty.svg new file mode 100644 index 00000000..23960f9f --- /dev/null +++ b/app/frontend/public/assets/icons/empty/ic-folder-empty.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/empty/ic-mail.svg b/app/frontend/public/assets/icons/empty/ic-mail.svg new file mode 100644 index 00000000..26806003 --- /dev/null +++ b/app/frontend/public/assets/icons/empty/ic-mail.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/faqs/ic-account.svg b/app/frontend/public/assets/icons/faqs/ic-account.svg new file mode 100644 index 00000000..510501c6 --- /dev/null +++ b/app/frontend/public/assets/icons/faqs/ic-account.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/faqs/ic-assurances.svg b/app/frontend/public/assets/icons/faqs/ic-assurances.svg new file mode 100644 index 00000000..9402854f --- /dev/null +++ b/app/frontend/public/assets/icons/faqs/ic-assurances.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/faqs/ic-delivery.svg b/app/frontend/public/assets/icons/faqs/ic-delivery.svg new file mode 100644 index 00000000..67f06153 --- /dev/null +++ b/app/frontend/public/assets/icons/faqs/ic-delivery.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/faqs/ic-package.svg b/app/frontend/public/assets/icons/faqs/ic-package.svg new file mode 100644 index 00000000..403217c9 --- /dev/null +++ b/app/frontend/public/assets/icons/faqs/ic-package.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/faqs/ic-payment.svg b/app/frontend/public/assets/icons/faqs/ic-payment.svg new file mode 100644 index 00000000..dd332b4a --- /dev/null +++ b/app/frontend/public/assets/icons/faqs/ic-payment.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/faqs/ic-refund.svg b/app/frontend/public/assets/icons/faqs/ic-refund.svg new file mode 100644 index 00000000..1c46cc54 --- /dev/null +++ b/app/frontend/public/assets/icons/faqs/ic-refund.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/files/ic-ai.svg b/app/frontend/public/assets/icons/files/ic-ai.svg new file mode 100644 index 00000000..4d8098a8 --- /dev/null +++ b/app/frontend/public/assets/icons/files/ic-ai.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/files/ic-audio.svg b/app/frontend/public/assets/icons/files/ic-audio.svg new file mode 100644 index 00000000..329f232a --- /dev/null +++ b/app/frontend/public/assets/icons/files/ic-audio.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/files/ic-document.svg b/app/frontend/public/assets/icons/files/ic-document.svg new file mode 100644 index 00000000..5a53ca0c --- /dev/null +++ b/app/frontend/public/assets/icons/files/ic-document.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/files/ic-excel.svg b/app/frontend/public/assets/icons/files/ic-excel.svg new file mode 100644 index 00000000..cb80eb2b --- /dev/null +++ b/app/frontend/public/assets/icons/files/ic-excel.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/files/ic-file.svg b/app/frontend/public/assets/icons/files/ic-file.svg new file mode 100644 index 00000000..f5295c2e --- /dev/null +++ b/app/frontend/public/assets/icons/files/ic-file.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/files/ic-folder.svg b/app/frontend/public/assets/icons/files/ic-folder.svg new file mode 100644 index 00000000..01f6671e --- /dev/null +++ b/app/frontend/public/assets/icons/files/ic-folder.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/frontend/public/assets/icons/files/ic-img.svg b/app/frontend/public/assets/icons/files/ic-img.svg new file mode 100644 index 00000000..a95194a9 --- /dev/null +++ b/app/frontend/public/assets/icons/files/ic-img.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/files/ic-js.svg b/app/frontend/public/assets/icons/files/ic-js.svg new file mode 100644 index 00000000..266b12ec --- /dev/null +++ b/app/frontend/public/assets/icons/files/ic-js.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/files/ic-pdf.svg b/app/frontend/public/assets/icons/files/ic-pdf.svg new file mode 100644 index 00000000..8ed54c94 --- /dev/null +++ b/app/frontend/public/assets/icons/files/ic-pdf.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/files/ic-power_point.svg b/app/frontend/public/assets/icons/files/ic-power_point.svg new file mode 100644 index 00000000..f2d7f144 --- /dev/null +++ b/app/frontend/public/assets/icons/files/ic-power_point.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/files/ic-pts.svg b/app/frontend/public/assets/icons/files/ic-pts.svg new file mode 100644 index 00000000..7ecbee0c --- /dev/null +++ b/app/frontend/public/assets/icons/files/ic-pts.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/files/ic-txt.svg b/app/frontend/public/assets/icons/files/ic-txt.svg new file mode 100644 index 00000000..1d34c348 --- /dev/null +++ b/app/frontend/public/assets/icons/files/ic-txt.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/files/ic-video.svg b/app/frontend/public/assets/icons/files/ic-video.svg new file mode 100644 index 00000000..fb6eca6c --- /dev/null +++ b/app/frontend/public/assets/icons/files/ic-video.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/files/ic-word.svg b/app/frontend/public/assets/icons/files/ic-word.svg new file mode 100644 index 00000000..b112fe58 --- /dev/null +++ b/app/frontend/public/assets/icons/files/ic-word.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/files/ic-zip.svg b/app/frontend/public/assets/icons/files/ic-zip.svg new file mode 100644 index 00000000..f34001e8 --- /dev/null +++ b/app/frontend/public/assets/icons/files/ic-zip.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/glass/ic-glass-bag.svg b/app/frontend/public/assets/icons/glass/ic-glass-bag.svg new file mode 100644 index 00000000..a384e030 --- /dev/null +++ b/app/frontend/public/assets/icons/glass/ic-glass-bag.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/glass/ic-glass-buy.svg b/app/frontend/public/assets/icons/glass/ic-glass-buy.svg new file mode 100644 index 00000000..07577a65 --- /dev/null +++ b/app/frontend/public/assets/icons/glass/ic-glass-buy.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/glass/ic-glass-message.svg b/app/frontend/public/assets/icons/glass/ic-glass-message.svg new file mode 100644 index 00000000..e82d8d86 --- /dev/null +++ b/app/frontend/public/assets/icons/glass/ic-glass-message.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/glass/ic-glass-users.svg b/app/frontend/public/assets/icons/glass/ic-glass-users.svg new file mode 100644 index 00000000..d69a579e --- /dev/null +++ b/app/frontend/public/assets/icons/glass/ic-glass-users.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/home/ic-design.svg b/app/frontend/public/assets/icons/home/ic-design.svg new file mode 100644 index 00000000..55fe0162 --- /dev/null +++ b/app/frontend/public/assets/icons/home/ic-design.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/frontend/public/assets/icons/home/ic-development.svg b/app/frontend/public/assets/icons/home/ic-development.svg new file mode 100644 index 00000000..b1f17745 --- /dev/null +++ b/app/frontend/public/assets/icons/home/ic-development.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/frontend/public/assets/icons/home/ic-make-brand.svg b/app/frontend/public/assets/icons/home/ic-make-brand.svg new file mode 100644 index 00000000..593dc8af --- /dev/null +++ b/app/frontend/public/assets/icons/home/ic-make-brand.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/frontend/public/assets/icons/navbar/ic-analytics.svg b/app/frontend/public/assets/icons/navbar/ic-analytics.svg new file mode 100644 index 00000000..a0182209 --- /dev/null +++ b/app/frontend/public/assets/icons/navbar/ic-analytics.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/frontend/public/assets/icons/navbar/ic-banking.svg b/app/frontend/public/assets/icons/navbar/ic-banking.svg new file mode 100644 index 00000000..8e1934ee --- /dev/null +++ b/app/frontend/public/assets/icons/navbar/ic-banking.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/navbar/ic-blank.svg b/app/frontend/public/assets/icons/navbar/ic-blank.svg new file mode 100644 index 00000000..3b21175a --- /dev/null +++ b/app/frontend/public/assets/icons/navbar/ic-blank.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/frontend/public/assets/icons/navbar/ic-blog.svg b/app/frontend/public/assets/icons/navbar/ic-blog.svg new file mode 100644 index 00000000..4d54ea9e --- /dev/null +++ b/app/frontend/public/assets/icons/navbar/ic-blog.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/frontend/public/assets/icons/navbar/ic-booking.svg b/app/frontend/public/assets/icons/navbar/ic-booking.svg new file mode 100644 index 00000000..db8b365d --- /dev/null +++ b/app/frontend/public/assets/icons/navbar/ic-booking.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/frontend/public/assets/icons/navbar/ic-calendar.svg b/app/frontend/public/assets/icons/navbar/ic-calendar.svg new file mode 100644 index 00000000..e24979ab --- /dev/null +++ b/app/frontend/public/assets/icons/navbar/ic-calendar.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/frontend/public/assets/icons/navbar/ic-chat.svg b/app/frontend/public/assets/icons/navbar/ic-chat.svg new file mode 100644 index 00000000..2cf385c9 --- /dev/null +++ b/app/frontend/public/assets/icons/navbar/ic-chat.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/frontend/public/assets/icons/navbar/ic-course.svg b/app/frontend/public/assets/icons/navbar/ic-course.svg new file mode 100644 index 00000000..22b1133f --- /dev/null +++ b/app/frontend/public/assets/icons/navbar/ic-course.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/frontend/public/assets/icons/navbar/ic-dashboard.svg b/app/frontend/public/assets/icons/navbar/ic-dashboard.svg new file mode 100644 index 00000000..30840517 --- /dev/null +++ b/app/frontend/public/assets/icons/navbar/ic-dashboard.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/frontend/public/assets/icons/navbar/ic-disabled.svg b/app/frontend/public/assets/icons/navbar/ic-disabled.svg new file mode 100644 index 00000000..ac241ab4 --- /dev/null +++ b/app/frontend/public/assets/icons/navbar/ic-disabled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/frontend/public/assets/icons/navbar/ic-ecommerce.svg b/app/frontend/public/assets/icons/navbar/ic-ecommerce.svg new file mode 100644 index 00000000..404be6dc --- /dev/null +++ b/app/frontend/public/assets/icons/navbar/ic-ecommerce.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/frontend/public/assets/icons/navbar/ic-external.svg b/app/frontend/public/assets/icons/navbar/ic-external.svg new file mode 100644 index 00000000..fb726f77 --- /dev/null +++ b/app/frontend/public/assets/icons/navbar/ic-external.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/frontend/public/assets/icons/navbar/ic-file.svg b/app/frontend/public/assets/icons/navbar/ic-file.svg new file mode 100644 index 00000000..161b0a5e --- /dev/null +++ b/app/frontend/public/assets/icons/navbar/ic-file.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/frontend/public/assets/icons/navbar/ic-folder.svg b/app/frontend/public/assets/icons/navbar/ic-folder.svg new file mode 100644 index 00000000..71595ed0 --- /dev/null +++ b/app/frontend/public/assets/icons/navbar/ic-folder.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/frontend/public/assets/icons/navbar/ic-invoice.svg b/app/frontend/public/assets/icons/navbar/ic-invoice.svg new file mode 100644 index 00000000..1f68e967 --- /dev/null +++ b/app/frontend/public/assets/icons/navbar/ic-invoice.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/frontend/public/assets/icons/navbar/ic-job.svg b/app/frontend/public/assets/icons/navbar/ic-job.svg new file mode 100644 index 00000000..dee73d24 --- /dev/null +++ b/app/frontend/public/assets/icons/navbar/ic-job.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/frontend/public/assets/icons/navbar/ic-kanban.svg b/app/frontend/public/assets/icons/navbar/ic-kanban.svg new file mode 100644 index 00000000..a7144384 --- /dev/null +++ b/app/frontend/public/assets/icons/navbar/ic-kanban.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/frontend/public/assets/icons/navbar/ic-label.svg b/app/frontend/public/assets/icons/navbar/ic-label.svg new file mode 100644 index 00000000..0488f1c4 --- /dev/null +++ b/app/frontend/public/assets/icons/navbar/ic-label.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/frontend/public/assets/icons/navbar/ic-lock.svg b/app/frontend/public/assets/icons/navbar/ic-lock.svg new file mode 100644 index 00000000..ebe9002c --- /dev/null +++ b/app/frontend/public/assets/icons/navbar/ic-lock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/frontend/public/assets/icons/navbar/ic-mail.svg b/app/frontend/public/assets/icons/navbar/ic-mail.svg new file mode 100644 index 00000000..5fa7e048 --- /dev/null +++ b/app/frontend/public/assets/icons/navbar/ic-mail.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/frontend/public/assets/icons/navbar/ic-menu-item.svg b/app/frontend/public/assets/icons/navbar/ic-menu-item.svg new file mode 100644 index 00000000..f3ffb176 --- /dev/null +++ b/app/frontend/public/assets/icons/navbar/ic-menu-item.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/frontend/public/assets/icons/navbar/ic-order.svg b/app/frontend/public/assets/icons/navbar/ic-order.svg new file mode 100644 index 00000000..8f011727 --- /dev/null +++ b/app/frontend/public/assets/icons/navbar/ic-order.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/frontend/public/assets/icons/navbar/ic-parameter.svg b/app/frontend/public/assets/icons/navbar/ic-parameter.svg new file mode 100644 index 00000000..289e44a1 --- /dev/null +++ b/app/frontend/public/assets/icons/navbar/ic-parameter.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/frontend/public/assets/icons/navbar/ic-product.svg b/app/frontend/public/assets/icons/navbar/ic-product.svg new file mode 100644 index 00000000..71a83700 --- /dev/null +++ b/app/frontend/public/assets/icons/navbar/ic-product.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/frontend/public/assets/icons/navbar/ic-tour.svg b/app/frontend/public/assets/icons/navbar/ic-tour.svg new file mode 100644 index 00000000..adab1ebc --- /dev/null +++ b/app/frontend/public/assets/icons/navbar/ic-tour.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/frontend/public/assets/icons/navbar/ic-user.svg b/app/frontend/public/assets/icons/navbar/ic-user.svg new file mode 100644 index 00000000..261a3a3a --- /dev/null +++ b/app/frontend/public/assets/icons/navbar/ic-user.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/frontend/public/assets/icons/notification/ic-chat.svg b/app/frontend/public/assets/icons/notification/ic-chat.svg new file mode 100644 index 00000000..2b1afc32 --- /dev/null +++ b/app/frontend/public/assets/icons/notification/ic-chat.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/notification/ic-delivery.svg b/app/frontend/public/assets/icons/notification/ic-delivery.svg new file mode 100644 index 00000000..f7960c65 --- /dev/null +++ b/app/frontend/public/assets/icons/notification/ic-delivery.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/notification/ic-mail.svg b/app/frontend/public/assets/icons/notification/ic-mail.svg new file mode 100644 index 00000000..6183ee9e --- /dev/null +++ b/app/frontend/public/assets/icons/notification/ic-mail.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/notification/ic-order.svg b/app/frontend/public/assets/icons/notification/ic-order.svg new file mode 100644 index 00000000..568193a0 --- /dev/null +++ b/app/frontend/public/assets/icons/notification/ic-order.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/platforms/ic-amplify.svg b/app/frontend/public/assets/icons/platforms/ic-amplify.svg new file mode 100644 index 00000000..a08c5d1f --- /dev/null +++ b/app/frontend/public/assets/icons/platforms/ic-amplify.svg @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/app/frontend/public/assets/icons/platforms/ic-auth0.svg b/app/frontend/public/assets/icons/platforms/ic-auth0.svg new file mode 100644 index 00000000..d02ff5fd --- /dev/null +++ b/app/frontend/public/assets/icons/platforms/ic-auth0.svg @@ -0,0 +1,5 @@ + + + diff --git a/app/frontend/public/assets/icons/platforms/ic-figma.svg b/app/frontend/public/assets/icons/platforms/ic-figma.svg new file mode 100644 index 00000000..56589262 --- /dev/null +++ b/app/frontend/public/assets/icons/platforms/ic-figma.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/frontend/public/assets/icons/platforms/ic-firebase.svg b/app/frontend/public/assets/icons/platforms/ic-firebase.svg new file mode 100644 index 00000000..1cfe0652 --- /dev/null +++ b/app/frontend/public/assets/icons/platforms/ic-firebase.svg @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/frontend/public/assets/icons/platforms/ic-js.svg b/app/frontend/public/assets/icons/platforms/ic-js.svg new file mode 100644 index 00000000..21b0c98f --- /dev/null +++ b/app/frontend/public/assets/icons/platforms/ic-js.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/frontend/public/assets/icons/platforms/ic-jwt.svg b/app/frontend/public/assets/icons/platforms/ic-jwt.svg new file mode 100644 index 00000000..4085270b --- /dev/null +++ b/app/frontend/public/assets/icons/platforms/ic-jwt.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/platforms/ic-mui.svg b/app/frontend/public/assets/icons/platforms/ic-mui.svg new file mode 100644 index 00000000..738adbe4 --- /dev/null +++ b/app/frontend/public/assets/icons/platforms/ic-mui.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/frontend/public/assets/icons/platforms/ic-nextjs.svg b/app/frontend/public/assets/icons/platforms/ic-nextjs.svg new file mode 100644 index 00000000..3de311d4 --- /dev/null +++ b/app/frontend/public/assets/icons/platforms/ic-nextjs.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/frontend/public/assets/icons/platforms/ic-react.svg b/app/frontend/public/assets/icons/platforms/ic-react.svg new file mode 100644 index 00000000..94610c6a --- /dev/null +++ b/app/frontend/public/assets/icons/platforms/ic-react.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/frontend/public/assets/icons/platforms/ic-supabase.svg b/app/frontend/public/assets/icons/platforms/ic-supabase.svg new file mode 100644 index 00000000..ac43e170 --- /dev/null +++ b/app/frontend/public/assets/icons/platforms/ic-supabase.svg @@ -0,0 +1,99 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/platforms/ic-ts.svg b/app/frontend/public/assets/icons/platforms/ic-ts.svg new file mode 100644 index 00000000..8d1bcd24 --- /dev/null +++ b/app/frontend/public/assets/icons/platforms/ic-ts.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/frontend/public/assets/icons/platforms/ic-vite.svg b/app/frontend/public/assets/icons/platforms/ic-vite.svg new file mode 100644 index 00000000..1f57c2ce --- /dev/null +++ b/app/frontend/public/assets/icons/platforms/ic-vite.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/settings/ic-align-right.svg b/app/frontend/public/assets/icons/settings/ic-align-right.svg new file mode 100644 index 00000000..c915aa12 --- /dev/null +++ b/app/frontend/public/assets/icons/settings/ic-align-right.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/frontend/public/assets/icons/settings/ic-autofit-width.svg b/app/frontend/public/assets/icons/settings/ic-autofit-width.svg new file mode 100644 index 00000000..bd9a6c90 --- /dev/null +++ b/app/frontend/public/assets/icons/settings/ic-autofit-width.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/frontend/public/assets/icons/settings/ic-contrast.svg b/app/frontend/public/assets/icons/settings/ic-contrast.svg new file mode 100644 index 00000000..9d190320 --- /dev/null +++ b/app/frontend/public/assets/icons/settings/ic-contrast.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/app/frontend/public/assets/icons/settings/ic-font.svg b/app/frontend/public/assets/icons/settings/ic-font.svg new file mode 100644 index 00000000..91abc012 --- /dev/null +++ b/app/frontend/public/assets/icons/settings/ic-font.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/frontend/public/assets/icons/settings/ic-moon.svg b/app/frontend/public/assets/icons/settings/ic-moon.svg new file mode 100644 index 00000000..f8519e4d --- /dev/null +++ b/app/frontend/public/assets/icons/settings/ic-moon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/frontend/public/assets/icons/settings/ic-nav-horizontal.svg b/app/frontend/public/assets/icons/settings/ic-nav-horizontal.svg new file mode 100644 index 00000000..20de80e0 --- /dev/null +++ b/app/frontend/public/assets/icons/settings/ic-nav-horizontal.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/settings/ic-nav-mini.svg b/app/frontend/public/assets/icons/settings/ic-nav-mini.svg new file mode 100644 index 00000000..f7a6b4bc --- /dev/null +++ b/app/frontend/public/assets/icons/settings/ic-nav-mini.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/settings/ic-nav-vertical.svg b/app/frontend/public/assets/icons/settings/ic-nav-vertical.svg new file mode 100644 index 00000000..9e545041 --- /dev/null +++ b/app/frontend/public/assets/icons/settings/ic-nav-vertical.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/assets/icons/settings/ic-sidebar-filled.svg b/app/frontend/public/assets/icons/settings/ic-sidebar-filled.svg new file mode 100644 index 00000000..d45bfb69 --- /dev/null +++ b/app/frontend/public/assets/icons/settings/ic-sidebar-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/frontend/public/assets/icons/settings/ic-sidebar-outline.svg b/app/frontend/public/assets/icons/settings/ic-sidebar-outline.svg new file mode 100644 index 00000000..aca6be99 --- /dev/null +++ b/app/frontend/public/assets/icons/settings/ic-sidebar-outline.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/frontend/public/assets/icons/settings/ic-siderbar-duotone.svg b/app/frontend/public/assets/icons/settings/ic-siderbar-duotone.svg new file mode 100644 index 00000000..46f51d70 --- /dev/null +++ b/app/frontend/public/assets/icons/settings/ic-siderbar-duotone.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/frontend/public/assets/icons/workspaces/logo-1.webp b/app/frontend/public/assets/icons/workspaces/logo-1.webp new file mode 100644 index 00000000..daf350d5 Binary files /dev/null and b/app/frontend/public/assets/icons/workspaces/logo-1.webp differ diff --git a/app/frontend/public/assets/icons/workspaces/logo-2.webp b/app/frontend/public/assets/icons/workspaces/logo-2.webp new file mode 100644 index 00000000..3b629ef1 Binary files /dev/null and b/app/frontend/public/assets/icons/workspaces/logo-2.webp differ diff --git a/app/frontend/public/assets/icons/workspaces/logo-3.webp b/app/frontend/public/assets/icons/workspaces/logo-3.webp new file mode 100644 index 00000000..273fe071 Binary files /dev/null and b/app/frontend/public/assets/icons/workspaces/logo-3.webp differ diff --git a/app/frontend/public/assets/illustrations/characters/character-1.webp b/app/frontend/public/assets/illustrations/characters/character-1.webp new file mode 100644 index 00000000..d3d206ae Binary files /dev/null and b/app/frontend/public/assets/illustrations/characters/character-1.webp differ diff --git a/app/frontend/public/assets/illustrations/characters/character-10.webp b/app/frontend/public/assets/illustrations/characters/character-10.webp new file mode 100644 index 00000000..2c633525 Binary files /dev/null and b/app/frontend/public/assets/illustrations/characters/character-10.webp differ diff --git a/app/frontend/public/assets/illustrations/characters/character-11.webp b/app/frontend/public/assets/illustrations/characters/character-11.webp new file mode 100644 index 00000000..c1d62bc9 Binary files /dev/null and b/app/frontend/public/assets/illustrations/characters/character-11.webp differ diff --git a/app/frontend/public/assets/illustrations/characters/character-2.webp b/app/frontend/public/assets/illustrations/characters/character-2.webp new file mode 100644 index 00000000..ef8771e1 Binary files /dev/null and b/app/frontend/public/assets/illustrations/characters/character-2.webp differ diff --git a/app/frontend/public/assets/illustrations/characters/character-3.webp b/app/frontend/public/assets/illustrations/characters/character-3.webp new file mode 100644 index 00000000..1971319c Binary files /dev/null and b/app/frontend/public/assets/illustrations/characters/character-3.webp differ diff --git a/app/frontend/public/assets/illustrations/characters/character-4.webp b/app/frontend/public/assets/illustrations/characters/character-4.webp new file mode 100644 index 00000000..685d7ce6 Binary files /dev/null and b/app/frontend/public/assets/illustrations/characters/character-4.webp differ diff --git a/app/frontend/public/assets/illustrations/characters/character-5.webp b/app/frontend/public/assets/illustrations/characters/character-5.webp new file mode 100644 index 00000000..c4b3ff76 Binary files /dev/null and b/app/frontend/public/assets/illustrations/characters/character-5.webp differ diff --git a/app/frontend/public/assets/illustrations/characters/character-6.webp b/app/frontend/public/assets/illustrations/characters/character-6.webp new file mode 100644 index 00000000..88bff2bc Binary files /dev/null and b/app/frontend/public/assets/illustrations/characters/character-6.webp differ diff --git a/app/frontend/public/assets/illustrations/characters/character-7.webp b/app/frontend/public/assets/illustrations/characters/character-7.webp new file mode 100644 index 00000000..9b38fa6b Binary files /dev/null and b/app/frontend/public/assets/illustrations/characters/character-7.webp differ diff --git a/app/frontend/public/assets/illustrations/characters/character-8.webp b/app/frontend/public/assets/illustrations/characters/character-8.webp new file mode 100644 index 00000000..78dc4744 Binary files /dev/null and b/app/frontend/public/assets/illustrations/characters/character-8.webp differ diff --git a/app/frontend/public/assets/illustrations/characters/character-9.webp b/app/frontend/public/assets/illustrations/characters/character-9.webp new file mode 100644 index 00000000..c84fd59e Binary files /dev/null and b/app/frontend/public/assets/illustrations/characters/character-9.webp differ diff --git a/app/frontend/public/assets/illustrations/illustration-dashboard.webp b/app/frontend/public/assets/illustrations/illustration-dashboard.webp new file mode 100644 index 00000000..3c1d1a3e Binary files /dev/null and b/app/frontend/public/assets/illustrations/illustration-dashboard.webp differ diff --git a/app/frontend/public/assets/illustrations/illustration-integration.webp b/app/frontend/public/assets/illustrations/illustration-integration.webp new file mode 100644 index 00000000..888e7db5 Binary files /dev/null and b/app/frontend/public/assets/illustrations/illustration-integration.webp differ diff --git a/app/frontend/public/assets/illustrations/illustration-receipt.webp b/app/frontend/public/assets/illustrations/illustration-receipt.webp new file mode 100644 index 00000000..4661fccb Binary files /dev/null and b/app/frontend/public/assets/illustrations/illustration-receipt.webp differ diff --git a/app/frontend/public/assets/illustrations/illustration-rocket-large.webp b/app/frontend/public/assets/illustrations/illustration-rocket-large.webp new file mode 100644 index 00000000..b0eed114 Binary files /dev/null and b/app/frontend/public/assets/illustrations/illustration-rocket-large.webp differ diff --git a/app/frontend/public/assets/illustrations/illustration-rocket-small.webp b/app/frontend/public/assets/illustrations/illustration-rocket-small.webp new file mode 100644 index 00000000..8f2d1d3c Binary files /dev/null and b/app/frontend/public/assets/illustrations/illustration-rocket-small.webp differ diff --git a/app/frontend/public/assets/illustrations/illustration-upgrade.webp b/app/frontend/public/assets/illustrations/illustration-upgrade.webp new file mode 100644 index 00000000..92672b24 Binary files /dev/null and b/app/frontend/public/assets/illustrations/illustration-upgrade.webp differ diff --git a/app/frontend/public/assets/images/about/hero.webp b/app/frontend/public/assets/images/about/hero.webp new file mode 100644 index 00000000..316d1a4b Binary files /dev/null and b/app/frontend/public/assets/images/about/hero.webp differ diff --git a/app/frontend/public/assets/images/about/testimonials.webp b/app/frontend/public/assets/images/about/testimonials.webp new file mode 100644 index 00000000..86b8a773 Binary files /dev/null and b/app/frontend/public/assets/images/about/testimonials.webp differ diff --git a/app/frontend/public/assets/images/about/vision.webp b/app/frontend/public/assets/images/about/vision.webp new file mode 100644 index 00000000..88a818bd Binary files /dev/null and b/app/frontend/public/assets/images/about/vision.webp differ diff --git a/app/frontend/public/assets/images/about/what-large.webp b/app/frontend/public/assets/images/about/what-large.webp new file mode 100644 index 00000000..d65d0033 Binary files /dev/null and b/app/frontend/public/assets/images/about/what-large.webp differ diff --git a/app/frontend/public/assets/images/about/what-small.webp b/app/frontend/public/assets/images/about/what-small.webp new file mode 100644 index 00000000..e7b96db5 Binary files /dev/null and b/app/frontend/public/assets/images/about/what-small.webp differ diff --git a/app/frontend/public/assets/images/contact/hero.webp b/app/frontend/public/assets/images/contact/hero.webp new file mode 100644 index 00000000..e1258eaa Binary files /dev/null and b/app/frontend/public/assets/images/contact/hero.webp differ diff --git a/app/frontend/public/assets/images/faqs/hero.webp b/app/frontend/public/assets/images/faqs/hero.webp new file mode 100644 index 00000000..7dbe51af Binary files /dev/null and b/app/frontend/public/assets/images/faqs/hero.webp differ diff --git a/app/frontend/public/assets/images/home/bundle-dark-1.webp b/app/frontend/public/assets/images/home/bundle-dark-1.webp new file mode 100644 index 00000000..073a5f3e Binary files /dev/null and b/app/frontend/public/assets/images/home/bundle-dark-1.webp differ diff --git a/app/frontend/public/assets/images/home/bundle-dark-2.webp b/app/frontend/public/assets/images/home/bundle-dark-2.webp new file mode 100644 index 00000000..b5b9623d Binary files /dev/null and b/app/frontend/public/assets/images/home/bundle-dark-2.webp differ diff --git a/app/frontend/public/assets/images/home/bundle-light-1.webp b/app/frontend/public/assets/images/home/bundle-light-1.webp new file mode 100644 index 00000000..693d04fc Binary files /dev/null and b/app/frontend/public/assets/images/home/bundle-light-1.webp differ diff --git a/app/frontend/public/assets/images/home/bundle-light-2.webp b/app/frontend/public/assets/images/home/bundle-light-2.webp new file mode 100644 index 00000000..dbca4e90 Binary files /dev/null and b/app/frontend/public/assets/images/home/bundle-light-2.webp differ diff --git a/app/frontend/public/assets/images/home/for-designer.webp b/app/frontend/public/assets/images/home/for-designer.webp new file mode 100644 index 00000000..7bddbda3 Binary files /dev/null and b/app/frontend/public/assets/images/home/for-designer.webp differ diff --git a/app/frontend/public/assets/images/home/hero-blur.webp b/app/frontend/public/assets/images/home/hero-blur.webp new file mode 100644 index 00000000..a12942e9 Binary files /dev/null and b/app/frontend/public/assets/images/home/hero-blur.webp differ diff --git a/app/frontend/public/assets/images/home/highlight-darkmode.webp b/app/frontend/public/assets/images/home/highlight-darkmode.webp new file mode 100644 index 00000000..cfb895b0 Binary files /dev/null and b/app/frontend/public/assets/images/home/highlight-darkmode.webp differ diff --git a/app/frontend/public/assets/images/home/highlight-presets-1.webp b/app/frontend/public/assets/images/home/highlight-presets-1.webp new file mode 100644 index 00000000..28b7e24e Binary files /dev/null and b/app/frontend/public/assets/images/home/highlight-presets-1.webp differ diff --git a/app/frontend/public/assets/images/home/highlight-presets-2.webp b/app/frontend/public/assets/images/home/highlight-presets-2.webp new file mode 100644 index 00000000..39d7c4f1 Binary files /dev/null and b/app/frontend/public/assets/images/home/highlight-presets-2.webp differ diff --git a/app/frontend/public/assets/images/home/highlight-presets-3.webp b/app/frontend/public/assets/images/home/highlight-presets-3.webp new file mode 100644 index 00000000..41be73e5 Binary files /dev/null and b/app/frontend/public/assets/images/home/highlight-presets-3.webp differ diff --git a/app/frontend/public/assets/images/home/highlight-presets-4.webp b/app/frontend/public/assets/images/home/highlight-presets-4.webp new file mode 100644 index 00000000..2cdb5cb3 Binary files /dev/null and b/app/frontend/public/assets/images/home/highlight-presets-4.webp differ diff --git a/app/frontend/public/assets/images/home/highlight-presets-5.webp b/app/frontend/public/assets/images/home/highlight-presets-5.webp new file mode 100644 index 00000000..92206467 Binary files /dev/null and b/app/frontend/public/assets/images/home/highlight-presets-5.webp differ diff --git a/app/frontend/public/assets/images/home/highlight-rtl.webp b/app/frontend/public/assets/images/home/highlight-rtl.webp new file mode 100644 index 00000000..99b0d7d7 Binary files /dev/null and b/app/frontend/public/assets/images/home/highlight-rtl.webp differ diff --git a/app/frontend/public/assets/images/home/home-chart.webp b/app/frontend/public/assets/images/home/home-chart.webp new file mode 100644 index 00000000..b192a775 Binary files /dev/null and b/app/frontend/public/assets/images/home/home-chart.webp differ diff --git a/app/frontend/public/assets/images/home/zone-landing.webp b/app/frontend/public/assets/images/home/zone-landing.webp new file mode 100644 index 00000000..4a1a7bd4 Binary files /dev/null and b/app/frontend/public/assets/images/home/zone-landing.webp differ diff --git a/app/frontend/public/assets/images/mock/avatar/avatar-1.webp b/app/frontend/public/assets/images/mock/avatar/avatar-1.webp new file mode 100644 index 00000000..d0860df6 Binary files /dev/null and b/app/frontend/public/assets/images/mock/avatar/avatar-1.webp differ diff --git a/app/frontend/public/assets/images/mock/avatar/avatar-10.webp b/app/frontend/public/assets/images/mock/avatar/avatar-10.webp new file mode 100644 index 00000000..a523203e Binary files /dev/null and b/app/frontend/public/assets/images/mock/avatar/avatar-10.webp differ diff --git a/app/frontend/public/assets/images/mock/avatar/avatar-11.webp b/app/frontend/public/assets/images/mock/avatar/avatar-11.webp new file mode 100644 index 00000000..7da3d8db Binary files /dev/null and b/app/frontend/public/assets/images/mock/avatar/avatar-11.webp differ diff --git a/app/frontend/public/assets/images/mock/avatar/avatar-12.webp b/app/frontend/public/assets/images/mock/avatar/avatar-12.webp new file mode 100644 index 00000000..2ef56666 Binary files /dev/null and b/app/frontend/public/assets/images/mock/avatar/avatar-12.webp differ diff --git a/app/frontend/public/assets/images/mock/avatar/avatar-13.webp b/app/frontend/public/assets/images/mock/avatar/avatar-13.webp new file mode 100644 index 00000000..d03b4805 Binary files /dev/null and b/app/frontend/public/assets/images/mock/avatar/avatar-13.webp differ diff --git a/app/frontend/public/assets/images/mock/avatar/avatar-14.webp b/app/frontend/public/assets/images/mock/avatar/avatar-14.webp new file mode 100644 index 00000000..80a8d294 Binary files /dev/null and b/app/frontend/public/assets/images/mock/avatar/avatar-14.webp differ diff --git a/app/frontend/public/assets/images/mock/avatar/avatar-15.webp b/app/frontend/public/assets/images/mock/avatar/avatar-15.webp new file mode 100644 index 00000000..9ac9816e Binary files /dev/null and b/app/frontend/public/assets/images/mock/avatar/avatar-15.webp differ diff --git a/app/frontend/public/assets/images/mock/avatar/avatar-16.webp b/app/frontend/public/assets/images/mock/avatar/avatar-16.webp new file mode 100644 index 00000000..7251b747 Binary files /dev/null and b/app/frontend/public/assets/images/mock/avatar/avatar-16.webp differ diff --git a/app/frontend/public/assets/images/mock/avatar/avatar-17.webp b/app/frontend/public/assets/images/mock/avatar/avatar-17.webp new file mode 100644 index 00000000..5d5a465a Binary files /dev/null and b/app/frontend/public/assets/images/mock/avatar/avatar-17.webp differ diff --git a/app/frontend/public/assets/images/mock/avatar/avatar-18.webp b/app/frontend/public/assets/images/mock/avatar/avatar-18.webp new file mode 100644 index 00000000..4c545e4e Binary files /dev/null and b/app/frontend/public/assets/images/mock/avatar/avatar-18.webp differ diff --git a/app/frontend/public/assets/images/mock/avatar/avatar-19.webp b/app/frontend/public/assets/images/mock/avatar/avatar-19.webp new file mode 100644 index 00000000..6fc8ff3c Binary files /dev/null and b/app/frontend/public/assets/images/mock/avatar/avatar-19.webp differ diff --git a/app/frontend/public/assets/images/mock/avatar/avatar-2.webp b/app/frontend/public/assets/images/mock/avatar/avatar-2.webp new file mode 100644 index 00000000..6af043ad Binary files /dev/null and b/app/frontend/public/assets/images/mock/avatar/avatar-2.webp differ diff --git a/app/frontend/public/assets/images/mock/avatar/avatar-20.webp b/app/frontend/public/assets/images/mock/avatar/avatar-20.webp new file mode 100644 index 00000000..8cb58ce3 Binary files /dev/null and b/app/frontend/public/assets/images/mock/avatar/avatar-20.webp differ diff --git a/app/frontend/public/assets/images/mock/avatar/avatar-21.webp b/app/frontend/public/assets/images/mock/avatar/avatar-21.webp new file mode 100644 index 00000000..b88979b3 Binary files /dev/null and b/app/frontend/public/assets/images/mock/avatar/avatar-21.webp differ diff --git a/app/frontend/public/assets/images/mock/avatar/avatar-22.webp b/app/frontend/public/assets/images/mock/avatar/avatar-22.webp new file mode 100644 index 00000000..2fe2c6bd Binary files /dev/null and b/app/frontend/public/assets/images/mock/avatar/avatar-22.webp differ diff --git a/app/frontend/public/assets/images/mock/avatar/avatar-23.webp b/app/frontend/public/assets/images/mock/avatar/avatar-23.webp new file mode 100644 index 00000000..5139e237 Binary files /dev/null and b/app/frontend/public/assets/images/mock/avatar/avatar-23.webp differ diff --git a/app/frontend/public/assets/images/mock/avatar/avatar-24.webp b/app/frontend/public/assets/images/mock/avatar/avatar-24.webp new file mode 100644 index 00000000..e6717200 Binary files /dev/null and b/app/frontend/public/assets/images/mock/avatar/avatar-24.webp differ diff --git a/app/frontend/public/assets/images/mock/avatar/avatar-25.webp b/app/frontend/public/assets/images/mock/avatar/avatar-25.webp new file mode 100644 index 00000000..e5bce1b6 Binary files /dev/null and b/app/frontend/public/assets/images/mock/avatar/avatar-25.webp differ diff --git a/app/frontend/public/assets/images/mock/avatar/avatar-3.webp b/app/frontend/public/assets/images/mock/avatar/avatar-3.webp new file mode 100644 index 00000000..9e7e1618 Binary files /dev/null and b/app/frontend/public/assets/images/mock/avatar/avatar-3.webp differ diff --git a/app/frontend/public/assets/images/mock/avatar/avatar-4.webp b/app/frontend/public/assets/images/mock/avatar/avatar-4.webp new file mode 100644 index 00000000..acdd7d71 Binary files /dev/null and b/app/frontend/public/assets/images/mock/avatar/avatar-4.webp differ diff --git a/app/frontend/public/assets/images/mock/avatar/avatar-5.webp b/app/frontend/public/assets/images/mock/avatar/avatar-5.webp new file mode 100644 index 00000000..d02bbcee Binary files /dev/null and b/app/frontend/public/assets/images/mock/avatar/avatar-5.webp differ diff --git a/app/frontend/public/assets/images/mock/avatar/avatar-6.webp b/app/frontend/public/assets/images/mock/avatar/avatar-6.webp new file mode 100644 index 00000000..95191387 Binary files /dev/null and b/app/frontend/public/assets/images/mock/avatar/avatar-6.webp differ diff --git a/app/frontend/public/assets/images/mock/avatar/avatar-7.webp b/app/frontend/public/assets/images/mock/avatar/avatar-7.webp new file mode 100644 index 00000000..b1272cbc Binary files /dev/null and b/app/frontend/public/assets/images/mock/avatar/avatar-7.webp differ diff --git a/app/frontend/public/assets/images/mock/avatar/avatar-8.webp b/app/frontend/public/assets/images/mock/avatar/avatar-8.webp new file mode 100644 index 00000000..35b9dfe3 Binary files /dev/null and b/app/frontend/public/assets/images/mock/avatar/avatar-8.webp differ diff --git a/app/frontend/public/assets/images/mock/avatar/avatar-9.webp b/app/frontend/public/assets/images/mock/avatar/avatar-9.webp new file mode 100644 index 00000000..b0a8158c Binary files /dev/null and b/app/frontend/public/assets/images/mock/avatar/avatar-9.webp differ diff --git a/app/frontend/public/assets/images/mock/company/company-1.webp b/app/frontend/public/assets/images/mock/company/company-1.webp new file mode 100644 index 00000000..76e3d2b8 Binary files /dev/null and b/app/frontend/public/assets/images/mock/company/company-1.webp differ diff --git a/app/frontend/public/assets/images/mock/company/company-10.webp b/app/frontend/public/assets/images/mock/company/company-10.webp new file mode 100644 index 00000000..aebefe09 Binary files /dev/null and b/app/frontend/public/assets/images/mock/company/company-10.webp differ diff --git a/app/frontend/public/assets/images/mock/company/company-11.webp b/app/frontend/public/assets/images/mock/company/company-11.webp new file mode 100644 index 00000000..94877dbb Binary files /dev/null and b/app/frontend/public/assets/images/mock/company/company-11.webp differ diff --git a/app/frontend/public/assets/images/mock/company/company-12.webp b/app/frontend/public/assets/images/mock/company/company-12.webp new file mode 100644 index 00000000..bc42915e Binary files /dev/null and b/app/frontend/public/assets/images/mock/company/company-12.webp differ diff --git a/app/frontend/public/assets/images/mock/company/company-2.webp b/app/frontend/public/assets/images/mock/company/company-2.webp new file mode 100644 index 00000000..0372a87d Binary files /dev/null and b/app/frontend/public/assets/images/mock/company/company-2.webp differ diff --git a/app/frontend/public/assets/images/mock/company/company-3.webp b/app/frontend/public/assets/images/mock/company/company-3.webp new file mode 100644 index 00000000..33f9fe24 Binary files /dev/null and b/app/frontend/public/assets/images/mock/company/company-3.webp differ diff --git a/app/frontend/public/assets/images/mock/company/company-4.webp b/app/frontend/public/assets/images/mock/company/company-4.webp new file mode 100644 index 00000000..e0ef68c2 Binary files /dev/null and b/app/frontend/public/assets/images/mock/company/company-4.webp differ diff --git a/app/frontend/public/assets/images/mock/company/company-5.webp b/app/frontend/public/assets/images/mock/company/company-5.webp new file mode 100644 index 00000000..91577bc8 Binary files /dev/null and b/app/frontend/public/assets/images/mock/company/company-5.webp differ diff --git a/app/frontend/public/assets/images/mock/company/company-6.webp b/app/frontend/public/assets/images/mock/company/company-6.webp new file mode 100644 index 00000000..cc3d4567 Binary files /dev/null and b/app/frontend/public/assets/images/mock/company/company-6.webp differ diff --git a/app/frontend/public/assets/images/mock/company/company-7.webp b/app/frontend/public/assets/images/mock/company/company-7.webp new file mode 100644 index 00000000..2493fe43 Binary files /dev/null and b/app/frontend/public/assets/images/mock/company/company-7.webp differ diff --git a/app/frontend/public/assets/images/mock/company/company-8.webp b/app/frontend/public/assets/images/mock/company/company-8.webp new file mode 100644 index 00000000..7c7cb1d8 Binary files /dev/null and b/app/frontend/public/assets/images/mock/company/company-8.webp differ diff --git a/app/frontend/public/assets/images/mock/company/company-9.webp b/app/frontend/public/assets/images/mock/company/company-9.webp new file mode 100644 index 00000000..0c29e1d9 Binary files /dev/null and b/app/frontend/public/assets/images/mock/company/company-9.webp differ diff --git a/app/frontend/public/assets/images/mock/course/about-summary.webp b/app/frontend/public/assets/images/mock/course/about-summary.webp new file mode 100644 index 00000000..8a7841af Binary files /dev/null and b/app/frontend/public/assets/images/mock/course/about-summary.webp differ diff --git a/app/frontend/public/assets/images/mock/course/course-1.webp b/app/frontend/public/assets/images/mock/course/course-1.webp new file mode 100644 index 00000000..e5d12f72 Binary files /dev/null and b/app/frontend/public/assets/images/mock/course/course-1.webp differ diff --git a/app/frontend/public/assets/images/mock/course/course-10.webp b/app/frontend/public/assets/images/mock/course/course-10.webp new file mode 100644 index 00000000..7c9057d3 Binary files /dev/null and b/app/frontend/public/assets/images/mock/course/course-10.webp differ diff --git a/app/frontend/public/assets/images/mock/course/course-11.webp b/app/frontend/public/assets/images/mock/course/course-11.webp new file mode 100644 index 00000000..6548f610 Binary files /dev/null and b/app/frontend/public/assets/images/mock/course/course-11.webp differ diff --git a/app/frontend/public/assets/images/mock/course/course-12.webp b/app/frontend/public/assets/images/mock/course/course-12.webp new file mode 100644 index 00000000..a2436f14 Binary files /dev/null and b/app/frontend/public/assets/images/mock/course/course-12.webp differ diff --git a/app/frontend/public/assets/images/mock/course/course-2.webp b/app/frontend/public/assets/images/mock/course/course-2.webp new file mode 100644 index 00000000..019a40cf Binary files /dev/null and b/app/frontend/public/assets/images/mock/course/course-2.webp differ diff --git a/app/frontend/public/assets/images/mock/course/course-3.webp b/app/frontend/public/assets/images/mock/course/course-3.webp new file mode 100644 index 00000000..d80ab5aa Binary files /dev/null and b/app/frontend/public/assets/images/mock/course/course-3.webp differ diff --git a/app/frontend/public/assets/images/mock/course/course-4.webp b/app/frontend/public/assets/images/mock/course/course-4.webp new file mode 100644 index 00000000..4e8fffae Binary files /dev/null and b/app/frontend/public/assets/images/mock/course/course-4.webp differ diff --git a/app/frontend/public/assets/images/mock/course/course-5.webp b/app/frontend/public/assets/images/mock/course/course-5.webp new file mode 100644 index 00000000..94b303c4 Binary files /dev/null and b/app/frontend/public/assets/images/mock/course/course-5.webp differ diff --git a/app/frontend/public/assets/images/mock/course/course-6.webp b/app/frontend/public/assets/images/mock/course/course-6.webp new file mode 100644 index 00000000..9bfae2ee Binary files /dev/null and b/app/frontend/public/assets/images/mock/course/course-6.webp differ diff --git a/app/frontend/public/assets/images/mock/course/course-7.webp b/app/frontend/public/assets/images/mock/course/course-7.webp new file mode 100644 index 00000000..7121c58b Binary files /dev/null and b/app/frontend/public/assets/images/mock/course/course-7.webp differ diff --git a/app/frontend/public/assets/images/mock/course/course-8.webp b/app/frontend/public/assets/images/mock/course/course-8.webp new file mode 100644 index 00000000..ef9e28b9 Binary files /dev/null and b/app/frontend/public/assets/images/mock/course/course-8.webp differ diff --git a/app/frontend/public/assets/images/mock/course/course-9.webp b/app/frontend/public/assets/images/mock/course/course-9.webp new file mode 100644 index 00000000..b06dc744 Binary files /dev/null and b/app/frontend/public/assets/images/mock/course/course-9.webp differ diff --git a/app/frontend/public/assets/images/mock/course/course-large-1.webp b/app/frontend/public/assets/images/mock/course/course-large-1.webp new file mode 100644 index 00000000..bd61b106 Binary files /dev/null and b/app/frontend/public/assets/images/mock/course/course-large-1.webp differ diff --git a/app/frontend/public/assets/images/mock/course/course-large-2.webp b/app/frontend/public/assets/images/mock/course/course-large-2.webp new file mode 100644 index 00000000..bb892845 Binary files /dev/null and b/app/frontend/public/assets/images/mock/course/course-large-2.webp differ diff --git a/app/frontend/public/assets/images/mock/course/course-large-3.webp b/app/frontend/public/assets/images/mock/course/course-large-3.webp new file mode 100644 index 00000000..7bbd2e31 Binary files /dev/null and b/app/frontend/public/assets/images/mock/course/course-large-3.webp differ diff --git a/app/frontend/public/assets/images/mock/course/download-app.webp b/app/frontend/public/assets/images/mock/course/download-app.webp new file mode 100644 index 00000000..01c90455 Binary files /dev/null and b/app/frontend/public/assets/images/mock/course/download-app.webp differ diff --git a/app/frontend/public/assets/images/mock/course/home-summary.webp b/app/frontend/public/assets/images/mock/course/home-summary.webp new file mode 100644 index 00000000..f1c9d8be Binary files /dev/null and b/app/frontend/public/assets/images/mock/course/home-summary.webp differ diff --git a/app/frontend/public/assets/images/mock/course/teacher-hero.webp b/app/frontend/public/assets/images/mock/course/teacher-hero.webp new file mode 100644 index 00000000..47fc8e90 Binary files /dev/null and b/app/frontend/public/assets/images/mock/course/teacher-hero.webp differ diff --git a/app/frontend/public/assets/images/mock/cover/cover-1.webp b/app/frontend/public/assets/images/mock/cover/cover-1.webp new file mode 100644 index 00000000..9fa25604 Binary files /dev/null and b/app/frontend/public/assets/images/mock/cover/cover-1.webp differ diff --git a/app/frontend/public/assets/images/mock/cover/cover-10.webp b/app/frontend/public/assets/images/mock/cover/cover-10.webp new file mode 100644 index 00000000..82ca7901 Binary files /dev/null and b/app/frontend/public/assets/images/mock/cover/cover-10.webp differ diff --git a/app/frontend/public/assets/images/mock/cover/cover-11.webp b/app/frontend/public/assets/images/mock/cover/cover-11.webp new file mode 100644 index 00000000..41ddd007 Binary files /dev/null and b/app/frontend/public/assets/images/mock/cover/cover-11.webp differ diff --git a/app/frontend/public/assets/images/mock/cover/cover-12.webp b/app/frontend/public/assets/images/mock/cover/cover-12.webp new file mode 100644 index 00000000..a0ab39a7 Binary files /dev/null and b/app/frontend/public/assets/images/mock/cover/cover-12.webp differ diff --git a/app/frontend/public/assets/images/mock/cover/cover-13.webp b/app/frontend/public/assets/images/mock/cover/cover-13.webp new file mode 100644 index 00000000..f650eada Binary files /dev/null and b/app/frontend/public/assets/images/mock/cover/cover-13.webp differ diff --git a/app/frontend/public/assets/images/mock/cover/cover-14.webp b/app/frontend/public/assets/images/mock/cover/cover-14.webp new file mode 100644 index 00000000..cf7493d1 Binary files /dev/null and b/app/frontend/public/assets/images/mock/cover/cover-14.webp differ diff --git a/app/frontend/public/assets/images/mock/cover/cover-15.webp b/app/frontend/public/assets/images/mock/cover/cover-15.webp new file mode 100644 index 00000000..8a82b5dd Binary files /dev/null and b/app/frontend/public/assets/images/mock/cover/cover-15.webp differ diff --git a/app/frontend/public/assets/images/mock/cover/cover-16.webp b/app/frontend/public/assets/images/mock/cover/cover-16.webp new file mode 100644 index 00000000..50abb728 Binary files /dev/null and b/app/frontend/public/assets/images/mock/cover/cover-16.webp differ diff --git a/app/frontend/public/assets/images/mock/cover/cover-17.webp b/app/frontend/public/assets/images/mock/cover/cover-17.webp new file mode 100644 index 00000000..30f36d00 Binary files /dev/null and b/app/frontend/public/assets/images/mock/cover/cover-17.webp differ diff --git a/app/frontend/public/assets/images/mock/cover/cover-18.webp b/app/frontend/public/assets/images/mock/cover/cover-18.webp new file mode 100644 index 00000000..ee64c4a7 Binary files /dev/null and b/app/frontend/public/assets/images/mock/cover/cover-18.webp differ diff --git a/app/frontend/public/assets/images/mock/cover/cover-19.webp b/app/frontend/public/assets/images/mock/cover/cover-19.webp new file mode 100644 index 00000000..4f1962ef Binary files /dev/null and b/app/frontend/public/assets/images/mock/cover/cover-19.webp differ diff --git a/app/frontend/public/assets/images/mock/cover/cover-2.webp b/app/frontend/public/assets/images/mock/cover/cover-2.webp new file mode 100644 index 00000000..5a5d2100 Binary files /dev/null and b/app/frontend/public/assets/images/mock/cover/cover-2.webp differ diff --git a/app/frontend/public/assets/images/mock/cover/cover-20.webp b/app/frontend/public/assets/images/mock/cover/cover-20.webp new file mode 100644 index 00000000..6e26beb8 Binary files /dev/null and b/app/frontend/public/assets/images/mock/cover/cover-20.webp differ diff --git a/app/frontend/public/assets/images/mock/cover/cover-21.webp b/app/frontend/public/assets/images/mock/cover/cover-21.webp new file mode 100644 index 00000000..7f945750 Binary files /dev/null and b/app/frontend/public/assets/images/mock/cover/cover-21.webp differ diff --git a/app/frontend/public/assets/images/mock/cover/cover-22.webp b/app/frontend/public/assets/images/mock/cover/cover-22.webp new file mode 100644 index 00000000..fc88ae66 Binary files /dev/null and b/app/frontend/public/assets/images/mock/cover/cover-22.webp differ diff --git a/app/frontend/public/assets/images/mock/cover/cover-23.webp b/app/frontend/public/assets/images/mock/cover/cover-23.webp new file mode 100644 index 00000000..ab700674 Binary files /dev/null and b/app/frontend/public/assets/images/mock/cover/cover-23.webp differ diff --git a/app/frontend/public/assets/images/mock/cover/cover-24.webp b/app/frontend/public/assets/images/mock/cover/cover-24.webp new file mode 100644 index 00000000..d6b9fb71 Binary files /dev/null and b/app/frontend/public/assets/images/mock/cover/cover-24.webp differ diff --git a/app/frontend/public/assets/images/mock/cover/cover-3.webp b/app/frontend/public/assets/images/mock/cover/cover-3.webp new file mode 100644 index 00000000..5b023b86 Binary files /dev/null and b/app/frontend/public/assets/images/mock/cover/cover-3.webp differ diff --git a/app/frontend/public/assets/images/mock/cover/cover-4.webp b/app/frontend/public/assets/images/mock/cover/cover-4.webp new file mode 100644 index 00000000..0e9ecbf0 Binary files /dev/null and b/app/frontend/public/assets/images/mock/cover/cover-4.webp differ diff --git a/app/frontend/public/assets/images/mock/cover/cover-5.webp b/app/frontend/public/assets/images/mock/cover/cover-5.webp new file mode 100644 index 00000000..8f1785f4 Binary files /dev/null and b/app/frontend/public/assets/images/mock/cover/cover-5.webp differ diff --git a/app/frontend/public/assets/images/mock/cover/cover-6.webp b/app/frontend/public/assets/images/mock/cover/cover-6.webp new file mode 100644 index 00000000..bb46a04c Binary files /dev/null and b/app/frontend/public/assets/images/mock/cover/cover-6.webp differ diff --git a/app/frontend/public/assets/images/mock/cover/cover-7.webp b/app/frontend/public/assets/images/mock/cover/cover-7.webp new file mode 100644 index 00000000..211355e0 Binary files /dev/null and b/app/frontend/public/assets/images/mock/cover/cover-7.webp differ diff --git a/app/frontend/public/assets/images/mock/cover/cover-8.webp b/app/frontend/public/assets/images/mock/cover/cover-8.webp new file mode 100644 index 00000000..70cd3fb2 Binary files /dev/null and b/app/frontend/public/assets/images/mock/cover/cover-8.webp differ diff --git a/app/frontend/public/assets/images/mock/cover/cover-9.webp b/app/frontend/public/assets/images/mock/cover/cover-9.webp new file mode 100644 index 00000000..7445854a Binary files /dev/null and b/app/frontend/public/assets/images/mock/cover/cover-9.webp differ diff --git a/app/frontend/public/assets/images/mock/m-product/product-1.webp b/app/frontend/public/assets/images/mock/m-product/product-1.webp new file mode 100644 index 00000000..75659df3 Binary files /dev/null and b/app/frontend/public/assets/images/mock/m-product/product-1.webp differ diff --git a/app/frontend/public/assets/images/mock/m-product/product-10.webp b/app/frontend/public/assets/images/mock/m-product/product-10.webp new file mode 100644 index 00000000..65e2c60e Binary files /dev/null and b/app/frontend/public/assets/images/mock/m-product/product-10.webp differ diff --git a/app/frontend/public/assets/images/mock/m-product/product-11.webp b/app/frontend/public/assets/images/mock/m-product/product-11.webp new file mode 100644 index 00000000..17c3bc83 Binary files /dev/null and b/app/frontend/public/assets/images/mock/m-product/product-11.webp differ diff --git a/app/frontend/public/assets/images/mock/m-product/product-12.webp b/app/frontend/public/assets/images/mock/m-product/product-12.webp new file mode 100644 index 00000000..2af581e9 Binary files /dev/null and b/app/frontend/public/assets/images/mock/m-product/product-12.webp differ diff --git a/app/frontend/public/assets/images/mock/m-product/product-13.webp b/app/frontend/public/assets/images/mock/m-product/product-13.webp new file mode 100644 index 00000000..c1f403b8 Binary files /dev/null and b/app/frontend/public/assets/images/mock/m-product/product-13.webp differ diff --git a/app/frontend/public/assets/images/mock/m-product/product-14.webp b/app/frontend/public/assets/images/mock/m-product/product-14.webp new file mode 100644 index 00000000..a5896f84 Binary files /dev/null and b/app/frontend/public/assets/images/mock/m-product/product-14.webp differ diff --git a/app/frontend/public/assets/images/mock/m-product/product-15.webp b/app/frontend/public/assets/images/mock/m-product/product-15.webp new file mode 100644 index 00000000..475a7af5 Binary files /dev/null and b/app/frontend/public/assets/images/mock/m-product/product-15.webp differ diff --git a/app/frontend/public/assets/images/mock/m-product/product-16.webp b/app/frontend/public/assets/images/mock/m-product/product-16.webp new file mode 100644 index 00000000..1b3b7ed6 Binary files /dev/null and b/app/frontend/public/assets/images/mock/m-product/product-16.webp differ diff --git a/app/frontend/public/assets/images/mock/m-product/product-17.webp b/app/frontend/public/assets/images/mock/m-product/product-17.webp new file mode 100644 index 00000000..ddd8da03 Binary files /dev/null and b/app/frontend/public/assets/images/mock/m-product/product-17.webp differ diff --git a/app/frontend/public/assets/images/mock/m-product/product-18.webp b/app/frontend/public/assets/images/mock/m-product/product-18.webp new file mode 100644 index 00000000..9ae8e26f Binary files /dev/null and b/app/frontend/public/assets/images/mock/m-product/product-18.webp differ diff --git a/app/frontend/public/assets/images/mock/m-product/product-19.webp b/app/frontend/public/assets/images/mock/m-product/product-19.webp new file mode 100644 index 00000000..c5017f6c Binary files /dev/null and b/app/frontend/public/assets/images/mock/m-product/product-19.webp differ diff --git a/app/frontend/public/assets/images/mock/m-product/product-2.webp b/app/frontend/public/assets/images/mock/m-product/product-2.webp new file mode 100644 index 00000000..65120df9 Binary files /dev/null and b/app/frontend/public/assets/images/mock/m-product/product-2.webp differ diff --git a/app/frontend/public/assets/images/mock/m-product/product-20.webp b/app/frontend/public/assets/images/mock/m-product/product-20.webp new file mode 100644 index 00000000..89317205 Binary files /dev/null and b/app/frontend/public/assets/images/mock/m-product/product-20.webp differ diff --git a/app/frontend/public/assets/images/mock/m-product/product-21.webp b/app/frontend/public/assets/images/mock/m-product/product-21.webp new file mode 100644 index 00000000..c7795b1d Binary files /dev/null and b/app/frontend/public/assets/images/mock/m-product/product-21.webp differ diff --git a/app/frontend/public/assets/images/mock/m-product/product-22.webp b/app/frontend/public/assets/images/mock/m-product/product-22.webp new file mode 100644 index 00000000..b96c8091 Binary files /dev/null and b/app/frontend/public/assets/images/mock/m-product/product-22.webp differ diff --git a/app/frontend/public/assets/images/mock/m-product/product-23.webp b/app/frontend/public/assets/images/mock/m-product/product-23.webp new file mode 100644 index 00000000..a38a7345 Binary files /dev/null and b/app/frontend/public/assets/images/mock/m-product/product-23.webp differ diff --git a/app/frontend/public/assets/images/mock/m-product/product-24.webp b/app/frontend/public/assets/images/mock/m-product/product-24.webp new file mode 100644 index 00000000..643a1a65 Binary files /dev/null and b/app/frontend/public/assets/images/mock/m-product/product-24.webp differ diff --git a/app/frontend/public/assets/images/mock/m-product/product-3.webp b/app/frontend/public/assets/images/mock/m-product/product-3.webp new file mode 100644 index 00000000..07b5312b Binary files /dev/null and b/app/frontend/public/assets/images/mock/m-product/product-3.webp differ diff --git a/app/frontend/public/assets/images/mock/m-product/product-4.webp b/app/frontend/public/assets/images/mock/m-product/product-4.webp new file mode 100644 index 00000000..c18893e7 Binary files /dev/null and b/app/frontend/public/assets/images/mock/m-product/product-4.webp differ diff --git a/app/frontend/public/assets/images/mock/m-product/product-5.webp b/app/frontend/public/assets/images/mock/m-product/product-5.webp new file mode 100644 index 00000000..014c146f Binary files /dev/null and b/app/frontend/public/assets/images/mock/m-product/product-5.webp differ diff --git a/app/frontend/public/assets/images/mock/m-product/product-6.webp b/app/frontend/public/assets/images/mock/m-product/product-6.webp new file mode 100644 index 00000000..3ff7420e Binary files /dev/null and b/app/frontend/public/assets/images/mock/m-product/product-6.webp differ diff --git a/app/frontend/public/assets/images/mock/m-product/product-7.webp b/app/frontend/public/assets/images/mock/m-product/product-7.webp new file mode 100644 index 00000000..6a0e9434 Binary files /dev/null and b/app/frontend/public/assets/images/mock/m-product/product-7.webp differ diff --git a/app/frontend/public/assets/images/mock/m-product/product-8.webp b/app/frontend/public/assets/images/mock/m-product/product-8.webp new file mode 100644 index 00000000..0aae38af Binary files /dev/null and b/app/frontend/public/assets/images/mock/m-product/product-8.webp differ diff --git a/app/frontend/public/assets/images/mock/m-product/product-9.webp b/app/frontend/public/assets/images/mock/m-product/product-9.webp new file mode 100644 index 00000000..2c86ff6f Binary files /dev/null and b/app/frontend/public/assets/images/mock/m-product/product-9.webp differ diff --git a/app/frontend/public/assets/images/mock/portrait/portrait-1.webp b/app/frontend/public/assets/images/mock/portrait/portrait-1.webp new file mode 100644 index 00000000..a36f12a6 Binary files /dev/null and b/app/frontend/public/assets/images/mock/portrait/portrait-1.webp differ diff --git a/app/frontend/public/assets/images/mock/portrait/portrait-2.webp b/app/frontend/public/assets/images/mock/portrait/portrait-2.webp new file mode 100644 index 00000000..4a3bb941 Binary files /dev/null and b/app/frontend/public/assets/images/mock/portrait/portrait-2.webp differ diff --git a/app/frontend/public/assets/images/mock/portrait/portrait-3.webp b/app/frontend/public/assets/images/mock/portrait/portrait-3.webp new file mode 100644 index 00000000..74c5ea6c Binary files /dev/null and b/app/frontend/public/assets/images/mock/portrait/portrait-3.webp differ diff --git a/app/frontend/public/assets/images/mock/portrait/portrait-4.webp b/app/frontend/public/assets/images/mock/portrait/portrait-4.webp new file mode 100644 index 00000000..dd896ac8 Binary files /dev/null and b/app/frontend/public/assets/images/mock/portrait/portrait-4.webp differ diff --git a/app/frontend/public/assets/images/mock/portrait/portrait-5.webp b/app/frontend/public/assets/images/mock/portrait/portrait-5.webp new file mode 100644 index 00000000..ae38d5f1 Binary files /dev/null and b/app/frontend/public/assets/images/mock/portrait/portrait-5.webp differ diff --git a/app/frontend/public/assets/images/mock/portrait/portrait-6.webp b/app/frontend/public/assets/images/mock/portrait/portrait-6.webp new file mode 100644 index 00000000..80833873 Binary files /dev/null and b/app/frontend/public/assets/images/mock/portrait/portrait-6.webp differ diff --git a/app/frontend/public/assets/images/mock/portrait/portrait-7.webp b/app/frontend/public/assets/images/mock/portrait/portrait-7.webp new file mode 100644 index 00000000..99574e9a Binary files /dev/null and b/app/frontend/public/assets/images/mock/portrait/portrait-7.webp differ diff --git a/app/frontend/public/assets/images/mock/portrait/portrait-8.webp b/app/frontend/public/assets/images/mock/portrait/portrait-8.webp new file mode 100644 index 00000000..60874001 Binary files /dev/null and b/app/frontend/public/assets/images/mock/portrait/portrait-8.webp differ diff --git a/app/frontend/public/assets/images/mock/travel/travel-1.webp b/app/frontend/public/assets/images/mock/travel/travel-1.webp new file mode 100644 index 00000000..9f74e39a Binary files /dev/null and b/app/frontend/public/assets/images/mock/travel/travel-1.webp differ diff --git a/app/frontend/public/assets/images/mock/travel/travel-10.webp b/app/frontend/public/assets/images/mock/travel/travel-10.webp new file mode 100644 index 00000000..36be24ac Binary files /dev/null and b/app/frontend/public/assets/images/mock/travel/travel-10.webp differ diff --git a/app/frontend/public/assets/images/mock/travel/travel-11.webp b/app/frontend/public/assets/images/mock/travel/travel-11.webp new file mode 100644 index 00000000..1db744be Binary files /dev/null and b/app/frontend/public/assets/images/mock/travel/travel-11.webp differ diff --git a/app/frontend/public/assets/images/mock/travel/travel-12.webp b/app/frontend/public/assets/images/mock/travel/travel-12.webp new file mode 100644 index 00000000..57926bd0 Binary files /dev/null and b/app/frontend/public/assets/images/mock/travel/travel-12.webp differ diff --git a/app/frontend/public/assets/images/mock/travel/travel-13.webp b/app/frontend/public/assets/images/mock/travel/travel-13.webp new file mode 100644 index 00000000..e58d82ff Binary files /dev/null and b/app/frontend/public/assets/images/mock/travel/travel-13.webp differ diff --git a/app/frontend/public/assets/images/mock/travel/travel-14.webp b/app/frontend/public/assets/images/mock/travel/travel-14.webp new file mode 100644 index 00000000..43cf8ae8 Binary files /dev/null and b/app/frontend/public/assets/images/mock/travel/travel-14.webp differ diff --git a/app/frontend/public/assets/images/mock/travel/travel-15.webp b/app/frontend/public/assets/images/mock/travel/travel-15.webp new file mode 100644 index 00000000..1bdfa49f Binary files /dev/null and b/app/frontend/public/assets/images/mock/travel/travel-15.webp differ diff --git a/app/frontend/public/assets/images/mock/travel/travel-16.webp b/app/frontend/public/assets/images/mock/travel/travel-16.webp new file mode 100644 index 00000000..0f67586f Binary files /dev/null and b/app/frontend/public/assets/images/mock/travel/travel-16.webp differ diff --git a/app/frontend/public/assets/images/mock/travel/travel-2.webp b/app/frontend/public/assets/images/mock/travel/travel-2.webp new file mode 100644 index 00000000..e19da411 Binary files /dev/null and b/app/frontend/public/assets/images/mock/travel/travel-2.webp differ diff --git a/app/frontend/public/assets/images/mock/travel/travel-3.webp b/app/frontend/public/assets/images/mock/travel/travel-3.webp new file mode 100644 index 00000000..c8975ba4 Binary files /dev/null and b/app/frontend/public/assets/images/mock/travel/travel-3.webp differ diff --git a/app/frontend/public/assets/images/mock/travel/travel-4.webp b/app/frontend/public/assets/images/mock/travel/travel-4.webp new file mode 100644 index 00000000..9a1d33fb Binary files /dev/null and b/app/frontend/public/assets/images/mock/travel/travel-4.webp differ diff --git a/app/frontend/public/assets/images/mock/travel/travel-5.webp b/app/frontend/public/assets/images/mock/travel/travel-5.webp new file mode 100644 index 00000000..67af1950 Binary files /dev/null and b/app/frontend/public/assets/images/mock/travel/travel-5.webp differ diff --git a/app/frontend/public/assets/images/mock/travel/travel-6.webp b/app/frontend/public/assets/images/mock/travel/travel-6.webp new file mode 100644 index 00000000..286f72f7 Binary files /dev/null and b/app/frontend/public/assets/images/mock/travel/travel-6.webp differ diff --git a/app/frontend/public/assets/images/mock/travel/travel-7.webp b/app/frontend/public/assets/images/mock/travel/travel-7.webp new file mode 100644 index 00000000..9d4b5e5f Binary files /dev/null and b/app/frontend/public/assets/images/mock/travel/travel-7.webp differ diff --git a/app/frontend/public/assets/images/mock/travel/travel-8.webp b/app/frontend/public/assets/images/mock/travel/travel-8.webp new file mode 100644 index 00000000..08f01e8d Binary files /dev/null and b/app/frontend/public/assets/images/mock/travel/travel-8.webp differ diff --git a/app/frontend/public/assets/images/mock/travel/travel-9.webp b/app/frontend/public/assets/images/mock/travel/travel-9.webp new file mode 100644 index 00000000..84d6095d Binary files /dev/null and b/app/frontend/public/assets/images/mock/travel/travel-9.webp differ diff --git a/app/frontend/public/favicon.ico b/app/frontend/public/favicon.ico new file mode 100644 index 00000000..45b0186d Binary files /dev/null and b/app/frontend/public/favicon.ico differ diff --git a/app/frontend/public/fonts/Roboto-Bold.ttf b/app/frontend/public/fonts/Roboto-Bold.ttf new file mode 100644 index 00000000..d998cf5b Binary files /dev/null and b/app/frontend/public/fonts/Roboto-Bold.ttf differ diff --git a/app/frontend/public/fonts/Roboto-Regular.ttf b/app/frontend/public/fonts/Roboto-Regular.ttf new file mode 100644 index 00000000..2b6392ff Binary files /dev/null and b/app/frontend/public/fonts/Roboto-Regular.ttf differ diff --git a/app/frontend/public/logo/logo-full.png b/app/frontend/public/logo/logo-full.png new file mode 100644 index 00000000..1109675d Binary files /dev/null and b/app/frontend/public/logo/logo-full.png differ diff --git a/app/frontend/public/logo/logo-full.svg b/app/frontend/public/logo/logo-full.svg new file mode 100644 index 00000000..894ef8f6 --- /dev/null +++ b/app/frontend/public/logo/logo-full.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/public/logo/logo-single.png b/app/frontend/public/logo/logo-single.png new file mode 100644 index 00000000..91841a17 Binary files /dev/null and b/app/frontend/public/logo/logo-single.png differ diff --git a/app/frontend/public/logo/logo-single.svg b/app/frontend/public/logo/logo-single.svg new file mode 100644 index 00000000..dec6d539 --- /dev/null +++ b/app/frontend/public/logo/logo-single.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/src/_mock/_blog.ts b/app/frontend/src/_mock/_blog.ts new file mode 100644 index 00000000..b38c446a --- /dev/null +++ b/app/frontend/src/_mock/_blog.ts @@ -0,0 +1,10 @@ +export const POST_PUBLISH_OPTIONS = [ + { value: 'published', label: 'Published' }, + { value: 'draft', label: 'Draft' }, +]; + +export const POST_SORT_OPTIONS = [ + { value: 'latest', label: 'Latest' }, + { value: 'popular', label: 'Popular' }, + { value: 'oldest', label: 'Oldest' }, +]; diff --git a/app/frontend/src/_mock/_calendar.ts b/app/frontend/src/_mock/_calendar.ts new file mode 100644 index 00000000..284ea28f --- /dev/null +++ b/app/frontend/src/_mock/_calendar.ts @@ -0,0 +1,14 @@ +import { info, error, primary, success, warning, secondary } from 'src/theme/core'; + +// ---------------------------------------------------------------------- + +export const CALENDAR_COLOR_OPTIONS = [ + primary.main, + secondary.main, + info.main, + info.darker, + success.main, + warning.main, + error.main, + error.darker, +]; diff --git a/app/frontend/src/_mock/_customerList.ts b/app/frontend/src/_mock/_customerList.ts new file mode 100644 index 00000000..38261a7f --- /dev/null +++ b/app/frontend/src/_mock/_customerList.ts @@ -0,0 +1,21 @@ + +import { _mock } from './_mock'; + +export const _customerList = Array.from({ length: 20 }, (_, index) => ({ + id: _mock.id(index), + username: _mock.username(index), + zipCode: '85807', + state: 'Virginia', + city: 'Rancho Cordova', + role: _mock.role(index), + email: _mock.email(index), + address: '908 Jack Locks', + name: _mock.fullName(index), + isVerified: _mock.boolean(index), + service_type: index % 2 === 0 ? 'PPPoE' : 'Hotspot', + country: _mock.countryNames(index), + avatarUrl: _mock.image.avatar(index), + phoneNumber: _mock.phoneNumber(index), + status: + (index % 2 && 'pending') || (index % 3 && 'banned') || (index % 4 && 'rejected') || 'active', +})); \ No newline at end of file diff --git a/app/frontend/src/_mock/_files.ts b/app/frontend/src/_mock/_files.ts new file mode 100644 index 00000000..a17efbeb --- /dev/null +++ b/app/frontend/src/_mock/_files.ts @@ -0,0 +1,96 @@ +import { _mock } from './_mock'; +import { _tags, _fileNames } from './assets'; + +// ---------------------------------------------------------------------- + +const GB = 1000000000 * 24; + +const FOLDERS = ['Docs', 'Projects', 'Work', 'Training', 'Sport', 'Foods']; + +const URLS = [ + _mock.image.cover(1), + 'https://www.cloud.com/s/c218bo6kjuqyv66/design_suriname_2015.mp3', + 'https://www.cloud.com/s/c218bo6kjuqyv66/expertise_2015_conakry_sao-tome-and-principe_gender.mp4', + 'https://www.cloud.com/s/c218bo6kjuqyv66/money-popup-crack.pdf', + _mock.image.cover(3), + _mock.image.cover(5), + 'https://www.cloud.com/s/c218bo6kjuqyv66/large_news.txt', + 'https://www.cloud.com/s/c218bo6kjuqyv66/nauru-6015-small-fighter-left-gender.psd', + 'https://www.cloud.com/s/c218bo6kjuqyv66/tv-xs.doc', + 'https://www.cloud.com/s/c218bo6kjuqyv66/gustavia-entertainment-productivity.docx', + 'https://www.cloud.com/s/c218bo6kjuqyv66/vintage_bahrain_saipan.xls', + 'https://www.cloud.com/s/c218bo6kjuqyv66/indonesia-quito-nancy-grace-left-glad.xlsx', + 'https://www.cloud.com/s/c218bo6kjuqyv66/legislation-grain.zip', + 'https://www.cloud.com/s/c218bo6kjuqyv66/large_energy_dry_philippines.rar', + 'https://www.cloud.com/s/c218bo6kjuqyv66/footer-243-ecuador.iso', + 'https://www.cloud.com/s/c218bo6kjuqyv66/kyrgyzstan-04795009-picabo-street-guide-style.ai', + 'https://www.cloud.com/s/c218bo6kjuqyv66/india-data-large-gk-chesterton-mother.esp', + 'https://www.cloud.com/s/c218bo6kjuqyv66/footer-barbados-celine-dion.ppt', + 'https://www.cloud.com/s/c218bo6kjuqyv66/socio_respectively_366996.pptx', + 'https://www.cloud.com/s/c218bo6kjuqyv66/socio_ahead_531437_sweden_popup.wav', + 'https://www.cloud.com/s/c218bo6kjuqyv66/trinidad_samuel-morse_bring.m4v', + _mock.image.cover(11), + _mock.image.cover(17), + 'https://www.cloud.com/s/c218bo6kjuqyv66/xl_david-blaine_component_tanzania_books.pdf', +]; + +const SHARED_PERSONS = Array.from({ length: 20 }, (_, index) => ({ + id: _mock.id(index), + name: _mock.fullName(index), + email: _mock.email(index), + avatarUrl: _mock.image.avatar(index), + permission: index % 2 ? 'view' : 'edit', +})); + +export const FILE_TYPE_OPTIONS = [ + 'folder', + 'txt', + 'zip', + 'audio', + 'image', + 'video', + 'word', + 'excel', + 'powerpoint', + 'pdf', + 'photoshop', + 'illustrator', +]; + +// ---------------------------------------------------------------------- + +const shared = (index: number) => + (index === 0 && SHARED_PERSONS.slice(0, 5)) || + (index === 1 && SHARED_PERSONS.slice(5, 9)) || + (index === 2 && SHARED_PERSONS.slice(9, 11)) || + (index === 3 && SHARED_PERSONS.slice(11, 12)) || + []; + +export const _folders = FOLDERS.map((name, index) => ({ + id: `${_mock.id(index)}_folder`, + name, + type: 'folder', + url: URLS[index], + shared: shared(index), + tags: _tags.slice(0, 5), + size: GB / ((index + 1) * 10), + totalFiles: (index + 1) * 100, + createdAt: _mock.time(index), + modifiedAt: _mock.time(index), + isFavorited: _mock.boolean(index + 1), +})); + +export const _files = _fileNames.map((name, index) => ({ + id: `${_mock.id(index)}_file`, + name, + url: URLS[index], + shared: shared(index), + tags: _tags.slice(0, 5), + size: GB / ((index + 1) * 500), + createdAt: _mock.time(index), + modifiedAt: _mock.time(index), + type: `${name.split('.').pop()}`, + isFavorited: _mock.boolean(index + 1), +})); + +export const _allFiles = [..._folders, ..._files]; diff --git a/app/frontend/src/_mock/_invoice.ts b/app/frontend/src/_mock/_invoice.ts new file mode 100644 index 00000000..0df00b76 --- /dev/null +++ b/app/frontend/src/_mock/_invoice.ts @@ -0,0 +1,66 @@ +import { fSub, fAdd } from 'src/utils/format-time'; + +import { _mock } from './_mock'; +import { _tags } from './assets'; +import { _addressBooks } from './_others'; + +// ---------------------------------------------------------------------- + +export const INVOICE_STATUS_OPTIONS = [ + { value: 'paid', label: 'Paid' }, + { value: 'pending', label: 'Pending' }, + { value: 'overdue', label: 'Overdue' }, + { value: 'draft', label: 'Draft' }, +]; + +export const INVOICE_SERVICE_OPTIONS = Array.from({ length: 8 }, (_, index) => ({ + id: _mock.id(index), + name: _tags[index], + price: _mock.number.price(index), +})); + +const ITEMS = Array.from({ length: 3 }, (__, index) => { + const total = INVOICE_SERVICE_OPTIONS[index].price * _mock.number.nativeS(index); + + return { + id: _mock.id(index), + total, + title: _mock.productName(index), + description: _mock.sentence(index), + price: INVOICE_SERVICE_OPTIONS[index].price, + service: INVOICE_SERVICE_OPTIONS[index].name, + quantity: _mock.number.nativeS(index), + }; +}); + +export const _invoices = Array.from({ length: 20 }, (_, index) => { + const taxes = _mock.number.price(index + 1); + + const discount = _mock.number.price(index + 2); + + const shipping = _mock.number.price(index + 3); + + const subtotal = ITEMS.reduce((accumulator, item) => accumulator + item.price * item.quantity, 0); + + const totalAmount = subtotal - shipping - discount + taxes; + + const status = + (index % 2 && 'paid') || (index % 3 && 'pending') || (index % 4 && 'overdue') || 'draft'; + + return { + id: _mock.id(index), + taxes, + status, + discount, + shipping, + subtotal, + totalAmount, + items: ITEMS, + invoiceNumber: `INV-199${index}`, + invoiceFrom: _addressBooks[index], + invoiceTo: _addressBooks[index + 1], + sent: _mock.number.nativeS(index), + createDate: fSub({ days: index }), + dueDate: fAdd({ days: index + 15, hours: index }), + }; +}); diff --git a/app/frontend/src/_mock/_job.ts b/app/frontend/src/_mock/_job.ts new file mode 100644 index 00000000..39ec1f46 --- /dev/null +++ b/app/frontend/src/_mock/_job.ts @@ -0,0 +1,155 @@ +import { _mock } from './_mock'; + +// ---------------------------------------------------------------------- + +export const JOB_DETAILS_TABS = [ + { label: 'Job content', value: 'content' }, + { label: 'Candidates', value: 'candidates' }, +]; + +export const JOB_SKILL_OPTIONS = [ + 'UI', + 'UX', + 'Html', + 'JavaScript', + 'TypeScript', + 'Communication', + 'Problem Solving', + 'Leadership', + 'Time Management', + 'Adaptability', + 'Collaboration', + 'Creativity', + 'Critical Thinking', + 'Technical Skills', + 'Customer Service', + 'Project Management', + 'Problem Diagnosis', +]; + +export const JOB_WORKING_SCHEDULE_OPTIONS = [ + 'Monday to Friday', + 'Weekend availability', + 'Day shift', +]; + +export const JOB_EMPLOYMENT_TYPE_OPTIONS = [ + { label: 'Full-time', value: 'Full-time' }, + { label: 'Part-time', value: 'Part-time' }, + { label: 'On demand', value: 'On demand' }, + { label: 'Negotiable', value: 'Negotiable' }, +]; + +export const JOB_EXPERIENCE_OPTIONS = [ + { label: 'No experience', value: 'No experience' }, + { label: '1 year exp', value: '1 year exp' }, + { label: '2 year exp', value: '2 year exp' }, + { label: '> 3 year exp', value: '> 3 year exp' }, +]; + +export const JOB_BENEFIT_OPTIONS = [ + { label: 'Free parking', value: 'Free parking' }, + { label: 'Bonus commission', value: 'Bonus commission' }, + { label: 'Travel', value: 'Travel' }, + { label: 'Device support', value: 'Device support' }, + { label: 'Health care', value: 'Health care' }, + { label: 'Training', value: 'Training' }, + { label: 'Health insurance', value: 'Health insurance' }, + { label: 'Retirement plans', value: 'Retirement plans' }, + { label: 'Paid time off', value: 'Paid time off' }, + { label: 'Flexible work schedule', value: 'Flexible work schedule' }, +]; + +export const JOB_PUBLISH_OPTIONS = [ + { label: 'Published', value: 'published' }, + { label: 'Draft', value: 'draft' }, +]; + +export const JOB_SORT_OPTIONS = [ + { label: 'Latest', value: 'latest' }, + { label: 'Popular', value: 'popular' }, + { label: 'Oldest', value: 'oldest' }, +]; + +const CANDIDATES = Array.from({ length: 12 }, (_, index) => ({ + id: _mock.id(index), + role: _mock.role(index), + name: _mock.fullName(index), + avatarUrl: _mock.image.avatar(index), +})); + +const CONTENT = ` +
Job description
+ +

Occaecati est et illo quibusdam accusamus qui. Incidunt aut et molestiae ut facere aut. Est quidem iusto praesentium excepturi harum nihil tenetur facilis. Ut omnis voluptates nihil accusantium doloribus eaque debitis.

+ +
Key responsibilities
+ +
    +
  • Working with agency for design drawing detail, quotation and local production.
  • +
  • Produce window displays, signs, interior displays, floor plans and special promotions displays.
  • +
  • Change displays to promote new product launches and reflect festive or seasonal themes.
  • +
  • Planning and executing the open/renovation/ closing store procedure.
  • +
  • Follow‐up store maintenance procedure and keep updating SKU In & Out.
  • +
  • Monitor costs and work within budget.
  • +
  • Liaise with suppliers and source elements.
  • +
+ +
Why You'll love working here
+ +
    +
  • Working with agency for design drawing detail, quotation and local production.
  • +
  • Produce window displays, signs, interior displays, floor plans and special promotions displays.
  • +
  • Change displays to promote new product launches and reflect festive or seasonal themes.
  • +
  • Planning and executing the open/renovation/ closing store procedure.
  • +
  • Follow‐up store maintenance procedure and keep updating SKU In & Out.
  • +
  • Monitor costs and work within budget.
  • +
  • Liaise with suppliers and source elements.
  • +
+`; + +export const _jobs = Array.from({ length: 12 }, (_, index) => { + const publish = index % 3 ? 'published' : 'draft'; + + const salary = { + type: (index % 5 && 'Custom') || 'Hourly', + price: _mock.number.price(index), + negotiable: _mock.boolean(index), + }; + + const benefits = JOB_BENEFIT_OPTIONS.slice(0, 3).map((option) => option.label); + + const experience = + JOB_EXPERIENCE_OPTIONS.map((option) => option.label)[index] || JOB_EXPERIENCE_OPTIONS[1].label; + + const employmentTypes = (index % 2 && ['Part-time']) || + (index % 3 && ['On demand']) || + (index % 4 && ['Negotiable']) || ['Full-time']; + + const company = { + name: _mock.companyNames(index), + logo: _mock.image.company(index), + phoneNumber: _mock.phoneNumber(index), + fullAddress: _mock.fullAddress(index), + }; + + return { + id: _mock.id(index), + salary, + publish, + company, + benefits, + experience, + employmentTypes, + content: CONTENT, + candidates: CANDIDATES, + role: _mock.role(index), + title: _mock.jobTitle(index), + createdAt: _mock.time(index), + expiredDate: _mock.time(index), + skills: JOB_SKILL_OPTIONS.slice(0, 3), + totalViews: _mock.number.nativeL(index), + locations: [_mock.countryNames(1), _mock.countryNames(2)], + workingSchedule: JOB_WORKING_SCHEDULE_OPTIONS.slice(0, 2), + }; +}); diff --git a/app/frontend/src/_mock/_map/cities.ts b/app/frontend/src/_mock/_map/cities.ts new file mode 100644 index 00000000..9836a3eb --- /dev/null +++ b/app/frontend/src/_mock/_map/cities.ts @@ -0,0 +1,182 @@ +export const cities = [ + { + city: 'New York', + population: '8,175,133', + photoUrl: + 'http://upload.wikimedia.org/wikipedia/commons/thumb/b/b9/Above_Gotham.jpg/240px-Above_Gotham.jpg', + state: 'New York', + latitude: 40.6643, + longitude: -73.9385, + }, + { + city: 'Los Angeles', + population: '3,792,621', + photoUrl: + 'http://upload.wikimedia.org/wikipedia/commons/thumb/5/57/LA_Skyline_Mountains2.jpg/240px-LA_Skyline_Mountains2.jpg', + state: 'California', + latitude: 34.0194, + longitude: -118.4108, + }, + { + city: 'Chicago', + population: '2,695,598', + photoUrl: + 'http://upload.wikimedia.org/wikipedia/commons/thumb/8/85/2008-06-10_3000x1000_chicago_skyline.jpg/240px-2008-06-10_3000x1000_chicago_skyline.jpg', + state: 'Illinois', + latitude: 41.8376, + longitude: -87.6818, + }, + { + city: 'Houston', + population: '2,100,263', + photoUrl: + 'http://upload.wikimedia.org/wikipedia/commons/thumb/6/60/Aerial_views_of_the_Houston%2C_Texas%2C_28005u.jpg/240px-Aerial_views_of_the_Houston%2C_Texas%2C_28005u.jpg', + state: 'Texas', + latitude: 29.7805, + longitude: -95.3863, + }, + { + city: 'Phoenix', + population: '1,445,632', + photoUrl: + 'http://upload.wikimedia.org/wikipedia/commons/thumb/b/b9/Downtown_Phoenix_Aerial_Looking_Northeast.jpg/207px-Downtown_Phoenix_Aerial_Looking_Northeast.jpg', + state: 'Arizona', + latitude: 33.5722, + longitude: -112.088, + }, + { + city: 'Philadelphia', + population: '1,526,006', + photoUrl: + 'http://upload.wikimedia.org/wikipedia/commons/thumb/4/4d/Philly_skyline.jpg/240px-Philly_skyline.jpg', + state: 'Pennsylvania', + latitude: 40.0094, + longitude: -75.1333, + }, + { + city: 'San Antonio', + population: '1,327,407', + photoUrl: + 'http://upload.wikimedia.org/wikipedia/commons/thumb/f/ff/Downtown_San_Antonio_View.JPG/240px-Downtown_San_Antonio_View.JPG', + state: 'Texas', + latitude: 29.4724, + longitude: -98.5251, + }, + { + city: 'San Diego', + population: '1,307,402', + photoUrl: + 'http://upload.wikimedia.org/wikipedia/commons/thumb/5/53/US_Navy_110604-N-NS602-574_Navy_and_Marine_Corps_personnel%2C_along_with_community_leaders_from_the_greater_San_Diego_area_come_together_to_commemora.jpg/240px-US_Navy_110604-N-NS602-574_Navy_and_Marine_Corps_personnel%2C_along_with_community_leaders_from_the_greater_San_Diego_area_come_together_to_commemora.jpg', + state: 'California', + latitude: 32.8153, + longitude: -117.135, + }, + { + city: 'Dallas', + population: '1,197,816', + photoUrl: + 'http://upload.wikimedia.org/wikipedia/commons/thumb/a/ab/Dallas_skyline_daytime.jpg/240px-Dallas_skyline_daytime.jpg', + state: 'Texas', + latitude: 32.7757, + longitude: -96.7967, + }, + { + city: 'San Jose', + population: '945,942', + photoUrl: + 'http://upload.wikimedia.org/wikipedia/commons/thumb/1/1e/Downtown_San_Jose_skyline.PNG/240px-Downtown_San_Jose_skyline.PNG', + state: 'California', + latitude: 37.2969, + longitude: -121.8193, + }, + { + city: 'Austin', + population: '790,390', + photoUrl: + 'http://upload.wikimedia.org/wikipedia/commons/thumb/9/97/Austin2012-12-01.JPG/240px-Austin2012-12-01.JPG', + state: 'Texas', + latitude: 30.3072, + longitude: -97.756, + }, + { + city: 'Jacksonville', + population: '821,784', + photoUrl: + 'http://upload.wikimedia.org/wikipedia/commons/thumb/f/f3/Skyline_of_Jacksonville_FL%2C_South_view_20160706_1.jpg/240px-Skyline_of_Jacksonville_FL%2C_South_view_20160706_1.jpg', + state: 'Florida', + latitude: 30.337, + longitude: -81.6613, + }, + { + city: 'San Francisco', + population: '805,235', + photoUrl: + 'http://upload.wikimedia.org/wikipedia/commons/thumb/6/6a/San_Francisco_skyline_from_Coit_Tower.jpg/240px-San_Francisco_skyline_from_Coit_Tower.jpg', + state: 'California', + latitude: 37.7751, + longitude: -122.4193, + }, + { + city: 'Columbus', + population: '787,033', + photoUrl: + 'http://upload.wikimedia.org/wikipedia/commons/thumb/f/fc/Columbus-ohio-skyline-panorama.jpg/240px-Columbus-ohio-skyline-panorama.jpg', + state: 'Ohio', + latitude: 39.9848, + longitude: -82.985, + }, + { + city: 'Indianapolis', + population: '820,445', + photoUrl: + 'http://upload.wikimedia.org/wikipedia/commons/thumb/1/16/Downtown_indy_from_parking_garage_zoom.JPG/213px-Downtown_indy_from_parking_garage_zoom.JPG', + state: 'Indiana', + latitude: 39.7767, + longitude: -86.1459, + }, + { + city: 'Fort Worth', + population: '741,206', + photoUrl: + 'http://upload.wikimedia.org/wikipedia/commons/thumb/d/db/FortWorthTexasSkylineW.jpg/240px-FortWorthTexasSkylineW.jpg', + state: 'Texas', + latitude: 32.7795, + longitude: -97.3463, + }, + { + city: 'Charlotte', + population: '731,424', + photoUrl: + 'http://upload.wikimedia.org/wikipedia/commons/thumb/7/7d/Charlotte_skyline45647.jpg/222px-Charlotte_skyline45647.jpg', + state: 'North Carolina', + latitude: 35.2087, + longitude: -80.8307, + }, + { + city: 'Seattle', + population: '608,660', + photoUrl: + 'http://upload.wikimedia.org/wikipedia/commons/thumb/3/36/SeattleI5Skyline.jpg/240px-SeattleI5Skyline.jpg', + state: 'Washington', + latitude: 47.6205, + longitude: -122.3509, + }, + { + city: 'Denver', + population: '600,158', + photoUrl: + 'http://upload.wikimedia.org/wikipedia/commons/thumb/0/0b/DenverCP.JPG/240px-DenverCP.JPG', + state: 'Colorado', + latitude: 39.7618, + longitude: -104.8806, + }, + { + city: 'El Paso', + population: '649,121', + photoUrl: + 'http://upload.wikimedia.org/wikipedia/commons/thumb/6/6d/Downtown_El_Paso_at_sunset.jpeg/240px-Downtown_El_Paso_at_sunset.jpeg', + state: 'Texas', + latitude: 31.8484, + longitude: -106.427, + }, +]; diff --git a/app/frontend/src/_mock/_map/countries.ts b/app/frontend/src/_mock/_map/countries.ts new file mode 100644 index 00000000..e4cb5bcf --- /dev/null +++ b/app/frontend/src/_mock/_map/countries.ts @@ -0,0 +1,86 @@ +import { _mock } from '../_mock'; + +// ---------------------------------------------------------------------- + +export const countries = [ + { + timezones: ['America/Aruba'], + latlng: [12.5, -69.96666666], + name: 'Aruba', + country_code: 'AW', + capital: 'Oranjestad', + photoUrl: _mock.image.cover(1), + }, + { + timezones: ['Asia/Kabul'], + latlng: [33, 65], + name: 'Afghanistan', + country_code: 'AF', + capital: 'Kabul', + photoUrl: _mock.image.cover(2), + }, + { + timezones: ['Africa/Luanda'], + latlng: [-12.5, 18.5], + name: 'Angola', + country_code: 'AO', + capital: 'Luanda', + photoUrl: _mock.image.cover(3), + }, + { + timezones: ['Pacific/Efate'], + latlng: [-16, 167], + name: 'Vanuatu', + country_code: 'VU', + capital: 'Port Vila', + photoUrl: _mock.image.cover(4), + }, + { + timezones: ['Pacific/Wallis'], + latlng: [-13.3, -176.2], + name: 'Wallis and Futuna', + country_code: 'WF', + capital: 'Mata-Utu', + photoUrl: _mock.image.cover(5), + }, + { + timezones: ['Pacific/Apia'], + latlng: [-13.58333333, -172.33333333], + name: 'Samoa', + country_code: 'WS', + capital: 'Apia', + photoUrl: _mock.image.cover(6), + }, + { + timezones: ['Asia/Aden'], + latlng: [15, 48], + name: 'Yemen', + country_code: 'YE', + capital: "Sana'a", + photoUrl: _mock.image.cover(7), + }, + { + timezones: ['Africa/Johannesburg'], + latlng: [-29, 24], + name: 'South Africa', + country_code: 'ZA', + capital: 'Pretoria', + photoUrl: _mock.image.cover(8), + }, + { + timezones: ['Africa/Lusaka'], + latlng: [-15, 30], + name: 'Zambia', + country_code: 'ZM', + capital: 'Lusaka', + photoUrl: _mock.image.cover(9), + }, + { + timezones: ['Africa/Harare'], + latlng: [-20, 30], + name: 'Zimbabwe', + country_code: 'ZW', + capital: 'Harare', + photoUrl: _mock.image.cover(10), + }, +]; diff --git a/app/frontend/src/_mock/_map/map-style-basic-v8.json b/app/frontend/src/_mock/_map/map-style-basic-v8.json new file mode 100644 index 00000000..4fb6e4eb --- /dev/null +++ b/app/frontend/src/_mock/_map/map-style-basic-v8.json @@ -0,0 +1,554 @@ +{ + "version": 8, + "name": "Basic", + "metadata": { + "mapbox:autocomposite": true + }, + "sources": { + "mapbox": { + "url": "mapbox://mapbox.mapbox-streets-v7", + "type": "vector" + } + }, + "sprite": "mapbox://sprites/mapbox/basic-v8", + "glyphs": "mapbox://fonts/mapbox/{fontstack}/{range}.pbf", + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "#dedede" + }, + "interactive": true + }, + { + "id": "landuse_overlay_national_park", + "type": "fill", + "source": "mapbox", + "source-layer": "landuse_overlay", + "filter": ["==", "class", "national_park"], + "paint": { + "fill-color": "#d2edae", + "fill-opacity": 0.75 + }, + "interactive": true + }, + { + "id": "landuse_park", + "type": "fill", + "source": "mapbox", + "source-layer": "landuse", + "filter": ["==", "class", "park"], + "paint": { + "fill-color": "#d2edae" + }, + "interactive": true + }, + { + "id": "waterway", + "type": "line", + "source": "mapbox", + "source-layer": "waterway", + "filter": ["all", ["==", "$type", "LineString"], ["in", "class", "river", "canal"]], + "paint": { + "line-color": "#a0cfdf", + "line-width": { + "base": 1.4, + "stops": [ + [8, 0.5], + [20, 15] + ] + } + }, + "interactive": true + }, + { + "id": "water", + "type": "fill", + "source": "mapbox", + "source-layer": "water", + "paint": { + "fill-color": "#a0cfdf" + }, + "interactive": true + }, + { + "id": "building", + "type": "fill", + "source": "mapbox", + "source-layer": "building", + "paint": { + "fill-color": "#d6d6d6" + }, + "interactive": true + }, + { + "interactive": true, + "layout": { + "line-cap": "butt", + "line-join": "miter" + }, + "filter": [ + "all", + ["==", "$type", "LineString"], + [ + "all", + [ + "in", + "class", + "motorway_link", + "street", + "street_limited", + "service", + "track", + "pedestrian", + "path", + "link" + ], + ["==", "structure", "tunnel"] + ] + ], + "type": "line", + "source": "mapbox", + "id": "tunnel_minor", + "paint": { + "line-color": "#efefef", + "line-width": { + "base": 1.55, + "stops": [ + [4, 0.25], + [20, 30] + ] + }, + "line-dasharray": [0.36, 0.18] + }, + "source-layer": "road" + }, + { + "interactive": true, + "layout": { + "line-cap": "butt", + "line-join": "miter" + }, + "filter": [ + "all", + ["==", "$type", "LineString"], + [ + "all", + ["in", "class", "motorway", "primary", "secondary", "tertiary", "trunk"], + ["==", "structure", "tunnel"] + ] + ], + "type": "line", + "source": "mapbox", + "id": "tunnel_major", + "paint": { + "line-color": "#fff", + "line-width": { + "base": 1.4, + "stops": [ + [6, 0.5], + [20, 30] + ] + }, + "line-dasharray": [0.28, 0.14] + }, + "source-layer": "road" + }, + { + "interactive": true, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "filter": [ + "all", + ["==", "$type", "LineString"], + [ + "all", + [ + "in", + "class", + "motorway_link", + "street", + "street_limited", + "service", + "track", + "pedestrian", + "path", + "link" + ], + ["in", "structure", "none", "ford"] + ] + ], + "type": "line", + "source": "mapbox", + "id": "road_minor", + "paint": { + "line-color": "#efefef", + "line-width": { + "base": 1.55, + "stops": [ + [4, 0.25], + [20, 30] + ] + } + }, + "source-layer": "road" + }, + { + "interactive": true, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "filter": [ + "all", + ["==", "$type", "LineString"], + [ + "all", + ["in", "class", "motorway", "primary", "secondary", "tertiary", "trunk"], + ["in", "structure", "none", "ford"] + ] + ], + "type": "line", + "source": "mapbox", + "id": "road_major", + "paint": { + "line-color": "#fff", + "line-width": { + "base": 1.4, + "stops": [ + [6, 0.5], + [20, 30] + ] + } + }, + "source-layer": "road" + }, + { + "interactive": true, + "layout": { + "line-cap": "butt", + "line-join": "miter" + }, + "filter": [ + "all", + ["==", "$type", "LineString"], + [ + "all", + [ + "in", + "class", + "motorway_link", + "street", + "street_limited", + "service", + "track", + "pedestrian", + "path", + "link" + ], + ["==", "structure", "bridge"] + ] + ], + "type": "line", + "source": "mapbox", + "id": "bridge_minor case", + "paint": { + "line-color": "#dedede", + "line-width": { + "base": 1.6, + "stops": [ + [12, 0.5], + [20, 10] + ] + }, + "line-gap-width": { + "base": 1.55, + "stops": [ + [4, 0.25], + [20, 30] + ] + } + }, + "source-layer": "road" + }, + { + "interactive": true, + "layout": { + "line-cap": "butt", + "line-join": "miter" + }, + "filter": [ + "all", + ["==", "$type", "LineString"], + [ + "all", + ["in", "class", "motorway", "primary", "secondary", "tertiary", "trunk"], + ["==", "structure", "bridge"] + ] + ], + "type": "line", + "source": "mapbox", + "id": "bridge_major case", + "paint": { + "line-color": "#dedede", + "line-width": { + "base": 1.6, + "stops": [ + [12, 0.5], + [20, 10] + ] + }, + "line-gap-width": { + "base": 1.55, + "stops": [ + [4, 0.25], + [20, 30] + ] + } + }, + "source-layer": "road" + }, + { + "interactive": true, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "filter": [ + "all", + ["==", "$type", "LineString"], + [ + "all", + [ + "in", + "class", + "motorway_link", + "street", + "street_limited", + "service", + "track", + "pedestrian", + "path", + "link" + ], + ["==", "structure", "bridge"] + ] + ], + "type": "line", + "source": "mapbox", + "id": "bridge_minor", + "paint": { + "line-color": "#efefef", + "line-width": { + "base": 1.55, + "stops": [ + [4, 0.25], + [20, 30] + ] + } + }, + "source-layer": "road" + }, + { + "interactive": true, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "filter": [ + "all", + ["==", "$type", "LineString"], + [ + "all", + ["in", "class", "motorway", "primary", "secondary", "tertiary", "trunk"], + ["==", "structure", "bridge"] + ] + ], + "type": "line", + "source": "mapbox", + "id": "bridge_major", + "paint": { + "line-color": "#fff", + "line-width": { + "base": 1.4, + "stops": [ + [6, 0.5], + [20, 30] + ] + } + }, + "source-layer": "road" + }, + { + "interactive": true, + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "filter": [ + "all", + ["==", "$type", "LineString"], + ["all", ["<=", "admin_level", 2], ["==", "maritime", 0]] + ], + "type": "line", + "source": "mapbox", + "id": "admin_country", + "paint": { + "line-color": "#8b8a8a", + "line-width": { + "base": 1.3, + "stops": [ + [3, 0.5], + [22, 15] + ] + } + }, + "source-layer": "admin" + }, + { + "interactive": true, + "minzoom": 5, + "layout": { + "icon-image": "{maki}-11", + "text-offset": [0, 0.5], + "text-field": "{name_en}", + "text-font": ["Open Sans Semibold", "Arial Unicode MS Bold"], + "text-max-width": 8, + "text-anchor": "top", + "text-size": 11, + "icon-size": 1 + }, + "filter": [ + "all", + ["==", "$type", "Point"], + ["all", ["==", "scalerank", 1], ["==", "localrank", 1]] + ], + "type": "symbol", + "source": "mapbox", + "id": "poi_label", + "paint": { + "text-color": "#666", + "text-halo-width": 1, + "text-halo-color": "rgba(255,255,255,0.75)", + "text-halo-blur": 1 + }, + "source-layer": "poi_label" + }, + { + "interactive": true, + "layout": { + "symbol-placement": "line", + "text-field": "{name_en}", + "text-font": ["Open Sans Semibold", "Arial Unicode MS Bold"], + "text-transform": "uppercase", + "text-letter-spacing": 0.1, + "text-size": { + "base": 1.4, + "stops": [ + [10, 8], + [20, 14] + ] + } + }, + "filter": [ + "all", + ["==", "$type", "LineString"], + ["in", "class", "motorway", "primary", "secondary", "tertiary", "trunk"] + ], + "type": "symbol", + "source": "mapbox", + "id": "road_major_label", + "paint": { + "text-color": "#666", + "text-halo-color": "rgba(255,255,255,0.75)", + "text-halo-width": 2 + }, + "source-layer": "road_label" + }, + { + "interactive": true, + "minzoom": 8, + "layout": { + "text-field": "{name_en}", + "text-font": ["Open Sans Semibold", "Arial Unicode MS Bold"], + "text-max-width": 6, + "text-size": { + "stops": [ + [6, 12], + [12, 16] + ] + } + }, + "filter": [ + "all", + ["==", "$type", "Point"], + ["in", "type", "town", "village", "hamlet", "suburb", "neighbourhood", "island"] + ], + "type": "symbol", + "source": "mapbox", + "id": "place_label_other", + "paint": { + "text-color": "#666", + "text-halo-color": "rgba(255,255,255,0.75)", + "text-halo-width": 1, + "text-halo-blur": 1 + }, + "source-layer": "place_label" + }, + { + "interactive": true, + "layout": { + "text-field": "{name_en}", + "text-font": ["Open Sans Bold", "Arial Unicode MS Bold"], + "text-max-width": 10, + "text-size": { + "stops": [ + [3, 12], + [8, 16] + ] + } + }, + "maxzoom": 16, + "filter": ["all", ["==", "$type", "Point"], ["==", "type", "city"]], + "type": "symbol", + "source": "mapbox", + "id": "place_label_city", + "paint": { + "text-color": "#666", + "text-halo-color": "rgba(255,255,255,0.75)", + "text-halo-width": 1, + "text-halo-blur": 1 + }, + "source-layer": "place_label" + }, + { + "interactive": true, + "layout": { + "text-field": "{name_en}", + "text-font": ["Open Sans Regular", "Arial Unicode MS Regular"], + "text-max-width": 10, + "text-size": { + "stops": [ + [3, 14], + [8, 22] + ] + } + }, + "maxzoom": 12, + "filter": ["==", "$type", "Point"], + "type": "symbol", + "source": "mapbox", + "id": "country_label", + "paint": { + "text-color": "#666", + "text-halo-color": "rgba(255,255,255,0.75)", + "text-halo-width": 1, + "text-halo-blur": 1 + }, + "source-layer": "country_label" + } + ] +} diff --git a/app/frontend/src/_mock/_mock.ts b/app/frontend/src/_mock/_mock.ts new file mode 100644 index 00000000..033fea0b --- /dev/null +++ b/app/frontend/src/_mock/_mock.ts @@ -0,0 +1,113 @@ +import { fSub } from 'src/utils/format-time'; + +import { CONFIG } from 'src/global-config'; + +import { + _id, + _ages, + _roles, + _prices, + _emails, + _ratings, + _nativeS, + _nativeM, + _nativeL, + statuses, + _percents, + _booleans, + _sentences, + _lastNames, + _fullNames, + _tourNames, + _jobTitles, + _taskNames, + _fileNames, + _postTitles, + _firstNames, + _eventNames, + _courseNames, + _fullAddress, + packageTypes, + _companyNames, + _productNames, + _descriptions, + _phoneNumbers, + _countryNames, + _indexedContent, +} from './assets'; + +// ---------------------------------------------------------------------- + +// Example subscribers array for mock data +const subscribers = [100, 200, 300, 400, 500]; + +export const _mock = { + id: (index: number) => _id[index], + uuid: (index: number) => `uuid-${_id[index]}`, + time: (index: number) => fSub({ days: index, hours: index }), + boolean: (index: number) => _booleans[index], + role: (index: number) => _roles[index], + // Text + courseNames: (index: number) => _courseNames[index], + indexContentNames: (index: number) => _indexedContent[index], + fileNames: (index: number) => _fileNames[index], + eventNames: (index: number) => _eventNames[index], + taskNames: (index: number) => _taskNames[index], + postTitle: (index: number) => _postTitles[index], + jobTitle: (index: number) => _jobTitles[index], + tourName: (index: number) => _tourNames[index], + productName: (index: number) => _productNames[index], + sentence: (index: number) => _sentences[index], + description: (index: number) => _descriptions[index], + username: (index: number) => `user${index + 1}`, + // Contact + email: (index: number) => _emails[index], + phoneNumber: (index: number) => _phoneNumbers[index], + fullAddress: (index: number) => _fullAddress[index], + // Name + firstName: (index: number) => _firstNames[index], + lastName: (index: number) => _lastNames[index], + fullName: (index: number) => _fullNames[index], + companyNames: (index: number) => _companyNames[index], + countryNames: (index: number) => _countryNames[index], + // Number + number: { + percent: (index: number) => _percents[index], + rating: (index: number) => _ratings[index], + age: (index: number) => _ages[index], + price: (index: number) => _prices[index], + nativeS: (index: number) => _nativeS[index], + nativeM: (index: number) => _nativeM[index], + nativeL: (index: number) => _nativeL[index], + subscribers: (i: number) => subscribers[i % subscribers.length], + }, + // Image + image: { + cover: (index: number) => + `${CONFIG.assetsDir}/assets/images/mock/cover/cover-${index + 1}.webp`, + avatar: (index: number) => + `${CONFIG.assetsDir}/assets/images/mock/avatar/avatar-${index + 1}.webp`, + travel: (index: number) => + `${CONFIG.assetsDir}/assets/images/mock/travel/travel-${index + 1}.webp`, + course: (index: number) => + `${CONFIG.assetsDir}/assets/images/mock/course/course-${index + 1}.webp`, + company: (index: number) => + `${CONFIG.assetsDir}/assets/images/mock/company/company-${index + 1}.webp`, + product: (index: number) => + `${CONFIG.assetsDir}/assets/images/mock/m-product/product-${index + 1}.webp`, + portrait: (index: number) => + `${CONFIG.assetsDir}/assets/images/mock/portrait/portrait-${index + 1}.webp`, + }, + // package + packageType: (i: number) => packageTypes[i % packageTypes.length], + packageName: (i: number) => `Package Plan ${i + 1}`, + dataLimit: (i: number) => `${10 + i * 5}GB`, + timeLimit: (i: number) => `${30 + i * 10}min`, + rateLimit: (i: number) => `${512 + i * 128}k/512k`, + sessionTimeout: (i: number) => 3600 + i * 60, + idleTimeout: (i: number) => 300 + i * 10, + price: (i: number) => parseFloat((4.99 + i).toFixed(2)), + packageStatus: (i: number) => statuses[i % statuses.length], + validityPeriod: (i: number) => `${7 + i} days`, + features: (i: number) => `Feature ${i + 1}, Feature ${i + 2}, Feature ${i + 3}`, +}; diff --git a/app/frontend/src/_mock/_order.ts b/app/frontend/src/_mock/_order.ts new file mode 100644 index 00000000..ee3fc145 --- /dev/null +++ b/app/frontend/src/_mock/_order.ts @@ -0,0 +1,85 @@ +import { _mock } from './_mock'; + +// ---------------------------------------------------------------------- + +export const ORDER_STATUS_OPTIONS = [ + { value: 'pending', label: 'Pending' }, + { value: 'completed', label: 'Completed' }, + { value: 'cancelled', label: 'Cancelled' }, + { value: 'refunded', label: 'Refunded' }, +]; + +const ITEMS = Array.from({ length: 3 }, (_, index) => ({ + id: _mock.id(index), + sku: `16H9UR${index}`, + quantity: index + 1, + name: _mock.productName(index), + coverUrl: _mock.image.product(index), + price: _mock.number.price(index), +})); + +export const _orders = Array.from({ length: 20 }, (_, index) => { + const shipping = 10; + + const discount = 10; + + const taxes = 10; + + const items = (index % 2 && ITEMS.slice(0, 1)) || (index % 3 && ITEMS.slice(1, 3)) || ITEMS; + + const totalQuantity = items.reduce((accumulator, item) => accumulator + item.quantity, 0); + + const subtotal = items.reduce((accumulator, item) => accumulator + item.price * item.quantity, 0); + + const totalAmount = subtotal - shipping - discount + taxes; + + const customer = { + id: _mock.id(index), + name: _mock.fullName(index), + email: _mock.email(index), + avatarUrl: _mock.image.avatar(index), + ipAddress: '192.158.1.38', + }; + + const delivery = { shipBy: 'DHL', speedy: 'Standard', trackingNumber: 'SPX037739199373' }; + + const history = { + orderTime: _mock.time(1), + paymentTime: _mock.time(2), + deliveryTime: _mock.time(3), + completionTime: _mock.time(4), + timeline: [ + { title: 'Delivery successful', time: _mock.time(1) }, + { title: 'Transporting to [2]', time: _mock.time(2) }, + { title: 'Transporting to [1]', time: _mock.time(3) }, + { title: 'The shipping unit has picked up the goods', time: _mock.time(4) }, + { title: 'Order has been created', time: _mock.time(5) }, + ], + }; + + return { + id: _mock.id(index), + orderNumber: `#601${index}`, + createdAt: _mock.time(index), + taxes, + items, + history, + subtotal, + shipping, + discount, + customer, + delivery, + totalAmount, + totalQuantity, + shippingAddress: { + fullAddress: '19034 Verna Unions Apt. 164 - Honolulu, RI / 87535', + phoneNumber: '365-374-4961', + }, + payment: { cardType: 'mastercard', cardNumber: '**** **** **** 5678' }, + status: + (index % 2 && 'completed') || + (index % 3 && 'pending') || + (index % 4 && 'cancelled') || + 'refunded', + }; +}); diff --git a/app/frontend/src/_mock/_others.ts b/app/frontend/src/_mock/_others.ts new file mode 100644 index 00000000..00f2175e --- /dev/null +++ b/app/frontend/src/_mock/_others.ts @@ -0,0 +1,219 @@ +import { _mock } from './_mock'; + +// ---------------------------------------------------------------------- + +export const _carouselsMembers = Array.from({ length: 6 }, (_, index) => ({ + id: _mock.id(index), + name: _mock.fullName(index), + role: _mock.role(index), + avatarUrl: _mock.image.portrait(index), +})); + +// ---------------------------------------------------------------------- + +export const _faqs = Array.from({ length: 8 }, (_, index) => ({ + id: _mock.id(index), + value: `panel${index + 1}`, + heading: `Questions ${index + 1}`, + detail: _mock.description(index), +})); + +// ---------------------------------------------------------------------- + +export const _addressBooks = Array.from({ length: 24 }, (_, index) => ({ + id: _mock.id(index), + primary: index === 0, + name: _mock.fullName(index), + email: _mock.email(index + 1), + fullAddress: _mock.fullAddress(index), + phoneNumber: _mock.phoneNumber(index), + company: _mock.companyNames(index + 1), + addressType: index === 0 ? 'Home' : 'Office', +})); + +// ---------------------------------------------------------------------- + +export const _contacts = Array.from({ length: 20 }, (_, index) => { + const status = + (index % 2 && 'online') || (index % 3 && 'offline') || (index % 4 && 'always') || 'busy'; + + return { + id: _mock.id(index), + status, + role: _mock.role(index), + email: _mock.email(index), + name: _mock.fullName(index), + phoneNumber: _mock.phoneNumber(index), + lastActivity: _mock.time(index), + avatarUrl: _mock.image.avatar(index), + address: _mock.fullAddress(index), + }; +}); + +// ---------------------------------------------------------------------- + +export const _notifications = Array.from({ length: 9 }, (_, index) => ({ + id: _mock.id(index), + avatarUrl: [ + _mock.image.avatar(1), + _mock.image.avatar(2), + _mock.image.avatar(3), + _mock.image.avatar(4), + _mock.image.avatar(5), + null, + null, + null, + null, + null, + ][index], + type: ['friend', 'project', 'file', 'tags', 'payment', 'order', 'chat', 'mail', 'delivery'][ + index + ], + category: [ + 'Communication', + 'Project UI', + 'File manager', + 'File manager', + 'File manager', + 'Order', + 'Order', + 'Communication', + 'Communication', + ][index], + isUnRead: _mock.boolean(index), + createdAt: _mock.time(index), + title: + (index === 0 && `

Deja Brady sent you a friend request

`) || + (index === 1 && + `

Jayvon Hull mentioned you in Minimal UI

`) || + (index === 2 && + `

Lainey Davidson added file to File manager

`) || + (index === 3 && + `

Angelique Morse added new tags to File manager

`) || + (index === 4 && + `

Giana Brandt request a payment of $200

`) || + (index === 5 && `

Your order is placed waiting for shipping

`) || + (index === 6 && `

Delivery processing your order is being shipped

`) || + (index === 7 && `

You have new message 5 unread messages

`) || + (index === 8 && `

You have new mail`) || + '', +})); + +// ---------------------------------------------------------------------- + +export const _mapContact = [ + { latlng: [33, 65], address: _mock.fullAddress(1), phoneNumber: _mock.phoneNumber(1) }, + { latlng: [-12.5, 18.5], address: _mock.fullAddress(2), phoneNumber: _mock.phoneNumber(2) }, +]; + +// ---------------------------------------------------------------------- + +export const _socials = [ + { + value: 'facebook', + label: 'Facebook', + path: 'https://www.facebook.com/caitlyn.kerluke', + }, + { + value: 'instagram', + label: 'Instagram', + path: 'https://www.instagram.com/caitlyn.kerluke', + }, + { + value: 'linkedin', + label: 'Linkedin', + path: 'https://www.linkedin.com/caitlyn.kerluke', + }, + { + value: 'twitter', + label: 'Twitter', + path: 'https://www.twitter.com/caitlyn.kerluke', + }, +]; + +// ---------------------------------------------------------------------- + +export const _pricingPlans = [ + { + subscription: 'basic', + price: 0, + caption: 'Forever', + lists: ['3 prototypes', '3 boards', 'Up to 5 team members'], + labelAction: 'Current plan', + }, + { + subscription: 'starter', + price: 4.99, + caption: 'Saving $24 a year', + lists: [ + '3 prototypes', + '3 boards', + 'Up to 5 team members', + 'Advanced security', + 'Issue escalation', + ], + labelAction: 'Choose starter', + }, + { + subscription: 'premium', + price: 9.99, + caption: 'Saving $124 a year', + lists: [ + '3 prototypes', + '3 boards', + 'Up to 5 team members', + 'Advanced security', + 'Issue escalation', + 'Issue development license', + 'Permissions & workflows', + ], + labelAction: 'Choose premium', + }, +]; + +// ---------------------------------------------------------------------- + +export const _testimonials = [ + { + name: _mock.fullName(1), + postedDate: _mock.time(1), + ratingNumber: _mock.number.rating(1), + avatarUrl: _mock.image.avatar(1), + content: `Excellent Work! Thanks a lot!`, + }, + { + name: _mock.fullName(2), + postedDate: _mock.time(2), + ratingNumber: _mock.number.rating(2), + avatarUrl: _mock.image.avatar(2), + content: `It's a very good dashboard and we are really liking the product . We've done some things, like migrate to TS and implementing a react useContext api, to fit our job methodology but the product is one of the best in terms of design and application architecture. The team did a really good job.`, + }, + { + name: _mock.fullName(3), + postedDate: _mock.time(3), + ratingNumber: _mock.number.rating(3), + avatarUrl: _mock.image.avatar(3), + content: `Customer support is realy fast and helpful the desgin of this theme is looks amazing also the code is very clean and readble realy good job !`, + }, + { + name: _mock.fullName(4), + postedDate: _mock.time(4), + ratingNumber: _mock.number.rating(4), + avatarUrl: _mock.image.avatar(4), + content: `Amazing, really good code quality and gives you a lot of examples for implementations.`, + }, + { + name: _mock.fullName(5), + postedDate: _mock.time(5), + ratingNumber: _mock.number.rating(5), + avatarUrl: _mock.image.avatar(5), + content: `Got a few questions after purchasing the product. The owner responded very fast and very helpfull. Overall the code is excellent and works very good. 5/5 stars!`, + }, + { + name: _mock.fullName(6), + postedDate: _mock.time(6), + ratingNumber: _mock.number.rating(6), + avatarUrl: _mock.image.avatar(6), + content: `CEO of Codealy.io here. We’ve built a developer assessment platform that makes sense - tasks are based on git repositories and run in virtual machines. We automate the pain points - storing candidates code, running it and sharing test results with the whole team, remotely. Bought this template as we need to provide an awesome dashboard for our early customers. I am super happy with purchase. The code is just as good as the design. Thanks!`, + }, +]; diff --git a/app/frontend/src/_mock/_overview.ts b/app/frontend/src/_mock/_overview.ts new file mode 100644 index 00000000..0daf3eed --- /dev/null +++ b/app/frontend/src/_mock/_overview.ts @@ -0,0 +1,387 @@ +import { today } from 'src/utils/format-time'; + +import { CONFIG } from 'src/global-config'; + +import { _mock } from './_mock'; + +// APP +// ---------------------------------------------------------------------- + +export const _appRelated = [ + 'Microsoft office 365', + 'Opera', + 'Adobe acrobat reader DC', + 'Joplin', + 'Topaz photo AI', +].map((name, index) => ({ + id: _mock.id(index), + name, + downloaded: _mock.number.nativeL(index), + ratingNumber: _mock.number.rating(index), + size: _mock.number.nativeL(index) * 1024, + totalReviews: _mock.number.nativeL(index), + shortcut: `${CONFIG.assetsDir}/assets/icons/apps/ic-app-${index + 1}.webp`, + price: [2, 4].includes(index) ? _mock.number.price(index) : 0, +})); + +export const _appInstalled = ['Germany', 'England', 'France', 'Korean', 'USA'].map( + (country, index) => ({ + id: _mock.id(index), + countryName: country, + android: _mock.number.nativeL(index), + windows: _mock.number.nativeL(index + 1), + apple: _mock.number.nativeL(index + 2), + countryCode: ['de', 'gb', 'fr', 'kr', 'us'][index], + }) +); + +export const _appAuthors = Array.from({ length: 3 }, (_, index) => ({ + id: _mock.id(index), + name: _mock.fullName(index), + avatarUrl: _mock.image.avatar(index), + totalFavorites: _mock.number.nativeL(index), +})); + +export const _appInvoices = Array.from({ length: 5 }, (_, index) => { + const category = ['Android', 'Mac', 'Windows', 'Android', 'Mac'][index]; + + const status = ['paid', 'out of date', 'progress', 'paid', 'paid'][index]; + + return { + id: _mock.id(index), + invoiceNumber: `INV-199${index}`, + price: _mock.number.price(index), + category, + status, + }; +}); + +export const _appFeatured = Array.from({ length: 3 }, (_, index) => ({ + id: _mock.id(index + 3), + title: _mock.postTitle(index + 3), + description: _mock.sentence(index + 3), + coverUrl: _mock.image.cover(index + 3), +})); + +// ANALYTIC +// ---------------------------------------------------------------------- + +export const _analyticTasks = Array.from({ length: 5 }, (_, index) => ({ + id: _mock.id(index), + name: _mock.taskNames(index), +})); + +export const _analyticPosts = Array.from({ length: 5 }, (_, index) => ({ + id: _mock.id(index), + postedAt: _mock.time(index), + title: _mock.postTitle(index), + coverUrl: _mock.image.cover(index), + description: _mock.sentence(index), +})); + +export const _analyticOrderTimeline = Array.from({ length: 5 }, (_, index) => { + const title = [ + '1983, orders, $4220', + '12 Invoices have been paid', + 'Order #37745 from September', + 'New order placed #XF-2356', + 'New order placed #XF-2346', + ][index]; + + return { + id: _mock.id(index), + title, + type: `order${index + 1}`, + time: _mock.time(index), + }; +}); + +export const _analyticTraffic = [ + { + value: 'facebook', + label: 'Facebook', + total: _mock.number.nativeL(1), + }, + { + value: 'google', + label: 'Google', + total: _mock.number.nativeL(2), + }, + { + value: 'linkedin', + label: 'Linkedin', + total: _mock.number.nativeL(3), + }, + { + value: 'twitter', + label: 'Twitter', + total: _mock.number.nativeL(4), + }, +]; + +// ECOMMERCE +// ---------------------------------------------------------------------- + +export const _ecommerceSalesOverview = ['Total profit', 'Total income', 'Total expenses'].map( + (label, index) => ({ + label, + totalAmount: _mock.number.price(index) * 100, + value: _mock.number.percent(index), + }) +); + +export const _ecommerceBestSalesman = Array.from({ length: 5 }, (_, index) => { + const category = ['CAP', 'Branded shoes', 'Headphone', 'Cell phone', 'Earings'][index]; + + return { + id: _mock.id(index), + category, + rank: `Top ${index + 1}`, + email: _mock.email(index), + name: _mock.fullName(index), + totalAmount: _mock.number.price(index), + avatarUrl: _mock.image.avatar(index + 8), + countryCode: ['de', 'gb', 'fr', 'kr', 'us'][index], + }; +}); + +export const _ecommerceLatestProducts = Array.from({ length: 5 }, (_, index) => { + const colors = (index === 0 && ['#2EC4B6', '#E71D36', '#FF9F1C', '#011627']) || + (index === 1 && ['#92140C', '#FFCF99']) || + (index === 2 && ['#0CECDD', '#FFF338', '#FF67E7', '#C400FF', '#52006A', '#046582']) || + (index === 3 && ['#845EC2', '#E4007C', '#2A1A5E']) || ['#090088']; + + return { + id: _mock.id(index), + colors, + name: _mock.productName(index), + price: _mock.number.price(index), + coverUrl: _mock.image.product(index), + priceSale: [1, 3].includes(index) ? _mock.number.price(index) : 0, + }; +}); + +export const _ecommerceNewProducts = Array.from({ length: 4 }, (_, index) => ({ + id: _mock.id(index), + name: _mock.productName(index), + coverUrl: _mock.image.product(index), +})); + +// BANKING +// ---------------------------------------------------------------------- + +export const _bankingContacts = Array.from({ length: 12 }, (_, index) => ({ + id: _mock.id(index), + name: _mock.fullName(index), + email: _mock.email(index), + avatarUrl: _mock.image.avatar(index), +})); + +export const _bankingCreditCard = [ + { + id: _mock.id(2), + balance: 23432.03, + cardType: 'mastercard', + cardHolder: _mock.fullName(2), + cardNumber: '**** **** **** 3640', + cardValid: '11/22', + }, + { + id: _mock.id(3), + balance: 18000.23, + cardType: 'visa', + cardHolder: _mock.fullName(3), + cardNumber: '**** **** **** 8864', + cardValid: '11/25', + }, + { + id: _mock.id(4), + balance: 2000.89, + cardType: 'mastercard', + cardHolder: _mock.fullName(4), + cardNumber: '**** **** **** 7755', + cardValid: '11/22', + }, +]; + +export const _bankingRecentTransitions = [ + { + id: _mock.id(2), + name: _mock.fullName(2), + avatarUrl: _mock.image.avatar(2), + type: 'Income', + message: 'Receive money from', + category: 'Annette black', + date: _mock.time(2), + status: 'progress', + amount: _mock.number.price(2), + }, + { + id: _mock.id(3), + name: _mock.fullName(3), + avatarUrl: _mock.image.avatar(3), + type: 'Expenses', + message: 'Payment for', + category: 'Courtney henry', + date: _mock.time(3), + status: 'completed', + amount: _mock.number.price(3), + }, + { + id: _mock.id(4), + name: _mock.fullName(4), + avatarUrl: _mock.image.avatar(4), + type: 'Receive', + message: 'Payment for', + category: 'Theresa webb', + date: _mock.time(4), + status: 'failed', + amount: _mock.number.price(4), + }, + { + id: _mock.id(5), + name: null, + avatarUrl: null, + type: 'Expenses', + message: 'Payment for', + category: 'Fast food', + date: _mock.time(5), + status: 'completed', + amount: _mock.number.price(5), + }, + { + id: _mock.id(6), + name: null, + avatarUrl: null, + type: 'Expenses', + message: 'Payment for', + category: 'Fitness', + date: _mock.time(6), + status: 'progress', + amount: _mock.number.price(6), + }, +]; + +// BOOKING +// ---------------------------------------------------------------------- + +export const _bookings = Array.from({ length: 5 }, (_, index) => { + const status = ['Paid', 'Paid', 'Pending', 'Cancelled', 'Paid'][index]; + + const customer = { + avatarUrl: _mock.image.avatar(index), + name: _mock.fullName(index), + phoneNumber: _mock.phoneNumber(index), + }; + + const destination = Array.from({ length: 5 }, (__, _index) => ({ + name: _mock.tourName(_index + 1), + coverUrl: _mock.image.travel(_index + 1), + }))[index]; + + return { + id: _mock.id(index), + destination, + status, + customer, + checkIn: _mock.time(index), + checkOut: _mock.time(index), + }; +}); + +export const _segmentors = Array.from({ length: 5 }, (_, index) => { + const type = ['rule-based', 'ai/llm', 'experimental', 'rule-based', 'rule-based'][index]; + const seg_name = ['Behavioral Segment', 'RFM Segment', 'Profile Segment', 'Loyalty Segment', 'Experiment'][index]; + + const status = [true, true, false, false, true][index]; + return { + id: _mock.uuid(index), + name: seg_name, + description: _mock.description(index), + avatarUrl: _mock.image.avatar(index), + coverUrl: _mock.image.travel(index), + priority: 1, + type, + enabled: status, + checkIn: _mock.time(index), + checkOut: _mock.time(index), + }; +}); + +export const _segmentorReview = Array.from({ length: 5 }, (_, index) => ({ + id: _mock.id(index), + name: _mock.fullName(index), + postedAt: _mock.time(index), + rating: _mock.number.rating(index), + avatarUrl: _mock.image.avatar(index), + description: _mock.description(index), + tags: ['Great sevice', 'Recommended', 'Best price'], +})); + +export const _segmentorNew = Array.from({ length: 8 }, (_, index) => ({ + guests: '3-5', + id: _mock.id(index), + bookedAt: _mock.time(index), + duration: '3 days 2 nights', + isHot: _mock.boolean(index), + name: _mock.fullName(index), + price: _mock.number.price(index), + avatarUrl: _mock.image.avatar(index), + coverUrl: _mock.image.travel(index), +})); + +export const _segmentorsOverview = Array.from({ length: 3 }, (_, index) => ({ + status: ['Pending', 'Canceled', 'Sold'][index], + quantity: _mock.number.nativeL(index), + value: _mock.number.percent(index + 5), +})); + +export const _bookingReview = Array.from({ length: 5 }, (_, index) => ({ + id: _mock.id(index), + name: _mock.fullName(index), + postedAt: _mock.time(index), + rating: _mock.number.rating(index), + avatarUrl: _mock.image.avatar(index), + description: _mock.description(index), + tags: ['Great sevice', 'Recommended', 'Best price'], +})); + +export const _bookingNew = Array.from({ length: 8 }, (_, index) => ({ + guests: '3-5', + id: _mock.id(index), + bookedAt: _mock.time(index), + duration: '3 days 2 nights', + isHot: _mock.boolean(index), + name: _mock.fullName(index), + price: _mock.number.price(index), + avatarUrl: _mock.image.avatar(index), + coverUrl: _mock.image.travel(index), +})); + +// COURSE +// ---------------------------------------------------------------------- + +export const _coursesContinue = Array.from({ length: 4 }, (_, index) => ({ + id: _mock.id(index), + title: _mock.indexContentNames(index), + coverUrl: _mock.image.course(index), + totalLesson: 5, + currentLesson: 5, +})); + +export const _coursesFeatured = Array.from({ length: 6 }, (_, index) => ({ + id: _mock.id(index), + title: _mock.indexContentNames(index), + coverUrl: _mock.image.course(index + 6), + totalDuration: 220, + totalStudents: _mock.number.nativeM(index), + price: _mock.number.price(index), +})); + +export const _coursesReminder = Array.from({ length: 4 }, (_, index) => ({ + id: _mock.id(index), + title: _mock.indexContentNames(index), + totalLesson: 12, + reminderAt: today(), + currentLesson: index + 7, +})); diff --git a/app/frontend/src/_mock/_package.ts b/app/frontend/src/_mock/_package.ts new file mode 100644 index 00000000..c7122fe1 --- /dev/null +++ b/app/frontend/src/_mock/_package.ts @@ -0,0 +1,20 @@ +import { _mock } from "./_mock"; + +export const _packages = Array.from({ length: 20 }, (_, index) => ({ + id: _mock.id(index), + type: _mock.packageType(index), + name: _mock.packageName(index), + data_limit: _mock.dataLimit(index), + time_limit: _mock.timeLimit(index), + rate_limit: _mock.rateLimit(index), + session_timeout: _mock.sessionTimeout(index), + idle_timeout: _mock.idleTimeout(index), + price: _mock.price(index), + status: _mock.packageStatus(index), + validity_period: _mock.validityPeriod(index), + features: _mock.features(index), + subscribers: _mock.number.subscribers(index), + description: _mock.sentence(index), + created_at: _mock.time(index), + updated_at: _mock.time(index), +})); \ No newline at end of file diff --git a/app/frontend/src/_mock/_product.ts b/app/frontend/src/_mock/_product.ts new file mode 100644 index 00000000..1f765a57 --- /dev/null +++ b/app/frontend/src/_mock/_product.ts @@ -0,0 +1,72 @@ +export const PRODUCT_GENDER_OPTIONS = [ + { label: 'Men', value: 'Men' }, + { label: 'Women', value: 'Women' }, + { label: 'Kids', value: 'Kids' }, +]; + +export const PRODUCT_CATEGORY_OPTIONS = ['Shose', 'Apparel', 'Accessories']; + +export const PRODUCT_RATING_OPTIONS = ['up4Star', 'up3Star', 'up2Star', 'up1Star']; + +export const PRODUCT_COLOR_OPTIONS = [ + '#FF4842', + '#1890FF', + '#FFC0CB', + '#00AB55', + '#FFC107', + '#7F00FF', + '#000000', + '#FFFFFF', +]; + +export const PRODUCT_COLOR_NAME_OPTIONS = [ + { value: '#FF4842', label: 'Red' }, + { value: '#1890FF', label: 'Blue' }, + { value: '#FFC0CB', label: 'Pink' }, + { value: '#00AB55', label: 'Green' }, + { value: '#FFC107', label: 'Yellow' }, + { value: '#7F00FF', label: 'Violet' }, + { value: '#000000', label: 'Black' }, + { value: '#FFFFFF', label: 'White' }, +]; + +export const PRODUCT_SIZE_OPTIONS = [ + { value: '7', label: '7' }, + { value: '8', label: '8' }, + { value: '8.5', label: '8.5' }, + { value: '9', label: '9' }, + { value: '9.5', label: '9.5' }, + { value: '10', label: '10' }, + { value: '10.5', label: '10.5' }, + { value: '11', label: '11' }, + { value: '11.5', label: '11.5' }, + { value: '12', label: '12' }, + { value: '13', label: '13' }, +]; + +export const LEAVE_TYPE_OPTIONS = [ + { value: 'casual', label: 'Casual' }, + { value: '1', label: 'Annual' }, + { value: 'sick', label: 'Sick' }, + { value: 'unpaid', label: 'Unpaid' }, + { value: 'maternity', label: 'Maternity' }, + { value: 'paternity', label: 'Paternity' }, +]; + +export const PRODUCT_PUBLISH_OPTIONS = [ + { value: 'published', label: 'Published' }, + { value: 'draft', label: 'Draft' }, +]; + +export const PRODUCT_SORT_OPTIONS = [ + { value: 'featured', label: 'Featured' }, + { value: 'newest', label: 'Newest' }, + { value: 'priceDesc', label: 'Price: High - Low' }, + { value: 'priceAsc', label: 'Price: Low - High' }, +]; + +export const PRODUCT_CATEGORY_GROUP_OPTIONS = [ + { group: 'Clothing', classify: ['Shirts', 'T-shirts', 'Jeans', 'Leather', 'Accessories'] }, + { group: 'Tailored', classify: ['Suits', 'Blazers', 'Trousers', 'Waistcoats', 'Apparel'] }, + { group: 'Accessories', classify: ['Shoes', 'Backpacks and bags', 'Bracelets', 'Face masks'] }, +]; diff --git a/app/frontend/src/_mock/_segmentors.ts b/app/frontend/src/_mock/_segmentors.ts new file mode 100644 index 00000000..ca8c3585 --- /dev/null +++ b/app/frontend/src/_mock/_segmentors.ts @@ -0,0 +1,56 @@ +// --- CUSTOMER SEGMENTATION FEATURES (Input for the Segmenter Agent) --- + +export const CUSTOMER_DEMOGRAPHIC_OPTIONS = [ + { label: 'High LTV (Lifetime Value)', value: 'high_ltv' }, + { label: 'First-Time Buyer', value: 'first_time' }, + { label: 'Subscription Holder', value: 'subscriber' }, + { label: 'Recent Churn Risk', value: 'churn_risk' }, +]; + +export const CUSTOMER_BEHAVIOR_OPTIONS = [ + 'Browsed Product X', + 'Abandoned Cart', + 'Opened Support Ticket', + 'Interacted with Ad Y', +]; + +export const CUSTOMER_RECENCY_OPTIONS = ['< 7 days', '< 30 days', '< 90 days', '> 90 days']; + + +// --- ORCHESTRATION & CAMPAIGN CONFIGURATION (Segmenter Output/Goal) --- + +export const CAMPAIGN_GOAL_OPTIONS = [ + { label: 'Increase Conversion Rate', value: 'convert' }, + { label: 'Reduce Churn / Re-engage', value: 're_engage' }, + { label: 'Promote New Product', value: 'upsell' }, + { label: 'Improve CSAT Score', value: 'csat' }, +]; + +export const SEGMENTATION_MODEL_OPTIONS = [ + { value: 'rfm_model', label: 'RFM (Recency, Frequency, Monetary)' }, + { value: 'intent_model', label: 'Intent-Based Clustering' }, + { value: 'predictive_ltv', label: 'Predictive LTV Model' }, + { value: 'goal_router', label: 'Goal-Specific Routing' }, +]; + +export const MESSAGE_CHANNEL_OPTIONS = [ + { value: 'email', label: 'Email' }, + { value: 'sms', label: 'SMS' }, + { value: 'in_app', label: 'In-App Notification' }, + { value: 'voice_call', label: 'Voice (TTS)' }, +]; + + +// --- AGENT STATUS & AUDIT OPTIONS (Compliance/Safety Agent) --- + +export const SAFETY_STATUS_OPTIONS = [ + { value: 'approved', label: 'Approved' }, + { value: 'blocked', label: 'Blocked by Safety Agent' }, + { value: 'rewritten', label: 'Auto-Rewritten' }, + { value: 'pending_review', label: 'Human Review Needed' }, +]; + +export const VIOLATION_TYPE_OPTIONS = [ + { group: 'Content Policy', classify: ['Medical Claim', 'Misleading Price', 'Toxic Language'] }, + { group: 'Brand Guidelines', classify: ['Tone Violation', 'Off-Brand Imagery'] }, +]; \ No newline at end of file diff --git a/app/frontend/src/_mock/_tour.ts b/app/frontend/src/_mock/_tour.ts new file mode 100644 index 00000000..79665db8 --- /dev/null +++ b/app/frontend/src/_mock/_tour.ts @@ -0,0 +1,128 @@ +import { _mock } from './_mock'; +import { _tags } from './assets'; + +// ---------------------------------------------------------------------- + +export const TOUR_DETAILS_TABS = [ + { label: 'Tour content', value: 'content' }, + { label: 'Booker', value: 'bookers' }, +]; + +export const TOUR_SORT_OPTIONS = [ + { label: 'Latest', value: 'latest' }, + { label: 'Popular', value: 'popular' }, + { label: 'Oldest', value: 'oldest' }, +]; + +export const TOUR_PUBLISH_OPTIONS = [ + { label: 'Published', value: 'published' }, + { label: 'Draft', value: 'draft' }, +]; + +export const TOUR_SERVICE_OPTIONS = [ + { label: 'Audio guide', value: 'Audio guide' }, + { label: 'Food and drinks', value: 'Food and drinks' }, + { label: 'Lunch', value: 'Lunch' }, + { label: 'Private tour', value: 'Private tour' }, + { label: 'Special activities', value: 'Special activities' }, + { label: 'Entrance fees', value: 'Entrance fees' }, + { label: 'Gratuities', value: 'Gratuities' }, + { label: 'Pick-up and drop off', value: 'Pick-up and drop off' }, + { label: 'Professional guide', value: 'Professional guide' }, + { label: 'Transport by air-conditioned', value: 'Transport by air-conditioned' }, +]; + +const CONTENT = ` +

Description
+ +

Occaecati est et illo quibusdam accusamus qui. Incidunt aut et molestiae ut facere aut. Est quidem iusto praesentium excepturi harum nihil tenetur facilis. Ut omnis voluptates nihil accusantium doloribus eaque debitis.

+ +
Highlights
+ +
    +
  • A fermentum in morbi pretium aliquam adipiscing donec tempus.
  • +
  • Vulputate placerat amet pulvinar lorem nisl.
  • +
  • Consequat feugiat habitant gravida quisque elit bibendum id adipiscing sed.
  • +
  • Etiam duis lobortis in fames ultrices commodo nibh.
  • +
+ +
Program
+ +

+ Day 1 +

+ +

Amet minim mollit non deserunt ullamco est sit aliqua dolor do amet sint. Velit officia consequat duis enim velit mollit. Exercitation veniam consequat sunt nostrud amet.

+ +

+ Day 2 +

+ +

Amet minim mollit non deserunt ullamco est sit aliqua dolor do amet sint. Velit officia consequat duis enim velit mollit. Exercitation veniam consequat sunt nostrud amet.

+ +

+ Day 3 +

+ +

Amet minim mollit non deserunt ullamco est sit aliqua dolor do amet sint. Velit officia consequat duis enim velit mollit. Exercitation veniam consequat sunt nostrud amet.

+`; + +const BOOKER = Array.from({ length: 12 }, (_, index) => ({ + id: _mock.id(index), + guests: index + 10, + name: _mock.fullName(index), + avatarUrl: _mock.image.avatar(index), +})); + +export const _tourGuides = Array.from({ length: 12 }, (_, index) => ({ + id: _mock.id(index), + name: _mock.fullName(index), + avatarUrl: _mock.image.avatar(index), + phoneNumber: _mock.phoneNumber(index), +})); + +export const TRAVEL_IMAGES = Array.from({ length: 16 }, (_, index) => _mock.image.travel(index)); + +export const _tours = Array.from({ length: 12 }, (_, index) => { + const available = { startDate: _mock.time(index + 1), endDate: _mock.time(index) }; + + const publish = index % 3 ? 'published' : 'draft'; + + const services = (index % 2 && ['Audio guide', 'Food and drinks']) || + (index % 3 && ['Lunch', 'Private tour']) || + (index % 4 && ['Special activities', 'Entrance fees']) || [ + 'Gratuities', + 'Pick-up and drop off', + 'Professional guide', + 'Transport by air-conditioned', + ]; + + const tourGuides = + (index === 0 && _tourGuides.slice(0, 1)) || + (index === 1 && _tourGuides.slice(1, 3)) || + (index === 2 && _tourGuides.slice(2, 5)) || + (index === 3 && _tourGuides.slice(4, 6)) || + _tourGuides.slice(6, 9); + + const images = TRAVEL_IMAGES.slice(index, index + 5); + + return { + images, + publish, + services, + available, + tourGuides, + bookers: BOOKER, + content: CONTENT, + id: _mock.id(index), + tags: _tags.slice(0, 5), + name: _mock.tourName(index), + createdAt: _mock.time(index), + durations: '4 days 3 nights', + price: _mock.number.price(index), + destination: _mock.countryNames(index), + priceSale: _mock.number.price(index), + totalViews: _mock.number.nativeL(index), + ratingNumber: _mock.number.rating(index), + }; +}); diff --git a/app/frontend/src/_mock/_user.ts b/app/frontend/src/_mock/_user.ts new file mode 100644 index 00000000..729e39e0 --- /dev/null +++ b/app/frontend/src/_mock/_user.ts @@ -0,0 +1,144 @@ +import { _mock } from './_mock'; + +// ---------------------------------------------------------------------- + +export const USER_STATUS_OPTIONS = [ + { value: 'active', label: 'Active' }, + { value: 'pending', label: 'Pending' }, + { value: 'banned', label: 'Banned' }, + { value: 'rejected', label: 'Rejected' }, +]; + +export const _userAbout = { + id: _mock.id(1), + role: _mock.role(1), + email: _mock.email(1), + school: _mock.companyNames(2), + company: _mock.companyNames(1), + country: _mock.countryNames(2), + coverUrl: _mock.image.cover(3), + totalFollowers: _mock.number.nativeL(1), + totalFollowing: _mock.number.nativeL(2), + quote: + 'Tart I love sugar plum I love oat cake. Sweet roll caramels I love jujubes. Topping cake wafer..', + socialLinks: { + facebook: `https://www.facebook.com/caitlyn.kerluke`, + instagram: `https://www.instagram.com/caitlyn.kerluke`, + linkedin: `https://www.linkedin.com/in/caitlyn.kerluke`, + twitter: `https://www.twitter.com/caitlyn.kerluke`, + }, +}; + +export const _userFollowers = Array.from({ length: 18 }, (_, index) => ({ + id: _mock.id(index), + name: _mock.fullName(index), + country: _mock.countryNames(index), + avatarUrl: _mock.image.avatar(index), +})); + +export const _userFriends = Array.from({ length: 18 }, (_, index) => ({ + id: _mock.id(index), + role: _mock.role(index), + name: _mock.fullName(index), + avatarUrl: _mock.image.avatar(index), +})); + +export const _userGallery = Array.from({ length: 12 }, (_, index) => ({ + id: _mock.id(index), + postedAt: _mock.time(index), + title: _mock.postTitle(index), + imageUrl: _mock.image.cover(index), +})); + +export const _userFeeds = Array.from({ length: 3 }, (_, index) => ({ + id: _mock.id(index), + createdAt: _mock.time(index), + media: _mock.image.travel(index + 1), + message: _mock.sentence(index), + personLikes: Array.from({ length: 20 }, (__, personIndex) => ({ + name: _mock.fullName(personIndex), + avatarUrl: _mock.image.avatar(personIndex + 2), + })), + comments: (index === 2 && []) || [ + { + id: _mock.id(7), + author: { + id: _mock.id(8), + avatarUrl: _mock.image.avatar(index + 5), + name: _mock.fullName(index + 5), + }, + createdAt: _mock.time(2), + message: 'Praesent venenatis metus at', + }, + { + id: _mock.id(9), + author: { + id: _mock.id(10), + avatarUrl: _mock.image.avatar(index + 6), + name: _mock.fullName(index + 6), + }, + createdAt: _mock.time(3), + message: + 'Etiam rhoncus. Nullam vel sem. Pellentesque libero tortor, tincidunt et, tincidunt eget, semper nec, quam. Sed lectus.', + }, + ], +})); + +export const _userCards = Array.from({ length: 21 }, (_, index) => ({ + id: _mock.id(index), + role: _mock.role(index), + name: _mock.fullName(index), + coverUrl: _mock.image.cover(index), + avatarUrl: _mock.image.avatar(index), + totalFollowers: _mock.number.nativeL(index), + totalPosts: _mock.number.nativeL(index + 2), + totalFollowing: _mock.number.nativeL(index + 1), +})); + +export const _userPayment = Array.from({ length: 3 }, (_, index) => ({ + id: _mock.id(index), + cardNumber: ['**** **** **** 1234', '**** **** **** 5678', '**** **** **** 7878'][index], + cardType: ['mastercard', 'visa', 'visa'][index], + primary: index === 1, +})); + +export const _userAddressBook = Array.from({ length: 4 }, (_, index) => ({ + id: _mock.id(index), + primary: index === 0, + name: _mock.fullName(index), + phoneNumber: _mock.phoneNumber(index), + fullAddress: _mock.fullAddress(index), + addressType: (index === 0 && 'Home') || 'Office', +})); + +export const _userInvoices = Array.from({ length: 10 }, (_, index) => ({ + id: _mock.id(index), + invoiceNumber: `INV-199${index}`, + createdAt: _mock.time(index), + price: _mock.number.price(index), +})); + +export const _userPlans = [ + { subscription: 'basic', price: 0, primary: false }, + { subscription: 'starter', price: 4.99, primary: true }, + { subscription: 'premium', price: 9.99, primary: false }, +]; + +export const _userList = Array.from({ length: 20 }, (_, index) => ({ + id: _mock.id(index), + zipCode: '85807', + state: 'Virginia', + city: 'Rancho Cordova', + role: _mock.role(index), + email: _mock.email(index), + address: '908 Jack Locks', + name: _mock.fullName(index), + isVerified: _mock.boolean(index), + company: _mock.companyNames(index), + country: _mock.countryNames(index), + avatarUrl: _mock.image.avatar(index), + phoneNumber: _mock.phoneNumber(index), + status: + (index % 2 && 'pending') || (index % 3 && 'banned') || (index % 4 && 'rejected') || 'active', +})); + diff --git a/app/frontend/src/_mock/assets.ts b/app/frontend/src/_mock/assets.ts new file mode 100644 index 00000000..7eea1210 --- /dev/null +++ b/app/frontend/src/_mock/assets.ts @@ -0,0 +1,723 @@ +// ---------------------------------------------------------------------- + +export const _id = Array.from( + { length: 40 }, + (_, index) => `e99f09a7-dd88-49d5-b1c8-1daf80c2d7b${index + 1}` +); + +// ---------------------------------------------------------------------- + +export const _booleans = [ + true, + true, + true, + false, + false, + true, + false, + false, + false, + false, + true, + true, + true, + false, + false, + false, + true, + false, + false, + false, + true, + false, + false, + true, +]; + +// ---------------------------------------------------------------------- + +export const _prices = [ + 83.74, 97.14, 68.71, 85.21, 52.17, 25.18, 43.84, 60.98, 98.42, 53.37, 72.75, 56.61, 64.55, 77.32, + 60.62, 79.81, 93.68, 47.44, 76.24, 92.87, 72.91, 20.54, 94.25, 37.51, +]; + +export const _ratings = [ + 4.2, 3.7, 4.5, 3.5, 0.5, 3.0, 2.5, 2.8, 4.9, 3.6, 2.5, 1.7, 3.9, 2.8, 4.1, 4.5, 2.2, 3.2, 0.6, + 1.3, 3.8, 3.8, 3.8, 2.0, +]; + +export const _ages = [ + 30, 26, 59, 47, 29, 46, 18, 56, 39, 19, 45, 18, 46, 56, 38, 41, 44, 48, 32, 45, 42, 60, 33, 57, +]; + +export const _percents = [ + 10.1, 13.6, 28.2, 42.1, 37.2, 18.5, 40.1, 94.8, 91.4, 53.0, 25.4, 62.9, 86.6, 62.4, 35.4, 17.6, + 52.0, 6.8, 95.3, 26.6, 69.9, 92.1, 46.2, 85.6, +]; + +export const _nativeS = [ + 11, 10, 7, 10, 12, 5, 10, 1, 8, 8, 10, 11, 12, 8, 4, 11, 8, 9, 4, 9, 2, 6, 3, 7, +]; + +export const _nativeM = [ + 497, 763, 684, 451, 433, 463, 951, 194, 425, 435, 807, 521, 538, 839, 394, 269, 453, 821, 364, + 849, 804, 776, 263, 239, +]; + +export const _nativeL = [ + 9911, 1947, 9124, 6984, 8488, 2034, 3364, 8401, 8996, 5271, 8478, 1139, 8061, 3035, 6733, 3952, + 2405, 3127, 6843, 4672, 6995, 6053, 5192, 9686, +]; + +export const _fullAddress = [ + `19034 Verna Unions Apt. 164 - Honolulu, RI / 87535`, + `1147 Rohan Drive Suite 819 - Burlington, VT / 82021`, + `18605 Thompson Circle Apt. 086 - Idaho Falls, WV / 50337`, + `110 Lamar Station Apt. 730 - Hagerstown, OK / 49808`, + `36901 Elmer Spurs Apt. 762 - Miramar, DE / 92836`, + `2089 Runolfsson Harbors Suite 886 - Chapel Hill, TX / 32827`, + `279 Karolann Ports Apt. 774 - Prescott Valley, WV / 53905`, + `96607 Claire Square Suite 591 - St. Louis Park, HI / 40802`, + `9388 Auer Station Suite 573 - Honolulu, AK / 98024`, + `47665 Adaline Squares Suite 510 - Blacksburg, NE / 53515`, + `989 Vernice Flats Apt. 183 - Billings, NV / 04147`, + `91020 Wehner Locks Apt. 673 - Albany, WY / 68763`, + `585 Candelario Pass Suite 090 - Columbus, LA / 25376`, + `80988 Renner Crest Apt. 000 - Fargo, VA / 24266`, + `28307 Shayne Pike Suite 523 - North Las Vegas, AZ / 28550`, + `205 Farrell Highway Suite 333 - Rock Hill, OK / 63421`, + `253 Kara Motorway Suite 821 - Manchester, SD / 09331`, + `13663 Kiara Oval Suite 606 - Missoula, AR / 44478`, + `8110 Claire Port Apt. 703 - Anchorage, TN / 01753`, + `4642 Demetris Lane Suite 407 - Edmond, AZ / 60888`, + `74794 Asha Flat Suite 890 - Lancaster, OR / 13466`, + `8135 Keeling Pines Apt. 326 - Alexandria, MA / 89442`, + `441 Gibson Shores Suite 247 - Pasco, NM / 60678`, + `4373 Emelia Valley Suite 596 - Columbia, NM / 42586`, +]; + +// ---------------------------------------------------------------------- + +export const _emails = [ + `nannie.abernathy70@yahoo.com`, + `ashlynn.ohara62@gmail.com`, + `milo.farrell@hotmail.com`, + `violet.ratke86@yahoo.com`, + `letha.lubowitz24@yahoo.com`, + `aditya.greenfelder31@gmail.com`, + `lenna.bergnaum27@hotmail.com`, + `luella.ryan33@gmail.com`, + `joana.simonis84@gmail.com`, + `marjolaine.white94@gmail.com`, + `vergie.block82@hotmail.com`, + `vito.hudson@hotmail.com`, + `tyrel.greenholt@gmail.com`, + `dwight.block85@yahoo.com`, + `mireya13@hotmail.com`, + `dasia.jenkins@hotmail.com`, + `benny89@yahoo.com`, + `dawn.goyette@gmail.com`, + `zella.hickle4@yahoo.com`, + `avery43@hotmail.com`, + `olen.legros@gmail.com`, + `jimmie.gerhold73@hotmail.com`, + `genevieve.powlowski@hotmail.com`, + `louie.kuphal39@gmail.com`, +]; + +// ---------------------------------------------------------------------- + +export const _fullNames = [ + `Jayvion Simon`, + `Lucian Obrien`, + `Deja Brady`, + `Harrison Stein`, + `Reece Chung`, + `Lainey Davidson`, + `Cristopher Cardenas`, + `Melanie Noble`, + `Chase Day`, + `Shawn Manning`, + `Soren Durham`, + `Cortez Herring`, + `Brycen Jimenez`, + `Giana Brandt`, + `Aspen Schmitt`, + `Colten Aguilar`, + `Angelique Morse`, + `Selina Boyer`, + `Lawson Bass`, + `Ariana Lang`, + `Amiah Pruitt`, + `Harold Mcgrath`, + `Esperanza Mcintyre`, + `Mireya Conner`, +]; + +export const _firstNames = [ + `Mossie`, + `David`, + `Ebba`, + `Chester`, + `Eula`, + `Jaren`, + `Boyd`, + `Brady`, + `Aida`, + `Anastasia`, + `Gregoria`, + `Julianne`, + `Ila`, + `Elyssa`, + `Lucio`, + `Lewis`, + `Jacinthe`, + `Molly`, + `Brown`, + `Fritz`, + `Keon`, + `Ella`, + `Ken`, + `Whitney`, +]; + +export const _lastNames = [ + `Carroll`, + `Simonis`, + `Yost`, + `Hand`, + `Emmerich`, + `Wilderman`, + `Howell`, + `Sporer`, + `Boehm`, + `Morar`, + `Koch`, + `Reynolds`, + `Padberg`, + `Watsica`, + `Upton`, + `Yundt`, + `Pfeffer`, + `Parker`, + `Zulauf`, + `Treutel`, + `McDermott`, + `McDermott`, + `Cruickshank`, + `Parisian`, +]; + +// ---------------------------------------------------------------------- + +export const _phoneNumbers = [ + '+1 202-555-0143', + '+1 416-555-0198', + '+44 20 7946 0958', + '+61 2 9876 5432', + '+91 22 1234 5678', + '+49 30 123456', + '+33 1 23456789', + '+81 3 1234 5678', + '+86 10 1234 5678', + '+55 11 2345-6789', + '+27 11 123 4567', + '+7 495 123-4567', + '+52 55 1234 5678', + '+39 06 123 4567', + '+34 91 123 4567', + '+31 20 123 4567', + '+46 8 123 456', + '+41 22 123 45 67', + '+82 2 123 4567', + '+54 11 1234-5678', + '+64 9 123 4567', + '+65 1234 5678', + '+60 3-1234 5678', + '+66 2 123 4567', + '+62 21 123 4567', + '+63 2 123 4567', + '+90 212 123 45 67', + '+966 11 123 4567', + '+971 2 123 4567', + '+20 2 12345678', + '+234 1 123 4567', + '+254 20 123 4567', + '+972 3-123-4567', + '+30 21 1234 5678', + '+353 1 123 4567', + '+351 21 123 4567', + '+47 21 23 45 67', + '+45 32 12 34 56', + '+358 9 123 4567', + '+48 22 123 45 67', +]; + +// ---------------------------------------------------------------------- + +export const _countryNames = [ + 'United States', + 'Canada', + 'United Kingdom', + 'Australia', + 'India', + 'Germany', + 'France', + 'Japan', + 'China', + 'Brazil', + 'South Africa', + 'Russia', + 'Mexico', + 'Italy', + 'Spain', + 'Netherlands', + 'Sweden', + 'Switzerland', + 'South Korea', + 'Argentina', + 'New Zealand', + 'Singapore', + 'Malaysia', + 'Thailand', + 'Indonesia', + 'Philippines', + 'Turkey', + 'Saudi Arabia', + 'United Arab Emirates', + 'Egypt', + 'Nigeria', + 'Kenya', + 'Israel', + 'Greece', + 'Ireland', + 'Portugal', + 'Norway', + 'Denmark', + 'Finland', + 'Poland', +]; + +// ---------------------------------------------------------------------- + +export const _roles = [ + `CEO`, + `CTO`, + `Project Coordinator`, + `Team Leader`, + `Software Developer`, + `Marketing Strategist`, + `Data Analyst`, + `Product Owner`, + `Graphic Designer`, + `Operations Manager`, + `Customer Support Specialist`, + `Sales Manager`, + `HR Recruiter`, + `Business Consultant`, + `Financial Planner`, + `Network Engineer`, + `Content Creator`, + `Quality Assurance Tester`, + `Public Relations Officer`, + `IT Administrator`, + `Compliance Officer`, + `Event Planner`, + `Legal Counsel`, + `Training Coordinator`, +]; + +// ---------------------------------------------------------------------- + +export const _postTitles = [ + `The Future of Personalization: Multi-Agent Orchestration on Azure`, // [cite: 122, 129] + `Beyond Static Segments: Moving to Dynamic Customer Journeys`, // [cite: 136] + `Ensuring Brand Safety: How Compliance Agents Protect Your Voice`, // [cite: 137, 171] + `The Power of RAG: Transforming Product Data into Contextual Offers`, // [cite: 168, 211] + `Explainable AI: Understanding Why a Customer Segment Was Chosen`, // [cite: 214, 215] + `From Mass Messaging to 1:1 Communication at Scale`, // [cite: 143] + `Building Trust: Why Citations Matter in AI-Generated Content`, // [cite: 180, 202] + `LangGraph Deep Dive: The Brain Behind the Orchestration`, // [cite: 161, 162] + `Hyper-Personalization: Leveraging Real-Time Data and Behavior`, // [cite: 141] + `Optimizing Conversion: The Role of the Experimentation Agent`, // [cite: 173, 220] + `Goal-Driven Segmentation: Clustering for High-Protein Shoppers`, // [cite: 166, 186] + `Safety First: Proactive Policy Enforcement in GenAI Marketing`, // [cite: 178, 196] + `Technical Architecture: Next.js, FastAPI, and Vector Search`, // [cite: 206-209] + `Breaking Down the Five-Phase Orchestration Strategy`, // [cite: 164] + `Voice of the Customer: Integrating Speech Services for Support`, // [cite: 100, 101] + `The End of Generic Spam: Delivering Value with Intelligent Agents`, // [cite: 143] + `Measuring Uplift: How Simulation Agents Predict ROI`, // [cite: 193, 220] + `Responsible AI: Navigating PII and Data Privacy in Marketing`, // [cite: 97, 98] + `Next-Gen Marketing: When Specialized Agents Collaborate`, // [cite: 132] + `Smart Retrieval: Using Embeddings and Vector Stores for Catalogs`, // [cite: 156, 188] + `The EchoVoice Advantage: Transparency, Control, and Speed`, // [cite: 126] + `Innovations in MarTech: The Shift to Agentic Workflows`, // [cite: 124] + `The Cost of Non-Compliance: Why Your AI Needs Guardrails`, // [cite: 94, 201] + `The Intersection of Creativity and Logic: Generative AI with Rules`, // [cite: 153, 197] +]; + +// ---------------------------------------------------------------------- + +export const _productNames = [ + `Urban Explorer Sneakers`, + `Classic Leather Loafers`, + `Mountain Trekking Boots`, + `Elegance Stiletto Heels`, + `Comfy Running Shoes`, + `Chic Ballet Flats`, + `Vintage Oxford Shoes`, + `Waterproof Hiking Boots`, + `Casual Slip-On Sneakers`, + `Premium Dress Shoes`, + `Sporty Trail Runners`, + `Sophisticated Brogues`, + `Beach Sandals`, + `Stylish Wedge Heels`, + `Lightweight Training Shoes`, + `Luxurious Moccasins`, + `Durable Work Boots`, + `Trendy Platform Sneakers`, + `Cozy Winter Boots`, + `Fashion Ankle Boots`, + `Breathable Tennis Shoes`, + `Elegant Evening Pumps`, + `Modern Skate Shoes`, + `Comfortable Walking Shoes`, +]; + +// ---------------------------------------------------------------------- + +export const _tourNames = [ + `Majestic Mountain Adventures`, + `Island Hopping Extravaganza`, + `Cultural Wonders of Europe`, + `Safari Expedition in Africa`, + `Grand Canyon Explorer`, + `Historic Cities of Asia`, + `Tropical Paradise Getaway`, + `Alaskan Wilderness Tour`, + `Mediterranean Cruise Voyage`, + `Enchanting Eastern Europe`, + `Scenic Coastal Road Trip`, + `Ancient Ruins Discovery`, + `Australian Outback Adventure`, + `Northern Lights Experience`, + `Wildlife Wonders of South America`, + `Royal Castles and Palaces`, + `Ultimate Beach Retreat`, + `National Parks Exploration`, + `Gastronomic Tour of Italy`, + `Hiking Trails of New Zealand`, + `Art and History of France`, + `Exotic Temples of India`, + `Canadian Rockies Journey`, + `Caribbean Sun and Fun`, +]; + +// ---------------------------------------------------------------------- + +export const _jobTitles = [ + `Software Engineer`, + `Marketing Manager`, + `Data Scientist`, + `Graphic Designer`, + `Financial Analyst`, + `Human Resources Specialist`, + `Project Manager`, + `Sales Executive`, + `Content Writer`, + `Network Administrator`, + `Customer Service Representative`, + `Product Manager`, + `Business Analyst`, + `Mechanical Engineer`, + `Operations Manager`, + `UX/UI Designer`, + `Accountant`, + `Social Media Manager`, + `Research Scientist`, + `Legal Advisor`, + `Public Relations Specialist`, + `Health and Safety Officer`, + `IT Support Specialist`, + `Environmental Consultant`, +]; + +// ---------------------------------------------------------------------- + +export const _companyNames = [ + `Lueilwitz and Sons`, + `Gleichner, Mueller and Tromp`, + `Nikolaus - Leuschke`, + `Hegmann, Kreiger and Bayer`, + `Grimes Inc`, + `Durgan - Murazik`, + `Altenwerth, Medhurst and Roberts`, + `Raynor Group`, + `Mraz, Donnelly and Collins`, + `Padberg - Bailey`, + `Heidenreich, Stokes and Parker`, + `Pagac and Sons`, + `Rempel, Hand and Herzog`, + `Dare - Treutel`, + `Kihn, Marquardt and Crist`, + `Nolan - Kunde`, + `Wuckert Inc`, + `Dibbert Inc`, + `Goyette and Sons`, + `Feest Group`, + `Bosco and Sons`, + `Bartell - Kovacek`, + `Schimmel - Raynor`, + `Tremblay LLC`, +]; + +// ---------------------------------------------------------------------- + +export const _tags = [ + `Technology`, + `Health and Wellness`, + `Travel`, + `Finance`, + `Education`, + `Food and Beverage`, + `Fashion`, + `Home and Garden`, + `Sports`, + `Entertainment`, + `Business`, + `Science`, + `Automotive`, + `Beauty`, + `Fitness`, + `Lifestyle`, + `Real Estate`, + `Parenting`, + `Pet Care`, + `Environmental`, + `DIY and Crafts`, + `Gaming`, + `Photography`, + `Music`, +]; + +// ---------------------------------------------------------------------- + +export const _taskNames = [ + `Prepare Monthly Financial Report`, + `Design New Marketing Campaign`, + `Analyze Customer Feedback`, + `Update Website Content`, + `Conduct Market Research`, + `Develop Software Application`, + `Organize Team Meeting`, + `Create Social Media Posts`, + `Review Project Plan`, + `Implement Security Protocols`, + `Write Technical Documentation`, + `Test New Product Features`, + `Manage Client Inquiries`, + `Train New Employees`, + `Coordinate Logistics`, + `Monitor Network Performance`, + `Develop Training Materials`, + `Draft Press Release`, + `Prepare Budget Proposal`, + `Evaluate Vendor Proposals`, + `Perform Data Analysis`, + `Conduct Quality Assurance`, + `Plan Event Logistics`, + `Optimize SEO Strategies`, +]; + +// ---------------------------------------------------------------------- + +export const _courseNames = [ + `Introduction to Python Programming`, + `Digital Marketing Fundamentals`, + `Data Science with R`, + `Graphic Design Essentials`, + `Financial Planning for Beginners`, + `Human Resource Management Basics`, + `Project Management Fundamentals`, + `Sales Techniques and Strategies`, + `Content Writing Mastery`, + `Network Security Fundamentals`, + `Customer Service Excellence`, + `Product Management Essentials`, + `Business Analytics with Excel`, + `Mechanical Engineering Principles`, + `Leadership and Team Management`, + `User Experience (UX) Design Basics`, + `Accounting Fundamentals`, + `Social Media Marketing Mastery`, + `Biotechnology Essentials`, + `Legal Studies for Non-Lawyers`, + `Public Speaking Confidence`, + `Health and Wellness Coaching`, + `Web Development Bootcamp`, + `Photography Masterclass`, +]; + + +export const _indexedContent = [ + // --- Core Product Content (RAG Source) --- + `Q3 2024 Product Catalog & Specs`, + `Brand Voice and Tone Guidelines V2.1`, + `Customer Loyalty Program T&Cs 2025`, + `Discounts and Promotions Policy Manual`, + `Product Recall and Safety Notices (2024)`, + `High-Protein Product Ingredient Manual`, + `Return and Exchange Policy FAQ`, + `Shipping and Delivery Service Level Agreements (SLA)`, + + // --- Compliance & Legal Content --- + `Marketing Compliance Audit Report Q1`, + `Data Privacy and GDPR Handling Procedures`, + `Regulatory Claim Restrictions (Medical/Health)`, + `Approved Legal Disclaimers Library`, + + // --- Customer & Support Content --- + `Top 50 Customer Support FAQs (Chatbot Source)`, + `Advanced Troubleshooting Guide for App`, + `Customer Sentiment Analysis Report H2 2024`, + `Subscription Management Workflow Manual`, + + // --- Operational/Segmentation Data --- + `Segmentation Cluster Definitions V3.0`, + `RFM Model Score Documentation`, + `Inventory Status and Backorder Policy`, + `New Store Location Opening Protocols`, + + // --- Featured/High-Priority Documents --- + `CEO Letter on Responsible AI Usage`, + `Mandatory PII Masking Procedures`, + `Executive Summary: Q4 Uplift Targets`, + `Voice of Customer (VOC) Survey Analysis 2024`, +]; + +// ---------------------------------------------------------------------- + +export const _fileNames = [ + 'cover-2.jpg', + 'design-suriname-2015.mp3', + 'expertise-2015-conakry-sao-tome-and-principe-gender.mp4', + 'money-popup-crack.pdf', + 'cover-4.jpg', + 'cover-6.jpg', + 'large-news.txt', + 'nauru-6015-small-fighter-left-gender.psd', + 'tv-xs.doc', + 'gustavia-entertainment-productivity.docx', + 'vintage-bahrain-saipan.xls', + 'indonesia-quito-nancy-grace-left-glad.xlsx', + 'legislation-grain.zip', + 'large-energy-dry-philippines.rar', + 'footer-243-ecuador.iso', + 'kyrgyzstan-04795009-picabo-street-guide-style.ai', + 'india-data-large-gk-chesterton-mother.esp', + 'footer-barbados-celine-dion.ppt', + 'socio-respectively-366996.pptx', + 'socio-ahead-531437-sweden-popup.wav', + 'trinidad-samuel-morse-bring.m4v', + 'cover-12.jpg', + 'cover-18.jpg', + 'xl-david-blaine-component-tanzania-books.pdf', +]; + +export const _eventNames = [ + `Annual General Meeting`, + `Summer Music Festival`, + `Tech Innovators Conference`, + `Charity Gala Dinner`, + `Spring Art Exhibition`, + `Corporate Training Workshop`, + `Community Health Fair`, + `Startup Pitch Night`, + `Regional Sports Tournament`, + `Book Launch Event`, + `Film Premiere Screening`, + `Industry Networking Mixer`, + `Holiday Craft Fair`, + `Environmental Awareness Week`, + `New Year's Eve Party`, + `Product Release Showcase`, + `Cultural Heritage Festival`, + `Science and Technology Expo`, + `Annual Awards Ceremony`, + `Fashion Week Runway Show`, + `Food and Wine Tasting`, + `Outdoor Adventure Camp`, + `Leadership Summit`, + `Wedding Expo`, +]; + +// ---------------------------------------------------------------------- + +export const _sentences = [ + `The sun slowly set over the horizon, painting the sky in vibrant hues of orange and pink.`, + `She eagerly opened the gift, her eyes sparkling with excitement.`, + `The old oak tree stood tall and majestic, its branches swaying gently in the breeze.`, + `The aroma of freshly brewed coffee filled the air, awakening my senses.`, + `The children giggled with joy as they ran through the sprinklers on a hot summer day.`, + `He carefully crafted a beautiful sculpture out of clay, his hands skillfully shaping the intricate details.`, + `The concert was a mesmerizing experience, with the music filling the venue and the crowd cheering in delight.`, + `The waves crashed against the shore, creating a soothing symphony of sound.`, + `The scent of blooming flowers wafted through the garden, creating a fragrant paradise.`, + `She gazed up at the night sky, marveling at the twinkling stars that dotted the darkness.`, + `The professor delivered a captivating lecture, engaging the students with thought-provoking ideas.`, + `The hiker trekked through the dense forest, guided by the soft glow of sunlight filtering through the trees.`, + `The delicate butterfly gracefully fluttered from flower to flower, sipping nectar with its slender proboscis.`, + `The aroma of freshly baked cookies filled the kitchen, tempting everyone with its irresistible scent.`, + 'The majestic waterfall cascaded down the rocks, creating a breathtaking display of nature`s power.', + `The actor delivered a powerful performance, moving the audience to tears with his emotional portrayal.`, + `The book transported me to a magical world, where imagination knew no bounds.`, + `The scent of rain filled the air as dark clouds gathered overhead, promising a refreshing downpour.`, + `The chef skillfully plated the dish, turning simple ingredients into a work of culinary art.`, + `The newborn baby let out a tiny cry, announcing its arrival to the world.`, + `The athlete sprinted across the finish line, arms raised in victory as the crowd erupted in applause.`, + `The ancient ruins stood as a testament to a civilization long gone, their grandeur still awe-inspiring.`, + `The artist dipped the brush into vibrant paint, bringing the canvas to life with bold strokes and vivid colors.`, + `The laughter of children echoed through the playground, filling the atmosphere with pure joy.`, +]; + +// ---------------------------------------------------------------------- + +export const _descriptions = [ + // --- Agent & Phase Descriptions --- + `**Segmentation Agent:** Automatically analyzes customer RFM scores and behavioral data to generate dynamic, explainable target segments for personalized campaigns.`, + `**Retrieval-Augmented Generation (RAG):** Secures approved product information from the Azure Search Index to ensure messages are accurate, relevant, and fully cited.`, + `**AI Message Generator:** Creates multiple hyper-personalized message variants (A/B/n) tailored specifically to the segment's demonstrated needs and campaign goals.`, + `**Safety & Compliance Agent:** Proactively evaluates every generated message against brand guidelines and policy rules, blocking or rewriting high-risk content.`, + `**Experimentation Agent:** Simulates campaign performance and conversion uplift, providing measurable ROI data before the message is deployed to end-users.`, + `**LangGraph Orchestration:** Manages the complex, multi-step flow between agents, ensuring decisions are logged and traceable from goal input to final output.`, + `**Explainability Engine:** Generates comprehensive documentation detailing why a specific message variant was chosen for a customer and which rules influenced its final form.`, + `**Closed-Loop Optimization:** Performance metrics from deployed campaigns are fed back into the system to continuously refine segmentation and generation strategies.`, + + // --- Feature & Benefit Descriptions --- + `Move beyond static segments to real-time decisioning. EchoVoice AI dynamically groups customers based on live intent, maximizing message relevance.`, + `Achieve true 1:1 personalization at scale without compromising governance. Every message is vetted by AI guardrails before distribution.`, + `Full citation of source content ensures messages are legally sound and accurate. Say goodbye to misquoted product features and compliance risk.`, + `Provides a unified view for both marketing teams (uplift) and compliance teams (auditability), bridging the gap between growth and risk management.`, + `Leverages Azure OpenAI and Vector Search for low-latency retrieval, ensuring fast, context-aware message generation even for large product catalogs.`, + `Proactive policy enforcement means the system catches medical claims, misleading price guarantees, and sensitive targeting attributes automatically.`, + `Design your campaign goals, and let the agents intelligently select the right segment, retrieve the best content, and generate the winning variant.`, + `The platform is production-ready, featuring modular agent systems, logging hooks, and scalable infrastructure designed for enterprise integration.`, + + // --- Compliance & Safety Focus --- + `Every blocked message is logged with the specific policy violation (e.g., Tone Violation, Medical Claim), providing a clear audit trail for regulators.`, + `The system guards against 'hallucination' by strictly limiting content generation to information verified and cited from the approved Product Content Store.`, + `Supports multi-channel deployment (Email, SMS, In-App, Voice TTS) while maintaining a consistent level of safety and personalization across all endpoints.`, + `Focus on high-value customers by prioritizing segments that show the highest predicted conversion uplift based on predictive modeling.`, + `Minimize PII (Personally Identifiable Information) exposure through multi-layer detection and protection used during the data ingestion and retrieval phases.`, + `Offers customizable policy frameworks, allowing non-technical compliance officers to update and extend the rules without writing code.`, + + // --- Technical & Advanced Features --- + `The architecture is built on Python (FastAPI) and React (Next.js), providing a performant, modern, and extendable foundation for your MarTech stack.`, + `Utilizes state-of-the-art vector embeddings to maximize the semantic relevance of retrieved content, drastically improving the quality of generated copy.`, + `Designed for the retail sector, excelling at complex inventory, pricing, and promotional logic required for accurate customer communication.`, + `Seamlessly integrates with existing Customer Data Platforms (CDPs) and inventory APIs via LangChain Tool invocation.`, +]; + +export const packageTypes = ['data', 'time', 'hybrid']; +export const statuses = ['active', 'pending', 'banned', 'rejected']; diff --git a/app/frontend/src/_mock/index.ts b/app/frontend/src/_mock/index.ts new file mode 100644 index 00000000..a7ad56f9 --- /dev/null +++ b/app/frontend/src/_mock/index.ts @@ -0,0 +1,28 @@ +export * from './_job'; + +export * from './_mock'; + +export * from './_user'; + +export * from './_tour'; + +export * from './_blog'; + +export * from './assets'; + +export * from './_files'; + +export * from './_order'; + +export * from './_others'; + +export * from './_invoice'; + +export * from './_product'; + +export * from './_overview'; + +export * from './_calendar'; + +export * from './_segmentors'; + diff --git a/app/frontend/src/actions/blog-ssr.ts b/app/frontend/src/actions/blog-ssr.ts new file mode 100644 index 00000000..443e4857 --- /dev/null +++ b/app/frontend/src/actions/blog-ssr.ts @@ -0,0 +1,29 @@ +import axios, { endpoints } from 'src/lib/axios'; + +// ---------------------------------------------------------------------- + +export async function getPosts() { + const res = await axios.get(endpoints.post.list); + + return res.data; +} + +// ---------------------------------------------------------------------- + +export async function getPost(title: string) { + const URL = title ? `${endpoints.post.details}?title=${title}` : ''; + + const res = await axios.get(URL); + + return res.data; +} + +// ---------------------------------------------------------------------- + +export async function getLatestPosts(title: string) { + const URL = title ? `${endpoints.post.latest}?title=${title}` : ''; + + const res = await axios.get(URL); + + return res.data; +} diff --git a/app/frontend/src/actions/blog.ts b/app/frontend/src/actions/blog.ts new file mode 100644 index 00000000..2da2d874 --- /dev/null +++ b/app/frontend/src/actions/blog.ts @@ -0,0 +1,121 @@ +import type { SWRConfiguration } from 'swr'; +import type { IPostItem } from 'src/types/blog'; + +import useSWR from 'swr'; +import { useMemo } from 'react'; + +import { fetcher, endpoints } from 'src/lib/axios'; + +// ---------------------------------------------------------------------- + +const swrOptions: SWRConfiguration = { + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, +}; + +// ---------------------------------------------------------------------- + +type PostsData = { + posts: IPostItem[]; +}; + +export function useGetPosts() { + const url = endpoints.post.list; + + const { data, isLoading, error, isValidating } = useSWR(url, fetcher, swrOptions); + + const memoizedValue = useMemo( + () => ({ + posts: data?.posts || [], + postsLoading: isLoading, + postsError: error, + postsValidating: isValidating, + postsEmpty: !isLoading && !data?.posts.length, + }), + [data?.posts, error, isLoading, isValidating] + ); + + return memoizedValue; +} + +// ---------------------------------------------------------------------- + +type PostData = { + post: IPostItem; +}; + +export function useGetPost(title: string) { + const url = title ? [endpoints.post.details, { params: { title } }] : ''; + + const { data, isLoading, error, isValidating } = useSWR(url, fetcher, swrOptions); + + const memoizedValue = useMemo( + () => ({ + post: data?.post, + postLoading: isLoading, + postError: error, + postValidating: isValidating, + }), + [data?.post, error, isLoading, isValidating] + ); + + return memoizedValue; +} + +// ---------------------------------------------------------------------- + +type LatestPostsData = { + latestPosts: IPostItem[]; +}; + +export function useGetLatestPosts(title: string) { + const url = title ? [endpoints.post.latest, { params: { title } }] : ''; + + const { data, isLoading, error, isValidating } = useSWR( + url, + fetcher, + swrOptions + ); + + const memoizedValue = useMemo( + () => ({ + latestPosts: data?.latestPosts || [], + latestPostsLoading: isLoading, + latestPostsError: error, + latestPostsValidating: isValidating, + latestPostsEmpty: !isLoading && !data?.latestPosts.length, + }), + [data?.latestPosts, error, isLoading, isValidating] + ); + + return memoizedValue; +} + +// ---------------------------------------------------------------------- + +type SearchResultsData = { + results: IPostItem[]; +}; + +export function useSearchPosts(query: string) { + const url = query ? [endpoints.post.search, { params: { query } }] : ''; + + const { data, isLoading, error, isValidating } = useSWR(url, fetcher, { + ...swrOptions, + keepPreviousData: true, + }); + + const memoizedValue = useMemo( + () => ({ + searchResults: data?.results || [], + searchLoading: isLoading, + searchError: error, + searchValidating: isValidating, + searchEmpty: !isLoading && !isValidating && !data?.results.length, + }), + [data?.results, error, isLoading, isValidating] + ); + + return memoizedValue; +} diff --git a/app/frontend/src/actions/calendar.ts b/app/frontend/src/actions/calendar.ts new file mode 100644 index 00000000..70e3e04e --- /dev/null +++ b/app/frontend/src/actions/calendar.ts @@ -0,0 +1,133 @@ +import type { SWRConfiguration } from 'swr'; +import type { ICalendarEvent } from 'src/types/calendar'; + +import { useMemo } from 'react'; +import useSWR, { mutate } from 'swr'; + +import axios, { fetcher, endpoints } from 'src/lib/axios'; + +// ---------------------------------------------------------------------- + +const enableServer = false; + +const CALENDAR_ENDPOINT = endpoints.calendar; + +const swrOptions: SWRConfiguration = { + revalidateIfStale: enableServer, + revalidateOnFocus: enableServer, + revalidateOnReconnect: enableServer, +}; + +// ---------------------------------------------------------------------- + +type EventsData = { + events: ICalendarEvent[]; +}; + +export function useGetEvents() { + const { data, isLoading, error, isValidating } = useSWR( + CALENDAR_ENDPOINT, + fetcher, + swrOptions + ); + + const memoizedValue = useMemo(() => { + const events = data?.events.map((event) => ({ ...event, textColor: event.color })); + + return { + events: events || [], + eventsLoading: isLoading, + eventsError: error, + eventsValidating: isValidating, + eventsEmpty: !isLoading && !isValidating && !data?.events.length, + }; + }, [data?.events, error, isLoading, isValidating]); + + return memoizedValue; +} + +// ---------------------------------------------------------------------- + +export async function createEvent(eventData: ICalendarEvent) { + /** + * Work on server + */ + if (enableServer) { + const data = { eventData }; + await axios.post(CALENDAR_ENDPOINT, data); + } + + /** + * Work in local + */ + + mutate( + CALENDAR_ENDPOINT, + (currentData) => { + const currentEvents: ICalendarEvent[] = currentData?.events; + + const events = [...currentEvents, eventData]; + + return { ...currentData, events }; + }, + false + ); +} + +// ---------------------------------------------------------------------- + +export async function updateEvent(eventData: Partial) { + /** + * Work on server + */ + if (enableServer) { + const data = { eventData }; + await axios.put(CALENDAR_ENDPOINT, data); + } + + /** + * Work in local + */ + + mutate( + CALENDAR_ENDPOINT, + (currentData) => { + const currentEvents: ICalendarEvent[] = currentData?.events; + + const events = currentEvents.map((event) => + event.id === eventData.id ? { ...event, ...eventData } : event + ); + + return { ...currentData, events }; + }, + false + ); +} + +// ---------------------------------------------------------------------- + +export async function deleteEvent(eventId: string) { + /** + * Work on server + */ + if (enableServer) { + const data = { eventId }; + await axios.patch(CALENDAR_ENDPOINT, data); + } + + /** + * Work in local + */ + + mutate( + CALENDAR_ENDPOINT, + (currentData) => { + const currentEvents: ICalendarEvent[] = currentData?.events; + + const events = currentEvents.filter((event) => event.id !== eventId); + + return { ...currentData, events }; + }, + false + ); +} diff --git a/app/frontend/src/actions/chat.ts b/app/frontend/src/actions/chat.ts new file mode 100644 index 00000000..11cd506a --- /dev/null +++ b/app/frontend/src/actions/chat.ts @@ -0,0 +1,220 @@ +import type { SWRConfiguration } from 'swr'; +import type { IChatMessage, IChatParticipant, IChatConversation } from 'src/types/chat'; + +import { useMemo } from 'react'; +import { keyBy } from 'es-toolkit'; +import useSWR, { mutate } from 'swr'; + +import axios, { fetcher, endpoints } from 'src/lib/axios'; + +// ---------------------------------------------------------------------- + +const enableServer = false; + +const CHART_ENDPOINT = endpoints.chat; + +const swrOptions: SWRConfiguration = { + revalidateIfStale: enableServer, + revalidateOnFocus: enableServer, + revalidateOnReconnect: enableServer, +}; + +// ---------------------------------------------------------------------- + +type ContactsData = { + contacts: IChatParticipant[]; +}; + +export function useGetContacts() { + const url = [CHART_ENDPOINT, { params: { endpoint: 'contacts' } }]; + + const { data, isLoading, error, isValidating } = useSWR(url, fetcher, swrOptions); + + const memoizedValue = useMemo( + () => ({ + contacts: data?.contacts || [], + contactsLoading: isLoading, + contactsError: error, + contactsValidating: isValidating, + contactsEmpty: !isLoading && !isValidating && !data?.contacts.length, + }), + [data?.contacts, error, isLoading, isValidating] + ); + + return memoizedValue; +} + +// ---------------------------------------------------------------------- + +type ConversationsData = { + conversations: IChatConversation[]; +}; + +export function useGetConversations() { + const url = [CHART_ENDPOINT, { params: { endpoint: 'conversations' } }]; + + const { data, isLoading, error, isValidating } = useSWR( + url, + fetcher, + swrOptions + ); + + const memoizedValue = useMemo(() => { + const byId = data?.conversations.length ? keyBy(data.conversations, (option) => option.id) : {}; + const allIds = Object.keys(byId); + + return { + conversations: { byId, allIds }, + conversationsLoading: isLoading, + conversationsError: error, + conversationsValidating: isValidating, + conversationsEmpty: !isLoading && !isValidating && !allIds.length, + }; + }, [data?.conversations, error, isLoading, isValidating]); + + return memoizedValue; +} + +// ---------------------------------------------------------------------- + +type ConversationData = { + conversation: IChatConversation; +}; + +export function useGetConversation(conversationId: string) { + const url = conversationId + ? [CHART_ENDPOINT, { params: { conversationId, endpoint: 'conversation' } }] + : ''; + + const { data, isLoading, error, isValidating } = useSWR( + url, + fetcher, + swrOptions + ); + + const memoizedValue = useMemo( + () => ({ + conversation: data?.conversation, + conversationLoading: isLoading, + conversationError: error, + conversationValidating: isValidating, + conversationEmpty: !isLoading && !isValidating && !data?.conversation, + }), + [data?.conversation, error, isLoading, isValidating] + ); + + return memoizedValue; +} + +// ---------------------------------------------------------------------- + +export async function sendMessage(conversationId: string, messageData: IChatMessage) { + const conversationsUrl = [CHART_ENDPOINT, { params: { endpoint: 'conversations' } }]; + + const conversationUrl = [ + CHART_ENDPOINT, + { params: { conversationId, endpoint: 'conversation' } }, + ]; + + /** + * Work on server + */ + if (enableServer) { + const data = { conversationId, messageData }; + await axios.put(CHART_ENDPOINT, data); + } + + /** + * Work in local + */ + mutate( + conversationUrl, + (currentData) => { + const currentConversation: IChatConversation = currentData.conversation; + + const conversation = { + ...currentConversation, + messages: [...currentConversation.messages, messageData], + }; + + return { ...currentData, conversation }; + }, + false + ); + + mutate( + conversationsUrl, + (currentData) => { + const currentConversations: IChatConversation[] = currentData.conversations; + + const conversations: IChatConversation[] = currentConversations.map( + (conversation: IChatConversation) => + conversation.id === conversationId + ? { ...conversation, messages: [...conversation.messages, messageData] } + : conversation + ); + + return { ...currentData, conversations }; + }, + false + ); +} + +// ---------------------------------------------------------------------- + +export async function createConversation(conversationData: IChatConversation) { + const url = [CHART_ENDPOINT, { params: { endpoint: 'conversations' } }]; + + /** + * Work on server + */ + const data = { conversationData }; + const res = await axios.post(CHART_ENDPOINT, data); + + /** + * Work in local + */ + + mutate( + url, + (currentData) => { + const currentConversations: IChatConversation[] = currentData.conversations; + + const conversations: IChatConversation[] = [...currentConversations, conversationData]; + + return { ...currentData, conversations }; + }, + false + ); + + return res.data; +} + +// ---------------------------------------------------------------------- + +export async function clickConversation(conversationId: string) { + /** + * Work on server + */ + if (enableServer) { + await axios.get(CHART_ENDPOINT, { params: { conversationId, endpoint: 'mark-as-seen' } }); + } + + /** + * Work in local + */ + + mutate( + [CHART_ENDPOINT, { params: { endpoint: 'conversations' } }], + (currentData) => { + const currentConversations: IChatConversation[] = currentData.conversations; + + const conversations = currentConversations.map((conversation: IChatConversation) => + conversation.id === conversationId ? { ...conversation, unreadCount: 0 } : conversation + ); + + return { ...currentData, conversations }; + }, + false + ); +} diff --git a/app/frontend/src/actions/kanban.ts b/app/frontend/src/actions/kanban.ts new file mode 100644 index 00000000..901bc6a1 --- /dev/null +++ b/app/frontend/src/actions/kanban.ts @@ -0,0 +1,344 @@ +import type { SWRConfiguration } from 'swr'; +import type { UniqueIdentifier } from '@dnd-kit/core'; +import type { IKanban, IKanbanTask, IKanbanColumn } from 'src/types/kanban'; + +import useSWR, { mutate } from 'swr'; +import { useMemo, startTransition } from 'react'; + +import axios, { fetcher, endpoints } from 'src/lib/axios'; + +// ---------------------------------------------------------------------- + +const enableServer = false; + +const KANBAN_ENDPOINT = endpoints.kanban; + +const swrOptions: SWRConfiguration = { + revalidateIfStale: enableServer, + revalidateOnFocus: enableServer, + revalidateOnReconnect: enableServer, +}; + +// ---------------------------------------------------------------------- + +type BoardData = { + board: IKanban; +}; + +export function useGetBoard() { + const { data, isLoading, error, isValidating } = useSWR( + KANBAN_ENDPOINT, + fetcher, + swrOptions + ); + + const memoizedValue = useMemo(() => { + const tasks = data?.board.tasks ?? {}; + const columns = data?.board.columns ?? []; + + return { + board: { tasks, columns }, + boardLoading: isLoading, + boardError: error, + boardValidating: isValidating, + boardEmpty: !isLoading && !isValidating && !columns.length, + }; + }, [data?.board.columns, data?.board.tasks, error, isLoading, isValidating]); + + return memoizedValue; +} + +// ---------------------------------------------------------------------- + +export async function createColumn(columnData: IKanbanColumn) { + /** + * Work on server + */ + if (enableServer) { + const data = { columnData }; + await axios.post(KANBAN_ENDPOINT, data, { params: { endpoint: 'create-column' } }); + } + + /** + * Work in local + */ + mutate( + KANBAN_ENDPOINT, + (currentData) => { + const { board } = currentData as BoardData; + + // add new column in board.columns + const columns = [...board.columns, columnData]; + + // add new task in board.tasks + const tasks = { ...board.tasks, [columnData.id]: [] }; + + return { ...currentData, board: { ...board, columns, tasks } }; + }, + false + ); +} + +// ---------------------------------------------------------------------- + +export async function updateColumn(columnId: UniqueIdentifier, columnName: string) { + /** + * Work on server + */ + if (enableServer) { + const data = { columnId, columnName }; + await axios.post(KANBAN_ENDPOINT, data, { params: { endpoint: 'update-column' } }); + } + + /** + * Work in local + */ + startTransition(() => { + mutate( + KANBAN_ENDPOINT, + (currentData) => { + const { board } = currentData as BoardData; + + const columns = board.columns.map((column) => + column.id === columnId + ? { + // Update data when found + ...column, + name: columnName, + } + : column + ); + + return { ...currentData, board: { ...board, columns } }; + }, + false + ); + }); +} + +// ---------------------------------------------------------------------- + +export async function moveColumn(updateColumns: IKanbanColumn[]) { + /** + * Work in local + */ + startTransition(() => { + mutate( + KANBAN_ENDPOINT, + (currentData) => { + const { board } = currentData as BoardData; + + return { ...currentData, board: { ...board, columns: updateColumns } }; + }, + false + ); + }); + + /** + * Work on server + */ + if (enableServer) { + const data = { updateColumns }; + await axios.post(KANBAN_ENDPOINT, data, { params: { endpoint: 'move-column' } }); + } +} + +// ---------------------------------------------------------------------- + +export async function clearColumn(columnId: UniqueIdentifier) { + /** + * Work on server + */ + if (enableServer) { + const data = { columnId }; + await axios.post(KANBAN_ENDPOINT, data, { params: { endpoint: 'clear-column' } }); + } + + /** + * Work in local + */ + startTransition(() => { + mutate( + KANBAN_ENDPOINT, + (currentData) => { + const { board } = currentData as BoardData; + + // remove all tasks in column + const tasks = { ...board.tasks, [columnId]: [] }; + + return { ...currentData, board: { ...board, tasks } }; + }, + false + ); + }); +} + +// ---------------------------------------------------------------------- + +export async function deleteColumn(columnId: UniqueIdentifier) { + /** + * Work on server + */ + if (enableServer) { + const data = { columnId }; + await axios.post(KANBAN_ENDPOINT, data, { params: { endpoint: 'delete-column' } }); + } + + /** + * Work in local + */ + mutate( + KANBAN_ENDPOINT, + (currentData) => { + const { board } = currentData as BoardData; + + // delete column in board.columns + const columns = board.columns.filter((column) => column.id !== columnId); + + // delete tasks by column deleted + const tasks = Object.keys(board.tasks) + .filter((key) => key !== columnId) + .reduce((obj: IKanban['tasks'], key) => { + obj[key] = board.tasks[key]; + return obj; + }, {}); + + return { ...currentData, board: { ...board, columns, tasks } }; + }, + false + ); +} + +// ---------------------------------------------------------------------- + +export async function createTask(columnId: UniqueIdentifier, taskData: IKanbanTask) { + /** + * Work on server + */ + if (enableServer) { + const data = { columnId, taskData }; + await axios.post(KANBAN_ENDPOINT, data, { params: { endpoint: 'create-task' } }); + } + + /** + * Work in local + */ + startTransition(() => { + mutate( + KANBAN_ENDPOINT, + (currentData) => { + const { board } = currentData as BoardData; + + // add task in board.tasks + const tasks = { ...board.tasks, [columnId]: [taskData, ...board.tasks[columnId]] }; + + return { ...currentData, board: { ...board, tasks } }; + }, + false + ); + }); +} + +// ---------------------------------------------------------------------- + +export async function updateTask(columnId: UniqueIdentifier, taskData: IKanbanTask) { + /** + * Work on server + */ + if (enableServer) { + const data = { columnId, taskData }; + await axios.post(KANBAN_ENDPOINT, data, { params: { endpoint: 'update-task' } }); + } + + /** + * Work in local + */ + startTransition(() => { + mutate( + KANBAN_ENDPOINT, + (currentData) => { + const { board } = currentData as BoardData; + + // tasks in column + const tasksInColumn = board.tasks[columnId]; + + // find and update task + const updateTasks = tasksInColumn.map((task) => + task.id === taskData.id + ? { + // Update data when found + ...task, + ...taskData, + } + : task + ); + + const tasks = { ...board.tasks, [columnId]: updateTasks }; + + return { ...currentData, board: { ...board, tasks } }; + }, + false + ); + }); +} + +// ---------------------------------------------------------------------- + +export async function moveTask(updateTasks: IKanban['tasks']) { + /** + * Work in local + */ + startTransition(() => { + mutate( + KANBAN_ENDPOINT, + (currentData) => { + const { board } = currentData as BoardData; + + // update board.tasks + const tasks = updateTasks; + + return { ...currentData, board: { ...board, tasks } }; + }, + false + ); + }); + + /** + * Work on server + */ + if (enableServer) { + const data = { updateTasks }; + await axios.post(KANBAN_ENDPOINT, data, { params: { endpoint: 'move-task' } }); + } +} + +// ---------------------------------------------------------------------- + +export async function deleteTask(columnId: UniqueIdentifier, taskId: UniqueIdentifier) { + /** + * Work on server + */ + if (enableServer) { + const data = { columnId, taskId }; + await axios.post(KANBAN_ENDPOINT, data, { params: { endpoint: 'delete-task' } }); + } + + /** + * Work in local + */ + mutate( + KANBAN_ENDPOINT, + (currentData) => { + const { board } = currentData as BoardData; + + // delete task in column + const tasks = { + ...board.tasks, + [columnId]: board.tasks[columnId].filter((task) => task.id !== taskId), + }; + + return { ...currentData, board: { ...board, tasks } }; + }, + false + ); +} diff --git a/app/frontend/src/actions/mail.ts b/app/frontend/src/actions/mail.ts new file mode 100644 index 00000000..b6d0308b --- /dev/null +++ b/app/frontend/src/actions/mail.ts @@ -0,0 +1,93 @@ +import type { SWRConfiguration } from 'swr'; +import type { IMail, IMailLabel } from 'src/types/mail'; + +import useSWR from 'swr'; +import { useMemo } from 'react'; +import { keyBy } from 'es-toolkit'; + +import { fetcher, endpoints } from 'src/lib/axios'; + +// ---------------------------------------------------------------------- + +const swrOptions: SWRConfiguration = { + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, +}; + +// ---------------------------------------------------------------------- + +type LabelsData = { + labels: IMailLabel[]; +}; + +export function useGetLabels() { + const url = endpoints.mail.labels; + + const { data, isLoading, error, isValidating } = useSWR(url, fetcher, swrOptions); + + const memoizedValue = useMemo( + () => ({ + labels: data?.labels || [], + labelsLoading: isLoading, + labelsError: error, + labelsValidating: isValidating, + labelsEmpty: !isLoading && !isValidating && !data?.labels.length, + }), + [data?.labels, error, isLoading, isValidating] + ); + + return memoizedValue; +} + +// ---------------------------------------------------------------------- + +type MailsData = { + mails: IMail[]; +}; + +export function useGetMails(labelId: string) { + const url = labelId ? [endpoints.mail.list, { params: { labelId } }] : ''; + + const { data, isLoading, error, isValidating } = useSWR(url, fetcher, swrOptions); + + const memoizedValue = useMemo(() => { + const byId = data?.mails.length ? keyBy(data?.mails, (option) => option.id) : {}; + const allIds = Object.keys(byId); + + return { + mails: { byId, allIds }, + mailsLoading: isLoading, + mailsError: error, + mailsValidating: isValidating, + mailsEmpty: !isLoading && !isValidating && !allIds.length, + }; + }, [data?.mails, error, isLoading, isValidating]); + + return memoizedValue; +} + +// ---------------------------------------------------------------------- + +type MailData = { + mail: IMail; +}; + +export function useGetMail(mailId: string) { + const url = mailId ? [endpoints.mail.details, { params: { mailId } }] : ''; + + const { data, isLoading, error, isValidating } = useSWR(url, fetcher, swrOptions); + + const memoizedValue = useMemo( + () => ({ + mail: data?.mail, + mailLoading: isLoading, + mailError: error, + mailValidating: isValidating, + mailEmpty: !isLoading && !isValidating && !data?.mail, + }), + [data?.mail, error, isLoading, isValidating] + ); + + return memoizedValue; +} diff --git a/app/frontend/src/actions/product-ssr.ts b/app/frontend/src/actions/product-ssr.ts new file mode 100644 index 00000000..4be25ab0 --- /dev/null +++ b/app/frontend/src/actions/product-ssr.ts @@ -0,0 +1,19 @@ +import axios, { endpoints } from 'src/lib/axios'; + +// ---------------------------------------------------------------------- + +export async function getProducts() { + const res = await axios.get(endpoints.leave.list); + + return res.data; +} + +// ---------------------------------------------------------------------- + +export async function getProduct(id: string) { + const URL = id ? `${endpoints.leave.details}?productId=${id}` : ''; + + const res = await axios.get(URL); + + return res.data; +} diff --git a/app/frontend/src/actions/product.ts b/app/frontend/src/actions/product.ts new file mode 100644 index 00000000..2bea5c6b --- /dev/null +++ b/app/frontend/src/actions/product.ts @@ -0,0 +1,134 @@ +import type { SWRConfiguration } from 'swr'; +import type { ILeaveItem2, ILeaveBalance } from 'src/types/product'; + +import useSWR from 'swr'; +import { useMemo } from 'react'; + +import { poster, putter, fetcher, deleter, endpoints } from 'src/lib/axios'; + +// ---------------------------------------------------------------------- + +const swrOptions: SWRConfiguration = { + revalidateIfStale: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, +}; + +// ---------------------------------------------------------------------- + + + +export function useGetProducts() { + const url = endpoints.leave.list; + + const { data, isLoading, error, isValidating } = useSWR(url, fetcher, swrOptions); + + const memoizedValue = useMemo( + () => ({ + products: data || [], + productsLoading: isLoading, + productsError: error, + productsValidating: isValidating, + productsEmpty: !isLoading && !isValidating && !data, + }), + [data, error, isLoading, isValidating] + ); + + return memoizedValue; +} + +// ---------------------------------------------------------------------- + +interface ILeaveBalanceData { + status: boolean; + data: ILeaveBalance[]; +} + +export function useGetLeaveBalances() { + const url = endpoints.leave.balances.list; + + const { data, isLoading, error, isValidating } = useSWR(url, fetcher, swrOptions); + + const memoizedValue = useMemo( + () => ({ + balances: data?.data || [], + balancesLoading: isLoading, + balancesError: error, + balancesValidating: isValidating, + balancesEmpty: !isLoading && !isValidating && !data, + }), + [data, error, isLoading, isValidating] + ); + + return memoizedValue; +} + +// ---------------------------------------------------------------------- + +type ProductData = { + leave: ILeaveItem2; +}; + +export function useGetProduct(productId: string) { + const url = productId ? [endpoints.leave.details, { params: { productId } }] : ''; + + const { data, isLoading, error, isValidating } = useSWR(url, fetcher, swrOptions); + + const memoizedValue = useMemo( + () => ({ + product: data?.leave, + productLoading: isLoading, + productError: error, + productValidating: isValidating, + }), + [data?.leave, error, isLoading, isValidating] + ); + + return memoizedValue; +} + +// ---------------------------------------------------------------------- + +type SearchResultsData = { + results: ILeaveItem2[]; +}; + +export function useSearchProducts(query: string) { + const url = query ? [endpoints.leave.search, { params: { query } }] : ''; + + const { data, isLoading, error, isValidating } = useSWR(url, fetcher, { + ...swrOptions, + keepPreviousData: true, + }); + + const memoizedValue = useMemo( + () => ({ + searchResults: data?.results || [], + searchLoading: isLoading, + searchError: error, + searchValidating: isValidating, + searchEmpty: !isLoading && !isValidating && !data?.results.length, + }), + [data?.results, error, isLoading, isValidating] + ); + + return memoizedValue; +} + + +export function createProduct(data: any) { + return poster([endpoints.leave.create, { + ...data, + }]); +} + +export function updateProduct(productId: string, data: any) { + return putter([endpoints.leave.update, { + ...data, + }]); +} + +export function deleteProduct(productId: string) { + const URL = `${endpoints.leave.delete}/${productId}/`; + return deleter(URL); +} diff --git a/app/frontend/src/app/(home)/layout.tsx b/app/frontend/src/app/(home)/layout.tsx new file mode 100644 index 00000000..16053769 --- /dev/null +++ b/app/frontend/src/app/(home)/layout.tsx @@ -0,0 +1,11 @@ +import { MainLayout } from 'src/layouts/main'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return {children}; +} diff --git a/app/frontend/src/app/(home)/page.tsx b/app/frontend/src/app/(home)/page.tsx new file mode 100644 index 00000000..4c94f1e3 --- /dev/null +++ b/app/frontend/src/app/(home)/page.tsx @@ -0,0 +1,52 @@ +import type { Metadata } from 'next'; + +import { HomeView } from 'src/sections/home/view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { + title: 'EchoVoice: Customer Personalization Orchestrator', + keywords: [ + 'EchoVoice', + 'Customer Personalization', + 'Personalization Orchestrator', + 'A/B/n experimentation', + 'regulated industries', + ], + description: + 'EchoVoice is a multi-agent AI personalization platform for regulated industries — delivering safe, on-brand, traceable customer messaging and A/B/n experimentation within an auditable orchestration pipeline.', + openGraph: { + title: 'EchoVoice: Customer Personalization Orchestrator', + description: + 'EchoVoice delivers compliant, on-brand personalization and A/B/n experimentation for regulated domains via a coordinated, auditable multi-agent orchestration pipeline.', + type: 'website', + url: 'https://echovoice.ai', + siteName: 'EchoVoice', + images: [ + { + url: 'https://echovoice.ai/og-image.png', + width: 1200, + height: 630, + alt: 'EchoVoice: Customer Personalization Orchestrator', + }, + ], + }, + twitter: { + title: 'EchoVoice: Customer Personalization Orchestrator', + description: + 'EchoVoice: compliant, on-brand personalization and A/B/n experimentation in regulated industries — safe, traceable messaging orchestrated by specialized agents.', + card: 'summary_large_image', + images: [ + { + url: 'https://echovoice.ai/og-image.png', + width: 1200, + height: 630, + alt: 'EchoVoice: Customer Personalization Orchestrator', + }, + ], + }, +}; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/about-us/layout.tsx b/app/frontend/src/app/about-us/layout.tsx new file mode 100644 index 00000000..16053769 --- /dev/null +++ b/app/frontend/src/app/about-us/layout.tsx @@ -0,0 +1,11 @@ +import { MainLayout } from 'src/layouts/main'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return {children}; +} diff --git a/app/frontend/src/app/about-us/page.tsx b/app/frontend/src/app/about-us/page.tsx new file mode 100644 index 00000000..1eba60c4 --- /dev/null +++ b/app/frontend/src/app/about-us/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { AboutView } from 'src/sections/about/view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `About us - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/auth-demo/centered/reset-password/layout.tsx b/app/frontend/src/app/auth-demo/centered/reset-password/layout.tsx new file mode 100644 index 00000000..41630e20 --- /dev/null +++ b/app/frontend/src/app/auth-demo/centered/reset-password/layout.tsx @@ -0,0 +1,11 @@ +import { AuthCenteredLayout } from 'src/layouts/auth-centered'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return {children}; +} diff --git a/app/frontend/src/app/auth-demo/centered/reset-password/page.tsx b/app/frontend/src/app/auth-demo/centered/reset-password/page.tsx new file mode 100644 index 00000000..46d19e8c --- /dev/null +++ b/app/frontend/src/app/auth-demo/centered/reset-password/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { CenteredResetPasswordView } from 'src/auth/view/auth-demo/centered'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Reset password | Layout centered - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/auth-demo/centered/sign-in/layout.tsx b/app/frontend/src/app/auth-demo/centered/sign-in/layout.tsx new file mode 100644 index 00000000..41630e20 --- /dev/null +++ b/app/frontend/src/app/auth-demo/centered/sign-in/layout.tsx @@ -0,0 +1,11 @@ +import { AuthCenteredLayout } from 'src/layouts/auth-centered'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return {children}; +} diff --git a/app/frontend/src/app/auth-demo/centered/sign-in/page.tsx b/app/frontend/src/app/auth-demo/centered/sign-in/page.tsx new file mode 100644 index 00000000..ca6051e6 --- /dev/null +++ b/app/frontend/src/app/auth-demo/centered/sign-in/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { CenteredSignInView } from 'src/auth/view/auth-demo/centered'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Sign in | Layout centered - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/auth-demo/centered/sign-up/layout.tsx b/app/frontend/src/app/auth-demo/centered/sign-up/layout.tsx new file mode 100644 index 00000000..41630e20 --- /dev/null +++ b/app/frontend/src/app/auth-demo/centered/sign-up/layout.tsx @@ -0,0 +1,11 @@ +import { AuthCenteredLayout } from 'src/layouts/auth-centered'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return {children}; +} diff --git a/app/frontend/src/app/auth-demo/centered/sign-up/page.tsx b/app/frontend/src/app/auth-demo/centered/sign-up/page.tsx new file mode 100644 index 00000000..eddc792f --- /dev/null +++ b/app/frontend/src/app/auth-demo/centered/sign-up/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { CenteredSignUpView } from 'src/auth/view/auth-demo/centered'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Sign up | Layout centered - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/auth-demo/centered/update-password/layout.tsx b/app/frontend/src/app/auth-demo/centered/update-password/layout.tsx new file mode 100644 index 00000000..41630e20 --- /dev/null +++ b/app/frontend/src/app/auth-demo/centered/update-password/layout.tsx @@ -0,0 +1,11 @@ +import { AuthCenteredLayout } from 'src/layouts/auth-centered'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return {children}; +} diff --git a/app/frontend/src/app/auth-demo/centered/update-password/page.tsx b/app/frontend/src/app/auth-demo/centered/update-password/page.tsx new file mode 100644 index 00000000..34351fc5 --- /dev/null +++ b/app/frontend/src/app/auth-demo/centered/update-password/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { CenteredUpdatePasswordView } from 'src/auth/view/auth-demo/centered'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { + title: `Update password | Layout centered - ${CONFIG.appName}`, +}; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/auth-demo/centered/verify/layout.tsx b/app/frontend/src/app/auth-demo/centered/verify/layout.tsx new file mode 100644 index 00000000..41630e20 --- /dev/null +++ b/app/frontend/src/app/auth-demo/centered/verify/layout.tsx @@ -0,0 +1,11 @@ +import { AuthCenteredLayout } from 'src/layouts/auth-centered'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return {children}; +} diff --git a/app/frontend/src/app/auth-demo/centered/verify/page.tsx b/app/frontend/src/app/auth-demo/centered/verify/page.tsx new file mode 100644 index 00000000..9eb45701 --- /dev/null +++ b/app/frontend/src/app/auth-demo/centered/verify/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { CenteredVerifyView } from 'src/auth/view/auth-demo/centered'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Verify | Layout centered - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/auth-demo/split/reset-password/layout.tsx b/app/frontend/src/app/auth-demo/split/reset-password/layout.tsx new file mode 100644 index 00000000..9726184f --- /dev/null +++ b/app/frontend/src/app/auth-demo/split/reset-password/layout.tsx @@ -0,0 +1,11 @@ +import { AuthSplitLayout } from 'src/layouts/auth-split'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return {children}; +} diff --git a/app/frontend/src/app/auth-demo/split/reset-password/page.tsx b/app/frontend/src/app/auth-demo/split/reset-password/page.tsx new file mode 100644 index 00000000..db8c48ff --- /dev/null +++ b/app/frontend/src/app/auth-demo/split/reset-password/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { SplitResetPasswordView } from 'src/auth/view/auth-demo/split'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Reset password | Layout split - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/auth-demo/split/sign-in/layout.tsx b/app/frontend/src/app/auth-demo/split/sign-in/layout.tsx new file mode 100644 index 00000000..75ab2bb2 --- /dev/null +++ b/app/frontend/src/app/auth-demo/split/sign-in/layout.tsx @@ -0,0 +1,19 @@ +import { AuthSplitLayout } from 'src/layouts/auth-split'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return ( + + {children} + + ); +} diff --git a/app/frontend/src/app/auth-demo/split/sign-in/page.tsx b/app/frontend/src/app/auth-demo/split/sign-in/page.tsx new file mode 100644 index 00000000..08398cae --- /dev/null +++ b/app/frontend/src/app/auth-demo/split/sign-in/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { SplitSignInView } from 'src/auth/view/auth-demo/split'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Sign in | Layout split - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/auth-demo/split/sign-up/layout.tsx b/app/frontend/src/app/auth-demo/split/sign-up/layout.tsx new file mode 100644 index 00000000..9726184f --- /dev/null +++ b/app/frontend/src/app/auth-demo/split/sign-up/layout.tsx @@ -0,0 +1,11 @@ +import { AuthSplitLayout } from 'src/layouts/auth-split'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return {children}; +} diff --git a/app/frontend/src/app/auth-demo/split/sign-up/page.tsx b/app/frontend/src/app/auth-demo/split/sign-up/page.tsx new file mode 100644 index 00000000..7e24be46 --- /dev/null +++ b/app/frontend/src/app/auth-demo/split/sign-up/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { SplitSignUpView } from 'src/auth/view/auth-demo/split'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Sign up | Layout split - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/auth-demo/split/update-password/layout.tsx b/app/frontend/src/app/auth-demo/split/update-password/layout.tsx new file mode 100644 index 00000000..9726184f --- /dev/null +++ b/app/frontend/src/app/auth-demo/split/update-password/layout.tsx @@ -0,0 +1,11 @@ +import { AuthSplitLayout } from 'src/layouts/auth-split'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return {children}; +} diff --git a/app/frontend/src/app/auth-demo/split/update-password/page.tsx b/app/frontend/src/app/auth-demo/split/update-password/page.tsx new file mode 100644 index 00000000..9b103ec3 --- /dev/null +++ b/app/frontend/src/app/auth-demo/split/update-password/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { SplitUpdatePasswordView } from 'src/auth/view/auth-demo/split'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Update password | Layout split - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/auth-demo/split/verify/layout.tsx b/app/frontend/src/app/auth-demo/split/verify/layout.tsx new file mode 100644 index 00000000..9726184f --- /dev/null +++ b/app/frontend/src/app/auth-demo/split/verify/layout.tsx @@ -0,0 +1,11 @@ +import { AuthSplitLayout } from 'src/layouts/auth-split'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return {children}; +} diff --git a/app/frontend/src/app/auth-demo/split/verify/page.tsx b/app/frontend/src/app/auth-demo/split/verify/page.tsx new file mode 100644 index 00000000..179086d0 --- /dev/null +++ b/app/frontend/src/app/auth-demo/split/verify/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { SplitVerifyView } from 'src/auth/view/auth-demo/split'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Verify | Layout split - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/auth/amplify/reset-password/layout.tsx b/app/frontend/src/app/auth/amplify/reset-password/layout.tsx new file mode 100644 index 00000000..9726184f --- /dev/null +++ b/app/frontend/src/app/auth/amplify/reset-password/layout.tsx @@ -0,0 +1,11 @@ +import { AuthSplitLayout } from 'src/layouts/auth-split'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return {children}; +} diff --git a/app/frontend/src/app/auth/amplify/reset-password/page.tsx b/app/frontend/src/app/auth/amplify/reset-password/page.tsx new file mode 100644 index 00000000..1fb02fec --- /dev/null +++ b/app/frontend/src/app/auth/amplify/reset-password/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { AmplifyResetPasswordView } from 'src/auth/view/amplify'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Reset password | Amplify - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/auth/amplify/sign-in/layout.tsx b/app/frontend/src/app/auth/amplify/sign-in/layout.tsx new file mode 100644 index 00000000..395562f3 --- /dev/null +++ b/app/frontend/src/app/auth/amplify/sign-in/layout.tsx @@ -0,0 +1,23 @@ +import { AuthSplitLayout } from 'src/layouts/auth-split'; + +import { GuestGuard } from 'src/auth/guard'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return ( + + + {children} + + + ); +} diff --git a/app/frontend/src/app/auth/amplify/sign-in/page.tsx b/app/frontend/src/app/auth/amplify/sign-in/page.tsx new file mode 100644 index 00000000..ef631d99 --- /dev/null +++ b/app/frontend/src/app/auth/amplify/sign-in/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { AmplifySignInView } from 'src/auth/view/amplify'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Sign in | Amplify - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/auth/amplify/sign-up/layout.tsx b/app/frontend/src/app/auth/amplify/sign-up/layout.tsx new file mode 100644 index 00000000..30b4db51 --- /dev/null +++ b/app/frontend/src/app/auth/amplify/sign-up/layout.tsx @@ -0,0 +1,17 @@ +import { AuthSplitLayout } from 'src/layouts/auth-split'; + +import { GuestGuard } from 'src/auth/guard'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return ( + + {children} + + ); +} diff --git a/app/frontend/src/app/auth/amplify/sign-up/page.tsx b/app/frontend/src/app/auth/amplify/sign-up/page.tsx new file mode 100644 index 00000000..096dd055 --- /dev/null +++ b/app/frontend/src/app/auth/amplify/sign-up/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { AmplifySignUpView } from 'src/auth/view/amplify'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Sign up | Amplify - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/auth/amplify/update-password/layout.tsx b/app/frontend/src/app/auth/amplify/update-password/layout.tsx new file mode 100644 index 00000000..9726184f --- /dev/null +++ b/app/frontend/src/app/auth/amplify/update-password/layout.tsx @@ -0,0 +1,11 @@ +import { AuthSplitLayout } from 'src/layouts/auth-split'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return {children}; +} diff --git a/app/frontend/src/app/auth/amplify/update-password/page.tsx b/app/frontend/src/app/auth/amplify/update-password/page.tsx new file mode 100644 index 00000000..3250eefe --- /dev/null +++ b/app/frontend/src/app/auth/amplify/update-password/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { AmplifyUpdatePasswordView } from 'src/auth/view/amplify'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Update password | Amplify - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/auth/amplify/verify/layout.tsx b/app/frontend/src/app/auth/amplify/verify/layout.tsx new file mode 100644 index 00000000..9726184f --- /dev/null +++ b/app/frontend/src/app/auth/amplify/verify/layout.tsx @@ -0,0 +1,11 @@ +import { AuthSplitLayout } from 'src/layouts/auth-split'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return {children}; +} diff --git a/app/frontend/src/app/auth/amplify/verify/page.tsx b/app/frontend/src/app/auth/amplify/verify/page.tsx new file mode 100644 index 00000000..4973919a --- /dev/null +++ b/app/frontend/src/app/auth/amplify/verify/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { AmplifyVerifyView } from 'src/auth/view/amplify'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Verify | Amplify - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/auth/auth0/callback/page.tsx b/app/frontend/src/app/auth/auth0/callback/page.tsx new file mode 100644 index 00000000..a7a78fd4 --- /dev/null +++ b/app/frontend/src/app/auth/auth0/callback/page.tsx @@ -0,0 +1,7 @@ +import { SplashScreen } from 'src/components/loading-screen'; + +// ---------------------------------------------------------------------- + +export default function CallbackPage() { + return ; +} diff --git a/app/frontend/src/app/auth/auth0/sign-in/layout.tsx b/app/frontend/src/app/auth/auth0/sign-in/layout.tsx new file mode 100644 index 00000000..395562f3 --- /dev/null +++ b/app/frontend/src/app/auth/auth0/sign-in/layout.tsx @@ -0,0 +1,23 @@ +import { AuthSplitLayout } from 'src/layouts/auth-split'; + +import { GuestGuard } from 'src/auth/guard'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return ( + + + {children} + + + ); +} diff --git a/app/frontend/src/app/auth/auth0/sign-in/page.tsx b/app/frontend/src/app/auth/auth0/sign-in/page.tsx new file mode 100644 index 00000000..14f4cc8b --- /dev/null +++ b/app/frontend/src/app/auth/auth0/sign-in/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { Auth0SignInView } from 'src/auth/view/auth0'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Sign in | Auth0 - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/auth/firebase/reset-password/layout.tsx b/app/frontend/src/app/auth/firebase/reset-password/layout.tsx new file mode 100644 index 00000000..9726184f --- /dev/null +++ b/app/frontend/src/app/auth/firebase/reset-password/layout.tsx @@ -0,0 +1,11 @@ +import { AuthSplitLayout } from 'src/layouts/auth-split'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return {children}; +} diff --git a/app/frontend/src/app/auth/firebase/reset-password/page.tsx b/app/frontend/src/app/auth/firebase/reset-password/page.tsx new file mode 100644 index 00000000..ca0f7902 --- /dev/null +++ b/app/frontend/src/app/auth/firebase/reset-password/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { FirebaseResetPasswordView } from 'src/auth/view/firebase'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Reset password | Firebase - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/auth/firebase/sign-in/layout.tsx b/app/frontend/src/app/auth/firebase/sign-in/layout.tsx new file mode 100644 index 00000000..395562f3 --- /dev/null +++ b/app/frontend/src/app/auth/firebase/sign-in/layout.tsx @@ -0,0 +1,23 @@ +import { AuthSplitLayout } from 'src/layouts/auth-split'; + +import { GuestGuard } from 'src/auth/guard'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return ( + + + {children} + + + ); +} diff --git a/app/frontend/src/app/auth/firebase/sign-in/page.tsx b/app/frontend/src/app/auth/firebase/sign-in/page.tsx new file mode 100644 index 00000000..1d715932 --- /dev/null +++ b/app/frontend/src/app/auth/firebase/sign-in/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { FirebaseSignInView } from 'src/auth/view/firebase'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Sign in | Firebase - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/auth/firebase/sign-up/layout.tsx b/app/frontend/src/app/auth/firebase/sign-up/layout.tsx new file mode 100644 index 00000000..30b4db51 --- /dev/null +++ b/app/frontend/src/app/auth/firebase/sign-up/layout.tsx @@ -0,0 +1,17 @@ +import { AuthSplitLayout } from 'src/layouts/auth-split'; + +import { GuestGuard } from 'src/auth/guard'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return ( + + {children} + + ); +} diff --git a/app/frontend/src/app/auth/firebase/sign-up/page.tsx b/app/frontend/src/app/auth/firebase/sign-up/page.tsx new file mode 100644 index 00000000..b945f27d --- /dev/null +++ b/app/frontend/src/app/auth/firebase/sign-up/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { FirebaseSignUpView } from 'src/auth/view/firebase'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Sign up | Firebase - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/auth/firebase/verify/layout.tsx b/app/frontend/src/app/auth/firebase/verify/layout.tsx new file mode 100644 index 00000000..9726184f --- /dev/null +++ b/app/frontend/src/app/auth/firebase/verify/layout.tsx @@ -0,0 +1,11 @@ +import { AuthSplitLayout } from 'src/layouts/auth-split'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return {children}; +} diff --git a/app/frontend/src/app/auth/firebase/verify/page.tsx b/app/frontend/src/app/auth/firebase/verify/page.tsx new file mode 100644 index 00000000..9002fe15 --- /dev/null +++ b/app/frontend/src/app/auth/firebase/verify/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { FirebaseVerifyView } from 'src/auth/view/firebase'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Verify | Firebase - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/auth/jwt/sign-in/layout.tsx b/app/frontend/src/app/auth/jwt/sign-in/layout.tsx new file mode 100644 index 00000000..395562f3 --- /dev/null +++ b/app/frontend/src/app/auth/jwt/sign-in/layout.tsx @@ -0,0 +1,23 @@ +import { AuthSplitLayout } from 'src/layouts/auth-split'; + +import { GuestGuard } from 'src/auth/guard'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return ( + + + {children} + + + ); +} diff --git a/app/frontend/src/app/auth/jwt/sign-in/page.tsx b/app/frontend/src/app/auth/jwt/sign-in/page.tsx new file mode 100644 index 00000000..0471ed2c --- /dev/null +++ b/app/frontend/src/app/auth/jwt/sign-in/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { JwtSignInView } from 'src/auth/view/jwt'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Sign in | Jwt - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/auth/jwt/sign-up/layout.tsx b/app/frontend/src/app/auth/jwt/sign-up/layout.tsx new file mode 100644 index 00000000..30b4db51 --- /dev/null +++ b/app/frontend/src/app/auth/jwt/sign-up/layout.tsx @@ -0,0 +1,17 @@ +import { AuthSplitLayout } from 'src/layouts/auth-split'; + +import { GuestGuard } from 'src/auth/guard'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return ( + + {children} + + ); +} diff --git a/app/frontend/src/app/auth/jwt/sign-up/page.tsx b/app/frontend/src/app/auth/jwt/sign-up/page.tsx new file mode 100644 index 00000000..98f451b4 --- /dev/null +++ b/app/frontend/src/app/auth/jwt/sign-up/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { JwtSignUpView } from 'src/auth/view/jwt'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Sign up | Jwt - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/auth/supabase/reset-password/layout.tsx b/app/frontend/src/app/auth/supabase/reset-password/layout.tsx new file mode 100644 index 00000000..9726184f --- /dev/null +++ b/app/frontend/src/app/auth/supabase/reset-password/layout.tsx @@ -0,0 +1,11 @@ +import { AuthSplitLayout } from 'src/layouts/auth-split'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return {children}; +} diff --git a/app/frontend/src/app/auth/supabase/reset-password/page.tsx b/app/frontend/src/app/auth/supabase/reset-password/page.tsx new file mode 100644 index 00000000..cb275876 --- /dev/null +++ b/app/frontend/src/app/auth/supabase/reset-password/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { SupabaseResetPasswordView } from 'src/auth/view/supabase'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Reset password | Supabase - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/auth/supabase/sign-in/layout.tsx b/app/frontend/src/app/auth/supabase/sign-in/layout.tsx new file mode 100644 index 00000000..395562f3 --- /dev/null +++ b/app/frontend/src/app/auth/supabase/sign-in/layout.tsx @@ -0,0 +1,23 @@ +import { AuthSplitLayout } from 'src/layouts/auth-split'; + +import { GuestGuard } from 'src/auth/guard'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return ( + + + {children} + + + ); +} diff --git a/app/frontend/src/app/auth/supabase/sign-in/page.tsx b/app/frontend/src/app/auth/supabase/sign-in/page.tsx new file mode 100644 index 00000000..49b0369a --- /dev/null +++ b/app/frontend/src/app/auth/supabase/sign-in/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { SupabaseSignInView } from 'src/auth/view/supabase'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Sign in | Supabase - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/auth/supabase/sign-up/layout.tsx b/app/frontend/src/app/auth/supabase/sign-up/layout.tsx new file mode 100644 index 00000000..30b4db51 --- /dev/null +++ b/app/frontend/src/app/auth/supabase/sign-up/layout.tsx @@ -0,0 +1,17 @@ +import { AuthSplitLayout } from 'src/layouts/auth-split'; + +import { GuestGuard } from 'src/auth/guard'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return ( + + {children} + + ); +} diff --git a/app/frontend/src/app/auth/supabase/sign-up/page.tsx b/app/frontend/src/app/auth/supabase/sign-up/page.tsx new file mode 100644 index 00000000..d4f0120e --- /dev/null +++ b/app/frontend/src/app/auth/supabase/sign-up/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { SupabaseSignUpView } from 'src/auth/view/supabase'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Sign up | Supabase - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/auth/supabase/update-password/layout.tsx b/app/frontend/src/app/auth/supabase/update-password/layout.tsx new file mode 100644 index 00000000..9726184f --- /dev/null +++ b/app/frontend/src/app/auth/supabase/update-password/layout.tsx @@ -0,0 +1,11 @@ +import { AuthSplitLayout } from 'src/layouts/auth-split'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return {children}; +} diff --git a/app/frontend/src/app/auth/supabase/update-password/page.tsx b/app/frontend/src/app/auth/supabase/update-password/page.tsx new file mode 100644 index 00000000..b8f99b80 --- /dev/null +++ b/app/frontend/src/app/auth/supabase/update-password/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { SupabaseUpdatePasswordView } from 'src/auth/view/supabase'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Update password | Supabase - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/auth/supabase/verify/layout.tsx b/app/frontend/src/app/auth/supabase/verify/layout.tsx new file mode 100644 index 00000000..9726184f --- /dev/null +++ b/app/frontend/src/app/auth/supabase/verify/layout.tsx @@ -0,0 +1,11 @@ +import { AuthSplitLayout } from 'src/layouts/auth-split'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return {children}; +} diff --git a/app/frontend/src/app/auth/supabase/verify/page.tsx b/app/frontend/src/app/auth/supabase/verify/page.tsx new file mode 100644 index 00000000..c565999d --- /dev/null +++ b/app/frontend/src/app/auth/supabase/verify/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { SupabaseVerifyView } from 'src/auth/view/supabase'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Verify | Supabase - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/blank/layout.tsx b/app/frontend/src/app/blank/layout.tsx new file mode 100644 index 00000000..16053769 --- /dev/null +++ b/app/frontend/src/app/blank/layout.tsx @@ -0,0 +1,11 @@ +import { MainLayout } from 'src/layouts/main'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return {children}; +} diff --git a/app/frontend/src/app/blank/page.tsx b/app/frontend/src/app/blank/page.tsx new file mode 100644 index 00000000..0d2aadf1 --- /dev/null +++ b/app/frontend/src/app/blank/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from 'next'; + +import Container from '@mui/material/Container'; +import Typography from '@mui/material/Typography'; + +import { CONFIG } from 'src/global-config'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Blank - ${CONFIG.appName}` }; + +export default function Page() { + return ( + + Blank + + ); +} diff --git a/app/frontend/src/app/coming-soon/layout.tsx b/app/frontend/src/app/coming-soon/layout.tsx new file mode 100644 index 00000000..03d85386 --- /dev/null +++ b/app/frontend/src/app/coming-soon/layout.tsx @@ -0,0 +1,19 @@ +import { SimpleLayout } from 'src/layouts/simple'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return ( + + {children} + + ); +} diff --git a/app/frontend/src/app/coming-soon/page.tsx b/app/frontend/src/app/coming-soon/page.tsx new file mode 100644 index 00000000..38d98a0f --- /dev/null +++ b/app/frontend/src/app/coming-soon/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { ComingSoonView } from 'src/sections/coming-soon/view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Coming soon - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/extra/animate/page.tsx b/app/frontend/src/app/components/extra/animate/page.tsx new file mode 100644 index 00000000..d4f2519d --- /dev/null +++ b/app/frontend/src/app/components/extra/animate/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { AnimateView } from 'src/sections/_examples/extra/animate-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Animate | Components - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/extra/carousel/page.tsx b/app/frontend/src/app/components/extra/carousel/page.tsx new file mode 100644 index 00000000..1ffb887f --- /dev/null +++ b/app/frontend/src/app/components/extra/carousel/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { CarouselView } from 'src/sections/_examples/extra/carousel-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Carousel | Components - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/extra/chart/page.tsx b/app/frontend/src/app/components/extra/chart/page.tsx new file mode 100644 index 00000000..310b971c --- /dev/null +++ b/app/frontend/src/app/components/extra/chart/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { ChartView } from 'src/sections/_examples/extra/chart-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Chart | Components - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/extra/dnd/page.tsx b/app/frontend/src/app/components/extra/dnd/page.tsx new file mode 100644 index 00000000..414aeef3 --- /dev/null +++ b/app/frontend/src/app/components/extra/dnd/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { DndView } from 'src/sections/_examples/extra/dnd-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Dnd | Components - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/extra/editor/page.tsx b/app/frontend/src/app/components/extra/editor/page.tsx new file mode 100644 index 00000000..5511685d --- /dev/null +++ b/app/frontend/src/app/components/extra/editor/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { EditorView } from 'src/sections/_examples/extra/editor-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Editor | Components - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/extra/form-validation/page.tsx b/app/frontend/src/app/components/extra/form-validation/page.tsx new file mode 100644 index 00000000..0c3d94db --- /dev/null +++ b/app/frontend/src/app/components/extra/form-validation/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { FormValidationView } from 'src/sections/_examples/extra/form-validation-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Form validation | Components - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/extra/form-wizard/page.tsx b/app/frontend/src/app/components/extra/form-wizard/page.tsx new file mode 100644 index 00000000..a799a878 --- /dev/null +++ b/app/frontend/src/app/components/extra/form-wizard/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { FormWizardView } from 'src/sections/_examples/extra/form-wizard-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Form wizard | Components - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/extra/image/page.tsx b/app/frontend/src/app/components/extra/image/page.tsx new file mode 100644 index 00000000..7bff91cf --- /dev/null +++ b/app/frontend/src/app/components/extra/image/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { ImageView } from 'src/sections/_examples/extra/image-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Image | Components - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/extra/label/page.tsx b/app/frontend/src/app/components/extra/label/page.tsx new file mode 100644 index 00000000..4aeb6b2b --- /dev/null +++ b/app/frontend/src/app/components/extra/label/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { LabelView } from 'src/sections/_examples/extra/label-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Label | Components - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/extra/layout/page.tsx b/app/frontend/src/app/components/extra/layout/page.tsx new file mode 100644 index 00000000..71d5d48f --- /dev/null +++ b/app/frontend/src/app/components/extra/layout/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { LayoutView } from 'src/sections/_examples/extra/layout-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Layout | Components - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/extra/lightbox/page.tsx b/app/frontend/src/app/components/extra/lightbox/page.tsx new file mode 100644 index 00000000..348fd879 --- /dev/null +++ b/app/frontend/src/app/components/extra/lightbox/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { LightboxView } from 'src/sections/_examples/extra/lightbox-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Lightbox | Components - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/extra/map/page.tsx b/app/frontend/src/app/components/extra/map/page.tsx new file mode 100644 index 00000000..2f0da53c --- /dev/null +++ b/app/frontend/src/app/components/extra/map/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { MapView } from 'src/sections/_examples/extra/map-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Map | Components - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/extra/markdown/page.tsx b/app/frontend/src/app/components/extra/markdown/page.tsx new file mode 100644 index 00000000..320083ed --- /dev/null +++ b/app/frontend/src/app/components/extra/markdown/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { MarkdownView } from 'src/sections/_examples/extra/markdown-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Markdown | Components - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/extra/mega-menu/page.tsx b/app/frontend/src/app/components/extra/mega-menu/page.tsx new file mode 100644 index 00000000..341b6a02 --- /dev/null +++ b/app/frontend/src/app/components/extra/mega-menu/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { MegaMenuView } from 'src/sections/_examples/extra/mega-menu-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Mega menu | Components - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/extra/multi-language/page.tsx b/app/frontend/src/app/components/extra/multi-language/page.tsx new file mode 100644 index 00000000..0f817720 --- /dev/null +++ b/app/frontend/src/app/components/extra/multi-language/page.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; +import { getServerTranslations } from 'src/locales/server'; + +import { MultiLanguageView } from 'src/sections/_examples/extra/multi-language-view'; +import { navData } from 'src/sections/_examples/extra/multi-language-view/nav-config-translate'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Multi language | Components - ${CONFIG.appName}` }; + +export default async function Page() { + let ssrNavData; + + if (!CONFIG.isStaticExport) { + const { t } = await getServerTranslations('navbar'); + const data = navData(t); + + ssrNavData = data; + } + + return ; +} diff --git a/app/frontend/src/app/components/extra/navigation-bar/page.tsx b/app/frontend/src/app/components/extra/navigation-bar/page.tsx new file mode 100644 index 00000000..4329916a --- /dev/null +++ b/app/frontend/src/app/components/extra/navigation-bar/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { NavigationBarView } from 'src/sections/_examples/extra/navigation-bar-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Navigation bar | Components - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/extra/organization-chart/page.tsx b/app/frontend/src/app/components/extra/organization-chart/page.tsx new file mode 100644 index 00000000..fd4d0e11 --- /dev/null +++ b/app/frontend/src/app/components/extra/organization-chart/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { OrganizationalChartView } from 'src/sections/_examples/extra/organizational-chart-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { + title: `Organizational chart | Components - ${CONFIG.appName}`, +}; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/extra/scroll-progress/page.tsx b/app/frontend/src/app/components/extra/scroll-progress/page.tsx new file mode 100644 index 00000000..13a8da98 --- /dev/null +++ b/app/frontend/src/app/components/extra/scroll-progress/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { ScrollProgressView } from 'src/sections/_examples/extra/scroll-progress-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Scroll progress | Components - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/extra/scroll/page.tsx b/app/frontend/src/app/components/extra/scroll/page.tsx new file mode 100644 index 00000000..dce20a2d --- /dev/null +++ b/app/frontend/src/app/components/extra/scroll/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { ScrollbarView } from 'src/sections/_examples/extra/scrollbar-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Scrollbar | Components - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/extra/snackbar/page.tsx b/app/frontend/src/app/components/extra/snackbar/page.tsx new file mode 100644 index 00000000..50666059 --- /dev/null +++ b/app/frontend/src/app/components/extra/snackbar/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { SnackbarView } from 'src/sections/_examples/extra/snackbar-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Snackbar | Components - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/extra/upload/page.tsx b/app/frontend/src/app/components/extra/upload/page.tsx new file mode 100644 index 00000000..3539deee --- /dev/null +++ b/app/frontend/src/app/components/extra/upload/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { UploadView } from 'src/sections/_examples/extra/upload-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Upload | Components - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/extra/utilities/page.tsx b/app/frontend/src/app/components/extra/utilities/page.tsx new file mode 100644 index 00000000..68f756c4 --- /dev/null +++ b/app/frontend/src/app/components/extra/utilities/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { UtilitiesView } from 'src/sections/_examples/extra/utilities-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Utilities | Components - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/extra/walktour/page.tsx b/app/frontend/src/app/components/extra/walktour/page.tsx new file mode 100644 index 00000000..90eea8bf --- /dev/null +++ b/app/frontend/src/app/components/extra/walktour/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { WalktourView } from 'src/sections/_examples/extra/walktour-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Walktour | Components - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/foundation/colors/page.tsx b/app/frontend/src/app/components/foundation/colors/page.tsx new file mode 100644 index 00000000..8870ce5c --- /dev/null +++ b/app/frontend/src/app/components/foundation/colors/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { ColorsView } from 'src/sections/_examples/foundation/colors-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Colors | Foundations - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/foundation/grid/page.tsx b/app/frontend/src/app/components/foundation/grid/page.tsx new file mode 100644 index 00000000..ce5e6d92 --- /dev/null +++ b/app/frontend/src/app/components/foundation/grid/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { GridView } from 'src/sections/_examples/foundation/grid-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Grid | Foundations - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/foundation/icons/page.tsx b/app/frontend/src/app/components/foundation/icons/page.tsx new file mode 100644 index 00000000..bf22d170 --- /dev/null +++ b/app/frontend/src/app/components/foundation/icons/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { IconsView } from 'src/sections/_examples/foundation/icons-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Icons | Foundations - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/foundation/shadows/page.tsx b/app/frontend/src/app/components/foundation/shadows/page.tsx new file mode 100644 index 00000000..619cee3e --- /dev/null +++ b/app/frontend/src/app/components/foundation/shadows/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { ShadowsView } from 'src/sections/_examples/foundation/shadows-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Shadows | Foundations - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/foundation/typography/page.tsx b/app/frontend/src/app/components/foundation/typography/page.tsx new file mode 100644 index 00000000..f0c7727f --- /dev/null +++ b/app/frontend/src/app/components/foundation/typography/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { TypographyView } from 'src/sections/_examples/foundation/typography-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Typography | Foundations - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/layout.tsx b/app/frontend/src/app/components/layout.tsx new file mode 100644 index 00000000..16053769 --- /dev/null +++ b/app/frontend/src/app/components/layout.tsx @@ -0,0 +1,11 @@ +import { MainLayout } from 'src/layouts/main'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return {children}; +} diff --git a/app/frontend/src/app/components/mui/accordion/page.tsx b/app/frontend/src/app/components/mui/accordion/page.tsx new file mode 100644 index 00000000..2b1ce31d --- /dev/null +++ b/app/frontend/src/app/components/mui/accordion/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { AccordionView } from 'src/sections/_examples/mui/accordion-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Accordion | MUI - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/mui/alert/page.tsx b/app/frontend/src/app/components/mui/alert/page.tsx new file mode 100644 index 00000000..354685d0 --- /dev/null +++ b/app/frontend/src/app/components/mui/alert/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { AlertView } from 'src/sections/_examples/mui/alert-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Alert | MUI - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/mui/autocomplete/page.tsx b/app/frontend/src/app/components/mui/autocomplete/page.tsx new file mode 100644 index 00000000..38656b57 --- /dev/null +++ b/app/frontend/src/app/components/mui/autocomplete/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { AutocompleteView } from 'src/sections/_examples/mui/autocomplete-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Autocomplete | MUI - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/mui/avatar/page.tsx b/app/frontend/src/app/components/mui/avatar/page.tsx new file mode 100644 index 00000000..0caad72f --- /dev/null +++ b/app/frontend/src/app/components/mui/avatar/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { AvatarView } from 'src/sections/_examples/mui/avatar-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Avatar | MUI - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/mui/badge/page.tsx b/app/frontend/src/app/components/mui/badge/page.tsx new file mode 100644 index 00000000..2565b87d --- /dev/null +++ b/app/frontend/src/app/components/mui/badge/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { BadgeView } from 'src/sections/_examples/mui/badge-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Badge | MUI - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/mui/breadcrumbs/page.tsx b/app/frontend/src/app/components/mui/breadcrumbs/page.tsx new file mode 100644 index 00000000..b534ce39 --- /dev/null +++ b/app/frontend/src/app/components/mui/breadcrumbs/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { BreadcrumbsView } from 'src/sections/_examples/mui/breadcrumbs-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Breadcrumbs | MUI - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/mui/buttons/page.tsx b/app/frontend/src/app/components/mui/buttons/page.tsx new file mode 100644 index 00000000..8ee60c37 --- /dev/null +++ b/app/frontend/src/app/components/mui/buttons/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { ButtonView } from 'src/sections/_examples/mui/button-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Button | MUI - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/mui/checkbox/page.tsx b/app/frontend/src/app/components/mui/checkbox/page.tsx new file mode 100644 index 00000000..796ad4ba --- /dev/null +++ b/app/frontend/src/app/components/mui/checkbox/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { CheckboxView } from 'src/sections/_examples/mui/checkbox-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Checkbox | MUI - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/mui/chip/page.tsx b/app/frontend/src/app/components/mui/chip/page.tsx new file mode 100644 index 00000000..648639e2 --- /dev/null +++ b/app/frontend/src/app/components/mui/chip/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { ChipView } from 'src/sections/_examples/mui/chip-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Chip | MUI - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/mui/data-grid/page.tsx b/app/frontend/src/app/components/mui/data-grid/page.tsx new file mode 100644 index 00000000..93b6c888 --- /dev/null +++ b/app/frontend/src/app/components/mui/data-grid/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { DataGridView } from 'src/sections/_examples/mui/data-grid-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `DataGrid | MUI - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/mui/date-pickers/page.tsx b/app/frontend/src/app/components/mui/date-pickers/page.tsx new file mode 100644 index 00000000..8f4076a0 --- /dev/null +++ b/app/frontend/src/app/components/mui/date-pickers/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { DatePickersView } from 'src/sections/_examples/mui/date-pickers-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Date pickers | MUI - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/mui/dialog/page.tsx b/app/frontend/src/app/components/mui/dialog/page.tsx new file mode 100644 index 00000000..1c165974 --- /dev/null +++ b/app/frontend/src/app/components/mui/dialog/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { DialogView } from 'src/sections/_examples/mui/dialog-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Dialog | MUI - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/mui/drawer/page.tsx b/app/frontend/src/app/components/mui/drawer/page.tsx new file mode 100644 index 00000000..11076093 --- /dev/null +++ b/app/frontend/src/app/components/mui/drawer/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { DrawerView } from 'src/sections/_examples/mui/drawer-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Drawer | MUI - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/mui/list/page.tsx b/app/frontend/src/app/components/mui/list/page.tsx new file mode 100644 index 00000000..703aede5 --- /dev/null +++ b/app/frontend/src/app/components/mui/list/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { ListView } from 'src/sections/_examples/mui/list-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `List | MUI - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/mui/menu/page.tsx b/app/frontend/src/app/components/mui/menu/page.tsx new file mode 100644 index 00000000..f7630151 --- /dev/null +++ b/app/frontend/src/app/components/mui/menu/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { MenuView } from 'src/sections/_examples/mui/menu-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Menu | MUI - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/mui/pagination/page.tsx b/app/frontend/src/app/components/mui/pagination/page.tsx new file mode 100644 index 00000000..c137c1e9 --- /dev/null +++ b/app/frontend/src/app/components/mui/pagination/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { PaginationView } from 'src/sections/_examples/mui/pagination-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Pagination | MUI - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/mui/popover/page.tsx b/app/frontend/src/app/components/mui/popover/page.tsx new file mode 100644 index 00000000..0edf37b5 --- /dev/null +++ b/app/frontend/src/app/components/mui/popover/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { PopoverView } from 'src/sections/_examples/mui/popover-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Popover | MUI - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/mui/progress/page.tsx b/app/frontend/src/app/components/mui/progress/page.tsx new file mode 100644 index 00000000..dd911c97 --- /dev/null +++ b/app/frontend/src/app/components/mui/progress/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { ProgressView } from 'src/sections/_examples/mui/progress-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Progress | MUI - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/mui/radio-button/page.tsx b/app/frontend/src/app/components/mui/radio-button/page.tsx new file mode 100644 index 00000000..9a75b924 --- /dev/null +++ b/app/frontend/src/app/components/mui/radio-button/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { RadioButtonView } from 'src/sections/_examples/mui/radio-button-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Radio button | MUI - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/mui/rating/page.tsx b/app/frontend/src/app/components/mui/rating/page.tsx new file mode 100644 index 00000000..14e31a36 --- /dev/null +++ b/app/frontend/src/app/components/mui/rating/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { RatingView } from 'src/sections/_examples/mui/rating-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Rating | MUI - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/mui/slider/page.tsx b/app/frontend/src/app/components/mui/slider/page.tsx new file mode 100644 index 00000000..d76413d4 --- /dev/null +++ b/app/frontend/src/app/components/mui/slider/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { SliderView } from 'src/sections/_examples/mui/slider-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Slider | MUI - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/mui/stepper/page.tsx b/app/frontend/src/app/components/mui/stepper/page.tsx new file mode 100644 index 00000000..9effb410 --- /dev/null +++ b/app/frontend/src/app/components/mui/stepper/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { StepperView } from 'src/sections/_examples/mui/stepper-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Stepper | MUI - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/mui/switch/page.tsx b/app/frontend/src/app/components/mui/switch/page.tsx new file mode 100644 index 00000000..0d2aaabd --- /dev/null +++ b/app/frontend/src/app/components/mui/switch/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { SwitchView } from 'src/sections/_examples/mui/switch-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Switch | MUI - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/mui/table/page.tsx b/app/frontend/src/app/components/mui/table/page.tsx new file mode 100644 index 00000000..be7ecab0 --- /dev/null +++ b/app/frontend/src/app/components/mui/table/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { TableView } from 'src/sections/_examples/mui/table-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Table | MUI - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/mui/tabs/page.tsx b/app/frontend/src/app/components/mui/tabs/page.tsx new file mode 100644 index 00000000..2d86663f --- /dev/null +++ b/app/frontend/src/app/components/mui/tabs/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { TabsView } from 'src/sections/_examples/mui/tabs-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Tabs | MUI - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/mui/textfield/page.tsx b/app/frontend/src/app/components/mui/textfield/page.tsx new file mode 100644 index 00000000..cf031532 --- /dev/null +++ b/app/frontend/src/app/components/mui/textfield/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { TextfieldView } from 'src/sections/_examples/mui/textfield-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Textfield | MUI - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/mui/timeline/page.tsx b/app/frontend/src/app/components/mui/timeline/page.tsx new file mode 100644 index 00000000..87b1ba77 --- /dev/null +++ b/app/frontend/src/app/components/mui/timeline/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { TimelineView } from 'src/sections/_examples/mui/timeline-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Timeline | MUI - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/mui/tooltip/page.tsx b/app/frontend/src/app/components/mui/tooltip/page.tsx new file mode 100644 index 00000000..08a6176a --- /dev/null +++ b/app/frontend/src/app/components/mui/tooltip/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { TooltipView } from 'src/sections/_examples/mui/tooltip-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Tooltip | MUI - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/mui/transfer-list/page.tsx b/app/frontend/src/app/components/mui/transfer-list/page.tsx new file mode 100644 index 00000000..c87d03e1 --- /dev/null +++ b/app/frontend/src/app/components/mui/transfer-list/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { TransferListView } from 'src/sections/_examples/mui/transfer-list-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Transfer list | MUI - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/mui/tree-view/page.tsx b/app/frontend/src/app/components/mui/tree-view/page.tsx new file mode 100644 index 00000000..d614d9bf --- /dev/null +++ b/app/frontend/src/app/components/mui/tree-view/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { TreeView } from 'src/sections/_examples/mui/tree-view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Tree view | MUI - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/components/page.tsx b/app/frontend/src/app/components/page.tsx new file mode 100644 index 00000000..fde6268c --- /dev/null +++ b/app/frontend/src/app/components/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { ComponentsView } from 'src/sections/_examples/view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `All components | MUI - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/contact-us/layout.tsx b/app/frontend/src/app/contact-us/layout.tsx new file mode 100644 index 00000000..16053769 --- /dev/null +++ b/app/frontend/src/app/contact-us/layout.tsx @@ -0,0 +1,11 @@ +import { MainLayout } from 'src/layouts/main'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return {children}; +} diff --git a/app/frontend/src/app/contact-us/page.tsx b/app/frontend/src/app/contact-us/page.tsx new file mode 100644 index 00000000..0750ea3d --- /dev/null +++ b/app/frontend/src/app/contact-us/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { ContactView } from 'src/sections/contact/view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Contact us - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/dashboard/blank/page.tsx b/app/frontend/src/app/dashboard/blank/page.tsx new file mode 100644 index 00000000..bccc23d5 --- /dev/null +++ b/app/frontend/src/app/dashboard/blank/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { BlankView } from 'src/sections/blank/view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Blank | Dashboard - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/dashboard/chat/page.tsx b/app/frontend/src/app/dashboard/chat/page.tsx new file mode 100644 index 00000000..1ac3921a --- /dev/null +++ b/app/frontend/src/app/dashboard/chat/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { ChatView } from 'src/sections/chat/view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Chat | Dashboard - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/dashboard/data-sources/page.tsx b/app/frontend/src/app/dashboard/data-sources/page.tsx new file mode 100644 index 00000000..55211292 --- /dev/null +++ b/app/frontend/src/app/dashboard/data-sources/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { FileManagerView } from 'src/sections/file-manager/view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `File manager | Dashboard - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/dashboard/generation/page.tsx b/app/frontend/src/app/dashboard/generation/page.tsx new file mode 100644 index 00000000..8c43ba1e --- /dev/null +++ b/app/frontend/src/app/dashboard/generation/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { OverviewGenerationView } from 'src/sections/overview/generation/view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Generation | Dashboard - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/dashboard/generation/settings/api-access/page.tsx b/app/frontend/src/app/dashboard/generation/settings/api-access/page.tsx new file mode 100644 index 00000000..88e79554 --- /dev/null +++ b/app/frontend/src/app/dashboard/generation/settings/api-access/page.tsx @@ -0,0 +1,86 @@ +'use client'; + +// import type { Metadata } from 'next'; + +import { useState } from 'react'; + +import Box from '@mui/material/Box'; +import Grid from '@mui/material/Grid2'; +import Paper from '@mui/material/Paper'; +import Button from '@mui/material/Button'; +import Divider from '@mui/material/Divider'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; + +import { DashboardContent } from 'src/layouts/dashboard'; + +import { toast } from 'src/components/snackbar'; + +// ---------------------------------------------------------------------- +; + +export default function Page() { + const [tenantId, setTenantId] = useState(''); + const [clientId, setClientId] = useState(''); + const [clientSecretHint] = useState('•••••••• (rotate via Azure portal)'); + + const handleRotate = () => { + toast.info('Rotate client secret (demo)'); + }; + + return ( + + + API & Access + + + + + + Azure AD / App Registration + + Configure the Azure AD application used for server-to-server authentication. You can use + managed identities or an App Registration. For production, prefer managed identities + where possible. + + + + setTenantId(e.target.value)} + /> + + setClientId(e.target.value)} /> + + + + + + + + + + Ingress / Network + + Restrict which IPs or VNets can call your retriever API. When running in Azure, combine + this with Private Endpoints and Service Endpoints for tighter network control. + + + + + + + + + + + ); +} diff --git a/app/frontend/src/app/dashboard/generation/settings/configuration/page.tsx b/app/frontend/src/app/dashboard/generation/settings/configuration/page.tsx new file mode 100644 index 00000000..7d6cb82f --- /dev/null +++ b/app/frontend/src/app/dashboard/generation/settings/configuration/page.tsx @@ -0,0 +1,261 @@ +"use client"; + +import { useState } from "react"; +import { usePopover } from "minimal-shared/hooks"; + +import Box from "@mui/material/Box"; +import Link from "@mui/material/Link"; +import Grid from "@mui/material/Grid2"; +import Paper from "@mui/material/Paper"; +import Switch from "@mui/material/Switch"; +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import IconButton from "@mui/material/IconButton"; +import FormControlLabel from "@mui/material/FormControlLabel"; + +import { DashboardContent } from "src/layouts/dashboard"; + +import { toast } from "src/components/snackbar"; +import { Iconify } from "src/components/iconify"; +import { CustomPopover } from "src/components/custom-popover/custom-popover"; + +// -------------------------------------------------------- + +function FeatureRow({ + opt, + hasToggle, + checked, + onToggle, +}: { + opt: { key: string; label: string; desc: string }; + hasToggle?: boolean; + checked?: boolean; + onToggle?: (v: boolean) => void; +}) { + const pop = usePopover(); + return ( + + {opt.label} + + + + + + + {hasToggle && ( + onToggle && onToggle(e.target.checked)} + inputProps={{ 'aria-label': `${opt.key}-toggle` }} + /> + )} + + + + + + {opt.label} + + + {opt.desc} + + + + + ); +} + +// -------------------------------------------------------- + +export default function Page() { + const [queryRewrite, setQueryRewrite] = useState(true); + const [retrievalMode, setRetrievalMode] = useState("hybrid"); + const [indexer, setIndexer] = useState("default-indexer"); + const [textEmbeddingsEnabled, setTextEmbeddingsEnabled] = useState(true); + const [embeddingsEnabled, setEmbeddingsEnabled] = useState(true); + const [allowOidsEnabled, setAllowOidsEnabled] = useState(false); + const [allowGOidsEnabled, setAllowGOidsEnabled] = useState(false); + + const handleSave = () => { + toast.success("Retriever configuration saved (demo)"); + }; + + return ( + + + Retriever Configuration + + + + + + {/* Query Rewriting */} + + Query Rewriting + + + + Toggle model-assisted query rewriting to normalize or expand user + queries before performing retrieval. + + + setQueryRewrite(e.target.checked)} + /> + } + label={queryRewrite ? "Enabled" : "Disabled"} + /> + + {/* Feature Rows */} + + + Embedding Features + + + + {[ + { + key: "text-embeddings", + label: "Text embeddings", + desc: "Generate vector embeddings from text using the chosen model.", + }, + { + key: "embeddings", + label: "Embeddings (general)", + desc: "Use precomputed embeddings or on-the-fly embeddings.", + }, + { + key: "image-embeddings", + label: "Image embeddings", + desc: "Create embeddings from images for multimodal retrieval.", + }, + ].map((opt) => { + if (opt.key === "text-embeddings") { + return ( + setTextEmbeddingsEnabled(v)} + /> + ); + } + + if (opt.key === "embeddings") { + return ( + setEmbeddingsEnabled(v)} + /> + ); + } + + return ; + })} + + + {/* Retrival Mode */} + setRetrievalMode(e.target.value)} + sx={{ mb: 2 }} + > + + + + + + {/* Indexer */} + setIndexer(e.target.value)} + sx={{ mb: 2 }} + disabled + /> + + {/* Allowed OIDs (toggle + conditional input) */} + setAllowOidsEnabled(v)} + /> + setAllowGOidsEnabled(v)} + /> + + + + + Data ingestion docs + + + + + + + + + + + + + + + + Help & Docs + + + + Quick links and notes for configuring retrievers. Use OID or Group filters to restrict retrieval to specific Azure Entra identities. + + + Data ingestion docs +
+ Monitoring guide +
+
+
+
+ ); +} diff --git a/app/frontend/src/app/dashboard/generation/settings/indexing-status/page.tsx b/app/frontend/src/app/dashboard/generation/settings/indexing-status/page.tsx new file mode 100644 index 00000000..03212e75 --- /dev/null +++ b/app/frontend/src/app/dashboard/generation/settings/indexing-status/page.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useState } from 'react'; + +import Box from '@mui/material/Box'; +import Grid from '@mui/material/Grid2'; +import Paper from '@mui/material/Paper'; +import Button from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; + +import { DashboardContent } from 'src/layouts/dashboard'; + +import { toast } from 'src/components/snackbar'; + +// ---------------------------------------------------------------------- + +export default function Page() { + const [status] = useState<'Idle' | 'Running' | 'Failed'>('Idle'); + const [lastRun] = useState('2025-11-27 14:02:11 UTC'); + const [nextRun] = useState('2025-11-28 02:00:00 UTC'); + const [errors] = useState(0); + + const handleRunNow = () => { + toast.info('Indexer run triggered (demo)'); + }; + + return ( + + + Indexing Status + + + + + + Indexer + + Overview of the document ingestion & indexer that powers retrieval. This reflects the + indexer referenced in docs/data_ingestion.md and the ingestion architecture. + + + + Current status: {status} + Last run: {lastRun} + Next scheduled run: {nextRun} + Recent errors: {errors} + + + + + + + + + + + ); +} diff --git a/app/frontend/src/app/dashboard/generation/settings/layout.tsx b/app/frontend/src/app/dashboard/generation/settings/layout.tsx new file mode 100644 index 00000000..ebfabb35 --- /dev/null +++ b/app/frontend/src/app/dashboard/generation/settings/layout.tsx @@ -0,0 +1,11 @@ +import { AccountLayout } from 'src/sections/account/account-layout'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return {children}; +} diff --git a/app/frontend/src/app/dashboard/generation/settings/page.tsx b/app/frontend/src/app/dashboard/generation/settings/page.tsx new file mode 100644 index 00000000..7cd5712b --- /dev/null +++ b/app/frontend/src/app/dashboard/generation/settings/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { AccountGeneralView } from 'src/sections/account/view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { + title: `Retrievers settings | Dashboard - ${CONFIG.appName}`, +}; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/dashboard/generation/settings/retrieval-logs/page.tsx b/app/frontend/src/app/dashboard/generation/settings/retrieval-logs/page.tsx new file mode 100644 index 00000000..3f9292c5 --- /dev/null +++ b/app/frontend/src/app/dashboard/generation/settings/retrieval-logs/page.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useState } from 'react'; + +import Box from '@mui/material/Box'; +import List from '@mui/material/List'; +import Grid from '@mui/material/Grid2'; +import Paper from '@mui/material/Paper'; +import Button from '@mui/material/Button'; +import ListItem from '@mui/material/ListItem'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import ListItemText from '@mui/material/ListItemText'; + +import { DashboardContent } from 'src/layouts/dashboard'; + +import { toast } from 'src/components/snackbar'; + +// ---------------------------------------------------------------------- + +export default function Page() { + const [filter, setFilter] = useState(''); + const [retentionDays, setRetentionDays] = useState(30); + + const sampleLogs = [ + { id: '1', ts: '2025-11-27T14:01:02Z', q: 'pricing for X', found: 3, latency: 42 }, + { id: '2', ts: '2025-11-27T14:05:12Z', q: 'how to reset password', found: 1, latency: 88 }, + { id: '3', ts: '2025-11-27T14:12:33Z', q: 'refund policy', found: 2, latency: 60 }, + ]; + + const handleExport = () => { + toast.success('Export started (demo)'); + }; + + return ( + + + Retrieval Logs + + + + + + + setFilter(e.target.value)} /> + setRetentionDays(Number(e.target.value))} + sx={{ width: 160 }} + /> + + + + + {sampleLogs + .filter((l) => l.q.includes(filter)) + .map((l) => ( + + + + ))} + + + + Tip: Use Azure Monitor or Diagnostic settings to capture and forward retrieval logs to Log + Analytics for deeper analysis. + + + + + + ); +} diff --git a/app/frontend/src/app/dashboard/layout.tsx b/app/frontend/src/app/dashboard/layout.tsx new file mode 100644 index 00000000..cc216535 --- /dev/null +++ b/app/frontend/src/app/dashboard/layout.tsx @@ -0,0 +1,22 @@ +import { CONFIG } from 'src/global-config'; +import { DashboardLayout } from 'src/layouts/dashboard'; + +import { AuthGuard } from 'src/auth/guard'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + if (CONFIG.auth.skip) { + return {children}; + } + + return ( + + {children} + + ); +} diff --git a/app/frontend/src/app/dashboard/loading.tsx b/app/frontend/src/app/dashboard/loading.tsx new file mode 100644 index 00000000..5bfa202d --- /dev/null +++ b/app/frontend/src/app/dashboard/loading.tsx @@ -0,0 +1,7 @@ +import { LoadingScreen } from 'src/components/loading-screen'; + +// ---------------------------------------------------------------------- + +export default function Loading() { + return ; +} diff --git a/app/frontend/src/app/dashboard/order/[id]/page.tsx b/app/frontend/src/app/dashboard/order/[id]/page.tsx new file mode 100644 index 00000000..59ad6254 --- /dev/null +++ b/app/frontend/src/app/dashboard/order/[id]/page.tsx @@ -0,0 +1,43 @@ +import type { Metadata } from 'next'; + +import { _orders } from 'src/_mock/_order'; +import { CONFIG } from 'src/global-config'; + +import { OrderDetailsView } from 'src/sections/order/view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Order details | Dashboard - ${CONFIG.appName}` }; + +type Props = { + params: { id: string }; +}; + +export default function Page({ params }: Props) { + const { id } = params; + + const currentOrder = _orders.find((order) => order.id === id); + + return ; +} + +// ---------------------------------------------------------------------- + +/** + * [1] Default + * Remove [1] and [2] if not using [2] + * Will remove in Next.js v15 + */ +const dynamic = CONFIG.isStaticExport ? 'auto' : 'force-dynamic'; +export { dynamic }; + +/** + * [2] Static exports + * https://nextjs.org/docs/app/building-your-application/deploying/static-exports + */ +export async function generateStaticParams() { + if (CONFIG.isStaticExport) { + return _orders.map((order) => ({ id: order.id })); + } + return []; +} diff --git a/app/frontend/src/app/dashboard/order/page.tsx b/app/frontend/src/app/dashboard/order/page.tsx new file mode 100644 index 00000000..7b29de12 --- /dev/null +++ b/app/frontend/src/app/dashboard/order/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { OrderListView } from 'src/sections/order/view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Order list | Dashboard - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/dashboard/page.tsx b/app/frontend/src/app/dashboard/page.tsx new file mode 100644 index 00000000..da0d527a --- /dev/null +++ b/app/frontend/src/app/dashboard/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { OverviewAppView } from 'src/sections/overview/app/view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Dashboard - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/dashboard/permission/page.tsx b/app/frontend/src/app/dashboard/permission/page.tsx new file mode 100644 index 00000000..2ace4439 --- /dev/null +++ b/app/frontend/src/app/dashboard/permission/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { PermissionDeniedView } from 'src/sections/permission/view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Permission | Dashboard - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/dashboard/post/[title]/edit/page.tsx b/app/frontend/src/app/dashboard/post/[title]/edit/page.tsx new file mode 100644 index 00000000..e914581e --- /dev/null +++ b/app/frontend/src/app/dashboard/post/[title]/edit/page.tsx @@ -0,0 +1,55 @@ +import type { Metadata } from 'next'; + +import { kebabCase } from 'es-toolkit'; + +import { CONFIG } from 'src/global-config'; +import axios, { endpoints } from 'src/lib/axios'; + +import { PostEditView } from 'src/sections/blog/view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Post edit | Dashboard - ${CONFIG.appName}` }; + +type Props = { + params: { title: string }; +}; + +export default async function Page({ params }: Props) { + const { title } = params; + + const { post } = await getPost(title); + + return ; +} + +// ---------------------------------------------------------------------- + +async function getPost(title: string) { + const URL = title ? `${endpoints.post.details}?title=${title}` : ''; + + const res = await axios.get(URL); + + return res.data; +} + +/** + * [1] Default + * Remove [1] and [2] if not using [2] + * Will remove in Next.js v15 + */ +const dynamic = CONFIG.isStaticExport ? 'auto' : 'force-dynamic'; +export { dynamic }; + +/** + * [2] Static exports + * https://nextjs.org/docs/app/building-your-application/deploying/static-exports + */ +export async function generateStaticParams() { + if (CONFIG.isStaticExport) { + const res = await axios.get(endpoints.post.list); + + return res.data.posts.map((post: { title: string }) => ({ title: kebabCase(post.title) })); + } + return []; +} diff --git a/app/frontend/src/app/dashboard/post/[title]/error.tsx b/app/frontend/src/app/dashboard/post/[title]/error.tsx new file mode 100644 index 00000000..42c30a11 --- /dev/null +++ b/app/frontend/src/app/dashboard/post/[title]/error.tsx @@ -0,0 +1,41 @@ +'use client'; + +import Button from '@mui/material/Button'; + +import { paths } from 'src/routes/paths'; +import { RouterLink } from 'src/routes/components'; + +import { DashboardContent } from 'src/layouts/dashboard'; + +import { Iconify } from 'src/components/iconify'; +import { EmptyContent } from 'src/components/empty-content'; + +// ---------------------------------------------------------------------- + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + + } + sx={{ mt: 3 }} + > + Back to list + + } + sx={{ py: 10, height: 'auto', flexGrow: 'unset' }} + /> + + ); +} diff --git a/app/frontend/src/app/dashboard/post/[title]/loading.tsx b/app/frontend/src/app/dashboard/post/[title]/loading.tsx new file mode 100644 index 00000000..9377c85a --- /dev/null +++ b/app/frontend/src/app/dashboard/post/[title]/loading.tsx @@ -0,0 +1,13 @@ +import { DashboardContent } from 'src/layouts/dashboard'; + +import { PostDetailsSkeleton } from 'src/sections/blog/post-skeleton'; + +// ---------------------------------------------------------------------- + +export default function Loading() { + return ( + + + + ); +} diff --git a/app/frontend/src/app/dashboard/post/[title]/page.tsx b/app/frontend/src/app/dashboard/post/[title]/page.tsx new file mode 100644 index 00000000..f65b5999 --- /dev/null +++ b/app/frontend/src/app/dashboard/post/[title]/page.tsx @@ -0,0 +1,55 @@ +import type { Metadata } from 'next'; + +import { kebabCase } from 'es-toolkit'; + +import { CONFIG } from 'src/global-config'; +import axios, { endpoints } from 'src/lib/axios'; + +import { PostDetailsView } from 'src/sections/blog/view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Post details | Dashboard - ${CONFIG.appName}` }; + +type Props = { + params: { title: string }; +}; + +export default async function Page({ params }: Props) { + const { title } = params; + + const { post } = await getPost(title); + + return ; +} + +// ---------------------------------------------------------------------- + +async function getPost(title: string) { + const URL = title ? `${endpoints.post.details}?title=${title}` : ''; + + const res = await axios.get(URL); + + return res.data; +} + +/** + * [1] Default + * Remove [1] and [2] if not using [2] + * Will remove in Next.js v15 + */ +const dynamic = CONFIG.isStaticExport ? 'auto' : 'force-dynamic'; +export { dynamic }; + +/** + * [2] Static exports + * https://nextjs.org/docs/app/building-your-application/deploying/static-exports + */ +export async function generateStaticParams() { + if (CONFIG.isStaticExport) { + const res = await axios.get(endpoints.post.list); + + return res.data.posts.map((post: { title: string }) => ({ title: kebabCase(post.title) })); + } + return []; +} diff --git a/app/frontend/src/app/dashboard/post/new/page.tsx b/app/frontend/src/app/dashboard/post/new/page.tsx new file mode 100644 index 00000000..24f9f68d --- /dev/null +++ b/app/frontend/src/app/dashboard/post/new/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { PostCreateView } from 'src/sections/blog/view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Create a new post | Dashboard - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/dashboard/post/page.tsx b/app/frontend/src/app/dashboard/post/page.tsx new file mode 100644 index 00000000..a4335af7 --- /dev/null +++ b/app/frontend/src/app/dashboard/post/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { PostListView } from 'src/sections/blog/view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Post list | Dashboard - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/dashboard/retrievers/page.tsx b/app/frontend/src/app/dashboard/retrievers/page.tsx new file mode 100644 index 00000000..574af79d --- /dev/null +++ b/app/frontend/src/app/dashboard/retrievers/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { OverviewRetrieverView } from 'src/sections/overview/course/view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Retrievers | Dashboard - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/dashboard/retrievers/settings/api-access/page.tsx b/app/frontend/src/app/dashboard/retrievers/settings/api-access/page.tsx new file mode 100644 index 00000000..88e79554 --- /dev/null +++ b/app/frontend/src/app/dashboard/retrievers/settings/api-access/page.tsx @@ -0,0 +1,86 @@ +'use client'; + +// import type { Metadata } from 'next'; + +import { useState } from 'react'; + +import Box from '@mui/material/Box'; +import Grid from '@mui/material/Grid2'; +import Paper from '@mui/material/Paper'; +import Button from '@mui/material/Button'; +import Divider from '@mui/material/Divider'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; + +import { DashboardContent } from 'src/layouts/dashboard'; + +import { toast } from 'src/components/snackbar'; + +// ---------------------------------------------------------------------- +; + +export default function Page() { + const [tenantId, setTenantId] = useState(''); + const [clientId, setClientId] = useState(''); + const [clientSecretHint] = useState('•••••••• (rotate via Azure portal)'); + + const handleRotate = () => { + toast.info('Rotate client secret (demo)'); + }; + + return ( + + + API & Access + + + + + + Azure AD / App Registration + + Configure the Azure AD application used for server-to-server authentication. You can use + managed identities or an App Registration. For production, prefer managed identities + where possible. + + + + setTenantId(e.target.value)} + /> + + setClientId(e.target.value)} /> + + + + + + + + + + Ingress / Network + + Restrict which IPs or VNets can call your retriever API. When running in Azure, combine + this with Private Endpoints and Service Endpoints for tighter network control. + + + + + + + + + + + ); +} diff --git a/app/frontend/src/app/dashboard/retrievers/settings/configuration/page.tsx b/app/frontend/src/app/dashboard/retrievers/settings/configuration/page.tsx new file mode 100644 index 00000000..7d6cb82f --- /dev/null +++ b/app/frontend/src/app/dashboard/retrievers/settings/configuration/page.tsx @@ -0,0 +1,261 @@ +"use client"; + +import { useState } from "react"; +import { usePopover } from "minimal-shared/hooks"; + +import Box from "@mui/material/Box"; +import Link from "@mui/material/Link"; +import Grid from "@mui/material/Grid2"; +import Paper from "@mui/material/Paper"; +import Switch from "@mui/material/Switch"; +import Button from "@mui/material/Button"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import IconButton from "@mui/material/IconButton"; +import FormControlLabel from "@mui/material/FormControlLabel"; + +import { DashboardContent } from "src/layouts/dashboard"; + +import { toast } from "src/components/snackbar"; +import { Iconify } from "src/components/iconify"; +import { CustomPopover } from "src/components/custom-popover/custom-popover"; + +// -------------------------------------------------------- + +function FeatureRow({ + opt, + hasToggle, + checked, + onToggle, +}: { + opt: { key: string; label: string; desc: string }; + hasToggle?: boolean; + checked?: boolean; + onToggle?: (v: boolean) => void; +}) { + const pop = usePopover(); + return ( + + {opt.label} + + + + + + + {hasToggle && ( + onToggle && onToggle(e.target.checked)} + inputProps={{ 'aria-label': `${opt.key}-toggle` }} + /> + )} + + + + + + {opt.label} + + + {opt.desc} + + + + + ); +} + +// -------------------------------------------------------- + +export default function Page() { + const [queryRewrite, setQueryRewrite] = useState(true); + const [retrievalMode, setRetrievalMode] = useState("hybrid"); + const [indexer, setIndexer] = useState("default-indexer"); + const [textEmbeddingsEnabled, setTextEmbeddingsEnabled] = useState(true); + const [embeddingsEnabled, setEmbeddingsEnabled] = useState(true); + const [allowOidsEnabled, setAllowOidsEnabled] = useState(false); + const [allowGOidsEnabled, setAllowGOidsEnabled] = useState(false); + + const handleSave = () => { + toast.success("Retriever configuration saved (demo)"); + }; + + return ( + + + Retriever Configuration + + + + + + {/* Query Rewriting */} + + Query Rewriting + + + + Toggle model-assisted query rewriting to normalize or expand user + queries before performing retrieval. + + + setQueryRewrite(e.target.checked)} + /> + } + label={queryRewrite ? "Enabled" : "Disabled"} + /> + + {/* Feature Rows */} + + + Embedding Features + + + + {[ + { + key: "text-embeddings", + label: "Text embeddings", + desc: "Generate vector embeddings from text using the chosen model.", + }, + { + key: "embeddings", + label: "Embeddings (general)", + desc: "Use precomputed embeddings or on-the-fly embeddings.", + }, + { + key: "image-embeddings", + label: "Image embeddings", + desc: "Create embeddings from images for multimodal retrieval.", + }, + ].map((opt) => { + if (opt.key === "text-embeddings") { + return ( + setTextEmbeddingsEnabled(v)} + /> + ); + } + + if (opt.key === "embeddings") { + return ( + setEmbeddingsEnabled(v)} + /> + ); + } + + return ; + })} + + + {/* Retrival Mode */} + setRetrievalMode(e.target.value)} + sx={{ mb: 2 }} + > + + + + + + {/* Indexer */} + setIndexer(e.target.value)} + sx={{ mb: 2 }} + disabled + /> + + {/* Allowed OIDs (toggle + conditional input) */} + setAllowOidsEnabled(v)} + /> + setAllowGOidsEnabled(v)} + /> + + + + + Data ingestion docs + + + + + + + + + + + + + + + + Help & Docs + + + + Quick links and notes for configuring retrievers. Use OID or Group filters to restrict retrieval to specific Azure Entra identities. + + + Data ingestion docs +
+ Monitoring guide +
+
+
+
+ ); +} diff --git a/app/frontend/src/app/dashboard/retrievers/settings/indexing-status/page.tsx b/app/frontend/src/app/dashboard/retrievers/settings/indexing-status/page.tsx new file mode 100644 index 00000000..03212e75 --- /dev/null +++ b/app/frontend/src/app/dashboard/retrievers/settings/indexing-status/page.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useState } from 'react'; + +import Box from '@mui/material/Box'; +import Grid from '@mui/material/Grid2'; +import Paper from '@mui/material/Paper'; +import Button from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; + +import { DashboardContent } from 'src/layouts/dashboard'; + +import { toast } from 'src/components/snackbar'; + +// ---------------------------------------------------------------------- + +export default function Page() { + const [status] = useState<'Idle' | 'Running' | 'Failed'>('Idle'); + const [lastRun] = useState('2025-11-27 14:02:11 UTC'); + const [nextRun] = useState('2025-11-28 02:00:00 UTC'); + const [errors] = useState(0); + + const handleRunNow = () => { + toast.info('Indexer run triggered (demo)'); + }; + + return ( + + + Indexing Status + + + + + + Indexer + + Overview of the document ingestion & indexer that powers retrieval. This reflects the + indexer referenced in docs/data_ingestion.md and the ingestion architecture. + + + + Current status: {status} + Last run: {lastRun} + Next scheduled run: {nextRun} + Recent errors: {errors} + + + + + + + + + + + ); +} diff --git a/app/frontend/src/app/dashboard/retrievers/settings/layout.tsx b/app/frontend/src/app/dashboard/retrievers/settings/layout.tsx new file mode 100644 index 00000000..ebfabb35 --- /dev/null +++ b/app/frontend/src/app/dashboard/retrievers/settings/layout.tsx @@ -0,0 +1,11 @@ +import { AccountLayout } from 'src/sections/account/account-layout'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return {children}; +} diff --git a/app/frontend/src/app/dashboard/retrievers/settings/page.tsx b/app/frontend/src/app/dashboard/retrievers/settings/page.tsx new file mode 100644 index 00000000..7cd5712b --- /dev/null +++ b/app/frontend/src/app/dashboard/retrievers/settings/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { AccountGeneralView } from 'src/sections/account/view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { + title: `Retrievers settings | Dashboard - ${CONFIG.appName}`, +}; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/dashboard/retrievers/settings/retrieval-logs/page.tsx b/app/frontend/src/app/dashboard/retrievers/settings/retrieval-logs/page.tsx new file mode 100644 index 00000000..3f9292c5 --- /dev/null +++ b/app/frontend/src/app/dashboard/retrievers/settings/retrieval-logs/page.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useState } from 'react'; + +import Box from '@mui/material/Box'; +import List from '@mui/material/List'; +import Grid from '@mui/material/Grid2'; +import Paper from '@mui/material/Paper'; +import Button from '@mui/material/Button'; +import ListItem from '@mui/material/ListItem'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import ListItemText from '@mui/material/ListItemText'; + +import { DashboardContent } from 'src/layouts/dashboard'; + +import { toast } from 'src/components/snackbar'; + +// ---------------------------------------------------------------------- + +export default function Page() { + const [filter, setFilter] = useState(''); + const [retentionDays, setRetentionDays] = useState(30); + + const sampleLogs = [ + { id: '1', ts: '2025-11-27T14:01:02Z', q: 'pricing for X', found: 3, latency: 42 }, + { id: '2', ts: '2025-11-27T14:05:12Z', q: 'how to reset password', found: 1, latency: 88 }, + { id: '3', ts: '2025-11-27T14:12:33Z', q: 'refund policy', found: 2, latency: 60 }, + ]; + + const handleExport = () => { + toast.success('Export started (demo)'); + }; + + return ( + + + Retrieval Logs + + + + + + + setFilter(e.target.value)} /> + setRetentionDays(Number(e.target.value))} + sx={{ width: 160 }} + /> + + + + + {sampleLogs + .filter((l) => l.q.includes(filter)) + .map((l) => ( + + + + ))} + + + + Tip: Use Azure Monitor or Diagnostic settings to capture and forward retrieval logs to Log + Analytics for deeper analysis. + + + + + + ); +} diff --git a/app/frontend/src/app/dashboard/segments/cards/page.tsx b/app/frontend/src/app/dashboard/segments/cards/page.tsx new file mode 100644 index 00000000..878393bd --- /dev/null +++ b/app/frontend/src/app/dashboard/segments/cards/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { SegmentorCardsView } from 'src/sections/segment/view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Segmentors | Dashboard - ${CONFIG.appName}` }; +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/dashboard/segments/list/page.tsx b/app/frontend/src/app/dashboard/segments/list/page.tsx new file mode 100644 index 00000000..0578b44c --- /dev/null +++ b/app/frontend/src/app/dashboard/segments/list/page.tsx @@ -0,0 +1,14 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { SegmentListView } from 'src/sections/segment/view/segment-list-view'; + + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Segmentors list | Dashboard - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/dashboard/segments/page.tsx b/app/frontend/src/app/dashboard/segments/page.tsx new file mode 100644 index 00000000..64095ca2 --- /dev/null +++ b/app/frontend/src/app/dashboard/segments/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { OverviewBookingView } from 'src/sections/overview/booking/view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Booking | Dashboard - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/dashboard/tour/[id]/edit/page.tsx b/app/frontend/src/app/dashboard/tour/[id]/edit/page.tsx new file mode 100644 index 00000000..4cec26fa --- /dev/null +++ b/app/frontend/src/app/dashboard/tour/[id]/edit/page.tsx @@ -0,0 +1,43 @@ +import type { Metadata } from 'next'; + +import { _tours } from 'src/_mock/_tour'; +import { CONFIG } from 'src/global-config'; + +import { TourEditView } from 'src/sections/tour/view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Tour edit | Dashboard - ${CONFIG.appName}` }; + +type Props = { + params: { id: string }; +}; + +export default function Page({ params }: Props) { + const { id } = params; + + const currentTour = _tours.find((tour) => tour.id === id); + + return ; +} + +// ---------------------------------------------------------------------- + +/** + * [1] Default + * Remove [1] and [2] if not using [2] + * Will remove in Next.js v15 + */ +const dynamic = CONFIG.isStaticExport ? 'auto' : 'force-dynamic'; +export { dynamic }; + +/** + * [2] Static exports + * https://nextjs.org/docs/app/building-your-application/deploying/static-exports + */ +export async function generateStaticParams() { + if (CONFIG.isStaticExport) { + return _tours.map((tour) => ({ id: tour.id })); + } + return []; +} diff --git a/app/frontend/src/app/dashboard/tour/[id]/page.tsx b/app/frontend/src/app/dashboard/tour/[id]/page.tsx new file mode 100644 index 00000000..b1afb391 --- /dev/null +++ b/app/frontend/src/app/dashboard/tour/[id]/page.tsx @@ -0,0 +1,42 @@ +import type { Metadata } from 'next'; + +import { _tours } from 'src/_mock/_tour'; +import { CONFIG } from 'src/global-config'; + +import { TourDetailsView } from 'src/sections/tour/view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Tour details | Dashboard - ${CONFIG.appName}` }; + +type Props = { + params: { id: string }; +}; + +export default function Page({ params }: Props) { + const { id } = params; + + const currentTour = _tours.find((tour) => tour.id === id); + + return ; +} + +// ---------------------------------------------------------------------- +/** + * [1] Default + * Remove [1] and [2] if not using [2] + * Will remove in Next.js v15 + */ +const dynamic = CONFIG.isStaticExport ? 'auto' : 'force-dynamic'; +export { dynamic }; + +/** + * [2] Static exports + * https://nextjs.org/docs/app/building-your-application/deploying/static-exports + */ +export async function generateStaticParams() { + if (CONFIG.isStaticExport) { + return _tours.map((tour) => ({ id: tour.id })); + } + return []; +} diff --git a/app/frontend/src/app/dashboard/tour/new/page.tsx b/app/frontend/src/app/dashboard/tour/new/page.tsx new file mode 100644 index 00000000..021de943 --- /dev/null +++ b/app/frontend/src/app/dashboard/tour/new/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { TourCreateView } from 'src/sections/tour/view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Create a new tour | Dashboard - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/dashboard/tour/page.tsx b/app/frontend/src/app/dashboard/tour/page.tsx new file mode 100644 index 00000000..9c777b33 --- /dev/null +++ b/app/frontend/src/app/dashboard/tour/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { TourListView } from 'src/sections/tour/view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Tour list | Dashboard - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/error/403/page.tsx b/app/frontend/src/app/error/403/page.tsx new file mode 100644 index 00000000..61568708 --- /dev/null +++ b/app/frontend/src/app/error/403/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { View403 } from 'src/sections/error'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `403 forbidden! | Error - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/error/404/page.tsx b/app/frontend/src/app/error/404/page.tsx new file mode 100644 index 00000000..4afce76e --- /dev/null +++ b/app/frontend/src/app/error/404/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { NotFoundView } from 'src/sections/error'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `404 page not found! | Error - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/error/500/page.tsx b/app/frontend/src/app/error/500/page.tsx new file mode 100644 index 00000000..01c60d46 --- /dev/null +++ b/app/frontend/src/app/error/500/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { View500 } from 'src/sections/error'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { + title: `500 Internal server error! | Error - ${CONFIG.appName}`, +}; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/faqs/layout.tsx b/app/frontend/src/app/faqs/layout.tsx new file mode 100644 index 00000000..16053769 --- /dev/null +++ b/app/frontend/src/app/faqs/layout.tsx @@ -0,0 +1,11 @@ +import { MainLayout } from 'src/layouts/main'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return {children}; +} diff --git a/app/frontend/src/app/faqs/page.tsx b/app/frontend/src/app/faqs/page.tsx new file mode 100644 index 00000000..e9e32cf9 --- /dev/null +++ b/app/frontend/src/app/faqs/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { FaqsView } from 'src/sections/faqs/view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Faqs - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/layout.tsx b/app/frontend/src/app/layout.tsx new file mode 100644 index 00000000..a6db04af --- /dev/null +++ b/app/frontend/src/app/layout.tsx @@ -0,0 +1,118 @@ +import 'src/global.css'; + +import type { Metadata, Viewport } from 'next'; + +import InitColorSchemeScript from '@mui/material/InitColorSchemeScript'; +import { AppRouterCacheProvider } from '@mui/material-nextjs/v14-appRouter'; + +import { CONFIG } from 'src/global-config'; +import { primary } from 'src/theme/core/palette'; +import { LocalizationProvider } from 'src/locales'; +import { detectLanguage } from 'src/locales/server'; +import { themeConfig, ThemeProvider } from 'src/theme'; +import { I18nProvider } from 'src/locales/i18n-provider'; + +import { Snackbar } from 'src/components/snackbar'; +import { ProgressBar } from 'src/components/progress-bar'; +import { MotionLazy } from 'src/components/animate/motion-lazy'; +import { detectSettings } from 'src/components/settings/server'; +import { SettingsDrawer, defaultSettings, SettingsProvider } from 'src/components/settings'; + +import { AuthProvider as JwtAuthProvider } from 'src/auth/context/jwt'; +import { AuthProvider as Auth0AuthProvider } from 'src/auth/context/auth0'; +import { AuthProvider as AmplifyAuthProvider } from 'src/auth/context/amplify'; +import { AuthProvider as SupabaseAuthProvider } from 'src/auth/context/supabase'; +import { AuthProvider as FirebaseAuthProvider } from 'src/auth/context/firebase'; + +// ---------------------------------------------------------------------- + +const AuthProvider = + (CONFIG.auth.method === 'amplify' && AmplifyAuthProvider) || + (CONFIG.auth.method === 'firebase' && FirebaseAuthProvider) || + (CONFIG.auth.method === 'supabase' && SupabaseAuthProvider) || + (CONFIG.auth.method === 'auth0' && Auth0AuthProvider) || + JwtAuthProvider; + +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + themeColor: primary.main, +}; + +export const metadata: Metadata = { + icons: [ + { + rel: 'icon', + url: `${CONFIG.assetsDir}/favicon.ico`, + }, + ], +}; + +// ---------------------------------------------------------------------- + +type RootLayoutProps = { + children: React.ReactNode; +}; + +async function getAppConfig() { + if (CONFIG.isStaticExport) { + return { + lang: 'en', + i18nLang: undefined, + cookieSettings: undefined, + dir: defaultSettings.direction, + }; + } else { + const [lang, settings] = await Promise.all([detectLanguage(), detectSettings()]); + + return { + lang: lang ?? 'en', + i18nLang: lang ?? 'en', + cookieSettings: settings, + dir: settings.direction, + }; + } +} + +export default async function RootLayout({ children }: RootLayoutProps) { + const appConfig = await getAppConfig(); + + return ( + + + + + + + + + + + + <> + + + + {children} + + + + + + + + + + + ); +} diff --git a/app/frontend/src/app/loading.tsx b/app/frontend/src/app/loading.tsx new file mode 100644 index 00000000..7d29a81a --- /dev/null +++ b/app/frontend/src/app/loading.tsx @@ -0,0 +1,7 @@ +import { SplashScreen } from 'src/components/loading-screen'; + +// ---------------------------------------------------------------------- + +export default function Loading() { + return ; +} diff --git a/app/frontend/src/app/maintenance/layout.tsx b/app/frontend/src/app/maintenance/layout.tsx new file mode 100644 index 00000000..03d85386 --- /dev/null +++ b/app/frontend/src/app/maintenance/layout.tsx @@ -0,0 +1,19 @@ +import { SimpleLayout } from 'src/layouts/simple'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return ( + + {children} + + ); +} diff --git a/app/frontend/src/app/maintenance/page.tsx b/app/frontend/src/app/maintenance/page.tsx new file mode 100644 index 00000000..1188f13c --- /dev/null +++ b/app/frontend/src/app/maintenance/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { MaintenanceView } from 'src/sections/maintenance/view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Maintenance - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/not-found.tsx b/app/frontend/src/app/not-found.tsx new file mode 100644 index 00000000..4afce76e --- /dev/null +++ b/app/frontend/src/app/not-found.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; + +import { NotFoundView } from 'src/sections/error'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `404 page not found! | Error - ${CONFIG.appName}` }; + +export default function Page() { + return ; +} diff --git a/app/frontend/src/app/post/[title]/error.tsx b/app/frontend/src/app/post/[title]/error.tsx new file mode 100644 index 00000000..23109e38 --- /dev/null +++ b/app/frontend/src/app/post/[title]/error.tsx @@ -0,0 +1,40 @@ +'use client'; + +import Button from '@mui/material/Button'; +import Container from '@mui/material/Container'; + +import { paths } from 'src/routes/paths'; +import { RouterLink } from 'src/routes/components'; + +import { Iconify } from 'src/components/iconify'; +import { EmptyContent } from 'src/components/empty-content'; + +// ---------------------------------------------------------------------- + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + + } + sx={{ mt: 3 }} + > + Back to list + + } + sx={{ py: 10 }} + /> + + ); +} diff --git a/app/frontend/src/app/post/[title]/loading.tsx b/app/frontend/src/app/post/[title]/loading.tsx new file mode 100644 index 00000000..b4920c21 --- /dev/null +++ b/app/frontend/src/app/post/[title]/loading.tsx @@ -0,0 +1,7 @@ +import { PostDetailsSkeleton } from 'src/sections/blog/post-skeleton'; + +// ---------------------------------------------------------------------- + +export default function Loading() { + return ; +} diff --git a/app/frontend/src/app/post/[title]/page.tsx b/app/frontend/src/app/post/[title]/page.tsx new file mode 100644 index 00000000..d5ce789f --- /dev/null +++ b/app/frontend/src/app/post/[title]/page.tsx @@ -0,0 +1,49 @@ +import type { Metadata } from 'next'; + +import { kebabCase } from 'es-toolkit'; + +import { CONFIG } from 'src/global-config'; +import axios, { endpoints } from 'src/lib/axios'; +import { getPost, getLatestPosts } from 'src/actions/blog-ssr'; + +import { PostDetailsHomeView } from 'src/sections/blog/view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Post details - ${CONFIG.appName}` }; + +type Props = { + params: { title: string }; +}; + +export default async function Page({ params }: Props) { + const { title } = params; + + const { post } = await getPost(title); + + const { latestPosts } = await getLatestPosts(title); + + return ; +} + +// ---------------------------------------------------------------------- + +/** + * [1] Default + * Remove [1] and [2] if not using [2] + * Will remove in Next.js v15 + */ +const dynamic = CONFIG.isStaticExport ? 'auto' : 'force-dynamic'; +export { dynamic }; + +/** + * [2] Static exports + * https://nextjs.org/docs/app/building-your-application/deploying/static-exports + */ +export async function generateStaticParams() { + if (CONFIG.isStaticExport) { + const res = await axios.get(endpoints.post.list); + return res.data.posts.map((post: { title: string }) => ({ title: kebabCase(post.title) })); + } + return []; +} diff --git a/app/frontend/src/app/post/layout.tsx b/app/frontend/src/app/post/layout.tsx new file mode 100644 index 00000000..16053769 --- /dev/null +++ b/app/frontend/src/app/post/layout.tsx @@ -0,0 +1,11 @@ +import { MainLayout } from 'src/layouts/main'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return {children}; +} diff --git a/app/frontend/src/app/post/page.tsx b/app/frontend/src/app/post/page.tsx new file mode 100644 index 00000000..92b9cc34 --- /dev/null +++ b/app/frontend/src/app/post/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from 'next'; + +import { CONFIG } from 'src/global-config'; +import { getPosts } from 'src/actions/blog-ssr'; + +import { PostListHomeView } from 'src/sections/blog/view'; + +// ---------------------------------------------------------------------- + +export const metadata: Metadata = { title: `Post list - ${CONFIG.appName}` }; + +export default async function Page() { + const { posts } = await getPosts(); + + return ; +} diff --git a/app/frontend/src/assets/data/countries.ts b/app/frontend/src/assets/data/countries.ts new file mode 100644 index 00000000..12d3a620 --- /dev/null +++ b/app/frontend/src/assets/data/countries.ts @@ -0,0 +1,250 @@ +export const countries = [ + { code: 'AD', label: 'Andorra', phone: '376' }, + { code: 'AE', label: 'United Arab Emirates', phone: '971' }, + { code: 'AF', label: 'Afghanistan', phone: '93' }, + { code: 'AG', label: 'Antigua and Barbuda', phone: '1-268' }, + { code: 'AI', label: 'Anguilla', phone: '1-264' }, + { code: 'AL', label: 'Albania', phone: '355' }, + { code: 'AM', label: 'Armenia', phone: '374' }, + { code: 'AO', label: 'Angola', phone: '244' }, + { code: 'AQ', label: 'Antarctica', phone: '672' }, + { code: 'AR', label: 'Argentina', phone: '54' }, + { code: 'AS', label: 'American Samoa', phone: '1-684' }, + { code: 'AT', label: 'Austria', phone: '43' }, + { code: 'AU', label: 'Australia', phone: '61' }, + { code: 'AW', label: 'Aruba', phone: '297' }, + { code: 'AX', label: 'Alland Islands', phone: '358' }, + { code: 'AZ', label: 'Azerbaijan', phone: '994' }, + { code: 'BA', label: 'Bosnia and Herzegovina', phone: '387' }, + { code: 'BB', label: 'Barbados', phone: '1-246' }, + { code: 'BD', label: 'Bangladesh', phone: '880' }, + { code: 'BE', label: 'Belgium', phone: '32' }, + { code: 'BF', label: 'Burkina Faso', phone: '226' }, + { code: 'BG', label: 'Bulgaria', phone: '359' }, + { code: 'BH', label: 'Bahrain', phone: '973' }, + { code: 'BI', label: 'Burundi', phone: '257' }, + { code: 'BJ', label: 'Benin', phone: '229' }, + { code: 'BL', label: 'Saint Barthelemy', phone: '590' }, + { code: 'BM', label: 'Bermuda', phone: '1-441' }, + { code: 'BN', label: 'Brunei Darussalam', phone: '673' }, + { code: 'BO', label: 'Bolivia', phone: '591' }, + { code: 'BR', label: 'Brazil', phone: '55' }, + { code: 'BS', label: 'Bahamas', phone: '1-242' }, + { code: 'BT', label: 'Bhutan', phone: '975' }, + { code: 'BV', label: 'Bouvet Island', phone: '47' }, + { code: 'BW', label: 'Botswana', phone: '267' }, + { code: 'BY', label: 'Belarus', phone: '375' }, + { code: 'BZ', label: 'Belize', phone: '501' }, + { code: 'CA', label: 'Canada', phone: '1' }, + { code: 'CC', label: 'Cocos (Keeling) Islands', phone: '61' }, + { code: 'CD', label: 'Congo, Democratic Republic of the', phone: '243' }, + { code: 'CF', label: 'Central African Republic', phone: '236' }, + { code: 'CG', label: 'Congo, Republic of the', phone: '242' }, + { code: 'CH', label: 'Switzerland', phone: '41' }, + { code: 'CI', label: "Cote d'Ivoire", phone: '225' }, + { code: 'CK', label: 'Cook Islands', phone: '682' }, + { code: 'CL', label: 'Chile', phone: '56' }, + { code: 'CM', label: 'Cameroon', phone: '237' }, + { code: 'CN', label: 'China', phone: '86' }, + { code: 'CO', label: 'Colombia', phone: '57' }, + { code: 'CR', label: 'Costa Rica', phone: '506' }, + { code: 'CU', label: 'Cuba', phone: '53' }, + { code: 'CV', label: 'Cape Verde', phone: '238' }, + { code: 'CW', label: 'Curacao', phone: '599' }, + { code: 'CX', label: 'Christmas Island', phone: '61' }, + { code: 'CY', label: 'Cyprus', phone: '357' }, + { code: 'CZ', label: 'Czech Republic', phone: '420' }, + { code: 'DE', label: 'Germany', phone: '49' }, + { code: 'DJ', label: 'Djibouti', phone: '253' }, + { code: 'DK', label: 'Denmark', phone: '45' }, + { code: 'DM', label: 'Dominica', phone: '1-767' }, + { code: 'DO', label: 'Dominican Republic', phone: '1-809' }, + { code: 'DZ', label: 'Algeria', phone: '213' }, + { code: 'EC', label: 'Ecuador', phone: '593' }, + { code: 'EE', label: 'Estonia', phone: '372' }, + { code: 'EG', label: 'Egypt', phone: '20' }, + { code: 'EH', label: 'Western Sahara', phone: '212' }, + { code: 'ER', label: 'Eritrea', phone: '291' }, + { code: 'ES', label: 'Spain', phone: '34' }, + { code: 'ET', label: 'Ethiopia', phone: '251' }, + { code: 'FI', label: 'Finland', phone: '358' }, + { code: 'FJ', label: 'Fiji', phone: '679' }, + { code: 'FK', label: 'Falkland Islands (Malvinas)', phone: '500' }, + { code: 'FM', label: 'Micronesia, Federated States of', phone: '691' }, + { code: 'FO', label: 'Faroe Islands', phone: '298' }, + { code: 'FR', label: 'France', phone: '33' }, + { code: 'GA', label: 'Gabon', phone: '241' }, + { code: 'GB', label: 'United Kingdom', phone: '44' }, + { code: 'GD', label: 'Grenada', phone: '1-473' }, + { code: 'GE', label: 'Georgia', phone: '995' }, + { code: 'GF', label: 'French Guiana', phone: '594' }, + { code: 'GG', label: 'Guernsey', phone: '44' }, + { code: 'GH', label: 'Ghana', phone: '233' }, + { code: 'GI', label: 'Gibraltar', phone: '350' }, + { code: 'GL', label: 'Greenland', phone: '299' }, + { code: 'GM', label: 'Gambia', phone: '220' }, + { code: 'GN', label: 'Guinea', phone: '224' }, + { code: 'GP', label: 'Guadeloupe', phone: '590' }, + { code: 'GQ', label: 'Equatorial Guinea', phone: '240' }, + { code: 'GR', label: 'Greece', phone: '30' }, + { code: 'GS', label: 'South Georgia and the South Sandwich Islands', phone: '500' }, + { code: 'GT', label: 'Guatemala', phone: '502' }, + { code: 'GU', label: 'Guam', phone: '1-671' }, + { code: 'GW', label: 'Guinea-Bissau', phone: '245' }, + { code: 'GY', label: 'Guyana', phone: '592' }, + { code: 'HK', label: 'Hong Kong', phone: '852' }, + { code: 'HM', label: 'Heard Island and McDonald Islands', phone: '672' }, + { code: 'HN', label: 'Honduras', phone: '504' }, + { code: 'HR', label: 'Croatia', phone: '385' }, + { code: 'HT', label: 'Haiti', phone: '509' }, + { code: 'HU', label: 'Hungary', phone: '36' }, + { code: 'ID', label: 'Indonesia', phone: '62' }, + { code: 'IE', label: 'Ireland', phone: '353' }, + { code: 'IL', label: 'Israel', phone: '972' }, + { code: 'IM', label: 'Isle of Man', phone: '44' }, + { code: 'IN', label: 'India', phone: '91' }, + { code: 'IO', label: 'British Indian Ocean Territory', phone: '246' }, + { code: 'IQ', label: 'Iraq', phone: '964' }, + { code: 'IR', label: 'Iran, Islamic Republic of', phone: '98' }, + { code: 'IS', label: 'Iceland', phone: '354' }, + { code: 'IT', label: 'Italy', phone: '39' }, + { code: 'JE', label: 'Jersey', phone: '44' }, + { code: 'JM', label: 'Jamaica', phone: '1-876' }, + { code: 'JO', label: 'Jordan', phone: '962' }, + { code: 'JP', label: 'Japan', phone: '81' }, + { code: 'KE', label: 'Kenya', phone: '254' }, + { code: 'KG', label: 'Kyrgyzstan', phone: '996' }, + { code: 'KH', label: 'Cambodia', phone: '855' }, + { code: 'KI', label: 'Kiribati', phone: '686' }, + { code: 'KM', label: 'Comoros', phone: '269' }, + { code: 'KN', label: 'Saint Kitts and Nevis', phone: '1-869' }, + { code: 'KP', label: "Korea, Democratic People's Republic of", phone: '850' }, + { code: 'KR', label: 'Korea, Republic of', phone: '82' }, + { code: 'KW', label: 'Kuwait', phone: '965' }, + { code: 'KY', label: 'Cayman Islands', phone: '1-345' }, + { code: 'KZ', label: 'Kazakhstan', phone: '7' }, + { code: 'LA', label: "Lao People's Democratic Republic", phone: '856' }, + { code: 'LB', label: 'Lebanon', phone: '961' }, + { code: 'LC', label: 'Saint Lucia', phone: '1-758' }, + { code: 'LI', label: 'Liechtenstein', phone: '423' }, + { code: 'LK', label: 'Sri Lanka', phone: '94' }, + { code: 'LR', label: 'Liberia', phone: '231' }, + { code: 'LS', label: 'Lesotho', phone: '266' }, + { code: 'LT', label: 'Lithuania', phone: '370' }, + { code: 'LU', label: 'Luxembourg', phone: '352' }, + { code: 'LV', label: 'Latvia', phone: '371' }, + { code: 'LY', label: 'Libya', phone: '218' }, + { code: 'MA', label: 'Morocco', phone: '212' }, + { code: 'MC', label: 'Monaco', phone: '377' }, + { code: 'MD', label: 'Moldova, Republic of', phone: '373' }, + { code: 'ME', label: 'Montenegro', phone: '382' }, + { code: 'MF', label: 'Saint Martin (French part)', phone: '590' }, + { code: 'MG', label: 'Madagascar', phone: '261' }, + { code: 'MH', label: 'Marshall Islands', phone: '692' }, + { code: 'MK', label: 'Macedonia, the Former Yugoslav Republic of', phone: '389' }, + { code: 'ML', label: 'Mali', phone: '223' }, + { code: 'MM', label: 'Myanmar', phone: '95' }, + { code: 'MN', label: 'Mongolia', phone: '976' }, + { code: 'MO', label: 'Macao', phone: '853' }, + { code: 'MP', label: 'Northern Mariana Islands', phone: '1-670' }, + { code: 'MQ', label: 'Martinique', phone: '596' }, + { code: 'MR', label: 'Mauritania', phone: '222' }, + { code: 'MS', label: 'Montserrat', phone: '1-664' }, + { code: 'MT', label: 'Malta', phone: '356' }, + { code: 'MU', label: 'Mauritius', phone: '230' }, + { code: 'MV', label: 'Maldives', phone: '960' }, + { code: 'MW', label: 'Malawi', phone: '265' }, + { code: 'MX', label: 'Mexico', phone: '52' }, + { code: 'MY', label: 'Malaysia', phone: '60' }, + { code: 'MZ', label: 'Mozambique', phone: '258' }, + { code: 'NA', label: 'Namibia', phone: '264' }, + { code: 'NC', label: 'New Caledonia', phone: '687' }, + { code: 'NE', label: 'Niger', phone: '227' }, + { code: 'NF', label: 'Norfolk Island', phone: '672' }, + { code: 'NG', label: 'Nigeria', phone: '234' }, + { code: 'NI', label: 'Nicaragua', phone: '505' }, + { code: 'NL', label: 'Netherlands', phone: '31' }, + { code: 'NO', label: 'Norway', phone: '47' }, + { code: 'NP', label: 'Nepal', phone: '977' }, + { code: 'NR', label: 'Nauru', phone: '674' }, + { code: 'NU', label: 'Niue', phone: '683' }, + { code: 'NZ', label: 'New Zealand', phone: '64' }, + { code: 'OM', label: 'Oman', phone: '968' }, + { code: 'PA', label: 'Panama', phone: '507' }, + { code: 'PE', label: 'Peru', phone: '51' }, + { code: 'PF', label: 'French Polynesia', phone: '689' }, + { code: 'PG', label: 'Papua New Guinea', phone: '675' }, + { code: 'PH', label: 'Philippines', phone: '63' }, + { code: 'PK', label: 'Pakistan', phone: '92' }, + { code: 'PL', label: 'Poland', phone: '48' }, + { code: 'PM', label: 'Saint Pierre and Miquelon', phone: '508' }, + { code: 'PN', label: 'Pitcairn', phone: '870' }, + { code: 'PR', label: 'Puerto Rico', phone: '1' }, + { code: 'PS', label: 'Palestine, State of', phone: '970' }, + { code: 'PT', label: 'Portugal', phone: '351' }, + { code: 'PW', label: 'Palau', phone: '680' }, + { code: 'PY', label: 'Paraguay', phone: '595' }, + { code: 'QA', label: 'Qatar', phone: '974' }, + { code: 'RE', label: 'Reunion', phone: '262' }, + { code: 'RO', label: 'Romania', phone: '40' }, + { code: 'RS', label: 'Serbia', phone: '381' }, + { code: 'RU', label: 'Russian Federation', phone: '7' }, + { code: 'RW', label: 'Rwanda', phone: '250' }, + { code: 'SA', label: 'Saudi Arabia', phone: '966' }, + { code: 'SB', label: 'Solomon Islands', phone: '677' }, + { code: 'SC', label: 'Seychelles', phone: '248' }, + { code: 'SD', label: 'Sudan', phone: '249' }, + { code: 'SE', label: 'Sweden', phone: '46' }, + { code: 'SG', label: 'Singapore', phone: '65' }, + { code: 'SH', label: 'Saint Helena', phone: '290' }, + { code: 'SI', label: 'Slovenia', phone: '386' }, + { code: 'SJ', label: 'Svalbard and Jan Mayen', phone: '47' }, + { code: 'SK', label: 'Slovakia', phone: '421' }, + { code: 'SL', label: 'Sierra Leone', phone: '232' }, + { code: 'SM', label: 'San Marino', phone: '378' }, + { code: 'SN', label: 'Senegal', phone: '221' }, + { code: 'SO', label: 'Somalia', phone: '252' }, + { code: 'SR', label: 'Suriname', phone: '597' }, + { code: 'SS', label: 'South Sudan', phone: '211' }, + { code: 'ST', label: 'Sao Tome and Principe', phone: '239' }, + { code: 'SV', label: 'El Salvador', phone: '503' }, + { code: 'SX', label: 'Sint Maarten (Dutch part)', phone: '1-721' }, + { code: 'SY', label: 'Syrian Arab Republic', phone: '963' }, + { code: 'SZ', label: 'Swaziland', phone: '268' }, + { code: 'TC', label: 'Turks and Caicos Islands', phone: '1-649' }, + { code: 'TD', label: 'Chad', phone: '235' }, + { code: 'TF', label: 'French Southern Territories', phone: '262' }, + { code: 'TG', label: 'Togo', phone: '228' }, + { code: 'TH', label: 'Thailand', phone: '66' }, + { code: 'TJ', label: 'Tajikistan', phone: '992' }, + { code: 'TK', label: 'Tokelau', phone: '690' }, + { code: 'TL', label: 'Timor-Leste', phone: '670' }, + { code: 'TM', label: 'Turkmenistan', phone: '993' }, + { code: 'TN', label: 'Tunisia', phone: '216' }, + { code: 'TO', label: 'Tonga', phone: '676' }, + { code: 'TR', label: 'Turkey', phone: '90' }, + { code: 'TT', label: 'Trinidad and Tobago', phone: '1-868' }, + { code: 'TV', label: 'Tuvalu', phone: '688' }, + { code: 'TW', label: 'Taiwan, Province of China', phone: '886' }, + { code: 'TZ', label: 'United Republic of Tanzania', phone: '255' }, + { code: 'UA', label: 'Ukraine', phone: '380' }, + { code: 'UG', label: 'Uganda', phone: '256' }, + { code: 'US', label: 'United States', phone: '1' }, + { code: 'UY', label: 'Uruguay', phone: '598' }, + { code: 'UZ', label: 'Uzbekistan', phone: '998' }, + { code: 'VA', label: 'Holy See (Vatican City State)', phone: '379' }, + { code: 'VC', label: 'Saint Vincent and the Grenadines', phone: '1-784' }, + { code: 'VE', label: 'Venezuela', phone: '58' }, + { code: 'VG', label: 'British Virgin Islands', phone: '1-284' }, + { code: 'VI', label: 'US Virgin Islands', phone: '1-340' }, + { code: 'VN', label: 'Vietnam', phone: '84' }, + { code: 'VU', label: 'Vanuatu', phone: '678' }, + { code: 'WF', label: 'Wallis and Futuna', phone: '681' }, + { code: 'WS', label: 'Samoa', phone: '685' }, + { code: 'XK', label: 'Kosovo', phone: '383' }, + { code: 'YE', label: 'Yemen', phone: '967' }, + { code: 'YT', label: 'Mayotte', phone: '262' }, + { code: 'ZA', label: 'South Africa', phone: '27' }, + { code: 'ZM', label: 'Zambia', phone: '260' }, + { code: 'ZW', label: 'Zimbabwe', phone: '263' }, +]; diff --git a/app/frontend/src/assets/data/index.ts b/app/frontend/src/assets/data/index.ts new file mode 100644 index 00000000..8ab54348 --- /dev/null +++ b/app/frontend/src/assets/data/index.ts @@ -0,0 +1 @@ +export * from './countries'; diff --git a/app/frontend/src/assets/icons/email-inbox-icon.tsx b/app/frontend/src/assets/icons/email-inbox-icon.tsx new file mode 100644 index 00000000..4a4389c0 --- /dev/null +++ b/app/frontend/src/assets/icons/email-inbox-icon.tsx @@ -0,0 +1,144 @@ +import type { SvgIconProps } from '@mui/material/SvgIcon'; + +import { memo, forwardRef } from 'react'; + +import SvgIcon from '@mui/material/SvgIcon'; + +// ---------------------------------------------------------------------- + +const EmailInboxIcon = forwardRef((props, ref) => { + const { sx, ...other } = props; + + return ( + ({ + '--primary-main': theme.vars.palette.primary.main, + '--warning-light': theme.vars.palette.warning.light, + '--warning-dark': theme.vars.palette.warning.dark, + width: 96, + flexShrink: 0, + height: 'auto', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}); + +export default memo(EmailInboxIcon); diff --git a/app/frontend/src/assets/icons/index.ts b/app/frontend/src/assets/icons/index.ts new file mode 100644 index 00000000..597f81f5 --- /dev/null +++ b/app/frontend/src/assets/icons/index.ts @@ -0,0 +1,15 @@ +export * from './social-icons'; + +export { default as SentIcon } from './sent-icon'; + +export { default as PasswordIcon } from './password-icon'; + +export { default as PlanFreeIcon } from './plan-free-icon'; + +export { default as EmailInboxIcon } from './email-inbox-icon'; + +export { default as PlanStarterIcon } from './plan-starter-icon'; + +export { default as PlanPremiumIcon } from './plan-premium-icon'; + +export { default as NewPasswordIcon } from './new-password-icon'; diff --git a/app/frontend/src/assets/icons/new-password-icon.tsx b/app/frontend/src/assets/icons/new-password-icon.tsx new file mode 100644 index 00000000..8ff180f3 --- /dev/null +++ b/app/frontend/src/assets/icons/new-password-icon.tsx @@ -0,0 +1,112 @@ +import type { SvgIconProps } from '@mui/material/SvgIcon'; + +import { memo, forwardRef } from 'react'; + +import SvgIcon from '@mui/material/SvgIcon'; + +// ---------------------------------------------------------------------- + +const NewPasswordIcon = forwardRef((props, ref) => { + const { sx, ...other } = props; + + return ( + ({ + '--primary-main': theme.vars.palette.primary.main, + '--warning-light': theme.vars.palette.warning.light, + '--warning-dark': theme.vars.palette.warning.dark, + width: 96, + flexShrink: 0, + height: 'auto', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}); + +export default memo(NewPasswordIcon); diff --git a/app/frontend/src/assets/icons/password-icon.tsx b/app/frontend/src/assets/icons/password-icon.tsx new file mode 100644 index 00000000..54a484a1 --- /dev/null +++ b/app/frontend/src/assets/icons/password-icon.tsx @@ -0,0 +1,112 @@ +import type { SvgIconProps } from '@mui/material/SvgIcon'; + +import { memo, forwardRef } from 'react'; + +import SvgIcon from '@mui/material/SvgIcon'; + +// ---------------------------------------------------------------------- + +const PasswordIcon = forwardRef((props, ref) => { + const { sx, ...other } = props; + + return ( + ({ + '--primary-main': theme.vars.palette.primary.main, + '--warning-light': theme.vars.palette.warning.light, + width: 96, + flexShrink: 0, + height: 'auto', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}); + +export default memo(PasswordIcon); diff --git a/app/frontend/src/assets/icons/plan-free-icon.tsx b/app/frontend/src/assets/icons/plan-free-icon.tsx new file mode 100644 index 00000000..2528cc5d --- /dev/null +++ b/app/frontend/src/assets/icons/plan-free-icon.tsx @@ -0,0 +1,53 @@ +import type { SvgIconProps } from '@mui/material/SvgIcon'; + +import { memo, forwardRef } from 'react'; + +import SvgIcon from '@mui/material/SvgIcon'; + +// ---------------------------------------------------------------------- + +const PlanFreeIcon = forwardRef((props, ref) => { + const { sx, ...other } = props; + + return ( + ({ + '--primary-main': theme.vars.palette.primary.main, + '--primary-dark': theme.vars.palette.primary.dark, + '--primary-darker': theme.vars.palette.primary.darker, + width: 48, + flexShrink: 0, + height: 'auto', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + + + + + + + + + + + ); +}); + +export default memo(PlanFreeIcon); diff --git a/app/frontend/src/assets/icons/plan-premium-icon.tsx b/app/frontend/src/assets/icons/plan-premium-icon.tsx new file mode 100644 index 00000000..822f9c61 --- /dev/null +++ b/app/frontend/src/assets/icons/plan-premium-icon.tsx @@ -0,0 +1,90 @@ +import type { SvgIconProps } from '@mui/material/SvgIcon'; + +import { memo, forwardRef } from 'react'; + +import SvgIcon from '@mui/material/SvgIcon'; + +// ---------------------------------------------------------------------- + +const PlanPremiumIcon = forwardRef((props, ref) => { + const { sx, ...other } = props; + + return ( + ({ + '--primary-main': theme.vars.palette.primary.main, + '--primary-dark': theme.vars.palette.primary.dark, + '--primary-darker': theme.vars.palette.primary.darker, + width: 48, + flexShrink: 0, + height: 'auto', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}); + +export default memo(PlanPremiumIcon); diff --git a/app/frontend/src/assets/icons/plan-starter-icon.tsx b/app/frontend/src/assets/icons/plan-starter-icon.tsx new file mode 100644 index 00000000..cf0e0ca5 --- /dev/null +++ b/app/frontend/src/assets/icons/plan-starter-icon.tsx @@ -0,0 +1,77 @@ +import type { SvgIconProps } from '@mui/material/SvgIcon'; + +import { memo, forwardRef } from 'react'; + +import SvgIcon from '@mui/material/SvgIcon'; + +// ---------------------------------------------------------------------- + +const PlanStarterIcon = forwardRef((props, ref) => { + const { sx, ...other } = props; + + return ( + ({ + '--primary-main': theme.vars.palette.primary.main, + '--primary-dark': theme.vars.palette.primary.dark, + '--primary-darker': theme.vars.palette.primary.darker, + width: 48, + flexShrink: 0, + height: 'auto', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + + + + + + + + + + + + + + + + + + + + + + + + + ); +}); + +export default memo(PlanStarterIcon); diff --git a/app/frontend/src/assets/icons/sent-icon.tsx b/app/frontend/src/assets/icons/sent-icon.tsx new file mode 100644 index 00000000..0a3f64fa --- /dev/null +++ b/app/frontend/src/assets/icons/sent-icon.tsx @@ -0,0 +1,78 @@ +import type { SvgIconProps } from '@mui/material/SvgIcon'; + +import { memo, forwardRef } from 'react'; + +import SvgIcon from '@mui/material/SvgIcon'; + +// ---------------------------------------------------------------------- + +const SentIcon = forwardRef((props, ref) => { + const { sx, ...other } = props; + + return ( + ({ + '--primary-main': theme.vars.palette.primary.main, + '--primary-dark': theme.vars.palette.primary.dark, + width: 96, + flexShrink: 0, + height: 'auto', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + + + + + + + + + + + + + + + + + + + + + + + ); +}); + +export default memo(SentIcon); diff --git a/app/frontend/src/assets/icons/social-icons.tsx b/app/frontend/src/assets/icons/social-icons.tsx new file mode 100644 index 00000000..2201b551 --- /dev/null +++ b/app/frontend/src/assets/icons/social-icons.tsx @@ -0,0 +1,221 @@ +import type { SvgIconProps } from '@mui/material/SvgIcon'; +import type { Theme, SxProps } from '@mui/material/styles'; + +import { useId, forwardRef } from 'react'; + +import SvgIcon from '@mui/material/SvgIcon'; + +// ---------------------------------------------------------------------- + +const baseStyles: SxProps = { + width: 20, + height: 'auto', + flexShrink: 0, +}; + +// ---------------------------------------------------------------------- + +export const LinkedinIcon = forwardRef((props, ref) => { + const { sx, ...other } = props; + + return ( + ({ + ...baseStyles, + color: '#0A66C2', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + + + ); +}); + +// ---------------------------------------------------------------------- + +export const FacebookIcon = forwardRef((props, ref) => { + const { sx, ...other } = props; + + return ( + ({ + ...baseStyles, + color: '#1877F2', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + + + ); +}); + +// ---------------------------------------------------------------------- + +export const GithubIcon = forwardRef((props, ref) => { + const { sx, ...other } = props; + + return ( + ({ + ...baseStyles, + color: 'text.primary', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + + + ); +}); + +// ---------------------------------------------------------------------- + +export const TwitterIcon = forwardRef((props, ref) => { + const { sx, ...other } = props; + + return ( + ({ + ...baseStyles, + color: 'text.primary', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + + + ); +}); + +// ---------------------------------------------------------------------- + +export const GoogleIcon = forwardRef((props, ref) => { + const { sx, ...other } = props; + + return ( + ({ + ...baseStyles, + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + + + + + + ); +}); + +// ---------------------------------------------------------------------- + +export const InstagramIcon = forwardRef((props, ref) => { + const { sx, ...other } = props; + + const gradientId = useId(); + + return ( + ({ + ...baseStyles, + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + + + + + + + + + + + + + + + + + + + + + ); +}); diff --git a/app/frontend/src/assets/illustrations/avatar-shape.tsx b/app/frontend/src/assets/illustrations/avatar-shape.tsx new file mode 100644 index 00000000..fd32e8f9 --- /dev/null +++ b/app/frontend/src/assets/illustrations/avatar-shape.tsx @@ -0,0 +1,37 @@ +import type { SvgIconProps } from '@mui/material/SvgIcon'; + +import { memo, forwardRef } from 'react'; + +import SvgIcon from '@mui/material/SvgIcon'; + +// ---------------------------------------------------------------------- + +const AvatarShape = forwardRef((props, ref) => { + const { sx, ...other } = props; + + return ( + ({ + width: 144, + height: 62, + color: 'background.paper', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + + + ); +}); + +export default memo(AvatarShape); diff --git a/app/frontend/src/assets/illustrations/background-shape.tsx b/app/frontend/src/assets/illustrations/background-shape.tsx new file mode 100644 index 00000000..b82294dd --- /dev/null +++ b/app/frontend/src/assets/illustrations/background-shape.tsx @@ -0,0 +1,25 @@ +import { useId } from 'react'; + +// ---------------------------------------------------------------------- + +export function BackgroundShape() { + const gradientId = useId(); + + return ( + <> + + + + + + + + + + ); +} diff --git a/app/frontend/src/assets/illustrations/booking-illustration.tsx b/app/frontend/src/assets/illustrations/booking-illustration.tsx new file mode 100644 index 00000000..3b9562cd --- /dev/null +++ b/app/frontend/src/assets/illustrations/booking-illustration.tsx @@ -0,0 +1,310 @@ +import type { SvgIconProps } from '@mui/material/SvgIcon'; + +import { memo, forwardRef } from 'react'; + +import SvgIcon from '@mui/material/SvgIcon'; + +// ---------------------------------------------------------------------- + +const BookingIllustration = forwardRef((props, ref) => { + const { sx, ...other } = props; + + return ( + ({ + '--primary-lighter': theme.vars.palette.primary.lighter, + '--primary-light': theme.vars.palette.primary.light, + '--primary-main': theme.vars.palette.primary.main, + '--primary-dark': theme.vars.palette.primary.dark, + '--primary-darker': theme.vars.palette.primary.darker, + width: 120, + maxWidth: 1, + flexShrink: 0, + height: 'auto', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}); + +export default memo(BookingIllustration); diff --git a/app/frontend/src/assets/illustrations/check-in-illustration.tsx b/app/frontend/src/assets/illustrations/check-in-illustration.tsx new file mode 100644 index 00000000..72432094 --- /dev/null +++ b/app/frontend/src/assets/illustrations/check-in-illustration.tsx @@ -0,0 +1,99 @@ +import type { SvgIconProps } from '@mui/material/SvgIcon'; + +import { memo, forwardRef } from 'react'; + +import SvgIcon from '@mui/material/SvgIcon'; + +// ---------------------------------------------------------------------- + +const CheckInIllustration = forwardRef((props, ref) => { + const { sx, ...other } = props; + + return ( + ({ + '--primary-lighter': theme.vars.palette.primary.lighter, + '--primary-light': theme.vars.palette.primary.light, + '--primary-main': theme.vars.palette.primary.main, + '--primary-dark': theme.vars.palette.primary.dark, + '--primary-darker': theme.vars.palette.primary.darker, + width: 120, + maxWidth: 1, + flexShrink: 0, + height: 'auto', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}); + +export default memo(CheckInIllustration); diff --git a/app/frontend/src/assets/illustrations/check-out-illustration.tsx b/app/frontend/src/assets/illustrations/check-out-illustration.tsx new file mode 100644 index 00000000..bfc64e6f --- /dev/null +++ b/app/frontend/src/assets/illustrations/check-out-illustration.tsx @@ -0,0 +1,73 @@ +import type { SvgIconProps } from '@mui/material/SvgIcon'; + +import { memo, forwardRef } from 'react'; + +import SvgIcon from '@mui/material/SvgIcon'; + +// ---------------------------------------------------------------------- + +const CheckoutIllustration = forwardRef((props, ref) => { + const { sx, ...other } = props; + + return ( + ({ + '--primary-light': theme.vars.palette.primary.light, + '--primary-main': theme.vars.palette.primary.main, + '--primary-dark': theme.vars.palette.primary.dark, + '--primary-darker': theme.vars.palette.primary.darker, + width: 120, + maxWidth: 1, + flexShrink: 0, + height: 'auto', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + + + + + + + + + + + + + + ); +}); + +export default memo(CheckoutIllustration); diff --git a/app/frontend/src/assets/illustrations/coming-soon-illustration.tsx b/app/frontend/src/assets/illustrations/coming-soon-illustration.tsx new file mode 100644 index 00000000..ecf12382 --- /dev/null +++ b/app/frontend/src/assets/illustrations/coming-soon-illustration.tsx @@ -0,0 +1,131 @@ +import type { SvgIconProps } from '@mui/material/SvgIcon'; + +import { memo, forwardRef } from 'react'; + +import SvgIcon from '@mui/material/SvgIcon'; + +import { CONFIG } from 'src/global-config'; + +import { BackgroundShape } from './background-shape'; + +// ---------------------------------------------------------------------- + +type SvgProps = SvgIconProps & { hideBackground?: boolean }; + +const ComingSoonIllustration = forwardRef((props, ref) => { + const { hideBackground, sx, ...other } = props; + + return ( + ({ + '--primary-light': theme.vars.palette.primary.light, + '--primary-main': theme.vars.palette.primary.main, + '--primary-dark': theme.vars.palette.primary.dark, + '--primary-darker': theme.vars.palette.primary.darker, + width: 320, + maxWidth: 1, + flexShrink: 0, + height: 'auto', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + {!hideBackground && } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}); + +export default memo(ComingSoonIllustration); diff --git a/app/frontend/src/assets/illustrations/forbidden-illustration.tsx b/app/frontend/src/assets/illustrations/forbidden-illustration.tsx new file mode 100644 index 00000000..de24c10c --- /dev/null +++ b/app/frontend/src/assets/illustrations/forbidden-illustration.tsx @@ -0,0 +1,101 @@ +import type { SvgIconProps } from '@mui/material/SvgIcon'; + +import { memo, forwardRef } from 'react'; + +import SvgIcon from '@mui/material/SvgIcon'; + +import { CONFIG } from 'src/global-config'; + +import { BackgroundShape } from './background-shape'; + +// ---------------------------------------------------------------------- + +type SvgProps = SvgIconProps & { hideBackground?: boolean }; + +const ForbiddenIllustration = forwardRef((props, ref) => { + const { hideBackground, sx, ...other } = props; + + return ( + ({ + '--primary-light': theme.vars.palette.primary.light, + '--primary-main': theme.vars.palette.primary.main, + '--primary-dark': theme.vars.palette.primary.dark, + '--primary-darker': theme.vars.palette.primary.darker, + width: 320, + maxWidth: 1, + flexShrink: 0, + height: 'auto', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + {!hideBackground && } + + + + + + + + + + + + + + + + + + + + + + + + + ); +}); + +export default memo(ForbiddenIllustration); diff --git a/app/frontend/src/assets/illustrations/index.ts b/app/frontend/src/assets/illustrations/index.ts new file mode 100644 index 00000000..8548afde --- /dev/null +++ b/app/frontend/src/assets/illustrations/index.ts @@ -0,0 +1,25 @@ +export { default as AvatarShape } from './avatar-shape'; + +export { default as SeoIllustration } from './seo-illustration'; + +export { default as UploadIllustration } from './upload-illustration'; + +export { default as BookingIllustration } from './booking-illustration'; + +export { default as CheckInIllustration } from './check-in-illustration'; + +export { default as CheckoutIllustration } from './check-out-illustration'; + +export { default as ForbiddenIllustration } from './forbidden-illustration'; + +export { default as MotivationIllustration } from './motivation-illustration'; + +export { default as ComingSoonIllustration } from './coming-soon-illustration'; + +export { default as MaintenanceIllustration } from './maintenance-illustration'; + +export { default as ServerErrorIllustration } from './server-error-illustration'; + +export { default as PageNotFoundIllustration } from './page-not-found-illustration'; + +export { default as OrderCompleteIllustration } from './order-complete-illustration'; diff --git a/app/frontend/src/assets/illustrations/maintenance-illustration.tsx b/app/frontend/src/assets/illustrations/maintenance-illustration.tsx new file mode 100644 index 00000000..3ac53fdd --- /dev/null +++ b/app/frontend/src/assets/illustrations/maintenance-illustration.tsx @@ -0,0 +1,228 @@ +import type { SvgIconProps } from '@mui/material/SvgIcon'; + +import { memo, forwardRef } from 'react'; + +import SvgIcon from '@mui/material/SvgIcon'; + +import { CONFIG } from 'src/global-config'; + +import { BackgroundShape } from './background-shape'; + +// ---------------------------------------------------------------------- + +type SvgProps = SvgIconProps & { hideBackground?: boolean }; + +const MaintenanceIllustration = forwardRef((props, ref) => { + const { hideBackground, sx, ...other } = props; + + return ( + ({ + '--primary-light': theme.vars.palette.primary.light, + '--primary-main': theme.vars.palette.primary.main, + '--primary-dark': theme.vars.palette.primary.dark, + '--primary-darker': theme.vars.palette.primary.darker, + width: 320, + maxWidth: 1, + flexShrink: 0, + height: 'auto', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + {!hideBackground && } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}); + +export default memo(MaintenanceIllustration); diff --git a/app/frontend/src/assets/illustrations/motivation-illustration.tsx b/app/frontend/src/assets/illustrations/motivation-illustration.tsx new file mode 100644 index 00000000..d7369487 --- /dev/null +++ b/app/frontend/src/assets/illustrations/motivation-illustration.tsx @@ -0,0 +1,84 @@ +import type { SvgIconProps } from '@mui/material/SvgIcon'; + +import { memo, forwardRef } from 'react'; + +import SvgIcon from '@mui/material/SvgIcon'; + +import { CONFIG } from 'src/global-config'; + +import { BackgroundShape } from './background-shape'; + +// ---------------------------------------------------------------------- + +type SvgProps = SvgIconProps & { hideBackground?: boolean }; + +const MotivationIllustration = forwardRef((props, ref) => { + const { hideBackground, sx, ...other } = props; + + return ( + ({ + '--primary-lighter': theme.vars.palette.primary.lighter, + '--primary-dark': theme.vars.palette.primary.dark, + '--primary-darker': theme.vars.palette.primary.darker, + width: 320, + maxWidth: 1, + flexShrink: 0, + height: 'auto', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + {!hideBackground && } + + + + + + + + + + + + + + + + + + + ); +}); + +export default memo(MotivationIllustration); diff --git a/app/frontend/src/assets/illustrations/order-complete-illustration.tsx b/app/frontend/src/assets/illustrations/order-complete-illustration.tsx new file mode 100644 index 00000000..7598349d --- /dev/null +++ b/app/frontend/src/assets/illustrations/order-complete-illustration.tsx @@ -0,0 +1,135 @@ +import type { SvgIconProps } from '@mui/material/SvgIcon'; + +import { memo, forwardRef } from 'react'; + +import SvgIcon from '@mui/material/SvgIcon'; + +import { CONFIG } from 'src/global-config'; + +import { BackgroundShape } from './background-shape'; + +// ---------------------------------------------------------------------- + +type SvgProps = SvgIconProps & { hideBackground?: boolean }; + +const OrderCompleteIllustration = forwardRef((props, ref) => { + const { hideBackground, sx, ...other } = props; + + return ( + ({ + '--primary-light': theme.vars.palette.primary.light, + '--primary-main': theme.vars.palette.primary.main, + '--primary-dark': theme.vars.palette.primary.dark, + '--primary-darker': theme.vars.palette.primary.darker, + width: 320, + maxWidth: 1, + flexShrink: 0, + height: 'auto', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + {!hideBackground && } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}); + +export default memo(OrderCompleteIllustration); diff --git a/app/frontend/src/assets/illustrations/page-not-found-illustration.tsx b/app/frontend/src/assets/illustrations/page-not-found-illustration.tsx new file mode 100644 index 00000000..4962240c --- /dev/null +++ b/app/frontend/src/assets/illustrations/page-not-found-illustration.tsx @@ -0,0 +1,87 @@ +import type { SvgIconProps } from '@mui/material/SvgIcon'; + +import { memo, forwardRef } from 'react'; + +import SvgIcon from '@mui/material/SvgIcon'; + +import { CONFIG } from 'src/global-config'; + +import { BackgroundShape } from './background-shape'; + +// ---------------------------------------------------------------------- + +type SvgProps = SvgIconProps & { hideBackground?: boolean }; + +const PageNotFoundIllustration = forwardRef((props, ref) => { + const { hideBackground, sx, ...other } = props; + + return ( + ({ + '--primary-light': theme.vars.palette.primary.light, + '--primary-main': theme.vars.palette.primary.main, + '--primary-dark': theme.vars.palette.primary.dark, + '--primary-darker': theme.vars.palette.primary.darker, + width: 320, + maxWidth: 1, + flexShrink: 0, + height: 'auto', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + {!hideBackground && } + + + + + + + + + + + + + + + + + + + + ); +}); + +export default memo(PageNotFoundIllustration); diff --git a/app/frontend/src/assets/illustrations/seo-illustration.tsx b/app/frontend/src/assets/illustrations/seo-illustration.tsx new file mode 100644 index 00000000..d15f4e30 --- /dev/null +++ b/app/frontend/src/assets/illustrations/seo-illustration.tsx @@ -0,0 +1,249 @@ +import type { SvgIconProps } from '@mui/material/SvgIcon'; + +import { memo, forwardRef } from 'react'; + +import SvgIcon from '@mui/material/SvgIcon'; + +import { CONFIG } from 'src/global-config'; + +import { BackgroundShape } from './background-shape'; + +// ---------------------------------------------------------------------- + +type SvgProps = SvgIconProps & { hideBackground?: boolean }; + +const SeoIllustration = forwardRef((props, ref) => { + const { hideBackground, sx, ...other } = props; + + return ( + ({ + '--primary-light': theme.vars.palette.primary.light, + '--primary-dark': theme.vars.palette.primary.dark, + width: 320, + maxWidth: 1, + flexShrink: 0, + height: 'auto', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + {!hideBackground && } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}); + +export default memo(SeoIllustration); diff --git a/app/frontend/src/assets/illustrations/server-error-illustration.tsx b/app/frontend/src/assets/illustrations/server-error-illustration.tsx new file mode 100644 index 00000000..b76b330b --- /dev/null +++ b/app/frontend/src/assets/illustrations/server-error-illustration.tsx @@ -0,0 +1,160 @@ +import type { SvgIconProps } from '@mui/material/SvgIcon'; + +import { memo, forwardRef } from 'react'; + +import SvgIcon from '@mui/material/SvgIcon'; + +import { CONFIG } from 'src/global-config'; + +import { BackgroundShape } from './background-shape'; + +// ---------------------------------------------------------------------- + +type SvgProps = SvgIconProps & { hideBackground?: boolean }; + +const ServerErrorIllustration = forwardRef((props, ref) => { + const { hideBackground, sx, ...other } = props; + + return ( + ({ + '--primary-lighter': theme.vars.palette.primary.lighter, + '--primary-light': theme.vars.palette.primary.light, + '--primary-main': theme.vars.palette.primary.main, + '--primary-dark': theme.vars.palette.primary.dark, + '--primary-darker': theme.vars.palette.primary.darker, + width: 320, + maxWidth: 1, + flexShrink: 0, + height: 'auto', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + {!hideBackground && } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}); + +export default memo(ServerErrorIllustration); diff --git a/app/frontend/src/assets/illustrations/upload-illustration.tsx b/app/frontend/src/assets/illustrations/upload-illustration.tsx new file mode 100644 index 00000000..9d8de876 --- /dev/null +++ b/app/frontend/src/assets/illustrations/upload-illustration.tsx @@ -0,0 +1,624 @@ +import type { SvgIconProps } from '@mui/material/SvgIcon'; + +import { memo, forwardRef } from 'react'; + +import SvgIcon from '@mui/material/SvgIcon'; + +import { BackgroundShape } from './background-shape'; + +// ---------------------------------------------------------------------- + +type SvgProps = SvgIconProps & { hideBackground?: boolean }; + +const UploadIllustration = forwardRef((props, ref) => { + const { hideBackground, sx, ...other } = props; + + return ( + ({ + '--primary-main': theme.vars.palette.primary.main, + '--primary-dark': theme.vars.palette.primary.dark, + '--primary-darker': theme.vars.palette.primary.darker, + width: 320, + maxWidth: 1, + flexShrink: 0, + height: 'auto', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + {!hideBackground && } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}); + +export default memo(UploadIllustration); diff --git a/app/frontend/src/auth/components/form-divider.tsx b/app/frontend/src/auth/components/form-divider.tsx new file mode 100644 index 00000000..1b2ddc10 --- /dev/null +++ b/app/frontend/src/auth/components/form-divider.tsx @@ -0,0 +1,28 @@ +import type { Theme, SxProps } from '@mui/material/styles'; + +import Divider from '@mui/material/Divider'; + +// ---------------------------------------------------------------------- + +type FormDividerProps = { + sx?: SxProps; + label?: React.ReactNode; +}; + +export function FormDivider({ sx, label = 'OR' }: FormDividerProps) { + return ( + ({ + my: 3, + typography: 'overline', + color: 'text.disabled', + '&::before, :after': { borderTopStyle: 'dashed' }, + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + > + {label} + + ); +} diff --git a/app/frontend/src/auth/components/form-head.tsx b/app/frontend/src/auth/components/form-head.tsx new file mode 100644 index 00000000..3eea302b --- /dev/null +++ b/app/frontend/src/auth/components/form-head.tsx @@ -0,0 +1,47 @@ +import type { BoxProps } from '@mui/material/Box'; + +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; + +// ---------------------------------------------------------------------- + +type FormHeadProps = BoxProps & { + icon?: React.ReactNode; + title: React.ReactNode; + description?: React.ReactNode; +}; + +export function FormHead({ sx, icon, title, description, ...other }: FormHeadProps) { + return ( + <> + {icon && ( + + {icon} + + )} + + ({ + mb: 5, + gap: 1.5, + display: 'flex', + textAlign: 'center', + whiteSpace: 'pre-line', + flexDirection: 'column', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + {title} + + {description && ( + + {description} + + )} + + + ); +} diff --git a/app/frontend/src/auth/components/form-resend-code.tsx b/app/frontend/src/auth/components/form-resend-code.tsx new file mode 100644 index 00000000..c2f3ce9d --- /dev/null +++ b/app/frontend/src/auth/components/form-resend-code.tsx @@ -0,0 +1,46 @@ +import type { BoxProps } from '@mui/material/Box'; + +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; + +// ---------------------------------------------------------------------- + +type FormResendCodeProps = BoxProps & { + value?: number; + disabled?: boolean; + onResendCode?: () => void; +}; + +export function FormResendCode({ + value, + disabled, + onResendCode, + sx, + ...other +}: FormResendCodeProps) { + return ( + ({ + mt: 3, + typography: 'body2', + alignSelf: 'center', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + {`Don’t have a code? `} + + Resend {disabled && value && value > 0 && `(${value}s)`} + + + ); +} diff --git a/app/frontend/src/auth/components/form-return-link.tsx b/app/frontend/src/auth/components/form-return-link.tsx new file mode 100644 index 00000000..23065e6a --- /dev/null +++ b/app/frontend/src/auth/components/form-return-link.tsx @@ -0,0 +1,41 @@ +import type { LinkProps } from '@mui/material/Link'; + +import Link from '@mui/material/Link'; + +import { RouterLink } from 'src/routes/components'; + +import { Iconify } from 'src/components/iconify'; + +// ---------------------------------------------------------------------- + +type FormReturnLinkProps = LinkProps & { + href: string; + icon?: React.ReactNode; + label?: React.ReactNode; +}; + +export function FormReturnLink({ sx, href, label, icon, children, ...other }: FormReturnLinkProps) { + return ( + + {icon || } + {label || 'Return to sign in'} + {children} + + ); +} diff --git a/app/frontend/src/auth/components/form-socials.tsx b/app/frontend/src/auth/components/form-socials.tsx new file mode 100644 index 00000000..d5cc5175 --- /dev/null +++ b/app/frontend/src/auth/components/form-socials.tsx @@ -0,0 +1,46 @@ +import type { BoxProps } from '@mui/material/Box'; + +import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; + +import { GithubIcon, GoogleIcon, TwitterIcon } from 'src/assets/icons'; + +// ---------------------------------------------------------------------- + +type FormSocialsProps = BoxProps & { + signInWithGoogle?: () => void; + singInWithGithub?: () => void; + signInWithTwitter?: () => void; +}; + +export function FormSocials({ + sx, + signInWithGoogle, + singInWithGithub, + signInWithTwitter, + ...other +}: FormSocialsProps) { + return ( + ({ + gap: 1.5, + display: 'flex', + justifyContent: 'center', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + + + + + + + + + + + ); +} diff --git a/app/frontend/src/auth/components/sign-up-terms.tsx b/app/frontend/src/auth/components/sign-up-terms.tsx new file mode 100644 index 00000000..0c3e7499 --- /dev/null +++ b/app/frontend/src/auth/components/sign-up-terms.tsx @@ -0,0 +1,35 @@ +import type { BoxProps } from '@mui/material/Box'; + +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; + +// ---------------------------------------------------------------------- + +export function SignUpTerms({ sx, ...other }: BoxProps) { + return ( + ({ + mt: 3, + display: 'block', + textAlign: 'center', + typography: 'caption', + color: 'text.secondary', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + {'By signing up, I agree to '} + + Terms of service + + {' and '} + + Privacy policy + + . + + ); +} diff --git a/app/frontend/src/auth/context/amplify/action.tsx b/app/frontend/src/auth/context/amplify/action.tsx new file mode 100644 index 00000000..08781e7a --- /dev/null +++ b/app/frontend/src/auth/context/amplify/action.tsx @@ -0,0 +1,99 @@ +'use client'; + +import type { + SignUpInput, + SignInInput, + ConfirmSignUpInput, + ResetPasswordInput, + ResendSignUpCodeInput, + ConfirmResetPasswordInput, +} from 'aws-amplify/auth'; + +import { + signIn as _signIn, + signUp as _signUp, + signOut as _signOut, + confirmSignUp as _confirmSignUp, + resetPassword as _resetPassword, + resendSignUpCode as _resendSignUpCode, + confirmResetPassword as _confirmResetPassword, +} from 'aws-amplify/auth'; + +// ---------------------------------------------------------------------- + +export type SignInParams = SignInInput; + +export type SignUpParams = SignUpInput & { firstName: string; lastName: string }; + +export type ResendSignUpCodeParams = ResendSignUpCodeInput; + +export type ConfirmSignUpParams = ConfirmSignUpInput; + +export type ResetPasswordParams = ResetPasswordInput; + +export type ConfirmResetPasswordParams = ConfirmResetPasswordInput; + +/** ************************************** + * Sign in + *************************************** */ +export const signInWithPassword = async ({ username, password }: SignInParams): Promise => { + await _signIn({ username, password }); +}; + +/** ************************************** + * Sign up + *************************************** */ +export const signUp = async ({ + username, + password, + firstName, + lastName, +}: SignUpParams): Promise => { + await _signUp({ + username, + password, + options: { userAttributes: { email: username, given_name: firstName, family_name: lastName } }, + }); +}; + +/** ************************************** + * Confirm sign up + *************************************** */ +export const confirmSignUp = async ({ + username, + confirmationCode, +}: ConfirmSignUpParams): Promise => { + await _confirmSignUp({ username, confirmationCode }); +}; + +/** ************************************** + * Resend code sign up + *************************************** */ +export const resendSignUpCode = async ({ username }: ResendSignUpCodeParams): Promise => { + await _resendSignUpCode({ username }); +}; + +/** ************************************** + * Sign out + *************************************** */ +export const signOut = async (): Promise => { + await _signOut(); +}; + +/** ************************************** + * Reset password + *************************************** */ +export const resetPassword = async ({ username }: ResetPasswordParams): Promise => { + await _resetPassword({ username }); +}; + +/** ************************************** + * Update password + *************************************** */ +export const updatePassword = async ({ + username, + confirmationCode, + newPassword, +}: ConfirmResetPasswordParams): Promise => { + await _confirmResetPassword({ username, confirmationCode, newPassword }); +}; diff --git a/app/frontend/src/auth/context/amplify/auth-provider.tsx b/app/frontend/src/auth/context/amplify/auth-provider.tsx new file mode 100644 index 00000000..7d0079c5 --- /dev/null +++ b/app/frontend/src/auth/context/amplify/auth-provider.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { Amplify } from 'aws-amplify'; +import { useSetState } from 'minimal-shared/hooks'; +import { useMemo, useEffect, useCallback } from 'react'; +import { fetchAuthSession, fetchUserAttributes } from 'aws-amplify/auth'; + +import axios from 'src/lib/axios'; +import { CONFIG } from 'src/global-config'; + +import { AuthContext } from '../auth-context'; + +import type { AuthState } from '../../types'; + +// ---------------------------------------------------------------------- + +/** + * NOTE: + * We only build demo at basic level. + * Customer will need to do some extra handling yourself if you want to extend the logic and other features... + */ + +/** + * Docs: + * https://docs.amplify.aws/react/build-a-backend/auth/manage-user-session/ + */ + +Amplify.configure({ + Auth: { + Cognito: { + userPoolId: CONFIG.amplify.userPoolId, + userPoolClientId: CONFIG.amplify.userPoolWebClientId, + }, + }, +}); + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export function AuthProvider({ children }: Props) { + const { state, setState } = useSetState({ user: null, loading: true }); + + const checkUserSession = useCallback(async () => { + try { + const authSession = (await fetchAuthSession({ forceRefresh: true })).tokens; + + if (authSession) { + const userAttributes = await fetchUserAttributes(); + + const accessToken = authSession.accessToken.toString(); + + setState({ user: { ...authSession, ...userAttributes }, loading: false }); + axios.defaults.headers.common.Authorization = `Bearer ${accessToken}`; + } else { + setState({ user: null, loading: false }); + delete axios.defaults.headers.common.Authorization; + } + } catch (error) { + console.error(error); + setState({ user: null, loading: false }); + } + }, [setState]); + + useEffect(() => { + checkUserSession(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // ---------------------------------------------------------------------- + + const checkAuthenticated = state.user ? 'authenticated' : 'unauthenticated'; + + const status = state.loading ? 'loading' : checkAuthenticated; + + const memoizedValue = useMemo( + () => ({ + user: state.user + ? { + ...state.user, + id: state.user?.sub, + accessToken: state.user?.accessToken?.toString(), + displayName: `${state.user?.given_name} ${state.user?.family_name}`, + role: state.user?.role ?? 'admin', + } + : null, + checkUserSession, + loading: status === 'loading', + authenticated: status === 'authenticated', + unauthenticated: status === 'unauthenticated', + }), + [checkUserSession, state.user, status] + ); + + return {children}; +} diff --git a/app/frontend/src/auth/context/amplify/index.ts b/app/frontend/src/auth/context/amplify/index.ts new file mode 100644 index 00000000..1597ff83 --- /dev/null +++ b/app/frontend/src/auth/context/amplify/index.ts @@ -0,0 +1,3 @@ +export * from './action'; + +export * from './auth-provider'; diff --git a/app/frontend/src/auth/context/auth-context.tsx b/app/frontend/src/auth/context/auth-context.tsx new file mode 100644 index 00000000..131b5e43 --- /dev/null +++ b/app/frontend/src/auth/context/auth-context.tsx @@ -0,0 +1,9 @@ +'use client'; + +import { createContext } from 'react'; + +import type { AuthContextValue } from '../types'; + +// ---------------------------------------------------------------------- + +export const AuthContext = createContext(undefined); diff --git a/app/frontend/src/auth/context/auth0/auth-provider.tsx b/app/frontend/src/auth/context/auth0/auth-provider.tsx new file mode 100644 index 00000000..4a51ad27 --- /dev/null +++ b/app/frontend/src/auth/context/auth0/auth-provider.tsx @@ -0,0 +1,96 @@ +'use client'; + +import type { AppState } from '@auth0/auth0-react'; + +import { useAuth0, Auth0Provider } from '@auth0/auth0-react'; +import { useMemo, useState, useEffect, useCallback } from 'react'; + +import axios from 'src/lib/axios'; +import { CONFIG } from 'src/global-config'; + +import { AuthContext } from '../auth-context'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export function AuthProvider({ children }: Props) { + const { domain, clientId, callbackUrl } = CONFIG.auth0; + + const onRedirectCallback = useCallback((appState?: AppState) => { + window.location.replace(appState?.returnTo || window.location.pathname); + }, []); + + if (!(domain && clientId && callbackUrl)) { + return null; + } + + return ( + + {children} + + ); +} + +// ---------------------------------------------------------------------- + +function AuthProviderContainer({ children }: Props) { + const { user, isLoading, isAuthenticated, getAccessTokenSilently } = useAuth0(); + + const [accessToken, setAccessToken] = useState(null); + + const getAccessToken = useCallback(async () => { + try { + if (isAuthenticated) { + const token = await getAccessTokenSilently(); + + setAccessToken(token); + axios.defaults.headers.common.Authorization = `Bearer ${token}`; + } else { + setAccessToken(null); + delete axios.defaults.headers.common.Authorization; + } + } catch (error) { + console.error(error); + } + }, [getAccessTokenSilently, isAuthenticated]); + + useEffect(() => { + getAccessToken(); + }, [getAccessToken]); + + // ---------------------------------------------------------------------- + + const checkAuthenticated = isAuthenticated ? 'authenticated' : 'unauthenticated'; + + const status = isLoading ? 'loading' : checkAuthenticated; + + const memoizedValue = useMemo( + () => ({ + user: user + ? { + ...user, + id: user?.sub, + accessToken, + displayName: user?.name, + photoURL: user?.picture, + role: user?.role ?? 'admin', + } + : null, + loading: status === 'loading', + authenticated: status === 'authenticated', + unauthenticated: status === 'unauthenticated', + }), + [accessToken, status, user] + ); + + return {children}; +} diff --git a/app/frontend/src/auth/context/auth0/index.ts b/app/frontend/src/auth/context/auth0/index.ts new file mode 100644 index 00000000..99c7072d --- /dev/null +++ b/app/frontend/src/auth/context/auth0/index.ts @@ -0,0 +1 @@ +export * from './auth-provider'; diff --git a/app/frontend/src/auth/context/firebase/action.ts b/app/frontend/src/auth/context/firebase/action.ts new file mode 100644 index 00000000..fdc0b1fb --- /dev/null +++ b/app/frontend/src/auth/context/firebase/action.ts @@ -0,0 +1,112 @@ +'use client'; + +import { doc, setDoc, collection } from 'firebase/firestore'; +import { + signOut as _signOut, + signInWithPopup as _signInWithPopup, + GoogleAuthProvider as _GoogleAuthProvider, + GithubAuthProvider as _GithubAuthProvider, + TwitterAuthProvider as _TwitterAuthProvider, + sendEmailVerification as _sendEmailVerification, + sendPasswordResetEmail as _sendPasswordResetEmail, + signInWithEmailAndPassword as _signInWithEmailAndPassword, + createUserWithEmailAndPassword as _createUserWithEmailAndPassword, +} from 'firebase/auth'; + +import { AUTH, FIRESTORE } from 'src/lib/firebase'; + +// ---------------------------------------------------------------------- + +export type SignInParams = { + email: string; + password: string; +}; + +export type SignUpParams = { + email: string; + password: string; + firstName: string; + lastName: string; +}; + +export type ForgotPasswordParams = { + email: string; +}; + +/** ************************************** + * Sign in + *************************************** */ +export const signInWithPassword = async ({ email, password }: SignInParams): Promise => { + try { + await _signInWithEmailAndPassword(AUTH, email, password); + + const user = AUTH.currentUser; + + if (!user?.emailVerified) { + throw new Error('Email not verified!'); + } + } catch (error) { + console.error('Error during sign in with password:', error); + throw error; + } +}; + +export const signInWithGoogle = async (): Promise => { + const provider = new _GoogleAuthProvider(); + await _signInWithPopup(AUTH, provider); +}; + +export const signInWithGithub = async (): Promise => { + const provider = new _GithubAuthProvider(); + await _signInWithPopup(AUTH, provider); +}; + +export const signInWithTwitter = async (): Promise => { + const provider = new _TwitterAuthProvider(); + await _signInWithPopup(AUTH, provider); +}; + +/** ************************************** + * Sign up + *************************************** */ +export const signUp = async ({ + email, + password, + firstName, + lastName, +}: SignUpParams): Promise => { + try { + const newUser = await _createUserWithEmailAndPassword(AUTH, email, password); + + /* + * (1) If skip emailVerified + * Remove : await _sendEmailVerification(newUser.user); + */ + await _sendEmailVerification(newUser.user); + + const userProfile = doc(collection(FIRESTORE, 'users'), newUser.user?.uid); + + await setDoc(userProfile, { + uid: newUser.user?.uid, + email, + displayName: `${firstName} ${lastName}`, + }); + } catch (error) { + console.error('Error during sign up:', error); + throw error; + } +}; + +/** ************************************** + * Sign out + *************************************** */ +export const signOut = async (): Promise => { + await _signOut(AUTH); +}; + +/** ************************************** + * Reset password + *************************************** */ +export const sendPasswordResetEmail = async ({ email }: ForgotPasswordParams): Promise => { + await _sendPasswordResetEmail(AUTH, email); +}; diff --git a/app/frontend/src/auth/context/firebase/auth-provider.tsx b/app/frontend/src/auth/context/firebase/auth-provider.tsx new file mode 100644 index 00000000..044fd77e --- /dev/null +++ b/app/frontend/src/auth/context/firebase/auth-provider.tsx @@ -0,0 +1,91 @@ +'use client'; + +import { doc, getDoc } from 'firebase/firestore'; +import { onAuthStateChanged } from 'firebase/auth'; +import { useSetState } from 'minimal-shared/hooks'; +import { useMemo, useEffect, useCallback } from 'react'; + +import axios from 'src/lib/axios'; +import { AUTH, FIRESTORE } from 'src/lib/firebase'; + +import { AuthContext } from '../auth-context'; + +import type { AuthState } from '../../types'; + +// ---------------------------------------------------------------------- + +/** + * NOTE: + * We only build demo at basic level. + * Customer will need to do some extra handling yourself if you want to extend the logic and other features... + */ + +type Props = { + children: React.ReactNode; +}; + +export function AuthProvider({ children }: Props) { + const { state, setState } = useSetState({ user: null, loading: true }); + + const checkUserSession = useCallback(async () => { + try { + onAuthStateChanged(AUTH, async (user: AuthState['user']) => { + if (user && user.emailVerified) { + /* + * (1) If skip emailVerified + * Remove the condition (if/else) : user.emailVerified + */ + const userProfile = doc(FIRESTORE, 'users', user.uid); + + const docSnap = await getDoc(userProfile); + + const profileData = docSnap.data(); + + const { accessToken } = user; + + setState({ user: { ...user, ...profileData }, loading: false }); + axios.defaults.headers.common.Authorization = `Bearer ${accessToken}`; + } else { + setState({ user: null, loading: false }); + delete axios.defaults.headers.common.Authorization; + } + }); + } catch (error) { + console.error(error); + setState({ user: null, loading: false }); + } + }, [setState]); + + useEffect(() => { + checkUserSession(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // ---------------------------------------------------------------------- + + const checkAuthenticated = state.user ? 'authenticated' : 'unauthenticated'; + + const status = state.loading ? 'loading' : checkAuthenticated; + + const memoizedValue = useMemo( + () => ({ + user: state.user + ? { + ...state.user, + id: state.user?.uid, + accessToken: state.user?.accessToken, + displayName: state.user?.displayName, + photoURL: state.user?.photoURL, + role: state.user?.role ?? 'admin', + } + : null, + checkUserSession, + loading: status === 'loading', + authenticated: status === 'authenticated', + unauthenticated: status === 'unauthenticated', + }), + [checkUserSession, state.user, status] + ); + + return {children}; +} diff --git a/app/frontend/src/auth/context/firebase/index.ts b/app/frontend/src/auth/context/firebase/index.ts new file mode 100644 index 00000000..1597ff83 --- /dev/null +++ b/app/frontend/src/auth/context/firebase/index.ts @@ -0,0 +1,3 @@ +export * from './action'; + +export * from './auth-provider'; diff --git a/app/frontend/src/auth/context/jwt/action.ts b/app/frontend/src/auth/context/jwt/action.ts new file mode 100644 index 00000000..c3fd1c3d --- /dev/null +++ b/app/frontend/src/auth/context/jwt/action.ts @@ -0,0 +1,96 @@ +'use client'; + +import axios, { endpoints } from 'src/lib/axios'; + +import { setSession } from './utils'; +import { JWT_STORAGE_KEY } from './constant'; + +// ---------------------------------------------------------------------- + +export type SignInParams = { + email: string; + password: string; +}; + +export type SignUpParams = { + email: string; + password: string; + firstName: string; + lastName: string; +}; + +/** ************************************** + * Sign in + *************************************** */ +export const signInWithPassword = async ({ email, password }: SignInParams): Promise => { + try { + const username = email; + const data = new URLSearchParams(); + data.append('username', username); + data.append('password', password); + + const res = await axios.post(endpoints.auth.signIn, data, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + + const { access_token } = res.data; + + if (!access_token) { + throw new Error('Access token not found in response'); + } + + setSession(access_token); + } catch (error: any) { + console.error('Error during sign in:', error); + + // Extract error message from response or use a default message + const errorMessage = error.response?.data?.detail || 'Invalid email or password. Please try again.'; + throw new Error(errorMessage); + } +}; + +/** ************************************** + * Sign up + *************************************** */ +export const signUp = async ({ + email, + password, + firstName, + lastName, +}: SignUpParams): Promise => { + const params = { + email, + password, + firstName, + lastName, + }; + + try { + const res = await axios.post(endpoints.auth.signUp, params); + + const { accessToken } = res.data; + + if (!accessToken) { + throw new Error('Access token not found in response'); + } + + sessionStorage.setItem(JWT_STORAGE_KEY, accessToken); + } catch (error) { + console.error('Error during sign up:', error); + throw error; + } +}; + +/** ************************************** + * Sign out + *************************************** */ +export const signOut = async (): Promise => { + try { + await setSession(null); + } catch (error) { + console.error('Error during sign out:', error); + throw error; + } +}; diff --git a/app/frontend/src/auth/context/jwt/auth-provider.tsx b/app/frontend/src/auth/context/jwt/auth-provider.tsx new file mode 100644 index 00000000..88a6327c --- /dev/null +++ b/app/frontend/src/auth/context/jwt/auth-provider.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { useSetState } from 'minimal-shared/hooks'; +import { useMemo, useEffect, useCallback } from 'react'; + +import axios, { endpoints } from 'src/lib/axios'; + +import { JWT_STORAGE_KEY } from './constant'; +import { AuthContext } from '../auth-context'; +import { setSession, isValidToken } from './utils'; + +import type { AuthState } from '../../types'; + +// ---------------------------------------------------------------------- + +/** + * NOTE: + * We only build demo at basic level. + * Customer will need to do some extra handling yourself if you want to extend the logic and other features... + */ + +type Props = { + children: React.ReactNode; +}; + +export function AuthProvider({ children }: Props) { + const { state, setState } = useSetState({ user: null, loading: true }); + + const checkUserSession = useCallback(async () => { + try { + const accessToken = sessionStorage.getItem(JWT_STORAGE_KEY); + + if (accessToken && isValidToken(accessToken)) { + setSession(accessToken); + + const res = await axios.get(endpoints.auth.me); + + const responseData = res.data; + + setState({ user: { ...responseData, accessToken }, loading: false }); + } else { + setState({ user: null, loading: false }); + } + } catch (error) { + console.error(error); + setState({ user: null, loading: false }); + } + }, [setState]); + + useEffect(() => { + checkUserSession(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // ---------------------------------------------------------------------- + + const checkAuthenticated = state.user ? 'authenticated' : 'unauthenticated'; + + const status = state.loading ? 'loading' : checkAuthenticated; + + const memoizedValue = useMemo( + () => ({ + user: state.user ? { ...state.user, role: state.user?.role ?? 'admin' } : null, + checkUserSession, + loading: status === 'loading', + authenticated: status === 'authenticated', + unauthenticated: status === 'unauthenticated', + }), + [checkUserSession, state.user, status] + ); + + return {children}; +} diff --git a/app/frontend/src/auth/context/jwt/constant.ts b/app/frontend/src/auth/context/jwt/constant.ts new file mode 100644 index 00000000..c9cb8277 --- /dev/null +++ b/app/frontend/src/auth/context/jwt/constant.ts @@ -0,0 +1 @@ +export const JWT_STORAGE_KEY = 'jwt_access_token'; diff --git a/app/frontend/src/auth/context/jwt/index.ts b/app/frontend/src/auth/context/jwt/index.ts new file mode 100644 index 00000000..0ec4a9b7 --- /dev/null +++ b/app/frontend/src/auth/context/jwt/index.ts @@ -0,0 +1,7 @@ +export * from './utils'; + +export * from './action'; + +export * from './constant'; + +export * from './auth-provider'; diff --git a/app/frontend/src/auth/context/jwt/utils.ts b/app/frontend/src/auth/context/jwt/utils.ts new file mode 100644 index 00000000..9c154c0f --- /dev/null +++ b/app/frontend/src/auth/context/jwt/utils.ts @@ -0,0 +1,94 @@ +import { paths } from 'src/routes/paths'; + +import axios from 'src/lib/axios'; + +import { JWT_STORAGE_KEY } from './constant'; + +// ---------------------------------------------------------------------- + +export function jwtDecode(token: string) { + try { + if (!token) return null; + + const parts = token.split('.'); + if (parts.length < 2) { + throw new Error('Invalid token!'); + } + + const base64Url = parts[1]; + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + const decoded = JSON.parse(atob(base64)); + + return decoded; + } catch (error) { + console.error('Error decoding token:', error); + throw error; + } +} + +// ---------------------------------------------------------------------- + +export function isValidToken(accessToken: string) { + if (!accessToken) { + return false; + } + + try { + const decoded = jwtDecode(accessToken); + + if (!decoded || !('exp' in decoded)) { + return false; + } + + const currentTime = Date.now() / 1000; + + return decoded.exp > currentTime; + } catch (error) { + console.error('Error during token validation:', error); + return false; + } +} + +// ---------------------------------------------------------------------- + +export function tokenExpired(exp: number) { + const currentTime = Date.now(); + const timeLeft = exp * 1000 - currentTime; + + setTimeout(() => { + try { + alert('Token expired!'); + sessionStorage.removeItem(JWT_STORAGE_KEY); + window.location.href = paths.auth.jwt.signIn; + } catch (error) { + console.error('Error during token expiration:', error); + throw error; + } + }, timeLeft); +} + +// ---------------------------------------------------------------------- + +export async function setSession(accessToken: string | null) { + try { + if (accessToken) { + sessionStorage.setItem(JWT_STORAGE_KEY, accessToken); + + axios.defaults.headers.common.Authorization = `Bearer ${accessToken}`; + + const decodedToken = jwtDecode(accessToken); // ~3 days by minimals server + + if (decodedToken && 'exp' in decodedToken) { + tokenExpired(decodedToken.exp); + } else { + throw new Error('Invalid access token!'); + } + } else { + sessionStorage.removeItem(JWT_STORAGE_KEY); + delete axios.defaults.headers.common.Authorization; + } + } catch (error) { + console.error('Error during set session:', error); + throw error; + } +} diff --git a/app/frontend/src/auth/context/supabase/action.tsx b/app/frontend/src/auth/context/supabase/action.tsx new file mode 100644 index 00000000..f7434801 --- /dev/null +++ b/app/frontend/src/auth/context/supabase/action.tsx @@ -0,0 +1,140 @@ +'use client'; + +import type { + AuthError, + AuthResponse, + UserResponse, + AuthTokenResponsePassword, + SignInWithPasswordCredentials, + SignUpWithPasswordCredentials, +} from '@supabase/supabase-js'; + +import { paths } from 'src/routes/paths'; + +import { supabase } from 'src/lib/supabase'; + +// ---------------------------------------------------------------------- + +export type SignInParams = { + email: string; + password: string; + options?: SignInWithPasswordCredentials['options']; +}; + +export type SignUpParams = { + email: string; + password: string; + firstName: string; + lastName: string; + options?: SignUpWithPasswordCredentials['options']; +}; + +export type ResetPasswordParams = { + email: string; + options?: { + redirectTo?: string; + captchaToken?: string; + }; +}; + +export type UpdatePasswordParams = { + password: string; + options?: { + emailRedirectTo?: string | undefined; + }; +}; + +/** ************************************** + * Sign in + *************************************** */ +export const signInWithPassword = async ({ + email, + password, +}: SignInParams): Promise => { + const { data, error } = await supabase.auth.signInWithPassword({ email, password }); + + if (error) { + console.error(error); + throw error; + } + + return { data, error }; +}; + +/** ************************************** + * Sign up + *************************************** */ +export const signUp = async ({ + email, + password, + firstName, + lastName, +}: SignUpParams): Promise => { + const { data, error } = await supabase.auth.signUp({ + email, + password, + options: { + emailRedirectTo: `${window.location.origin}${paths.dashboard.root}`, + data: { display_name: `${firstName} ${lastName}` }, + }, + }); + + if (error) { + console.error(error); + throw error; + } + + if (!data?.user?.identities?.length) { + throw new Error('This user already exists'); + } + + return { data, error }; +}; + +/** ************************************** + * Sign out + *************************************** */ +export const signOut = async (): Promise<{ + error: AuthError | null; +}> => { + const { error } = await supabase.auth.signOut(); + + if (error) { + console.error(error); + throw error; + } + + return { error }; +}; + +/** ************************************** + * Reset password + *************************************** */ +export const resetPassword = async ({ + email, +}: ResetPasswordParams): Promise<{ data: {}; error: null } | { data: null; error: AuthError }> => { + const { data, error } = await supabase.auth.resetPasswordForEmail(email, { + redirectTo: `${window.location.origin}${paths.auth.supabase.updatePassword}`, + }); + + if (error) { + console.error(error); + throw error; + } + + return { data, error }; +}; + +/** ************************************** + * Update password + *************************************** */ +export const updatePassword = async ({ password }: UpdatePasswordParams): Promise => { + const { data, error } = await supabase.auth.updateUser({ password }); + + if (error) { + console.error(error); + throw error; + } + + return { data, error }; +}; diff --git a/app/frontend/src/auth/context/supabase/auth-provider.tsx b/app/frontend/src/auth/context/supabase/auth-provider.tsx new file mode 100644 index 00000000..ae4fc565 --- /dev/null +++ b/app/frontend/src/auth/context/supabase/auth-provider.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { useSetState } from 'minimal-shared/hooks'; +import { useMemo, useEffect, useCallback } from 'react'; + +import axios from 'src/lib/axios'; +import { supabase } from 'src/lib/supabase'; + +import { AuthContext } from '../auth-context'; + +import type { AuthState } from '../../types'; + +// ---------------------------------------------------------------------- + +/** + * NOTE: + * We only build demo at basic level. + * Customer will need to do some extra handling yourself if you want to extend the logic and other features... + */ + +type Props = { + children: React.ReactNode; +}; + +export function AuthProvider({ children }: Props) { + const { state, setState } = useSetState({ user: null, loading: true }); + + const checkUserSession = useCallback(async () => { + try { + const { + data: { session }, + error, + } = await supabase.auth.getSession(); + + if (error) { + setState({ user: null, loading: false }); + console.error(error); + throw error; + } + + if (session) { + const accessToken = session?.access_token; + + setState({ user: { ...session, ...session?.user }, loading: false }); + axios.defaults.headers.common.Authorization = `Bearer ${accessToken}`; + } else { + setState({ user: null, loading: false }); + delete axios.defaults.headers.common.Authorization; + } + } catch (error) { + console.error(error); + setState({ user: null, loading: false }); + } + }, [setState]); + + useEffect(() => { + checkUserSession(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // ---------------------------------------------------------------------- + + const checkAuthenticated = state.user ? 'authenticated' : 'unauthenticated'; + + const status = state.loading ? 'loading' : checkAuthenticated; + + const memoizedValue = useMemo( + () => ({ + user: state.user + ? { + ...state.user, + id: state.user?.id, + accessToken: state.user?.access_token, + displayName: `${state.user?.user_metadata.display_name}`, + role: state.user?.role ?? 'admin', + } + : null, + checkUserSession, + loading: status === 'loading', + authenticated: status === 'authenticated', + unauthenticated: status === 'unauthenticated', + }), + [checkUserSession, state.user, status] + ); + + return {children}; +} diff --git a/app/frontend/src/auth/context/supabase/index.ts b/app/frontend/src/auth/context/supabase/index.ts new file mode 100644 index 00000000..1597ff83 --- /dev/null +++ b/app/frontend/src/auth/context/supabase/index.ts @@ -0,0 +1,3 @@ +export * from './action'; + +export * from './auth-provider'; diff --git a/app/frontend/src/auth/guard/auth-guard.tsx b/app/frontend/src/auth/guard/auth-guard.tsx new file mode 100644 index 00000000..7a7e3da4 --- /dev/null +++ b/app/frontend/src/auth/guard/auth-guard.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +import { paths } from 'src/routes/paths'; +import { useRouter, usePathname } from 'src/routes/hooks'; + +import { CONFIG } from 'src/global-config'; + +import { SplashScreen } from 'src/components/loading-screen'; + +import { useAuthContext } from '../hooks'; + +// ---------------------------------------------------------------------- + +type AuthGuardProps = { + children: React.ReactNode; +}; + +const signInPaths = { + jwt: paths.auth.jwt.signIn, + auth0: paths.auth.auth0.signIn, + amplify: paths.auth.amplify.signIn, + firebase: paths.auth.firebase.signIn, + supabase: paths.auth.supabase.signIn, +}; + +export function AuthGuard({ children }: AuthGuardProps) { + const router = useRouter(); + const pathname = usePathname(); + + const { authenticated, loading } = useAuthContext(); + + const [isChecking, setIsChecking] = useState(true); + + const createRedirectPath = (currentPath: string) => { + const queryString = new URLSearchParams({ returnTo: pathname }).toString(); + return `${currentPath}?${queryString}`; + }; + + const checkPermissions = async (): Promise => { + if (loading) { + return; + } + + if (!authenticated) { + const { method } = CONFIG.auth; + + const signInPath = signInPaths[method]; + const redirectPath = createRedirectPath(signInPath); + + router.replace(redirectPath); + + return; + } + + setIsChecking(false); + }; + + useEffect(() => { + checkPermissions(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [authenticated, loading]); + + if (isChecking) { + return ; + } + + return <>{children}; +} diff --git a/app/frontend/src/auth/guard/guest-guard.tsx b/app/frontend/src/auth/guard/guest-guard.tsx new file mode 100644 index 00000000..e4692662 --- /dev/null +++ b/app/frontend/src/auth/guard/guest-guard.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +import { useSearchParams } from 'src/routes/hooks'; + +import { CONFIG } from 'src/global-config'; + +import { SplashScreen } from 'src/components/loading-screen'; + +import { useAuthContext } from '../hooks'; + +// ---------------------------------------------------------------------- + +type GuestGuardProps = { + children: React.ReactNode; +}; + +export function GuestGuard({ children }: GuestGuardProps) { + const { loading, authenticated } = useAuthContext(); + + const searchParams = useSearchParams(); + const returnTo = searchParams.get('returnTo') || CONFIG.auth.redirectPath; + + const [isChecking, setIsChecking] = useState(true); + + const checkPermissions = async (): Promise => { + if (loading) { + return; + } + + if (authenticated) { + // Redirect authenticated users to the returnTo path + // Using `window.location.href` instead of `router.replace` to avoid unnecessary re-rendering + // that might be caused by the AuthGuard component + window.location.href = returnTo; + return; + } + + setIsChecking(false); + }; + + useEffect(() => { + checkPermissions(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [authenticated, loading]); + + if (isChecking) { + return ; + } + + return <>{children}; +} diff --git a/app/frontend/src/auth/guard/index.ts b/app/frontend/src/auth/guard/index.ts new file mode 100644 index 00000000..a85224fb --- /dev/null +++ b/app/frontend/src/auth/guard/index.ts @@ -0,0 +1,5 @@ +export * from './auth-guard'; + +export * from './guest-guard'; + +export * from './role-based-guard'; diff --git a/app/frontend/src/auth/guard/role-based-guard.tsx b/app/frontend/src/auth/guard/role-based-guard.tsx new file mode 100644 index 00000000..c98ecf4b --- /dev/null +++ b/app/frontend/src/auth/guard/role-based-guard.tsx @@ -0,0 +1,57 @@ +'use client'; + +import type { Theme, SxProps } from '@mui/material/styles'; + +import { m } from 'framer-motion'; + +import Container from '@mui/material/Container'; +import Typography from '@mui/material/Typography'; + +import { ForbiddenIllustration } from 'src/assets/illustrations'; + +import { varBounce, MotionContainer } from 'src/components/animate'; + +// ---------------------------------------------------------------------- + +export type RoleBasedGuardProp = { + sx?: SxProps; + currentRole: string; + hasContent?: boolean; + acceptRoles: string[]; + children: React.ReactNode; +}; + +export function RoleBasedGuard({ + sx, + children, + hasContent, + currentRole, + acceptRoles, +}: RoleBasedGuardProp) { + if (typeof acceptRoles !== 'undefined' && !acceptRoles.includes(currentRole)) { + return hasContent ? ( + + + + Permission denied + + + + + + You do not have permission to access this page. + + + + + + + + ) : null; + } + + return <> {children} ; +} diff --git a/app/frontend/src/auth/hooks/index.ts b/app/frontend/src/auth/hooks/index.ts new file mode 100644 index 00000000..4555a5eb --- /dev/null +++ b/app/frontend/src/auth/hooks/index.ts @@ -0,0 +1,2 @@ +export { useMockedUser } from './use-mocked-user'; +export { useAuthContext } from './use-auth-context'; diff --git a/app/frontend/src/auth/hooks/use-auth-context.ts b/app/frontend/src/auth/hooks/use-auth-context.ts new file mode 100644 index 00000000..91433bbf --- /dev/null +++ b/app/frontend/src/auth/hooks/use-auth-context.ts @@ -0,0 +1,17 @@ +'use client'; + +import { useContext } from 'react'; + +import { AuthContext } from '../context/auth-context'; + +// ---------------------------------------------------------------------- + +export function useAuthContext() { + const context = useContext(AuthContext); + + if (!context) { + throw new Error('useAuthContext: Context must be used inside AuthProvider'); + } + + return context; +} diff --git a/app/frontend/src/auth/hooks/use-mocked-user.ts b/app/frontend/src/auth/hooks/use-mocked-user.ts new file mode 100644 index 00000000..64fe9425 --- /dev/null +++ b/app/frontend/src/auth/hooks/use-mocked-user.ts @@ -0,0 +1,33 @@ +import { _mock } from 'src/_mock'; + +// To get the user from the , you can use + +// Change: +// import { useMockedUser } from 'src/auth/hooks'; +// const { user } = useMockedUser(); + +// To: +// import { useAuthContext } from 'src/auth/hooks'; +// const { user } = useAuthContext(); + +// ---------------------------------------------------------------------- + +export function useMockedUser() { + const user = { + id: '8864c717-587d-472a-929a-8e5f298024da-0', + displayName: 'Noel Osiro', + email: 'noel.osiro@echovoice.com', + photoURL: _mock.image.avatar(24), + phoneNumber: _mock.phoneNumber(1), + country: _mock.countryNames(1), + address: '90210 Broadway Blvd', + state: 'California', + city: 'San Francisco', + zipCode: '94116', + about: 'AI Engineer, Microsoft AI Innovator', + role: 'admin', + isPublic: true, + }; + + return { user }; +} diff --git a/app/frontend/src/auth/types.ts b/app/frontend/src/auth/types.ts new file mode 100644 index 00000000..f81d79b5 --- /dev/null +++ b/app/frontend/src/auth/types.ts @@ -0,0 +1,14 @@ +export type UserType = Record | null; + +export type AuthState = { + user: UserType; + loading: boolean; +}; + +export type AuthContextValue = { + user: UserType; + loading: boolean; + authenticated: boolean; + unauthenticated: boolean; + checkUserSession?: () => Promise; +}; diff --git a/app/frontend/src/auth/utils/error-message.ts b/app/frontend/src/auth/utils/error-message.ts new file mode 100644 index 00000000..4c423c56 --- /dev/null +++ b/app/frontend/src/auth/utils/error-message.ts @@ -0,0 +1,20 @@ +// ---------------------------------------------------------------------- + +export function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message || error.name || 'An error occurred'; + } + + if (typeof error === 'string') { + return error; + } + + if (typeof error === 'object' && error !== null) { + const errorMessage = (error as { message?: string }).message; + if (typeof errorMessage === 'string') { + return errorMessage; + } + } + + return `Unknown error: ${error}`; +} diff --git a/app/frontend/src/auth/utils/index.ts b/app/frontend/src/auth/utils/index.ts new file mode 100644 index 00000000..595296f1 --- /dev/null +++ b/app/frontend/src/auth/utils/index.ts @@ -0,0 +1 @@ +export * from './error-message'; diff --git a/app/frontend/src/auth/view/amplify/amplify-reset-password-view.tsx b/app/frontend/src/auth/view/amplify/amplify-reset-password-view.tsx new file mode 100644 index 00000000..3c51c36f --- /dev/null +++ b/app/frontend/src/auth/view/amplify/amplify-reset-password-view.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { z as zod } from 'zod'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import Box from '@mui/material/Box'; +import LoadingButton from '@mui/lab/LoadingButton'; + +import { paths } from 'src/routes/paths'; +import { useRouter } from 'src/routes/hooks'; + +import { PasswordIcon } from 'src/assets/icons'; + +import { Form, Field } from 'src/components/hook-form'; + +import { resetPassword } from '../../context/amplify'; +import { FormHead } from '../../components/form-head'; +import { FormReturnLink } from '../../components/form-return-link'; + +// ---------------------------------------------------------------------- + +export type ResetPasswordSchemaType = zod.infer; + +export const ResetPasswordSchema = zod.object({ + email: zod + .string() + .min(1, { message: 'Email is required!' }) + .email({ message: 'Email must be a valid email address!' }), +}); + +// ---------------------------------------------------------------------- + +export function AmplifyResetPasswordView() { + const router = useRouter(); + + const defaultValues: ResetPasswordSchemaType = { + email: '', + }; + + const methods = useForm({ + resolver: zodResolver(ResetPasswordSchema), + defaultValues, + }); + + const { + handleSubmit, + formState: { isSubmitting }, + } = methods; + + const createRedirectPath = (query: string) => { + const queryString = new URLSearchParams({ email: query }).toString(); + return `${paths.auth.amplify.updatePassword}?${queryString}`; + }; + + const onSubmit = handleSubmit(async (data) => { + try { + await resetPassword({ username: data.email }); + + const redirectPath = createRedirectPath(data.email); + + router.push(redirectPath); + } catch (error) { + console.error(error); + } + }); + + const renderForm = () => ( + + + + + Send request + + + ); + + return ( + <> + } + title="Forgot your password?" + description={`Please enter the email address associated with your account and we'll email you a link to reset your password.`} + /> + +
+ {renderForm()} +
+ + + + ); +} diff --git a/app/frontend/src/auth/view/amplify/amplify-sign-in-view.tsx b/app/frontend/src/auth/view/amplify/amplify-sign-in-view.tsx new file mode 100644 index 00000000..e5ce4f0d --- /dev/null +++ b/app/frontend/src/auth/view/amplify/amplify-sign-in-view.tsx @@ -0,0 +1,159 @@ +'use client'; + +import { z as zod } from 'zod'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useBoolean } from 'minimal-shared/hooks'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Alert from '@mui/material/Alert'; +import IconButton from '@mui/material/IconButton'; +import LoadingButton from '@mui/lab/LoadingButton'; +import InputAdornment from '@mui/material/InputAdornment'; + +import { paths } from 'src/routes/paths'; +import { useRouter } from 'src/routes/hooks'; +import { RouterLink } from 'src/routes/components'; + +import { Iconify } from 'src/components/iconify'; +import { Form, Field } from 'src/components/hook-form'; + +import { useAuthContext } from '../../hooks'; +import { getErrorMessage } from '../../utils'; +import { FormHead } from '../../components/form-head'; +import { signInWithPassword } from '../../context/amplify'; + +// ---------------------------------------------------------------------- + +export type SignInSchemaType = zod.infer; + +export const SignInSchema = zod.object({ + email: zod + .string() + .min(1, { message: 'Email is required!' }) + .email({ message: 'Email must be a valid email address!' }), + password: zod + .string() + .min(1, { message: 'Password is required!' }) + .min(6, { message: 'Password must be at least 6 characters!' }), +}); + +// ---------------------------------------------------------------------- + +export function AmplifySignInView() { + const router = useRouter(); + + const showPassword = useBoolean(); + + const { checkUserSession } = useAuthContext(); + + const [errorMessage, setErrorMessage] = useState(null); + + const defaultValues: SignInSchemaType = { + email: '', + password: '', + }; + + const methods = useForm({ + resolver: zodResolver(SignInSchema), + defaultValues, + }); + + const { + handleSubmit, + formState: { isSubmitting }, + } = methods; + + const onSubmit = handleSubmit(async (data) => { + try { + await signInWithPassword({ username: data.email, password: data.password }); + await checkUserSession?.(); + + router.refresh(); + } catch (error) { + console.error(error); + const feedbackMessage = getErrorMessage(error); + setErrorMessage(feedbackMessage); + } + }); + + const renderForm = () => ( + + + + + + Forgot password? + + + + + + + + ), + }, + }} + /> + + + + Sign in + + + ); + + return ( + <> + + {`Don’t have an account? `} + + Get started + + + } + sx={{ textAlign: { xs: 'center', md: 'left' } }} + /> + + {!!errorMessage && ( + + {errorMessage} + + )} + +
+ {renderForm()} +
+ + ); +} diff --git a/app/frontend/src/auth/view/amplify/amplify-sign-up-view.tsx b/app/frontend/src/auth/view/amplify/amplify-sign-up-view.tsx new file mode 100644 index 00000000..96f52883 --- /dev/null +++ b/app/frontend/src/auth/view/amplify/amplify-sign-up-view.tsx @@ -0,0 +1,175 @@ +'use client'; + +import { z as zod } from 'zod'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useBoolean } from 'minimal-shared/hooks'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Alert from '@mui/material/Alert'; +import IconButton from '@mui/material/IconButton'; +import LoadingButton from '@mui/lab/LoadingButton'; +import InputAdornment from '@mui/material/InputAdornment'; + +import { paths } from 'src/routes/paths'; +import { useRouter } from 'src/routes/hooks'; +import { RouterLink } from 'src/routes/components'; + +import { Iconify } from 'src/components/iconify'; +import { Form, Field } from 'src/components/hook-form'; + +import { getErrorMessage } from '../../utils'; +import { signUp } from '../../context/amplify'; +import { FormHead } from '../../components/form-head'; +import { SignUpTerms } from '../../components/sign-up-terms'; + +// ---------------------------------------------------------------------- + +export type SignUpSchemaType = zod.infer; + +export const SignUpSchema = zod.object({ + firstName: zod.string().min(1, { message: 'First name is required!' }), + lastName: zod.string().min(1, { message: 'Last name is required!' }), + email: zod + .string() + .min(1, { message: 'Email is required!' }) + .email({ message: 'Email must be a valid email address!' }), + password: zod + .string() + .min(1, { message: 'Password is required!' }) + .min(6, { message: 'Password must be at least 6 characters!' }), +}); + +// ---------------------------------------------------------------------- + +export function AmplifySignUpView() { + const router = useRouter(); + + const showPassword = useBoolean(); + + const [errorMessage, setErrorMessage] = useState(null); + + const defaultValues: SignUpSchemaType = { + firstName: '', + lastName: '', + email: '', + password: '', + }; + + const methods = useForm({ + resolver: zodResolver(SignUpSchema), + defaultValues, + }); + + const { + handleSubmit, + formState: { isSubmitting }, + } = methods; + + const createRedirectPath = (query: string) => { + const queryString = new URLSearchParams({ email: query }).toString(); + return `${paths.auth.amplify.verify}?${queryString}`; + }; + + const onSubmit = handleSubmit(async (data) => { + try { + await signUp({ + username: data.email, + password: data.password, + firstName: data.firstName, + lastName: data.lastName, + }); + + const redirectPath = createRedirectPath(data.email); + + router.push(redirectPath); + } catch (error) { + console.error(error); + const feedbackMessage = getErrorMessage(error); + setErrorMessage(feedbackMessage); + } + }); + + const renderForm = () => ( + + + + + + + + + + + + + + ), + }, + }} + /> + + + Create account + + + ); + + return ( + <> + + {`Already have an account? `} + + Get started + + + } + sx={{ textAlign: { xs: 'center', md: 'left' } }} + /> + + {!!errorMessage && ( + + {errorMessage} + + )} + +
+ {renderForm()} +
+ + + + ); +} diff --git a/app/frontend/src/auth/view/amplify/amplify-update-password-view.tsx b/app/frontend/src/auth/view/amplify/amplify-update-password-view.tsx new file mode 100644 index 00000000..ce375aa9 --- /dev/null +++ b/app/frontend/src/auth/view/amplify/amplify-update-password-view.tsx @@ -0,0 +1,195 @@ +'use client'; + +import { z as zod } from 'zod'; +import { useCallback } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useBoolean, useCountdownSeconds } from 'minimal-shared/hooks'; + +import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; +import LoadingButton from '@mui/lab/LoadingButton'; +import InputAdornment from '@mui/material/InputAdornment'; + +import { paths } from 'src/routes/paths'; +import { useRouter, useSearchParams } from 'src/routes/hooks'; + +import { SentIcon } from 'src/assets/icons'; + +import { Iconify } from 'src/components/iconify'; +import { Form, Field } from 'src/components/hook-form'; + +import { FormHead } from '../../components/form-head'; +import { FormReturnLink } from '../../components/form-return-link'; +import { FormResendCode } from '../../components/form-resend-code'; +import { resetPassword, updatePassword } from '../../context/amplify'; + +// ---------------------------------------------------------------------- + +export type UpdatePasswordSchemaType = zod.infer; + +export const UpdatePasswordSchema = zod + .object({ + code: zod + .string() + .min(1, { message: 'Code is required!' }) + .min(6, { message: 'Code must be at least 6 characters!' }), + email: zod + .string() + .min(1, { message: 'Email is required!' }) + .email({ message: 'Email must be a valid email address!' }), + password: zod + .string() + .min(1, { message: 'Password is required!' }) + .min(6, { message: 'Password must be at least 6 characters!' }), + confirmPassword: zod.string().min(1, { message: 'Confirm password is required!' }), + }) + .refine((data) => data.password === data.confirmPassword, { + message: 'Passwords do not match!', + path: ['confirmPassword'], + }); + +// ---------------------------------------------------------------------- + +export function AmplifyUpdatePasswordView() { + const router = useRouter(); + + const searchParams = useSearchParams(); + + const email = searchParams.get('email'); + + const showPassword = useBoolean(); + + const countdown = useCountdownSeconds(5); + + const defaultValues: UpdatePasswordSchemaType = { + code: '', + email: email || '', + password: '', + confirmPassword: '', + }; + + const methods = useForm({ + resolver: zodResolver(UpdatePasswordSchema), + defaultValues, + }); + + const { + watch, + handleSubmit, + formState: { isSubmitting }, + } = methods; + + const values = watch(); + + const onSubmit = handleSubmit(async (data) => { + try { + await updatePassword({ + username: data.email, + confirmationCode: data.code, + newPassword: data.password, + }); + + router.push(paths.auth.amplify.signIn); + } catch (error) { + console.error(error); + } + }); + + const handleResendCode = useCallback(async () => { + if (!countdown.isCounting) { + try { + countdown.reset(); + countdown.start(); + + await resetPassword({ username: values.email }); + } catch (error) { + console.error(error); + } + } + }, [countdown, values.email]); + + const renderForm = () => ( + + + + + + + + + + + ), + }, + }} + /> + + + + + + + ), + }, + }} + /> + + + Update password + + + ); + + return ( + <> + } + title="Request sent successfully!" + description={`We've sent a 6-digit confirmation email to your email. \nPlease enter the code in below box to verify your email.`} + /> + +
+ {renderForm()} +
+ + + + + + ); +} diff --git a/app/frontend/src/auth/view/amplify/amplify-verify-view.tsx b/app/frontend/src/auth/view/amplify/amplify-verify-view.tsx new file mode 100644 index 00000000..31263e9d --- /dev/null +++ b/app/frontend/src/auth/view/amplify/amplify-verify-view.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { z as zod } from 'zod'; +import { useCallback } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useCountdownSeconds } from 'minimal-shared/hooks'; + +import Box from '@mui/material/Box'; +import LoadingButton from '@mui/lab/LoadingButton'; + +import { paths } from 'src/routes/paths'; +import { useRouter, useSearchParams } from 'src/routes/hooks'; + +import { EmailInboxIcon } from 'src/assets/icons'; + +import { Form, Field } from 'src/components/hook-form'; + +import { FormHead } from '../../components/form-head'; +import { FormReturnLink } from '../../components/form-return-link'; +import { FormResendCode } from '../../components/form-resend-code'; +import { confirmSignUp, resendSignUpCode } from '../../context/amplify'; + +// ---------------------------------------------------------------------- + +export type VerifySchemaType = zod.infer; + +export const VerifySchema = zod.object({ + code: zod + .string() + .min(1, { message: 'Code is required!' }) + .min(6, { message: 'Code must be at least 6 characters!' }), + email: zod + .string() + .min(1, { message: 'Email is required!' }) + .email({ message: 'Email must be a valid email address!' }), +}); + +// ---------------------------------------------------------------------- + +export function AmplifyVerifyView() { + const router = useRouter(); + + const searchParams = useSearchParams(); + + const email = searchParams.get('email'); + + const countdown = useCountdownSeconds(5); + + const defaultValues: VerifySchemaType = { + code: '', + email: email || '', + }; + + const methods = useForm({ + resolver: zodResolver(VerifySchema), + defaultValues, + }); + + const { + watch, + handleSubmit, + formState: { isSubmitting }, + } = methods; + + const values = watch(); + + const onSubmit = handleSubmit(async (data) => { + try { + await confirmSignUp({ username: data.email, confirmationCode: data.code }); + router.push(paths.auth.amplify.signIn); + } catch (error) { + console.error(error); + } + }); + + const handleResendCode = useCallback(async () => { + if (!countdown.isCounting) { + try { + countdown.reset(); + countdown.start(); + + await resendSignUpCode?.({ username: values.email }); + } catch (error) { + console.error(error); + } + } + }, [countdown, values.email]); + + const renderForm = () => ( + + + + + + + Verify + + + ); + + return ( + <> + } + title="Please check your email!" + description={`We've emailed a 6-digit confirmation code. \nPlease enter the code in the box below to verify your email.`} + /> + +
+ {renderForm()} +
+ + + + + + ); +} diff --git a/app/frontend/src/auth/view/amplify/index.ts b/app/frontend/src/auth/view/amplify/index.ts new file mode 100644 index 00000000..61a7d7bd --- /dev/null +++ b/app/frontend/src/auth/view/amplify/index.ts @@ -0,0 +1,9 @@ +export * from './amplify-verify-view'; + +export * from './amplify-sign-in-view'; + +export * from './amplify-sign-up-view'; + +export * from './amplify-reset-password-view'; + +export * from './amplify-update-password-view'; diff --git a/app/frontend/src/auth/view/auth-demo/centered/centered-reset-password-view.tsx b/app/frontend/src/auth/view/auth-demo/centered/centered-reset-password-view.tsx new file mode 100644 index 00000000..206a849c --- /dev/null +++ b/app/frontend/src/auth/view/auth-demo/centered/centered-reset-password-view.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { z as zod } from 'zod'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import Box from '@mui/material/Box'; +import LoadingButton from '@mui/lab/LoadingButton'; + +import { paths } from 'src/routes/paths'; + +import { PasswordIcon } from 'src/assets/icons'; + +import { Form, Field } from 'src/components/hook-form'; + +import { FormHead } from '../../../components/form-head'; +import { FormReturnLink } from '../../../components/form-return-link'; + +// ---------------------------------------------------------------------- + +export type ResetPasswordSchemaType = zod.infer; + +export const ResetPasswordSchema = zod.object({ + email: zod + .string() + .min(1, { message: 'Email is required!' }) + .email({ message: 'Email must be a valid email address!' }), +}); + +// ---------------------------------------------------------------------- + +export function CenteredResetPasswordView() { + const defaultValues: ResetPasswordSchemaType = { + email: '', + }; + + const methods = useForm({ + resolver: zodResolver(ResetPasswordSchema), + defaultValues, + }); + + const { + handleSubmit, + formState: { isSubmitting }, + } = methods; + + const onSubmit = handleSubmit(async (data) => { + try { + await new Promise((resolve) => setTimeout(resolve, 500)); + console.info('DATA', data); + } catch (error) { + console.error(error); + } + }); + + const renderForm = () => ( + + + + + Send request + + + ); + + return ( + <> + } + title="Forgot your password?" + description={`Please enter the email address associated with your account and we'll email you a link to reset your password.`} + /> + +
+ {renderForm()} +
+ + + + ); +} diff --git a/app/frontend/src/auth/view/auth-demo/centered/centered-sign-in-view.tsx b/app/frontend/src/auth/view/auth-demo/centered/centered-sign-in-view.tsx new file mode 100644 index 00000000..edd75292 --- /dev/null +++ b/app/frontend/src/auth/view/auth-demo/centered/centered-sign-in-view.tsx @@ -0,0 +1,149 @@ +'use client'; + +import { z as zod } from 'zod'; +import { useForm } from 'react-hook-form'; +import { useBoolean } from 'minimal-shared/hooks'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import IconButton from '@mui/material/IconButton'; +import LoadingButton from '@mui/lab/LoadingButton'; +import InputAdornment from '@mui/material/InputAdornment'; + +import { paths } from 'src/routes/paths'; +import { RouterLink } from 'src/routes/components'; + +import { Iconify } from 'src/components/iconify'; +import { Form, Field } from 'src/components/hook-form'; +import { AnimateLogoRotate } from 'src/components/animate'; + +import { FormHead } from '../../../components/form-head'; +import { FormSocials } from '../../../components/form-socials'; +import { FormDivider } from '../../../components/form-divider'; + +// ---------------------------------------------------------------------- + +export type SignInSchemaType = zod.infer; + +export const SignInSchema = zod.object({ + email: zod + .string() + .min(1, { message: 'Email is required!' }) + .email({ message: 'Email must be a valid email address!' }), + password: zod + .string() + .min(1, { message: 'Password is required!' }) + .min(6, { message: 'Password must be at least 6 characters!' }), +}); + +// ---------------------------------------------------------------------- + +export function CenteredSignInView() { + const showPassword = useBoolean(); + + const defaultValues: SignInSchemaType = { + email: '', + password: '', + }; + + const methods = useForm({ + resolver: zodResolver(SignInSchema), + defaultValues, + }); + + const { + handleSubmit, + formState: { isSubmitting }, + } = methods; + + const onSubmit = handleSubmit(async (data) => { + try { + await new Promise((resolve) => setTimeout(resolve, 500)); + console.info('DATA', data); + } catch (error) { + console.error(error); + } + }); + + const renderForm = () => ( + + + + + + Forgot password? + + + + + + + + ), + }, + }} + /> + + + + Sign in + + + ); + + return ( + <> + + + + {`Don’t have an account? `} + + Get started + + + } + /> + +
+ {renderForm()} +
+ + + + {}} + singInWithGithub={() => {}} + signInWithTwitter={() => {}} + /> + + ); +} diff --git a/app/frontend/src/auth/view/auth-demo/centered/centered-sign-up-view.tsx b/app/frontend/src/auth/view/auth-demo/centered/centered-sign-up-view.tsx new file mode 100644 index 00000000..5906b9d0 --- /dev/null +++ b/app/frontend/src/auth/view/auth-demo/centered/centered-sign-up-view.tsx @@ -0,0 +1,167 @@ +'use client'; + +import { z as zod } from 'zod'; +import { useForm } from 'react-hook-form'; +import { useBoolean } from 'minimal-shared/hooks'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import IconButton from '@mui/material/IconButton'; +import LoadingButton from '@mui/lab/LoadingButton'; +import InputAdornment from '@mui/material/InputAdornment'; + +import { paths } from 'src/routes/paths'; +import { RouterLink } from 'src/routes/components'; + +import { Iconify } from 'src/components/iconify'; +import { Form, Field } from 'src/components/hook-form'; +import { AnimateLogoRotate } from 'src/components/animate'; + +import { FormHead } from '../../../components/form-head'; +import { FormSocials } from '../../../components/form-socials'; +import { FormDivider } from '../../../components/form-divider'; +import { SignUpTerms } from '../../../components/sign-up-terms'; + +// ---------------------------------------------------------------------- + +export type SignUpSchemaType = zod.infer; + +export const SignUpSchema = zod.object({ + firstName: zod.string().min(1, { message: 'First name is required!' }), + lastName: zod.string().min(1, { message: 'Last name is required!' }), + email: zod + .string() + .min(1, { message: 'Email is required!' }) + .email({ message: 'Email must be a valid email address!' }), + password: zod + .string() + .min(1, { message: 'Password is required!' }) + .min(6, { message: 'Password must be at least 6 characters!' }), +}); + +// ---------------------------------------------------------------------- + +export function CenteredSignUpView() { + const showPassword = useBoolean(); + + const defaultValues: SignUpSchemaType = { + firstName: '', + lastName: '', + email: '', + password: '', + }; + + const methods = useForm({ + resolver: zodResolver(SignUpSchema), + defaultValues, + }); + + const { + handleSubmit, + formState: { isSubmitting }, + } = methods; + + const onSubmit = handleSubmit(async (data) => { + try { + await new Promise((resolve) => setTimeout(resolve, 500)); + console.info('DATA', data); + } catch (error) { + console.error(error); + } + }); + + const renderForm = () => ( + + + + + + + + + + + + + + ), + }, + }} + /> + + + Create account + + + ); + + return ( + <> + + + + {`Already have an account? `} + + Get started + + + } + /> + +
+ {renderForm()} +
+ + + + + + {}} + singInWithGithub={() => {}} + signInWithTwitter={() => {}} + /> + + ); +} diff --git a/app/frontend/src/auth/view/auth-demo/centered/centered-update-password-view.tsx b/app/frontend/src/auth/view/auth-demo/centered/centered-update-password-view.tsx new file mode 100644 index 00000000..a449a1d2 --- /dev/null +++ b/app/frontend/src/auth/view/auth-demo/centered/centered-update-password-view.tsx @@ -0,0 +1,157 @@ +'use client'; + +import { z as zod } from 'zod'; +import { useForm } from 'react-hook-form'; +import { useBoolean } from 'minimal-shared/hooks'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; +import LoadingButton from '@mui/lab/LoadingButton'; +import InputAdornment from '@mui/material/InputAdornment'; + +import { paths } from 'src/routes/paths'; + +import { SentIcon } from 'src/assets/icons'; + +import { Iconify } from 'src/components/iconify'; +import { Form, Field } from 'src/components/hook-form'; + +import { FormHead } from '../../../components/form-head'; +import { FormResendCode } from '../../../components/form-resend-code'; +import { FormReturnLink } from '../../../components/form-return-link'; + +// ---------------------------------------------------------------------- + +export type UpdatePasswordSchemaType = zod.infer; + +export const UpdatePasswordSchema = zod + .object({ + code: zod + .string() + .min(1, { message: 'Code is required!' }) + .min(6, { message: 'Code must be at least 6 characters!' }), + email: zod + .string() + .min(1, { message: 'Email is required!' }) + .email({ message: 'Email must be a valid email address!' }), + password: zod + .string() + .min(1, { message: 'Password is required!' }) + .min(6, { message: 'Password must be at least 6 characters!' }), + confirmPassword: zod.string().min(1, { message: 'Confirm password is required!' }), + }) + .refine((data) => data.password === data.confirmPassword, { + message: 'Passwords do not match!', + path: ['confirmPassword'], + }); + +// ---------------------------------------------------------------------- + +export function CenteredUpdatePasswordView() { + const showPassword = useBoolean(); + + const defaultValues: UpdatePasswordSchemaType = { + code: '', + email: '', + password: '', + confirmPassword: '', + }; + + const methods = useForm({ + resolver: zodResolver(UpdatePasswordSchema), + defaultValues, + }); + const { + handleSubmit, + formState: { isSubmitting }, + } = methods; + + const onSubmit = handleSubmit(async (data) => { + try { + await new Promise((resolve) => setTimeout(resolve, 500)); + console.info('DATA', data); + } catch (error) { + console.error(error); + } + }); + + const renderForm = () => ( + + + + + + + + + + + ), + }, + }} + /> + + + + + + + ), + }, + }} + /> + + + Update password + + + ); + + return ( + <> + } + title="Request sent successfully!" + description={`We've sent a 6-digit confirmation email to your email. \nPlease enter the code in below box to verify your email.`} + /> + +
+ {renderForm()} +
+ + {}} value={0} disabled={false} /> + + + + ); +} diff --git a/app/frontend/src/auth/view/auth-demo/centered/centered-verify-view.tsx b/app/frontend/src/auth/view/auth-demo/centered/centered-verify-view.tsx new file mode 100644 index 00000000..c271b8a3 --- /dev/null +++ b/app/frontend/src/auth/view/auth-demo/centered/centered-verify-view.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { z as zod } from 'zod'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import Box from '@mui/material/Box'; +import LoadingButton from '@mui/lab/LoadingButton'; + +import { paths } from 'src/routes/paths'; + +import { EmailInboxIcon } from 'src/assets/icons'; + +import { Form, Field } from 'src/components/hook-form'; + +import { FormHead } from '../../../components/form-head'; +import { FormResendCode } from '../../../components/form-resend-code'; +import { FormReturnLink } from '../../../components/form-return-link'; + +// ---------------------------------------------------------------------- + +export type VerifySchemaType = zod.infer; + +export const VerifySchema = zod.object({ + code: zod + .string() + .min(1, { message: 'Code is required!' }) + .min(6, { message: 'Code must be at least 6 characters!' }), + email: zod + .string() + .min(1, { message: 'Email is required!' }) + .email({ message: 'Email must be a valid email address!' }), +}); + +// ---------------------------------------------------------------------- + +export function CenteredVerifyView() { + const defaultValues: VerifySchemaType = { + code: '', + email: '', + }; + + const methods = useForm({ + resolver: zodResolver(VerifySchema), + defaultValues, + }); + + const { + handleSubmit, + formState: { isSubmitting }, + } = methods; + + const onSubmit = handleSubmit(async (data) => { + try { + await new Promise((resolve) => setTimeout(resolve, 500)); + console.info('DATA', data); + } catch (error) { + console.error(error); + } + }); + + const renderForm = () => ( + + + + + + + Verify + + + ); + + return ( + <> + } + title="Please check your email!" + description={`We've emailed a 6-digit confirmation code. \nPlease enter the code in the box below to verify your email.`} + /> + +
+ {renderForm()} +
+ + {}} value={0} disabled={false} /> + + + + ); +} diff --git a/app/frontend/src/auth/view/auth-demo/centered/index.ts b/app/frontend/src/auth/view/auth-demo/centered/index.ts new file mode 100644 index 00000000..65ce0b4c --- /dev/null +++ b/app/frontend/src/auth/view/auth-demo/centered/index.ts @@ -0,0 +1,9 @@ +export * from './centered-verify-view'; + +export * from './centered-sign-in-view'; + +export * from './centered-sign-up-view'; + +export * from './centered-reset-password-view'; + +export * from './centered-update-password-view'; diff --git a/app/frontend/src/auth/view/auth-demo/split/index.ts b/app/frontend/src/auth/view/auth-demo/split/index.ts new file mode 100644 index 00000000..9c5d9145 --- /dev/null +++ b/app/frontend/src/auth/view/auth-demo/split/index.ts @@ -0,0 +1,9 @@ +export * from './split-verify-view'; + +export * from './split-sign-in-view'; + +export * from './split-sign-up-view'; + +export * from './split-reset-password-view'; + +export * from './split-update-password-view'; diff --git a/app/frontend/src/auth/view/auth-demo/split/split-reset-password-view.tsx b/app/frontend/src/auth/view/auth-demo/split/split-reset-password-view.tsx new file mode 100644 index 00000000..682c912c --- /dev/null +++ b/app/frontend/src/auth/view/auth-demo/split/split-reset-password-view.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { z as zod } from 'zod'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import Box from '@mui/material/Box'; +import LoadingButton from '@mui/lab/LoadingButton'; + +import { paths } from 'src/routes/paths'; + +import { PasswordIcon } from 'src/assets/icons'; + +import { Form, Field } from 'src/components/hook-form'; + +import { FormHead } from '../../../components/form-head'; +import { FormReturnLink } from '../../../components/form-return-link'; + +// ---------------------------------------------------------------------- + +export type ResetPasswordSchemaType = zod.infer; + +export const ResetPasswordSchema = zod.object({ + email: zod + .string() + .min(1, { message: 'Email is required!' }) + .email({ message: 'Email must be a valid email address!' }), +}); + +// ---------------------------------------------------------------------- + +export function SplitResetPasswordView() { + const defaultValues: ResetPasswordSchemaType = { + email: '', + }; + + const methods = useForm({ + resolver: zodResolver(ResetPasswordSchema), + defaultValues, + }); + + const { + handleSubmit, + formState: { isSubmitting }, + } = methods; + + const onSubmit = handleSubmit(async (data) => { + try { + await new Promise((resolve) => setTimeout(resolve, 500)); + console.info('DATA', data); + } catch (error) { + console.error(error); + } + }); + + const renderForm = () => ( + + + + + Send request + + + ); + + return ( + <> + } + title="Forgot your password?" + description={`Please enter the email address associated with your account and we'll email you a link to reset your password.`} + /> + +
+ {renderForm()} +
+ + + + ); +} diff --git a/app/frontend/src/auth/view/auth-demo/split/split-sign-in-view.tsx b/app/frontend/src/auth/view/auth-demo/split/split-sign-in-view.tsx new file mode 100644 index 00000000..95112509 --- /dev/null +++ b/app/frontend/src/auth/view/auth-demo/split/split-sign-in-view.tsx @@ -0,0 +1,159 @@ +'use client'; + +import { z as zod } from 'zod'; +import { useForm } from 'react-hook-form'; +import { useBoolean } from 'minimal-shared/hooks'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import IconButton from '@mui/material/IconButton'; +import LoadingButton from '@mui/lab/LoadingButton'; +import InputAdornment from '@mui/material/InputAdornment'; + +import { paths } from 'src/routes/paths'; +import { RouterLink } from 'src/routes/components'; + +import { Iconify } from 'src/components/iconify'; +import { Form, Field } from 'src/components/hook-form'; + +import { FormHead } from '../../../components/form-head'; +import { FormSocials } from '../../../components/form-socials'; +import { FormDivider } from '../../../components/form-divider'; + +// ---------------------------------------------------------------------- + +export type SignInSchemaType = zod.infer; + +export const SignInSchema = zod.object({ + email: zod + .string() + .min(1, { message: 'Email is required!' }) + .email({ message: 'Email must be a valid email address!' }), + password: zod + .string() + .min(1, { message: 'Password is required!' }) + .min(6, { message: 'Password must be at least 6 characters!' }), +}); + +// ---------------------------------------------------------------------- + +export function SplitSignInView() { + const showPassword = useBoolean(); + + const defaultValues: SignInSchemaType = { + email: '', + password: '', + }; + + const methods = useForm({ + resolver: zodResolver(SignInSchema), + defaultValues, + }); + + const { + handleSubmit, + formState: { isSubmitting }, + } = methods; + + const onSubmit = handleSubmit(async (data) => { + try { + await new Promise((resolve) => setTimeout(resolve, 500)); + console.info('DATA', data); + } catch (error) { + console.error(error); + } + }); + + const renderForm = () => ( + + + + + + Forgot password? + + + + + + + + ), + }, + }} + /> + + + + Sign in + + + ); + + return ( + <> + + {`Don’t have an account? `} + + Get started + + + } + sx={{ textAlign: { xs: 'center', md: 'left' } }} + /> + +
+ {renderForm()} +
+ + + + {}} + singInWithGithub={() => {}} + signInWithTwitter={() => {}} + /> + + ); +} diff --git a/app/frontend/src/auth/view/auth-demo/split/split-sign-up-view.tsx b/app/frontend/src/auth/view/auth-demo/split/split-sign-up-view.tsx new file mode 100644 index 00000000..e6521ebf --- /dev/null +++ b/app/frontend/src/auth/view/auth-demo/split/split-sign-up-view.tsx @@ -0,0 +1,155 @@ +'use client'; + +import { z as zod } from 'zod'; +import { useForm } from 'react-hook-form'; +import { useBoolean } from 'minimal-shared/hooks'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import IconButton from '@mui/material/IconButton'; +import LoadingButton from '@mui/lab/LoadingButton'; +import InputAdornment from '@mui/material/InputAdornment'; + +import { paths } from 'src/routes/paths'; +import { RouterLink } from 'src/routes/components'; + +import { Iconify } from 'src/components/iconify'; +import { Form, Field } from 'src/components/hook-form'; + +import { FormHead } from '../../../components/form-head'; +import { FormDivider } from '../../../components/form-divider'; +import { FormSocials } from '../../../components/form-socials'; +import { SignUpTerms } from '../../../components/sign-up-terms'; + +// ---------------------------------------------------------------------- + +export type SignUpSchemaType = zod.infer; + +export const SignUpSchema = zod.object({ + firstName: zod.string().min(1, { message: 'First name is required!' }), + lastName: zod.string().min(1, { message: 'Last name is required!' }), + email: zod + .string() + .min(1, { message: 'Email is required!' }) + .email({ message: 'Email must be a valid email address!' }), + password: zod + .string() + .min(1, { message: 'Password is required!' }) + .min(6, { message: 'Password must be at least 6 characters!' }), +}); + +// ---------------------------------------------------------------------- + +export function SplitSignUpView() { + const showPassword = useBoolean(); + + const defaultValues: SignUpSchemaType = { + firstName: '', + lastName: '', + email: '', + password: '', + }; + + const methods = useForm({ + resolver: zodResolver(SignUpSchema), + defaultValues, + }); + + const { + handleSubmit, + formState: { isSubmitting }, + } = methods; + + const onSubmit = handleSubmit(async (data) => { + try { + await new Promise((resolve) => setTimeout(resolve, 500)); + console.info('DATA', data); + } catch (error) { + console.error(error); + } + }); + + const renderForm = () => ( + + + + + + + + + + + + + + ), + }, + }} + /> + + + Create account + + + ); + + return ( + <> + + {`Already have an account? `} + + Get started + + + } + sx={{ textAlign: { xs: 'center', md: 'left' } }} + /> + +
+ {renderForm()} +
+ + + + + + {}} + singInWithGithub={() => {}} + signInWithTwitter={() => {}} + /> + + ); +} diff --git a/app/frontend/src/auth/view/auth-demo/split/split-update-password-view.tsx b/app/frontend/src/auth/view/auth-demo/split/split-update-password-view.tsx new file mode 100644 index 00000000..bf908024 --- /dev/null +++ b/app/frontend/src/auth/view/auth-demo/split/split-update-password-view.tsx @@ -0,0 +1,158 @@ +'use client'; + +import { z as zod } from 'zod'; +import { useForm } from 'react-hook-form'; +import { useBoolean } from 'minimal-shared/hooks'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; +import LoadingButton from '@mui/lab/LoadingButton'; +import InputAdornment from '@mui/material/InputAdornment'; + +import { paths } from 'src/routes/paths'; + +import { SentIcon } from 'src/assets/icons'; + +import { Iconify } from 'src/components/iconify'; +import { Form, Field } from 'src/components/hook-form'; + +import { FormHead } from '../../../components/form-head'; +import { FormResendCode } from '../../../components/form-resend-code'; +import { FormReturnLink } from '../../../components/form-return-link'; + +// ---------------------------------------------------------------------- + +export type UpdatePasswordSchemaType = zod.infer; + +export const UpdatePasswordSchema = zod + .object({ + code: zod + .string() + .min(1, { message: 'Code is required!' }) + .min(6, { message: 'Code must be at least 6 characters!' }), + email: zod + .string() + .min(1, { message: 'Email is required!' }) + .email({ message: 'Email must be a valid email address!' }), + password: zod + .string() + .min(1, { message: 'Password is required!' }) + .min(6, { message: 'Password must be at least 6 characters!' }), + confirmPassword: zod.string().min(1, { message: 'Confirm password is required!' }), + }) + .refine((data) => data.password === data.confirmPassword, { + message: 'Passwords do not match!', + path: ['confirmPassword'], + }); + +// ---------------------------------------------------------------------- + +export function SplitUpdatePasswordView() { + const showPassword = useBoolean(); + + const defaultValues: UpdatePasswordSchemaType = { + code: '', + email: '', + password: '', + confirmPassword: '', + }; + + const methods = useForm({ + resolver: zodResolver(UpdatePasswordSchema), + defaultValues, + }); + + const { + handleSubmit, + formState: { isSubmitting }, + } = methods; + + const onSubmit = handleSubmit(async (data) => { + try { + await new Promise((resolve) => setTimeout(resolve, 500)); + console.info('DATA', data); + } catch (error) { + console.error(error); + } + }); + + const renderForm = () => ( + + + + + + + + + + + ), + }, + }} + /> + + + + + + + ), + }, + }} + /> + + + Update password + + + ); + + return ( + <> + } + title="Request sent successfully!" + description={`We've sent a 6-digit confirmation email to your email. \nPlease enter the code in below box to verify your email.`} + /> + +
+ {renderForm()} +
+ + {}} value={0} disabled={false} /> + + + + ); +} diff --git a/app/frontend/src/auth/view/auth-demo/split/split-verify-view.tsx b/app/frontend/src/auth/view/auth-demo/split/split-verify-view.tsx new file mode 100644 index 00000000..b9553a3e --- /dev/null +++ b/app/frontend/src/auth/view/auth-demo/split/split-verify-view.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { z as zod } from 'zod'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import Box from '@mui/material/Box'; +import LoadingButton from '@mui/lab/LoadingButton'; + +import { paths } from 'src/routes/paths'; + +import { EmailInboxIcon } from 'src/assets/icons'; + +import { Form, Field } from 'src/components/hook-form'; + +import { FormHead } from '../../../components/form-head'; +import { FormReturnLink } from '../../../components/form-return-link'; +import { FormResendCode } from '../../../components/form-resend-code'; + +// ---------------------------------------------------------------------- + +export type VerifySchemaType = zod.infer; + +export const VerifySchema = zod.object({ + code: zod + .string() + .min(1, { message: 'Code is required!' }) + .min(6, { message: 'Code must be at least 6 characters!' }), + email: zod + .string() + .min(1, { message: 'Email is required!' }) + .email({ message: 'Email must be a valid email address!' }), +}); + +// ---------------------------------------------------------------------- + +export function SplitVerifyView() { + const defaultValues: VerifySchemaType = { + code: '', + email: '', + }; + + const methods = useForm({ + resolver: zodResolver(VerifySchema), + defaultValues, + }); + + const { + handleSubmit, + formState: { isSubmitting }, + } = methods; + + const onSubmit = handleSubmit(async (data) => { + try { + await new Promise((resolve) => setTimeout(resolve, 500)); + console.info('DATA', data); + } catch (error) { + console.error(error); + } + }); + + const renderForm = () => ( + + + + + + + Verify + + + ); + + return ( + <> + } + title="Please check your email!" + description={`We've emailed a 6-digit confirmation code. \nPlease enter the code in the box below to verify your email.`} + /> + +
+ {renderForm()} +
+ + {}} value={0} disabled={false} /> + + + + ); +} diff --git a/app/frontend/src/auth/view/auth0/auth0-sign-in-view.tsx b/app/frontend/src/auth/view/auth0/auth0-sign-in-view.tsx new file mode 100644 index 00000000..5e58bbcc --- /dev/null +++ b/app/frontend/src/auth/view/auth0/auth0-sign-in-view.tsx @@ -0,0 +1,101 @@ +'use client'; + +import { useCallback } from 'react'; +import { useAuth0 } from '@auth0/auth0-react'; + +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Divider from '@mui/material/Divider'; +import Typography from '@mui/material/Typography'; + +import { useSearchParams } from 'src/routes/hooks'; + +import { CONFIG } from 'src/global-config'; + +// ---------------------------------------------------------------------- + +export function Auth0SignInView() { + const { loginWithPopup, loginWithRedirect } = useAuth0(); + + const searchParams = useSearchParams(); + + const returnTo = searchParams.get('returnTo'); + + const handleSignInWithPopup = useCallback(async () => { + try { + await loginWithPopup(); + } catch (error) { + console.error(error); + } + }, [loginWithPopup]); + + const handleSignUpWithPopup = useCallback(async () => { + try { + await loginWithPopup({ authorizationParams: { screen_hint: 'signup' } }); + } catch (error) { + console.error(error); + } + }, [loginWithPopup]); + + const handleSignInWithRedirect = useCallback(async () => { + try { + await loginWithRedirect({ appState: { returnTo: returnTo || CONFIG.auth.redirectPath } }); + } catch (error) { + console.error(error); + } + }, [loginWithRedirect, returnTo]); + + const handleSignUpWithRedirect = useCallback(async () => { + try { + await loginWithRedirect({ + appState: { returnTo: returnTo || CONFIG.auth.redirectPath }, + authorizationParams: { screen_hint: 'signup' }, + }); + } catch (error) { + console.error(error); + } + }, [loginWithRedirect, returnTo]); + + return ( + + + Sign in to your account + + + + + + + + + + + + ); +} diff --git a/app/frontend/src/auth/view/auth0/index.ts b/app/frontend/src/auth/view/auth0/index.ts new file mode 100644 index 00000000..2c9bb767 --- /dev/null +++ b/app/frontend/src/auth/view/auth0/index.ts @@ -0,0 +1 @@ +export * from './auth0-sign-in-view'; diff --git a/app/frontend/src/auth/view/firebase/firebase-reset-password-view.tsx b/app/frontend/src/auth/view/firebase/firebase-reset-password-view.tsx new file mode 100644 index 00000000..85d54a05 --- /dev/null +++ b/app/frontend/src/auth/view/firebase/firebase-reset-password-view.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { z as zod } from 'zod'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import Box from '@mui/material/Box'; +import LoadingButton from '@mui/lab/LoadingButton'; + +import { paths } from 'src/routes/paths'; +import { useRouter } from 'src/routes/hooks'; + +import { PasswordIcon } from 'src/assets/icons'; + +import { Form, Field } from 'src/components/hook-form'; + +import { FormHead } from '../../components/form-head'; +import { sendPasswordResetEmail } from '../../context/firebase'; +import { FormReturnLink } from '../../components/form-return-link'; + +// ---------------------------------------------------------------------- + +export type ResetPasswordSchemaType = zod.infer; + +export const ResetPasswordSchema = zod.object({ + email: zod + .string() + .min(1, { message: 'Email is required!' }) + .email({ message: 'Email must be a valid email address!' }), +}); + +// ---------------------------------------------------------------------- + +export function FirebaseResetPasswordView() { + const router = useRouter(); + + const defaultValues: ResetPasswordSchemaType = { + email: '', + }; + + const methods = useForm({ + resolver: zodResolver(ResetPasswordSchema), + defaultValues, + }); + + const { + handleSubmit, + formState: { isSubmitting }, + } = methods; + + const createRedirectPath = (query: string) => { + const queryString = new URLSearchParams({ email: query }).toString(); + return `${paths.auth.firebase.verify}?${queryString}`; + }; + + const onSubmit = handleSubmit(async (data) => { + try { + await sendPasswordResetEmail({ email: data.email }); + + const redirectPath = createRedirectPath(data.email); + + router.push(redirectPath); + } catch (error) { + console.error(error); + } + }); + + const renderForm = () => ( + + + + + Send request + + + ); + + return ( + <> + } + title="Forgot your password?" + description={`Please enter the email address associated with your account and we'll email you a link to reset your password.`} + /> + +
+ {renderForm()} +
+ + + + ); +} diff --git a/app/frontend/src/auth/view/firebase/firebase-sign-in-view.tsx b/app/frontend/src/auth/view/firebase/firebase-sign-in-view.tsx new file mode 100644 index 00000000..21094700 --- /dev/null +++ b/app/frontend/src/auth/view/firebase/firebase-sign-in-view.tsx @@ -0,0 +1,198 @@ +'use client'; + +import { z as zod } from 'zod'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useBoolean } from 'minimal-shared/hooks'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Alert from '@mui/material/Alert'; +import IconButton from '@mui/material/IconButton'; +import LoadingButton from '@mui/lab/LoadingButton'; +import InputAdornment from '@mui/material/InputAdornment'; + +import { paths } from 'src/routes/paths'; +import { useRouter } from 'src/routes/hooks'; +import { RouterLink } from 'src/routes/components'; + +import { Iconify } from 'src/components/iconify'; +import { Form, Field } from 'src/components/hook-form'; + +import { useAuthContext } from '../../hooks'; +import { getErrorMessage } from '../../utils'; +import { FormHead } from '../../components/form-head'; +import { FormDivider } from '../../components/form-divider'; +import { FormSocials } from '../../components/form-socials'; +import { + signInWithGoogle, + signInWithGithub, + signInWithTwitter, + signInWithPassword, +} from '../../context/firebase'; + +// ---------------------------------------------------------------------- + +export type SignInSchemaType = zod.infer; + +export const SignInSchema = zod.object({ + email: zod + .string() + .min(1, { message: 'Email is required!' }) + .email({ message: 'Email must be a valid email address!' }), + password: zod + .string() + .min(1, { message: 'Password is required!' }) + .min(6, { message: 'Password must be at least 6 characters!' }), +}); + +// ---------------------------------------------------------------------- + +export function FirebaseSignInView() { + const router = useRouter(); + + const showPassword = useBoolean(); + + const { checkUserSession } = useAuthContext(); + + const [errorMessage, setErrorMessage] = useState(null); + + const defaultValues: SignInSchemaType = { + email: '', + password: '', + }; + + const methods = useForm({ + resolver: zodResolver(SignInSchema), + defaultValues, + }); + + const { + handleSubmit, + formState: { isSubmitting }, + } = methods; + + const onSubmit = handleSubmit(async (data) => { + try { + await signInWithPassword({ email: data.email, password: data.password }); + await checkUserSession?.(); + + router.refresh(); + } catch (error) { + console.error(error); + const feedbackMessage = getErrorMessage(error); + setErrorMessage(feedbackMessage); + } + }); + + const handleSignInWithGoogle = async () => { + try { + await signInWithGoogle(); + } catch (error) { + console.error(error); + } + }; + + const handleSignInWithGithub = async () => { + try { + await signInWithGithub(); + } catch (error) { + console.error(error); + } + }; + + const handleSignInWithTwitter = async () => { + try { + await signInWithTwitter(); + } catch (error) { + console.error(error); + } + }; + + const renderForm = () => ( + + + + + + Forgot password? + + + + + + + + ), + }, + }} + /> + + + + Sign in + + + ); + + return ( + <> + + {`Don’t have an account? `} + + Get started + + + } + sx={{ textAlign: { xs: 'center', md: 'left' } }} + /> + + {!!errorMessage && ( + + {errorMessage} + + )} + +
+ {renderForm()} +
+ + + + + + ); +} diff --git a/app/frontend/src/auth/view/firebase/firebase-sign-up-view.tsx b/app/frontend/src/auth/view/firebase/firebase-sign-up-view.tsx new file mode 100644 index 00000000..3268e36f --- /dev/null +++ b/app/frontend/src/auth/view/firebase/firebase-sign-up-view.tsx @@ -0,0 +1,214 @@ +'use client'; + +import { z as zod } from 'zod'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useBoolean } from 'minimal-shared/hooks'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Alert from '@mui/material/Alert'; +import IconButton from '@mui/material/IconButton'; +import LoadingButton from '@mui/lab/LoadingButton'; +import InputAdornment from '@mui/material/InputAdornment'; + +import { paths } from 'src/routes/paths'; +import { useRouter } from 'src/routes/hooks'; +import { RouterLink } from 'src/routes/components'; + +import { Iconify } from 'src/components/iconify'; +import { Form, Field } from 'src/components/hook-form'; + +import { getErrorMessage } from '../../utils'; +import { FormHead } from '../../components/form-head'; +import { FormDivider } from '../../components/form-divider'; +import { FormSocials } from '../../components/form-socials'; +import { SignUpTerms } from '../../components/sign-up-terms'; +import { + signUp, + signInWithGithub, + signInWithGoogle, + signInWithTwitter, +} from '../../context/firebase'; + +// ---------------------------------------------------------------------- + +export type SignUpSchemaType = zod.infer; + +export const SignUpSchema = zod.object({ + firstName: zod.string().min(1, { message: 'First name is required!' }), + lastName: zod.string().min(1, { message: 'Last name is required!' }), + email: zod + .string() + .min(1, { message: 'Email is required!' }) + .email({ message: 'Email must be a valid email address!' }), + password: zod + .string() + .min(1, { message: 'Password is required!' }) + .min(6, { message: 'Password must be at least 6 characters!' }), +}); + +// ---------------------------------------------------------------------- + +export function FirebaseSignUpView() { + const router = useRouter(); + + const showPassword = useBoolean(); + + const [errorMessage, setErrorMessage] = useState(null); + + const defaultValues: SignUpSchemaType = { + firstName: '', + lastName: '', + email: '', + password: '', + }; + + const methods = useForm({ + resolver: zodResolver(SignUpSchema), + defaultValues, + }); + + const { + handleSubmit, + formState: { isSubmitting }, + } = methods; + + const createRedirectPath = (query: string) => { + const queryString = new URLSearchParams({ email: query }).toString(); + return `${paths.auth.firebase.verify}?${queryString}`; + }; + + const onSubmit = handleSubmit(async (data) => { + try { + await signUp({ + email: data.email, + password: data.password, + firstName: data.firstName, + lastName: data.lastName, + }); + + const redirectPath = createRedirectPath(data.email); + + router.push(redirectPath); + } catch (error) { + console.error(error); + const feedbackMessage = getErrorMessage(error); + setErrorMessage(feedbackMessage); + } + }); + + const handleSignInWithGoogle = async () => { + try { + await signInWithGoogle(); + } catch (error) { + console.error(error); + } + }; + + const handleSignInWithGithub = async () => { + try { + await signInWithGithub(); + } catch (error) { + console.error(error); + } + }; + + const handleSignInWithTwitter = async () => { + try { + await signInWithTwitter(); + } catch (error) { + console.error(error); + } + }; + + const renderForm = () => ( + + + + + + + + + + + + + + ), + }, + }} + /> + + + Create account + + + ); + + return ( + <> + + {`Already have an account? `} + + Get started + + + } + sx={{ textAlign: { xs: 'center', md: 'left' } }} + /> + + {!!errorMessage && ( + + {errorMessage} + + )} + +
+ {renderForm()} +
+ + + + + + + + ); +} diff --git a/app/frontend/src/auth/view/firebase/firebase-verify-view.tsx b/app/frontend/src/auth/view/firebase/firebase-verify-view.tsx new file mode 100644 index 00000000..675c7eda --- /dev/null +++ b/app/frontend/src/auth/view/firebase/firebase-verify-view.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { paths } from 'src/routes/paths'; + +import { EmailInboxIcon } from 'src/assets/icons'; + +import { FormHead } from '../../components/form-head'; +import { FormReturnLink } from '../../components/form-return-link'; + +// ---------------------------------------------------------------------- + +export function FirebaseVerifyView() { + return ( + <> + } + title="Please check your email!" + description={`We've emailed a 6-digit confirmation code. \nPlease enter the code in the box below to verify your email.`} + /> + + + + ); +} diff --git a/app/frontend/src/auth/view/firebase/index.ts b/app/frontend/src/auth/view/firebase/index.ts new file mode 100644 index 00000000..bbce399c --- /dev/null +++ b/app/frontend/src/auth/view/firebase/index.ts @@ -0,0 +1,7 @@ +export * from './firebase-verify-view'; + +export * from './firebase-sign-in-view'; + +export * from './firebase-sign-up-view'; + +export * from './firebase-reset-password-view'; diff --git a/app/frontend/src/auth/view/jwt/index.ts b/app/frontend/src/auth/view/jwt/index.ts new file mode 100644 index 00000000..0e2428a3 --- /dev/null +++ b/app/frontend/src/auth/view/jwt/index.ts @@ -0,0 +1,3 @@ +export * from './jwt-sign-in-view'; + +export * from './jwt-sign-up-view'; diff --git a/app/frontend/src/auth/view/jwt/jwt-sign-in-view.tsx b/app/frontend/src/auth/view/jwt/jwt-sign-in-view.tsx new file mode 100644 index 00000000..25bacc76 --- /dev/null +++ b/app/frontend/src/auth/view/jwt/jwt-sign-in-view.tsx @@ -0,0 +1,159 @@ +'use client'; + +import { z as zod } from 'zod'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useBoolean } from 'minimal-shared/hooks'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Alert from '@mui/material/Alert'; +import IconButton from '@mui/material/IconButton'; +import LoadingButton from '@mui/lab/LoadingButton'; +import InputAdornment from '@mui/material/InputAdornment'; + +import { paths } from 'src/routes/paths'; +import { useRouter } from 'src/routes/hooks'; +import { RouterLink } from 'src/routes/components'; + +import { Iconify } from 'src/components/iconify'; +import { Form, Field } from 'src/components/hook-form'; + +import { useAuthContext } from '../../hooks'; +import { getErrorMessage } from '../../utils'; +import { FormHead } from '../../components/form-head'; +import { signInWithPassword } from '../../context/jwt'; + +// ---------------------------------------------------------------------- + +export type SignInSchemaType = zod.infer; + +export const SignInSchema = zod.object({ + email: zod + .string() + .min(1, { message: 'Email is required!' }) + .email({ message: 'Email must be a valid email address!' }), + password: zod + .string() + .min(1, { message: 'Password is required!' }) + .min(6, { message: 'Password must be at least 6 characters!' }), +}); + +// ---------------------------------------------------------------------- + +export function JwtSignInView() { + const router = useRouter(); + + const showPassword = useBoolean(); + + const { checkUserSession } = useAuthContext(); + + const [errorMessage, setErrorMessage] = useState(null); + + const defaultValues: SignInSchemaType = { + email: '', + password: '', + }; + + const methods = useForm({ + resolver: zodResolver(SignInSchema), + defaultValues, + }); + + const { + handleSubmit, + formState: { isSubmitting }, + } = methods; + + const onSubmit = handleSubmit(async (data) => { + try { + await signInWithPassword({ email: data.email, password: data.password }); + await checkUserSession?.(); + + router.refresh(); + } catch (error) { + console.error(error); + const feedbackMessage = getErrorMessage(error); + setErrorMessage(feedbackMessage); + } + }); + + const renderForm = () => ( + + + + + + Forgot password? + + + + + + + + ), + }, + }} + /> + + + + Sign in + + + ); + + return ( + <> + + {`Don’t have an account? `} + + Get started + + + } + sx={{ textAlign: { xs: 'center', md: 'left' } }} + /> + + {!!errorMessage && ( + + {errorMessage} + + )} + +
+ {renderForm()} +
+ + ); +} diff --git a/app/frontend/src/auth/view/jwt/jwt-sign-up-view.tsx b/app/frontend/src/auth/view/jwt/jwt-sign-up-view.tsx new file mode 100644 index 00000000..6b2191a0 --- /dev/null +++ b/app/frontend/src/auth/view/jwt/jwt-sign-up-view.tsx @@ -0,0 +1,172 @@ +'use client'; + +import { z as zod } from 'zod'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useBoolean } from 'minimal-shared/hooks'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Alert from '@mui/material/Alert'; +import IconButton from '@mui/material/IconButton'; +import LoadingButton from '@mui/lab/LoadingButton'; +import InputAdornment from '@mui/material/InputAdornment'; + +import { paths } from 'src/routes/paths'; +import { useRouter } from 'src/routes/hooks'; +import { RouterLink } from 'src/routes/components'; + +import { Iconify } from 'src/components/iconify'; +import { Form, Field } from 'src/components/hook-form'; + +import { signUp } from '../../context/jwt'; +import { useAuthContext } from '../../hooks'; +import { getErrorMessage } from '../../utils'; +import { FormHead } from '../../components/form-head'; +import { SignUpTerms } from '../../components/sign-up-terms'; + +// ---------------------------------------------------------------------- + +export type SignUpSchemaType = zod.infer; + +export const SignUpSchema = zod.object({ + firstName: zod.string().min(1, { message: 'First name is required!' }), + lastName: zod.string().min(1, { message: 'Last name is required!' }), + email: zod + .string() + .min(1, { message: 'Email is required!' }) + .email({ message: 'Email must be a valid email address!' }), + password: zod + .string() + .min(1, { message: 'Password is required!' }) + .min(6, { message: 'Password must be at least 6 characters!' }), +}); + +// ---------------------------------------------------------------------- + +export function JwtSignUpView() { + const router = useRouter(); + + const showPassword = useBoolean(); + + const { checkUserSession } = useAuthContext(); + + const [errorMessage, setErrorMessage] = useState(null); + + const defaultValues: SignUpSchemaType = { + firstName: 'Hello', + lastName: 'Friend', + email: 'hello@gmail.com', + password: '@2Minimal', + }; + + const methods = useForm({ + resolver: zodResolver(SignUpSchema), + defaultValues, + }); + + const { + handleSubmit, + formState: { isSubmitting }, + } = methods; + + const onSubmit = handleSubmit(async (data) => { + try { + await signUp({ + email: data.email, + password: data.password, + firstName: data.firstName, + lastName: data.lastName, + }); + await checkUserSession?.(); + + router.refresh(); + } catch (error) { + console.error(error); + const feedbackMessage = getErrorMessage(error); + setErrorMessage(feedbackMessage); + } + }); + + const renderForm = () => ( + + + + + + + + + + + + + + ), + }, + }} + /> + + + Create account + + + ); + + return ( + <> + + {`Already have an account? `} + + Get started + + + } + sx={{ textAlign: { xs: 'center', md: 'left' } }} + /> + + {!!errorMessage && ( + + {errorMessage} + + )} + +
+ {renderForm()} +
+ + + + ); +} diff --git a/app/frontend/src/auth/view/supabase/index.ts b/app/frontend/src/auth/view/supabase/index.ts new file mode 100644 index 00000000..0cb4cc24 --- /dev/null +++ b/app/frontend/src/auth/view/supabase/index.ts @@ -0,0 +1,9 @@ +export * from './supabase-verify-view'; + +export * from './supabase-sign-in-view'; + +export * from './supabase-sign-up-view'; + +export * from './supabase-reset-password-view'; + +export * from './supabase-update-password-view'; diff --git a/app/frontend/src/auth/view/supabase/supabase-reset-password-view.tsx b/app/frontend/src/auth/view/supabase/supabase-reset-password-view.tsx new file mode 100644 index 00000000..60699521 --- /dev/null +++ b/app/frontend/src/auth/view/supabase/supabase-reset-password-view.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { z as zod } from 'zod'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import Box from '@mui/material/Box'; +import LoadingButton from '@mui/lab/LoadingButton'; + +import { paths } from 'src/routes/paths'; +import { useRouter } from 'src/routes/hooks'; + +import { PasswordIcon } from 'src/assets/icons'; + +import { Form, Field } from 'src/components/hook-form'; + +import { FormHead } from '../../components/form-head'; +import { resetPassword } from '../../context/supabase'; +import { FormReturnLink } from '../../components/form-return-link'; + +// ---------------------------------------------------------------------- + +export type ResetPasswordSchemaType = zod.infer; + +export const ResetPasswordSchema = zod.object({ + email: zod + .string() + .min(1, { message: 'Email is required!' }) + .email({ message: 'Email must be a valid email address!' }), +}); + +// ---------------------------------------------------------------------- + +export function SupabaseResetPasswordView() { + const router = useRouter(); + + const defaultValues: ResetPasswordSchemaType = { + email: '', + }; + + const methods = useForm({ + resolver: zodResolver(ResetPasswordSchema), + defaultValues, + }); + + const { + handleSubmit, + formState: { isSubmitting }, + } = methods; + + const onSubmit = handleSubmit(async (data) => { + try { + await resetPassword({ email: data.email }); + + router.push(paths.auth.supabase.verify); + } catch (error) { + console.error(error); + } + }); + + const renderForm = () => ( + + + + + Send request + + + ); + + return ( + <> + } + title="Forgot your password?" + description={`Please enter the email address associated with your account and we'll email you a link to reset your password.`} + /> + +
+ {renderForm()} +
+ + + + ); +} diff --git a/app/frontend/src/auth/view/supabase/supabase-sign-in-view.tsx b/app/frontend/src/auth/view/supabase/supabase-sign-in-view.tsx new file mode 100644 index 00000000..1c1b7407 --- /dev/null +++ b/app/frontend/src/auth/view/supabase/supabase-sign-in-view.tsx @@ -0,0 +1,159 @@ +'use client'; + +import { z as zod } from 'zod'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useBoolean } from 'minimal-shared/hooks'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Alert from '@mui/material/Alert'; +import IconButton from '@mui/material/IconButton'; +import LoadingButton from '@mui/lab/LoadingButton'; +import InputAdornment from '@mui/material/InputAdornment'; + +import { paths } from 'src/routes/paths'; +import { useRouter } from 'src/routes/hooks'; +import { RouterLink } from 'src/routes/components'; + +import { Iconify } from 'src/components/iconify'; +import { Form, Field } from 'src/components/hook-form'; + +import { useAuthContext } from '../../hooks'; +import { getErrorMessage } from '../../utils'; +import { FormHead } from '../../components/form-head'; +import { signInWithPassword } from '../../context/supabase'; + +// ---------------------------------------------------------------------- + +export type SignInSchemaType = zod.infer; + +export const SignInSchema = zod.object({ + email: zod + .string() + .min(1, { message: 'Email is required!' }) + .email({ message: 'Email must be a valid email address!' }), + password: zod + .string() + .min(1, { message: 'Password is required!' }) + .min(6, { message: 'Password must be at least 6 characters!' }), +}); + +// ---------------------------------------------------------------------- + +export function SupabaseSignInView() { + const router = useRouter(); + + const showPassword = useBoolean(); + + const { checkUserSession } = useAuthContext(); + + const [errorMessage, setErrorMessage] = useState(null); + + const defaultValues: SignInSchemaType = { + email: '', + password: '', + }; + + const methods = useForm({ + resolver: zodResolver(SignInSchema), + defaultValues, + }); + + const { + handleSubmit, + formState: { isSubmitting }, + } = methods; + + const onSubmit = handleSubmit(async (data) => { + try { + await signInWithPassword({ email: data.email, password: data.password }); + await checkUserSession?.(); + + router.refresh(); + } catch (error) { + console.error(error); + const feedbackMessage = getErrorMessage(error); + setErrorMessage(feedbackMessage); + } + }); + + const renderForm = () => ( + + + + + + Forgot password? + + + + + + + + ), + }, + }} + /> + + + + Sign in + + + ); + + return ( + <> + + {`Don’t have an account? `} + + Get started + + + } + sx={{ textAlign: { xs: 'center', md: 'left' } }} + /> + + {!!errorMessage && ( + + {errorMessage} + + )} + +
+ {renderForm()} +
+ + ); +} diff --git a/app/frontend/src/auth/view/supabase/supabase-sign-up-view.tsx b/app/frontend/src/auth/view/supabase/supabase-sign-up-view.tsx new file mode 100644 index 00000000..491b6ef8 --- /dev/null +++ b/app/frontend/src/auth/view/supabase/supabase-sign-up-view.tsx @@ -0,0 +1,168 @@ +'use client'; + +import { z as zod } from 'zod'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useBoolean } from 'minimal-shared/hooks'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Alert from '@mui/material/Alert'; +import IconButton from '@mui/material/IconButton'; +import LoadingButton from '@mui/lab/LoadingButton'; +import InputAdornment from '@mui/material/InputAdornment'; + +import { paths } from 'src/routes/paths'; +import { useRouter } from 'src/routes/hooks'; +import { RouterLink } from 'src/routes/components'; + +import { Iconify } from 'src/components/iconify'; +import { Form, Field } from 'src/components/hook-form'; + +import { getErrorMessage } from '../../utils'; +import { signUp } from '../../context/supabase'; +import { FormHead } from '../../components/form-head'; +import { SignUpTerms } from '../../components/sign-up-terms'; + +// ---------------------------------------------------------------------- + +export type SignUpSchemaType = zod.infer; + +export const SignUpSchema = zod.object({ + firstName: zod.string().min(1, { message: 'First name is required!' }), + lastName: zod.string().min(1, { message: 'Last name is required!' }), + email: zod + .string() + .min(1, { message: 'Email is required!' }) + .email({ message: 'Email must be a valid email address!' }), + password: zod + .string() + .min(1, { message: 'Password is required!' }) + .min(6, { message: 'Password must be at least 6 characters!' }), +}); + +// ---------------------------------------------------------------------- + +export function SupabaseSignUpView() { + const router = useRouter(); + + const showPassword = useBoolean(); + + const [errorMessage, setErrorMessage] = useState(null); + + const defaultValues: SignUpSchemaType = { + firstName: '', + lastName: '', + email: '', + password: '', + }; + + const methods = useForm({ + resolver: zodResolver(SignUpSchema), + defaultValues, + }); + + const { + handleSubmit, + formState: { isSubmitting }, + } = methods; + + const onSubmit = handleSubmit(async (data) => { + try { + await signUp({ + email: data.email, + password: data.password, + firstName: data.firstName, + lastName: data.lastName, + }); + + router.push(paths.auth.supabase.verify); + } catch (error) { + console.error(error); + const feedbackMessage = getErrorMessage(error); + setErrorMessage(feedbackMessage); + } + }); + + const renderForm = () => ( + + + + + + + + + + + + + + ), + }, + }} + /> + + + Create account + + + ); + + return ( + <> + + {`Already have an account? `} + + Get started + + + } + sx={{ textAlign: { xs: 'center', md: 'left' } }} + /> + + {!!errorMessage && ( + + {errorMessage} + + )} + +
+ {renderForm()} +
+ + + + ); +} diff --git a/app/frontend/src/auth/view/supabase/supabase-update-password-view.tsx b/app/frontend/src/auth/view/supabase/supabase-update-password-view.tsx new file mode 100644 index 00000000..1458638d --- /dev/null +++ b/app/frontend/src/auth/view/supabase/supabase-update-password-view.tsx @@ -0,0 +1,151 @@ +'use client'; + +import { z as zod } from 'zod'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useBoolean } from 'minimal-shared/hooks'; +import { zodResolver } from '@hookform/resolvers/zod'; + +import Box from '@mui/material/Box'; +import Alert from '@mui/material/Alert'; +import IconButton from '@mui/material/IconButton'; +import LoadingButton from '@mui/lab/LoadingButton'; +import InputAdornment from '@mui/material/InputAdornment'; + +import { paths } from 'src/routes/paths'; +import { useRouter } from 'src/routes/hooks'; + +import { NewPasswordIcon } from 'src/assets/icons'; + +import { Iconify } from 'src/components/iconify'; +import { Form, Field } from 'src/components/hook-form'; + +import { getErrorMessage } from '../../utils'; +import { FormHead } from '../../components/form-head'; +import { updatePassword } from '../../context/supabase'; + +// ---------------------------------------------------------------------- + +export type UpdatePasswordSchemaType = zod.infer; + +export const UpdatePasswordSchema = zod + .object({ + password: zod + .string() + .min(1, { message: 'Password is required!' }) + .min(6, { message: 'Password must be at least 6 characters!' }), + confirmPassword: zod.string().min(1, { message: 'Confirm password is required!' }), + }) + .refine((data) => data.password === data.confirmPassword, { + message: 'Passwords do not match!', + path: ['confirmPassword'], + }); + +// ---------------------------------------------------------------------- + +export function SupabaseUpdatePasswordView() { + const router = useRouter(); + + const showPassword = useBoolean(); + + const [errorMessage, setErrorMessage] = useState(null); + + const defaultValues: UpdatePasswordSchemaType = { + password: '', + confirmPassword: '', + }; + + const methods = useForm({ + resolver: zodResolver(UpdatePasswordSchema), + defaultValues, + }); + + const { + handleSubmit, + formState: { isSubmitting }, + } = methods; + + const onSubmit = handleSubmit(async (data) => { + try { + await updatePassword({ password: data.password }); + + router.push(paths.dashboard.root); + } catch (error) { + console.error(error); + const feedbackMessage = getErrorMessage(error); + setErrorMessage(feedbackMessage); + } + }); + + const renderForm = () => ( + + + + + + + ), + }, + }} + /> + + + + + + + ), + }, + }} + /> + + + Update password + + + ); + + return ( + <> + } + title="Update password" + description="Successful updates enable access using the new password." + /> + + {!!errorMessage && ( + + {errorMessage} + + )} + +
+ {renderForm()} +
+ + ); +} diff --git a/app/frontend/src/auth/view/supabase/supabase-verify-view.tsx b/app/frontend/src/auth/view/supabase/supabase-verify-view.tsx new file mode 100644 index 00000000..2e1cf3c9 --- /dev/null +++ b/app/frontend/src/auth/view/supabase/supabase-verify-view.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { paths } from 'src/routes/paths'; + +import { EmailInboxIcon } from 'src/assets/icons'; + +import { FormHead } from '../../components/form-head'; +import { FormReturnLink } from '../../components/form-return-link'; + +// ---------------------------------------------------------------------- + +export function SupabaseVerifyView() { + return ( + <> + } + title="Please check your email!" + description={`We've emailed a 6-digit confirmation code. \nPlease enter the code in the box below to verify your email.`} + /> + + + + ); +} diff --git a/app/frontend/src/components/animate/animate-border.tsx b/app/frontend/src/components/animate/animate-border.tsx new file mode 100644 index 00000000..7690c9f3 --- /dev/null +++ b/app/frontend/src/components/animate/animate-border.tsx @@ -0,0 +1,269 @@ +'use client'; + +import type { BoxProps } from '@mui/material/Box'; +import type { Theme, SxProps, CSSObject } from '@mui/material/styles'; + +import { mergeClasses } from 'minimal-shared/utils'; +import { useRef, useState, useEffect, forwardRef } from 'react'; +import { + m, + useTransform, + useMotionValue, + useAnimationFrame, + useMotionTemplate, +} from 'framer-motion'; + +import Box from '@mui/material/Box'; +import { useTheme } from '@mui/material/styles'; + +import { createClasses } from 'src/theme/create-classes'; + +// ---------------------------------------------------------------------- + +const animateBorderClasses = { + root: createClasses('border__animation__root'), + primaryBorder: createClasses('border__animation__primary'), + secondaryBorder: createClasses('border__animation__secondary'), + svgWrapper: createClasses('border__animation__svg__wrapper'), + movingShape: createClasses('border__animation__moving__shape'), +}; + +type BorderStyleProps = { + width?: string; + size?: number; + sx?: SxProps; +}; + +type AnimateBorderProps = BoxProps & { + duration?: number; + slotProps?: { + primaryBorder?: BorderStyleProps; + secondaryBorder?: BorderStyleProps; + outlineColor?: string | ((theme: Theme) => string); + svgSettings?: { + rx?: string; + ry?: string; + }; + }; +}; + +export function AnimateBorder({ + sx, + children, + duration, + slotProps, + className, + ...other +}: AnimateBorderProps) { + const theme = useTheme(); + + const rootRef = useRef(null); + + const primaryBorderRef = useRef(null); + + const [isHidden, setIsHidden] = useState(false); + + const secondaryBorderStyles = useComputedElementStyles(theme, primaryBorderRef); + + useEffect(() => { + const handleVisibility = () => { + if (rootRef.current) { + const displayStyle = getComputedStyle(rootRef.current).display; + setIsHidden(displayStyle === 'none'); + } + }; + + handleVisibility(); + + window.addEventListener('resize', handleVisibility); + + return () => { + window.removeEventListener('resize', handleVisibility); + }; + }, []); + + const outlineColor = + typeof slotProps?.outlineColor === 'function' + ? slotProps?.outlineColor(theme) + : slotProps?.outlineColor; + + const borderProps = { + duration, + isHidden, + rx: slotProps?.svgSettings?.rx, + ry: slotProps?.svgSettings?.ry, + }; + + const renderPrimaryBorder = () => ( + + ); + + const renderSecondaryBorder = () => + slotProps?.secondaryBorder && ( + + ); + + return ( + + {renderPrimaryBorder()} + {renderSecondaryBorder()} + {children} + + ); +} + +// ---------------------------------------------------------------------- + +type MovingBorderProps = BoxProps<'span'> & { + rx?: string; + ry?: string; + duration?: number; + isHidden?: boolean; + size?: BorderStyleProps['size']; +}; + +const MovingBorder = forwardRef((props, ref) => { + const { sx, rx = '30%', ry = '30%', size, duration = 8, isHidden, ...other } = props; + + const svgRectRef = useRef(null); + const progress = useMotionValue(0); + + const updateAnimationFrame = (time: number) => { + if (!svgRectRef.current) return; + try { + const pathLength = svgRectRef.current.getTotalLength(); + const pixelsPerMs = pathLength / (duration * 1000); + progress.set((time * pixelsPerMs) % pathLength); + } catch { + return; + } + }; + + const calculateTransform = (val: number) => { + if (!svgRectRef.current) return { x: 0, y: 0 }; + try { + const point = svgRectRef.current.getPointAtLength(val); + return point ? { x: point.x, y: point.y } : { x: 0, y: 0 }; + } catch { + return { x: 0, y: 0 }; + } + }; + + useAnimationFrame((time) => (!isHidden ? updateAnimationFrame(time) : undefined)); + + const x = useTransform(progress, (val) => calculateTransform(val).x); + const y = useTransform(progress, (val) => calculateTransform(val).y); + const transform = useMotionTemplate`translateX(${x}px) translateY(${y}px) translateX(-50%) translateY(-50%)`; + + return ( + + + + + + + + ); +}); + +// ---------------------------------------------------------------------- + +function useComputedElementStyles(theme: Theme, ref: React.RefObject) { + const [computedStyles, setComputedStyles] = useState(null); + + const isRtl = theme.direction === 'rtl'; + + useEffect(() => { + if (ref.current) { + const style = getComputedStyle(ref.current); + setComputedStyles({ + paddingTop: style.paddingBottom, + paddingBottom: style.paddingTop, + paddingLeft: isRtl ? style.paddingLeft : style.paddingRight, + paddingRight: isRtl ? style.paddingRight : style.paddingLeft, + borderTopLeftRadius: isRtl ? style.borderBottomLeftRadius : style.borderBottomRightRadius, + borderTopRightRadius: isRtl ? style.borderBottomRightRadius : style.borderBottomLeftRadius, + borderBottomLeftRadius: isRtl ? style.borderTopLeftRadius : style.borderTopRightRadius, + borderBottomRightRadius: isRtl ? style.borderTopRightRadius : style.borderTopLeftRadius, + }); + } + }, [ref, isRtl]); + + return { + padding: `${computedStyles?.paddingTop} ${computedStyles?.paddingRight} ${computedStyles?.paddingBottom} ${computedStyles?.paddingLeft}`, + borderRadius: `${computedStyles?.borderTopLeftRadius} ${computedStyles?.borderTopRightRadius} ${computedStyles?.borderBottomRightRadius} ${computedStyles?.borderBottomLeftRadius}`, + }; +} diff --git a/app/frontend/src/components/animate/animate-count-up.tsx b/app/frontend/src/components/animate/animate-count-up.tsx new file mode 100644 index 00000000..9f46a55c --- /dev/null +++ b/app/frontend/src/components/animate/animate-count-up.tsx @@ -0,0 +1,90 @@ +import type { UseInViewOptions } from 'framer-motion'; +import type { TypographyProps } from '@mui/material/Typography'; + +import { useRef, useEffect } from 'react'; +import { m, animate, useInView, useTransform, useMotionValue } from 'framer-motion'; + +import Typography from '@mui/material/Typography'; + +// ---------------------------------------------------------------------- + +export type AnimateCountUpProps = TypographyProps & { + to: number; + from?: number; + toFixed?: number; + duration?: number; + unit?: 'k' | 'm' | 'b' | string; + once?: UseInViewOptions['once']; + amount?: UseInViewOptions['amount']; +}; + +export function AnimateCountUp({ + to, + sx, + from = 0, + toFixed = 0, + once = true, + duration = 2, + amount = 0.5, + unit: unitProp, + component = 'p', + ...other +}: AnimateCountUpProps) { + const countRef = useRef(null); + + const shortNumber = shortenNumber(to); + + const startCount = useMotionValue(from); + const endCount = shortNumber ? shortNumber.value : to; + + const unit = unitProp ?? shortNumber?.unit; + + const inView = useInView(countRef, { once, amount }); + + const rounded = useTransform(startCount, (latest) => + latest.toFixed(isFloat(latest) ? toFixed : 0) + ); + + useEffect(() => { + if (inView) { + animate(startCount, endCount, { duration }); + } + }, [duration, endCount, inView, startCount]); + + return ( + + {rounded} + {unit} + + ); +} + +// ---------------------------------------------------------------------- + +function isFloat(n: number | string) { + return typeof n === 'number' && !Number.isInteger(n); +} + +function shortenNumber(value: number): { unit: string; value: number } | undefined { + if (value >= 1e9) { + return { unit: 'b', value: value / 1e9 }; + } + if (value >= 1e6) { + return { unit: 'm', value: value / 1e6 }; + } + if (value >= 1e3) { + return { unit: 'k', value: value / 1e3 }; + } + return undefined; +} diff --git a/app/frontend/src/components/animate/animate-logo.tsx b/app/frontend/src/components/animate/animate-logo.tsx new file mode 100644 index 00000000..b01bef58 --- /dev/null +++ b/app/frontend/src/components/animate/animate-logo.tsx @@ -0,0 +1,144 @@ +import type { Theme, SxProps } from '@mui/material/styles'; + +import { m } from 'framer-motion'; +import { forwardRef } from 'react'; +import { varAlpha } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; + +import { Logo } from '../logo'; + +import type { LogoProps } from '../logo'; + +// ---------------------------------------------------------------------- + +export type AnimateLogoProps = React.ComponentProps<'div'> & { + sx?: SxProps; + logo?: React.ReactNode; + slotProps?: { + logo?: LogoProps; + }; +}; + +export const AnimateLogoZoom = forwardRef((props, ref) => { + const { logo, slotProps, sx, ...other } = props; + + return ( + + + {logo ?? ( + + )} + + + + + + + ); +}); + +const LogoZoomRoot = styled('div')(() => ({ + width: 120, + height: 120, + alignItems: 'center', + position: 'relative', + display: 'inline-flex', + justifyContent: 'center', +})); + +const LogoZoomPrimaryOutline = styled(m.span)(({ theme }) => ({ + position: 'absolute', + width: 'calc(100% - 20px)', + height: 'calc(100% - 20px)', + border: `solid 3px ${varAlpha(theme.vars.palette.primary.darkChannel, 0.24)}`, +})); + +const LogoZoomSecondaryOutline = styled(m.span)(({ theme }) => ({ + width: '100%', + height: '100%', + position: 'absolute', + border: `solid 8px ${varAlpha(theme.vars.palette.primary.darkChannel, 0.24)}`, +})); + +// ---------------------------------------------------------------------- + +export const AnimateLogoRotate = forwardRef((props, ref) => { + const { logo, sx, slotProps, ...other } = props; + + return ( + + {logo ?? ( + + )} + + + + ); +}); + +const LogoRotateRoot = styled('div')(() => ({ + width: 96, + height: 96, + alignItems: 'center', + position: 'relative', + display: 'inline-flex', + justifyContent: 'center', +})); + +const LogoRotateBackground = styled(m.span)(({ theme }) => ({ + width: '100%', + height: '100%', + opacity: 0.16, + borderRadius: '50%', + position: 'absolute', + backgroundImage: `linear-gradient(135deg, transparent 50%, ${theme.vars.palette.primary.main} 100%)`, + transition: theme.transitions.create(['opacity'], { + easing: theme.transitions.easing.easeInOut, + duration: theme.transitions.duration.shorter, + }), +})); diff --git a/app/frontend/src/components/animate/animate-text.tsx b/app/frontend/src/components/animate/animate-text.tsx new file mode 100644 index 00000000..aa02b866 --- /dev/null +++ b/app/frontend/src/components/animate/animate-text.tsx @@ -0,0 +1,174 @@ +import type { Theme, SxProps } from '@mui/material/styles'; +import type { TypographyProps } from '@mui/material/Typography'; +import type { Variants, UseInViewOptions } from 'framer-motion'; + +import { useRef, useMemo, useEffect } from 'react'; +import { mergeClasses } from 'minimal-shared/utils'; +import { m, useInView, useAnimation } from 'framer-motion'; + +import { styled } from '@mui/material/styles'; +import Typography from '@mui/material/Typography'; + +import { createClasses } from 'src/theme/create-classes'; + +import { varFade, varContainer } from './variants'; + +// ---------------------------------------------------------------------- + +export const animateTextClasses = { + root: createClasses('animate__text__root'), + lines: createClasses('animate__text__lines'), + line: createClasses('animate__text__line'), + word: createClasses('animate__text__word'), + char: createClasses('animate__text__char'), + space: createClasses('animate__text__space'), + srOnly: 'sr-only', +}; + +const srOnlyStyles: SxProps = { + p: 0, + width: '1px', + height: '1px', + margin: '-1px', + borderWidth: 0, + overflow: 'hidden', + position: 'absolute', + whiteSpace: 'nowrap', + clip: 'rect(0, 0, 0, 0)', +}; + +export type AnimateTextProps = TypographyProps & { + variants?: Variants; + repeatDelayMs?: number; + textContent: string | string[]; + once?: UseInViewOptions['once']; + amount?: UseInViewOptions['amount']; +}; + +export function AnimateText({ + sx, + variants, + className, + textContent, + once = true, + amount = 1 / 3, + component = 'p', + repeatDelayMs = 100, // 1000 = 1s + ...other +}: AnimateTextProps) { + const textRef = useRef(null); + + const animationControls = useAnimation(); + + const textArray = useMemo( + () => (Array.isArray(textContent) ? textContent : [textContent]), + [textContent] + ); + + const isInView = useInView(textRef, { once, amount }); + + useEffect(() => { + let timeout: NodeJS.Timeout; + + const triggerAnimation = () => { + if (repeatDelayMs) { + timeout = setTimeout(async () => { + await animationControls.start('initial'); + animationControls.start('animate'); + }, repeatDelayMs); + } else { + animationControls.start('animate'); + } + }; + + if (isInView) { + triggerAnimation(); + } else { + animationControls.start('initial'); + } + + return () => clearTimeout(timeout); + }, [animationControls, isInView, repeatDelayMs]); + + return ( + + {textArray.join(' ')} + + + {textArray?.map((line, lineIndex) => ( + + {line.split(' ').map((word, wordIndex) => { + const lastWordInline = line.split(' ')[line.split(' ').length - 1]; + + return ( + + {word.split('').map((char, charIndex) => ( + + {char} + + ))} + + {lastWordInline !== word && ( + +   + + )} + + ); + })} + + ))} + + + ); +} + +// ---------------------------------------------------------------------- + +const TextLine = styled('span')``; + +const TextWord = styled('span')``; + +const AnimatedTextContainer = styled(m.span)``; + +const AnimatedTextChar = styled(m.span)``; diff --git a/app/frontend/src/components/animate/features.ts b/app/frontend/src/components/animate/features.ts new file mode 100644 index 00000000..9e51e8f3 --- /dev/null +++ b/app/frontend/src/components/animate/features.ts @@ -0,0 +1,3 @@ +import { domMax } from 'framer-motion'; + +export default domMax; diff --git a/app/frontend/src/components/animate/index.ts b/app/frontend/src/components/animate/index.ts new file mode 100644 index 00000000..ba9c981d --- /dev/null +++ b/app/frontend/src/components/animate/index.ts @@ -0,0 +1,15 @@ +export * from './variants'; + +export * from './animate-text'; + +export * from './animate-logo'; + +export * from './animate-border'; + +export * from './motion-viewport'; + +export * from './scroll-progress'; + +export * from './animate-count-up'; + +export * from './motion-container'; diff --git a/app/frontend/src/components/animate/motion-container.tsx b/app/frontend/src/components/animate/motion-container.tsx new file mode 100644 index 00000000..20be0067 --- /dev/null +++ b/app/frontend/src/components/animate/motion-container.tsx @@ -0,0 +1,36 @@ +import type { MotionProps } from 'framer-motion'; +import type { BoxProps } from '@mui/material/Box'; + +import { m } from 'framer-motion'; +import { forwardRef } from 'react'; + +import Box from '@mui/material/Box'; + +import { varContainer } from './variants'; + +// ---------------------------------------------------------------------- + +export type MotionContainerProps = BoxProps & + MotionProps & { + animate?: boolean; + action?: boolean; + }; + +export const MotionContainer = forwardRef((props, ref) => { + const { animate, action = false, sx, children, ...other } = props; + + return ( + + {children} + + ); +}); diff --git a/app/frontend/src/components/animate/motion-lazy.tsx b/app/frontend/src/components/animate/motion-lazy.tsx new file mode 100644 index 00000000..c535ef66 --- /dev/null +++ b/app/frontend/src/components/animate/motion-lazy.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { LazyMotion } from 'framer-motion'; + +// ---------------------------------------------------------------------- + +export type MotionLazyProps = { + children: React.ReactNode; +}; + +const loadFeaturesAsync = async () => import('./features').then((res) => res.default); + +export function MotionLazy({ children }: MotionLazyProps) { + return ( + + {children} + + ); +} diff --git a/app/frontend/src/components/animate/motion-viewport.tsx b/app/frontend/src/components/animate/motion-viewport.tsx new file mode 100644 index 00000000..7c34a68e --- /dev/null +++ b/app/frontend/src/components/animate/motion-viewport.tsx @@ -0,0 +1,43 @@ +import type { MotionProps } from 'framer-motion'; +import type { BoxProps } from '@mui/material/Box'; + +import { m } from 'framer-motion'; +import { forwardRef } from 'react'; + +import Box from '@mui/material/Box'; +import { useTheme } from '@mui/material/styles'; +import useMediaQuery from '@mui/material/useMediaQuery'; + +import { varContainer } from './variants'; + +// ---------------------------------------------------------------------- + +export type MotionViewportProps = BoxProps & + MotionProps & { + disableAnimate?: boolean; + }; + +export const MotionViewport = forwardRef((props, ref) => { + const { children, viewport, disableAnimate = true, ...other } = props; + + const theme = useTheme(); + const smDown = useMediaQuery(theme.breakpoints.down('sm')); + + const disabled = smDown && disableAnimate; + + const baseProps = disabled + ? {} + : { + component: m.div, + initial: 'initial', + whileInView: 'animate', + variants: varContainer(), + viewport: { once: true, amount: 0.3, ...viewport }, + }; + + return ( + + {children} + + ); +}); diff --git a/app/frontend/src/components/animate/scroll-progress/index.ts b/app/frontend/src/components/animate/scroll-progress/index.ts new file mode 100644 index 00000000..1521b571 --- /dev/null +++ b/app/frontend/src/components/animate/scroll-progress/index.ts @@ -0,0 +1,3 @@ +export * from './scroll-progress'; + +export * from './use-scroll-progress'; diff --git a/app/frontend/src/components/animate/scroll-progress/scroll-progress.tsx b/app/frontend/src/components/animate/scroll-progress/scroll-progress.tsx new file mode 100644 index 00000000..18b408c3 --- /dev/null +++ b/app/frontend/src/components/animate/scroll-progress/scroll-progress.tsx @@ -0,0 +1,138 @@ +import type { BoxProps } from '@mui/material/Box'; +import type { Theme, SxProps } from '@mui/material/styles'; +import type { MotionValue, MotionProps } from 'framer-motion'; + +import { Fragment } from 'react'; +import { mergeClasses } from 'minimal-shared/utils'; +import { m, useSpring, useTransform } from 'framer-motion'; + +import Box from '@mui/material/Box'; +import Portal from '@mui/material/Portal'; +import { styled, useTheme } from '@mui/material/styles'; + +import { createClasses } from 'src/theme/create-classes'; + +// ---------------------------------------------------------------------- + +export const scrollProgressClasses = { + circular: createClasses('scroll__progress__circular'), + linear: createClasses('scroll__progress__linear'), +}; + +type BaseProps = MotionProps & React.ComponentProps<'svg'> & React.ComponentProps<'div'>; + +export interface ScrollProgressProps extends BaseProps { + size?: number; + portal?: boolean; + thickness?: number; + sx?: SxProps; + whenScroll?: 'x' | 'y'; + progress: MotionValue; + variant: 'linear' | 'circular'; + color?: 'inherit' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'; + slotProps?: { + wrapper?: BoxProps; + }; +} + +export function ScrollProgress({ + sx, + size, + portal, + variant, + slotProps, + className, + thickness = 3.6, + whenScroll = 'y', + color = 'primary', + progress: progressProps, + ...other +}: ScrollProgressProps) { + const theme = useTheme(); + + const isRtl = theme.direction === 'rtl'; + + const transformProgress = useTransform(progressProps, [0, -1], [0, 1]); + + const progress = isRtl && whenScroll === 'x' ? transformProgress : progressProps; + + const scaleX = useSpring(progress, { stiffness: 100, damping: 30, restDelta: 0.001 }); + + const progressSize = variant === 'circular' ? (size ?? 64) : (size ?? 3); + + const renderCircular = () => ( + + + + + + ); + + const renderLinear = () => ( + + ); + + const PortalWrapper = portal ? Portal : Fragment; + + return ( + + + {variant === 'circular' ? renderCircular() : renderLinear()} + + + ); +} + +// ---------------------------------------------------------------------- + +const CircularRoot = styled(m.svg)(({ theme }) => ({ + transform: 'rotate(-90deg)', + color: theme.vars.palette.text.primary, + circle: { fill: 'none', strokeDashoffset: 0, stroke: 'currentColor' }, +})); + +const LinearRoot = styled(m.div)(({ theme }) => ({ + top: 0, + left: 0, + right: 0, + transformOrigin: '0%', + backgroundColor: theme.vars.palette.text.primary, +})); diff --git a/app/frontend/src/components/animate/scroll-progress/use-scroll-progress.ts b/app/frontend/src/components/animate/scroll-progress/use-scroll-progress.ts new file mode 100644 index 00000000..d7484e4a --- /dev/null +++ b/app/frontend/src/components/animate/scroll-progress/use-scroll-progress.ts @@ -0,0 +1,33 @@ +'use client'; + +import type { MotionValue } from 'framer-motion'; + +import { useRef, useMemo } from 'react'; +import { useScroll } from 'framer-motion'; + +// ---------------------------------------------------------------------- + +export type UseScrollProgressReturn = { + scrollXProgress: MotionValue; + scrollYProgress: MotionValue; + elementRef: React.RefObject; +}; + +export type UseScrollProgress = 'document' | 'container'; + +export function useScrollProgress(target: UseScrollProgress = 'document'): UseScrollProgressReturn { + const elementRef = useRef(null); + + const options = { container: elementRef }; + + const { scrollYProgress, scrollXProgress } = useScroll( + target === 'container' ? options : undefined + ); + + const memoizedValue = useMemo( + () => ({ elementRef, scrollXProgress, scrollYProgress }), + [elementRef, scrollXProgress, scrollYProgress] + ); + + return memoizedValue; +} diff --git a/app/frontend/src/components/animate/variants/actions.ts b/app/frontend/src/components/animate/variants/actions.ts new file mode 100644 index 00000000..5b84ee27 --- /dev/null +++ b/app/frontend/src/components/animate/variants/actions.ts @@ -0,0 +1,24 @@ +import type { Transition } from 'framer-motion'; + +// ---------------------------------------------------------------------- + +export const varHover = (value = 1.09) => ({ + scale: value, +}); + +export const varTap = (value = 0.9) => ({ + scale: value, +}); + +export const transitionTap = (props?: Transition): Transition => ({ + type: 'spring', + stiffness: 400, + damping: 18, + ...props, +}); + +export const transitionHover = (props?: Transition): Transition => ({ + duration: 0.32, + ease: [0.43, 0.13, 0.23, 0.96], + ...props, +}); diff --git a/app/frontend/src/components/animate/variants/background.ts b/app/frontend/src/components/animate/variants/background.ts new file mode 100644 index 00000000..1d763400 --- /dev/null +++ b/app/frontend/src/components/animate/variants/background.ts @@ -0,0 +1,131 @@ +import type { Variants, Transition, TargetAndTransition } from 'framer-motion'; + +// ---------------------------------------------------------------------- + +type Direction = 'top' | 'bottom' | 'left' | 'right'; + +export const varBgColor = (colors: string[], options?: TargetAndTransition): Variants => ({ + animate: { + background: colors, + ...options, + transition: { + duration: 5, + ease: 'linear', + repeat: Infinity, + repeatType: 'reverse', + ...options?.transition, + }, + }, +}); + +// ---------------------------------------------------------------------- + +export const varBgKenburns = (direction: Direction, options?: TargetAndTransition): Variants => { + const transition: Transition = { + duration: 5, + ease: 'easeOut', + ...options?.transition, + }; + + const variants: Record = { + top: { + animate: { + scale: [1, 1.25], + y: [0, -15], + transformOrigin: ['50% 16%', '50% top'], + ...options, + transition, + }, + }, + bottom: { + animate: { + scale: [1, 1.25], + y: [0, 15], + transformOrigin: ['50% 84%', '50% bottom'], + ...options, + transition, + }, + }, + left: { + animate: { + scale: [1, 1.25], + x: [0, 20], + y: [0, 15], + transformOrigin: ['16% 50%', '0% left'], + ...options, + transition, + }, + }, + right: { + animate: { + scale: [1, 1.25], + x: [0, -20], + y: [0, -15], + transformOrigin: ['84% 50%', '0% right'], + ...options, + transition, + }, + }, + }; + + return variants[direction]; +}; + +// ---------------------------------------------------------------------- + +export const varBgPan = ( + direction: Direction, + colors: string[], + options?: TargetAndTransition +): Variants => { + const gradient = (deg: number) => `linear-gradient(${deg}deg, ${colors.join(', ')})`; + + const transition: Transition = { + duration: 5, + ease: 'linear', + repeat: Infinity, + repeatType: 'reverse', + ...options?.transition, + }; + + const variants: Record = { + top: { + animate: { + backgroundImage: [gradient(0), gradient(0)], + backgroundPosition: ['center 99%', 'center 1%'], + backgroundSize: ['100% 600%', '100% 600%'], + ...options, + transition, + }, + }, + right: { + animate: { + backgroundImage: [gradient(270), gradient(270)], + backgroundPosition: ['1% center', '99% center'], + backgroundSize: ['600% 100%', '600% 100%'], + ...options, + transition, + }, + }, + bottom: { + animate: { + backgroundImage: [gradient(0), gradient(0)], + backgroundPosition: ['center 1%', 'center 99%'], + backgroundSize: ['100% 600%', '100% 600%'], + ...options, + transition, + }, + }, + left: { + animate: { + backgroundPosition: ['99% center', '1% center'], + backgroundImage: [gradient(270), gradient(270)], + backgroundSize: ['600% 100%', '600% 100%'], + ...options, + transition, + }, + }, + }; + + return variants[direction]; +}; diff --git a/app/frontend/src/components/animate/variants/bounce.ts b/app/frontend/src/components/animate/variants/bounce.ts new file mode 100644 index 00000000..f1919b6a --- /dev/null +++ b/app/frontend/src/components/animate/variants/bounce.ts @@ -0,0 +1,116 @@ +import type { Variants, Transition } from 'framer-motion'; + +import { transitionExit, transitionEnter } from './transition'; + +// ---------------------------------------------------------------------- + +type Direction = + | 'in' + | 'inUp' + | 'inDown' + | 'inLeft' + | 'inRight' + | 'out' + | 'outUp' + | 'outDown' + | 'outLeft' + | 'outRight'; + +type Options = { + distance?: number; + transition?: Transition; +}; + +export const varBounce = (direction: Direction, options?: Options): Variants => { + const distance = options?.distance || 720; + + const variants: Record = { + /**** In ****/ + in: { + initial: {}, + animate: { + scale: [0.3, 1.1, 0.9, 1.03, 0.97, 1], + opacity: [0, 1, 1, 1, 1, 1], + transition: transitionEnter(options?.transition), + }, + }, + inUp: { + initial: {}, + animate: { + y: [distance, -24, 12, -4, 0], + scaleY: [4, 0.9, 0.95, 0.985, 1], + opacity: [0, 1, 1, 1, 1], + transition: { ...transitionEnter(options?.transition) }, + }, + }, + inDown: { + initial: {}, + animate: { + y: [-distance, 24, -12, 4, 0], + scaleY: [4, 0.9, 0.95, 0.985, 1], + opacity: [0, 1, 1, 1, 1], + transition: transitionEnter(options?.transition), + }, + }, + inLeft: { + initial: {}, + animate: { + x: [-distance, 24, -12, 4, 0], + scaleX: [3, 1, 0.98, 0.995, 1], + opacity: [0, 1, 1, 1, 1], + transition: transitionEnter(options?.transition), + }, + }, + inRight: { + initial: {}, + animate: { + x: [distance, -24, 12, -4, 0], + scaleX: [3, 1, 0.98, 0.995, 1], + opacity: [0, 1, 1, 1, 1], + transition: transitionEnter(options?.transition), + }, + }, + /**** Out ****/ + out: { + animate: { + scale: [0.9, 1.1, 0.3], + opacity: [1, 1, 0], + transition: transitionExit(options?.transition), + }, + }, + outUp: { + animate: { + y: [-12, 24, -distance], + scaleY: [0.985, 0.9, 3], + opacity: [1, 1, 0], + transition: transitionExit(options?.transition), + }, + }, + outDown: { + animate: { + y: [12, -24, distance], + scaleY: [0.985, 0.9, 3], + opacity: [1, 1, 0], + transition: transitionExit(options?.transition), + }, + }, + outLeft: { + animate: { + x: [0, 24, -distance], + scaleX: [1, 0.9, 2], + opacity: [1, 1, 0], + transition: transitionExit(options?.transition), + }, + }, + outRight: { + animate: { + x: [0, -24, distance], + scaleX: [1, 0.9, 2], + opacity: [1, 1, 0], + transition: transitionExit(options?.transition), + }, + }, + }; + + return variants[direction]; +}; diff --git a/app/frontend/src/components/animate/variants/container.ts b/app/frontend/src/components/animate/variants/container.ts new file mode 100644 index 00000000..83f6f640 --- /dev/null +++ b/app/frontend/src/components/animate/variants/container.ts @@ -0,0 +1,25 @@ +import type { Variants, Transition } from 'framer-motion'; + +// ---------------------------------------------------------------------- + +type Options = { + transitionIn?: Transition; + transitionOut?: Transition; +}; + +export const varContainer = (props?: Options): Variants => ({ + animate: { + transition: { + staggerChildren: 0.05, + delayChildren: 0.05, + ...props?.transitionIn, + }, + }, + exit: { + transition: { + staggerChildren: 0.05, + staggerDirection: -1, + ...props?.transitionOut, + }, + }, +}); diff --git a/app/frontend/src/components/animate/variants/fade.ts b/app/frontend/src/components/animate/variants/fade.ts new file mode 100644 index 00000000..a4273c5b --- /dev/null +++ b/app/frontend/src/components/animate/variants/fade.ts @@ -0,0 +1,102 @@ +import type { Variants, Transition } from 'framer-motion'; + +import { transitionExit, transitionEnter } from './transition'; + +// ---------------------------------------------------------------------- + +type Direction = + | 'in' + | 'inUp' + | 'inDown' + | 'inLeft' + | 'inRight' + | 'out' + | 'outUp' + | 'outDown' + | 'outLeft' + | 'outRight'; + +type Options = { + distance?: number; + transitionIn?: Transition; + transitionOut?: Transition; +}; + +export const varFade = (direction: Direction, options?: Options): Variants => { + const distance = options?.distance || 120; + const transitionIn = options?.transitionIn; + const transitionOut = options?.transitionOut; + + const variants: Record = { + /**** In ****/ + in: { + initial: { opacity: 0 }, + animate: { opacity: 1, transition: transitionEnter }, + exit: { opacity: 0, transition: transitionExit }, + }, + inUp: { + initial: { y: distance, opacity: 0 }, + animate: { y: 0, opacity: 1, transition: transitionEnter(transitionIn) }, + exit: { y: distance, opacity: 0, transition: transitionExit(transitionOut) }, + }, + inDown: { + initial: { y: -distance, opacity: 0 }, + animate: { y: 0, opacity: 1, transition: transitionEnter(transitionIn) }, + exit: { y: -distance, opacity: 0, transition: transitionExit(transitionOut) }, + }, + inLeft: { + initial: { x: -distance, opacity: 0 }, + animate: { x: 0, opacity: 1, transition: transitionEnter(transitionIn) }, + exit: { x: -distance, opacity: 0, transition: transitionExit(transitionOut) }, + }, + inRight: { + initial: { x: distance, opacity: 0 }, + animate: { x: 0, opacity: 1, transition: transitionEnter(transitionIn) }, + exit: { x: distance, opacity: 0, transition: transitionExit(transitionOut) }, + }, + /**** Out ****/ + out: { + initial: { opacity: 1 }, + animate: { opacity: 0, transition: transitionEnter(transitionIn) }, + exit: { opacity: 1, transition: transitionExit(transitionOut) }, + }, + outUp: { + initial: { y: 0, opacity: 1 }, + animate: { + y: -distance, + opacity: 0, + transition: transitionEnter(transitionIn), + }, + exit: { y: 0, opacity: 1, transition: transitionExit(transitionOut) }, + }, + outDown: { + initial: { y: 0, opacity: 1 }, + animate: { + y: distance, + opacity: 0, + transition: transitionEnter(transitionIn), + }, + exit: { y: 0, opacity: 1, transition: transitionExit(transitionOut) }, + }, + outLeft: { + initial: { x: 0, opacity: 1 }, + animate: { + x: -distance, + opacity: 0, + transition: transitionEnter(transitionIn), + }, + exit: { x: 0, opacity: 1, transition: transitionExit(transitionOut) }, + }, + outRight: { + initial: { x: 0, opacity: 1 }, + animate: { + x: distance, + opacity: 0, + transition: transitionEnter(transitionIn), + }, + exit: { x: 0, opacity: 1, transition: transitionExit(transitionOut) }, + }, + }; + + return variants[direction]; +}; diff --git a/app/frontend/src/components/animate/variants/flip.ts b/app/frontend/src/components/animate/variants/flip.ts new file mode 100644 index 00000000..94592b44 --- /dev/null +++ b/app/frontend/src/components/animate/variants/flip.ts @@ -0,0 +1,43 @@ +import type { Variants, Transition } from 'framer-motion'; + +import { transitionExit, transitionEnter } from './transition'; + +// ---------------------------------------------------------------------- + +type Direction = 'inX' | 'inY' | 'outX' | 'outY'; + +type Options = { + distance?: number; + transitionIn?: Transition; + transitionOut?: Transition; +}; + +export const varFlip = (direction: Direction, options?: Options): Variants => { + const transitionIn = options?.transitionIn; + const transitionOut = options?.transitionOut; + + const variants: Record = { + /**** In ****/ + inX: { + initial: { rotateX: -180, opacity: 0 }, + animate: { rotateX: 0, opacity: 1, transition: transitionEnter(transitionIn) }, + exit: { rotateX: -180, opacity: 0, transition: transitionExit(transitionOut) }, + }, + inY: { + initial: { rotateY: -180, opacity: 0 }, + animate: { rotateY: 0, opacity: 1, transition: transitionEnter(transitionIn) }, + exit: { rotateY: -180, opacity: 0, transition: transitionExit(transitionOut) }, + }, + /**** Out ****/ + outX: { + initial: { rotateX: 0, opacity: 1 }, + animate: { rotateX: 70, opacity: 0, transition: transitionExit(transitionOut) }, + }, + outY: { + initial: { rotateY: 0, opacity: 1 }, + animate: { rotateY: 70, opacity: 0, transition: transitionExit(transitionOut) }, + }, + }; + + return variants[direction]; +}; diff --git a/app/frontend/src/components/animate/variants/index.ts b/app/frontend/src/components/animate/variants/index.ts new file mode 100644 index 00000000..a7d86d04 --- /dev/null +++ b/app/frontend/src/components/animate/variants/index.ts @@ -0,0 +1,23 @@ +export * from './path'; + +export * from './fade'; + +export * from './zoom'; + +export * from './flip'; + +export * from './slide'; + +export * from './scale'; + +export * from './bounce'; + +export * from './rotate'; + +export * from './actions'; + +export * from './container'; + +export * from './transition'; + +export * from './background'; diff --git a/app/frontend/src/components/animate/variants/path.ts b/app/frontend/src/components/animate/variants/path.ts new file mode 100644 index 00000000..d3db77c4 --- /dev/null +++ b/app/frontend/src/components/animate/variants/path.ts @@ -0,0 +1,16 @@ +import type { Variants, TargetAndTransition } from 'framer-motion'; + +// ---------------------------------------------------------------------- + +export const varPath = (props?: TargetAndTransition): Variants => ({ + animate: { + fillOpacity: [0, 0, 1], + pathLength: [1, 0.4, 0], + ...props, + transition: { + duration: 2, + ease: [0.43, 0.13, 0.23, 0.96], + ...props?.transition, + }, + }, +}); diff --git a/app/frontend/src/components/animate/variants/rotate.ts b/app/frontend/src/components/animate/variants/rotate.ts new file mode 100644 index 00000000..2db43224 --- /dev/null +++ b/app/frontend/src/components/animate/variants/rotate.ts @@ -0,0 +1,35 @@ +import type { Variants, Transition } from 'framer-motion'; + +import { transitionExit, transitionEnter } from './transition'; + +// ---------------------------------------------------------------------- + +type Direction = 'in' | 'out'; + +type Options = { + deg?: number; + transitionIn?: Transition; + transitionOut?: Transition; +}; + +export const varRotate = (direction: Direction, options?: Options): Variants => { + const deg = options?.deg || 360; + const transitionIn = options?.transitionIn; + const transitionOut = options?.transitionOut; + + const variants: Record = { + /**** In ****/ + in: { + initial: { opacity: 0, rotate: -deg }, + animate: { opacity: 1, rotate: 0, transition: transitionEnter(transitionIn) }, + exit: { opacity: 0, rotate: -deg, transition: transitionExit(transitionOut) }, + }, + /**** Out ****/ + out: { + initial: { opacity: 1, rotate: 0 }, + animate: { opacity: 0, rotate: -deg, transition: transitionExit(transitionOut) }, + }, + }; + + return variants[direction]; +}; diff --git a/app/frontend/src/components/animate/variants/scale.ts b/app/frontend/src/components/animate/variants/scale.ts new file mode 100644 index 00000000..59cca405 --- /dev/null +++ b/app/frontend/src/components/animate/variants/scale.ts @@ -0,0 +1,51 @@ +import type { Variants, Transition } from 'framer-motion'; + +import { transitionExit, transitionEnter } from './transition'; + +// ---------------------------------------------------------------------- + +type Direction = 'in' | 'inX' | 'inY' | 'out' | 'outX' | 'outY'; + +type Options = { + transitionIn?: Transition; + transitionOut?: Transition; +}; + +export const varScale = (direction: Direction, options?: Options): Variants => { + const transitionIn = options?.transitionIn; + const transitionOut = options?.transitionOut; + + const variants: Record = { + /**** In ****/ + in: { + initial: { scale: 0, opacity: 0 }, + animate: { scale: 1, opacity: 1, transition: transitionEnter(transitionIn) }, + exit: { scale: 0, opacity: 0, transition: transitionExit(transitionOut) }, + }, + inX: { + initial: { scaleX: 0, opacity: 0 }, + animate: { scaleX: 1, opacity: 1, transition: transitionEnter(transitionIn) }, + exit: { scaleX: 0, opacity: 0, transition: transitionExit(transitionOut) }, + }, + inY: { + initial: { scaleY: 0, opacity: 0 }, + animate: { scaleY: 1, opacity: 1, transition: transitionEnter(transitionIn) }, + exit: { scaleY: 0, opacity: 0, transition: transitionExit(transitionOut) }, + }, + /**** Out ****/ + out: { + initial: { scale: 1, opacity: 1 }, + animate: { scale: 0, opacity: 0, transition: transitionEnter(transitionIn) }, + }, + outX: { + initial: { scaleX: 1, opacity: 1 }, + animate: { scaleX: 0, opacity: 0, transition: transitionEnter(transitionIn) }, + }, + outY: { + initial: { scaleY: 1, opacity: 1 }, + animate: { scaleY: 0, opacity: 0, transition: transitionEnter(transitionIn) }, + }, + }; + + return variants[direction]; +}; diff --git a/app/frontend/src/components/animate/variants/slide.ts b/app/frontend/src/components/animate/variants/slide.ts new file mode 100644 index 00000000..32c197c5 --- /dev/null +++ b/app/frontend/src/components/animate/variants/slide.ts @@ -0,0 +1,74 @@ +import type { Variants, Transition } from 'framer-motion'; + +import { transitionExit, transitionEnter } from './transition'; + +// ---------------------------------------------------------------------- + +type Direction = + | 'inUp' + | 'inDown' + | 'inLeft' + | 'inRight' + | 'outUp' + | 'outDown' + | 'outLeft' + | 'outRight'; + +type Options = { + distance?: number; + transitionIn?: Transition; + transitionOut?: Transition; +}; + +export const varSlide = (direction: Direction, options?: Options): Variants => { + const distance = options?.distance || 160; + const transitionIn = options?.transitionIn; + const transitionOut = options?.transitionOut; + + const variants: Record = { + /**** In ****/ + inUp: { + initial: { y: distance }, + animate: { y: 0, transition: transitionEnter(transitionIn) }, + exit: { y: distance, transition: transitionExit(transitionOut) }, + }, + inDown: { + initial: { y: -distance }, + animate: { y: 0, transition: transitionEnter(transitionIn) }, + exit: { y: -distance, transition: transitionExit(transitionOut) }, + }, + inLeft: { + initial: { x: -distance }, + animate: { x: 0, transition: transitionEnter(transitionIn) }, + exit: { x: -distance, transition: transitionExit(transitionOut) }, + }, + inRight: { + initial: { x: distance }, + animate: { x: 0, transition: transitionEnter(transitionIn) }, + exit: { x: distance, transition: transitionExit(transitionOut) }, + }, + /**** Out ****/ + outUp: { + initial: { y: 0 }, + animate: { y: -distance, transition: transitionEnter(transitionIn) }, + exit: { y: 0, transition: transitionExit(transitionOut) }, + }, + outDown: { + initial: { y: 0 }, + animate: { y: distance, transition: transitionEnter(transitionIn) }, + exit: { y: 0, transition: transitionExit(transitionOut) }, + }, + outLeft: { + initial: { x: 0 }, + animate: { x: -distance, transition: transitionEnter(transitionIn) }, + exit: { x: 0, transition: transitionExit(transitionOut) }, + }, + outRight: { + initial: { x: 0 }, + animate: { x: distance, transition: transitionEnter(transitionIn) }, + exit: { x: 0, transition: transitionExit(transitionOut) }, + }, + }; + + return variants[direction]; +}; diff --git a/app/frontend/src/components/animate/variants/transition.ts b/app/frontend/src/components/animate/variants/transition.ts new file mode 100644 index 00000000..fc01b2c9 --- /dev/null +++ b/app/frontend/src/components/animate/variants/transition.ts @@ -0,0 +1,15 @@ +import type { Transition } from 'framer-motion'; + +// ---------------------------------------------------------------------- + +export const transitionEnter = (props?: Transition): Transition => ({ + duration: 0.64, + ease: [0.43, 0.13, 0.23, 0.96], + ...props, +}); + +export const transitionExit = (props?: Transition): Transition => ({ + duration: 0.48, + ease: [0.43, 0.13, 0.23, 0.96], + ...props, +}); diff --git a/app/frontend/src/components/animate/variants/zoom.ts b/app/frontend/src/components/animate/variants/zoom.ts new file mode 100644 index 00000000..4e57930f --- /dev/null +++ b/app/frontend/src/components/animate/variants/zoom.ts @@ -0,0 +1,145 @@ +import type { Variants, Transition } from 'framer-motion'; + +import { transitionExit, transitionEnter } from './transition'; + +// ---------------------------------------------------------------------- + +type Direction = + | 'in' + | 'inUp' + | 'inDown' + | 'inLeft' + | 'inRight' + | 'out' + | 'outUp' + | 'outDown' + | 'outLeft' + | 'outRight'; + +type Options = { + distance?: number; + transitionIn?: Transition; + transitionOut?: Transition; +}; + +export const varZoom = (direction: Direction, options?: Options): Variants => { + const distance = options?.distance || 720; + const transitionIn = options?.transitionIn; + const transitionOut = options?.transitionOut; + + const variants = { + /**** In ****/ + in: { + initial: { scale: 0, opacity: 0 }, + animate: { scale: 1, opacity: 1, transition: transitionEnter(transitionIn) }, + exit: { scale: 0, opacity: 0, transition: transitionExit(transitionOut) }, + }, + inUp: { + initial: { + scale: 0, + opacity: 0, + translateY: distance, + }, + animate: { + scale: 1, + opacity: 1, + translateY: 0, + transition: transitionEnter(transitionIn), + }, + exit: { + scale: 0, + opacity: 0, + translateY: distance, + transition: transitionExit(transitionOut), + }, + }, + inDown: { + initial: { scale: 0, opacity: 0, translateY: -distance }, + animate: { + scale: 1, + opacity: 1, + translateY: 0, + transition: transitionEnter(transitionIn), + }, + exit: { + scale: 0, + opacity: 0, + translateY: -distance, + transition: transitionExit(transitionOut), + }, + }, + inLeft: { + initial: { scale: 0, opacity: 0, translateX: -distance }, + animate: { + scale: 1, + opacity: 1, + translateX: 0, + transition: transitionEnter(transitionIn), + }, + exit: { + scale: 0, + opacity: 0, + translateX: -distance, + transition: transitionExit(transitionOut), + }, + }, + inRight: { + initial: { scale: 0, opacity: 0, translateX: distance }, + animate: { + scale: 1, + opacity: 1, + translateX: 0, + transition: transitionEnter(transitionIn), + }, + exit: { + scale: 0, + opacity: 0, + translateX: distance, + transition: transitionExit(transitionOut), + }, + }, + /**** Out ****/ + out: { + initial: { scale: 1, opacity: 1 }, + animate: { scale: 0, opacity: 0, transition: transitionEnter(transitionIn) }, + }, + outUp: { + initial: { scale: 1, opacity: 1 }, + animate: { + scale: 0, + opacity: 0, + translateY: -distance, + transition: transitionEnter(transitionIn), + }, + }, + outDown: { + initial: { scale: 1, opacity: 1 }, + animate: { + scale: 0, + opacity: 0, + translateY: distance, + transition: transitionEnter(transitionIn), + }, + }, + outLeft: { + initial: { scale: 1, opacity: 1 }, + animate: { + scale: 0, + opacity: 0, + translateX: -distance, + transition: transitionEnter(transitionIn), + }, + }, + outRight: { + initial: { scale: 1, opacity: 1 }, + animate: { + scale: 0, + opacity: 0, + translateX: distance, + transition: transitionEnter(transitionIn), + }, + }, + }; + + return variants[direction]; +}; diff --git a/app/frontend/src/components/carousel/breakpoints.ts b/app/frontend/src/components/carousel/breakpoints.ts new file mode 100644 index 00000000..2af83d9b --- /dev/null +++ b/app/frontend/src/components/carousel/breakpoints.ts @@ -0,0 +1,9 @@ +// ---------------------------------------------------------------------- + +export const carouselBreakpoints = { + xs: '(min-width: 0px)', + sm: '(min-width: 600px)', + md: '(min-width: 900px)', + lg: '(min-width: 1200px)', + xl: '(min-width: 1536px)', +}; diff --git a/app/frontend/src/components/carousel/carousel.tsx b/app/frontend/src/components/carousel/carousel.tsx new file mode 100644 index 00000000..4825026f --- /dev/null +++ b/app/frontend/src/components/carousel/carousel.tsx @@ -0,0 +1,108 @@ +import { Children, isValidElement } from 'react'; +import { mergeClasses } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; + +import { carouselClasses } from './classes'; +import { CarouselSlide } from './components/carousel-slide'; + +import type { CarouselProps, CarouselOptions } from './types'; + +// ---------------------------------------------------------------------- + +export function Carousel({ + sx, + carousel, + children, + slotProps, + className, + ...other +}: CarouselProps) { + const { mainRef, options } = carousel; + + const axis = options?.axis ?? 'x'; + const slideSpacing = options?.slideSpacing ?? '0px'; + + const renderChildren = () => + Children.map(children, (child) => { + if (isValidElement(child)) { + const reactChild = child as React.ReactElement<{ key?: React.Key }>; + + return ( + + {child} + + ); + } + return null; + }); + + return ( + + ({ + ...(carousel.pluginNames?.includes('autoHeight') && { + alignItems: 'flex-start', + transition: theme.transitions.create(['height'], { + easing: theme.transitions.easing.easeInOut, + duration: theme.transitions.duration.shorter, + }), + }), + }), + ...(Array.isArray(slotProps?.container) + ? (slotProps?.container ?? []) + : [slotProps?.container]), + ]} + > + {renderChildren()} + + + ); +} + +// ---------------------------------------------------------------------- + +const CarouselRoot = styled('div', { + shouldForwardProp: (prop: string) => !['axis', 'sx'].includes(prop), +})>(() => ({ + margin: 'auto', + maxWidth: '100%', + overflow: 'hidden', + position: 'relative', + variants: [{ props: { axis: 'y' }, style: { height: '100%' } }], +})); + +const CarouselContainer = styled('ul', { + shouldForwardProp: (prop: string) => !['axis', 'slideSpacing', 'sx'].includes(prop), +})>(({ slideSpacing }) => ({ + display: 'flex', + backfaceVisibility: 'hidden', + variants: [ + { + props: { axis: 'x' }, + style: { + touchAction: 'pan-y pinch-zoom', + marginLeft: `calc(${slideSpacing} * -1)`, + }, + }, + { + props: { axis: 'y' }, + style: { + height: '100%', + flexDirection: 'column', + touchAction: 'pan-x pinch-zoom', + marginTop: `calc(${slideSpacing} * -1)`, + }, + }, + ], +})); diff --git a/app/frontend/src/components/carousel/classes.ts b/app/frontend/src/components/carousel/classes.ts new file mode 100644 index 00000000..44599d93 --- /dev/null +++ b/app/frontend/src/components/carousel/classes.ts @@ -0,0 +1,40 @@ +import { createClasses } from 'src/theme/create-classes'; + +// ---------------------------------------------------------------------- + +export const carouselClasses = { + root: createClasses('carousel__root'), + container: createClasses('carousel__container'), + // dots + dots: { + root: createClasses('carousel__dots__root'), + item: createClasses('carousel__dot__item'), + itemSelected: createClasses('carousel__dot__selected'), + }, + // arrows + arrows: { + root: createClasses('carousel__arrows__root'), + label: createClasses('carousel__arrows__label'), + prev: createClasses('carousel__arrow__prev'), + next: createClasses('carousel__arrow__next'), + svg: createClasses('carousel__arrows__svg'), + }, + // slide + slide: { + root: createClasses('carousel__slide__root'), + content: createClasses('carousel__slide__content'), + parallax: createClasses('carousel__slide__content__parallax'), + }, + // thumbs + thumbs: { + root: createClasses('carousel__thumbs__root'), + container: createClasses('carousel__thumbs__container'), + item: createClasses('carousel__thumb__item'), + image: createClasses('carousel__thumb__item__image'), + }, + // progress + progress: { + root: createClasses('carousel__progress__root'), + bar: createClasses('carousel__progress__bar'), + }, +}; diff --git a/app/frontend/src/components/carousel/components/arrow-button.tsx b/app/frontend/src/components/carousel/components/arrow-button.tsx new file mode 100644 index 00000000..55c363dc --- /dev/null +++ b/app/frontend/src/components/carousel/components/arrow-button.tsx @@ -0,0 +1,83 @@ +import { mergeClasses } from 'minimal-shared/utils'; + +import SvgIcon from '@mui/material/SvgIcon'; +import { styled } from '@mui/material/styles'; +import ButtonBase from '@mui/material/ButtonBase'; + +import { carouselClasses } from '../classes'; + +import type { CarouselOptions, CarouselArrowButtonProps } from '../types'; + +// ---------------------------------------------------------------------- + +const prevSvgPath = ( + +); + +const nextSvgPath = ( + +); + +export function ArrowButton({ + sx, + svgIcon, + options, + variant, + className, + svgSize = 20, + ...other +}: CarouselArrowButtonProps) { + const isPrev = variant === 'prev'; + + const svgContent = svgIcon || (isPrev ? prevSvgPath : nextSvgPath); + + return ( + + + {svgContent} + + + ); +} + +// ---------------------------------------------------------------------- + +const ArrowButtonRoot = styled(ButtonBase, { + shouldForwardProp: (prop: string) => !['axis', 'direction', 'sx'].includes(prop), +})>(({ theme }) => ({ + borderRadius: '50%', + boxSizing: 'content-box', + padding: theme.spacing(1), + transition: theme.transitions.create(['all'], { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.short, + }), + variants: [ + { props: { disabled: true }, style: { opacity: 0.4 } }, + { + props: { axis: 'y' }, + style: { [`& .${carouselClasses.arrows.svg}`]: { transform: 'rotate(90deg)' } }, + }, + { + props: { direction: 'rtl' }, + style: { [`& .${carouselClasses.arrows.svg}`]: { transform: 'scaleX(-1)' } }, + }, + ], +})); diff --git a/app/frontend/src/components/carousel/components/carousel-arrow-buttons.tsx b/app/frontend/src/components/carousel/components/carousel-arrow-buttons.tsx new file mode 100644 index 00000000..e9d88daa --- /dev/null +++ b/app/frontend/src/components/carousel/components/carousel-arrow-buttons.tsx @@ -0,0 +1,200 @@ +import type { Theme } from '@mui/material/styles'; + +import { varAlpha, mergeClasses } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; + +import { ArrowButton } from './arrow-button'; +import { carouselClasses } from '../classes'; + +import type { CarouselArrowButtonsProps } from '../types'; + +// ---------------------------------------------------------------------- + +const BasicButtonsRoot = styled('div')(({ theme }) => ({ + gap: '4px', + zIndex: 9, + alignItems: 'center', + display: 'inline-flex', + color: theme.vars.palette.action.active, +})); + +export function CarouselArrowBasicButtons({ + sx, + options, + slotProps, + onClickPrev, + onClickNext, + disablePrev, + disableNext, + className, + ...other +}: CarouselArrowButtonsProps) { + return ( + + + + + + ); +} + +// ---------------------------------------------------------------------- + +export function CarouselArrowFloatButtons({ + sx, + options, + slotProps, + onClickPrev, + onClickNext, + disablePrev, + disableNext, +}: CarouselArrowButtonsProps) { + const baseStyles = (theme: Theme) => ({ + zIndex: 9, + top: '50%', + borderRadius: 1.5, + position: 'absolute', + color: 'common.white', + bgcolor: 'text.primary', + '&:hover': { opacity: 0.8 }, + ...theme.applyStyles('dark', { + color: 'grey.800', + }), + }); + + return ( + <> + ({ + ...baseStyles(theme), + left: 0, + transform: 'translate(-50%, -50%)', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ...(Array.isArray(slotProps?.prevBtn?.sx) + ? (slotProps?.prevBtn?.sx ?? []) + : [slotProps?.prevBtn?.sx]), + ]} + /> + + ({ + ...baseStyles(theme), + right: 0, + transform: 'translate(50%, -50%)', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ...(Array.isArray(slotProps?.nextBtn?.sx) + ? (slotProps?.nextBtn?.sx ?? []) + : [slotProps?.nextBtn?.sx]), + ]} + /> + + ); +} + +// ---------------------------------------------------------------------- + +const NumberButtonsRoot = styled('div')(({ theme }) => ({ + gap: '2px', + zIndex: 9, + alignItems: 'center', + display: 'inline-flex', + padding: theme.spacing(0.5), + color: theme.vars.palette.common.white, + borderRadius: theme.shape.borderRadius * 1.25, + backgroundColor: varAlpha(theme.vars.palette.grey['900Channel'], 0.48), + [`& .${carouselClasses.arrows.label}`]: { + ...theme.typography.subtitle2, + margin: theme.spacing(0, 0.5), + }, + [`& .${carouselClasses.arrows.prev}`]: { + borderRadius: 'inherit', + padding: theme.spacing(0.75), + }, + [`& .${carouselClasses.arrows.next}`]: { + borderRadius: 'inherit', + padding: theme.spacing(0.75), + }, +})); + +export function CarouselArrowNumberButtons({ + sx, + options, + slotProps, + className, + totalSlides, + onClickPrev, + onClickNext, + disablePrev, + disableNext, + selectedIndex, + ...other +}: CarouselArrowButtonsProps) { + return ( + + + + + {selectedIndex}/{totalSlides} + + + + + ); +} diff --git a/app/frontend/src/components/carousel/components/carousel-dot-buttons.tsx b/app/frontend/src/components/carousel/components/carousel-dot-buttons.tsx new file mode 100644 index 00000000..71c8bba9 --- /dev/null +++ b/app/frontend/src/components/carousel/components/carousel-dot-buttons.tsx @@ -0,0 +1,145 @@ +import type { CSSObject } from '@mui/material/styles'; + +import { varAlpha, mergeClasses } from 'minimal-shared/utils'; + +import Box from '@mui/material/Box'; +import { styled } from '@mui/material/styles'; +import ButtonBase from '@mui/material/ButtonBase'; + +import { carouselClasses } from '../classes'; + +import type { CarouselDotButtonsProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function CarouselDotButtons({ + sx, + gap, + slotProps, + className, + onClickDot, + scrollSnaps, + selectedIndex, + variant = 'circular', + ...other +}: CarouselDotButtonsProps) { + const GAPS = { rounded: gap ?? 2, circular: gap ?? 2, number: gap ?? 6 }; + + const SIZES = { + circular: slotProps?.dot?.size ?? 18, + rounded: slotProps?.dot?.size ?? 18, + number: slotProps?.dot?.size ?? 28, + }; + + return ( + ({ + gap: `${GAPS[variant]}px`, + height: SIZES[variant], + zIndex: 9, + display: 'flex', + '& > li': { + display: 'inline-flex', + }, + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + {scrollSnaps.map((_, index) => { + const selected = index === selectedIndex; + + return ( +
  • + onClickDot(index)} + sx={[ + () => ({ + width: SIZES[variant], + height: SIZES[variant], + }), + ...(Array.isArray(slotProps?.dot?.sx) + ? (slotProps?.dot?.sx ?? []) + : [slotProps?.dot?.sx]), + ]} + > + {variant === 'number' && index + 1} + +
  • + ); + })} +
    + ); +} + +// ---------------------------------------------------------------------- + +type DotItemProps = Pick & { + selected?: boolean; +}; + +const DotItem = styled(ButtonBase, { + shouldForwardProp: (prop: string) => !['variant', 'selected', 'sx'].includes(prop), +})(({ selected, theme }) => { + const dotStyles: CSSObject = { + width: 8, + height: 8, + content: '""', + opacity: 0.24, + borderRadius: '50%', + backgroundColor: 'currentColor', + transition: theme.transitions.create(['width', 'opacity'], { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.short, + }), + }; + + return { + variants: [ + { + props: { variant: 'circular' }, + style: { '&::before': { ...dotStyles, ...(selected && { opacity: 1 }) } }, + }, + { + props: { variant: 'rounded' }, + style: { + '&::before': { + ...dotStyles, + ...(selected && { + opacity: 1, + width: 'calc(100% - 4px)', + borderRadius: theme.shape.borderRadius, + }), + }, + }, + }, + { + props: { variant: 'number' }, + style: { + ...theme.typography.caption, + borderRadius: '50%', + color: theme.vars.palette.text.disabled, + border: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.16)}`, + ...(selected && { + color: theme.vars.palette.common.white, + backgroundColor: theme.vars.palette.text.primary, + fontWeight: theme.typography.fontWeightSemiBold, + ...theme.applyStyles('dark', { + color: theme.vars.palette.grey[800], + }), + }), + }, + }, + ], + }; +}); diff --git a/app/frontend/src/components/carousel/components/carousel-progress-bar.tsx b/app/frontend/src/components/carousel/components/carousel-progress-bar.tsx new file mode 100644 index 00000000..0eb031e8 --- /dev/null +++ b/app/frontend/src/components/carousel/components/carousel-progress-bar.tsx @@ -0,0 +1,44 @@ +import { varAlpha, mergeClasses } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; + +import { carouselClasses } from '../classes'; + +import type { CarouselProgressBarProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function CarouselProgressBar({ sx, value, className, ...other }: CarouselProgressBarProps) { + return ( + + + + ); +} + +// ---------------------------------------------------------------------- + +const ProgressBarRoot = styled('div')(({ theme }) => ({ + height: 6, + maxWidth: 120, + width: '100%', + borderRadius: 6, + overflow: 'hidden', + position: 'relative', + color: theme.vars.palette.text.primary, + backgroundColor: varAlpha(theme.vars.palette.grey['500Channel'], 0.2), +})); + +const ProgressBar = styled('span')(({ theme }) => ({ + top: 0, + bottom: 0, + width: '100%', + left: '-100%', + position: 'absolute', + backgroundColor: 'currentColor', + transform: `translate3d(calc(var(--progress-value) * ${theme.direction === 'rtl' ? -1 : 1}%), 0px, 0px)`, +})); diff --git a/app/frontend/src/components/carousel/components/carousel-slide.tsx b/app/frontend/src/components/carousel/components/carousel-slide.tsx new file mode 100644 index 00000000..68ee5104 --- /dev/null +++ b/app/frontend/src/components/carousel/components/carousel-slide.tsx @@ -0,0 +1,50 @@ +import { mergeClasses } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; + +import { getSlideSize } from '../utils'; +import { carouselClasses } from '../classes'; + +import type { CarouselOptions, CarouselSlideProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function CarouselSlide({ sx, options, children, className, ...other }: CarouselSlideProps) { + const slideSize = getSlideSize(options?.slidesToShow); + + return ( + + {options?.parallax ? ( +
    +
    {children}
    +
    + ) : ( + children + )} +
    + ); +} + +// ---------------------------------------------------------------------- + +const CarouselSlideRoot = styled('li', { + shouldForwardProp: (prop: string) => !['axis', 'slideSpacing', 'sx'].includes(prop), +})>(({ slideSpacing }) => ({ + display: 'block', + position: 'relative', + [`& .${carouselClasses.slide.content}`]: { + overflow: 'hidden', + position: 'relative', + borderRadius: 'inherit', + }, + variants: [ + { props: { axis: 'x' }, style: { minWidth: 0, paddingLeft: slideSpacing } }, + { props: { axis: 'y' }, style: { minHeight: 0, paddingTop: slideSpacing } }, + ], +})); diff --git a/app/frontend/src/components/carousel/components/carousel-thumb.tsx b/app/frontend/src/components/carousel/components/carousel-thumb.tsx new file mode 100644 index 00000000..ef0454c8 --- /dev/null +++ b/app/frontend/src/components/carousel/components/carousel-thumb.tsx @@ -0,0 +1,59 @@ +import { mergeClasses } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; +import ButtonBase from '@mui/material/ButtonBase'; + +import { carouselClasses } from '../classes'; + +import type { CarouselThumbProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function CarouselThumb({ + sx, + src, + index, + selected, + className, + ...other +}: CarouselThumbProps) { + return ( + + {`carousel-thumb-${index}`} + + ); +} + +// ---------------------------------------------------------------------- + +const ThumbRoot = styled(ButtonBase, { + shouldForwardProp: (prop: string) => !['selected', 'sx'].includes(prop), +})>(({ theme }) => ({ + width: 64, + height: 64, + opacity: 0.48, + flexShrink: 0, + cursor: 'pointer', + borderRadius: theme.shape.borderRadius * 1.25, + transition: theme.transitions.create(['opacity', 'box-shadow'], { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.short, + }), + [`& .${carouselClasses.thumbs.image}`]: { + width: '100%', + height: '100%', + objectFit: 'cover', + borderRadius: 'inherit', + }, + variants: [ + { + props: { selected: true }, + style: { opacity: 1, boxShadow: `0 0 0 2px ${theme.vars.palette.primary.main}` }, + }, + ], +})); diff --git a/app/frontend/src/components/carousel/components/carousel-thumbs.tsx b/app/frontend/src/components/carousel/components/carousel-thumbs.tsx new file mode 100644 index 00000000..cfa00cd5 --- /dev/null +++ b/app/frontend/src/components/carousel/components/carousel-thumbs.tsx @@ -0,0 +1,144 @@ +import { mergeClasses } from 'minimal-shared/utils'; +import { Children, forwardRef, isValidElement } from 'react'; + +import { styled } from '@mui/material/styles'; + +import { carouselClasses } from '../classes'; +import { CarouselSlide } from './carousel-slide'; + +import type { CarouselOptions, CarouselThumbsProps } from '../types'; + +// ---------------------------------------------------------------------- + +export const CarouselThumbs = forwardRef((props, ref) => { + const { children, slotProps, options, sx, className, ...other } = props; + + const axis = options?.axis ?? 'x'; + const slideSpacing = options?.slideSpacing ?? '12px'; + + const renderChildren = () => + Children.map(children, (child) => { + if (isValidElement(child)) { + const reactChild = child as React.ReactElement<{ key?: React.Key }>; + + return ( + + {child} + + ); + } + return null; + }); + + return ( + + + {renderChildren()} + + + ); +}); + +// ---------------------------------------------------------------------- + +type ThumbsRootProps = Pick & { + enableMask?: boolean; +}; + +const ThumbsRoot = styled('div', { + shouldForwardProp: (prop: string) => !['axis', 'enableMask', 'sx'].includes(prop), +})(({ enableMask, theme }) => { + const maskBg = `${theme.vars.palette.background.paper} 20%, transparent 100%)`; + + return { + flexShrink: 0, + margin: 'auto', + maxWidth: '100%', + overflow: 'hidden', + position: 'relative', + variants: [ + { + props: { axis: 'x' }, + style: { + maxWidth: '100%', + padding: theme.spacing(0.5), + ...(enableMask && { + '&::before, &::after': { + top: 0, + zIndex: 9, + width: 40, + content: '""', + height: '100%', + position: 'absolute', + }, + '&::before': { left: -8, background: `linear-gradient(to right, ${maskBg}` }, + '&::after': { right: -8, background: `linear-gradient(to left, ${maskBg}` }, + }), + }, + }, + { + props: { axis: 'y' }, + style: { + height: '100%', + maxHeight: '100%', + padding: theme.spacing(0.5), + ...(enableMask && { + '&::before, &::after': { + left: 0, + zIndex: 9, + height: 40, + content: '""', + width: '100%', + position: 'absolute', + }, + '&::before': { top: -8, background: `linear-gradient(to bottom, ${maskBg}` }, + '&::after': { bottom: -8, background: `linear-gradient(to top, ${maskBg}` }, + }), + }, + }, + ], + }; +}); + +type ThumbsContainerProps = Pick; + +const ThumbsContainer = styled('ul', { + shouldForwardProp: (prop: string) => !['axis', 'slideSpacing', 'sx'].includes(prop), +})(({ slideSpacing }) => ({ + display: 'flex', + backfaceVisibility: 'hidden', + variants: [ + { + props: { axis: 'x' }, + style: { + touchAction: 'pan-y pinch-zoom', + marginLeft: `calc(${slideSpacing} * -1)`, + }, + }, + { + props: { axis: 'y' }, + style: { + height: '100%', + flexDirection: 'column', + touchAction: 'pan-x pinch-zoom', + marginTop: `calc(${slideSpacing} * -1)`, + }, + }, + ], +})); diff --git a/app/frontend/src/components/carousel/components/index.ts b/app/frontend/src/components/carousel/components/index.ts new file mode 100644 index 00000000..ee46a8e1 --- /dev/null +++ b/app/frontend/src/components/carousel/components/index.ts @@ -0,0 +1,13 @@ +export * from './arrow-button'; + +export * from './carousel-slide'; + +export * from './carousel-thumb'; + +export * from './carousel-thumbs'; + +export * from './carousel-dot-buttons'; + +export * from './carousel-progress-bar'; + +export * from './carousel-arrow-buttons'; diff --git a/app/frontend/src/components/carousel/hooks/use-carousel-arrows.ts b/app/frontend/src/components/carousel/hooks/use-carousel-arrows.ts new file mode 100644 index 00000000..3ac361fc --- /dev/null +++ b/app/frontend/src/components/carousel/hooks/use-carousel-arrows.ts @@ -0,0 +1,43 @@ +import type { EmblaCarouselType } from 'embla-carousel'; + +import { useState, useEffect, useCallback } from 'react'; + +import type { UseCarouselArrowsReturn } from '../types'; + +// ---------------------------------------------------------------------- + +export const useCarouselArrows = (mainApi?: EmblaCarouselType): UseCarouselArrowsReturn => { + const [disablePrev, setDisabledPrevBtn] = useState(true); + + const [disableNext, setDisabledNextBtn] = useState(true); + + const onClickPrev = useCallback(() => { + if (!mainApi) return; + mainApi.scrollPrev(); + }, [mainApi]); + + const onClickNext = useCallback(() => { + if (!mainApi) return; + mainApi.scrollNext(); + }, [mainApi]); + + const onSelect = useCallback((_mainApi: EmblaCarouselType) => { + setDisabledPrevBtn(!_mainApi.canScrollPrev()); + setDisabledNextBtn(!_mainApi.canScrollNext()); + }, []); + + useEffect(() => { + if (!mainApi) return; + + onSelect(mainApi); + mainApi.on('reInit', onSelect); + mainApi.on('select', onSelect); + }, [mainApi, onSelect]); + + return { + disablePrev, + disableNext, + onClickPrev, + onClickNext, + }; +}; diff --git a/app/frontend/src/components/carousel/hooks/use-carousel-auto-play.ts b/app/frontend/src/components/carousel/hooks/use-carousel-auto-play.ts new file mode 100644 index 00000000..9c641355 --- /dev/null +++ b/app/frontend/src/components/carousel/hooks/use-carousel-auto-play.ts @@ -0,0 +1,47 @@ +import type {} from 'embla-carousel-autoplay'; +import type { EmblaCarouselType } from 'embla-carousel'; + +import { useState, useEffect, useCallback } from 'react'; + +import type { UseCarouselAutoPlayReturn } from '../types'; + +// ---------------------------------------------------------------------- + +export function useCarouselAutoPlay(mainApi?: EmblaCarouselType): UseCarouselAutoPlayReturn { + const [isPlaying, setIsPlaying] = useState(false); + + const onClickAutoplay = useCallback( + (callback: () => void) => { + const autoplay = mainApi?.plugins()?.autoplay; + if (!autoplay) return; + + const resetOrStop = + autoplay.options.stopOnInteraction === false ? autoplay.reset : autoplay.stop; + + resetOrStop(); + callback(); + }, + [mainApi] + ); + + const onTogglePlay = useCallback(() => { + const autoplay = mainApi?.plugins()?.autoplay; + if (!autoplay) return; + + const playOrStop = autoplay.isPlaying() ? autoplay.stop : autoplay.play; + playOrStop(); + }, [mainApi]); + + useEffect(() => { + const autoplay = mainApi?.plugins()?.autoplay; + if (!autoplay) return; + + setIsPlaying(autoplay.isPlaying()); + mainApi + .on('autoplay:play', () => setIsPlaying(true)) + .on('autoplay:stop', () => setIsPlaying(false)) + .on('reInit', () => setIsPlaying(false)); + }, [mainApi]); + + return { isPlaying, onTogglePlay, onClickAutoplay }; +} diff --git a/app/frontend/src/components/carousel/hooks/use-carousel-auto-scroll.ts b/app/frontend/src/components/carousel/hooks/use-carousel-auto-scroll.ts new file mode 100644 index 00000000..b618818e --- /dev/null +++ b/app/frontend/src/components/carousel/hooks/use-carousel-auto-scroll.ts @@ -0,0 +1,47 @@ +import type {} from 'embla-carousel-auto-scroll'; +import type { EmblaCarouselType } from 'embla-carousel'; + +import { useState, useEffect, useCallback } from 'react'; + +import type { UseCarouselAutoPlayReturn } from '../types'; + +// ---------------------------------------------------------------------- + +export function useCarouselAutoScroll(mainApi?: EmblaCarouselType): UseCarouselAutoPlayReturn { + const [isPlaying, setIsPlaying] = useState(false); + + const onClickAutoplay = useCallback( + (callback: () => void) => { + const autoScroll = mainApi?.plugins()?.autoScroll; + if (!autoScroll) return; + + const resetOrStop = + autoScroll.options.stopOnInteraction === false ? autoScroll.reset : autoScroll.stop; + + resetOrStop(); + callback(); + }, + [mainApi] + ); + + const onTogglePlay = useCallback(() => { + const autoScroll = mainApi?.plugins()?.autoScroll; + if (!autoScroll) return; + + const playOrStop = autoScroll.isPlaying() ? autoScroll.stop : autoScroll.play; + playOrStop(); + }, [mainApi]); + + useEffect(() => { + const autoScroll = mainApi?.plugins()?.autoScroll; + if (!autoScroll) return; + + setIsPlaying(autoScroll.isPlaying()); + mainApi + .on('autoScroll:play', () => setIsPlaying(true)) + .on('autoScroll:stop', () => setIsPlaying(false)) + .on('reInit', () => setIsPlaying(false)); + }, [mainApi]); + + return { isPlaying, onTogglePlay, onClickAutoplay }; +} diff --git a/app/frontend/src/components/carousel/hooks/use-carousel-dots.ts b/app/frontend/src/components/carousel/hooks/use-carousel-dots.ts new file mode 100644 index 00000000..72c7786f --- /dev/null +++ b/app/frontend/src/components/carousel/hooks/use-carousel-dots.ts @@ -0,0 +1,49 @@ +import type { EmblaCarouselType } from 'embla-carousel'; + +import { useState, useEffect, useCallback } from 'react'; + +import type { UseCarouselDotsReturn } from '../types'; + +// ---------------------------------------------------------------------- + +export function useCarouselDots(mainApi?: EmblaCarouselType): UseCarouselDotsReturn { + const [dotCount, setDotCount] = useState(0); + + const [selectedIndex, setSelectedIndex] = useState(0); + + const [scrollSnaps, setScrollSnaps] = useState([]); + + const onClickDot = useCallback( + (index: number) => { + if (!mainApi) return; + mainApi.scrollTo(index); + }, + [mainApi] + ); + + const onInit = useCallback((_mainApi: EmblaCarouselType) => { + setScrollSnaps(_mainApi.scrollSnapList()); + }, []); + + const onSelect = useCallback((_mainApi: EmblaCarouselType) => { + setSelectedIndex(_mainApi.selectedScrollSnap()); + setDotCount(_mainApi.scrollSnapList().length); + }, []); + + useEffect(() => { + if (!mainApi) return; + + onInit(mainApi); + onSelect(mainApi); + mainApi.on('reInit', onInit); + mainApi.on('reInit', onSelect); + mainApi.on('select', onSelect); + }, [mainApi, onInit, onSelect]); + + return { + dotCount, + scrollSnaps, + selectedIndex, + onClickDot, + }; +} diff --git a/app/frontend/src/components/carousel/hooks/use-carousel-parallax.ts b/app/frontend/src/components/carousel/hooks/use-carousel-parallax.ts new file mode 100644 index 00000000..16d08005 --- /dev/null +++ b/app/frontend/src/components/carousel/hooks/use-carousel-parallax.ts @@ -0,0 +1,94 @@ +import type { EmblaEventType, EmblaCarouselType } from 'embla-carousel'; + +import { useRef, useEffect, useCallback } from 'react'; + +import { carouselClasses } from '../classes'; + +import type { CarouselOptions } from '../types'; + +// ---------------------------------------------------------------------- + +export function useParallax(mainApi?: EmblaCarouselType, parallax?: CarouselOptions['parallax']) { + const tweenFactor = useRef(0); + + const tweenNodes = useRef([]); + + const TWEEN_FACTOR_BASE = typeof parallax === 'number' ? parallax : 0.24; + + const setTweenNodes = useCallback((_mainApi: EmblaCarouselType): void => { + tweenNodes.current = _mainApi + .slideNodes() + .map( + (slideNode) => slideNode.querySelector(`.${carouselClasses.slide.parallax}`) as HTMLElement + ); + }, []); + + const setTweenFactor = useCallback( + (_mainApi: EmblaCarouselType) => { + tweenFactor.current = TWEEN_FACTOR_BASE * _mainApi.scrollSnapList().length; + }, + [TWEEN_FACTOR_BASE] + ); + + const tweenParallax = useCallback((_mainApi: EmblaCarouselType, eventName?: EmblaEventType) => { + const engine = _mainApi.internalEngine(); + + const scrollProgress = _mainApi.scrollProgress(); + + const slidesInView = _mainApi.slidesInView(); + + const isScrollEvent = eventName === 'scroll'; + + _mainApi.scrollSnapList().forEach((scrollSnap, snapIndex) => { + let diffToTarget = scrollSnap - scrollProgress; + + const slidesInSnap = engine.slideRegistry[snapIndex]; + + slidesInSnap.forEach((slideIndex) => { + if (isScrollEvent && !slidesInView.includes(slideIndex)) return; + + if (engine.options.loop) { + engine.slideLooper.loopPoints.forEach((loopItem) => { + const target = loopItem.target(); + + if (slideIndex === loopItem.index && target !== 0) { + const sign = Math.sign(target); + + if (sign === -1) { + diffToTarget = scrollSnap - (1 + scrollProgress); + } + if (sign === 1) { + diffToTarget = scrollSnap + (1 - scrollProgress); + } + } + }); + } + + const translateValue = diffToTarget * (-1 * tweenFactor.current) * 100; + + const tweenNode = tweenNodes.current[slideIndex]; + + if (tweenNode) { + tweenNode.style.transform = `translateX(${translateValue}%)`; + } + }); + }); + }, []); + + useEffect(() => { + if (!mainApi || !parallax) return; + + setTweenNodes(mainApi); + setTweenFactor(mainApi); + tweenParallax(mainApi); + + mainApi + .on('reInit', setTweenNodes) + .on('reInit', setTweenFactor) + .on('reInit', tweenParallax) + .on('scroll', tweenParallax); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mainApi, tweenParallax]); + + return null; +} diff --git a/app/frontend/src/components/carousel/hooks/use-carousel-progress.ts b/app/frontend/src/components/carousel/hooks/use-carousel-progress.ts new file mode 100644 index 00000000..cff35bf4 --- /dev/null +++ b/app/frontend/src/components/carousel/hooks/use-carousel-progress.ts @@ -0,0 +1,27 @@ +import type { EmblaCarouselType } from 'embla-carousel'; + +import { useState, useEffect, useCallback } from 'react'; + +import type { UseCarouselProgressReturn } from '../types'; + +// ---------------------------------------------------------------------- + +export function useCarouselProgress(mainApi?: EmblaCarouselType): UseCarouselProgressReturn { + const [scrollProgress, setScrollProgress] = useState(0); + + const onScroll = useCallback((_mainApi: EmblaCarouselType) => { + const progress = Math.max(0, Math.min(1, _mainApi.scrollProgress())); + + setScrollProgress(progress * 100); + }, []); + + useEffect(() => { + if (!mainApi) return; + + onScroll(mainApi); + mainApi.on('reInit', onScroll); + mainApi.on('scroll', onScroll); + }, [mainApi, onScroll]); + + return { value: scrollProgress }; +} diff --git a/app/frontend/src/components/carousel/hooks/use-carousel.ts b/app/frontend/src/components/carousel/hooks/use-carousel.ts new file mode 100644 index 00000000..df1dc255 --- /dev/null +++ b/app/frontend/src/components/carousel/hooks/use-carousel.ts @@ -0,0 +1,84 @@ +import type { EmblaPluginType } from 'embla-carousel'; + +import { useMemo } from 'react'; +import useEmblaCarousel from 'embla-carousel-react'; + +import { useTheme } from '@mui/material/styles'; + +import { useThumbs } from './use-thumbs'; +import { useCarouselDots } from './use-carousel-dots'; +import { useParallax } from './use-carousel-parallax'; +import { useCarouselArrows } from './use-carousel-arrows'; +import { useCarouselProgress } from './use-carousel-progress'; +import { useCarouselAutoPlay } from './use-carousel-auto-play'; +import { useCarouselAutoScroll } from './use-carousel-auto-scroll'; + +import type { CarouselOptions, UseCarouselReturn } from '../types'; + +// ---------------------------------------------------------------------- + +export const useCarousel = ( + options?: CarouselOptions, + plugins?: EmblaPluginType[] +): UseCarouselReturn => { + const theme = useTheme(); + + const [mainRef, mainApi] = useEmblaCarousel({ ...options, direction: theme.direction }, plugins); + + const { disablePrev, disableNext, onClickPrev, onClickNext } = useCarouselArrows(mainApi); + + const pluginNames = plugins?.map((plugin) => plugin.name); + + const _dots = useCarouselDots(mainApi); + + const _autoplay = useCarouselAutoPlay(mainApi); + + const _autoScroll = useCarouselAutoScroll(mainApi); + + const _progress = useCarouselProgress(mainApi); + + const _thumbs = useThumbs(mainApi, options?.thumbs); + + useParallax(mainApi, options?.parallax); + + const controls = useMemo(() => { + if (pluginNames?.includes('autoplay')) { + return { + onClickPrev: () => _autoplay.onClickAutoplay(onClickPrev), + onClickNext: () => _autoplay.onClickAutoplay(onClickNext), + }; + } + if (pluginNames?.includes('autoScroll')) { + return { + onClickPrev: () => _autoScroll.onClickAutoplay(onClickPrev), + onClickNext: () => _autoScroll.onClickAutoplay(onClickNext), + }; + } + return { onClickPrev, onClickNext }; + }, [_autoScroll, _autoplay, onClickNext, onClickPrev, pluginNames]); + + const mergedOptions = { ...options, ...mainApi?.internalEngine().options }; + + return { + options: mergedOptions, + pluginNames, + mainRef, + mainApi, + // arrows + arrows: { + disablePrev, + disableNext, + onClickPrev: controls.onClickPrev, + onClickNext: controls.onClickNext, + }, + // dots + dots: _dots, + // thumbs + thumbs: _thumbs, + // progress + progress: _progress, + // autoplay + autoplay: _autoplay, + autoScroll: _autoScroll, + }; +}; diff --git a/app/frontend/src/components/carousel/hooks/use-thumbs.ts b/app/frontend/src/components/carousel/hooks/use-thumbs.ts new file mode 100644 index 00000000..05c9e543 --- /dev/null +++ b/app/frontend/src/components/carousel/hooks/use-thumbs.ts @@ -0,0 +1,49 @@ +import type { EmblaCarouselType } from 'embla-carousel'; + +import useEmblaCarousel from 'embla-carousel-react'; +import { useState, useEffect, useCallback } from 'react'; + +import type { CarouselOptions, UseCarouselThumbsReturn } from '../types'; + +// ---------------------------------------------------------------------- + +export function useThumbs( + mainApi?: EmblaCarouselType, + options?: Partial +): UseCarouselThumbsReturn { + const [thumbsRef, thumbsApi] = useEmblaCarousel({ + containScroll: 'keepSnaps', + dragFree: true, + ...options, + }); + + const [selectedIndex, setSelectedIndex] = useState(0); + + const onClickThumb = useCallback( + (index: number) => { + if (!mainApi || !thumbsApi) return; + mainApi.scrollTo(index); + }, + [mainApi, thumbsApi] + ); + + const onSelect = useCallback(() => { + if (!mainApi || !thumbsApi) return; + setSelectedIndex(mainApi.selectedScrollSnap()); + thumbsApi.scrollTo(mainApi.selectedScrollSnap()); + }, [mainApi, thumbsApi, setSelectedIndex]); + + useEffect(() => { + if (!mainApi) return; + onSelect(); + mainApi.on('select', onSelect); + mainApi.on('reInit', onSelect); + }, [mainApi, onSelect]); + + return { + onClickThumb, + thumbsRef, + thumbsApi, + selectedIndex, + }; +} diff --git a/app/frontend/src/components/carousel/index.ts b/app/frontend/src/components/carousel/index.ts new file mode 100644 index 00000000..1e47dfa2 --- /dev/null +++ b/app/frontend/src/components/carousel/index.ts @@ -0,0 +1,11 @@ +export * from './classes'; + +export * from './carousel'; + +export * from './components'; + +export * from './breakpoints'; + +export * from './hooks/use-carousel'; + +export type * from './types'; diff --git a/app/frontend/src/components/carousel/types.ts b/app/frontend/src/components/carousel/types.ts new file mode 100644 index 00000000..52396660 --- /dev/null +++ b/app/frontend/src/components/carousel/types.ts @@ -0,0 +1,155 @@ +import type { BoxProps } from '@mui/material/Box'; +import type { ButtonBaseProps } from '@mui/material/ButtonBase'; +import type { UseEmblaCarouselType } from 'embla-carousel-react'; +import type { Theme, SxProps, Breakpoint } from '@mui/material/styles'; +import type { EmblaOptionsType, EmblaCarouselType } from 'embla-carousel'; + +// ---------------------------------------------------------------------- + +/** + * Dot Buttons + */ +export type UseCarouselDotsReturn = { + dotCount: number; + selectedIndex: number; + scrollSnaps: number[]; + onClickDot: (index: number) => void; +}; + +export type CarouselDotButtonsProps = BoxProps<'ul'> & + Omit & { + gap?: number; + variant?: 'circular' | 'rounded' | 'number'; + slotProps?: { + dot?: { + size?: number; + sx?: SxProps; + }; + }; + }; + +/** + * Prev & Next Buttons + */ +export type UseCarouselArrowsReturn = { + disablePrev: boolean; + disableNext: boolean; + onClickPrev: () => void; + onClickNext: () => void; +}; + +export type CarouselArrowButtonProps = ButtonBaseProps & { + svgSize?: number; + variant: 'prev' | 'next'; + svgIcon?: React.ReactNode; + options?: CarouselArrowButtonsProps['options']; +}; + +export type CarouselArrowButtonsProps = React.ComponentProps<'div'> & + UseCarouselArrowsReturn & { + sx?: SxProps; + totalSlides?: number; + selectedIndex?: number; + options?: Partial; + slotProps?: { + prevBtn?: Pick & { + sx?: SxProps; + }; + nextBtn?: Pick & { + sx?: SxProps; + }; + }; + }; + +/** + * Thumbs + */ +export type UseCarouselThumbsReturn = { + selectedIndex: number; + thumbsApi?: EmblaCarouselType; + thumbsRef: UseEmblaCarouselType[0]; + onClickThumb: (index: number) => void; +}; + +export type CarouselThumbProps = ButtonBaseProps & { + src: string; + index: number; + selected: boolean; +}; + +export type CarouselThumbsProps = React.ComponentProps<'div'> & { + options?: Partial; + sx?: SxProps; + slotProps?: { + slide?: SxProps; + container?: SxProps; + disableMask?: boolean; + }; +}; + +/** + * Progress + */ +export type UseCarouselProgressReturn = { + value: number; +}; + +export type CarouselProgressBarProps = React.ComponentProps<'div'> & + UseCarouselProgressReturn & { + sx?: SxProps; + }; + +/** + * Autoplay + */ +export type UseCarouselAutoPlayReturn = { + isPlaying: boolean; + onTogglePlay: () => void; + onClickAutoplay: (callback: () => void) => void; +}; + +/** + * Slide + */ +export type CarouselSlideProps = React.ComponentProps<'li'> & { + options?: Partial; + sx?: SxProps; +}; + +/** + * Carousel + */ +export type CarouselBaseOptions = EmblaOptionsType & { + slideSpacing?: string; + parallax?: boolean | number; + slidesToShow?: string | number | Partial>; +}; + +export type CarouselOptions = CarouselBaseOptions & { + thumbs?: CarouselBaseOptions; + breakpoints?: { + [key: string]: Omit; + }; +}; + +export type UseCarouselReturn = { + pluginNames?: string[]; + options?: CarouselOptions; + mainRef: UseEmblaCarouselType[0]; + mainApi?: EmblaCarouselType; + thumbs: UseCarouselThumbsReturn; + dots: UseCarouselDotsReturn; + autoplay: UseCarouselAutoPlayReturn; + progress: UseCarouselProgressReturn; + autoScroll: UseCarouselAutoPlayReturn; + arrows: UseCarouselArrowsReturn; +}; + +export type CarouselProps = React.ComponentProps<'div'> & { + sx?: SxProps; + carousel: UseCarouselReturn; + slotProps?: { + container?: SxProps; + slide?: SxProps; + }; +}; diff --git a/app/frontend/src/components/carousel/utils.ts b/app/frontend/src/components/carousel/utils.ts new file mode 100644 index 00000000..6c0b9de6 --- /dev/null +++ b/app/frontend/src/components/carousel/utils.ts @@ -0,0 +1,42 @@ +import type { Breakpoint } from '@mui/material/styles'; + +import type { CarouselOptions } from './types'; + +// ---------------------------------------------------------------------- + +type ObjectValue = { + [key: string]: string | number; +}; + +type InputValue = CarouselOptions['slidesToShow']; + +export function getSlideSize(slidesToShow: InputValue): InputValue { + if (slidesToShow && typeof slidesToShow === 'object') { + return Object.keys(slidesToShow).reduce((acc, key) => { + const sizeByKey = slidesToShow[key as Breakpoint]; + acc[key] = getValue(sizeByKey); + return acc; + }, {}); + } + + return getValue(slidesToShow); +} + +function getValue(value: string | number = 1): string { + if (typeof value === 'string') { + const isSupported = value === 'auto' || value.endsWith('%') || value.endsWith('px'); + + if (!isSupported) { + throw new Error(`Only accepts values: auto, px, %, or number.`); + } + // value is either 'auto', ends with '%', or ends with 'px' + return `0 0 ${value}`; + } + + if (typeof value === 'number') { + return `0 0 ${100 / value}%`; + } + + // Default case should not be reached due to the type signature, but we include it for safety + throw new Error(`Invalid value type. Only accepts values: auto, px, %, or number.`); +} diff --git a/app/frontend/src/components/chart/chart.tsx b/app/frontend/src/components/chart/chart.tsx new file mode 100644 index 00000000..646223f0 --- /dev/null +++ b/app/frontend/src/components/chart/chart.tsx @@ -0,0 +1,51 @@ +import { lazy, Suspense, forwardRef } from 'react'; +import { useIsClient } from 'minimal-shared/hooks'; +import { mergeClasses } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; + +import { chartClasses } from './classes'; +import { ChartLoading } from './components'; + +import type { ChartProps } from './types'; + +// ---------------------------------------------------------------------- + +const LazyChart = lazy(() => + import('react-apexcharts').then((module) => ({ default: module.default })) +); + +export const Chart = forwardRef((props, ref) => { + const { type, series, options, slotProps, className, sx, ...other } = props; + + const isClient = useIsClient(); + + const renderFallback = () => ; + + return ( + + {isClient ? ( + + + + ) : ( + renderFallback() + )} + + ); +}); + +// ---------------------------------------------------------------------- + +const ChartRoot = styled('div')(({ theme }) => ({ + width: '100%', + flexShrink: 0, + position: 'relative', + borderRadius: theme.shape.borderRadius * 1.5, +})); diff --git a/app/frontend/src/components/chart/classes.ts b/app/frontend/src/components/chart/classes.ts new file mode 100644 index 00000000..5557a590 --- /dev/null +++ b/app/frontend/src/components/chart/classes.ts @@ -0,0 +1,19 @@ +import { createClasses } from 'src/theme/create-classes'; + +// ---------------------------------------------------------------------- + +export const chartClasses = { + root: createClasses('chart__root'), + loading: createClasses('chart__loading'), + legends: { + root: createClasses('chart__legends__root'), + item: { + wrap: createClasses('chart__legends__item__wrap'), + root: createClasses('chart__legends__item__root'), + dot: createClasses('chart__legends__item__dot'), + icon: createClasses('chart__legends__item__icon'), + label: createClasses('chart__legends__item__label'), + value: createClasses('chart__legends__item__value'), + }, + }, +}; diff --git a/app/frontend/src/components/chart/components/chart-legends.tsx b/app/frontend/src/components/chart/components/chart-legends.tsx new file mode 100644 index 00000000..ec7c1130 --- /dev/null +++ b/app/frontend/src/components/chart/components/chart-legends.tsx @@ -0,0 +1,128 @@ +import { mergeClasses } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; + +import { chartClasses } from '../classes'; + +// ---------------------------------------------------------------------- + +export type ChartLegendsProps = React.ComponentProps & { + labels?: string[]; + colors?: string[]; + values?: string[]; + sublabels?: string[]; + icons?: React.ReactNode[]; + slotProps?: { + wrapper?: React.ComponentProps; + root?: React.ComponentProps; + dot?: React.ComponentProps; + icon?: React.ComponentProps; + value?: React.ComponentProps; + label?: React.ComponentProps; + }; +}; + +export function ChartLegends({ + sx, + className, + slotProps, + icons = [], + values = [], + labels = [], + colors = [], + sublabels = [], + ...other +}: ChartLegendsProps) { + return ( + + {labels.map((series, index) => ( + + + {icons.length ? ( + + {icons[index]} + + ) : ( + + )} + + + {series} + {!!sublabels.length && <> {` (${sublabels[index]})`}} + + + + {values && ( + + {values[index]} + + )} + + ))} + + ); +} + +// ---------------------------------------------------------------------- + +const ListRoot = styled('ul')(({ theme }) => ({ + display: 'flex', + flexWrap: 'wrap', + gap: theme.spacing(2), +})); + +const ItemWrap = styled('li')(() => ({ + display: 'inline-flex', + flexDirection: 'column', +})); + +const ItemRoot = styled('div')(({ theme }) => ({ + gap: 6, + alignItems: 'center', + display: 'inline-flex', + justifyContent: 'flex-start', + fontSize: theme.typography.pxToRem(13), + fontWeight: theme.typography.fontWeightMedium, +})); + +const ItemIcon = styled('span')({ + display: 'inline-flex', + color: 'var(--icon-color)', + /** + * As ':first-child' for ssr + * https://github.com/emotion-js/emotion/issues/1105#issuecomment-1126025608 + */ + '& > :first-of-type:not(style):not(:first-of-type ~ *), & > style + *': { width: 20, height: 20 }, +}); + +const ItemDot = styled('span')({ + width: 12, + height: 12, + flexShrink: 0, + display: 'flex', + borderRadius: '50%', + position: 'relative', + alignItems: 'center', + justifyContent: 'center', + color: 'var(--icon-color)', + backgroundColor: 'currentColor', +}); + +const ItemLabel = styled('span')({ flexShrink: 0 }); + +const ItemValue = styled('span')(({ theme }) => ({ + ...theme.typography.h6, + marginTop: theme.spacing(1), +})); diff --git a/app/frontend/src/components/chart/components/chart-loading.tsx b/app/frontend/src/components/chart/components/chart-loading.tsx new file mode 100644 index 00000000..f4e031ee --- /dev/null +++ b/app/frontend/src/components/chart/components/chart-loading.tsx @@ -0,0 +1,51 @@ +import type { BoxProps } from '@mui/material/Box'; + +import { mergeClasses } from 'minimal-shared/utils'; + +import Box from '@mui/material/Box'; +import Skeleton from '@mui/material/Skeleton'; + +import { chartClasses } from '../classes'; + +import type { ChartProps } from '../types'; + +// ---------------------------------------------------------------------- + +export type ChartLoadingProps = BoxProps & Pick; + +export function ChartLoading({ sx, className, type, ...other }: ChartLoadingProps) { + const circularTypes: ChartProps['type'][] = ['donut', 'radialBar', 'pie', 'polarArea']; + + return ( + ({ + top: 0, + left: 0, + width: 1, + zIndex: 9, + height: 1, + p: 'inherit', + overflow: 'hidden', + alignItems: 'center', + position: 'absolute', + borderRadius: 'inherit', + justifyContent: 'center', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + + + ); +} diff --git a/app/frontend/src/components/chart/components/chart-select.tsx b/app/frontend/src/components/chart/components/chart-select.tsx new file mode 100644 index 00000000..4ffa680e --- /dev/null +++ b/app/frontend/src/components/chart/components/chart-select.tsx @@ -0,0 +1,82 @@ +import type { ButtonBaseProps } from '@mui/material/ButtonBase'; + +import { varAlpha } from 'minimal-shared/utils'; +import { usePopover } from 'minimal-shared/hooks'; + +import MenuList from '@mui/material/MenuList'; +import MenuItem from '@mui/material/MenuItem'; +import ButtonBase from '@mui/material/ButtonBase'; + +import { Iconify } from 'src/components/iconify'; + +import { CustomPopover } from '../../custom-popover'; + +import type { CustomPopoverProps } from '../../custom-popover'; + +// ---------------------------------------------------------------------- + +type ChartSelectProps = Omit & { + options: string[]; + value: string; + onChange: (newValue: string) => void; + slotProps?: { + button?: ButtonBaseProps; + popover?: CustomPopoverProps; + }; +}; + +export function ChartSelect({ options, value, onChange, slotProps, ...other }: ChartSelectProps) { + const { open, anchorEl, onClose, onOpen } = usePopover(); + + const renderMenuActions = () => ( + + + {options.map((option) => ( + { + onClose(); + onChange(option); + }} + > + {option} + + ))} + + + ); + + return ( + <> + ({ + pr: 1, + pl: 1.5, + gap: 1.5, + height: 34, + borderRadius: 1, + typography: 'subtitle2', + border: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.24)}`, + }), + ...(Array.isArray(slotProps?.button?.sx) + ? (slotProps?.button?.sx ?? []) + : [slotProps?.button?.sx]), + ]} + {...other} + > + {value} + + + + + {renderMenuActions()} + + ); +} diff --git a/app/frontend/src/components/chart/components/index.ts b/app/frontend/src/components/chart/components/index.ts new file mode 100644 index 00000000..371db2f6 --- /dev/null +++ b/app/frontend/src/components/chart/components/index.ts @@ -0,0 +1,5 @@ +export * from './chart-select'; + +export * from './chart-legends'; + +export * from './chart-loading'; diff --git a/app/frontend/src/components/chart/index.ts b/app/frontend/src/components/chart/index.ts new file mode 100644 index 00000000..9765b2be --- /dev/null +++ b/app/frontend/src/components/chart/index.ts @@ -0,0 +1,7 @@ +export * from './chart'; + +export * from './use-chart'; + +export * from './components'; + +export type * from './types'; diff --git a/app/frontend/src/components/chart/styles.css b/app/frontend/src/components/chart/styles.css new file mode 100644 index 00000000..4f16c93b --- /dev/null +++ b/app/frontend/src/components/chart/styles.css @@ -0,0 +1,56 @@ +.apexcharts-canvas { + /** + * Tooltip + */ + .apexcharts-tooltip { + min-width: 80px; + border-radius: 10px; + backdrop-filter: blur(6px); + color: var(--palette-text-primary); + box-shadow: var(--customShadows-dropdown); + background-color: rgba(var(--palette-background-defaultChannel) / 0.9); + } + .apexcharts-xaxistooltip { + border-radius: 10px; + border-color: transparent; + backdrop-filter: blur(6px); + color: var(--palette-text-primary); + box-shadow: var(--customShadows-dropdown); + background-color: rgba(var(--palette-background-defaultChannel) / 0.9); + &::before { + border-bottom-color: rgba(var(--palette-grey-500Channel) / 0.16); + } + &::after { + border-bottom-color: rgba(var(--palette-background-defaultChannel) / 0.9); + } + } + .apexcharts-tooltip-title { + font-weight: 700; + text-align: center; + color: var(--palette-text-secondary); + background-color: var(--palette-background-neutral); + } + /** + * Tooltip: group + */ + .apexcharts-tooltip-series-group { + padding: 4px 12px; + } + .apexcharts-tooltip-marker { + margin-right: 8px; + } + /** + * Legend + */ + .apexcharts-legend { + padding: 0; + } + .apexcharts-legend-marker { + margin-right: 6px; + } + .apexcharts-legend-text { + margin-left: 0; + padding-left: 0; + line-height: 18px; + } +} diff --git a/app/frontend/src/components/chart/types.ts b/app/frontend/src/components/chart/types.ts new file mode 100644 index 00000000..0e3922a8 --- /dev/null +++ b/app/frontend/src/components/chart/types.ts @@ -0,0 +1,14 @@ +import type { Theme, SxProps } from '@mui/material/styles'; +import type { Props as ApexProps } from 'react-apexcharts'; + +// ---------------------------------------------------------------------- + +export type ChartOptions = ApexProps['options']; + +export type ChartProps = React.ComponentProps<'div'> & + Pick & { + sx?: SxProps; + slotProps?: { + loading?: SxProps; + }; + }; diff --git a/app/frontend/src/components/chart/use-chart.ts b/app/frontend/src/components/chart/use-chart.ts new file mode 100644 index 00000000..2deca766 --- /dev/null +++ b/app/frontend/src/components/chart/use-chart.ts @@ -0,0 +1,227 @@ +import type { Theme } from '@mui/material/styles'; + +import { merge } from 'es-toolkit'; +import { varAlpha } from 'minimal-shared/utils'; + +import { useTheme } from '@mui/material/styles'; + +import type { ChartOptions } from './types'; + +// ---------------------------------------------------------------------- + +export function useChart(updatedOptions?: ChartOptions): ChartOptions { + const theme = useTheme(); + + const baseOptions = baseChartOptions(theme) ?? {}; + + return merge(baseOptions, updatedOptions ?? {}); +} + +// ---------------------------------------------------------------------- + +const baseChartOptions = (theme: Theme): ChartOptions => { + const LABEL_TOTAL = { + show: true, + label: 'Total', + color: theme.vars.palette.text.secondary, + fontSize: theme.typography.subtitle2.fontSize as string, + fontWeight: theme.typography.subtitle2.fontWeight, + }; + + const LABEL_VALUE = { + offsetY: 8, + color: theme.vars.palette.text.primary, + fontSize: theme.typography.h4.fontSize as string, + fontWeight: theme.typography.h4.fontWeight, + }; + + return { + /** ************************************** + * Chart + * https://apexcharts.com/docs/options/chart/animations/ + *************************************** */ + chart: { + toolbar: { show: false }, + zoom: { enabled: false }, + parentHeightOffset: 0, + fontFamily: theme.typography.fontFamily, + foreColor: theme.vars.palette.text.disabled, + animations: { + enabled: true, + speed: 360, + animateGradually: { enabled: true, delay: 120 }, + dynamicAnimation: { enabled: true, speed: 360 }, + }, + }, + + /** ************************************** + * Colors + * https://apexcharts.com/docs/options/colors/ + *************************************** */ + colors: [ + theme.palette.primary.main, + theme.palette.warning.main, + theme.palette.info.main, + theme.palette.error.main, + theme.palette.success.main, + theme.palette.warning.dark, + theme.palette.success.darker, + theme.palette.info.dark, + theme.palette.info.darker, + ], + + /** ************************************** + * States + * https://apexcharts.com/docs/options/states/ + *************************************** */ + states: { + hover: { filter: { type: 'darken' } }, + active: { filter: { type: 'darken' } }, + }, + + /** ************************************** + * Fill + * https://apexcharts.com/docs/options/fill/ + *************************************** */ + fill: { + opacity: 1, + gradient: { + type: 'vertical', + shadeIntensity: 0, + opacityFrom: 0.4, + opacityTo: 0, + stops: [0, 100], + }, + }, + + /** ************************************** + * Data labels + * https://apexcharts.com/docs/options/datalabels/ + *************************************** */ + dataLabels: { enabled: false }, + + /** ************************************** + * Stroke + * https://apexcharts.com/docs/options/stroke/ + *************************************** */ + stroke: { width: 2.5, curve: 'smooth', lineCap: 'round' }, + + /** ************************************** + * Grid + * https://apexcharts.com/docs/options/grid/ + *************************************** */ + grid: { + strokeDashArray: 3, + borderColor: theme.vars.palette.divider, + padding: { top: 0, right: 0, bottom: 0 }, + xaxis: { lines: { show: false } }, + }, + + /** ************************************** + * Axis + * https://apexcharts.com/docs/options/xaxis/ + * https://apexcharts.com/docs/options/yaxis/ + *************************************** */ + xaxis: { axisBorder: { show: false }, axisTicks: { show: false } }, + yaxis: { tickAmount: 5 }, + + /** ************************************** + * Markers + * https://apexcharts.com/docs/options/markers/ + *************************************** */ + markers: { + size: 0, + strokeColors: theme.vars.palette.background.paper, + }, + + /** ************************************** + * Tooltip + *************************************** */ + tooltip: { theme: 'false', fillSeriesColor: false, x: { show: true } }, + + /** ************************************** + * Legend + * https://apexcharts.com/docs/options/legend/ + *************************************** */ + legend: { + show: false, + position: 'top', + fontWeight: 500, + fontSize: '13px', + horizontalAlign: 'right', + markers: { shape: 'circle' }, + labels: { colors: theme.vars.palette.text.primary }, + itemMargin: { horizontal: 8, vertical: 8 }, + }, + + /** ************************************** + * plotOptions + *************************************** */ + plotOptions: { + /** + * bar + * https://apexcharts.com/docs/options/plotoptions/bar/ + */ + bar: { borderRadius: 4, columnWidth: '48%', borderRadiusApplication: 'end' }, + /** + * pie + donut + * https://apexcharts.com/docs/options/plotoptions/pie/ + */ + pie: { + donut: { labels: { show: true, value: { ...LABEL_VALUE }, total: { ...LABEL_TOTAL } } }, + }, + /** + * radialBar + * https://apexcharts.com/docs/options/plotoptions/radialbar/ + */ + radialBar: { + hollow: { margin: -8, size: '100%' }, + track: { + margin: -8, + strokeWidth: '50%', + background: varAlpha(theme.vars.palette.grey['500Channel'], 0.16), + }, + dataLabels: { value: { ...LABEL_VALUE }, total: { ...LABEL_TOTAL } }, + }, + /** + * radar + * https://apexcharts.com/docs/options/plotoptions/radar/ + */ + radar: { + polygons: { + fill: { colors: ['transparent'] }, + strokeColors: theme.vars.palette.divider, + connectorColors: theme.vars.palette.divider, + }, + }, + /** + * polarArea + * https://apexcharts.com/docs/options/plotoptions/polararea/ + */ + polarArea: { + rings: { strokeColor: theme.vars.palette.divider }, + spokes: { connectorColors: theme.vars.palette.divider }, + }, + /** + * heatmap + * https://apexcharts.com/docs/options/plotoptions/heatmap/ + */ + heatmap: { distributed: true }, + }, + + /** ************************************** + * Responsive + * https://apexcharts.com/docs/options/responsive/ + *************************************** */ + responsive: [ + { + breakpoint: theme.breakpoints.values.sm, // sm ~ 600 + options: { plotOptions: { bar: { borderRadius: 3, columnWidth: '80%' } } }, + }, + { + breakpoint: theme.breakpoints.values.md, // md ~ 900 + options: { plotOptions: { bar: { columnWidth: '60%' } } }, + }, + ], + }; +}; diff --git a/app/frontend/src/components/color-utils/classes.ts b/app/frontend/src/components/color-utils/classes.ts new file mode 100644 index 00000000..9bf4bbdc --- /dev/null +++ b/app/frontend/src/components/color-utils/classes.ts @@ -0,0 +1,18 @@ +import { createClasses } from 'src/theme/create-classes'; + +// ---------------------------------------------------------------------- + +export const colorPreviewClasses = { + root: createClasses('color__preview__root'), + item: createClasses('color__preview__item'), + label: createClasses('color__preview__label'), +}; + +export const colorPickerClasses = { + root: createClasses('color__picker__root'), + item: { + root: createClasses('color__picker__item__root'), + container: createClasses('color__picker__item__container'), + icon: createClasses('color__picker__item__icon'), + }, +}; diff --git a/app/frontend/src/components/color-utils/color-picker.tsx b/app/frontend/src/components/color-utils/color-picker.tsx new file mode 100644 index 00000000..b2d40eb3 --- /dev/null +++ b/app/frontend/src/components/color-utils/color-picker.tsx @@ -0,0 +1,182 @@ +import type { Theme, SxProps } from '@mui/material/styles'; + +import { forwardRef, useCallback } from 'react'; +import { varAlpha, mergeClasses } from 'minimal-shared/utils'; + +import ButtonBase from '@mui/material/ButtonBase'; +import { styled, alpha as hexAlpha } from '@mui/material/styles'; + +import { Iconify } from '../iconify'; +import { colorPickerClasses } from './classes'; + +// ---------------------------------------------------------------------- + +export type ColorPickerSlotProps = { + item?: React.ComponentProps; + itemContainer?: React.ComponentProps; + icon?: React.ComponentProps; +}; + +export type ColorPickerProps = Omit, 'onChange'> & { + sx?: SxProps; + size?: number; + options?: string[]; + limit?: 'auto' | number; + value?: string | string[]; + variant?: 'circular' | 'rounded' | 'square'; + onChange?: (value: string | string[]) => void; + slotProps?: ColorPickerSlotProps; +}; + +export const ColorPicker = forwardRef((props, ref) => { + const { + sx, + value, + size = 36, + onChange, + slotProps, + className, + options = [], + limit = 'auto', + variant = 'circular', + ...other + } = props; + + const isSingleSelect = typeof value === 'string'; + + const handleSelect = useCallback( + (color: string) => { + if (isSingleSelect) { + if (color !== value) { + onChange?.(color); + } + } else { + const selected = value as string[]; + + const newSelected = selected.includes(color) + ? selected.filter((currentColor) => currentColor !== color) + : [...selected, color]; + + onChange?.(newSelected); + } + }, + [onChange, value, isSingleSelect] + ); + + return ( + + {options.map((color) => { + const hasSelected = isSingleSelect ? value === color : (value as string[]).includes(color); + + return ( +
  • + handleSelect(color)} + className={colorPickerClasses.item.root} + {...slotProps?.item} + > + + + + +
  • + ); + })} +
    + ); +}); + +// ---------------------------------------------------------------------- + +const ColorPickerRoot = styled('ul', { + shouldForwardProp: (prop: string) => !['limit', 'sx'].includes(prop), +})>(({ limit }) => ({ + flexWrap: 'wrap', + flexDirection: 'row', + display: 'inline-flex', + '& > li': { display: 'inline-flex' }, + ...(typeof limit === 'number' && { + justifyContent: 'flex-end', + width: `calc(var(--item-size) * ${limit})`, + }), +})); + +const ItemRoot = styled(ButtonBase)(() => ({ + width: 'var(--item-size)', + height: 'var(--item-size)', + borderRadius: 'var(--item-radius)', +})); + +const ItemContainer = styled('span', { + shouldForwardProp: (prop: string) => !['color', 'hasSelected', 'sx'].includes(prop), +})<{ color: string; hasSelected: boolean }>(({ color, theme }) => ({ + alignItems: 'center', + display: 'inline-flex', + borderRadius: 'inherit', + justifyContent: 'center', + backgroundColor: color, + width: 'calc(var(--item-size) - 16px)', + height: 'calc(var(--item-size) - 16px)', + border: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.16)}`, + transition: theme.transitions.create(['all'], { + duration: theme.transitions.duration.shortest, + }), + variants: [ + { + props: { hasSelected: true }, + style: { + width: 'calc(var(--item-size) - 8px)', + height: 'calc(var(--item-size) - 8px)', + outline: `solid 2px ${hexAlpha(color, 0.08)}`, + boxShadow: `4px 4px 8px 0 ${hexAlpha(color, 0.48)}`, + }, + }, + ], +})); + +const ItemIcon = styled(Iconify, { + shouldForwardProp: (prop: string) => !['color', 'hasSelected', 'sx'].includes(prop), +})<{ color: string; hasSelected: boolean }>(({ color, theme }) => ({ + width: 0, + height: 0, + color: theme.palette.getContrastText(color), + transition: theme.transitions.create(['all'], { + duration: theme.transitions.duration.shortest, + }), + variants: [ + { + props: { hasSelected: true }, + style: { + width: 'calc(var(--item-size) / 2.4)', + height: 'calc(var(--item-size) / 2.4)', + }, + }, + ], +})); diff --git a/app/frontend/src/components/color-utils/color-preview.tsx b/app/frontend/src/components/color-utils/color-preview.tsx new file mode 100644 index 00000000..ccc0c47b --- /dev/null +++ b/app/frontend/src/components/color-utils/color-preview.tsx @@ -0,0 +1,85 @@ +import { forwardRef } from 'react'; +import { varAlpha, mergeClasses } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; + +import { colorPreviewClasses } from './classes'; + +// ---------------------------------------------------------------------- + +export type ColorPreviewSlotProps = { + item?: React.ComponentProps; + label?: React.ComponentProps; +}; + +export type ColorPreviewProps = React.ComponentProps & { + limit?: number; + size?: number; + gap?: number; + colors: string[]; + slotProps?: ColorPreviewSlotProps; +}; + +export const ColorPreview = forwardRef((props, ref) => { + const { sx, colors, limit = 3, size = 16, gap = 6, className, slotProps, ...other } = props; + + const colorsRange = colors.slice(0, limit); + const remainingColorCount = colors.length - limit; + + return ( + + {colorsRange.map((color, index) => ( + + ))} + + {colors.length > limit && ( + {`+${remainingColorCount}`} + )} + + ); +}); + +// ---------------------------------------------------------------------- + +const ColorPreviewRoot = styled('ul')(() => ({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', +})); + +const ItemRoot = styled('li')(({ theme }) => ({ + borderRadius: '50%', + width: 'var(--item-size)', + height: 'var(--item-size)', + marginLeft: 'var(--item-gap)', + backgroundColor: 'var(--item-color)', + border: `solid 2px ${theme.vars.palette.background.paper}`, + boxShadow: `inset -1px 1px 2px ${varAlpha(theme.vars.palette.common.blackChannel, 0.24)}`, +})); + +const ItemLabel = styled('li')(({ theme }) => ({ + ...theme.typography.subtitle2, +})); diff --git a/app/frontend/src/components/color-utils/index.ts b/app/frontend/src/components/color-utils/index.ts new file mode 100644 index 00000000..efdd9796 --- /dev/null +++ b/app/frontend/src/components/color-utils/index.ts @@ -0,0 +1,5 @@ +export * from './classes'; + +export * from './color-picker'; + +export * from './color-preview'; diff --git a/app/frontend/src/components/country-select/country-select.tsx b/app/frontend/src/components/country-select/country-select.tsx new file mode 100644 index 00000000..24a82391 --- /dev/null +++ b/app/frontend/src/components/country-select/country-select.tsx @@ -0,0 +1,191 @@ +import type { TextFieldProps } from '@mui/material/TextField'; +import type { + AutocompleteProps, + AutocompleteRenderInputParams, + AutocompleteRenderGetTagProps, +} from '@mui/material/Autocomplete'; + +import { useMemo, useCallback } from 'react'; + +import Chip from '@mui/material/Chip'; +import TextField from '@mui/material/TextField'; +import Autocomplete from '@mui/material/Autocomplete'; +import InputAdornment from '@mui/material/InputAdornment'; +import { filledInputClasses } from '@mui/material/FilledInput'; +import { outlinedInputClasses } from '@mui/material/OutlinedInput'; + +import { countries } from 'src/assets/data'; + +import { FlagIcon, flagIconClasses } from 'src/components/flag-icon'; + +// ---------------------------------------------------------------------- + +type Value = string; + +export type AutocompleteBaseProps = Omit< + AutocompleteProps, + 'options' | 'renderOption' | 'renderInput' | 'renderTags' | 'getOptionLabel' +>; + +export type CountrySelectProps = AutocompleteBaseProps & { + label?: string; + error?: boolean; + placeholder?: string; + hiddenLabel?: boolean; + getValue?: 'label' | 'code'; + helperText?: React.ReactNode; + variant?: TextFieldProps['variant']; +}; + +export function CountrySelect({ + id, + label, + error, + variant, + multiple, + helperText, + hiddenLabel, + placeholder, + getValue = 'label', + ...other +}: CountrySelectProps) { + const options = useMemo( + () => countries.map((country) => (getValue === 'label' ? country.label : country.code)), + [getValue] + ); + + const getCountry = useCallback((inputValue: string) => { + const country = countries.find( + (op) => op.label === inputValue || op.code === inputValue || op.phone === inputValue + ); + return { + code: country?.code || '', + label: country?.label || '', + phone: country?.phone || '', + }; + }, []); + + const renderOption = useCallback( + (props: React.HTMLAttributes, option: Value) => { + const country = getCountry(option); + + return ( +
  • + + {country.label} ({country.code}) +{country.phone} +
  • + ); + }, + [getCountry] + ); + + const renderInput = useCallback( + (params: AutocompleteRenderInputParams) => { + const country = getCountry(params.inputProps.value as Value); + + const baseField = { + ...params, + label, + variant, + placeholder, + helperText, + hiddenLabel, + error: !!error, + inputProps: { ...params.inputProps, autoComplete: 'new-password' }, + }; + + if (multiple) { + return ; + } + + return ( + + + + ), + }, + }} + sx={{ + [`& .${outlinedInputClasses.root}`]: { + [`& .${flagIconClasses.root}`]: { ml: 0.5, mr: -0.5 }, + }, + [`& .${filledInputClasses.root}`]: { + [`& .${flagIconClasses.root}`]: { ml: 0.5, mr: -0.5, mt: hiddenLabel ? 0 : -2 }, + }, + }} + /> + ); + }, + [getCountry, label, variant, placeholder, helperText, hiddenLabel, error, multiple] + ); + + const renderTags = useCallback( + (selected: Value[], getTagProps: AutocompleteRenderGetTagProps) => + selected.map((option, index) => { + const country = getCountry(option); + + return ( + + } + /> + ); + }), + [getCountry] + ); + + const getOptionLabel = useCallback( + (option: Value) => { + if (getValue === 'code') { + const country = countries.find((op) => op.code === option); + return country?.label ?? ''; + } + return option; + }, + [getValue] + ); + + return ( + + ); +} diff --git a/app/frontend/src/components/country-select/index.ts b/app/frontend/src/components/country-select/index.ts new file mode 100644 index 00000000..437d9a50 --- /dev/null +++ b/app/frontend/src/components/country-select/index.ts @@ -0,0 +1 @@ +export * from './country-select'; diff --git a/app/frontend/src/components/custom-breadcrumbs/back-link.tsx b/app/frontend/src/components/custom-breadcrumbs/back-link.tsx new file mode 100644 index 00000000..dff730f1 --- /dev/null +++ b/app/frontend/src/components/custom-breadcrumbs/back-link.tsx @@ -0,0 +1,50 @@ +import type { LinkProps } from '@mui/material/Link'; + +import Link from '@mui/material/Link'; + +import { RouterLink } from 'src/routes/components'; + +import { Iconify, iconifyClasses } from 'src/components/iconify'; + +// ---------------------------------------------------------------------- + +export type BackLinkProps = LinkProps & { + label?: string; +}; + +export function BackLink({ sx, label, ...other }: BackLinkProps) { + return ( + ({ + verticalAlign: 'middle', + [`& .${iconifyClasses.root}`]: { + verticalAlign: 'inherit', + transform: 'translateY(-2px)', + ml: { + xs: '-14px', + md: '-18px', + }, + transition: theme.transitions.create(['opacity'], { + duration: theme.transitions.duration.shorter, + easing: theme.transitions.easing.sharp, + }), + }, + '&:hover': { + [`& .${iconifyClasses.root}`]: { + opacity: 0.48, + }, + }, + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + + {label} + + ); +} diff --git a/app/frontend/src/components/custom-breadcrumbs/breadcrumb-link.tsx b/app/frontend/src/components/custom-breadcrumbs/breadcrumb-link.tsx new file mode 100644 index 00000000..b74d9041 --- /dev/null +++ b/app/frontend/src/components/custom-breadcrumbs/breadcrumb-link.tsx @@ -0,0 +1,69 @@ +import type { Theme, SxProps } from '@mui/material/styles'; + +import Link from '@mui/material/Link'; +import { styled } from '@mui/material/styles'; + +import { RouterLink } from 'src/routes/components'; + +// ---------------------------------------------------------------------- + +export type BreadcrumbsLinkProps = React.ComponentProps<'div'> & { + name?: string; + href?: string; + disabled?: boolean; + icon?: React.ReactNode; + sx?: SxProps; +}; + +export function BreadcrumbsLink({ href, icon, name, disabled, ...other }: BreadcrumbsLinkProps) { + const renderContent = () => ( + + {icon && {icon}} + {name} + + ); + + if (href) { + return ( + + {renderContent()} + + ); + } + + return renderContent(); +} + +// ---------------------------------------------------------------------- + +const ItemRoot = styled('div', { + shouldForwardProp: (prop: string) => !['disabled', 'sx'].includes(prop), +})>(({ disabled, theme }) => ({ + ...theme.typography.body2, + alignItems: 'center', + gap: theme.spacing(1), + display: 'inline-flex', + color: theme.vars.palette.text.primary, + ...(disabled && { + cursor: 'default', + pointerEvents: 'none', + color: theme.vars.palette.text.disabled, + }), +})); + +const ItemIcon = styled('span')(() => ({ + display: 'inherit', + /** + * As ':first-child' for ssr + * https://github.com/emotion-js/emotion/issues/1105#issuecomment-1126025608 + */ + '& > :first-of-type:not(style):not(:first-of-type ~ *), & > style + *': { width: 20, height: 20 }, +})); diff --git a/app/frontend/src/components/custom-breadcrumbs/custom-breadcrumbs.tsx b/app/frontend/src/components/custom-breadcrumbs/custom-breadcrumbs.tsx new file mode 100644 index 00000000..277ce122 --- /dev/null +++ b/app/frontend/src/components/custom-breadcrumbs/custom-breadcrumbs.tsx @@ -0,0 +1,96 @@ +import type { Theme, SxProps } from '@mui/material/styles'; +import type { BreadcrumbsProps } from '@mui/material/Breadcrumbs'; + +import Breadcrumbs from '@mui/material/Breadcrumbs'; + +import { BackLink } from './back-link'; +import { MoreLinks } from './more-links'; +import { BreadcrumbsLink } from './breadcrumb-link'; +import { + BreadcrumbsRoot, + BreadcrumbsHeading, + BreadcrumbsContent, + BreadcrumbsContainer, + BreadcrumbsSeparator, +} from './styles'; + +import type { MoreLinksProps } from './more-links'; +import type { BreadcrumbsLinkProps } from './breadcrumb-link'; + +// ---------------------------------------------------------------------- + +export type CustomBreadcrumbsSlotProps = { + breadcrumbs: BreadcrumbsProps; + moreLinks: Omit; + heading: React.ComponentProps; + content: React.ComponentProps; + container: React.ComponentProps; +}; + +export type CustomBreadcrumbsSlots = { + breadcrumbs?: React.ReactNode; +}; + +export type CustomBreadcrumbsProps = React.ComponentProps<'div'> & { + sx?: SxProps; + heading?: string; + activeLast?: boolean; + backHref?: string; + action?: React.ReactNode; + links?: BreadcrumbsLinkProps[]; + moreLinks?: MoreLinksProps['links']; + slots?: CustomBreadcrumbsSlots; + slotProps?: Partial; +}; + +export function CustomBreadcrumbs({ + sx, + action, + backHref, + heading, + slots = {}, + links = [], + moreLinks = [], + slotProps = {}, + activeLast = false, + ...other +}: CustomBreadcrumbsProps) { + const lastLink = links[links.length - 1]?.name; + + const renderHeading = () => ( + + {backHref ? : heading} + + ); + + const renderLinks = () => + slots?.breadcrumbs ?? ( + } {...slotProps?.breadcrumbs}> + {links.map((link, index) => ( + + ))} + + ); + + const renderMoreLinks = () => ; + + return ( + + + + {(heading || backHref) && renderHeading()} + {(!!links.length || slots?.breadcrumbs) && renderLinks()} + + {action} + + + {!!moreLinks?.length && renderMoreLinks()} + + ); +} diff --git a/app/frontend/src/components/custom-breadcrumbs/index.ts b/app/frontend/src/components/custom-breadcrumbs/index.ts new file mode 100644 index 00000000..a5569c05 --- /dev/null +++ b/app/frontend/src/components/custom-breadcrumbs/index.ts @@ -0,0 +1 @@ +export * from './custom-breadcrumbs'; diff --git a/app/frontend/src/components/custom-breadcrumbs/more-links.tsx b/app/frontend/src/components/custom-breadcrumbs/more-links.tsx new file mode 100644 index 00000000..32c00526 --- /dev/null +++ b/app/frontend/src/components/custom-breadcrumbs/more-links.tsx @@ -0,0 +1,30 @@ +import Link from '@mui/material/Link'; +import { styled } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +export type MoreLinksProps = React.ComponentProps & { + links?: string[]; +}; + +export function MoreLinks({ links, sx, ...other }: MoreLinksProps) { + return ( + + {links?.map((href) => ( +
  • + + {href} + +
  • + ))} +
    + ); +} + +// ---------------------------------------------------------------------- + +const MoreLinksRoot = styled('ul')(() => ({ + display: 'flex', + flexDirection: 'column', + '& > li': { display: 'flex' }, +})); diff --git a/app/frontend/src/components/custom-breadcrumbs/styles.ts b/app/frontend/src/components/custom-breadcrumbs/styles.ts new file mode 100644 index 00000000..437d2839 --- /dev/null +++ b/app/frontend/src/components/custom-breadcrumbs/styles.ts @@ -0,0 +1,38 @@ +import { styled } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +export const BreadcrumbsRoot = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), +})); + +export const BreadcrumbsHeading = styled('h6')(({ theme }) => ({ + ...theme.typography.h4, + margin: 0, + padding: 0, + display: 'inline-flex', +})); + +export const BreadcrumbsContainer = styled('div')(({ theme }) => ({ + display: 'flex', + flexWrap: 'wrap', + gap: theme.spacing(2), + alignItems: 'flex-start', + justifyContent: 'flex-end', +})); + +export const BreadcrumbsContent = styled('div')(({ theme }) => ({ + display: 'flex', + flex: '1 1 auto', + gap: theme.spacing(2), + flexDirection: 'column', +})); + +export const BreadcrumbsSeparator = styled('span')(({ theme }) => ({ + width: 4, + height: 4, + borderRadius: '50%', + backgroundColor: theme.vars.palette.text.disabled, +})); diff --git a/app/frontend/src/components/custom-date-range-picker/custom-date-range-picker.tsx b/app/frontend/src/components/custom-date-range-picker/custom-date-range-picker.tsx new file mode 100644 index 00000000..086d209b --- /dev/null +++ b/app/frontend/src/components/custom-date-range-picker/custom-date-range-picker.tsx @@ -0,0 +1,119 @@ +import type { DialogProps } from '@mui/material/Dialog'; +import type { Theme, SxProps } from '@mui/material/styles'; + +import { useCallback } from 'react'; + +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import { useTheme } from '@mui/material/styles'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import FormHelperText from '@mui/material/FormHelperText'; +import { DatePicker } from '@mui/x-date-pickers/DatePicker'; +import { DateCalendar } from '@mui/x-date-pickers/DateCalendar'; + +import type { UseDateRangePickerReturn } from './use-date-range-picker'; + +// ---------------------------------------------------------------------- + +export type CustomDateRangePickerProps = DialogProps & + UseDateRangePickerReturn & { onSubmit?: () => void }; + +export function CustomDateRangePicker({ + open, + error, + onClose, + onSubmit, + /********/ + startDate, + endDate, + onChangeStartDate, + onChangeEndDate, + /********/ + PaperProps, + variant = 'input', + title = 'Select date range', + ...other +}: CustomDateRangePickerProps) { + const theme = useTheme(); + const mdUp = useMediaQuery(theme.breakpoints.up('md')); + + const isCalendarView = variant === 'calendar'; + + const handleSubmit = useCallback(() => { + onClose(); + onSubmit?.(); + }, [onClose, onSubmit]); + + const contentStyles: SxProps = { + py: 1, + gap: 3, + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + ...(isCalendarView && mdUp && { flexDirection: 'row' }), + }; + + const blockStyles: SxProps = { + borderRadius: 2, + border: `dashed 1px ${theme.vars.palette.divider}`, + }; + + return ( + + {title} + + + + {isCalendarView ? ( + <> + + + + + + + + + ) : ( + <> + + + + )} + + + {error && ( + + End date must be later than start date + + )} + + + + + + + + ); +} diff --git a/app/frontend/src/components/custom-date-range-picker/index.ts b/app/frontend/src/components/custom-date-range-picker/index.ts new file mode 100644 index 00000000..e659c9c7 --- /dev/null +++ b/app/frontend/src/components/custom-date-range-picker/index.ts @@ -0,0 +1,3 @@ +export * from './use-date-range-picker'; + +export * from './custom-date-range-picker'; diff --git a/app/frontend/src/components/custom-date-range-picker/use-date-range-picker.ts b/app/frontend/src/components/custom-date-range-picker/use-date-range-picker.ts new file mode 100644 index 00000000..68e893f0 --- /dev/null +++ b/app/frontend/src/components/custom-date-range-picker/use-date-range-picker.ts @@ -0,0 +1,91 @@ +import type { IDatePickerControl } from 'src/types/common'; + +import { useState, useCallback } from 'react'; + +import { fIsAfter, fDateRangeShortLabel } from 'src/utils/format-time'; + +// ---------------------------------------------------------------------- + +export type UseDateRangePickerReturn = { + startDate: IDatePickerControl; + endDate: IDatePickerControl; + onChangeStartDate: (newValue: IDatePickerControl) => void; + onChangeEndDate: (newValue: IDatePickerControl) => void; + /********/ + open: boolean; + onOpen?: () => void; + onClose: () => void; + onReset?: () => void; + /********/ + selected?: boolean; + error?: boolean; + /********/ + label?: string; + shortLabel?: string; + /********/ + title?: string; + variant?: 'calendar' | 'input'; + /********/ + setStartDate?: React.Dispatch>; + setEndDate?: React.Dispatch>; +}; + +export function useDateRangePicker( + start: IDatePickerControl, + end: IDatePickerControl +): UseDateRangePickerReturn { + const [open, setOpen] = useState(false); + + const [endDate, setEndDate] = useState(end as IDatePickerControl); + const [startDate, setStartDate] = useState(start as IDatePickerControl); + + const error = fIsAfter(startDate, endDate); + + const onOpen = useCallback(() => { + setOpen(true); + }, []); + + const onClose = useCallback(() => { + setOpen(false); + }, []); + + const onChangeStartDate = useCallback((newValue: IDatePickerControl) => { + setStartDate(newValue); + }, []); + + const onChangeEndDate = useCallback( + (newValue: IDatePickerControl) => { + if (error) { + setEndDate(null); + } + setEndDate(newValue); + }, + [error] + ); + + const onReset = useCallback(() => { + setStartDate(null); + setEndDate(null); + }, []); + + return { + startDate: startDate as IDatePickerControl, + endDate: endDate as IDatePickerControl, + onChangeStartDate, + onChangeEndDate, + /********/ + open, + onOpen, + onClose, + onReset, + /********/ + error, + selected: !!startDate && !!endDate, + /********/ + label: fDateRangeShortLabel(startDate, endDate, true), + shortLabel: fDateRangeShortLabel(startDate, endDate), + /********/ + setStartDate, + setEndDate, + }; +} diff --git a/app/frontend/src/components/custom-dialog/confirm-dialog.tsx b/app/frontend/src/components/custom-dialog/confirm-dialog.tsx new file mode 100644 index 00000000..366f4078 --- /dev/null +++ b/app/frontend/src/components/custom-dialog/confirm-dialog.tsx @@ -0,0 +1,34 @@ +import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; + +import type { ConfirmDialogProps } from './types'; + +// ---------------------------------------------------------------------- + +export function ConfirmDialog({ + open, + title, + action, + content, + onClose, + ...other +}: ConfirmDialogProps) { + return ( + + {title} + + {content && {content} } + + + {action} + + + + + ); +} diff --git a/app/frontend/src/components/custom-dialog/index.ts b/app/frontend/src/components/custom-dialog/index.ts new file mode 100644 index 00000000..ff0d9972 --- /dev/null +++ b/app/frontend/src/components/custom-dialog/index.ts @@ -0,0 +1 @@ +export * from './confirm-dialog'; diff --git a/app/frontend/src/components/custom-dialog/types.ts b/app/frontend/src/components/custom-dialog/types.ts new file mode 100644 index 00000000..97a44136 --- /dev/null +++ b/app/frontend/src/components/custom-dialog/types.ts @@ -0,0 +1,10 @@ +import type { DialogProps } from '@mui/material/Dialog'; + +// ---------------------------------------------------------------------- + +export type ConfirmDialogProps = Omit & { + onClose: () => void; + title: React.ReactNode; + action: React.ReactNode; + content?: React.ReactNode; +}; diff --git a/app/frontend/src/components/custom-popover/custom-popover.tsx b/app/frontend/src/components/custom-popover/custom-popover.tsx new file mode 100644 index 00000000..5e992795 --- /dev/null +++ b/app/frontend/src/components/custom-popover/custom-popover.tsx @@ -0,0 +1,64 @@ +import Popover from '@mui/material/Popover'; +import { listClasses } from '@mui/material/List'; +import { menuItemClasses } from '@mui/material/MenuItem'; + +import { Arrow } from './styles'; +import { calculateAnchorOrigin } from './utils'; + +import type { CustomPopoverProps } from './types'; + +// ---------------------------------------------------------------------- + +export function CustomPopover({ + open, + onClose, + children, + anchorEl, + slotProps, + ...other +}: CustomPopoverProps) { + const { arrow: arrowProps, paper: paperProps, ...otherSlotProps } = slotProps ?? {}; + + const arrowSize = arrowProps?.size ?? 14; + const arrowOffset = arrowProps?.offset ?? 17; + const arrowPlacement = arrowProps?.placement ?? 'top-right'; + + const { paperStyles, anchorOrigin, transformOrigin } = calculateAnchorOrigin(arrowPlacement); + + return ( + + {!arrowProps?.hide && ( + + )} + + {children} + + ); +} diff --git a/app/frontend/src/components/custom-popover/index.ts b/app/frontend/src/components/custom-popover/index.ts new file mode 100644 index 00000000..8bf76ce0 --- /dev/null +++ b/app/frontend/src/components/custom-popover/index.ts @@ -0,0 +1,3 @@ +export * from './custom-popover'; + +export type * from './types'; diff --git a/app/frontend/src/components/custom-popover/styles.tsx b/app/frontend/src/components/custom-popover/styles.tsx new file mode 100644 index 00000000..3dcd3b9e --- /dev/null +++ b/app/frontend/src/components/custom-popover/styles.tsx @@ -0,0 +1,140 @@ +import type { Theme, CSSObject } from '@mui/material/styles'; + +import { varAlpha } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; + +import type { PopoverArrow } from './types'; + +// ---------------------------------------------------------------------- + +const centerStyles: Record = { + hCenter: { left: 0, right: 0, margin: 'auto' }, + vCenter: { top: 0, bottom: 0, margin: 'auto' }, +}; + +const getRtlPosition = (position: 'left' | 'right', isRtl: boolean, value: number): CSSObject => ({ + [position]: isRtl ? 'auto' : value, + [position === 'left' ? 'right' : 'left']: isRtl ? value : 'auto', +}); + +const createBackgroundStyles = (theme: Theme, color: 'cyan' | 'red', size: number): CSSObject => { + const colorChannel = theme.vars.palette[color === 'cyan' ? 'info' : 'error'].mainChannel; + + return { + backgroundRepeat: 'no-repeat', + backgroundSize: `${size * 3}px ${size * 3}px`, + backgroundColor: theme.vars.palette.background.paper, + backgroundPosition: color === 'cyan' ? 'top right' : 'bottom left', + backgroundImage: `linear-gradient(45deg, ${varAlpha(colorChannel, 0.1)}, ${varAlpha(colorChannel, 0.1)})`, + }; +}; + +const arrowDirection: Record = { + top: { top: 0, rotate: '135deg', translate: '0 -50%' }, + bottom: { bottom: 0, rotate: '-45deg', translate: '0 50%' }, + left: { rotate: '45deg', translate: '-50% 0' }, + right: { rotate: '-135deg', translate: '50% 0' }, +}; + +export const Arrow = styled('span', { + shouldForwardProp: (prop: string) => !['size', 'placement', 'offset', 'sx'].includes(prop), +})(({ offset = 0, size = 0, theme }) => { + const isRtl = theme.direction === 'rtl'; + + const cyanBackgroundStyles = createBackgroundStyles(theme, 'cyan', size); + const redBackgroundStyles = createBackgroundStyles(theme, 'red', size); + + return { + width: size, + height: size, + position: 'absolute', + backdropFilter: '6px', + borderBottomLeftRadius: size / 4, + clipPath: 'polygon(0% 0%, 100% 100%, 0% 100%)', + backgroundColor: theme.vars.palette.background.paper, + border: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.12)}`, + ...theme.applyStyles('dark', { + border: `solid 1px ${varAlpha(theme.vars.palette.common.blackChannel, 0.12)}`, + }), + + variants: [ + /** + * @position top* + */ + { + props: ({ placement }) => placement?.startsWith('top-'), + style: { ...arrowDirection.top }, + }, + { + props: { placement: 'top-left' }, + style: { ...getRtlPosition('left', isRtl, offset) }, + }, + { + props: { placement: 'top-center' }, + style: { ...centerStyles.hCenter }, + }, + { + props: { placement: 'top-right' }, + style: { ...getRtlPosition('right', isRtl, offset), ...cyanBackgroundStyles }, + }, + /** + * @position bottom* + */ + { + props: ({ placement }) => placement?.startsWith('bottom-'), + style: { ...arrowDirection.bottom }, + }, + { + props: { placement: 'bottom-left' }, + style: { ...getRtlPosition('left', isRtl, offset), ...redBackgroundStyles }, + }, + { + props: { placement: 'bottom-center' }, + style: { ...centerStyles.hCenter }, + }, + { + props: { placement: 'bottom-right' }, + style: { ...getRtlPosition('right', isRtl, offset) }, + }, + /** + * @position left* + */ + { + props: ({ placement }) => placement?.startsWith('left-'), + style: { ...getRtlPosition('left', isRtl, 0), ...arrowDirection.left }, + }, + { + props: { placement: 'left-top' }, + style: { top: offset }, + }, + { + props: { placement: 'left-center' }, + style: { ...centerStyles.vCenter, ...redBackgroundStyles }, + }, + { + props: { placement: 'left-bottom' }, + style: { ...redBackgroundStyles, bottom: offset }, + }, + /** + * @position right* + */ + { + props: ({ placement }) => placement?.startsWith('right-'), + style: { ...getRtlPosition('right', isRtl, 0), ...arrowDirection.right }, + }, + { + props: { placement: 'right-top' }, + style: { ...cyanBackgroundStyles, top: offset }, + }, + { + props: { placement: 'right-center' }, + style: { ...centerStyles.vCenter, ...cyanBackgroundStyles }, + }, + { + props: { placement: 'right-bottom' }, + style: { bottom: offset }, + }, + ], + }; +}); diff --git a/app/frontend/src/components/custom-popover/types.ts b/app/frontend/src/components/custom-popover/types.ts new file mode 100644 index 00000000..b7a96d4f --- /dev/null +++ b/app/frontend/src/components/custom-popover/types.ts @@ -0,0 +1,32 @@ +import type { PaperProps } from '@mui/material/Paper'; +import type { PopoverProps } from '@mui/material/Popover'; +import type { Theme, SxProps } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +export type PopoverArrow = { + hide?: boolean; + size?: number; + offset?: number; + sx?: SxProps; + placement?: + | 'top-left' + | 'top-center' + | 'top-right' + | 'bottom-left' + | 'bottom-center' + | 'bottom-right' + | 'left-top' + | 'left-center' + | 'left-bottom' + | 'right-top' + | 'right-center' + | 'right-bottom'; +}; + +export type CustomPopoverProps = PopoverProps & { + slotProps?: PopoverProps['slotProps'] & { + arrow?: PopoverArrow; + paper?: PaperProps; + }; +}; diff --git a/app/frontend/src/components/custom-popover/utils.ts b/app/frontend/src/components/custom-popover/utils.ts new file mode 100644 index 00000000..ca4eefd0 --- /dev/null +++ b/app/frontend/src/components/custom-popover/utils.ts @@ -0,0 +1,129 @@ +import type { CSSObject } from '@mui/material/styles'; +import type { PopoverOrigin } from '@mui/material/Popover'; + +import type { PopoverArrow } from './types'; + +// ---------------------------------------------------------------------- + +const POPOVER_DISTANCE = 0.75; + +export type CalculateAnchorOriginProps = { + paperStyles?: CSSObject; + anchorOrigin: PopoverOrigin; + transformOrigin: PopoverOrigin; +}; + +export function calculateAnchorOrigin( + arrow: PopoverArrow['placement'] +): CalculateAnchorOriginProps { + let props: CalculateAnchorOriginProps; + + switch (arrow) { + /** + * top-* + */ + case 'top-left': + props = { + paperStyles: { ml: -POPOVER_DISTANCE }, + anchorOrigin: { vertical: 'bottom', horizontal: 'left' }, + transformOrigin: { vertical: 'top', horizontal: 'left' }, + }; + break; + case 'top-center': + props = { + paperStyles: undefined, + anchorOrigin: { vertical: 'bottom', horizontal: 'center' }, + transformOrigin: { vertical: 'top', horizontal: 'center' }, + }; + break; + case 'top-right': + props = { + paperStyles: { ml: POPOVER_DISTANCE }, + anchorOrigin: { vertical: 'bottom', horizontal: 'right' }, + transformOrigin: { vertical: 'top', horizontal: 'right' }, + }; + break; + /** + * bottom-* + */ + case 'bottom-left': + props = { + paperStyles: { ml: -POPOVER_DISTANCE }, + anchorOrigin: { vertical: 'top', horizontal: 'left' }, + transformOrigin: { vertical: 'bottom', horizontal: 'left' }, + }; + break; + case 'bottom-center': + props = { + paperStyles: undefined, + anchorOrigin: { vertical: 'top', horizontal: 'center' }, + transformOrigin: { vertical: 'bottom', horizontal: 'center' }, + }; + break; + case 'bottom-right': + props = { + paperStyles: { ml: POPOVER_DISTANCE }, + anchorOrigin: { vertical: 'top', horizontal: 'right' }, + transformOrigin: { vertical: 'bottom', horizontal: 'right' }, + }; + break; + /** + * left-* + */ + case 'left-top': + props = { + paperStyles: { mt: -POPOVER_DISTANCE }, + anchorOrigin: { vertical: 'top', horizontal: 'right' }, + transformOrigin: { vertical: 'top', horizontal: 'left' }, + }; + break; + case 'left-center': + props = { + paperStyles: undefined, + anchorOrigin: { vertical: 'center', horizontal: 'right' }, + transformOrigin: { vertical: 'center', horizontal: 'left' }, + }; + break; + case 'left-bottom': + props = { + paperStyles: { mt: POPOVER_DISTANCE }, + anchorOrigin: { vertical: 'bottom', horizontal: 'right' }, + transformOrigin: { vertical: 'bottom', horizontal: 'left' }, + }; + break; + /** + * right-* + */ + case 'right-top': + props = { + paperStyles: { mt: -POPOVER_DISTANCE }, + anchorOrigin: { vertical: 'top', horizontal: 'left' }, + transformOrigin: { vertical: 'top', horizontal: 'right' }, + }; + break; + case 'right-center': + props = { + paperStyles: undefined, + anchorOrigin: { vertical: 'center', horizontal: 'left' }, + transformOrigin: { vertical: 'center', horizontal: 'right' }, + }; + break; + case 'right-bottom': + props = { + paperStyles: { mt: POPOVER_DISTANCE }, + anchorOrigin: { vertical: 'bottom', horizontal: 'left' }, + transformOrigin: { vertical: 'bottom', horizontal: 'right' }, + }; + break; + + // top-right + default: + props = { + paperStyles: { ml: POPOVER_DISTANCE }, + anchorOrigin: { vertical: 'bottom', horizontal: 'right' }, + transformOrigin: { vertical: 'top', horizontal: 'right' }, + }; + } + + return props; +} diff --git a/app/frontend/src/components/custom-tabs/custom-tabs.tsx b/app/frontend/src/components/custom-tabs/custom-tabs.tsx new file mode 100644 index 00000000..dc815582 --- /dev/null +++ b/app/frontend/src/components/custom-tabs/custom-tabs.tsx @@ -0,0 +1,78 @@ +import type { TabsProps } from '@mui/material/Tabs'; +import type { Theme, SxProps } from '@mui/material/styles'; + +import NoSsr from '@mui/material/NoSsr'; +import { tabClasses } from '@mui/material/Tab'; +import Tabs, { tabsClasses } from '@mui/material/Tabs'; + +// ---------------------------------------------------------------------- + +export type CustomTabsProps = TabsProps & { + slotProps?: TabsProps['slotProps'] & { + scroller?: SxProps; + indicator?: SxProps; + tab?: SxProps; + selected?: SxProps; + scrollButtons?: SxProps; + flexContainer?: SxProps; + }; +}; + +export function CustomTabs({ children, slotProps, sx, ...other }: CustomTabsProps) { + return ( + ({ + gap: { sm: 0 }, + minHeight: 38, + flexShrink: 0, + alignItems: 'center', + bgcolor: 'background.neutral', + [`& .${tabsClasses.scroller}`]: { p: 1, ...slotProps?.scroller }, + [`& .${tabsClasses.flexContainer}`]: { gap: 0, ...slotProps?.flexContainer }, + [`& .${tabsClasses.scrollButtons}`]: { + borderRadius: 1, + minHeight: 'inherit', + ...slotProps?.scrollButtons, + }, + [`& .${tabsClasses.indicator}`]: { + py: 1, + height: 1, + bgcolor: 'transparent', + '& > span': { + width: 1, + height: 1, + borderRadius: 1, + display: 'block', + bgcolor: 'common.white', + boxShadow: theme.vars.customShadows.z1, + ...theme.applyStyles('dark', { + bgcolor: 'grey.900', + }), + ...slotProps?.indicator, + }, + }, + [`& .${tabClasses.root}`]: { + py: 1, + px: 2, + zIndex: 1, + minHeight: 'auto', + ...slotProps?.tab, + [`&.${tabClasses.selected}`]: { ...slotProps?.selected }, + }, + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + TabIndicatorProps={{ + children: ( + + + + ), + }} + > + {children} + + ); +} diff --git a/app/frontend/src/components/custom-tabs/index.ts b/app/frontend/src/components/custom-tabs/index.ts new file mode 100644 index 00000000..3daee9aa --- /dev/null +++ b/app/frontend/src/components/custom-tabs/index.ts @@ -0,0 +1 @@ +export * from './custom-tabs'; diff --git a/app/frontend/src/components/editor/classes.ts b/app/frontend/src/components/editor/classes.ts new file mode 100644 index 00000000..11cea50f --- /dev/null +++ b/app/frontend/src/components/editor/classes.ts @@ -0,0 +1,47 @@ +import { createClasses } from 'src/theme/create-classes'; + +// ---------------------------------------------------------------------- + +export const editorClasses = { + root: createClasses('editor__root'), + toolbar: { + hr: createClasses('editor__toolbar__hr'), + root: createClasses('editor__toolbar__root'), + bold: createClasses('editor__toolbar__bold'), + code: createClasses('editor__toolbar__code'), + undo: createClasses('editor__toolbar__undo'), + redo: createClasses('editor__toolbar__redo'), + link: createClasses('editor__toolbar__link'), + clear: createClasses('editor__toolbar__clear'), + image: createClasses('editor__toolbar__image'), + italic: createClasses('editor__toolbar__italic'), + strike: createClasses('editor__toolbar__strike'), + underline: createClasses('editor__toolbar__underline'), + hardbreak: createClasses('editor__toolbar__hardbreak'), + unsetlink: createClasses('editor__toolbar__unsetlink'), + codeBlock: createClasses('editor__toolbar__code__block'), + alignLeft: createClasses('editor__toolbar__align__left'), + fullscreen: createClasses('editor__toolbar__fullscreen'), + blockquote: createClasses('editor__toolbar__blockquote'), + bulletList: createClasses('editor__toolbar__bullet__list'), + alignRight: createClasses('editor__toolbar__align__right'), + orderedList: createClasses('editor__toolbar__ordered__list'), + alignCenter: createClasses('editor__toolbar__align__center'), + alignJustify: createClasses('editor__toolbar__align__justify'), + }, + content: { + hr: createClasses('editor__content__hr'), + root: createClasses('editor__content__root'), + link: createClasses('editor__content__link'), + image: createClasses('editor__content__image'), + codeInline: createClasses('editor__content__code'), + heading: createClasses('editor__content__heading'), + listItem: createClasses('editor__content__listItem'), + codeBlock: createClasses('editor__content__code__block'), + blockquote: createClasses('editor__content__blockquote'), + langSelect: createClasses('editor__content__lang__select'), + placeholder: createClasses('editor__content__placeholder'), + bulletList: createClasses('editor__content__bullet__list'), + orderedList: createClasses('editor__content__ordered__list'), + }, +}; diff --git a/app/frontend/src/components/editor/components/code-highlight-block.css b/app/frontend/src/components/editor/components/code-highlight-block.css new file mode 100644 index 00000000..82f6af81 --- /dev/null +++ b/app/frontend/src/components/editor/components/code-highlight-block.css @@ -0,0 +1,82 @@ +pre { + code[as='code'] { + .hljs-comment { + color: #999; + } + .hljs-tag { + color: #b4b7b4; + } + .hljs-operator, + .hljs-punctuation, + .hljs-subst { + color: #ccc; + } + .hljs-operator { + opacity: 0.7; + } + .hljs-bullet, + .hljs-deletion, + .hljs-name, + .hljs-selector-tag, + .hljs-template-variable, + .hljs-variable { + color: #f2777a; + } + .hljs-attr, + .hljs-link, + .hljs-literal, + .hljs-number, + .hljs-symbol, + .hljs-variable.constant_ { + color: #f99157; + } + .hljs-class .hljs-title, + .hljs-title, + .hljs-title.class_ { + color: #fc6; + } + .hljs-strong { + font-weight: 700; + color: #fc6; + } + .hljs-addition, + .hljs-code, + .hljs-string, + .hljs-title.class_.inherited__ { + color: #9c9; + } + .hljs-built_in, + .hljs-doctag, + .hljs-keyword.hljs-atrule, + .hljs-quote, + .hljs-regexp { + color: #6cc; + } + .hljs-attribute, + .hljs-function .hljs-title, + .hljs-section, + .hljs-title.function_, + .ruby .hljs-property { + color: #69c; + } + .diff .hljs-meta, + .hljs-keyword, + .hljs-template-tag, + .hljs-type { + color: #c9c; + } + .hljs-emphasis { + color: #c9c; + font-style: italic; + } + .hljs-meta, + .hljs-meta .hljs-keyword, + .hljs-meta .hljs-string { + color: #a3685a; + } + .hljs-meta .hljs-keyword, + .hljs-meta-keyword { + font-weight: 700; + } + } +} diff --git a/app/frontend/src/components/editor/components/code-highlight-block.tsx b/app/frontend/src/components/editor/components/code-highlight-block.tsx new file mode 100644 index 00000000..1d186db3 --- /dev/null +++ b/app/frontend/src/components/editor/components/code-highlight-block.tsx @@ -0,0 +1,41 @@ +import './code-highlight-block.css'; + +import type { NodeViewProps } from '@tiptap/react'; + +import { NodeViewContent, NodeViewWrapper } from '@tiptap/react'; + +import { editorClasses } from '../classes'; + +// ---------------------------------------------------------------------- + +export function CodeHighlightBlock({ + node: { + attrs: { language: defaultLanguage }, + }, + extension, + updateAttributes, +}: NodeViewProps) { + return ( + + + +
    +        
    +      
    +
    + ); +} diff --git a/app/frontend/src/components/editor/components/heading-block.tsx b/app/frontend/src/components/editor/components/heading-block.tsx new file mode 100644 index 00000000..b2703576 --- /dev/null +++ b/app/frontend/src/components/editor/components/heading-block.tsx @@ -0,0 +1,133 @@ +import { useState } from 'react'; +import { varAlpha } from 'minimal-shared/utils'; + +import Menu from '@mui/material/Menu'; +import { listClasses } from '@mui/material/List'; +import ButtonBase, { buttonBaseClasses } from '@mui/material/ButtonBase'; + +import { Iconify } from '../../iconify'; +import { ToolbarItem } from './toolbar-item'; + +import type { EditorToolbarProps } from '../types'; + +// ---------------------------------------------------------------------- + +export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6; + +const HEADING_OPTIONS = [ + 'Heading 1', + 'Heading 2', + 'Heading 3', + 'Heading 4', + 'Heading 5', + 'Heading 6', +]; + +export function HeadingBlock({ editor }: Pick) { + const [anchorEl, setAnchorEl] = useState(null); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + if (!editor) { + return null; + } + + return ( + <> + ({ + px: 1, + width: 120, + height: 32, + borderRadius: 0.75, + typography: 'body2', + justifyContent: 'space-between', + border: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.2)}`, + })} + > + {(editor.isActive('heading', { level: 1 }) && 'Heading 1') || + (editor.isActive('heading', { level: 2 }) && 'Heading 2') || + (editor.isActive('heading', { level: 3 }) && 'Heading 3') || + (editor.isActive('heading', { level: 4 }) && 'Heading 4') || + (editor.isActive('heading', { level: 5 }) && 'Heading 5') || + (editor.isActive('heading', { level: 6 }) && 'Heading 6') || + 'Paragraph'} + + + + + + { + handleClose(); + editor.chain().focus().setParagraph().run(); + }} + /> + + {HEADING_OPTIONS.map((heading, index) => { + const level = (index + 1) as HeadingLevel; + + return ( + { + handleClose(); + editor.chain().focus().toggleHeading({ level }).run(); + }} + sx={{ + ...(heading !== 'Paragraph' && { + fontSize: 18 - index, + fontWeight: 'fontWeightBold', + }), + }} + /> + ); + })} + + + ); +} diff --git a/app/frontend/src/components/editor/components/image-block.tsx b/app/frontend/src/components/editor/components/image-block.tsx new file mode 100644 index 00000000..b77d9c5b --- /dev/null +++ b/app/frontend/src/components/editor/components/image-block.tsx @@ -0,0 +1,80 @@ +import { useState, useCallback } from 'react'; + +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Popover from '@mui/material/Popover'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; + +import { editorClasses } from '../classes'; +import { ToolbarItem } from './toolbar-item'; + +import type { EditorToolbarProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function ImageBlock({ editor }: Pick) { + const [url, setUrl] = useState(''); + + const [anchorEl, setAnchorEl] = useState(null); + + const handleOpenPopover = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClosePopover = () => { + setAnchorEl(null); + }; + + const handleUpdateUrl = useCallback(() => { + handleClosePopover(); + + if (anchorEl) { + editor?.chain().focus().setImage({ src: url }).run(); + } + }, [anchorEl, editor, url]); + + if (!editor) { + return null; + } + + return ( + <> + + } + /> + + + URL + + + + ) => { + setUrl(event.target.value); + }} + sx={{ width: 240 }} + /> + + + + + ); +} diff --git a/app/frontend/src/components/editor/components/link-block.tsx b/app/frontend/src/components/editor/components/link-block.tsx new file mode 100644 index 00000000..202a7d42 --- /dev/null +++ b/app/frontend/src/components/editor/components/link-block.tsx @@ -0,0 +1,102 @@ +import { useState, useCallback } from 'react'; + +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Popover from '@mui/material/Popover'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; + +import { editorClasses } from '../classes'; +import { ToolbarItem } from './toolbar-item'; + +import type { EditorToolbarProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function LinkBlock({ editor }: Pick) { + const [url, setUrl] = useState(''); + + const [anchorEl, setAnchorEl] = useState(null); + + const handleOpenPopover = (event: React.MouseEvent) => { + const previousUrl = editor?.getAttributes('link').href; + + setAnchorEl(event.currentTarget); + + if (previousUrl) { + setUrl(previousUrl); + } else { + setUrl(''); + } + }; + + const handleClosePopover = () => { + setAnchorEl(null); + }; + + const handleUpdateUrl = useCallback(() => { + handleClosePopover(); + + if (!url) { + editor?.chain().focus().extendMarkRange('link').unsetLink().run(); + } else { + editor?.chain().focus().extendMarkRange('link').setLink({ href: url }).run(); + } + }, [editor, url]); + + if (!editor) { + return null; + } + + return ( + <> + + } + /> + + editor.chain().focus().unsetLink().run()} + icon={ + + } + /> + + + + URL + + + + ) => { + setUrl(event.target.value); + }} + sx={{ width: 240 }} + /> + + + + + ); +} diff --git a/app/frontend/src/components/editor/components/toolbar-item.tsx b/app/frontend/src/components/editor/components/toolbar-item.tsx new file mode 100644 index 00000000..5bfbf632 --- /dev/null +++ b/app/frontend/src/components/editor/components/toolbar-item.tsx @@ -0,0 +1,55 @@ +import SvgIcon from '@mui/material/SvgIcon'; +import { styled } from '@mui/material/styles'; +import ButtonBase from '@mui/material/ButtonBase'; + +import type { EditorToolbarItemProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function ToolbarItem({ + sx, + icon, + label, + active, + disabled, + ...other +}: EditorToolbarItemProps) { + return ( + + {icon && {icon}} + {label && label} + + ); +} + +// ---------------------------------------------------------------------- + +const ItemRoot = styled(ButtonBase, { + shouldForwardProp: (prop: string) => !['active', 'disabled', 'sx'].includes(prop), +})>(({ theme }) => ({ + ...theme.typography.body2, + width: 28, + height: 28, + padding: theme.spacing(0, 0.75), + borderRadius: theme.shape.borderRadius * 0.75, + '&:hover': { + backgroundColor: theme.vars.palette.action.hover, + }, + variants: [ + { + props: { active: true }, + style: { + backgroundColor: theme.vars.palette.action.selected, + border: `solid 1px ${theme.vars.palette.action.hover}`, + }, + }, + { + props: { disabled: true }, + style: { + opacity: 0.48, + pointerEvents: 'none', + cursor: 'not-allowed', + }, + }, + ], +})); diff --git a/app/frontend/src/components/editor/editor.tsx b/app/frontend/src/components/editor/editor.tsx new file mode 100644 index 00000000..8319492f --- /dev/null +++ b/app/frontend/src/components/editor/editor.tsx @@ -0,0 +1,164 @@ +import { common, createLowlight } from 'lowlight'; +import LinkExtension from '@tiptap/extension-link'; +import Underline from '@tiptap/extension-underline'; +import { mergeClasses } from 'minimal-shared/utils'; +import ImageExtension from '@tiptap/extension-image'; +import StarterKitExtension from '@tiptap/starter-kit'; +import TextAlignExtension from '@tiptap/extension-text-align'; +import PlaceholderExtension from '@tiptap/extension-placeholder'; +import { useState, useEffect, forwardRef, useCallback } from 'react'; +import CodeBlockLowlightExtension from '@tiptap/extension-code-block-lowlight'; +import { useEditor, EditorContent, ReactNodeViewRenderer } from '@tiptap/react'; + +import Box from '@mui/material/Box'; +import Portal from '@mui/material/Portal'; +import Backdrop from '@mui/material/Backdrop'; +import FormHelperText from '@mui/material/FormHelperText'; + +import { Toolbar } from './toolbar'; +import { EditorRoot } from './styles'; +import { editorClasses } from './classes'; +import { CodeHighlightBlock } from './components/code-highlight-block'; + +import type { EditorProps } from './types'; + +// ---------------------------------------------------------------------- + +export const Editor = forwardRef((props, ref) => { + const { + sx, + error, + onChange, + slotProps, + helperText, + resetValue, + className, + editable = true, + fullItem = false, + value: content = '', + placeholder = 'Write something awesome...', + ...other + } = props; + + const [fullScreen, setFullScreen] = useState(false); + + const handleToggleFullScreen = useCallback(() => { + setFullScreen((prev) => !prev); + }, []); + + const lowlight = createLowlight(common); + + const editor = useEditor({ + content, + editable, + immediatelyRender: false, + shouldRerenderOnTransaction: false, + extensions: [ + Underline, + StarterKitExtension.configure({ + codeBlock: false, + code: { HTMLAttributes: { class: editorClasses.content.codeInline } }, + heading: { HTMLAttributes: { class: editorClasses.content.heading } }, + horizontalRule: { HTMLAttributes: { class: editorClasses.content.hr } }, + listItem: { HTMLAttributes: { class: editorClasses.content.listItem } }, + blockquote: { HTMLAttributes: { class: editorClasses.content.blockquote } }, + bulletList: { HTMLAttributes: { class: editorClasses.content.bulletList } }, + orderedList: { HTMLAttributes: { class: editorClasses.content.orderedList } }, + }), + PlaceholderExtension.configure({ + placeholder, + emptyEditorClass: editorClasses.content.placeholder, + }), + ImageExtension.configure({ HTMLAttributes: { class: editorClasses.content.image } }), + TextAlignExtension.configure({ types: ['heading', 'paragraph'] }), + LinkExtension.configure({ + autolink: true, + openOnClick: false, + HTMLAttributes: { class: editorClasses.content.link }, + }), + CodeBlockLowlightExtension.extend({ + addNodeView() { + return ReactNodeViewRenderer(CodeHighlightBlock); + }, + }).configure({ lowlight, HTMLAttributes: { class: editorClasses.content.codeBlock } }), + ], + onUpdate({ editor: _editor }) { + const html = _editor.getHTML(); + onChange?.(html); + }, + ...other, + }); + + useEffect(() => { + const timer = setTimeout(() => { + if (editor?.isEmpty && content !== '

    ') { + editor.commands.setContent(content); + } + }, 100); + return () => clearTimeout(timer); + }, [content, editor]); + + useEffect(() => { + if (resetValue && !content) { + editor?.commands.clearContent(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [content]); + + useEffect(() => { + if (fullScreen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + }, [fullScreen]); + + return ( + + {fullScreen && ({ zIndex: theme.zIndex.modal - 1 })]} />} + + ({ + display: 'flex', + flexDirection: 'column', + ...(!editable && { cursor: 'not-allowed' }), + }), + ...(Array.isArray(slotProps?.wrapper?.sx) + ? (slotProps?.wrapper?.sx ?? []) + : [slotProps?.wrapper?.sx]), + ]} + > + + + + + + {helperText && ( + + {helperText} + + )} + + + ); +}); diff --git a/app/frontend/src/components/editor/index.ts b/app/frontend/src/components/editor/index.ts new file mode 100644 index 00000000..79f9fdf7 --- /dev/null +++ b/app/frontend/src/components/editor/index.ts @@ -0,0 +1,5 @@ +export * from './editor'; + +export * from './classes'; + +export type * from './types'; diff --git a/app/frontend/src/components/editor/styles.tsx b/app/frontend/src/components/editor/styles.tsx new file mode 100644 index 00000000..aca26547 --- /dev/null +++ b/app/frontend/src/components/editor/styles.tsx @@ -0,0 +1,181 @@ +import { varAlpha } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; + +import { editorClasses } from './classes'; + +// ---------------------------------------------------------------------- + +const MARGIN = '0.75em'; + +type EditorRootProps = { + error?: boolean; + disabled?: boolean; + fullScreen?: boolean; +}; + +export const EditorRoot = styled('div', { + shouldForwardProp: (prop: string) => !['error', 'disabled', 'fullScreen', 'sx'].includes(prop), +})(({ error, disabled, fullScreen, theme }) => ({ + minHeight: 240, + display: 'flex', + flexDirection: 'column', + borderRadius: theme.shape.borderRadius, + border: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.2)}`, + scrollbarWidth: 'thin', + scrollbarColor: `${varAlpha(theme.vars.palette.text.disabledChannel, 0.4)} ${varAlpha(theme.vars.palette.text.disabledChannel, 0.08)}`, + /** + * State: error + */ + ...(error && { border: `solid 1px ${theme.vars.palette.error.main}` }), + /** + * State: disabled + */ + ...(disabled && { opacity: 0.48, pointerEvents: 'none' }), + /** + * State: fullScreen + */ + ...(fullScreen && { + top: 16, + left: 16, + position: 'fixed', + zIndex: theme.zIndex.modal, + maxHeight: 'unset !important', + width: `calc(100% - ${32}px)`, + height: `calc(100% - ${32}px)`, + backgroundColor: theme.vars.palette.background.default, + }), + /** + * Placeholder + */ + [`& .${editorClasses.content.placeholder}`]: { + '&:first-of-type::before': { + ...theme.typography.body2, + height: 0, + float: 'left', + pointerEvents: 'none', + content: 'attr(data-placeholder)', + color: theme.vars.palette.text.disabled, + }, + }, + /** + * Content + */ + [`& .${editorClasses.content.root}`]: { + display: 'flex', + flex: '1 1 auto', + overflowY: 'auto', + flexDirection: 'column', + borderBottomLeftRadius: 'inherit', + borderBottomRightRadius: 'inherit', + backgroundColor: varAlpha(theme.vars.palette.grey['500Channel'], 0.08), + ...(error && { backgroundColor: varAlpha(theme.vars.palette.error.mainChannel, 0.08) }), + '& .tiptap': { + '> * + *': { marginTop: 0, marginBottom: MARGIN }, + '&.ProseMirror': { flex: '1 1 auto', outline: 'none', padding: theme.spacing(0, 2) }, + /** + * Heading & Paragraph + */ + h1: { ...theme.typography.h1, marginTop: 40, marginBottom: 8 }, + h2: { ...theme.typography.h2, marginTop: 40, marginBottom: 8 }, + h3: { ...theme.typography.h3, marginTop: 24, marginBottom: 8 }, + h4: { ...theme.typography.h4, marginTop: 24, marginBottom: 8 }, + h5: { ...theme.typography.h5, marginTop: 24, marginBottom: 8 }, + h6: { ...theme.typography.h6, marginTop: 24, marginBottom: 8 }, + p: { ...theme.typography.body1, marginBottom: '1.25rem' }, + [`& .${editorClasses.content.heading}`]: {}, + /** + * Link + */ + [`& .${editorClasses.content.link}`]: { color: theme.vars.palette.primary.main }, + /** + * Hr Divider + */ + [`& .${editorClasses.content.hr}`]: { + flexShrink: 0, + borderWidth: 0, + margin: '2em 0', + msFlexNegative: 0, + WebkitFlexShrink: 0, + borderStyle: 'solid', + borderBottomWidth: 'thin', + borderColor: theme.vars.palette.divider, + }, + /** + * Image + */ [`& .${editorClasses.content.image}`]: { + width: '100%', + height: 'auto', + maxWidth: '100%', + margin: 'auto auto 1.25em', + }, + /** + * List + */ [`& .${editorClasses.content.bulletList}`]: { paddingLeft: 16, listStyleType: 'disc' }, + [`& .${editorClasses.content.orderedList}`]: { paddingLeft: 16 }, + [`& .${editorClasses.content.listItem}`]: { lineHeight: 2, '& > p': { margin: 0 } }, + /** + * Blockquote + */ + [`& .${editorClasses.content.blockquote}`]: { + lineHeight: 1.5, + fontSize: '1.5em', + margin: '24px auto', + position: 'relative', + fontFamily: 'Georgia, serif', + padding: theme.spacing(3, 3, 3, 8), + color: theme.vars.palette.text.secondary, + borderLeft: `solid 8px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.08)}`, + [theme.breakpoints.up('md')]: { width: '100%', maxWidth: 640 }, + '& p': { margin: 0, fontSize: 'inherit', fontFamily: 'inherit' }, + '&::before': { + left: 16, + top: -8, + display: 'block', + fontSize: '3em', + content: '"\\201C"', + position: 'absolute', + color: theme.vars.palette.text.disabled, + }, + }, + /** + * Code inline + */ + [`& .${editorClasses.content.codeInline}`]: { + padding: theme.spacing(0.25, 0.5), + color: theme.vars.palette.text.secondary, + fontSize: theme.typography.body2.fontSize, + borderRadius: theme.shape.borderRadius / 2, + backgroundColor: varAlpha(theme.vars.palette.grey['500Channel'], 0.2), + }, + /** + * Code block + */ + [`& .${editorClasses.content.codeBlock}`]: { + position: 'relative', + '& pre': { + overflowX: 'auto', + color: theme.vars.palette.common.white, + padding: theme.spacing(5, 3, 3, 3), + borderRadius: theme.shape.borderRadius, + backgroundColor: theme.vars.palette.grey[900], + fontFamily: "'JetBrainsMono', monospace", + '& code': { fontSize: theme.typography.body2.fontSize }, + }, + [`& .${editorClasses.content.langSelect}`]: { + top: 8, + right: 8, + zIndex: 1, + padding: 4, + outline: 'none', + borderRadius: 4, + position: 'absolute', + color: theme.vars.palette.common.white, + fontWeight: theme.typography.fontWeightMedium, + borderColor: varAlpha(theme.vars.palette.grey['500Channel'], 0.08), + backgroundColor: varAlpha(theme.vars.palette.grey['500Channel'], 0.08), + }, + }, + }, + }, +})); diff --git a/app/frontend/src/components/editor/toolbar.tsx b/app/frontend/src/components/editor/toolbar.tsx new file mode 100644 index 00000000..348c9e66 --- /dev/null +++ b/app/frontend/src/components/editor/toolbar.tsx @@ -0,0 +1,268 @@ +import type { StackProps } from '@mui/material/Stack'; +import type { Theme, SxProps } from '@mui/material/styles'; + +import { varAlpha } from 'minimal-shared/utils'; + +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Divider from '@mui/material/Divider'; + +import { editorClasses } from './classes'; +import { LinkBlock } from './components/link-block'; +import { ImageBlock } from './components/image-block'; +import { ToolbarItem } from './components/toolbar-item'; +import { HeadingBlock } from './components/heading-block'; + +import type { EditorToolbarProps } from './types'; + +// ---------------------------------------------------------------------- + +/** + * Icons + * https://remixicon.com + */ + +export function Toolbar({ + sx, + editor, + fullItem, + fullScreen, + onToggleFullScreen, + ...other +}: StackProps & EditorToolbarProps) { + if (!editor) { + return null; + } + + const boxStyles: SxProps = { + gap: 0.5, + display: 'flex', + }; + + return ( + } + sx={[ + (theme) => ({ + gap: 1, + p: 1.25, + flexWrap: 'wrap', + flexDirection: 'row', + alignItems: 'center', + bgcolor: 'background.paper', + borderTopLeftRadius: 'inherit', + borderTopRightRadius: 'inherit', + borderBottom: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.2)}`, + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + + + {/* Text style */} + + editor.chain().focus().toggleBold().run()} + icon={ + + } + /> + editor.chain().focus().toggleItalic().run()} + icon={} + /> + editor.chain().focus().toggleUnderline().run()} + icon={ + + } + /> + editor.chain().focus().toggleStrike().run()} + icon={ + + } + /> + + + {/* List */} + + editor.chain().focus().toggleBulletList().run()} + icon={ + + } + /> + editor.chain().focus().toggleOrderedList().run()} + icon={ + + } + /> + + + {/* Text align */} + + editor.chain().focus().setTextAlign('left').run()} + icon={} + /> + editor.chain().focus().setTextAlign('center').run()} + icon={} + /> + editor.chain().focus().setTextAlign('right').run()} + icon={} + /> + editor.chain().focus().setTextAlign('justify').run()} + icon={} + /> + + + {/* Code - Code block */} + {fullItem && ( + + editor.chain().focus().toggleCode().run()} + icon={ + + } + /> + editor.chain().focus().toggleCodeBlock().run()} + icon={ + + } + /> + + )} + + {/* Blockquote - Hr line */} + {fullItem && ( + + editor.chain().focus().toggleBlockquote().run()} + icon={ + + } + /> + editor.chain().focus().setHorizontalRule().run()} + icon={} + /> + + )} + + {/* Link - Image */} + + + + + + {/* HardBreak - Clear */} + + editor.chain().focus().setHardBreak().run()} + className={editorClasses.toolbar.hardbreak} + icon={ + + } + /> + editor.chain().focus().clearNodes().unsetAllMarks().run()} + icon={ + + } + /> + + + {/* Undo - Redo */} + {fullItem && ( + + editor.chain().focus().undo().run()} + icon={ + + } + /> + editor.chain().focus().redo().run()} + icon={ + + } + /> + + )} + + + + ) : ( + + ) + } + /> + + + ); +} diff --git a/app/frontend/src/components/editor/types.ts b/app/frontend/src/components/editor/types.ts new file mode 100644 index 00000000..07ed192c --- /dev/null +++ b/app/frontend/src/components/editor/types.ts @@ -0,0 +1,35 @@ +import type { BoxProps } from '@mui/material/Box'; +import type { Theme, SxProps } from '@mui/material/styles'; +import type { Editor, EditorOptions } from '@tiptap/react'; +import type { ButtonBaseProps } from '@mui/material/ButtonBase'; + +// ---------------------------------------------------------------------- + +export type EditorProps = Partial & { + value?: string; + error?: boolean; + fullItem?: boolean; + className?: string; + sx?: SxProps; + resetValue?: boolean; + placeholder?: string; + helperText?: React.ReactNode; + onChange?: (value: string) => void; + slotProps?: { + wrapper?: BoxProps; + }; +}; + +export type EditorToolbarProps = { + fullScreen: boolean; + editor: Editor | null; + onToggleFullScreen: () => void; + fullItem?: EditorProps['fullItem']; +}; + +export type EditorToolbarItemProps = ButtonBaseProps & { + label?: string; + active?: boolean; + disabled?: boolean; + icon?: React.ReactNode; +}; diff --git a/app/frontend/src/components/empty-content/empty-content.tsx b/app/frontend/src/components/empty-content/empty-content.tsx new file mode 100644 index 00000000..dc189e7a --- /dev/null +++ b/app/frontend/src/components/empty-content/empty-content.tsx @@ -0,0 +1,117 @@ +import type { BoxProps } from '@mui/material/Box'; +import type { Theme, SxProps } from '@mui/material/styles'; +import type { TypographyProps } from '@mui/material/Typography'; + +import { varAlpha } from 'minimal-shared/utils'; + +import Box from '@mui/material/Box'; +import { styled } from '@mui/material/styles'; +import Typography from '@mui/material/Typography'; + +import { CONFIG } from 'src/global-config'; + +// ---------------------------------------------------------------------- + +export type EmptyContentProps = React.ComponentProps<'div'> & { + title?: string; + imgUrl?: string; + filled?: boolean; + sx?: SxProps; + description?: string; + action?: React.ReactNode; + slotProps?: { + img?: BoxProps<'img'>; + title?: TypographyProps; + description?: TypographyProps; + }; +}; + +export function EmptyContent({ + sx, + imgUrl, + action, + filled, + slotProps, + description, + title = 'No data', + ...other +}: EmptyContentProps) { + return ( + + + + {title && ( + + {title} + + )} + + {description && ( + + {description} + + )} + + {action && action} + + ); +} + +// ---------------------------------------------------------------------- + +const ContentRoot = styled('div', { + shouldForwardProp: (prop: string) => !['filled', 'sx'].includes(prop), +})>(({ filled, theme }) => ({ + flexGrow: 1, + height: '100%', + display: 'flex', + alignItems: 'center', + flexDirection: 'column', + justifyContent: 'center', + padding: theme.spacing(0, 3), + ...(filled && { + borderRadius: theme.shape.borderRadius * 2, + backgroundColor: varAlpha(theme.vars.palette.grey['500Channel'], 0.04), + border: `dashed 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.08)}`, + }), +})); diff --git a/app/frontend/src/components/empty-content/index.ts b/app/frontend/src/components/empty-content/index.ts new file mode 100644 index 00000000..026ff327 --- /dev/null +++ b/app/frontend/src/components/empty-content/index.ts @@ -0,0 +1 @@ +export * from './empty-content'; diff --git a/app/frontend/src/components/file-thumbnail/action-buttons.tsx b/app/frontend/src/components/file-thumbnail/action-buttons.tsx new file mode 100644 index 00000000..311c6c03 --- /dev/null +++ b/app/frontend/src/components/file-thumbnail/action-buttons.tsx @@ -0,0 +1,68 @@ +import type { ButtonBaseProps } from '@mui/material/ButtonBase'; +import type { IconButtonProps } from '@mui/material/IconButton'; + +import { varAlpha } from 'minimal-shared/utils'; + +import ButtonBase from '@mui/material/ButtonBase'; +import IconButton from '@mui/material/IconButton'; + +import { Iconify } from '../iconify'; + +// ---------------------------------------------------------------------- + +export function DownloadButton({ sx, ...other }: ButtonBaseProps) { + return ( + ({ + p: 0, + top: 0, + right: 0, + width: 1, + height: 1, + zIndex: 9, + opacity: 0, + position: 'absolute', + color: 'common.white', + borderRadius: 'inherit', + transition: theme.transitions.create(['opacity']), + '&:hover': { + ...theme.mixins.bgBlur({ + color: varAlpha(theme.vars.palette.grey['900Channel'], 0.64), + }), + opacity: 1, + }, + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + + + ); +} + +// ---------------------------------------------------------------------- + +export function RemoveButton({ sx, ...other }: IconButtonProps) { + return ( + ({ + p: 0.35, + top: 4, + right: 4, + position: 'absolute', + color: 'common.white', + bgcolor: varAlpha(theme.vars.palette.grey['900Channel'], 0.48), + '&:hover': { bgcolor: varAlpha(theme.vars.palette.grey['900Channel'], 0.72) }, + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + + + ); +} diff --git a/app/frontend/src/components/file-thumbnail/classes.ts b/app/frontend/src/components/file-thumbnail/classes.ts new file mode 100644 index 00000000..292d161c --- /dev/null +++ b/app/frontend/src/components/file-thumbnail/classes.ts @@ -0,0 +1,11 @@ +import { createClasses } from 'src/theme/create-classes'; + +// ---------------------------------------------------------------------- + +export const fileThumbnailClasses = { + root: createClasses('file__thumbnail__root'), + img: createClasses('file__thumbnail__img'), + icon: createClasses('file__thumbnail__icon'), + removeBtn: createClasses('file__thumbnail__remove__button'), + downloadBtn: createClasses('file__thumbnail__download__button'), +}; diff --git a/app/frontend/src/components/file-thumbnail/file-thumbnail.tsx b/app/frontend/src/components/file-thumbnail/file-thumbnail.tsx new file mode 100644 index 00000000..79d3295e --- /dev/null +++ b/app/frontend/src/components/file-thumbnail/file-thumbnail.tsx @@ -0,0 +1,108 @@ +import { forwardRef } from 'react'; +import { mergeClasses } from 'minimal-shared/utils'; + +import Tooltip from '@mui/material/Tooltip'; +import { styled } from '@mui/material/styles'; + +import { fileThumbnailClasses } from './classes'; +import { fileData, fileThumb, fileFormat } from './utils'; +import { RemoveButton, DownloadButton } from './action-buttons'; + +import type { FileThumbnailProps } from './types'; + +// ---------------------------------------------------------------------- + +export const FileThumbnail = forwardRef((props, ref) => { + const { file, tooltip, onRemove, imageView, slotProps, onDownload, className, sx, ...other } = + props; + + const { icon, removeBtn, downloadBtn, tooltip: tooltipProps } = slotProps ?? {}; + + const { name, path } = fileData(file); + + const previewUrl = typeof file === 'string' ? file : URL.createObjectURL(file); + + const format = fileFormat(path ?? previewUrl); + + const renderItem = () => ( + + {format === 'image' && imageView ? ( + + ) : ( + + )} + + {onRemove && ( + + )} + + {onDownload && ( + + )} + + ); + + if (tooltip) { + return ( + + {renderItem()} + + ); + } + + return renderItem(); +}); + +// ---------------------------------------------------------------------- + +const ItemRoot = styled('span')(({ theme }) => ({ + width: 36, + height: 36, + flexShrink: 0, + alignItems: 'center', + position: 'relative', + display: 'inline-flex', + justifyContent: 'center', + borderRadius: theme.shape.borderRadius * 1.25, +})); + +const ItemIcon = styled('img')(() => ({ + width: '100%', + height: '100%', +})); + +const ItemImg = styled('img')(() => ({ + width: '100%', + height: '100%', + objectFit: 'cover', + borderRadius: 'inherit', +})); diff --git a/app/frontend/src/components/file-thumbnail/index.ts b/app/frontend/src/components/file-thumbnail/index.ts new file mode 100644 index 00000000..9cecf1a1 --- /dev/null +++ b/app/frontend/src/components/file-thumbnail/index.ts @@ -0,0 +1,7 @@ +export * from './utils'; + +export * from './action-buttons'; + +export * from './file-thumbnail'; + +export type * from './types'; diff --git a/app/frontend/src/components/file-thumbnail/types.ts b/app/frontend/src/components/file-thumbnail/types.ts new file mode 100644 index 00000000..12572ad6 --- /dev/null +++ b/app/frontend/src/components/file-thumbnail/types.ts @@ -0,0 +1,28 @@ +import type { TooltipProps } from '@mui/material/Tooltip'; +import type { Theme, SxProps } from '@mui/material/styles'; +import type { ButtonBaseProps } from '@mui/material/ButtonBase'; +import type { IconButtonProps } from '@mui/material/IconButton'; + +// ---------------------------------------------------------------------- + +export interface ExtendFile extends File { + path?: string; + preview?: string; + lastModifiedDate?: Date; +} + +export type FileThumbnailProps = React.ComponentProps<'div'> & { + tooltip?: boolean; + file: File | string; + imageView?: boolean; + sx?: SxProps; + onDownload?: () => void; + onRemove?: () => void; + slotProps?: { + tooltip?: TooltipProps; + removeBtn?: IconButtonProps; + downloadBtn?: ButtonBaseProps; + img?: React.ComponentProps<'img'> & { sx?: SxProps }; + icon?: React.ComponentProps<'img'> & { sx?: SxProps }; + }; +}; diff --git a/app/frontend/src/components/file-thumbnail/utils.ts b/app/frontend/src/components/file-thumbnail/utils.ts new file mode 100644 index 00000000..8df5b1f6 --- /dev/null +++ b/app/frontend/src/components/file-thumbnail/utils.ts @@ -0,0 +1,156 @@ +import { CONFIG } from 'src/global-config'; + +import type { ExtendFile } from './types'; + +// ---------------------------------------------------------------------- + +// Define more types here +const FORMAT_PDF = ['pdf']; +const FORMAT_TEXT = ['txt']; +const FORMAT_PHOTOSHOP = ['psd']; +const FORMAT_WORD = ['doc', 'docx']; +const FORMAT_EXCEL = ['xls', 'xlsx']; +const FORMAT_ZIP = ['zip', 'rar', 'iso']; +const FORMAT_ILLUSTRATOR = ['ai', 'esp']; +const FORMAT_POWERPOINT = ['ppt', 'pptx']; +const FORMAT_AUDIO = ['wav', 'aif', 'mp3', 'aac']; +const FORMAT_IMG = ['jpg', 'jpeg', 'gif', 'bmp', 'png', 'svg', 'webp']; +const FORMAT_VIDEO = ['m4v', 'avi', 'mpg', 'mp4', 'webm']; + +const iconUrl = (icon: string) => `${CONFIG.assetsDir}/assets/icons/files/${icon}.svg`; + +// ---------------------------------------------------------------------- + +export function fileFormat(fileUrl: string) { + let format; + + const fileByUrl = fileTypeByUrl(fileUrl); + + switch (fileUrl.includes(fileByUrl)) { + case FORMAT_TEXT.includes(fileByUrl): + format = 'txt'; + break; + case FORMAT_ZIP.includes(fileByUrl): + format = 'zip'; + break; + case FORMAT_AUDIO.includes(fileByUrl): + format = 'audio'; + break; + case FORMAT_IMG.includes(fileByUrl): + format = 'image'; + break; + case FORMAT_VIDEO.includes(fileByUrl): + format = 'video'; + break; + case FORMAT_WORD.includes(fileByUrl): + format = 'word'; + break; + case FORMAT_EXCEL.includes(fileByUrl): + format = 'excel'; + break; + case FORMAT_POWERPOINT.includes(fileByUrl): + format = 'powerpoint'; + break; + case FORMAT_PDF.includes(fileByUrl): + format = 'pdf'; + break; + case FORMAT_PHOTOSHOP.includes(fileByUrl): + format = 'photoshop'; + break; + case FORMAT_ILLUSTRATOR.includes(fileByUrl): + format = 'illustrator'; + break; + default: + format = fileTypeByUrl(fileUrl); + } + + return format; +} + +// ---------------------------------------------------------------------- + +export function fileThumb(fileUrl: string) { + let thumb; + + switch (fileFormat(fileUrl)) { + case 'folder': + thumb = iconUrl('ic-folder'); + break; + case 'txt': + thumb = iconUrl('ic-txt'); + break; + case 'zip': + thumb = iconUrl('ic-zip'); + break; + case 'audio': + thumb = iconUrl('ic-audio'); + break; + case 'video': + thumb = iconUrl('ic-video'); + break; + case 'word': + thumb = iconUrl('ic-word'); + break; + case 'excel': + thumb = iconUrl('ic-excel'); + break; + case 'powerpoint': + thumb = iconUrl('ic-power_point'); + break; + case 'pdf': + thumb = iconUrl('ic-pdf'); + break; + case 'photoshop': + thumb = iconUrl('ic-pts'); + break; + case 'illustrator': + thumb = iconUrl('ic-ai'); + break; + case 'image': + thumb = iconUrl('ic-img'); + break; + default: + thumb = iconUrl('ic-file'); + } + return thumb; +} + +// ---------------------------------------------------------------------- + +export function fileTypeByUrl(fileUrl: string) { + return (fileUrl && fileUrl.split('.').pop()) || ''; +} + +// ---------------------------------------------------------------------- + +export function fileNameByUrl(fileUrl: string) { + return fileUrl.split('/').pop(); +} + +// ---------------------------------------------------------------------- + +export function fileData(file: File | string) { + // From url + if (typeof file === 'string') { + return { + preview: file, + name: fileNameByUrl(file), + type: fileTypeByUrl(file), + size: undefined, + path: file, + lastModified: undefined, + lastModifiedDate: undefined, + }; + } + + // From file + return { + name: file.name, + size: file.size, + path: (file as ExtendFile).path, + type: file.type, + preview: (file as ExtendFile).preview, + lastModified: file.lastModified, + lastModifiedDate: (file as ExtendFile).lastModifiedDate, + }; +} diff --git a/app/frontend/src/components/filters-result/filters-block.tsx b/app/frontend/src/components/filters-result/filters-block.tsx new file mode 100644 index 00000000..d57e7b48 --- /dev/null +++ b/app/frontend/src/components/filters-result/filters-block.tsx @@ -0,0 +1,49 @@ +import type { Theme, SxProps } from '@mui/material/styles'; + +import { styled } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +export type FilterBlockProps = React.ComponentProps<'div'> & { + label: string; + isShow: boolean; + sx?: SxProps; + children: React.ReactNode; +}; + +export function FiltersBlock({ label, children, isShow, sx, ...other }: FilterBlockProps) { + if (!isShow) { + return null; + } + + return ( + + {label} + {children} + + ); +} + +// ---------------------------------------------------------------------- + +const BlockRoot = styled('div')(({ theme }) => ({ + display: 'flex', + overflow: 'hidden', + gap: theme.spacing(1), + padding: theme.spacing(1), + borderRadius: theme.shape.borderRadius, + border: `dashed 1px ${theme.vars.palette.divider}`, +})); + +const BlockLabel = styled('span')(({ theme }) => ({ + height: 24, + lineHeight: '24px', + fontSize: theme.typography.subtitle2.fontSize, + fontWeight: theme.typography.subtitle2.fontWeight, +})); + +const BlockContent = styled('div')(({ theme }) => ({ + display: 'flex', + flexWrap: 'wrap', + gap: theme.spacing(1), +})); diff --git a/app/frontend/src/components/filters-result/filters-result.tsx b/app/frontend/src/components/filters-result/filters-result.tsx new file mode 100644 index 00000000..12dc943c --- /dev/null +++ b/app/frontend/src/components/filters-result/filters-result.tsx @@ -0,0 +1,64 @@ +import type { ChipProps } from '@mui/material/Chip'; +import type { Theme, SxProps } from '@mui/material/styles'; + +import Button from '@mui/material/Button'; +import { styled } from '@mui/material/styles'; + +import { Iconify } from 'src/components/iconify'; + +// ---------------------------------------------------------------------- + +export const chipProps: ChipProps = { size: 'small', variant: 'soft' }; + +export type FiltersResultProps = React.ComponentProps<'div'> & { + totalResults: number; + onReset?: () => void; + sx?: SxProps; +}; + +export function FiltersResult({ + sx, + onReset, + children, + totalResults, + ...other +}: FiltersResultProps) { + return ( + + + {totalResults} + results found + + + + {children} + + + + + ); +} + +// ---------------------------------------------------------------------- + +const ResultRoot = styled('div')``; + +const ResultLabel = styled('div')(({ theme }) => ({ + ...theme.typography.body2, + marginBottom: theme.spacing(1.5), + '& span': { color: theme.vars.palette.text.secondary }, +})); + +const ResultContent = styled('div')(({ theme }) => ({ + flexGrow: 1, + display: 'flex', + flexWrap: 'wrap', + alignItems: 'center', + gap: theme.spacing(1), +})); diff --git a/app/frontend/src/components/filters-result/index.ts b/app/frontend/src/components/filters-result/index.ts new file mode 100644 index 00000000..edbcbbe6 --- /dev/null +++ b/app/frontend/src/components/filters-result/index.ts @@ -0,0 +1,3 @@ +export * from './filters-block'; + +export * from './filters-result'; diff --git a/app/frontend/src/components/flag-icon/classes.ts b/app/frontend/src/components/flag-icon/classes.ts new file mode 100644 index 00000000..63397df8 --- /dev/null +++ b/app/frontend/src/components/flag-icon/classes.ts @@ -0,0 +1,8 @@ +import { createClasses } from 'src/theme/create-classes'; + +// ---------------------------------------------------------------------- + +export const flagIconClasses = { + root: createClasses('flag__icon__root'), + img: createClasses('flag__icon__img'), +}; diff --git a/app/frontend/src/components/flag-icon/flag-icon.tsx b/app/frontend/src/components/flag-icon/flag-icon.tsx new file mode 100644 index 00000000..0d65e4dd --- /dev/null +++ b/app/frontend/src/components/flag-icon/flag-icon.tsx @@ -0,0 +1,60 @@ +import type { Theme, SxProps } from '@mui/material/styles'; + +import { forwardRef } from 'react'; +import { mergeClasses } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; + +import { flagIconClasses } from './classes'; + +// ---------------------------------------------------------------------- + +export type FlagIconProps = React.ComponentProps<'span'> & { + code?: string; + sx?: SxProps; +}; + +export const FlagIcon = forwardRef((props, ref) => { + const { code, className, sx, ...other } = props; + + if (!code) { + return null; + } + + return ( + + + + ); +}); + +// ---------------------------------------------------------------------- + +const FlagRoot = styled('span')(({ theme }) => ({ + width: 26, + height: 20, + flexShrink: 0, + overflow: 'hidden', + borderRadius: '5px', + alignItems: 'center', + display: 'inline-flex', + justifyContent: 'center', + backgroundColor: theme.vars.palette.background.neutral, +})); + +const FlagImg = styled('img')(() => ({ + width: '100%', + height: '100%', + maxWidth: 'unset', + objectFit: 'cover', +})); diff --git a/app/frontend/src/components/flag-icon/index.ts b/app/frontend/src/components/flag-icon/index.ts new file mode 100644 index 00000000..b005d5a7 --- /dev/null +++ b/app/frontend/src/components/flag-icon/index.ts @@ -0,0 +1,3 @@ +export * from './classes'; + +export * from './flag-icon'; diff --git a/app/frontend/src/components/hook-form/fields.tsx b/app/frontend/src/components/hook-form/fields.tsx new file mode 100644 index 00000000..d3fe62fc --- /dev/null +++ b/app/frontend/src/components/hook-form/fields.tsx @@ -0,0 +1,41 @@ +import { RHFCode } from './rhf-code'; +import { RHFRating } from './rhf-rating'; +import { RHFEditor } from './rhf-editor'; +import { RHFSlider } from './rhf-slider'; +import { RHFTextField } from './rhf-text-field'; +import { RHFRadioGroup } from './rhf-radio-group'; +import { RHFPhoneInput } from './rhf-phone-input'; +import { RHFNumberInput } from './rhf-number-input'; +import { RHFAutocomplete } from './rhf-autocomplete'; +import { RHFCountrySelect } from './rhf-country-select'; +import { RHFSwitch, RHFMultiSwitch } from './rhf-switch'; +import { RHFSelect, RHFMultiSelect } from './rhf-select'; +import { RHFCheckbox, RHFMultiCheckbox } from './rhf-checkbox'; +import { RHFUpload, RHFUploadBox, RHFUploadAvatar } from './rhf-upload'; +import { RHFDatePicker, RHFMobileDateTimePicker } from './rhf-date-picker'; + +// ---------------------------------------------------------------------- + +export const Field = { + Code: RHFCode, + Editor: RHFEditor, + Select: RHFSelect, + Upload: RHFUpload, + Switch: RHFSwitch, + Slider: RHFSlider, + Rating: RHFRating, + Text: RHFTextField, + Phone: RHFPhoneInput, + Checkbox: RHFCheckbox, + UploadBox: RHFUploadBox, + RadioGroup: RHFRadioGroup, + DatePicker: RHFDatePicker, + NumberInput: RHFNumberInput, + MultiSelect: RHFMultiSelect, + MultiSwitch: RHFMultiSwitch, + UploadAvatar: RHFUploadAvatar, + Autocomplete: RHFAutocomplete, + MultiCheckbox: RHFMultiCheckbox, + CountrySelect: RHFCountrySelect, + MobileDateTimePicker: RHFMobileDateTimePicker, +}; diff --git a/app/frontend/src/components/hook-form/form-provider.tsx b/app/frontend/src/components/hook-form/form-provider.tsx new file mode 100644 index 00000000..b91d4ee3 --- /dev/null +++ b/app/frontend/src/components/hook-form/form-provider.tsx @@ -0,0 +1,21 @@ +import type { UseFormReturn } from 'react-hook-form'; + +import { FormProvider as RHFForm } from 'react-hook-form'; + +// ---------------------------------------------------------------------- + +export type FormProps = { + onSubmit?: () => void; + children: React.ReactNode; + methods: UseFormReturn; +}; + +export function Form({ children, onSubmit, methods }: FormProps) { + return ( + +
    + {children} +
    +
    + ); +} diff --git a/app/frontend/src/components/hook-form/help-text.tsx b/app/frontend/src/components/hook-form/help-text.tsx new file mode 100644 index 00000000..68434545 --- /dev/null +++ b/app/frontend/src/components/hook-form/help-text.tsx @@ -0,0 +1,38 @@ +import type { FormHelperTextProps } from '@mui/material/FormHelperText'; + +import FormHelperText from '@mui/material/FormHelperText'; + +// ---------------------------------------------------------------------- + +export type HelperTextProps = FormHelperTextProps & { + errorMessage?: string; + disableGutters?: boolean; + helperText?: React.ReactNode; +}; + +export function HelperText({ + sx, + helperText, + errorMessage, + disableGutters, + ...other +}: HelperTextProps) { + if (errorMessage || helperText) { + return ( + + {errorMessage || helperText} + + ); + } + + return null; +} diff --git a/app/frontend/src/components/hook-form/index.ts b/app/frontend/src/components/hook-form/index.ts new file mode 100644 index 00000000..a7c4fa81 --- /dev/null +++ b/app/frontend/src/components/hook-form/index.ts @@ -0,0 +1,35 @@ +export * from './fields'; + +export * from './rhf-code'; + +export * from './rhf-upload'; + +export * from './rhf-select'; + +export * from './rhf-rating'; + +export * from './rhf-switch'; + +export * from './rhf-editor'; + +export * from './rhf-slider'; + +export * from './rhf-checkbox'; + +export * from './schema-helper'; + +export * from './form-provider'; + +export * from './rhf-text-field'; + +export * from './rhf-date-picker'; + +export * from './rhf-radio-group'; + +export * from './rhf-phone-input'; + +export * from './rhf-number-input'; + +export * from './rhf-autocomplete'; + +export * from './rhf-country-select'; diff --git a/app/frontend/src/components/hook-form/rhf-autocomplete.tsx b/app/frontend/src/components/hook-form/rhf-autocomplete.tsx new file mode 100644 index 00000000..ed0dd29f --- /dev/null +++ b/app/frontend/src/components/hook-form/rhf-autocomplete.tsx @@ -0,0 +1,71 @@ +import type { TextFieldProps } from '@mui/material/TextField'; +import type { AutocompleteProps } from '@mui/material/Autocomplete'; + +import { Controller, useFormContext } from 'react-hook-form'; + +import TextField from '@mui/material/TextField'; +import Autocomplete from '@mui/material/Autocomplete'; + +// ---------------------------------------------------------------------- + +export type AutocompleteBaseProps = Omit< + AutocompleteProps, + 'renderInput' +>; + +export type RHFAutocompleteProps = AutocompleteBaseProps & { + name: string; + label?: string; + placeholder?: string; + helperText?: React.ReactNode; + slotProps?: AutocompleteBaseProps['slotProps'] & { + textfield?: TextFieldProps; + }; +}; + +export function RHFAutocomplete({ + name, + label, + slotProps, + helperText, + placeholder, + ...other +}: RHFAutocompleteProps) { + const { control, setValue } = useFormContext(); + + const { textfield, ...otherSlotProps } = slotProps ?? {}; + + return ( + ( + setValue(name, newValue, { shouldValidate: true })} + renderInput={(params) => ( + + )} + {...other} + {...otherSlotProps} + /> + )} + /> + ); +} diff --git a/app/frontend/src/components/hook-form/rhf-checkbox.tsx b/app/frontend/src/components/hook-form/rhf-checkbox.tsx new file mode 100644 index 00000000..9fedb9ca --- /dev/null +++ b/app/frontend/src/components/hook-form/rhf-checkbox.tsx @@ -0,0 +1,159 @@ +import type { BoxProps } from '@mui/material/Box'; +import type { CheckboxProps } from '@mui/material/Checkbox'; +import type { FormGroupProps } from '@mui/material/FormGroup'; +import type { FormLabelProps } from '@mui/material/FormLabel'; +import type { FormControlProps } from '@mui/material/FormControl'; +import type { FormHelperTextProps } from '@mui/material/FormHelperText'; +import type { FormControlLabelProps } from '@mui/material/FormControlLabel'; + +import { Controller, useFormContext } from 'react-hook-form'; + +import Box from '@mui/material/Box'; +import Checkbox from '@mui/material/Checkbox'; +import FormGroup from '@mui/material/FormGroup'; +import FormLabel from '@mui/material/FormLabel'; +import FormControl from '@mui/material/FormControl'; +import FormControlLabel from '@mui/material/FormControlLabel'; + +import { HelperText } from './help-text'; + +// ---------------------------------------------------------------------- + +type RHFCheckboxProps = Omit & { + name: string; + helperText?: React.ReactNode; + slotProps?: { + wrapper?: BoxProps; + checkbox?: CheckboxProps; + helperText?: FormHelperTextProps; + }; +}; + +export function RHFCheckbox({ + sx, + name, + label, + slotProps, + helperText, + ...other +}: RHFCheckboxProps) { + const { control } = useFormContext(); + + return ( + ( + + + } + sx={[{ mx: 0 }, ...(Array.isArray(sx) ? (sx ?? []) : [sx])]} + {...other} + /> + + + + )} + /> + ); +} + +// ---------------------------------------------------------------------- + +type RHFMultiCheckboxProps = FormGroupProps & { + name: string; + label?: string; + helperText?: React.ReactNode; + options: { label: string; value: string }[]; + slotProps?: { + wrapper?: FormControlProps; + checkbox?: CheckboxProps; + formLabel?: FormLabelProps; + helperText?: FormHelperTextProps; + }; +}; + +export function RHFMultiCheckbox({ + name, + label, + options, + slotProps, + helperText, + ...other +}: RHFMultiCheckboxProps) { + const { control } = useFormContext(); + + const getSelected = (selectedItems: string[], item: string) => + selectedItems.includes(item) + ? selectedItems.filter((value) => value !== item) + : [...selectedItems, item]; + + return ( + ( + + {label && ( + + {label} + + )} + + + {options.map((option) => ( + field.onChange(getSelected(field.value, option.value))} + {...slotProps?.checkbox} + inputProps={{ + id: `${option.label}-checkbox`, + ...(!option.label && { 'aria-label': `${option.label} checkbox` }), + ...slotProps?.checkbox?.inputProps, + }} + /> + } + label={option.label} + /> + ))} + + + + + )} + /> + ); +} diff --git a/app/frontend/src/components/hook-form/rhf-code.tsx b/app/frontend/src/components/hook-form/rhf-code.tsx new file mode 100644 index 00000000..1b2028a3 --- /dev/null +++ b/app/frontend/src/components/hook-form/rhf-code.tsx @@ -0,0 +1,82 @@ +import type { BoxProps } from '@mui/material/Box'; +import type { MuiOtpInputProps } from 'mui-one-time-password-input'; +import type { FormHelperTextProps } from '@mui/material/FormHelperText'; + +import { MuiOtpInput } from 'mui-one-time-password-input'; +import { Controller, useFormContext } from 'react-hook-form'; + +import Box from '@mui/material/Box'; +import { inputBaseClasses } from '@mui/material/InputBase'; + +import { HelperText } from './help-text'; + +// ---------------------------------------------------------------------- + +export interface RHFCodesProps extends Omit { + name: string; + maxSize?: number; + placeholder?: string; + helperText?: React.ReactNode; + slotProps?: { + wrapper?: BoxProps; + helperText?: FormHelperTextProps; + textfield?: MuiOtpInputProps['TextFieldsProps']; + }; +} + +export function RHFCode({ + name, + slotProps, + helperText, + maxSize = 56, + placeholder = '-', + ...other +}: RHFCodesProps) { + const { control } = useFormContext(); + + return ( + ( + + + + + + )} + /> + ); +} diff --git a/app/frontend/src/components/hook-form/rhf-country-select.tsx b/app/frontend/src/components/hook-form/rhf-country-select.tsx new file mode 100644 index 00000000..e5c83ba9 --- /dev/null +++ b/app/frontend/src/components/hook-form/rhf-country-select.tsx @@ -0,0 +1,32 @@ +import type { CountrySelectProps } from 'src/components/country-select'; + +import { Controller, useFormContext } from 'react-hook-form'; + +import { CountrySelect } from 'src/components/country-select'; + +// ---------------------------------------------------------------------- + +export type RHFCountrySelectProps = CountrySelectProps & { + name: string; +}; + +export function RHFCountrySelect({ name, helperText, ...other }: RHFCountrySelectProps) { + const { control, setValue } = useFormContext(); + + return ( + ( + setValue(name, newValue, { shouldValidate: true })} + error={!!error} + helperText={error?.message ?? helperText} + {...other} + /> + )} + /> + ); +} diff --git a/app/frontend/src/components/hook-form/rhf-date-picker.tsx b/app/frontend/src/components/hook-form/rhf-date-picker.tsx new file mode 100644 index 00000000..bb9c119c --- /dev/null +++ b/app/frontend/src/components/hook-form/rhf-date-picker.tsx @@ -0,0 +1,86 @@ +import type { Dayjs } from 'dayjs'; +import type { TextFieldProps } from '@mui/material/TextField'; +import type { DatePickerProps } from '@mui/x-date-pickers/DatePicker'; +import type { MobileDateTimePickerProps } from '@mui/x-date-pickers/MobileDateTimePicker'; + +import dayjs from 'dayjs'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { DatePicker } from '@mui/x-date-pickers/DatePicker'; +import { MobileDateTimePicker } from '@mui/x-date-pickers/MobileDateTimePicker'; + +import { formatPatterns } from 'src/utils/format-time'; + +// ---------------------------------------------------------------------- + +type RHFDatePickerProps = DatePickerProps & { + name: string; +}; + +export function RHFDatePicker({ name, slotProps, ...other }: RHFDatePickerProps) { + const { control } = useFormContext(); + + return ( + ( + field.onChange(dayjs(newValue).format())} + format={formatPatterns.split.date} + slotProps={{ + ...slotProps, + textField: { + fullWidth: true, + error: !!error, + helperText: error?.message ?? (slotProps?.textField as TextFieldProps)?.helperText, + ...slotProps?.textField, + }, + }} + {...other} + /> + )} + /> + ); +} + +// ---------------------------------------------------------------------- + +type RHFMobileDateTimePickerProps = MobileDateTimePickerProps & { + name: string; +}; + +export function RHFMobileDateTimePicker({ + name, + slotProps, + ...other +}: RHFMobileDateTimePickerProps) { + const { control } = useFormContext(); + + return ( + ( + field.onChange(dayjs(newValue).format())} + format={formatPatterns.split.dateTime} + slotProps={{ + textField: { + fullWidth: true, + error: !!error, + helperText: error?.message ?? (slotProps?.textField as TextFieldProps)?.helperText, + ...slotProps?.textField, + }, + ...slotProps, + }} + {...other} + /> + )} + /> + ); +} diff --git a/app/frontend/src/components/hook-form/rhf-editor.tsx b/app/frontend/src/components/hook-form/rhf-editor.tsx new file mode 100644 index 00000000..24e8e346 --- /dev/null +++ b/app/frontend/src/components/hook-form/rhf-editor.tsx @@ -0,0 +1,34 @@ +import { Controller, useFormContext } from 'react-hook-form'; + +import { Editor } from '../editor'; + +import type { EditorProps } from '../editor'; + +// ---------------------------------------------------------------------- + +export type RHFEditorProps = EditorProps & { + name: string; +}; + +export function RHFEditor({ name, helperText, ...other }: RHFEditorProps) { + const { + control, + formState: { isSubmitSuccessful }, + } = useFormContext(); + + return ( + ( + + )} + /> + ); +} diff --git a/app/frontend/src/components/hook-form/rhf-number-input.tsx b/app/frontend/src/components/hook-form/rhf-number-input.tsx new file mode 100644 index 00000000..36820f24 --- /dev/null +++ b/app/frontend/src/components/hook-form/rhf-number-input.tsx @@ -0,0 +1,31 @@ +import { Controller, useFormContext } from 'react-hook-form'; + +import { NumberInput } from '../number-input'; + +import type { NumberInputProps } from '../number-input'; + +// ---------------------------------------------------------------------- + +export type RHFNumberInputProps = NumberInputProps & { + name: string; +}; + +export function RHFNumberInput({ name, helperText, ...other }: RHFNumberInputProps) { + const { control } = useFormContext(); + + return ( + ( + field.onChange(value)} + {...other} + error={!!error} + helperText={error?.message ?? helperText} + /> + )} + /> + ); +} diff --git a/app/frontend/src/components/hook-form/rhf-phone-input.tsx b/app/frontend/src/components/hook-form/rhf-phone-input.tsx new file mode 100644 index 00000000..5d3d9c09 --- /dev/null +++ b/app/frontend/src/components/hook-form/rhf-phone-input.tsx @@ -0,0 +1,31 @@ +import { Controller, useFormContext } from 'react-hook-form'; + +import { PhoneInput } from '../phone-input'; + +import type { PhoneInputProps } from '../phone-input'; + +// ---------------------------------------------------------------------- + +export type RHFPhoneInputProps = Omit & { + name: string; +}; + +export function RHFPhoneInput({ name, helperText, ...other }: RHFPhoneInputProps) { + const { control } = useFormContext(); + + return ( + ( + + )} + /> + ); +} diff --git a/app/frontend/src/components/hook-form/rhf-radio-group.tsx b/app/frontend/src/components/hook-form/rhf-radio-group.tsx new file mode 100644 index 00000000..83288a1e --- /dev/null +++ b/app/frontend/src/components/hook-form/rhf-radio-group.tsx @@ -0,0 +1,97 @@ +import type { RadioProps } from '@mui/material/Radio'; +import type { FormLabelProps } from '@mui/material/FormLabel'; +import type { RadioGroupProps } from '@mui/material/RadioGroup'; +import type { FormControlProps } from '@mui/material/FormControl'; +import type { FormHelperTextProps } from '@mui/material/FormHelperText'; + +import { Controller, useFormContext } from 'react-hook-form'; + +import Radio from '@mui/material/Radio'; +import FormLabel from '@mui/material/FormLabel'; +import RadioGroup from '@mui/material/RadioGroup'; +import FormControl from '@mui/material/FormControl'; +import FormControlLabel from '@mui/material/FormControlLabel'; + +import { HelperText } from './help-text'; + +// ---------------------------------------------------------------------- + +export type RHFRadioGroupProps = RadioGroupProps & { + name: string; + label?: string; + options: { label: string; value: string }[]; + helperText?: React.ReactNode; + slotProps?: { + wrapper?: FormControlProps; + radio?: RadioProps; + formLabel?: FormLabelProps; + helperText?: FormHelperTextProps; + }; +}; + +export function RHFRadioGroup({ + sx, + name, + label, + options, + helperText, + slotProps, + ...other +}: RHFRadioGroupProps) { + const { control } = useFormContext(); + + const labelledby = `${name}-radios`; + + return ( + ( + + {label && ( + + {label} + + )} + + + {options.map((option) => ( + + } + label={option.label} + /> + ))} + + + + + )} + /> + ); +} diff --git a/app/frontend/src/components/hook-form/rhf-rating.tsx b/app/frontend/src/components/hook-form/rhf-rating.tsx new file mode 100644 index 00000000..74d30e49 --- /dev/null +++ b/app/frontend/src/components/hook-form/rhf-rating.tsx @@ -0,0 +1,56 @@ +import type { BoxProps } from '@mui/material/Box'; +import type { RatingProps } from '@mui/material/Rating'; +import type { FormHelperTextProps } from '@mui/material/FormHelperText'; + +import { Controller, useFormContext } from 'react-hook-form'; + +import Box from '@mui/material/Box'; +import Rating from '@mui/material/Rating'; + +import { HelperText } from './help-text'; + +// ---------------------------------------------------------------------- + +export type RHFRatingProps = RatingProps & { + name: string; + helperText?: React.ReactNode; + slotProps?: { + wrapper?: BoxProps; + helperText?: FormHelperTextProps; + }; +}; + +export function RHFRating({ name, helperText, slotProps, ...other }: RHFRatingProps) { + const { control } = useFormContext(); + + return ( + ( + + field.onChange(Number(newValue))} + {...other} + /> + + + + )} + /> + ); +} diff --git a/app/frontend/src/components/hook-form/rhf-select.tsx b/app/frontend/src/components/hook-form/rhf-select.tsx new file mode 100644 index 00000000..d975bad3 --- /dev/null +++ b/app/frontend/src/components/hook-form/rhf-select.tsx @@ -0,0 +1,193 @@ +import type { ChipProps } from '@mui/material/Chip'; +import type { SelectProps } from '@mui/material/Select'; +import type { CheckboxProps } from '@mui/material/Checkbox'; +import type { TextFieldProps } from '@mui/material/TextField'; +import type { InputLabelProps } from '@mui/material/InputLabel'; +import type { FormControlProps } from '@mui/material/FormControl'; +import type { FormHelperTextProps } from '@mui/material/FormHelperText'; + +import { merge } from 'es-toolkit'; +import { Controller, useFormContext } from 'react-hook-form'; + +import Box from '@mui/material/Box'; +import Chip from '@mui/material/Chip'; +import Select from '@mui/material/Select'; +import MenuItem from '@mui/material/MenuItem'; +import Checkbox from '@mui/material/Checkbox'; +import TextField from '@mui/material/TextField'; +import InputLabel from '@mui/material/InputLabel'; +import FormControl from '@mui/material/FormControl'; + +import { HelperText } from './help-text'; + +// ---------------------------------------------------------------------- + +type RHFSelectProps = TextFieldProps & { + name: string; + children: React.ReactNode; +}; + +export function RHFSelect({ + name, + children, + helperText, + slotProps = {}, + ...other +}: RHFSelectProps) { + const { control } = useFormContext(); + + const labelId = `${name}-select`; + + const baseSlotProps: TextFieldProps['slotProps'] = { + select: { + sx: { textTransform: 'capitalize' }, + MenuProps: { + slotProps: { + paper: { + sx: [{ maxHeight: 220 }], + }, + }, + }, + }, + htmlInput: { id: labelId }, + inputLabel: { htmlFor: labelId }, + }; + + return ( + ( + + {children} + + )} + /> + ); +} + +// ---------------------------------------------------------------------- + +type RHFMultiSelectProps = FormControlProps & { + name: string; + label?: string; + chip?: boolean; + checkbox?: boolean; + placeholder?: string; + helperText?: React.ReactNode; + options: { label: string; value: string }[]; + slotProps?: { + chip?: ChipProps; + select?: SelectProps; + checkbox?: CheckboxProps; + inputLabel?: InputLabelProps; + helperText?: FormHelperTextProps; + }; +}; + +export function RHFMultiSelect({ + name, + chip, + label, + options, + checkbox, + placeholder, + slotProps, + helperText, + ...other +}: RHFMultiSelectProps) { + const { control } = useFormContext(); + + const labelId = `${name}-multi-select`; + + return ( + { + const renderLabel = () => ( + + {label} + + ); + + const renderOptions = () => + options.map((option) => ( + + {checkbox && ( + + )} + + {option.label} + + )); + + return ( + + {label && renderLabel()} + + + + + + ); + }} + /> + ); +} diff --git a/app/frontend/src/components/hook-form/rhf-slider.tsx b/app/frontend/src/components/hook-form/rhf-slider.tsx new file mode 100644 index 00000000..81c592e1 --- /dev/null +++ b/app/frontend/src/components/hook-form/rhf-slider.tsx @@ -0,0 +1,44 @@ +import type { BoxProps } from '@mui/material/Box'; +import type { SliderProps } from '@mui/material/Slider'; +import type { FormHelperTextProps } from '@mui/material/FormHelperText'; + +import { Controller, useFormContext } from 'react-hook-form'; + +import Box from '@mui/material/Box'; +import Slider from '@mui/material/Slider'; + +import { HelperText } from './help-text'; + +// ---------------------------------------------------------------------- + +export type RHFSliderProps = SliderProps & { + name: string; + helperText?: React.ReactNode; + slotProps?: { + wrapper?: BoxProps; + helperText?: FormHelperTextProps; + }; +}; + +export function RHFSlider({ name, helperText, slotProps, ...other }: RHFSliderProps) { + const { control } = useFormContext(); + + return ( + ( + + + + + + )} + /> + ); +} diff --git a/app/frontend/src/components/hook-form/rhf-switch.tsx b/app/frontend/src/components/hook-form/rhf-switch.tsx new file mode 100644 index 00000000..12d7c82b --- /dev/null +++ b/app/frontend/src/components/hook-form/rhf-switch.tsx @@ -0,0 +1,155 @@ +import type { BoxProps } from '@mui/material/Box'; +import type { SwitchProps } from '@mui/material/Switch'; +import type { FormGroupProps } from '@mui/material/FormGroup'; +import type { FormLabelProps } from '@mui/material/FormLabel'; +import type { FormControlProps } from '@mui/material/FormControl'; +import type { FormHelperTextProps } from '@mui/material/FormHelperText'; +import type { FormControlLabelProps } from '@mui/material/FormControlLabel'; + +import { Controller, useFormContext } from 'react-hook-form'; + +import Box from '@mui/material/Box'; +import Switch from '@mui/material/Switch'; +import FormGroup from '@mui/material/FormGroup'; +import FormLabel from '@mui/material/FormLabel'; +import FormControl from '@mui/material/FormControl'; +import FormControlLabel from '@mui/material/FormControlLabel'; + +import { HelperText } from './help-text'; + +// ---------------------------------------------------------------------- + +export type RHFSwitchProps = Omit & { + name: string; + helperText?: React.ReactNode; + slotProps?: { + wrapper?: BoxProps; + switch?: SwitchProps; + helperText?: FormHelperTextProps; + }; +}; + +export function RHFSwitch({ name, helperText, label, slotProps, sx, ...other }: RHFSwitchProps) { + const { control } = useFormContext(); + + return ( + ( + + + } + sx={[{ mx: 0 }, ...(Array.isArray(sx) ? (sx ?? []) : [sx])]} + {...other} + /> + + + + )} + /> + ); +} + +// ---------------------------------------------------------------------- + +type RHFMultiSwitchProps = FormGroupProps & { + name: string; + label?: string; + helperText?: React.ReactNode; + options: { + label: string; + value: string; + }[]; + slotProps?: { + wrapper?: FormControlProps; + switch: SwitchProps; + formLabel?: FormLabelProps; + helperText?: FormHelperTextProps; + }; +}; + +export function RHFMultiSwitch({ + name, + label, + options, + helperText, + slotProps, + ...other +}: RHFMultiSwitchProps) { + const { control } = useFormContext(); + + const getSelected = (selectedItems: string[], item: string) => + selectedItems.includes(item) + ? selectedItems.filter((value) => value !== item) + : [...selectedItems, item]; + + return ( + ( + + {label && ( + + {label} + + )} + + + {options.map((option) => ( + field.onChange(getSelected(field.value, option.value))} + {...slotProps?.switch} + inputProps={{ + id: `${option.label}-switch`, + ...(!option.label && { 'aria-label': `${option.label} switch` }), + ...slotProps?.switch?.inputProps, + }} + /> + } + label={option.label} + /> + ))} + + + + + )} + /> + ); +} diff --git a/app/frontend/src/components/hook-form/rhf-text-field.tsx b/app/frontend/src/components/hook-form/rhf-text-field.tsx new file mode 100644 index 00000000..6d4bc479 --- /dev/null +++ b/app/frontend/src/components/hook-form/rhf-text-field.tsx @@ -0,0 +1,64 @@ +import type { TextFieldProps } from '@mui/material/TextField'; + +import { Controller, useFormContext } from 'react-hook-form'; +import { transformValue, transformValueOnBlur, transformValueOnChange } from 'minimal-shared/utils'; + +import TextField from '@mui/material/TextField'; + +// ---------------------------------------------------------------------- + +export type RHFTextFieldProps = TextFieldProps & { + name: string; +}; + +export function RHFTextField({ + name, + helperText, + slotProps, + type = 'text', + ...other +}: RHFTextFieldProps) { + const { control } = useFormContext(); + + const isNumberType = type === 'number'; + + return ( + ( + { + const transformedValue = isNumberType + ? transformValueOnChange(event.target.value) + : event.target.value; + + field.onChange(transformedValue); + }} + onBlur={(event) => { + const transformedValue = isNumberType + ? transformValueOnBlur(event.target.value) + : event.target.value; + + field.onChange(transformedValue); + }} + type={isNumberType ? 'text' : type} + error={!!error} + helperText={error?.message ?? helperText} + slotProps={{ + ...slotProps, + htmlInput: { + autoComplete: 'off', + ...slotProps?.htmlInput, + ...(isNumberType && { inputMode: 'decimal', pattern: '[0-9]*\\.?[0-9]*' }), + }, + }} + {...other} + /> + )} + /> + ); +} diff --git a/app/frontend/src/components/hook-form/rhf-upload.tsx b/app/frontend/src/components/hook-form/rhf-upload.tsx new file mode 100644 index 00000000..fe41dad3 --- /dev/null +++ b/app/frontend/src/components/hook-form/rhf-upload.tsx @@ -0,0 +1,90 @@ +import type { BoxProps } from '@mui/material/Box'; + +import { Controller, useFormContext } from 'react-hook-form'; + +import Box from '@mui/material/Box'; + +import { HelperText } from './help-text'; +import { Upload, UploadBox, UploadAvatar } from '../upload'; + +import type { UploadProps } from '../upload'; + +// ---------------------------------------------------------------------- + +export type RHFUploadProps = UploadProps & { + name: string; + slotProps?: { + wrapper?: BoxProps; + }; +}; + +export function RHFUploadAvatar({ name, slotProps, ...other }: RHFUploadProps) { + const { control, setValue } = useFormContext(); + + return ( + { + const onDrop = (acceptedFiles: File[]) => { + const value = acceptedFiles[0]; + + setValue(name, value, { shouldValidate: true }); + }; + + return ( + + + + + + ); + }} + /> + ); +} + +// ---------------------------------------------------------------------- + +export function RHFUploadBox({ name, ...other }: RHFUploadProps) { + const { control } = useFormContext(); + + return ( + ( + + )} + /> + ); +} + +// ---------------------------------------------------------------------- + +export function RHFUpload({ name, multiple, helperText, ...other }: RHFUploadProps) { + const { control, setValue } = useFormContext(); + + return ( + { + const uploadProps = { + multiple, + accept: { 'image/*': [] }, + error: !!error, + helperText: error?.message ?? helperText, + }; + + const onDrop = (acceptedFiles: File[]) => { + const value = multiple ? [...field.value, ...acceptedFiles] : acceptedFiles[0]; + + setValue(name, value, { shouldValidate: true }); + }; + + return ; + }} + /> + ); +} diff --git a/app/frontend/src/components/hook-form/schema-helper.ts b/app/frontend/src/components/hook-form/schema-helper.ts new file mode 100644 index 00000000..ef20b6e7 --- /dev/null +++ b/app/frontend/src/components/hook-form/schema-helper.ts @@ -0,0 +1,140 @@ +import type { ZodTypeAny } from 'zod'; + +import dayjs from 'dayjs'; +import { z as zod } from 'zod'; + +// ---------------------------------------------------------------------- + +type MessageMapProps = { + required?: string; + invalid_type?: string; +}; + +export const schemaHelper = { + /** + * Phone number + * Apply for phone number input. + */ + phoneNumber: (props?: { message?: MessageMapProps; isValid?: (text: string) => boolean }) => + zod + .string({ + required_error: props?.message?.required ?? 'Phone number is required!', + invalid_type_error: props?.message?.invalid_type ?? 'Invalid phone number!', + }) + .min(1, { message: props?.message?.required ?? 'Phone number is required!' }) + .refine((data) => props?.isValid?.(data), { + message: props?.message?.invalid_type ?? 'Invalid phone number!', + }), + /** + * Date + * Apply for date pickers. + */ + date: (props?: { message?: MessageMapProps }) => + zod.coerce + .date() + .nullable() + .transform((dateString, ctx) => { + const date = dayjs(dateString).format(); + + const stringToDate = zod.string().pipe(zod.coerce.date()); + + if (!dateString) { + ctx.addIssue({ + code: zod.ZodIssueCode.custom, + message: props?.message?.required ?? 'Date is required!', + }); + return null; + } + + if (!stringToDate.safeParse(date).success) { + ctx.addIssue({ + code: zod.ZodIssueCode.invalid_date, + message: props?.message?.invalid_type ?? 'Invalid Date!!', + }); + } + + return date; + }) + .pipe(zod.union([zod.number(), zod.string(), zod.date(), zod.null()])), + /** + * Editor + * defaultValue === '' |

    + * Apply for editor + */ + editor: (props?: { message: string }) => + zod.string().min(8, { message: props?.message ?? 'Content is required!' }), + /** + * Nullable Input + * Apply for input, select... with null value. + */ + nullableInput: (schema: T, options?: { message?: string }) => + schema.nullable().transform((val, ctx) => { + if (val === null || val === undefined) { + ctx.addIssue({ + code: zod.ZodIssueCode.custom, + message: options?.message ?? 'Field can not be null!', + }); + return val; + } + return val; + }), + /** + * Boolean + * Apply for checkbox, switch... + */ + boolean: (props?: { message: string }) => + zod.boolean({ coerce: true }).refine((val) => val === true, { + message: props?.message ?? 'Field is required!', + }), + /** + * Slider + * Apply for slider with range [min, max]. + */ + sliderRange: (props: { message?: string; min: number; max: number }) => + zod + .number() + .array() + .refine((data) => data[0] >= props?.min && data[1] <= props?.max, { + message: props.message ?? `Range must be between ${props?.min} and ${props?.max}`, + }), + /** + * File + * Apply for upload single file. + */ + file: (props?: { message: string }) => + zod.custom().transform((data, ctx) => { + const hasFile = data instanceof File || (typeof data === 'string' && !!data.length); + + if (!hasFile) { + ctx.addIssue({ + code: zod.ZodIssueCode.custom, + message: props?.message ?? 'File is required!', + }); + return null; + } + + return data; + }), + /** + * Files + * Apply for upload multiple files. + */ + files: (props?: { message: string; minFiles?: number }) => + zod.array(zod.custom()).transform((data, ctx) => { + const minFiles = props?.minFiles ?? 2; + + if (!data.length) { + ctx.addIssue({ + code: zod.ZodIssueCode.custom, + message: props?.message ?? 'Files is required!', + }); + } else if (data.length < minFiles) { + ctx.addIssue({ + code: zod.ZodIssueCode.custom, + message: `Must have at least ${minFiles} items!`, + }); + } + + return data; + }), +}; diff --git a/app/frontend/src/components/iconify/classes.ts b/app/frontend/src/components/iconify/classes.ts new file mode 100644 index 00000000..6d4a4633 --- /dev/null +++ b/app/frontend/src/components/iconify/classes.ts @@ -0,0 +1,7 @@ +import { createClasses } from 'src/theme/create-classes'; + +// ---------------------------------------------------------------------- + +export const iconifyClasses = { + root: createClasses('iconify__root'), +}; diff --git a/app/frontend/src/components/iconify/iconify.tsx b/app/frontend/src/components/iconify/iconify.tsx new file mode 100644 index 00000000..5d3d25d6 --- /dev/null +++ b/app/frontend/src/components/iconify/iconify.tsx @@ -0,0 +1,56 @@ +'use client'; + +import type { IconProps } from '@iconify/react'; +import type { Theme, SxProps } from '@mui/material/styles'; + +import { forwardRef } from 'react'; +import { Icon, disableCache } from '@iconify/react'; +import { mergeClasses } from 'minimal-shared/utils'; + +import NoSsr from '@mui/material/NoSsr'; +import { styled } from '@mui/material/styles'; + +import { iconifyClasses } from './classes'; + +// ---------------------------------------------------------------------- + +export type IconifyProps = React.ComponentProps & IconProps; + +export const Iconify = forwardRef((props, ref) => { + const { className, width = 20, sx, ...other } = props; + + const baseStyles: SxProps = { + width, + height: width, + flexShrink: 0, + display: 'inline-flex', + }; + + const renderFallback = () => ( + + ); + + return ( + + + + ); +}); + +// https://iconify.design/docs/iconify-icon/disable-cache.html +disableCache('local'); + +// ---------------------------------------------------------------------- + +const IconRoot = styled(Icon)``; + +const IconFallback = styled('span')``; diff --git a/app/frontend/src/components/iconify/index.ts b/app/frontend/src/components/iconify/index.ts new file mode 100644 index 00000000..7210d5c3 --- /dev/null +++ b/app/frontend/src/components/iconify/index.ts @@ -0,0 +1,3 @@ +export * from './classes'; + +export * from './iconify'; diff --git a/app/frontend/src/components/image/classes.ts b/app/frontend/src/components/image/classes.ts new file mode 100644 index 00000000..1e4fd983 --- /dev/null +++ b/app/frontend/src/components/image/classes.ts @@ -0,0 +1,13 @@ +import { createClasses } from 'src/theme/create-classes'; + +// ---------------------------------------------------------------------- + +export const imageClasses = { + root: createClasses('image__root'), + img: createClasses('image__img'), + overlay: createClasses('image__overlay'), + placeholder: createClasses('image__placeholder'), + state: { + loaded: '--loaded', + }, +}; diff --git a/app/frontend/src/components/image/image.tsx b/app/frontend/src/components/image/image.tsx new file mode 100644 index 00000000..9b3e03d7 --- /dev/null +++ b/app/frontend/src/components/image/image.tsx @@ -0,0 +1,138 @@ +import type { UseInViewOptions } from 'framer-motion'; +import type { Breakpoint } from '@mui/material/styles'; + +import { useInView } from 'framer-motion'; +import { mergeRefs, mergeClasses } from 'minimal-shared/utils'; +import { useRef, useState, forwardRef, useCallback, startTransition } from 'react'; + +import { imageClasses } from './classes'; +import { ImageImg, ImageRoot, ImageOverlay, ImagePlaceholder } from './styles'; + +import type { EffectsType } from './styles'; + +// ---------------------------------------------------------------------- + +type AspectRatioType = + | '2/3' + | '3/2' + | '4/3' + | '3/4' + | '6/4' + | '4/6' + | '16/9' + | '9/16' + | '21/9' + | '9/21' + | '1/1' + | string; + +export type ImageProps = React.ComponentProps & { + src?: string; + alt?: string; + delayTime?: number; + onLoad?: () => void; + effect?: EffectsType; + visibleByDefault?: boolean; + disablePlaceholder?: boolean; + viewportOptions?: UseInViewOptions; + ratio?: AspectRatioType | Partial>; + slotProps?: { + img?: Omit, 'src' | 'alt'>; + overlay?: React.ComponentProps; + placeholder?: React.ComponentProps; + }; +}; + +const DEFAULT_DELAY = 0; +const DEFAULT_EFFECT: EffectsType = { + style: 'blur', + duration: 300, + disabled: false, +}; + +export const Image = forwardRef((props, ref) => { + const { + sx, + src, + ratio, + onLoad, + effect, + alt = '', + slotProps, + className, + viewportOptions, + disablePlaceholder, + visibleByDefault = false, + delayTime = DEFAULT_DELAY, + ...other + } = props; + + const localRef = useRef(null); + const [isLoaded, setIsLoaded] = useState(false); + + const isInView = useInView(localRef, { + once: true, + ...viewportOptions, + }); + + const handleImageLoad = useCallback(() => { + const timer = setTimeout(() => { + startTransition(() => { + setIsLoaded(true); + onLoad?.(); + }); + }, delayTime); + + return () => clearTimeout(timer); + }, [delayTime, onLoad]); + + const finalEffect = { + ...DEFAULT_EFFECT, + ...effect, + }; + + const shouldRenderImage = visibleByDefault || isInView; + const showPlaceholder = !visibleByDefault && !isLoaded && !disablePlaceholder; + + const renderComponents = { + overlay: () => + slotProps?.overlay && ( + + ), + placeholder: () => + showPlaceholder && ( + + ), + image: () => ( + + ), + }; + + return ( + + {renderComponents.overlay()} + {renderComponents.placeholder()} + {shouldRenderImage && renderComponents.image()} + + ); +}); diff --git a/app/frontend/src/components/image/index.ts b/app/frontend/src/components/image/index.ts new file mode 100644 index 00000000..315d5d05 --- /dev/null +++ b/app/frontend/src/components/image/index.ts @@ -0,0 +1,3 @@ +export * from './image'; + +export * from './classes'; diff --git a/app/frontend/src/components/image/styles.ts b/app/frontend/src/components/image/styles.ts new file mode 100644 index 00000000..1149d41d --- /dev/null +++ b/app/frontend/src/components/image/styles.ts @@ -0,0 +1,84 @@ +import type { CSSObject } from '@mui/material/styles'; + +import { styled } from '@mui/material/styles'; + +import { imageClasses } from './classes'; + +// ---------------------------------------------------------------------- + +const placeholderImage = + 'data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjUxMiIgdmlld0JveD0iMCAwIDUxMiA1MTIiIHdpZHRoPSI1MTIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgPHJhZGlhbEdyYWRpZW50IGlkPSJhIiBjeD0iNTAlIiBjeT0iNDYuODAxMTAyJSIgcj0iOTUuNDk3MTEyJSI+CiAgICA8c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiNmZmYiIHN0b3Atb3BhY2l0eT0iMCIgLz4KICAgIDxzdG9wIG9mZnNldD0iMSIgc3RvcC1jb2xvcj0iIzkxOWVhYiIgc3RvcC1vcGFjaXR5PSIuNDgiIC8+CiAgPC9yYWRpYWxHcmFkaWVudD4KICA8cGF0aCBkPSJtODggODZoNTEydjUxMmgtNTEyeiIgZmlsbD0idXJsKCNhKSIgZmlsbC1ydWxlPSJldmVub2RkIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtODggLTg2KSIgLz4KPC9zdmc+Cg=='; + +const sharedStyles: CSSObject = { + top: 0, + left: 0, + width: '100%', + height: '100%', + display: 'inherit', + aspectRatio: 'inherit', + borderRadius: 'inherit', +}; + +export const ImageRoot = styled('span', { + shouldForwardProp: (prop: string) => !['effect', 'sx'].includes(prop), +})<{ effect?: EffectsType }>(({ effect }) => ({ + maxWidth: '100%', + overflow: 'hidden', + position: 'relative', + display: 'inline-block', + verticalAlign: 'bottom', + aspectRatio: 'var(--aspect-ratio)', + ...(effect && getEffectStyles(effect)), +})); + +export const ImageImg = styled('img')(() => ({ + ...sharedStyles, + objectFit: 'cover', +})); + +export const ImageOverlay = styled('span')({ + ...sharedStyles, + zIndex: 1, + position: 'absolute', +}); + +export const ImagePlaceholder = styled('span')({ + ...sharedStyles, + content: '""', + position: 'absolute', + backgroundSize: 'cover', + backgroundPosition: 'center', + backgroundRepeat: 'no-repeat', + backgroundImage: `url(${placeholderImage})`, +}); + +// ---------------------------------------------------------------------- + +export type EffectsType = { + duration?: number; + disabled?: boolean; + style?: 'blur' | 'black-and-white' | 'opacity'; +}; + +const getEffectStyles = (effect?: EffectsType) => { + const { style, duration } = effect ?? {}; + + const transition = + style === 'opacity' + ? `opacity ${duration}ms` + : `opacity ${Number(duration) / 2}ms, filter ${duration}ms`; + + return { + [`& .${imageClasses.img}`]: { + transition, + ...(style === 'opacity' && { opacity: 0 }), + ...(style === 'blur' && { filter: 'blur(12px)', opacity: 0 }), + ...(style === 'black-and-white' && { filter: 'grayscale(1)', opacity: 0 }), + }, + [`&.${imageClasses.state.loaded} .${imageClasses.img}`]: { + ...(style === 'opacity' && { opacity: 1 }), + ...(style === 'blur' && { filter: 'blur(0)', opacity: 1 }), + ...(style === 'black-and-white' && { filter: 'grayscale(0)', opacity: 1 }), + }, + }; +}; diff --git a/app/frontend/src/components/label/classes.ts b/app/frontend/src/components/label/classes.ts new file mode 100644 index 00000000..00420532 --- /dev/null +++ b/app/frontend/src/components/label/classes.ts @@ -0,0 +1,8 @@ +import { createClasses } from 'src/theme/create-classes'; + +// ---------------------------------------------------------------------- + +export const labelClasses = { + root: createClasses('label__root'), + icon: createClasses('label__icon'), +}; diff --git a/app/frontend/src/components/label/index.ts b/app/frontend/src/components/label/index.ts new file mode 100644 index 00000000..71f3c7e6 --- /dev/null +++ b/app/frontend/src/components/label/index.ts @@ -0,0 +1,7 @@ +export * from './label'; + +export * from './styles'; + +export * from './classes'; + +export type * from './types'; diff --git a/app/frontend/src/components/label/label.tsx b/app/frontend/src/components/label/label.tsx new file mode 100644 index 00000000..99101ec9 --- /dev/null +++ b/app/frontend/src/components/label/label.tsx @@ -0,0 +1,42 @@ +import { forwardRef } from 'react'; +import { upperFirst } from 'es-toolkit'; +import { mergeClasses } from 'minimal-shared/utils'; + +import { labelClasses } from './classes'; +import { LabelRoot, LabelIcon } from './styles'; + +import type { LabelProps } from './types'; + +// ---------------------------------------------------------------------- + +export const Label = forwardRef((props, ref) => { + const { + endIcon, + children, + startIcon, + className, + disabled, + variant = 'soft', + color = 'default', + sx, + ...other + } = props; + + return ( + + {startIcon && {startIcon}} + + {typeof children === 'string' ? upperFirst(children) : children} + + {endIcon && {endIcon}} + + ); +}); diff --git a/app/frontend/src/components/label/styles.tsx b/app/frontend/src/components/label/styles.tsx new file mode 100644 index 00000000..52b58526 --- /dev/null +++ b/app/frontend/src/components/label/styles.tsx @@ -0,0 +1,117 @@ +'use client'; + +import type { CSSObject } from '@mui/material/styles'; + +import { varAlpha } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; + +import type { LabelProps } from './types'; + +// ---------------------------------------------------------------------- + +export const LabelRoot = styled('span', { + shouldForwardProp: (prop: string) => !['color', 'variant', 'disabled', 'sx'].includes(prop), +})(({ color, variant, disabled, theme }) => { + const defaultStyles: CSSObject = { + ...(color === 'default' && { + /** + * @variant filled + */ + ...(variant === 'filled' && { + color: theme.vars.palette.common.white, + backgroundColor: theme.vars.palette.text.primary, + ...theme.applyStyles('dark', { + color: theme.vars.palette.grey[800], + }), + }), + /** + * @variant outlined + */ + ...(variant === 'outlined' && { + backgroundColor: 'transparent', + color: theme.vars.palette.text.primary, + border: `2px solid ${theme.vars.palette.text.primary}`, + }), + /** + * @variant soft + */ + ...(variant === 'soft' && { + color: theme.vars.palette.text.secondary, + backgroundColor: varAlpha(theme.vars.palette.grey['500Channel'], 0.16), + }), + /** + * @variant inverted + */ + ...(variant === 'inverted' && { + color: theme.vars.palette.grey[800], + backgroundColor: theme.vars.palette.grey[300], + }), + }), + }; + + const colorStyles: CSSObject = { + ...(color && + color !== 'default' && { + /** + * @variant filled + */ + ...(variant === 'filled' && { + color: theme.vars.palette[color].contrastText, + backgroundColor: theme.vars.palette[color].main, + }), + /** + * @variant outlined + */ + ...(variant === 'outlined' && { + backgroundColor: 'transparent', + color: theme.vars.palette[color].main, + border: `2px solid ${theme.vars.palette[color].main}`, + }), + /** + * @variant soft + */ + ...(variant === 'soft' && { + color: theme.vars.palette[color].dark, + backgroundColor: varAlpha(theme.vars.palette[color].mainChannel, 0.16), + ...theme.applyStyles('dark', { + color: theme.vars.palette[color].light, + }), + }), + /** + * @variant inverted + */ + ...(variant === 'inverted' && { + color: theme.vars.palette[color].darker, + backgroundColor: theme.vars.palette[color].lighter, + }), + }), + }; + + return { + height: 24, + minWidth: 24, + lineHeight: 0, + cursor: 'default', + alignItems: 'center', + whiteSpace: 'nowrap', + display: 'inline-flex', + gap: theme.spacing(0.75), + justifyContent: 'center', + padding: theme.spacing(0, 0.75), + fontSize: theme.typography.pxToRem(12), + fontWeight: theme.typography.fontWeightBold, + borderRadius: theme.shape.borderRadius * 0.75, + transition: theme.transitions.create(['all'], { duration: theme.transitions.duration.shorter }), + ...defaultStyles, + ...colorStyles, + ...(disabled && { opacity: 0.48, pointerEvents: 'none' }), + }; +}); + +export const LabelIcon = styled('span')({ + width: 16, + height: 16, + flexShrink: 0, + '& svg, img': { width: '100%', height: '100%', objectFit: 'cover' }, +}); diff --git a/app/frontend/src/components/label/types.ts b/app/frontend/src/components/label/types.ts new file mode 100644 index 00000000..4db6635b --- /dev/null +++ b/app/frontend/src/components/label/types.ts @@ -0,0 +1,23 @@ +import type { Theme, SxProps } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +export type LabelColor = + | 'default' + | 'primary' + | 'secondary' + | 'info' + | 'success' + | 'warning' + | 'error'; + +export type LabelVariant = 'filled' | 'outlined' | 'soft' | 'inverted'; + +export interface LabelProps extends React.ComponentProps<'span'> { + sx?: SxProps; + disabled?: boolean; + color?: LabelColor; + variant?: LabelVariant; + endIcon?: React.ReactNode; + startIcon?: React.ReactNode; +} diff --git a/app/frontend/src/components/lightbox/classes.ts b/app/frontend/src/components/lightbox/classes.ts new file mode 100644 index 00000000..33d7a00d --- /dev/null +++ b/app/frontend/src/components/lightbox/classes.ts @@ -0,0 +1,7 @@ +import { createClasses } from 'src/theme/create-classes'; + +// ---------------------------------------------------------------------- + +export const lightboxClasses = { + root: createClasses('lightbox__root'), +}; diff --git a/app/frontend/src/components/lightbox/index.ts b/app/frontend/src/components/lightbox/index.ts new file mode 100644 index 00000000..7c3fb106 --- /dev/null +++ b/app/frontend/src/components/lightbox/index.ts @@ -0,0 +1,5 @@ +export * from './lightbox'; + +export * from './use-light-box'; + +export type * from './types'; diff --git a/app/frontend/src/components/lightbox/lightbox.tsx b/app/frontend/src/components/lightbox/lightbox.tsx new file mode 100644 index 00000000..3b6faf87 --- /dev/null +++ b/app/frontend/src/components/lightbox/lightbox.tsx @@ -0,0 +1,101 @@ +import { mergeClasses } from 'minimal-shared/utils'; +import ReactLightbox, { useLightboxState } from 'yet-another-react-lightbox'; + +import Box from '@mui/material/Box'; + +import { Iconify } from '../iconify'; +import { getPlugins } from './utils'; +import { lightboxClasses } from './classes'; + +import type { LightBoxProps } from './types'; + +// ---------------------------------------------------------------------- + +export function Lightbox({ + slides, + disableZoom, + disableVideo, + disableTotal, + disableCaptions, + disableSlideshow, + disableThumbnails, + disableFullscreen, + onGetCurrentIndex, + className, + ...other +}: LightBoxProps) { + const totalItems = slides ? slides.length : 0; + + return ( + { + if (onGetCurrentIndex) { + onGetCurrentIndex(index); + } + }, + }} + toolbar={{ + buttons: [ + , + 'close', + ], + }} + render={{ + iconClose: () => , + iconZoomIn: () => , + iconZoomOut: () => , + iconSlideshowPlay: () => , + iconSlideshowPause: () => , + iconPrev: () => , + iconNext: () => , + iconExitFullscreen: () => , + iconEnterFullscreen: () => , + }} + className={mergeClasses([lightboxClasses.root, className])} + {...other} + /> + ); +} + +// ---------------------------------------------------------------------- + +type DisplayTotalProps = { + totalItems: number; + disableTotal?: boolean; +}; + +function DisplayTotal({ totalItems, disableTotal }: DisplayTotalProps) { + const { currentIndex } = useLightboxState(); + + if (disableTotal) { + return null; + } + + return ( + + {currentIndex + 1} / {totalItems} + + ); +} diff --git a/app/frontend/src/components/lightbox/styles.css b/app/frontend/src/components/lightbox/styles.css new file mode 100644 index 00000000..37716851 --- /dev/null +++ b/app/frontend/src/components/lightbox/styles.css @@ -0,0 +1,35 @@ +@import 'yet-another-react-lightbox/styles.css'; +@import 'yet-another-react-lightbox/plugins/captions.css'; +@import 'yet-another-react-lightbox/plugins/thumbnails.css'; + +.yarl__root { + --yarl__thumbnails_thumbnail_padding: 0; + --yarl__thumbnails_thumbnail_border: transparent; + --yarl__color_backdrop: rgba(var(--palette-grey-900Channel) / 0.9); + --yarl__slide_captions_container_background: rgba(var(--palette-common-blackChannel) / 0.32); +} +.yarl__slide_title { + font-size: 20px; + font-weight: 600; +} +.yarl__slide_description { + font-size: 14px; +} +.yarl__button { + filter: unset; +} +.yarl__thumbnails_thumbnail { + opacity: 0.48; + border-radius: 10px; + border-color: transparent; +} +.yarl__thumbnails_thumbnail_active { + opacity: 1; + border-color: var(--palette-primary-main); +} +.yarl__thumbnails_vignette { + --yarl__thumbnails_vignette_size: 0; +} +.yarl__video_container { + background: var(--palette-common-black); +} diff --git a/app/frontend/src/components/lightbox/types.ts b/app/frontend/src/components/lightbox/types.ts new file mode 100644 index 00000000..38b0c6ba --- /dev/null +++ b/app/frontend/src/components/lightbox/types.ts @@ -0,0 +1,14 @@ +import type { LightboxExternalProps } from 'yet-another-react-lightbox'; + +// ---------------------------------------------------------------------- + +export type LightBoxProps = LightboxExternalProps & { + disableZoom?: boolean; + disableVideo?: boolean; + disableTotal?: boolean; + disableCaptions?: boolean; + disableSlideshow?: boolean; + disableThumbnails?: boolean; + disableFullscreen?: boolean; + onGetCurrentIndex?: (index: number) => void; +}; diff --git a/app/frontend/src/components/lightbox/use-light-box.ts b/app/frontend/src/components/lightbox/use-light-box.ts new file mode 100644 index 00000000..375d3b68 --- /dev/null +++ b/app/frontend/src/components/lightbox/use-light-box.ts @@ -0,0 +1,42 @@ +import type { Slide, SlideImage, SlideVideo } from 'yet-another-react-lightbox'; + +import { useState, useCallback } from 'react'; + +// ---------------------------------------------------------------------- + +export type UseLightBoxReturn = { + open: boolean; + selected: number; + onClose: () => void; + onOpen: (slideUrl: string) => void; + setSelected: React.Dispatch>; +}; + +export function useLightBox(slides: Slide[]): UseLightBoxReturn { + const [selected, setSelected] = useState(-1); + + const handleOpen = useCallback( + (slideUrl: string) => { + const slideIndex = slides.findIndex((slide) => + slide.type === 'video' + ? (slide as SlideVideo).poster === slideUrl + : (slide as SlideImage).src === slideUrl + ); + + setSelected(slideIndex); + }, + [slides] + ); + + const handleClose = useCallback(() => { + setSelected(-1); + }, []); + + return { + selected, + open: selected >= 0, + onOpen: handleOpen, + onClose: handleClose, + setSelected, + }; +} diff --git a/app/frontend/src/components/lightbox/utils.ts b/app/frontend/src/components/lightbox/utils.ts new file mode 100644 index 00000000..11c79ead --- /dev/null +++ b/app/frontend/src/components/lightbox/utils.ts @@ -0,0 +1,42 @@ +import Zoom from 'yet-another-react-lightbox/plugins/zoom'; +import Video from 'yet-another-react-lightbox/plugins/video'; +import Captions from 'yet-another-react-lightbox/plugins/captions'; +import Slideshow from 'yet-another-react-lightbox/plugins/slideshow'; +import Fullscreen from 'yet-another-react-lightbox/plugins/fullscreen'; +import Thumbnails from 'yet-another-react-lightbox/plugins/thumbnails'; + +import type { LightBoxProps } from './types'; + +// ---------------------------------------------------------------------- + +export function getPlugins({ + disableZoom, + disableVideo, + disableCaptions, + disableSlideshow, + disableThumbnails, + disableFullscreen, +}: Partial) { + let plugins = [Captions, Fullscreen, Slideshow, Thumbnails, Video, Zoom]; + + if (disableThumbnails) { + plugins = plugins.filter((plugin) => plugin !== Thumbnails); + } + if (disableCaptions) { + plugins = plugins.filter((plugin) => plugin !== Captions); + } + if (disableFullscreen) { + plugins = plugins.filter((plugin) => plugin !== Fullscreen); + } + if (disableSlideshow) { + plugins = plugins.filter((plugin) => plugin !== Slideshow); + } + if (disableZoom) { + plugins = plugins.filter((plugin) => plugin !== Zoom); + } + if (disableVideo) { + plugins = plugins.filter((plugin) => plugin !== Video); + } + + return plugins; +} diff --git a/app/frontend/src/components/loading-screen/index.ts b/app/frontend/src/components/loading-screen/index.ts new file mode 100644 index 00000000..0744cffe --- /dev/null +++ b/app/frontend/src/components/loading-screen/index.ts @@ -0,0 +1,3 @@ +export * from './splash-screen'; + +export * from './loading-screen'; diff --git a/app/frontend/src/components/loading-screen/loading-screen.tsx b/app/frontend/src/components/loading-screen/loading-screen.tsx new file mode 100644 index 00000000..16937d60 --- /dev/null +++ b/app/frontend/src/components/loading-screen/loading-screen.tsx @@ -0,0 +1,41 @@ +'use client'; + +import type { Theme, SxProps } from '@mui/material/styles'; + +import { Fragment } from 'react'; + +import Portal from '@mui/material/Portal'; +import { styled } from '@mui/material/styles'; +import LinearProgress from '@mui/material/LinearProgress'; + +// ---------------------------------------------------------------------- + +export type LoadingScreenProps = React.ComponentProps<'div'> & { + portal?: boolean; + sx?: SxProps; +}; + +export function LoadingScreen({ portal, sx, ...other }: LoadingScreenProps) { + const PortalWrapper = portal ? Portal : Fragment; + + return ( + + + + + + ); +} + +// ---------------------------------------------------------------------- + +const LoadingContent = styled('div')(({ theme }) => ({ + flexGrow: 1, + width: '100%', + display: 'flex', + minHeight: '100%', + alignItems: 'center', + justifyContent: 'center', + paddingLeft: theme.spacing(5), + paddingRight: theme.spacing(5), +})); diff --git a/app/frontend/src/components/loading-screen/splash-screen.tsx b/app/frontend/src/components/loading-screen/splash-screen.tsx new file mode 100644 index 00000000..b24b15cd --- /dev/null +++ b/app/frontend/src/components/loading-screen/splash-screen.tsx @@ -0,0 +1,56 @@ +'use client'; + +import type { Theme, SxProps } from '@mui/material/styles'; + +import { Fragment } from 'react'; + +import Portal from '@mui/material/Portal'; +import { styled } from '@mui/material/styles'; + +import { AnimateLogoZoom } from 'src/components/animate'; + +// ---------------------------------------------------------------------- + +export type SplashScreenProps = React.ComponentProps<'div'> & { + portal?: boolean; + sx?: SxProps; + slotProps?: { + wrapper?: React.ComponentProps; + }; +}; + +export function SplashScreen({ portal = true, slotProps, sx, ...other }: SplashScreenProps) { + const PortalWrapper = portal ? Portal : Fragment; + + return ( + + + + + + + + ); +} + +// ---------------------------------------------------------------------- + +const LoadingWrapper = styled('div')({ + flexGrow: 1, + display: 'flex', + flexDirection: 'column', +}); + +const LoadingContent = styled('div')(({ theme }) => ({ + right: 0, + bottom: 0, + zIndex: 9998, + flexGrow: 1, + width: '100%', + height: '100%', + display: 'flex', + position: 'fixed', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: theme.vars.palette.background.default, +})); diff --git a/app/frontend/src/components/logo/classes.ts b/app/frontend/src/components/logo/classes.ts new file mode 100644 index 00000000..38011995 --- /dev/null +++ b/app/frontend/src/components/logo/classes.ts @@ -0,0 +1,7 @@ +import { createClasses } from 'src/theme/create-classes'; + +// ---------------------------------------------------------------------- + +export const logoClasses = { + root: createClasses('logo__root'), +}; diff --git a/app/frontend/src/components/logo/index.ts b/app/frontend/src/components/logo/index.ts new file mode 100644 index 00000000..b86e7ad3 --- /dev/null +++ b/app/frontend/src/components/logo/index.ts @@ -0,0 +1,3 @@ +export * from './logo'; + +export * from './classes'; diff --git a/app/frontend/src/components/logo/logo.tsx b/app/frontend/src/components/logo/logo.tsx new file mode 100644 index 00000000..1152c336 --- /dev/null +++ b/app/frontend/src/components/logo/logo.tsx @@ -0,0 +1,216 @@ +import type { LinkProps } from '@mui/material/Link'; + +import { useId, forwardRef } from 'react'; +import { mergeClasses } from 'minimal-shared/utils'; + +import Link from '@mui/material/Link'; +import { styled, useTheme } from '@mui/material/styles'; + +import { RouterLink } from 'src/routes/components'; + +import { logoClasses } from './classes'; + +// ---------------------------------------------------------------------- + +export type LogoProps = LinkProps & { + isSingle?: boolean; + disabled?: boolean; +}; + +export const Logo = forwardRef((props, ref) => { + const { className, href = '/', isSingle = true, disabled, sx, ...other } = props; + + const theme = useTheme(); + + const gradientId = useId(); + + const TEXT_PRIMARY = theme.vars.palette.text.primary; + const PRIMARY_LIGHT = theme.vars.palette.primary.light; + const PRIMARY_MAIN = theme.vars.palette.primary.main; + const PRIMARY_DARKER = theme.vars.palette.primary.dark; + + /* + * OR using local (public folder) + * + const singleLogo = ( + Single logo + ); + + const fullLogo = ( + Full logo + ); + * + */ + + const singleLogo = ( + + + + + + + + + + + + + + + + + + + + ); + + const fullLogo = ( + + + + + + + + + + + + + + + + + + + + + ); + + return ( + ({ + width: 40, + height: 40, + ...(!isSingle && { width: 102, height: 36 }), + ...(disabled && { pointerEvents: 'none' }), + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + {isSingle ? singleLogo : fullLogo} + + ); +}); + +// ---------------------------------------------------------------------- + +const LogoRoot = styled(Link)(() => ({ + flexShrink: 0, + color: 'transparent', + display: 'inline-flex', + verticalAlign: 'middle', +})); diff --git a/app/frontend/src/components/map/index.ts b/app/frontend/src/components/map/index.ts new file mode 100644 index 00000000..8cdc567e --- /dev/null +++ b/app/frontend/src/components/map/index.ts @@ -0,0 +1,7 @@ +export * from './map'; + +export * from './map-popup'; + +export * from './map-marker'; + +export * from './map-controls'; diff --git a/app/frontend/src/components/map/map-controls.tsx b/app/frontend/src/components/map/map-controls.tsx new file mode 100644 index 00000000..41d4db53 --- /dev/null +++ b/app/frontend/src/components/map/map-controls.tsx @@ -0,0 +1,46 @@ +import type { + ScaleControlProps, + GeolocateControlProps, + FullscreenControlProps, + NavigationControlProps, +} from 'react-map-gl'; + +import { ScaleControl, GeolocateControl, NavigationControl, FullscreenControl } from 'react-map-gl'; + +// ---------------------------------------------------------------------- + +export type MapControlsProps = { + hideScale?: boolean; + hideGeolocate?: boolean; + hideFullscreen?: boolean; + hideNavigation?: boolean; + slotProps?: { + scale?: ScaleControlProps; + geolocate?: GeolocateControlProps; + fullscreen?: FullscreenControlProps; + navigation?: NavigationControlProps; + }; +}; + +export function MapControls({ + hideScale, + hideGeolocate, + hideFullscreen, + hideNavigation, + slotProps, +}: MapControlsProps) { + return ( + <> + {!hideGeolocate && ( + + )} + {!hideFullscreen && } + {!hideScale && } + {!hideNavigation && } + + ); +} diff --git a/app/frontend/src/components/map/map-marker.tsx b/app/frontend/src/components/map/map-marker.tsx new file mode 100644 index 00000000..843ba5be --- /dev/null +++ b/app/frontend/src/components/map/map-marker.tsx @@ -0,0 +1,35 @@ +import type { MarkerProps } from 'react-map-gl'; + +import { Marker } from 'react-map-gl'; + +import SvgIcon from '@mui/material/SvgIcon'; + +// ---------------------------------------------------------------------- + +const SIZE = 20; + +const ICON = `M20.2,15.7L20.2,15.7c1.1-1.6,1.8-3.6,1.8-5.7c0-5.6-4.5-10-10-10S2,4.5,2,10c0,2,0.6,3.9,1.6,5.4c0,0.1,0.1,0.2,0.2,0.3 + c0,0,0.1,0.1,0.1,0.2c0.2,0.3,0.4,0.6,0.7,0.9c2.6,3.1,7.4,7.6,7.4,7.6s4.8-4.5,7.4-7.5c0.2-0.3,0.5-0.6,0.7-0.9 + C20.1,15.8,20.2,15.8,20.2,15.7z`; + +// ---------------------------------------------------------------------- + +export function MapMarker({ ...other }: MarkerProps) { + return ( + + ({ + height: SIZE, + stroke: 'none', + cursor: 'pointer', + fill: theme.vars.palette.error.main, + transform: `translate(${-SIZE / 2}px, ${-SIZE}px)`, + }), + ]} + > + + + + ); +} diff --git a/app/frontend/src/components/map/map-popup.tsx b/app/frontend/src/components/map/map-popup.tsx new file mode 100644 index 00000000..47baba69 --- /dev/null +++ b/app/frontend/src/components/map/map-popup.tsx @@ -0,0 +1,19 @@ +import { Popup } from 'react-map-gl'; + +import { styled } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +export type MapPopupProps = React.ComponentProps; + +export function MapPopup({ sx, children, ...other }: MapPopupProps) { + return ( + + {children} + + ); +} + +// ---------------------------------------------------------------------- + +const MapPopupRoot = styled(Popup)``; diff --git a/app/frontend/src/components/map/map.tsx b/app/frontend/src/components/map/map.tsx new file mode 100644 index 00000000..28094e41 --- /dev/null +++ b/app/frontend/src/components/map/map.tsx @@ -0,0 +1,55 @@ +import type { Theme, SxProps } from '@mui/material/styles'; +import type { MapRef, MapProps as ReactMapProps } from 'react-map-gl'; + +import { lazy, Suspense, forwardRef } from 'react'; +import { useIsClient } from 'minimal-shared/hooks'; + +import Skeleton from '@mui/material/Skeleton'; +import { styled } from '@mui/material/styles'; + +import { CONFIG } from 'src/global-config'; + +// ---------------------------------------------------------------------- + +const LazyMap = lazy(() => import('react-map-gl').then((module) => ({ default: module.default }))); + +export type MapProps = ReactMapProps & { sx?: SxProps }; + +export const Map = forwardRef((props, ref) => { + const { sx, ...other } = props; + + const isClient = useIsClient(); + + const renderFallback = () => ( + + ); + + return ( + + {isClient ? ( + + + + ) : ( + renderFallback() + )} + + ); +}); + +// ---------------------------------------------------------------------- + +const MapRoot = styled('div')({ + width: '100%', + overflow: 'hidden', + position: 'relative', +}); diff --git a/app/frontend/src/components/map/styles.css b/app/frontend/src/components/map/styles.css new file mode 100644 index 00000000..0c84d683 --- /dev/null +++ b/app/frontend/src/components/map/styles.css @@ -0,0 +1,58 @@ +@import 'mapbox-gl/dist/mapbox-gl.css'; + +.mapboxgl-map { + .mapboxgl-ctrl.mapboxgl-ctrl-group { + border-radius: 8px; + box-shadow: 0 8px 16px 0 rgba(var(--palette-grey-500Channel), 0.16); + } + .mapboxgl-ctrl.mapboxgl-ctrl-scale { + border: none; + color: white; + font-weight: 700; + line-height: 14px; + border-radius: 4px; + background-image: linear-gradient(to right, #8a2387, #e94057, #f27121); + } + .mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon { + transform: scale(0.75); + } + .mapboxgl-ctrl-group button + button { + border-top: solid 1px var(--palette-divider); + } + /* popup */ + .mapboxgl-popup-content { + padding: 8px; + max-width: 180px; + border-radius: 12px; + background-color: var(--palette-background-paper); + color: var(--palette-text-primary); + box-shadow: 0 20px 40px -4px rgba(0, 0, 0, 0.16); + } + .mapboxgl-popup-tip { + transform: translateY(-1px); + border-top-color: var(--palette-background-paper); + } + .mapboxgl-popup-close-button { + top: 4px; + right: 4px; + width: 24px; + height: 24px; + opacity: 0.48; + font-size: 20px; + border-radius: 50%; + color: var(--palette-grey-500); + transition: opacity 200ms linear 0s; + &:hover { + opacity: 1; + } + &:focus { + outline: none; + } + } + .mapboxgl-ctrl .mapboxgl-ctrl-logo { + display: none; + } + .mapboxgl-ctrl-bottom-right { + display: none; + } +} diff --git a/app/frontend/src/components/markdown/classes.ts b/app/frontend/src/components/markdown/classes.ts new file mode 100644 index 00000000..0319c01c --- /dev/null +++ b/app/frontend/src/components/markdown/classes.ts @@ -0,0 +1,14 @@ +import { createClasses } from 'src/theme/create-classes'; + +// ---------------------------------------------------------------------- + +export const markdownClasses = { + root: createClasses('markdown__root'), + content: { + pre: createClasses('markdown__content__pre'), + codeInline: createClasses('markdown__content__codeInline'), + codeBlock: createClasses('markdown__content__codeBlock'), + image: createClasses('markdown__content__image'), + link: createClasses('markdown__content__link'), + }, +}; diff --git a/app/frontend/src/components/markdown/code-highlight-block.css b/app/frontend/src/components/markdown/code-highlight-block.css new file mode 100644 index 00000000..8d8cf379 --- /dev/null +++ b/app/frontend/src/components/markdown/code-highlight-block.css @@ -0,0 +1,82 @@ +pre { + code { + .hljs-comment { + color: #999; + } + .hljs-tag { + color: #b4b7b4; + } + .hljs-operator, + .hljs-punctuation, + .hljs-subst { + color: #ccc; + } + .hljs-operator { + opacity: 0.7; + } + .hljs-bullet, + .hljs-deletion, + .hljs-name, + .hljs-selector-tag, + .hljs-template-variable, + .hljs-variable { + color: #f2777a; + } + .hljs-attr, + .hljs-link, + .hljs-literal, + .hljs-number, + .hljs-symbol, + .hljs-variable.constant_ { + color: #f99157; + } + .hljs-class .hljs-title, + .hljs-title, + .hljs-title.class_ { + color: #fc6; + } + .hljs-strong { + font-weight: 700; + color: #fc6; + } + .hljs-addition, + .hljs-code, + .hljs-string, + .hljs-title.class_.inherited__ { + color: #9c9; + } + .hljs-built_in, + .hljs-doctag, + .hljs-keyword.hljs-atrule, + .hljs-quote, + .hljs-regexp { + color: #6cc; + } + .hljs-attribute, + .hljs-function .hljs-title, + .hljs-section, + .hljs-title.function_, + .ruby .hljs-property { + color: #69c; + } + .diff .hljs-meta, + .hljs-keyword, + .hljs-template-tag, + .hljs-type { + color: #c9c; + } + .hljs-emphasis { + color: #c9c; + font-style: italic; + } + .hljs-meta, + .hljs-meta .hljs-keyword, + .hljs-meta .hljs-string { + color: #a3685a; + } + .hljs-meta .hljs-keyword, + .hljs-meta-keyword { + font-weight: 700; + } + } +} diff --git a/app/frontend/src/components/markdown/html-tags.ts b/app/frontend/src/components/markdown/html-tags.ts new file mode 100644 index 00000000..44c86bcb --- /dev/null +++ b/app/frontend/src/components/markdown/html-tags.ts @@ -0,0 +1,172 @@ +/** All html tags + * https://github.com/harrysolovay/all-html-tags + */ + +export const htmlTags = [ + 'a', + 'abbr', + 'acronym', + 'address', + 'applet', + 'area', + 'article', + 'aside', + 'audio', + 'b', + 'base', + 'basefont', + 'bdi', + 'bdo', + 'bgsound', + 'big', + 'blink', + 'blockquote', + 'body', + 'br', + 'button', + 'canvas', + 'caption', + 'center', + 'circle', + 'cite', + 'clipPath', + 'code', + 'col', + 'colgroup', + 'command', + 'content', + 'data', + 'datalist', + 'dd', + 'defs', + 'del', + 'details', + 'dfn', + 'dialog', + 'dir', + 'div', + 'dl', + 'dt', + 'element', + 'ellipse', + 'em', + 'embed', + 'fieldset', + 'figcaption', + 'figure', + 'font', + 'footer', + 'foreignObject', + 'form', + 'frame', + 'frameset', + 'g', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'head', + 'header', + 'hgroup', + 'hr', + 'html', + 'i', + 'iframe', + 'image', + 'img', + 'input', + 'ins', + 'isindex', + 'kbd', + 'keygen', + 'label', + 'legend', + 'li', + 'line', + 'linearGradient', + 'link', + 'listing', + 'main', + 'map', + 'mark', + 'marquee', + 'mask', + 'math', + 'menu', + 'menuitem', + 'meta', + 'meter', + 'multicol', + 'nav', + 'nextid', + 'nobr', + 'noembed', + 'noframes', + 'noscript', + 'object', + 'ol', + 'optgroup', + 'option', + 'output', + 'p', + 'param', + 'path', + 'pattern', + 'picture', + 'plaintext', + 'polygon', + 'polyline', + 'pre', + 'progress', + 'q', + 'radialGradient', + 'rb', + 'rbc', + 'rect', + 'rp', + 'rt', + 'rtc', + 'ruby', + 's', + 'samp', + 'script', + 'section', + 'select', + 'shadow', + 'slot', + 'small', + 'source', + 'spacer', + 'span', + 'stop', + 'strike', + 'strong', + 'style', + 'sub', + 'summary', + 'sup', + 'svg', + 'table', + 'tbody', + 'td', + 'template', + 'text', + 'textarea', + 'tfoot', + 'th', + 'thead', + 'time', + 'title', + 'tr', + 'track', + 'tspan', + 'tt', + 'u', + 'ul', + 'var', + 'video', + 'wbr', + 'xmp', +]; diff --git a/app/frontend/src/components/markdown/html-to-markdown.ts b/app/frontend/src/components/markdown/html-to-markdown.ts new file mode 100644 index 00000000..17fe0a87 --- /dev/null +++ b/app/frontend/src/components/markdown/html-to-markdown.ts @@ -0,0 +1,62 @@ +import type { Node, Filter } from 'turndown'; + +import TurndownService from 'turndown'; + +import { htmlTags } from './html-tags'; + +// ---------------------------------------------------------------------- + +type INode = HTMLElement & { + isBlock: boolean; +}; + +const excludeTags = ['pre', 'code']; + +const turndownService = new TurndownService({ codeBlockStyle: 'fenced', fence: '```' }); + +const filterTags = htmlTags.filter((item) => !excludeTags.includes(item)) as Filter; + +/** + * Custom rule + * https://github.com/mixmark-io/turndown/issues/241#issuecomment-400591362 + */ +turndownService.addRule('keep', { + filter: filterTags, + replacement(content: string, node: Node) { + const { isBlock, outerHTML } = node as INode; + + return node && isBlock ? `\n\n${outerHTML}\n\n` : outerHTML; + }, +}); + +// ---------------------------------------------------------------------- + +export function htmlToMarkdown(html: string) { + return turndownService.turndown(html); +} +// ---------------------------------------------------------------------- + +export function isMarkdownContent(content: string) { + // Checking if the content contains Markdown-specific patterns + const markdownPatterns = [ + /* Heading */ + /^#+\s/, + /* List item */ + /^(\*|-|\d+\.)\s/, + /* Code block */ + /^```/, + /* Table */ + /^\|/, + /* Unordered list */ + /^(\s*)[*+-] [^\r\n]+/, + /* Ordered list */ + /^(\s*)\d+\. [^\r\n]+/, + /* Image */ + /!\[.*?\]\(.*?\)/, + /* Link */ + /\[.*?\]\(.*?\)/, + ]; + + // Checking if any of the patterns match + return markdownPatterns.some((pattern) => pattern.test(content)); +} diff --git a/app/frontend/src/components/markdown/index.ts b/app/frontend/src/components/markdown/index.ts new file mode 100644 index 00000000..12b07fd5 --- /dev/null +++ b/app/frontend/src/components/markdown/index.ts @@ -0,0 +1,3 @@ +export * from './markdown'; + +export type * from './types'; diff --git a/app/frontend/src/components/markdown/markdown.tsx b/app/frontend/src/components/markdown/markdown.tsx new file mode 100644 index 00000000..c76382cc --- /dev/null +++ b/app/frontend/src/components/markdown/markdown.tsx @@ -0,0 +1,94 @@ +import './code-highlight-block.css'; + +import type { Options } from 'react-markdown'; + +import { useMemo } from 'react'; +import remarkGfm from 'remark-gfm'; +import rehypeRaw from 'rehype-raw'; +import rehypeHighlight from 'rehype-highlight'; +import { mergeClasses, isExternalLink } from 'minimal-shared/utils'; + +import Link from '@mui/material/Link'; + +import { RouterLink } from 'src/routes/components'; + +import { Image } from '../image'; +import { MarkdownRoot } from './styles'; +import { markdownClasses } from './classes'; +import { htmlToMarkdown, isMarkdownContent } from './html-to-markdown'; + +import type { MarkdownProps } from './types'; + +// ---------------------------------------------------------------------- + +export function Markdown({ children, sx, className, ...other }: MarkdownProps) { + const content = useMemo(() => { + if (isMarkdownContent(`${children}`)) { + return children; + } + return htmlToMarkdown(`${children}`.trim()); + }, [children]); + + return ( + value} + */ + className={mergeClasses([markdownClasses.root, className])} + sx={sx} + {...other} + /> + ); +} + +// ---------------------------------------------------------------------- + +type ComponentTag = { + [key: string]: any; +}; + +const rehypePlugins = [rehypeRaw, rehypeHighlight, [remarkGfm, { singleTilde: false }]]; + +const components = { + img: ({ ...other }: ComponentTag) => ( + + ), + a: ({ href, children, node, ...other }: ComponentTag) => { + const linkProps = isExternalLink(href) + ? { target: '_blank', rel: 'noopener' } + : { component: RouterLink }; + + return ( + + {children} + + ); + }, + pre: ({ children }: ComponentTag) => ( +
    +
    {children}
    +
    + ), + code({ className, children, node, ...other }: ComponentTag) { + const language = /language-(\w+)/.exec(className || ''); + + return language ? ( + + {children} + + ) : ( + + {children} + + ); + }, +}; diff --git a/app/frontend/src/components/markdown/styles.ts b/app/frontend/src/components/markdown/styles.ts new file mode 100644 index 00000000..7546d243 --- /dev/null +++ b/app/frontend/src/components/markdown/styles.ts @@ -0,0 +1,153 @@ +import ReactMarkdown from 'react-markdown'; +import { varAlpha } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; + +import { markdownClasses } from './classes'; + +// ---------------------------------------------------------------------- + +const MARGIN = '0.75em'; + +export const MarkdownRoot = styled(ReactMarkdown)(({ theme }) => ({ + '> * + *': { marginTop: 0, marginBottom: MARGIN }, + /** + * Heading & Paragraph + */ + h1: { ...theme.typography.h1, marginTop: 40, marginBottom: 8 }, + h2: { ...theme.typography.h2, marginTop: 40, marginBottom: 8 }, + h3: { ...theme.typography.h3, marginTop: 24, marginBottom: 8 }, + h4: { ...theme.typography.h4, marginTop: 24, marginBottom: 8 }, + h5: { ...theme.typography.h5, marginTop: 24, marginBottom: 8 }, + h6: { ...theme.typography.h6, marginTop: 24, marginBottom: 8 }, + p: { ...theme.typography.body1, marginBottom: '1.25rem' }, + /** + * Hr Divider + */ + hr: { + flexShrink: 0, + borderWidth: 0, + margin: '2em 0', + msFlexNegative: 0, + WebkitFlexShrink: 0, + borderStyle: 'solid', + borderBottomWidth: 'thin', + borderColor: theme.vars.palette.divider, + }, + /** + * Image + */ + [`& .${markdownClasses.content.image}`]: { + width: '100%', + height: 'auto', + maxWidth: '100%', + margin: 'auto auto 1.25em', + }, + /** + * List + */ + '& ul': { listStyleType: 'disc' }, + '& ul, & ol': { + paddingLeft: 16, + '& > li': { lineHeight: 2, '& > p': { margin: 0, display: 'inline-block' } }, + }, + /** + * Blockquote + */ + '& blockquote': { + lineHeight: 1.5, + fontSize: '1.5em', + margin: '24px auto', + position: 'relative', + fontFamily: 'Georgia, serif', + padding: theme.spacing(3, 3, 3, 8), + color: theme.vars.palette.text.secondary, + borderLeft: `solid 8px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.08)}`, + [theme.breakpoints.up('md')]: { width: '100%', maxWidth: 640 }, + '& p': { margin: 0, fontSize: 'inherit', fontFamily: 'inherit' }, + '&::before': { + left: 16, + top: -8, + display: 'block', + fontSize: '3em', + content: '"\\201C"', + position: 'absolute', + color: theme.vars.palette.text.disabled, + }, + }, + /** + * Code inline + */ + [`& .${markdownClasses.content.codeInline}`]: { + padding: theme.spacing(0.25, 0.5), + color: theme.vars.palette.text.secondary, + fontSize: theme.typography.body2.fontSize, + borderRadius: theme.shape.borderRadius / 2, + backgroundColor: varAlpha(theme.vars.palette.grey['500Channel'], 0.2), + }, + /** + * Code Block + */ + [`& .${markdownClasses.content.codeBlock}`]: { + position: 'relative', + '& pre': { + overflowX: 'auto', + padding: theme.spacing(3), + color: theme.vars.palette.common.white, + borderRadius: theme.shape.borderRadius, + fontFamily: "'JetBrainsMono', monospace", + backgroundColor: theme.vars.palette.grey[900], + '& code': { fontSize: theme.typography.body2.fontSize }, + ...theme.applyStyles('dark', { + backgroundColor: theme.vars.palette.grey[800], + }), + }, + }, + /** + * Table + */ + table: { + width: '100%', + borderCollapse: 'collapse', + border: `1px solid ${theme.vars.palette.divider}`, + 'th, td': { padding: theme.spacing(1), border: `1px solid ${theme.vars.palette.divider}` }, + 'tbody tr:nth-of-type(odd)': { backgroundColor: theme.vars.palette.background.neutral }, + }, + /** + * Checkbox + */ + input: { + '&[type=checkbox]': { + position: 'relative', + cursor: 'pointer', + '&:before': { + content: '""', + top: -2, + left: -2, + width: 17, + height: 17, + borderRadius: 3, + position: 'absolute', + backgroundColor: theme.vars.palette.grey[300], + ...theme.applyStyles('dark', { + backgroundColor: theme.vars.palette.grey[700], + }), + }, + '&:checked': { + '&:before': { backgroundColor: theme.vars.palette.primary.main }, + '&:after': { + top: 1, + left: 5, + width: 4, + height: 9, + content: '""', + position: 'absolute', + borderStyle: 'solid', + transform: 'rotate(45deg)', + borderWidth: '0 2px 2px 0', + borderColor: theme.vars.palette.common.white, + }, + }, + }, + }, +})); diff --git a/app/frontend/src/components/markdown/types.ts b/app/frontend/src/components/markdown/types.ts new file mode 100644 index 00000000..de5fe94c --- /dev/null +++ b/app/frontend/src/components/markdown/types.ts @@ -0,0 +1,10 @@ +import type { Options } from 'react-markdown'; +import type { Theme, SxProps } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +export type MarkdownProps = Options & + React.ComponentProps<'div'> & { + asHtml?: boolean; + sx?: SxProps; + }; diff --git a/app/frontend/src/components/mega-menu/components/index.ts b/app/frontend/src/components/mega-menu/components/index.ts new file mode 100644 index 00000000..f504069f --- /dev/null +++ b/app/frontend/src/components/mega-menu/components/index.ts @@ -0,0 +1,13 @@ +export * from './nav-item'; + +export * from './nav-drawer'; + +export * from './nav-elements'; + +export * from './nav-dropdown'; + +export * from './nav-carousel'; + +export * from './nav-sub-list'; + +export * from './nav-dropdown-content'; diff --git a/app/frontend/src/components/mega-menu/components/nav-carousel.tsx b/app/frontend/src/components/mega-menu/components/nav-carousel.tsx new file mode 100644 index 00000000..df8995ac --- /dev/null +++ b/app/frontend/src/components/mega-menu/components/nav-carousel.tsx @@ -0,0 +1,87 @@ +import { mergeClasses } from 'minimal-shared/utils'; + +import Link from '@mui/material/Link'; +import { styled } from '@mui/material/styles'; + +import { Image } from '../../image'; +import { megaMenuClasses } from '../styles'; +import { + Carousel, + useCarousel, + CarouselDotButtons, + CarouselArrowBasicButtons, +} from '../../carousel'; + +import type { NavCarouselProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function NavCarousel({ sx, slides, className, options, ...other }: NavCarouselProps) { + const carousel = useCarousel({ + ...options, + slidesToShow: options?.slidesToShow ?? 8, + slidesToScroll: options?.slidesToScroll ?? 8, + }); + + return ( + + + {slides.map((item) => ( + + {item.coverUrl} + {item.name} + + ))} + + + + + + + + ); +} + +// ---------------------------------------------------------------------- + +const CarouselRoot = styled('div')(({ theme }) => ({ + position: 'relative', + paddingTop: theme.spacing(2), +})); + +const CarouselFooter = styled('div')(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + marginTop: theme.spacing(2), + justifyContent: 'space-between', +})); + +const CarouselItemRoot = styled(Link)(({ theme }) => ({ + display: 'flex', + gap: theme.spacing(1), + flexDirection: 'column', + padding: theme.spacing(0, 1), + transition: theme.transitions.create('color'), + '&:hover': { color: theme.vars.palette.primary.main }, +})); + +const CarouselItemTitle = styled('span')(({ theme }) => ({ + ...theme.typography.caption, + ...theme.mixins.maxLine({ line: 2, persistent: theme.typography.caption }), + fontWeight: theme.typography.fontWeightSemiBold, +})); diff --git a/app/frontend/src/components/mega-menu/components/nav-drawer.tsx b/app/frontend/src/components/mega-menu/components/nav-drawer.tsx new file mode 100644 index 00000000..3d482698 --- /dev/null +++ b/app/frontend/src/components/mega-menu/components/nav-drawer.tsx @@ -0,0 +1,30 @@ +import { styled } from '@mui/material/styles'; +import IconButton from '@mui/material/IconButton'; + +import { Iconify } from '../../iconify'; + +// ---------------------------------------------------------------------- + +export type NavDrawerHeaderProps = React.ComponentProps<'div'> & { + title: string; + onBack: () => void; +}; + +export const NavDrawerHeader = styled(({ onBack, title, ...other }: NavDrawerHeaderProps) => ( +
    + + ({ ...(theme.direction === 'rtl' && { transform: 'scaleX(-1)' }) })} + /> + + {title} +
    +))(({ theme }) => ({ + ...theme.typography.subtitle1, + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), + padding: theme.spacing(1.5, 1), +})); diff --git a/app/frontend/src/components/mega-menu/components/nav-dropdown-content.tsx b/app/frontend/src/components/mega-menu/components/nav-dropdown-content.tsx new file mode 100644 index 00000000..17bbc195 --- /dev/null +++ b/app/frontend/src/components/mega-menu/components/nav-dropdown-content.tsx @@ -0,0 +1,108 @@ +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Masonry from '@mui/lab/Masonry'; +import Divider from '@mui/material/Divider'; +import Typography from '@mui/material/Typography'; + +import { RouterLink } from 'src/routes/components'; + +import { Iconify } from '../../iconify'; +import { NavSubList } from './nav-sub-list'; +import { megaMenuClasses } from '../styles'; +import { NavCarousel } from './nav-carousel'; +import { NavUl } from './nav-elements'; + +import type { NavListProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function NavDropdownContent({ + data, + slotProps, + isMultiList, +}: NavListProps & { isMultiList: boolean }) { + if (!data.children) { + return null; + } + + if (!isMultiList) { + return ( + + + + ); + } + + return ( + <> + + + + + {!!data.moreLink && ( + + {data.moreLink.title} + + )} + + {!!data.slides && ( + <> + + + + )} + + {!!data.tags && ( + <> + + + + Hot products: + + + {data.tags.map((tag, index) => ( + ({ + color: 'text.secondary', + transition: theme.transitions.create(['color']), + '&:hover': { color: 'text.primary' }, + }), + ]} + > + {index === 0 ? tag.title : `, ${tag.title} `} + + ))} + + + )} + + ); +} diff --git a/app/frontend/src/components/mega-menu/components/nav-dropdown.tsx b/app/frontend/src/components/mega-menu/components/nav-dropdown.tsx new file mode 100644 index 00000000..f1185911 --- /dev/null +++ b/app/frontend/src/components/mega-menu/components/nav-dropdown.tsx @@ -0,0 +1,69 @@ +import { mergeClasses } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; + +import { megaMenuClasses } from '../styles'; + +// ---------------------------------------------------------------------- + +export type NavDropdownProps = React.ComponentProps<'div'> & { + open?: boolean; + isMultiList?: boolean; + variant?: 'vertical' | 'horizontal'; +}; + +export const NavDropdown = styled( + (props: NavDropdownProps) => ( +
    +
    {props.children}
    +
    + ), + { + shouldForwardProp: (prop: string) => !['open', 'variant', 'isMultiList', 'sx'].includes(prop), + } +)(({ isMultiList, theme }) => ({ + opacity: 0, + visibility: 'hidden', + position: 'absolute', + pointerEvents: 'none', + transform: 'scale(0.92)', + zIndex: theme.zIndex.drawer, + transition: theme.transitions.create(['opacity', 'visibility', 'transform'], { + duration: theme.transitions.duration.shorter, + easing: theme.transitions.easing.sharp, + }), + [`& .${megaMenuClasses.dropdown.paper}`]: { + ...theme.mixins.paperStyles(theme, { dropdown: true }), + borderRadius: theme.shape.borderRadius * 2, + padding: theme.spacing(isMultiList ? 2.5 : 2), + }, + variants: [ + { + props: { open: true }, + style: { + opacity: 1, + transform: 'scale(1)', + visibility: 'visible', + pointerEvents: 'auto', + }, + }, + { + props: { variant: 'horizontal' }, + style: { + minWidth: 180, + paddingTop: theme.spacing(0.75), + ...(isMultiList && { left: 0, width: '100%' }), + }, + }, + { + props: { variant: 'vertical' }, + style: { + top: -24, + minWidth: 240, + left: 'var(--nav-width)', + paddingLeft: theme.spacing(0.75), + ...(isMultiList && { width: 'var(--nav-dropdown-width, 800px)' }), + }, + }, + ], +})); diff --git a/app/frontend/src/components/mega-menu/components/nav-elements.tsx b/app/frontend/src/components/mega-menu/components/nav-elements.tsx new file mode 100644 index 00000000..5be2d56f --- /dev/null +++ b/app/frontend/src/components/mega-menu/components/nav-elements.tsx @@ -0,0 +1,38 @@ +import { mergeClasses } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; + +import { megaMenuClasses } from '../styles'; + +// ---------------------------------------------------------------------- + +export const Nav = styled('nav')``; + +// ---------------------------------------------------------------------- + +type NavLiProps = React.ComponentProps<'li'> & { + disabled?: boolean; +}; + +export const NavLi = styled( + (props: NavLiProps) => ( +
  • + ), + { shouldForwardProp: (prop: string) => !['disabled', 'sx'].includes(prop) } +)({ + display: 'inline-block', + variants: [ + { + props: { disabled: true }, + style: { cursor: 'not-allowed' }, + }, + ], +}); + +// ---------------------------------------------------------------------- + +type NavUlProps = React.ComponentProps<'ul'>; + +export const NavUl = styled((props: NavUlProps) => ( +
      +))(() => ({ display: 'flex', flexDirection: 'column' })); diff --git a/app/frontend/src/components/mega-menu/components/nav-item.tsx b/app/frontend/src/components/mega-menu/components/nav-item.tsx new file mode 100644 index 00000000..1784dc5a --- /dev/null +++ b/app/frontend/src/components/mega-menu/components/nav-item.tsx @@ -0,0 +1,157 @@ +import { forwardRef } from 'react'; +import { mergeClasses } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; +import ButtonBase from '@mui/material/ButtonBase'; + +import { Iconify } from '../../iconify'; +import { createNavItem } from '../utils'; +import { navItemStyles, megaMenuClasses } from '../styles'; + +import type { NavItemProps } from '../types'; + +// ---------------------------------------------------------------------- + +export const NavItem = forwardRef((props, ref) => { + const { + path, + icon, + info, + title, + /********/ + open, + active, + disabled, + /********/ + render, + hasChild, + slotProps, + className, + externalLink, + enabledRootRedirect, + ...other + } = props; + + const navItem = createNavItem({ + path, + icon, + info, + render, + hasChild, + externalLink, + enabledRootRedirect, + }); + + const ownerState: StyledState = { open, active, disabled }; + + return ( + + {icon && ( + + {navItem.renderIcon} + + )} + + {title && ( + + {title} + + )} + + {info && ( + + {navItem.renderInfo} + + )} + + {hasChild && ( + + )} + + ); +}); + +// ---------------------------------------------------------------------- + +type StyledState = Pick; + +const shouldForwardProp = (prop: string) => !['active', 'open', 'disabled', 'sx'].includes(prop); + +/** + * @slot root + */ +const ItemRoot = styled(ButtonBase, { shouldForwardProp })(({ theme }) => ({ + width: '100%', + minHeight: 'var(--nav-item-height)', + padding: 'var(--nav-item-padding)', + borderRadius: 'var(--nav-item-radius)', + transition: theme.transitions.create(['background-color'], { + duration: theme.transitions.duration.standard, + }), + '&:hover': { backgroundColor: 'var(--nav-item-hover-bg)' }, + variants: [ + { + props: { active: true }, + style: { + color: 'var(--nav-item-active-color)', + backgroundColor: 'var(--nav-item-active-bg)', + '&:hover': { backgroundColor: 'var(--nav-item-active-hover-bg)' }, + }, + }, + { props: { open: true }, style: { backgroundColor: 'var(--nav-item-hover-bg)' } }, + { + props: { open: true, active: true }, + style: { backgroundColor: 'var(--nav-item-active-hover-bg)' }, + }, + { props: { disabled: true }, style: navItemStyles.disabled }, + ], +})); + +/** + * @slot icon + */ +const ItemIcon = styled('span', { shouldForwardProp })(() => ({ + ...navItemStyles.icon, + width: 'var(--nav-icon-size)', + height: 'var(--nav-icon-size)', + margin: 'var(--nav-icon-margin)', +})); + +/** @slot title */ +const ItemTitle = styled('span', { shouldForwardProp })(({ theme }) => ({ + ...navItemStyles.title(theme), + ...theme.typography.body2, + fontWeight: theme.typography.fontWeightMedium, + variants: [ + { props: { active: true }, style: { fontWeight: theme.typography.fontWeightSemiBold } }, + ], +})); + +/** + * @slot icon + */ +const ItemInfo = styled('span', { shouldForwardProp })(() => ({ ...navItemStyles.info })); + +/** + * @slot arrow + */ +const ItemArrow = styled(Iconify, { shouldForwardProp })(({ theme }) => ({ + ...navItemStyles.arrow(theme), +})); diff --git a/app/frontend/src/components/mega-menu/components/nav-sub-list.tsx b/app/frontend/src/components/mega-menu/components/nav-sub-list.tsx new file mode 100644 index 00000000..1889c51d --- /dev/null +++ b/app/frontend/src/components/mega-menu/components/nav-sub-list.tsx @@ -0,0 +1,77 @@ +import { isEqualPath } from 'minimal-shared/utils'; + +import Link from '@mui/material/Link'; +import { styled } from '@mui/material/styles'; +import Typography from '@mui/material/Typography'; + +import { usePathname } from 'src/routes/hooks'; +import { RouterLink } from 'src/routes/components'; + +import { megaMenuClasses } from '../styles'; +import { NavUl, NavLi } from './nav-elements'; + +import type { NavSubItemProps, NavSubListProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function NavSubList({ data, slotProps, ...other }: NavSubListProps) { + const pathname = usePathname(); + + return ( + <> + {data?.map((list) => ( + + {list?.subheader && ( + + {list.subheader} + + )} + + + {list.items.map((item) => ( + + + {item.title} + + + ))} + + + ))} + + ); +} + +// ---------------------------------------------------------------------- + +const NavSubItem = styled(Link, { + shouldForwardProp: (prop: string) => !['active', 'sx'].includes(prop), +})>(({ theme }) => ({ + ...theme.typography.body2, + fontSize: theme.typography.pxToRem(13), + color: theme.vars.palette.text.secondary, + transition: theme.transitions.create(['color']), + '&:hover': { color: theme.vars.palette.text.primary }, + variants: [ + { + props: { active: true }, + style: { + textDecoration: 'underline', + color: theme.vars.palette.text.primary, + fontWeight: theme.typography.fontWeightSemiBold, + }, + }, + ], +})); diff --git a/app/frontend/src/components/mega-menu/horizontal/index.ts b/app/frontend/src/components/mega-menu/horizontal/index.ts new file mode 100644 index 00000000..f0052496 --- /dev/null +++ b/app/frontend/src/components/mega-menu/horizontal/index.ts @@ -0,0 +1 @@ +export * from './mega-menu-horizontal'; diff --git a/app/frontend/src/components/mega-menu/horizontal/mega-menu-horizontal.tsx b/app/frontend/src/components/mega-menu/horizontal/mega-menu-horizontal.tsx new file mode 100644 index 00000000..1b579647 --- /dev/null +++ b/app/frontend/src/components/mega-menu/horizontal/mega-menu-horizontal.tsx @@ -0,0 +1,46 @@ +import { mergeClasses } from 'minimal-shared/utils'; + +import { useTheme } from '@mui/material/styles'; + +import { NavList } from './nav-list'; +import { Nav, NavUl } from '../components'; +import { megaMenuVars, megaMenuClasses } from '../styles'; + +import type { MegaMenuProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function MegaMenuHorizontal({ + sx, + data, + render, + slotProps, + className, + enabledRootRedirect, + cssVars: overridesVars, + ...other +}: MegaMenuProps) { + const theme = useTheme(); + + const cssVars = { ...megaMenuVars(theme, 'horizontal'), ...overridesVars }; + + return ( + + ); +} diff --git a/app/frontend/src/components/mega-menu/horizontal/nav-list.tsx b/app/frontend/src/components/mega-menu/horizontal/nav-list.tsx new file mode 100644 index 00000000..f19045a5 --- /dev/null +++ b/app/frontend/src/components/mega-menu/horizontal/nav-list.tsx @@ -0,0 +1,78 @@ +import { useRef, useCallback } from 'react'; +import { useBoolean } from 'minimal-shared/hooks'; +import { isActiveLink, isExternalLink } from 'minimal-shared/utils'; + +import { usePathname } from 'src/routes/hooks'; + +import { megaMenuClasses } from '../styles'; +import { NavLi, NavItem, NavDropdown, NavDropdownContent } from '../components'; + +import type { NavListProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function NavList({ data, render, slotProps, enabledRootRedirect }: NavListProps) { + const pathname = usePathname(); + const navItemRef = useRef(null); + + const isActive = isActiveLink(pathname, data.path, !!data.children); + const { value: open, onFalse: onClose, onTrue: onOpen } = useBoolean(); + + const isSingleList = data.children?.length === 1; + const isMultiList = !isSingleList; + + const handleOpenMenu = useCallback(() => { + if (data.children) { + onOpen(); + } + }, [data.children, onOpen]); + + const renderNavItem = () => ( + + ); + + const renderDropdown = () => + !!data.children && ( + + + + ); + + return ( + + {renderNavItem()} + {renderDropdown()} + + ); +} diff --git a/app/frontend/src/components/mega-menu/index.ts b/app/frontend/src/components/mega-menu/index.ts new file mode 100644 index 00000000..16e9edb5 --- /dev/null +++ b/app/frontend/src/components/mega-menu/index.ts @@ -0,0 +1,11 @@ +export * from './mobile'; + +export * from './vertical'; + +export * from './horizontal'; + +export * from './styles/classes'; + +export * from './styles/css-vars'; + +export type * from './types'; diff --git a/app/frontend/src/components/mega-menu/mobile/index.ts b/app/frontend/src/components/mega-menu/mobile/index.ts new file mode 100644 index 00000000..c0ad171f --- /dev/null +++ b/app/frontend/src/components/mega-menu/mobile/index.ts @@ -0,0 +1 @@ +export * from './mega-menu-mobile'; diff --git a/app/frontend/src/components/mega-menu/mobile/mega-menu-mobile.tsx b/app/frontend/src/components/mega-menu/mobile/mega-menu-mobile.tsx new file mode 100644 index 00000000..d41c8ec3 --- /dev/null +++ b/app/frontend/src/components/mega-menu/mobile/mega-menu-mobile.tsx @@ -0,0 +1,118 @@ +import { useEffect, cloneElement } from 'react'; +import { useBoolean } from 'minimal-shared/hooks'; +import { mergeClasses } from 'minimal-shared/utils'; + +import SvgIcon from '@mui/material/SvgIcon'; +import { useTheme } from '@mui/material/styles'; +import IconButton from '@mui/material/IconButton'; +import Drawer, { drawerClasses } from '@mui/material/Drawer'; + +import { usePathname } from 'src/routes/hooks'; + +import { Scrollbar } from 'src/components/scrollbar'; + +import { NavList } from './nav-list'; +import { Nav, NavUl } from '../components'; +import { megaMenuVars, megaMenuClasses } from '../styles'; + +import type { MegaMenuProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function MegaMenuMobile({ + sx, + data, + slots, + render, + className, + slotProps, + cssVars: overridesVars, + ...other +}: MegaMenuProps) { + const theme = useTheme(); + + const pathname = usePathname(); + + const drawerRootOpen = useBoolean(); + + const cssVars = { ...megaMenuVars(theme, 'mobile'), ...overridesVars }; + + useEffect(() => { + // If the pathname changes, close the drawer + if (drawerRootOpen.value) { + drawerRootOpen.onFalse(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pathname]); + + const renderButton = slots?.button ? ( + cloneElement(slots.button, { onClick: drawerRootOpen.onTrue }) + ) : ( + + + + + + + + ); + + return ( + <> + {renderButton} + + + {slots?.topArea} + + + + + + {slots?.bottomArea} + + + ); +} diff --git a/app/frontend/src/components/mega-menu/mobile/nav-list.tsx b/app/frontend/src/components/mega-menu/mobile/nav-list.tsx new file mode 100644 index 00000000..a48e96d1 --- /dev/null +++ b/app/frontend/src/components/mega-menu/mobile/nav-list.tsx @@ -0,0 +1,120 @@ +import { useBoolean } from 'minimal-shared/hooks'; +import { useRef, useEffect, useCallback } from 'react'; +import { isActiveLink, isExternalLink } from 'minimal-shared/utils'; + +import Divider from '@mui/material/Divider'; +import Drawer, { drawerClasses } from '@mui/material/Drawer'; + +import { usePathname } from 'src/routes/hooks'; + +import { Scrollbar } from 'src/components/scrollbar'; + +import { Nav, NavUl, NavLi, NavItem, NavSubList, NavDrawerHeader } from '../components'; + +import type { NavListProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function NavList({ + data, + render, + cssVars, + slotProps, + onCloseDrawerRoot, +}: NavListProps & { onCloseDrawerRoot: () => void }) { + const pathname = usePathname(); + const navItemRef = useRef(null); + + const isActive = isActiveLink(pathname, data.path, !!data.children); + const { value: open, onFalse: onClose, onTrue: onOpen } = useBoolean(); + + useEffect(() => { + // If the pathname changes, close the drawer + if (open) { + onClose(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pathname]); + + useEffect(() => { + // If the data has children and is active, open the subdrawer + if (!!data.children && isActive) { + onOpen(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleOpenSubDrawer = useCallback(() => { + if (data.children) { + onOpen(); + } + }, [data.children, onOpen]); + + const handleCloseSubDrawer = useCallback(() => { + onClose(); + onCloseDrawerRoot(); + }, [onClose, onCloseDrawerRoot]); + + const handleBack = useCallback(() => { + onClose(); + }, [onClose]); + + const renderNavItem = () => ( + + ); + + const renderDrawer = () => + !!data.children && ( + + + + + + + + + + ); + + return ( + + {renderNavItem()} + {renderDrawer()} + + ); +} diff --git a/app/frontend/src/components/mega-menu/styles/classes.ts b/app/frontend/src/components/mega-menu/styles/classes.ts new file mode 100644 index 00000000..3090e647 --- /dev/null +++ b/app/frontend/src/components/mega-menu/styles/classes.ts @@ -0,0 +1,35 @@ +import { createClasses } from 'src/theme/create-classes'; + +// ---------------------------------------------------------------------- + +export const megaMenuClasses = { + mobile: createClasses('mega__menu__mobile'), + vertical: createClasses('mega__menu__desktop__vertical'), + horizontal: createClasses('mega__menu__desktop__horizontal'), + carousel: { + root: createClasses('nav__carousel_root'), + item: createClasses('nav__carousel_item'), + }, + li: createClasses('nav__li'), + ul: createClasses('nav__ul'), + subheader: createClasses('nav__subheader'), + dropdown: { + root: createClasses('nav__dropdown__root'), + paper: createClasses('nav__dropdown__paper'), + }, + item: { + root: createClasses('nav__item__root'), + sub: createClasses('nav__item__sub'), + icon: createClasses('nav__item__icon'), + info: createClasses('nav__item__info'), + texts: createClasses('nav__item__texts'), + title: createClasses('nav__item__title'), + arrow: createClasses('nav__item__arrow'), + caption: createClasses('nav__item__caption'), + }, + state: { + open: '--open', + active: '--active', + disabled: '--disabled', + }, +}; diff --git a/app/frontend/src/components/mega-menu/styles/css-vars.ts b/app/frontend/src/components/mega-menu/styles/css-vars.ts new file mode 100644 index 00000000..450ca348 --- /dev/null +++ b/app/frontend/src/components/mega-menu/styles/css-vars.ts @@ -0,0 +1,68 @@ +import type { Theme } from '@mui/material/styles'; + +import { varAlpha } from 'minimal-shared/utils'; + +// ---------------------------------------------------------------------- + +export function megaMenuVars(theme: Theme, variant: 'vertical' | 'horizontal' | 'mobile') { + const { + shape, + spacing, + vars: { palette }, + } = theme; + + const getValue = (values: { + vertical?: string | number; + horizontal?: string | number; + mobile?: string | number; + }) => values[variant]; + + return { + '--nav-width': getValue({ + mobile: '280px', + vertical: '260px', + horizontal: 'unset', + }), + '--nav-item-gap': getValue({ + mobile: theme.spacing(0.5), + vertical: theme.spacing(0.5), + horizontal: theme.spacing(2.5), + }), + '--nav-item-radius': getValue({ + mobile: '0', + vertical: '0', + horizontal: `${shape.borderRadius}px`, + }), + '--nav-item-height': getValue({ + mobile: '40px', + vertical: '40px', + horizontal: '32px', + }), + '--nav-item-padding': getValue({ + mobile: spacing(1, 1.5, 1, 2.5), + vertical: spacing(1, 1.5, 1, 2.5), + horizontal: spacing(0.5, 1), + }), + // icon + '--nav-icon-size': '22px', + '--nav-icon-margin': getValue({ + mobile: '0 16px 0 0', + vertical: '0 16px 0 0', + horizontal: '0 8px 0 0', + }), + // hover + '--nav-item-hover-bg': palette.action.hover, + // active + '--nav-item-active-color': palette.primary.main, + '--nav-item-active-bg': getValue({ + mobile: varAlpha(palette.primary.mainChannel, 0.08), + vertical: varAlpha(palette.primary.mainChannel, 0.08), + horizontal: 'transparent', + }), + '--nav-item-active-hover-bg': getValue({ + mobile: varAlpha(palette.primary.mainChannel, 0.16), + vertical: varAlpha(palette.primary.mainChannel, 0.16), + horizontal: varAlpha(palette.primary.mainChannel, 0.08), + }), + }; +} diff --git a/app/frontend/src/components/mega-menu/styles/index.ts b/app/frontend/src/components/mega-menu/styles/index.ts new file mode 100644 index 00000000..33ec265f --- /dev/null +++ b/app/frontend/src/components/mega-menu/styles/index.ts @@ -0,0 +1,5 @@ +export * from './classes'; + +export * from './css-vars'; + +export * from './nav-item-styles'; diff --git a/app/frontend/src/components/mega-menu/styles/nav-item-styles.ts b/app/frontend/src/components/mega-menu/styles/nav-item-styles.ts new file mode 100644 index 00000000..03252f8a --- /dev/null +++ b/app/frontend/src/components/mega-menu/styles/nav-item-styles.ts @@ -0,0 +1,58 @@ +import type { Theme, CSSObject } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +type NavItemStyles = { + icon: CSSObject; + info: CSSObject; + texts: CSSObject; + disabled: CSSObject; + captionIcon: CSSObject; + title: (theme: Theme) => CSSObject; + arrow: (theme: Theme) => CSSObject; + captionText: (theme: Theme) => CSSObject; +}; + +export const navItemStyles: NavItemStyles = { + icon: { + width: 22, + height: 22, + flexShrink: 0, + display: 'inline-flex', + /** + * As ':first-child' for ssr + * https://github.com/emotion-js/emotion/issues/1105#issuecomment-1126025608 + */ + '& > :first-of-type:not(style):not(:first-of-type ~ *), & > style + *': { + width: '100%', + height: '100%', + }, + }, + texts: { flex: '1 1 auto', display: 'inline-flex', flexDirection: 'column' }, + title: (theme: Theme) => ({ + ...theme.mixins.maxLine({ line: 1 }), + flex: '1 1 auto', + }), + info: { + fontSize: 12, + flexShrink: 0, + fontWeight: 600, + marginLeft: '6px', + lineHeight: 18 / 12, + display: 'inline-flex', + }, + arrow: (theme: Theme) => ({ + width: 16, + height: 16, + flexShrink: 0, + marginLeft: '6px', + display: 'inline-flex', + ...(theme.direction === 'rtl' && { transform: 'scaleX(-1)' }), + }), + captionIcon: { width: 16, height: 16 }, + captionText: (theme: Theme) => ({ + ...theme.mixins.maxLine({ line: 1 }), + ...theme.typography.caption, + }), + disabled: { opacity: 0.48, pointerEvents: 'none' }, +}; diff --git a/app/frontend/src/components/mega-menu/types.ts b/app/frontend/src/components/mega-menu/types.ts new file mode 100644 index 00000000..bd86c9a7 --- /dev/null +++ b/app/frontend/src/components/mega-menu/types.ts @@ -0,0 +1,115 @@ +import type { LinkProps } from '@mui/material/Link'; +import type { MasonryProps } from '@mui/lab/Masonry'; +import type { ButtonBaseProps } from '@mui/material/ButtonBase'; +import type { Theme, SxProps, CSSObject } from '@mui/material/styles'; + +import type { CarouselOptions } from '../carousel'; + +// ---------------------------------------------------------------------- + +/** + * Item + */ +export type NavItemRenderProps = { + navIcon?: Record; + navInfo?: (val: string) => Record; +}; + +export type NavItemStateProps = { + open?: boolean; + active?: boolean; + disabled?: boolean; +}; + +export type NavItemSlotProps = { + sx?: SxProps; + icon?: SxProps; + texts?: SxProps; + title?: SxProps; + caption?: SxProps; + info?: SxProps; + arrow?: SxProps; +}; + +export type NavSlotProps = { + rootItem?: Pick; + subItem?: SxProps; + subheader?: SxProps; + dropdown?: SxProps; + tags?: SxProps; + moreLink?: SxProps; + masonry?: Omit, 'ref' | 'children'>; + carousel?: { + sx?: SxProps; + options?: CarouselOptions; + }; +}; + +export type NavCarouselProps = React.ComponentProps<'div'> & + NavSlotProps['carousel'] & { + slides: { + name: string; + path: string; + coverUrl: string; + }[]; + }; + +export type NavItemOptionsProps = { + hasChild?: boolean; + externalLink?: boolean; + enabledRootRedirect?: boolean; + render?: NavItemRenderProps; + slotProps?: NavItemSlotProps; +}; + +export type NavItemDataProps = Pick & { + path: string; + title: string; + icon?: string | React.ReactNode; + info?: string[] | React.ReactNode; + slides?: NavCarouselProps['slides']; + moreLink?: { path: string; title: string }; + tags?: { path: string; title: string }[]; + children?: { + subheader?: string; + items: { title: string; path: string }[]; + }[]; +}; + +export type NavItemProps = ButtonBaseProps & + NavItemDataProps & + NavItemStateProps & + NavItemOptionsProps; + +export type NavSubItemProps = LinkProps & + Pick & + Pick; + +/** + * List + */ +export type NavListProps = Pick & { + cssVars?: CSSObject; + data: NavItemDataProps; + slotProps?: NavSlotProps; + slots?: { + button?: React.ReactElement; + topArea?: React.ReactNode; + bottomArea?: React.ReactNode; + }; +}; + +export type NavSubListProps = React.ComponentProps<'li'> & { + sx?: SxProps; + slotProps?: NavSlotProps; + data: NavItemDataProps['children']; +}; + +/** + * Main + */ +export type MegaMenuProps = React.ComponentProps<'nav'> & + Omit & { + sx?: SxProps; + data: NavItemDataProps[]; + }; diff --git a/app/frontend/src/components/mega-menu/utils/create-nav-item.ts b/app/frontend/src/components/mega-menu/utils/create-nav-item.ts new file mode 100644 index 00000000..3dbe7bb7 --- /dev/null +++ b/app/frontend/src/components/mega-menu/utils/create-nav-item.ts @@ -0,0 +1,62 @@ +import { cloneElement } from 'react'; + +import { RouterLink } from 'src/routes/components'; + +import type { NavItemDataProps, NavItemOptionsProps } from '../types'; + +// ---------------------------------------------------------------------- + +type CreateNavItemReturn = { + baseProps: Record; + renderIcon: React.ReactNode; + renderInfo: React.ReactNode; +}; + +type CreateNavItemProps = Pick & NavItemOptionsProps; + +export function createNavItem({ + path, + icon, + info, + render, + hasChild, + externalLink, + enabledRootRedirect, +}: CreateNavItemProps): CreateNavItemReturn { + const linkProps = externalLink + ? { href: path, target: '_blank', rel: 'noopener' } + : { component: RouterLink, href: path }; + + const baseProps = hasChild && !enabledRootRedirect ? { component: 'div' } : linkProps; + + /** + * Render @icon + */ + let renderIcon = null; + + if (icon && render?.navIcon && typeof icon === 'string') { + renderIcon = render?.navIcon[icon]; + } else { + renderIcon = icon; + } + + /** + * Render @info + */ + let renderInfo = null; + + if (info && render?.navInfo && Array.isArray(info)) { + const [key, value] = info; + const element = render.navInfo(value)[key]; + + renderInfo = element ? cloneElement(element) : null; + } else { + renderInfo = info; + } + + return { + baseProps, + renderIcon, + renderInfo, + }; +} diff --git a/app/frontend/src/components/mega-menu/utils/index.ts b/app/frontend/src/components/mega-menu/utils/index.ts new file mode 100644 index 00000000..69366bdb --- /dev/null +++ b/app/frontend/src/components/mega-menu/utils/index.ts @@ -0,0 +1 @@ +export * from './create-nav-item'; diff --git a/app/frontend/src/components/mega-menu/vertical/index.ts b/app/frontend/src/components/mega-menu/vertical/index.ts new file mode 100644 index 00000000..6545cf1e --- /dev/null +++ b/app/frontend/src/components/mega-menu/vertical/index.ts @@ -0,0 +1 @@ +export * from './mega-menu-vertical'; diff --git a/app/frontend/src/components/mega-menu/vertical/mega-menu-vertical.tsx b/app/frontend/src/components/mega-menu/vertical/mega-menu-vertical.tsx new file mode 100644 index 00000000..08e08573 --- /dev/null +++ b/app/frontend/src/components/mega-menu/vertical/mega-menu-vertical.tsx @@ -0,0 +1,60 @@ +import { mergeClasses } from 'minimal-shared/utils'; +import { useClientRect } from 'minimal-shared/hooks'; + +import { useTheme } from '@mui/material/styles'; + +import { NavList } from './nav-list'; +import { Nav, NavUl } from '../components'; +import { megaMenuVars, megaMenuClasses } from '../styles'; + +import type { MegaMenuProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function MegaMenuVertical({ + sx, + data, + render, + slotProps, + className, + enabledRootRedirect, + cssVars: overridesVars, + ...other +}: MegaMenuProps) { + const theme = useTheme(); + + const navRect = useClientRect(); + + const cssVars = { + ...megaMenuVars(theme, 'vertical'), + ...overridesVars, + }; + + return ( + + ); +} diff --git a/app/frontend/src/components/mega-menu/vertical/nav-list.tsx b/app/frontend/src/components/mega-menu/vertical/nav-list.tsx new file mode 100644 index 00000000..79273ef0 --- /dev/null +++ b/app/frontend/src/components/mega-menu/vertical/nav-list.tsx @@ -0,0 +1,74 @@ +import { useRef, useCallback } from 'react'; +import { useBoolean } from 'minimal-shared/hooks'; +import { isActiveLink, isExternalLink } from 'minimal-shared/utils'; + +import { usePathname } from 'src/routes/hooks'; + +import { NavLi, NavItem, NavDropdown, NavDropdownContent } from '../components'; + +import type { NavListProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function NavList({ data, render, slotProps, enabledRootRedirect }: NavListProps) { + const pathname = usePathname(); + const navItemRef = useRef(null); + + const isActive = isActiveLink(pathname, data.path, !!data.children); + const { value: open, onFalse: onClose, onTrue: onOpen } = useBoolean(); + + const isSingleList = data.children?.length === 1; + const isMultiList = !isSingleList; + + const handleOpenMenu = useCallback(() => { + if (data.children) { + onOpen(); + } + }, [data.children, onOpen]); + + const renderNavItem = () => ( + + ); + + const renderDropdown = () => + !!data.children && ( + + + + ); + + return ( + + {renderNavItem()} + {renderDropdown()} + + ); +} diff --git a/app/frontend/src/components/nav-basic/components/index.ts b/app/frontend/src/components/nav-basic/components/index.ts new file mode 100644 index 00000000..28e54c38 --- /dev/null +++ b/app/frontend/src/components/nav-basic/components/index.ts @@ -0,0 +1,5 @@ +export * from './nav-dropdown'; + +export * from './nav-collapse'; + +export * from './nav-elements'; diff --git a/app/frontend/src/components/nav-basic/components/nav-collapse.tsx b/app/frontend/src/components/nav-basic/components/nav-collapse.tsx new file mode 100644 index 00000000..183c078b --- /dev/null +++ b/app/frontend/src/components/nav-basic/components/nav-collapse.tsx @@ -0,0 +1,36 @@ +import type { CSSObject } from '@mui/material/styles'; + +import { styled } from '@mui/material/styles'; +import Collapse from '@mui/material/Collapse'; + +import { navBasicClasses } from '../styles'; + +// ---------------------------------------------------------------------- + +export const NavCollapse = styled(Collapse, { + shouldForwardProp: (prop: string) => !['depth', 'sx'].includes(prop), +})<{ depth?: number }>(({ depth, theme }) => { + const verticalLineStyles: CSSObject = { + top: 0, + left: 0, + bottom: 0, + width: '1px', + content: '""', + opacity: 0.24, + position: 'absolute', + backgroundColor: theme.vars.palette.grey[500], + }; + + return { + ...(depth && { + ...(depth + 1 !== 1 && { + paddingLeft: 'calc(var(--nav-item-pl) + var(--nav-icon-size) / 2)', + [`& .${navBasicClasses.ul}`]: { + paddingLeft: '12px', + position: 'relative', + '&::before': verticalLineStyles, + }, + }), + }), + }; +}); diff --git a/app/frontend/src/components/nav-basic/components/nav-dropdown.tsx b/app/frontend/src/components/nav-basic/components/nav-dropdown.tsx new file mode 100644 index 00000000..fbee2182 --- /dev/null +++ b/app/frontend/src/components/nav-basic/components/nav-dropdown.tsx @@ -0,0 +1,25 @@ +import type { CSSObject } from '@mui/material/styles'; + +import { styled } from '@mui/material/styles'; +import Popover, { popoverClasses } from '@mui/material/Popover'; + +// ---------------------------------------------------------------------- + +export const NavDropdownPaper = styled('div')(({ theme }) => ({ + ...theme.mixins.paperStyles(theme, { dropdown: true }), + width: 'var(--nav-dropdown-width)', +})); + +// ---------------------------------------------------------------------- + +export const NavDropdown = styled(Popover)(({ open, theme }) => ({ + pointerEvents: 'none', + [`& .${popoverClasses.paper}`]: { + boxShadow: 'none', + overflow: 'unset', + backdropFilter: 'none', + background: 'transparent', + padding: theme.spacing(0, 0.75), + ...(open && { pointerEvents: 'auto' }), + } as CSSObject, +})); diff --git a/app/frontend/src/components/nav-basic/components/nav-elements.tsx b/app/frontend/src/components/nav-basic/components/nav-elements.tsx new file mode 100644 index 00000000..2a2b0b71 --- /dev/null +++ b/app/frontend/src/components/nav-basic/components/nav-elements.tsx @@ -0,0 +1,36 @@ +import { mergeClasses } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; + +import { navBasicClasses } from '../styles'; + +// ---------------------------------------------------------------------- + +export const Nav = styled('nav')``; + +// ---------------------------------------------------------------------- + +type NavLiProps = React.ComponentProps<'li'> & { disabled?: boolean }; + +export const NavLi = styled( + (props: NavLiProps) => ( +
    • + ), + { shouldForwardProp: (prop: string) => !['disabled', 'sx'].includes(prop) } +)(() => ({ + display: 'inline-block', + variants: [ + { + props: { disabled: true }, + style: { cursor: 'not-allowed' }, + }, + ], +})); + +// ---------------------------------------------------------------------- + +type NavUlProps = React.ComponentProps<'ul'>; + +export const NavUl = styled((props: NavUlProps) => ( +
        +))(() => ({ display: 'flex', flexDirection: 'column' })); diff --git a/app/frontend/src/components/nav-basic/desktop/index.ts b/app/frontend/src/components/nav-basic/desktop/index.ts new file mode 100644 index 00000000..ad390e35 --- /dev/null +++ b/app/frontend/src/components/nav-basic/desktop/index.ts @@ -0,0 +1,3 @@ +export * from './nav-basic-desktop'; + +export { NavItem as NavBasicDesktopItem } from './nav-item'; diff --git a/app/frontend/src/components/nav-basic/desktop/nav-basic-desktop.tsx b/app/frontend/src/components/nav-basic/desktop/nav-basic-desktop.tsx new file mode 100644 index 00000000..bf5bd4e3 --- /dev/null +++ b/app/frontend/src/components/nav-basic/desktop/nav-basic-desktop.tsx @@ -0,0 +1,45 @@ +import { useTheme } from '@mui/material/styles'; + +import { NavList } from './nav-list'; +import { Nav, NavUl } from '../components'; +import { navBasicVars, navBasicClasses } from '../styles'; + +import type { NavBasicProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function NavBasicDesktop({ + sx, + data, + render, + slotProps, + enabledRootRedirect, + cssVars: overridesVars, + ...other +}: NavBasicProps) { + const theme = useTheme(); + + const cssVars = { ...navBasicVars.desktop(theme), ...overridesVars }; + + return ( + + ); +} diff --git a/app/frontend/src/components/nav-basic/desktop/nav-item.tsx b/app/frontend/src/components/nav-basic/desktop/nav-item.tsx new file mode 100644 index 00000000..3eb18d80 --- /dev/null +++ b/app/frontend/src/components/nav-basic/desktop/nav-item.tsx @@ -0,0 +1,218 @@ +import type { CSSObject } from '@mui/material/styles'; + +import { forwardRef } from 'react'; +import { mergeClasses } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; +import ButtonBase from '@mui/material/ButtonBase'; + +import { Iconify } from '../../iconify'; +import { createNavItem } from '../utils'; +import { navItemStyles, navBasicClasses } from '../styles'; + +import type { NavItemProps } from '../types'; + +// ---------------------------------------------------------------------- + +export const NavItem = forwardRef((props, ref) => { + const { + path, + icon, + info, + title, + caption, + /********/ + open, + active, + disabled, + /********/ + depth, + render, + hasChild, + slotProps, + className, + externalLink, + enabledRootRedirect, + ...other + } = props; + + const navItem = createNavItem({ + path, + icon, + info, + depth, + render, + hasChild, + externalLink, + enabledRootRedirect, + }); + + const ownerState: StyledState = { + open, + active, + disabled, + variant: navItem.rootItem ? 'rootItem' : 'subItem', + }; + + return ( + + {icon && ( + + {navItem.renderIcon} + + )} + + {title && ( + + + {title} + + + {caption && navItem.subItem && ( + + {caption} + + )} + + )} + + {info && ( + + {navItem.renderInfo} + + )} + + {hasChild && ( + + )} + + ); +}); + +// ---------------------------------------------------------------------- + +type StyledState = Pick & { + variant: 'rootItem' | 'subItem'; +}; + +const shouldForwardProp = (prop: string) => + !['open', 'active', 'disabled', 'variant', 'sx'].includes(prop); + +/** + * @slot root + */ +const ItemRoot = styled(ButtonBase, { shouldForwardProp })(({ + active, + open, + theme, +}) => { + const rootItemStyles: CSSObject = { + padding: 'var(--nav-item-root-padding)', + borderRadius: 'var(--nav-item-radius)', + transition: theme.transitions.create(['all'], { duration: theme.transitions.duration.shorter }), + '&:hover': { opacity: 0.64 }, + ...(open && { opacity: 0.64 }), + ...(active && { color: 'var(--nav-item-root-active-color)' }), + }; + + const subItemStyles: CSSObject = { + width: '100%', + color: 'var(--nav-item-sub-color)', + padding: 'var(--nav-item-sub-padding)', + borderRadius: 'var(--nav-item-sub-radius)', + '&:hover': { + color: 'var(--nav-item-sub-hover-color)', + backgroundColor: 'var(--nav-item-sub-hover-bg)', + }, + ...(open && { + color: 'var(--nav-item-sub-open-color)', + backgroundColor: 'var(--nav-item-sub-open-bg)', + }), + ...(active && { + color: 'var(--nav-item-sub-active-color)', + backgroundColor: 'var(--nav-item-sub-active-bg)', + }), + }; + + return { + variants: [ + { props: { variant: 'rootItem' }, style: rootItemStyles }, + { props: { variant: 'subItem' }, style: subItemStyles }, + { props: { disabled: true }, style: navItemStyles.disabled }, + ], + }; +}); + +/** + * @slot icon + */ +const ItemIcon = styled('span', { shouldForwardProp })(() => ({ + ...navItemStyles.icon, + width: 'var(--nav-icon-size)', + height: 'var(--nav-icon-size)', + margin: 'var(--nav-icon-margin)', +})); + +/** + * @slot texts + */ +const ItemTexts = styled('span', { shouldForwardProp })(() => ({ + ...navItemStyles.texts, +})); + +/** + * @slot title + */ +const ItemTitle = styled('span', { shouldForwardProp })(({ theme }) => ({ + ...navItemStyles.title(theme), + ...theme.typography.body2, + fontWeight: theme.typography.fontWeightMedium, + variants: [ + { props: { active: true }, style: { fontWeight: theme.typography.fontWeightSemiBold } }, + ], +})); + +/** + * @slot caption text + */ +const ItemCaptionText = styled('span', { shouldForwardProp })(({ theme }) => ({ + ...navItemStyles.captionText(theme), + color: 'var(--nav-item-caption-color)', +})); + +/** + * @slot info + */ +const ItemInfo = styled('span', { shouldForwardProp })(() => ({ + ...navItemStyles.info, +})); + +/** + * @slot arrow + */ +const ItemArrow = styled(Iconify, { shouldForwardProp })(({ theme }) => ({ + ...navItemStyles.arrow(theme), + variants: [{ props: { variant: 'subItem' }, style: { marginRight: theme.spacing(-0.5) } }], +})); diff --git a/app/frontend/src/components/nav-basic/desktop/nav-list.tsx b/app/frontend/src/components/nav-basic/desktop/nav-list.tsx new file mode 100644 index 00000000..c6474f6a --- /dev/null +++ b/app/frontend/src/components/nav-basic/desktop/nav-list.tsx @@ -0,0 +1,170 @@ +import { useEffect, useCallback } from 'react'; +import { usePopoverHover } from 'minimal-shared/hooks'; +import { isActiveLink, isExternalLink } from 'minimal-shared/utils'; + +import { useTheme } from '@mui/material/styles'; +import { popoverClasses } from '@mui/material/Popover'; + +import { usePathname } from 'src/routes/hooks'; + +import { NavItem } from './nav-item'; +import { navBasicClasses } from '../styles'; +import { NavUl, NavLi, NavDropdown, NavDropdownPaper } from '../components'; + +import type { NavListProps, NavSubListProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function NavList({ + data, + depth, + render, + cssVars, + slotProps, + enabledRootRedirect, +}: NavListProps) { + const theme = useTheme(); + + const pathname = usePathname(); + + const isActive = isActiveLink(pathname, data.path, !!data.children); + + const { + open, + onOpen, + onClose, + anchorEl, + elementRef: navItemRef, + } = usePopoverHover(); + + const isRtl = theme.direction === 'rtl'; + const id = open ? `${data.title}-popover` : undefined; + + useEffect(() => { + // If the pathname changes, close the menu + if (open) { + onClose(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pathname]); + + const handleOpenMenu = useCallback(() => { + if (data.children) { + onOpen(); + } + }, [data.children, onOpen]); + + const renderNavItem = () => ( + + ); + + const renderDropdown = () => + !!data.children && ( + + + + + + ); + + return ( + + {renderNavItem()} + + {/* + * TODO: Fix the issue with the transition effect on close. + * Add `open` condition to disable transition effect on close. + * If you don't care about the effect when turned off, you can ignore it because it's safe or wait for MUI to help fix this issue. + * https://github.com/mui/material-ui/issues/43106 + */} + {open && renderDropdown()} + + ); +} + +// ---------------------------------------------------------------------- + +function NavSubList({ + data, + render, + cssVars, + depth = 0, + slotProps, + enabledRootRedirect, +}: NavSubListProps) { + return ( + + {data.map((list) => ( + + ))} + + ); +} diff --git a/app/frontend/src/components/nav-basic/index.ts b/app/frontend/src/components/nav-basic/index.ts new file mode 100644 index 00000000..5508a25c --- /dev/null +++ b/app/frontend/src/components/nav-basic/index.ts @@ -0,0 +1,9 @@ +export * from './mobile'; + +export * from './styles'; + +export * from './desktop'; + +export * from './components'; + +export type * from './types'; diff --git a/app/frontend/src/components/nav-basic/mobile/index.ts b/app/frontend/src/components/nav-basic/mobile/index.ts new file mode 100644 index 00000000..c25aff0e --- /dev/null +++ b/app/frontend/src/components/nav-basic/mobile/index.ts @@ -0,0 +1,3 @@ +export * from './nav-basic-mobile'; + +export { NavItem as NavBasicMobileItem } from './nav-item'; diff --git a/app/frontend/src/components/nav-basic/mobile/nav-basic-mobile.tsx b/app/frontend/src/components/nav-basic/mobile/nav-basic-mobile.tsx new file mode 100644 index 00000000..e1621812 --- /dev/null +++ b/app/frontend/src/components/nav-basic/mobile/nav-basic-mobile.tsx @@ -0,0 +1,44 @@ +import { useTheme } from '@mui/material/styles'; + +import { NavList } from './nav-list'; +import { Nav, NavUl } from '../components'; +import { navBasicVars, navBasicClasses } from '../styles'; + +import type { NavBasicProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function NavBasicMobile({ + sx, + data, + render, + slotProps, + enabledRootRedirect, + cssVars: overridesVars, + ...other +}: NavBasicProps) { + const theme = useTheme(); + + const cssVars = { ...navBasicVars.mobile(theme), ...overridesVars }; + + return ( + + ); +} diff --git a/app/frontend/src/components/nav-basic/mobile/nav-item.tsx b/app/frontend/src/components/nav-basic/mobile/nav-item.tsx new file mode 100644 index 00000000..74ba61d3 --- /dev/null +++ b/app/frontend/src/components/nav-basic/mobile/nav-item.tsx @@ -0,0 +1,242 @@ +import type { CSSObject } from '@mui/material/styles'; + +import { forwardRef } from 'react'; +import { mergeClasses } from 'minimal-shared/utils'; + +import Tooltip from '@mui/material/Tooltip'; +import { styled } from '@mui/material/styles'; +import ButtonBase from '@mui/material/ButtonBase'; + +import { Iconify } from '../../iconify'; +import { createNavItem } from '../utils'; +import { navItemStyles, navBasicClasses } from '../styles'; + +import type { NavItemProps } from '../types'; + +// ---------------------------------------------------------------------- + +export const NavItem = forwardRef((props, ref) => { + const { + path, + icon, + info, + title, + caption, + /********/ + open, + active, + disabled, + /********/ + depth, + render, + hasChild, + slotProps, + className, + externalLink, + enabledRootRedirect, + ...other + } = props; + + const navItem = createNavItem({ + path, + icon, + info, + depth, + render, + hasChild, + externalLink, + enabledRootRedirect, + }); + + const ownerState: StyledState = { + open, + active, + disabled, + variant: navItem.rootItem ? 'rootItem' : 'subItem', + }; + + return ( + + {icon && ( + + {navItem.renderIcon} + + )} + + {title && ( + + + {title} + + + {caption && ( + + + {caption} + + + )} + + )} + + {info && ( + + {navItem.renderInfo} + + )} + + {hasChild && ( + + )} + + ); +}); + +// ---------------------------------------------------------------------- + +type StyledState = Pick & { + variant: 'rootItem' | 'subItem'; +}; + +const shouldForwardProp = (prop: string) => + !['open', 'active', 'disabled', 'variant', 'sx'].includes(prop); + +/** + * @slot root + */ +const ItemRoot = styled(ButtonBase, { shouldForwardProp })(({ + open, + theme, + active, +}) => { + const dotStyles: CSSObject = { + width: 3, + left: -13, + height: 16, + content: '""', + borderRadius: 3, + position: 'absolute', + backgroundColor: 'currentColor', + transform: active ? 'scale(1)' : 'scale(0)', + transition: theme.transitions.create(['transform'], { + duration: theme.transitions.duration.short, + }), + }; + + const rootItemStyles: CSSObject = { + minHeight: 'var(--nav-item-root-height)', + ...(open && { + color: 'var(--nav-item-root-open-color)', + backgroundColor: 'var(--nav-item-root-open-bg)', + }), + ...(active && { + color: 'var(--nav-item-root-active-color)', + backgroundColor: 'var(--nav-item-root-active-bg)', + '&:hover': { backgroundColor: 'var(--nav-item-root-active-hover-bg)' }, + ...theme.applyStyles('dark', { + color: 'var(--nav-item-root-active-color-on-dark)', + }), + }), + }; + + const subItemStyles: CSSObject = { + minHeight: 'var(--nav-item-sub-height)', + '&::before': dotStyles, + ...(open && { + color: 'var(--nav-item-sub-open-color)', + backgroundColor: 'var(--nav-item-sub-open-bg)', + }), + ...(active && { + color: 'var(--nav-item-sub-active-color)', + backgroundColor: 'var(--nav-item-sub-active-bg)', + }), + }; + + return { + width: '100%', + color: 'var(--nav-item-color)', + borderRadius: 'var(--nav-item-radius)', + paddingTop: 'var(--nav-item-pt)', + paddingLeft: 'var(--nav-item-pl)', + paddingRight: 'var(--nav-item-pr)', + paddingBottom: 'var(--nav-item-pb)', + '&:hover': { backgroundColor: 'var(--nav-item-hover-color)' }, + variants: [ + { props: { variant: 'rootItem' }, style: rootItemStyles }, + { props: { variant: 'subItem' }, style: subItemStyles }, + { props: { disabled: true }, style: navItemStyles.disabled }, + ], + }; +}); + +/** + * @slot icon + */ +const ItemIcon = styled('span', { shouldForwardProp })(() => ({ + ...navItemStyles.icon, + width: 'var(--nav-icon-size)', + height: 'var(--nav-icon-size)', + margin: 'var(--nav-icon-margin)', +})); + +/** + * @slot texts + */ +const ItemTexts = styled('span', { shouldForwardProp })(() => ({ + ...navItemStyles.texts, +})); + +/** + * @slot title + */ +const ItemTitle = styled('span', { shouldForwardProp })(({ theme }) => ({ + ...navItemStyles.title(theme), + ...theme.typography.body2, + fontWeight: theme.typography.fontWeightMedium, + variants: [ + { props: { active: true }, style: { fontWeight: theme.typography.fontWeightSemiBold } }, + ], +})); + +/** + * @slot caption text + */ +const ItemCaptionText = styled('span', { shouldForwardProp })(({ theme }) => ({ + ...navItemStyles.captionText(theme), + color: 'var(--nav-item-caption-color)', +})); + +/** + * @slot info + */ +const ItemInfo = styled('span', { shouldForwardProp })(() => ({ + ...navItemStyles.info, +})); + +/** + * @slot arrow + */ +const ItemArrow = styled(Iconify, { shouldForwardProp })(({ theme }) => ({ + ...navItemStyles.arrow(theme), +})); diff --git a/app/frontend/src/components/nav-basic/mobile/nav-list.tsx b/app/frontend/src/components/nav-basic/mobile/nav-list.tsx new file mode 100644 index 00000000..6feae513 --- /dev/null +++ b/app/frontend/src/components/nav-basic/mobile/nav-list.tsx @@ -0,0 +1,106 @@ +import { useBoolean } from 'minimal-shared/hooks'; +import { useRef, useEffect, useCallback } from 'react'; +import { isActiveLink, isExternalLink } from 'minimal-shared/utils'; + +import { usePathname } from 'src/routes/hooks'; + +import { NavItem } from './nav-item'; +import { navBasicClasses } from '../styles'; +import { NavLi, NavUl, NavCollapse } from '../components'; + +import type { NavListProps, NavSubListProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function NavList({ data, depth, render, slotProps, enabledRootRedirect }: NavListProps) { + const pathname = usePathname(); + const navItemRef = useRef(null); + + const isActive = isActiveLink(pathname, data.path, !!data.children); + const { value: open, onFalse: onClose, onToggle } = useBoolean(isActive); + + useEffect(() => { + if (!isActive) { + onClose(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pathname]); + + const handleToggleMenu = useCallback(() => { + if (data.children) { + onToggle(); + } + }, [data.children, onToggle]); + + const renderNavItem = () => ( + + ); + + const renderCollapse = () => + !!data.children && ( + + + + ); + + return ( + + {renderNavItem()} + {renderCollapse()} + + ); +} + +// ---------------------------------------------------------------------- + +function NavSubList({ data, render, depth = 0, slotProps, enabledRootRedirect }: NavSubListProps) { + return ( + + {data.map((list) => ( + + ))} + + ); +} diff --git a/app/frontend/src/components/nav-basic/styles/classes.ts b/app/frontend/src/components/nav-basic/styles/classes.ts new file mode 100644 index 00000000..432c1c70 --- /dev/null +++ b/app/frontend/src/components/nav-basic/styles/classes.ts @@ -0,0 +1,30 @@ +import { createClasses } from 'src/theme/create-classes'; + +// ---------------------------------------------------------------------- + +export const navBasicClasses = { + mobile: createClasses('nav__basic__mobile'), + desktop: createClasses('nav__basic__desktop'), + li: createClasses('nav__li'), + ul: createClasses('nav__ul'), + subheader: createClasses('nav__subheader'), + dropdown: { + root: createClasses('nav__dropdown__root'), + paper: createClasses('nav__dropdown__paper'), + }, + item: { + root: createClasses('nav__item__root'), + sub: createClasses('nav__item__sub'), + icon: createClasses('nav__item__icon'), + info: createClasses('nav__item__info'), + texts: createClasses('nav__item__texts'), + title: createClasses('nav__item__title'), + arrow: createClasses('nav__item__arrow'), + caption: createClasses('nav__item__caption'), + }, + state: { + open: '--open', + active: '--active', + disabled: '--disabled', + }, +}; diff --git a/app/frontend/src/components/nav-basic/styles/css-vars.ts b/app/frontend/src/components/nav-basic/styles/css-vars.ts new file mode 100644 index 00000000..c7eeba2b --- /dev/null +++ b/app/frontend/src/components/nav-basic/styles/css-vars.ts @@ -0,0 +1,78 @@ +import type { Theme } from '@mui/material/styles'; + +import { varAlpha } from 'minimal-shared/utils'; + +// ---------------------------------------------------------------------- + +function desktopVars(theme: Theme) { + const { + shape, + vars: { palette }, + } = theme; + + return { + '--nav-dropdown-width': '200px', + // + '--nav-item-gap': '24px', + '--nav-item-radius': '0', + '--nav-item-caption-color': palette.text.disabled, + // root + '--nav-item-root-padding': '0', + '--nav-item-root-active-color': palette.primary.main, + // sub + '--nav-item-sub-radius': `${shape.borderRadius * 0.75}px`, + '--nav-item-sub-padding': '6px 8px 6px 8px', + '--nav-item-sub-color': palette.text.secondary, + '--nav-item-sub-hover-color': palette.text.primary, + '--nav-item-sub-hover-bg': palette.action.hover, + '--nav-item-sub-active-color': palette.text.primary, + '--nav-item-sub-active-bg': palette.action.selected, + '--nav-item-sub-open-color': palette.text.primary, + '--nav-item-sub-open-bg': palette.action.hover, + // icon + '--nav-icon-size': '22px', + '--nav-icon-margin': '0 8px 0 0', + }; +} + +// ---------------------------------------------------------------------- + +function mobileVars(theme: Theme) { + const { + shape, + vars: { palette }, + } = theme; + + return { + '--nav-item-gap': '4px', + '--nav-item-radius': `${shape.borderRadius}px`, + '--nav-item-pt': '4px', + '--nav-item-pr': '8px', + '--nav-item-pb': '4px', + '--nav-item-pl': '12px', + '--nav-item-color': palette.text.secondary, + '--nav-item-hover-color': palette.action.hover, + '--nav-item-caption-color': palette.text.disabled, + // root + '--nav-item-root-height': '44px', + '--nav-item-root-active-color': palette.primary.main, + '--nav-item-root-active-color-on-dark': palette.primary.light, + '--nav-item-root-active-bg': varAlpha(palette.primary.mainChannel, 0.08), + '--nav-item-root-active-hover-bg': varAlpha(palette.primary.mainChannel, 0.16), + '--nav-item-root-open-color': palette.text.primary, + '--nav-item-root-open-bg': palette.action.hover, + // sub + '--nav-item-sub-height': '36px', + '--nav-item-sub-active-color': palette.text.primary, + '--nav-item-sub-active-bg': palette.action.hover, + '--nav-item-sub-open-color': palette.text.primary, + '--nav-item-sub-open-bg': palette.action.hover, + // icon + '--nav-icon-size': '24px', + '--nav-icon-margin': '0 12px 0 0', + }; +} + +// ---------------------------------------------------------------------- + +export const navBasicVars = { desktop: desktopVars, mobile: mobileVars }; diff --git a/app/frontend/src/components/nav-basic/styles/index.ts b/app/frontend/src/components/nav-basic/styles/index.ts new file mode 100644 index 00000000..33ec265f --- /dev/null +++ b/app/frontend/src/components/nav-basic/styles/index.ts @@ -0,0 +1,5 @@ +export * from './classes'; + +export * from './css-vars'; + +export * from './nav-item-styles'; diff --git a/app/frontend/src/components/nav-basic/styles/nav-item-styles.ts b/app/frontend/src/components/nav-basic/styles/nav-item-styles.ts new file mode 100644 index 00000000..03252f8a --- /dev/null +++ b/app/frontend/src/components/nav-basic/styles/nav-item-styles.ts @@ -0,0 +1,58 @@ +import type { Theme, CSSObject } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +type NavItemStyles = { + icon: CSSObject; + info: CSSObject; + texts: CSSObject; + disabled: CSSObject; + captionIcon: CSSObject; + title: (theme: Theme) => CSSObject; + arrow: (theme: Theme) => CSSObject; + captionText: (theme: Theme) => CSSObject; +}; + +export const navItemStyles: NavItemStyles = { + icon: { + width: 22, + height: 22, + flexShrink: 0, + display: 'inline-flex', + /** + * As ':first-child' for ssr + * https://github.com/emotion-js/emotion/issues/1105#issuecomment-1126025608 + */ + '& > :first-of-type:not(style):not(:first-of-type ~ *), & > style + *': { + width: '100%', + height: '100%', + }, + }, + texts: { flex: '1 1 auto', display: 'inline-flex', flexDirection: 'column' }, + title: (theme: Theme) => ({ + ...theme.mixins.maxLine({ line: 1 }), + flex: '1 1 auto', + }), + info: { + fontSize: 12, + flexShrink: 0, + fontWeight: 600, + marginLeft: '6px', + lineHeight: 18 / 12, + display: 'inline-flex', + }, + arrow: (theme: Theme) => ({ + width: 16, + height: 16, + flexShrink: 0, + marginLeft: '6px', + display: 'inline-flex', + ...(theme.direction === 'rtl' && { transform: 'scaleX(-1)' }), + }), + captionIcon: { width: 16, height: 16 }, + captionText: (theme: Theme) => ({ + ...theme.mixins.maxLine({ line: 1 }), + ...theme.typography.caption, + }), + disabled: { opacity: 0.48, pointerEvents: 'none' }, +}; diff --git a/app/frontend/src/components/nav-basic/types.ts b/app/frontend/src/components/nav-basic/types.ts new file mode 100644 index 00000000..25307124 --- /dev/null +++ b/app/frontend/src/components/nav-basic/types.ts @@ -0,0 +1,81 @@ +import type { ButtonBaseProps } from '@mui/material/ButtonBase'; +import type { Theme, SxProps, CSSObject } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +/** + * Item + */ +export type NavItemRenderProps = { + navIcon?: Record; + navInfo?: (val: string) => Record; +}; + +export type NavItemStateProps = { + open?: boolean; + active?: boolean; + disabled?: boolean; +}; + +export type NavItemSlotProps = { + sx?: SxProps; + icon?: SxProps; + texts?: SxProps; + title?: SxProps; + caption?: SxProps; + info?: SxProps; + arrow?: SxProps; +}; + +export type NavSlotProps = { + rootItem?: NavItemSlotProps; + subItem?: NavItemSlotProps; + dropdown?: { + paper?: SxProps; + }; +}; + +export type NavItemOptionsProps = { + depth?: number; + hasChild?: boolean; + externalLink?: boolean; + enabledRootRedirect?: boolean; + render?: NavItemRenderProps; + slotProps?: NavItemSlotProps; +}; + +export type NavItemDataProps = Pick & { + path: string; + title: string; + caption?: string; + children?: NavItemDataProps[]; + icon?: string | React.ReactNode; + info?: string[] | React.ReactNode; +}; + +export type NavItemProps = ButtonBaseProps & + NavItemDataProps & + NavItemStateProps & + NavItemOptionsProps; + +/** + * List + */ +export type NavListProps = Pick & { + cssVars?: CSSObject; + data: NavItemDataProps; + slotProps?: NavSlotProps; +}; + +export type NavSubListProps = Omit & { + data: NavItemDataProps[]; +}; + +/** + * Main + */ +export type NavBasicProps = React.ComponentProps<'nav'> & + Omit & { + sx?: SxProps; + data: NavItemDataProps[]; + }; diff --git a/app/frontend/src/components/nav-basic/utils/create-nav-item.ts b/app/frontend/src/components/nav-basic/utils/create-nav-item.ts new file mode 100644 index 00000000..151dc73a --- /dev/null +++ b/app/frontend/src/components/nav-basic/utils/create-nav-item.ts @@ -0,0 +1,74 @@ +import { cloneElement } from 'react'; + +import { RouterLink } from 'src/routes/components'; + +import type { NavItemDataProps, NavItemOptionsProps } from '../types'; + +// ---------------------------------------------------------------------- + +type CreateNavItemReturn = { + subItem: boolean; + rootItem: boolean; + subDeepItem: boolean; + baseProps: Record; + renderIcon: React.ReactNode; + renderInfo: React.ReactNode; +}; + +type CreateNavItemProps = Pick & + Omit; + +export function createNavItem({ + path, + icon, + info, + depth, + render, + hasChild, + externalLink, + enabledRootRedirect, +}: CreateNavItemProps): CreateNavItemReturn { + const rootItem = depth === 1; + const subItem = !rootItem; + const subDeepItem = Number(depth) > 2; + + const linkProps = externalLink + ? { href: path, target: '_blank', rel: 'noopener' } + : { component: RouterLink, href: path }; + + const baseProps = hasChild && !enabledRootRedirect ? { component: 'div' } : linkProps; + + /** + * Render @icon + */ + let renderIcon = null; + + if (icon && render?.navIcon && typeof icon === 'string') { + renderIcon = render?.navIcon[icon]; + } else { + renderIcon = icon; + } + + /** + * Render @info + */ + let renderInfo = null; + + if (info && render?.navInfo && Array.isArray(info)) { + const [key, value] = info; + const element = render.navInfo(value)[key]; + + renderInfo = element ? cloneElement(element) : null; + } else { + renderInfo = info; + } + + return { + subItem, + rootItem, + subDeepItem, + baseProps, + renderIcon, + renderInfo, + }; +} diff --git a/app/frontend/src/components/nav-basic/utils/index.ts b/app/frontend/src/components/nav-basic/utils/index.ts new file mode 100644 index 00000000..69366bdb --- /dev/null +++ b/app/frontend/src/components/nav-basic/utils/index.ts @@ -0,0 +1 @@ +export * from './create-nav-item'; diff --git a/app/frontend/src/components/nav-section/components/index.ts b/app/frontend/src/components/nav-section/components/index.ts new file mode 100644 index 00000000..ee3508e2 --- /dev/null +++ b/app/frontend/src/components/nav-section/components/index.ts @@ -0,0 +1,7 @@ +export * from './nav-collapse'; + +export * from './nav-dropdown'; + +export * from './nav-elements'; + +export * from './nav-subheader'; diff --git a/app/frontend/src/components/nav-section/components/nav-collapse.tsx b/app/frontend/src/components/nav-section/components/nav-collapse.tsx new file mode 100644 index 00000000..767b85db --- /dev/null +++ b/app/frontend/src/components/nav-section/components/nav-collapse.tsx @@ -0,0 +1,38 @@ +import type { CSSObject } from '@mui/material/styles'; + +import { styled } from '@mui/material/styles'; +import Collapse from '@mui/material/Collapse'; + +import { navSectionClasses } from '../styles'; + +// ---------------------------------------------------------------------- + +export const NavCollapse = styled(Collapse, { + shouldForwardProp: (prop: string) => !['depth', 'sx'].includes(prop), +})<{ depth?: number }>(({ depth, theme }) => { + const verticalLineStyles: CSSObject = { + top: 0, + left: 0, + width: '2px', + content: '""', + position: 'absolute', + backgroundColor: 'var(--nav-bullet-light-color)', + bottom: 'calc(var(--nav-item-sub-height) - 2px - var(--nav-bullet-size) / 2)', + ...theme.applyStyles('dark', { + backgroundColor: 'var(--nav-bullet-dark-color)', + }), + }; + + return { + ...(depth && { + ...(depth + 1 !== 1 && { + paddingLeft: 'calc(var(--nav-item-pl) + var(--nav-icon-size) / 2)', + [`& .${navSectionClasses.ul}`]: { + position: 'relative', + paddingLeft: 'var(--nav-bullet-size)', + '&::before': verticalLineStyles, + }, + }), + }), + }; +}); diff --git a/app/frontend/src/components/nav-section/components/nav-dropdown.tsx b/app/frontend/src/components/nav-section/components/nav-dropdown.tsx new file mode 100644 index 00000000..f38c145f --- /dev/null +++ b/app/frontend/src/components/nav-section/components/nav-dropdown.tsx @@ -0,0 +1,25 @@ +import type { CSSObject } from '@mui/material/styles'; + +import { styled } from '@mui/material/styles'; +import Popover, { popoverClasses } from '@mui/material/Popover'; + +// ---------------------------------------------------------------------- + +export const NavDropdownPaper = styled('div')(({ theme }) => ({ + minWidth: 180, + ...theme.mixins.paperStyles(theme, { dropdown: true }), +})); + +// ---------------------------------------------------------------------- + +export const NavDropdown = styled(Popover)(({ open, theme }) => ({ + pointerEvents: 'none', + [`& .${popoverClasses.paper}`]: { + boxShadow: 'none', + overflow: 'unset', + backdropFilter: 'none', + background: 'transparent', + padding: theme.spacing(0, 0.75), + ...(open && { pointerEvents: 'auto' }), + } as CSSObject, +})); diff --git a/app/frontend/src/components/nav-section/components/nav-elements.tsx b/app/frontend/src/components/nav-section/components/nav-elements.tsx new file mode 100644 index 00000000..bebf4075 --- /dev/null +++ b/app/frontend/src/components/nav-section/components/nav-elements.tsx @@ -0,0 +1,33 @@ +import { mergeClasses } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; + +import { navSectionClasses } from '../styles'; + +// ---------------------------------------------------------------------- + +export const Nav = styled('nav')``; + +// ---------------------------------------------------------------------- + +type NavLiProps = React.ComponentProps<'li'> & { + disabled?: boolean; +}; + +export const NavLi = styled( + (props: NavLiProps) => ( +
      • + ), + { shouldForwardProp: (prop: string) => !['disabled', 'sx'].includes(prop) } +)(() => ({ + display: 'inline-block', + variants: [{ props: { disabled: true }, style: { cursor: 'not-allowed' } }], +})); + +// ---------------------------------------------------------------------- + +type NavUlProps = React.ComponentProps<'ul'>; + +export const NavUl = styled((props: NavUlProps) => ( +
          +))(() => ({ display: 'flex', flexDirection: 'column' })); diff --git a/app/frontend/src/components/nav-section/components/nav-subheader.tsx b/app/frontend/src/components/nav-section/components/nav-subheader.tsx new file mode 100644 index 00000000..b28c0787 --- /dev/null +++ b/app/frontend/src/components/nav-section/components/nav-subheader.tsx @@ -0,0 +1,55 @@ +import type { ListSubheaderProps } from '@mui/material/ListSubheader'; + +import { mergeClasses } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; +import ListSubheader from '@mui/material/ListSubheader'; + +import { navSectionClasses } from '../styles'; +import { Iconify, iconifyClasses } from '../../iconify'; + +// ---------------------------------------------------------------------- + +export type NavSubheaderProps = ListSubheaderProps & { open?: boolean }; + +export const NavSubheader = styled(({ open, children, className, ...other }: NavSubheaderProps) => ( + + + {children} + +))(({ theme }) => ({ + ...theme.typography.overline, + cursor: 'pointer', + alignItems: 'center', + position: 'relative', + gap: theme.spacing(1), + display: 'inline-flex', + alignSelf: 'flex-start', + color: 'var(--nav-subheader-color)', + padding: theme.spacing(2, 1, 1, 1.5), + fontSize: theme.typography.pxToRem(11), + transition: theme.transitions.create(['color', 'padding-left'], { + duration: theme.transitions.duration.standard, + }), + [`& .${iconifyClasses.root}`]: { + left: -4, + opacity: 0, + position: 'absolute', + transition: theme.transitions.create(['opacity'], { + duration: theme.transitions.duration.standard, + }), + }, + '&:hover': { + paddingLeft: theme.spacing(2), + color: 'var(--nav-subheader-hover-color)', + [`& .${iconifyClasses.root}`]: { opacity: 1 }, + }, +})); diff --git a/app/frontend/src/components/nav-section/horizontal/index.ts b/app/frontend/src/components/nav-section/horizontal/index.ts new file mode 100644 index 00000000..d1fbe2db --- /dev/null +++ b/app/frontend/src/components/nav-section/horizontal/index.ts @@ -0,0 +1,3 @@ +export * from './nav-section-horizontal'; + +export { NavItem as NavSectionHorizontalItem } from './nav-item'; diff --git a/app/frontend/src/components/nav-section/horizontal/nav-item.tsx b/app/frontend/src/components/nav-section/horizontal/nav-item.tsx new file mode 100644 index 00000000..111a6633 --- /dev/null +++ b/app/frontend/src/components/nav-section/horizontal/nav-item.tsx @@ -0,0 +1,220 @@ +import type { CSSObject } from '@mui/material/styles'; + +import { forwardRef } from 'react'; +import { mergeClasses } from 'minimal-shared/utils'; + +import Tooltip from '@mui/material/Tooltip'; +import { styled } from '@mui/material/styles'; +import ButtonBase from '@mui/material/ButtonBase'; + +import { Iconify } from '../../iconify'; +import { createNavItem } from '../utils'; +import { navItemStyles, navSectionClasses } from '../styles'; + +import type { NavItemProps } from '../types'; + +// ---------------------------------------------------------------------- + +export const NavItem = forwardRef((props, ref) => { + const { + path, + icon, + info, + title, + caption, + /********/ + open, + active, + disabled, + /********/ + depth, + render, + hasChild, + slotProps, + className, + externalLink, + enabledRootRedirect, + ...other + } = props; + + const navItem = createNavItem({ + path, + icon, + info, + depth, + render, + hasChild, + externalLink, + enabledRootRedirect, + }); + + const ownerState: StyledState = { + open, + active, + disabled, + variant: navItem.rootItem ? 'rootItem' : 'subItem', + }; + + return ( + + {icon && ( + + {navItem.renderIcon} + + )} + + {title && ( + + {title} + + )} + + {caption && ( + + + + )} + + {info && ( + + {navItem.renderInfo} + + )} + + {hasChild && ( + + )} + + ); +}); + +// ---------------------------------------------------------------------- + +type StyledState = Pick & { + variant: 'rootItem' | 'subItem'; +}; + +const shouldForwardProp = (prop: string) => + !['open', 'active', 'disabled', 'variant', 'sx'].includes(prop); + +/** + * @slot root + */ +const ItemRoot = styled(ButtonBase, { shouldForwardProp })(({ + active, + open, + theme, +}) => { + const rootItemStyles: CSSObject = { + padding: 'var(--nav-item-root-padding)', + minHeight: 'var(--nav-item-root-height)', + ...(open && { + color: 'var(--nav-item-root-open-color)', + backgroundColor: 'var(--nav-item-root-open-bg)', + }), + ...(active && { + color: 'var(--nav-item-root-active-color)', + backgroundColor: 'var(--nav-item-root-active-bg)', + '&:hover': { backgroundColor: 'var(--nav-item-root-active-hover-bg)' }, + ...theme.applyStyles('dark', { + color: 'var(--nav-item-root-active-color-on-dark)', + }), + }), + }; + + const subItemStyles: CSSObject = { + padding: 'var(--nav-item-sub-padding)', + minHeight: 'var(--nav-item-sub-height)', + color: theme.vars.palette.text.secondary, + ...(open && { + color: 'var(--nav-item-sub-open-color)', + backgroundColor: 'var(--nav-item-sub-open-bg)', + }), + ...(active && { + color: 'var(--nav-item-sub-active-color)', + backgroundColor: 'var(--nav-item-sub-active-bg)', + }), + }; + + return { + width: '100%', + flexShrink: 0, + color: 'var(--nav-item-color)', + borderRadius: 'var(--nav-item-radius)', + '&:hover': { backgroundColor: 'var(--nav-item-hover-bg)' }, + variants: [ + { props: { variant: 'rootItem' }, style: rootItemStyles }, + { props: { variant: 'subItem' }, style: subItemStyles }, + { props: { disabled: true }, style: navItemStyles.disabled }, + ], + }; +}); + +/** + * @slot icon + */ +const ItemIcon = styled('span', { shouldForwardProp })(() => ({ + ...navItemStyles.icon, + width: 'var(--nav-icon-size)', + height: 'var(--nav-icon-size)', + margin: 'var(--nav-icon-root-margin)', + variants: [{ props: { variant: 'subItem' }, style: { margin: 'var(--nav-icon-sub-margin)' } }], +})); + +/** + * @slot title + */ +const ItemTitle = styled('span', { shouldForwardProp })(({ theme }) => ({ + ...navItemStyles.title(theme), + ...theme.typography.body2, + fontWeight: theme.typography.fontWeightMedium, + variants: [ + { props: { active: true }, style: { fontWeight: theme.typography.fontWeightSemiBold } }, + ], +})); + +/** + * @slot caption icon + */ +const ItemCaptionIcon = styled(Iconify, { shouldForwardProp })(({ theme }) => ({ + ...navItemStyles.captionIcon, + color: 'var(--nav-item-caption-color)', + variants: [{ props: { variant: 'rootItem' }, style: { marginLeft: theme.spacing(0.75) } }], +})); + +/** + * @slot info + */ +const ItemInfo = styled('span', { shouldForwardProp })(({ theme }) => ({ + ...navItemStyles.info, +})); + +/** + * @slot arrow + */ +const ItemArrow = styled(Iconify, { shouldForwardProp })(({ theme }) => ({ + ...navItemStyles.arrow(theme), + variants: [{ props: { variant: 'subItem' }, style: { marginRight: theme.spacing(-0.5) } }], +})); diff --git a/app/frontend/src/components/nav-section/horizontal/nav-list.tsx b/app/frontend/src/components/nav-section/horizontal/nav-list.tsx new file mode 100644 index 00000000..6fd72b39 --- /dev/null +++ b/app/frontend/src/components/nav-section/horizontal/nav-list.tsx @@ -0,0 +1,177 @@ +import { useEffect, useCallback } from 'react'; +import { usePopoverHover } from 'minimal-shared/hooks'; +import { isActiveLink, isExternalLink } from 'minimal-shared/utils'; + +import { useTheme } from '@mui/material/styles'; +import { popoverClasses } from '@mui/material/Popover'; + +import { usePathname } from 'src/routes/hooks'; + +import { NavItem } from './nav-item'; +import { navSectionClasses } from '../styles'; +import { NavUl, NavLi, NavDropdown, NavDropdownPaper } from '../components'; + +import type { NavListProps, NavSubListProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function NavList({ + data, + depth, + render, + cssVars, + slotProps, + currentRole, + enabledRootRedirect, +}: NavListProps) { + const theme = useTheme(); + + const pathname = usePathname(); + + const isActive = isActiveLink(pathname, data.path, !!data.children); + + const { + open, + onOpen, + onClose, + anchorEl, + elementRef: navItemRef, + } = usePopoverHover(); + + const isRtl = theme.direction === 'rtl'; + const id = open ? `${data.title}-popover` : undefined; + + useEffect(() => { + // If the pathname changes, close the menu + if (open) { + onClose(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pathname]); + + const handleOpenMenu = useCallback(() => { + if (data.children) { + onOpen(); + } + }, [data.children, onOpen]); + + const renderNavItem = () => ( + + ); + + const renderDropdown = () => + !!data.children && ( + + + + + + ); + + // Hidden item by role + if (data.roles && currentRole && !data.roles.includes(currentRole)) { + return null; + } + + return ( + + {renderNavItem()} + {/* + * TODO: Should be removed in MUI next. + * Add `open` condition to disable transition effect on close. + * https://github.com/mui/material-ui/issues/43106 + */} + {open && renderDropdown()} + + ); +} + +// ---------------------------------------------------------------------- + +function NavSubList({ + data, + render, + cssVars, + depth = 0, + slotProps, + currentRole, + enabledRootRedirect, +}: NavSubListProps) { + return ( + + {data.map((list) => ( + + ))} + + ); +} diff --git a/app/frontend/src/components/nav-section/horizontal/nav-section-horizontal.tsx b/app/frontend/src/components/nav-section/horizontal/nav-section-horizontal.tsx new file mode 100644 index 00000000..5aa13bce --- /dev/null +++ b/app/frontend/src/components/nav-section/horizontal/nav-section-horizontal.tsx @@ -0,0 +1,95 @@ +import { mergeClasses } from 'minimal-shared/utils'; + +import { useTheme } from '@mui/material/styles'; + +import { NavList } from './nav-list'; +import { Scrollbar } from '../../scrollbar'; +import { Nav, NavUl, NavLi } from '../components'; +import { navSectionClasses, navSectionCssVars } from '../styles'; + +import type { NavGroupProps, NavSectionProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function NavSectionHorizontal({ + sx, + data, + render, + className, + slotProps, + currentRole, + enabledRootRedirect, + cssVars: overridesVars, + ...other +}: NavSectionProps) { + const theme = useTheme(); + + const cssVars = { ...navSectionCssVars.horizontal(theme), ...overridesVars }; + + return ( + + + + ); +} + +// ---------------------------------------------------------------------- + +function Group({ + items, + render, + cssVars, + slotProps, + currentRole, + enabledRootRedirect, +}: NavGroupProps) { + return ( + + + {items.map((list) => ( + + ))} + + + ); +} diff --git a/app/frontend/src/components/nav-section/index.ts b/app/frontend/src/components/nav-section/index.ts new file mode 100644 index 00000000..09aced35 --- /dev/null +++ b/app/frontend/src/components/nav-section/index.ts @@ -0,0 +1,13 @@ +export * from './mini'; + +export * from './utils'; + +export * from './styles'; + +export * from './vertical'; + +export * from './components'; + +export * from './horizontal'; + +export type * from './types'; diff --git a/app/frontend/src/components/nav-section/mini/index.ts b/app/frontend/src/components/nav-section/mini/index.ts new file mode 100644 index 00000000..bead531b --- /dev/null +++ b/app/frontend/src/components/nav-section/mini/index.ts @@ -0,0 +1,3 @@ +export * from './nav-section-mini'; + +export { NavItem as NavSectionMiniItem } from './nav-item'; diff --git a/app/frontend/src/components/nav-section/mini/nav-item.tsx b/app/frontend/src/components/nav-section/mini/nav-item.tsx new file mode 100644 index 00000000..57ffe8a8 --- /dev/null +++ b/app/frontend/src/components/nav-section/mini/nav-item.tsx @@ -0,0 +1,244 @@ +import type { CSSObject } from '@mui/material/styles'; + +import { forwardRef } from 'react'; +import { mergeClasses } from 'minimal-shared/utils'; + +import Tooltip from '@mui/material/Tooltip'; +import { styled } from '@mui/material/styles'; +import ButtonBase from '@mui/material/ButtonBase'; + +import { Iconify } from '../../iconify'; +import { createNavItem } from '../utils'; +import { navItemStyles, navSectionClasses } from '../styles'; + +import type { NavItemProps } from '../types'; + +// ---------------------------------------------------------------------- + +export const NavItem = forwardRef((props, ref) => { + const { + path, + icon, + info, + title, + caption, + /********/ + open, + active, + disabled, + /********/ + depth, + render, + hasChild, + slotProps, + className, + externalLink, + enabledRootRedirect, + ...other + } = props; + + const navItem = createNavItem({ + path, + icon, + info, + depth, + render, + hasChild, + externalLink, + enabledRootRedirect, + }); + + const ownerState: StyledState = { + open, + active, + disabled, + variant: navItem.rootItem ? 'rootItem' : 'subItem', + }; + + return ( + + {icon && ( + + {navItem.renderIcon} + + )} + + {title && ( + + {title} + + )} + + {caption && ( + + + + )} + + {info && navItem.subItem && ( + + {navItem.renderInfo} + + )} + + {hasChild && ( + + )} + + ); +}); + +// ---------------------------------------------------------------------- + +type StyledState = Pick & { + variant: 'rootItem' | 'subItem'; +}; + +const shouldForwardProp = (prop: string) => + !['open', 'active', 'disabled', 'variant', 'sx'].includes(prop); + +/** + * @slot root + */ +const ItemRoot = styled(ButtonBase, { shouldForwardProp })(({ + active, + open, + theme, +}) => { + const rootItemStyles: CSSObject = { + textAlign: 'center', + flexDirection: 'column', + minHeight: 'var(--nav-item-root-height)', + padding: 'var(--nav-item-root-padding)', + ...(open && { + color: 'var(--nav-item-root-open-color)', + backgroundColor: 'var(--nav-item-root-open-bg)', + }), + ...(active && { + color: 'var(--nav-item-root-active-color)', + backgroundColor: 'var(--nav-item-root-active-bg)', + '&:hover': { backgroundColor: 'var(--nav-item-root-active-hover-bg)' }, + ...theme.applyStyles('dark', { + color: 'var(--nav-item-root-active-color-on-dark)', + }), + }), + }; + + const subItemStyles: CSSObject = { + minHeight: 'var(--nav-item-sub-height)', + padding: 'var(--nav-item-sub-padding)', + color: theme.vars.palette.text.secondary, + ...(open && { + color: 'var(--nav-item-sub-open-color)', + backgroundColor: 'var(--nav-item-sub-open-bg)', + }), + ...(active && { + color: 'var(--nav-item-sub-active-color)', + backgroundColor: 'var(--nav-item-sub-active-bg)', + }), + }; + + return { + width: '100%', + color: 'var(--nav-item-color)', + borderRadius: 'var(--nav-item-radius)', + '&:hover': { backgroundColor: 'var(--nav-item-hover-bg)' }, + variants: [ + { props: { variant: 'rootItem' }, style: rootItemStyles }, + { props: { variant: 'subItem' }, style: subItemStyles }, + { props: { disabled: true }, style: navItemStyles.disabled }, + ], + }; +}); + +/** + * @slot icon + */ +const ItemIcon = styled('span', { shouldForwardProp })(() => ({ + ...navItemStyles.icon, + width: 'var(--nav-icon-size)', + height: 'var(--nav-icon-size)', + margin: 'var(--nav-icon-root-margin)', + variants: [{ props: { variant: 'subItem' }, style: { margin: 'var(--nav-icon-sub-margin)' } }], +})); + +/** + * @slot title + */ +const ItemTitle = styled('span', { shouldForwardProp })(({ active, theme }) => ({ + ...navItemStyles.title(theme), + lineHeight: '16px', + fontSize: theme.typography.pxToRem(10), + fontWeight: theme.typography.fontWeightSemiBold, + variants: [ + { + props: { variant: 'rootItem' }, + style: { ...(active && { fontWeight: theme.typography.fontWeightBold }) }, + }, + { + props: { variant: 'subItem' }, + style: { + ...theme.typography.body2, + fontWeight: theme.typography.fontWeightMedium, + ...(active && { fontWeight: theme.typography.fontWeightSemiBold }), + }, + }, + ], +})); + +/** + * @slot caption icon + */ +const ItemCaptionIcon = styled(Iconify, { shouldForwardProp })(({ theme }) => ({ + ...navItemStyles.captionIcon, + color: 'var(--nav-item-caption-color)', + variants: [{ props: { variant: 'rootItem' }, style: { top: 11, left: 6, position: 'absolute' } }], +})); + +/** + * @slot info + */ +const ItemInfo = styled('span', { shouldForwardProp })(({ theme }) => ({ + ...navItemStyles.info, +})); + +/** + * @slot arrow + */ +const ItemArrow = styled(Iconify, { shouldForwardProp })(({ theme }) => ({ + ...navItemStyles.arrow(theme), + variants: [ + { + props: { variant: 'rootItem' }, + style: { + margin: 0, + top: 11, + right: 6, + position: 'absolute', + }, + }, + { props: { variant: 'subItem' }, style: { marginRight: theme.spacing(-0.5) } }, + ], +})); diff --git a/app/frontend/src/components/nav-section/mini/nav-list.tsx b/app/frontend/src/components/nav-section/mini/nav-list.tsx new file mode 100644 index 00000000..0709b171 --- /dev/null +++ b/app/frontend/src/components/nav-section/mini/nav-list.tsx @@ -0,0 +1,165 @@ +import { useEffect, useCallback } from 'react'; +import { usePopoverHover } from 'minimal-shared/hooks'; +import { isActiveLink, isExternalLink } from 'minimal-shared/utils'; + +import { useTheme } from '@mui/material/styles'; + +import { usePathname } from 'src/routes/hooks'; + +import { NavItem } from './nav-item'; +import { navSectionClasses } from '../styles'; +import { NavUl, NavLi, NavDropdown, NavDropdownPaper } from '../components'; + +import type { NavListProps, NavSubListProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function NavList({ + data, + depth, + render, + cssVars, + slotProps, + currentRole, + enabledRootRedirect, +}: NavListProps) { + const theme = useTheme(); + + const pathname = usePathname(); + + const isActive = isActiveLink(pathname, data.path, !!data.children); + + const { + open, + onOpen, + onClose, + anchorEl, + elementRef: navItemRef, + } = usePopoverHover(); + + const isRtl = theme.direction === 'rtl'; + const id = open ? `${data.title}-popover` : undefined; + + useEffect(() => { + // If the pathname changes, close the menu + if (open) { + onClose(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pathname]); + + const handleOpenMenu = useCallback(() => { + if (data.children) { + onOpen(); + } + }, [data.children, onOpen]); + + const renderNavItem = () => ( + + ); + + const renderDropdown = () => + !!data.children && ( + + + + + + ); + + // Hidden item by role + if (data.roles && currentRole && !data.roles.includes(currentRole)) { + return null; + } + + return ( + + {renderNavItem()} + {/* + * TODO: Should be removed in MUI next. + * Add `open` condition to disable transition effect on close. + * https://github.com/mui/material-ui/issues/43106 + */} + {open && renderDropdown()} + + ); +} + +// ---------------------------------------------------------------------- + +function NavSubList({ + data, + render, + cssVars, + depth = 0, + slotProps, + currentRole, + enabledRootRedirect, +}: NavSubListProps) { + return ( + + {data.map((list) => ( + + ))} + + ); +} diff --git a/app/frontend/src/components/nav-section/mini/nav-section-mini.tsx b/app/frontend/src/components/nav-section/mini/nav-section-mini.tsx new file mode 100644 index 00000000..8e2092ec --- /dev/null +++ b/app/frontend/src/components/nav-section/mini/nav-section-mini.tsx @@ -0,0 +1,79 @@ +import { mergeClasses } from 'minimal-shared/utils'; + +import { useTheme } from '@mui/material/styles'; + +import { NavList } from './nav-list'; +import { Nav, NavUl, NavLi } from '../components'; +import { navSectionClasses, navSectionCssVars } from '../styles'; + +import type { NavGroupProps, NavSectionProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function NavSectionMini({ + sx, + data, + render, + className, + slotProps, + currentRole, + enabledRootRedirect, + cssVars: overridesVars, + ...other +}: NavSectionProps) { + const theme = useTheme(); + + const cssVars = { ...navSectionCssVars.mini(theme), ...overridesVars }; + + return ( + + ); +} + +// ---------------------------------------------------------------------- + +function Group({ + items, + render, + cssVars, + slotProps, + currentRole, + enabledRootRedirect, +}: NavGroupProps) { + return ( + + + {items.map((list) => ( + + ))} + + + ); +} diff --git a/app/frontend/src/components/nav-section/styles/classes.ts b/app/frontend/src/components/nav-section/styles/classes.ts new file mode 100644 index 00000000..6f28979b --- /dev/null +++ b/app/frontend/src/components/nav-section/styles/classes.ts @@ -0,0 +1,31 @@ +import { createClasses } from 'src/theme/create-classes'; + +// ---------------------------------------------------------------------- + +export const navSectionClasses = { + mini: createClasses('nav__section__mini'), + vertical: createClasses('nav__section__vertical'), + horizontal: createClasses('nav__section__horizontal'), + li: createClasses('nav__li'), + ul: createClasses('nav__ul'), + subheader: createClasses('nav__subheader'), + dropdown: { + root: createClasses('nav__dropdown__root'), + paper: createClasses('nav__dropdown__paper'), + }, + item: { + root: createClasses('nav__item__root'), + sub: createClasses('nav__item__sub'), + icon: createClasses('nav__item__icon'), + info: createClasses('nav__item__info'), + texts: createClasses('nav__item__texts'), + title: createClasses('nav__item__title'), + arrow: createClasses('nav__item__arrow'), + caption: createClasses('nav__item__caption'), + }, + state: { + open: '--open', + active: '--active', + disabled: '--disabled', + }, +}; diff --git a/app/frontend/src/components/nav-section/styles/css-vars.ts b/app/frontend/src/components/nav-section/styles/css-vars.ts new file mode 100644 index 00000000..2aa56b85 --- /dev/null +++ b/app/frontend/src/components/nav-section/styles/css-vars.ts @@ -0,0 +1,116 @@ +import type { Theme } from '@mui/material/styles'; + +import { varAlpha } from 'minimal-shared/utils'; + +// ---------------------------------------------------------------------- + +export const bulletColor = { dark: '#282F37', light: '#EDEFF2' }; + +function colorVars(theme: Theme, variant?: 'vertical' | 'mini' | 'horizontal') { + const { + vars: { palette }, + } = theme; + + return { + '--nav-item-color': palette.text.secondary, + '--nav-item-hover-bg': palette.action.hover, + '--nav-item-caption-color': palette.text.disabled, + // root + '--nav-item-root-active-color': palette.primary.main, + '--nav-item-root-active-color-on-dark': palette.primary.light, + '--nav-item-root-active-bg': varAlpha(palette.primary.mainChannel, 0.08), + '--nav-item-root-active-hover-bg': varAlpha(palette.primary.mainChannel, 0.16), + '--nav-item-root-open-color': palette.text.primary, + '--nav-item-root-open-bg': palette.action.hover, + // sub + '--nav-item-sub-active-color': palette.text.primary, + '--nav-item-sub-active-bg': palette.action.selected, + '--nav-item-sub-open-color': palette.text.primary, + '--nav-item-sub-open-bg': palette.action.hover, + ...(variant === 'vertical' && { + '--nav-item-sub-active-bg': palette.action.hover, + '--nav-subheader-color': palette.text.disabled, + '--nav-subheader-hover-color': palette.text.primary, + }), + }; +} + +// ---------------------------------------------------------------------- + +function verticalVars(theme: Theme) { + const { shape } = theme; + + return { + ...colorVars(theme, 'vertical'), + '--nav-item-gap': '4px', + '--nav-item-radius': `${shape.borderRadius}px`, + '--nav-item-pt': '4px', + '--nav-item-pr': '8px', + '--nav-item-pb': '4px', + '--nav-item-pl': '12px', + // root + '--nav-item-root-height': '44px', + // sub + '--nav-item-sub-height': '36px', + // icon + '--nav-icon-size': '24px', + '--nav-icon-margin': '0 12px 0 0', + // bullet + '--nav-bullet-size': '12px', + '--nav-bullet-light-color': bulletColor.light, + '--nav-bullet-dark-color': bulletColor.dark, + }; +} + +// ---------------------------------------------------------------------- + +function miniVars(theme: Theme) { + const { shape } = theme; + + return { + ...colorVars(theme, 'mini'), + '--nav-item-gap': '4px', + '--nav-item-radius': `${shape.borderRadius}px`, + // root + '--nav-item-root-height': '56px', + '--nav-item-root-padding': '8px 4px 6px 4px', + // sub + '--nav-item-sub-height': '34px', + '--nav-item-sub-padding': '0 8px', + // icon + '--nav-icon-size': '22px', + '--nav-icon-root-margin': '0 0 6px 0', + '--nav-icon-sub-margin': '0 8px 0 0', + }; +} + +// ---------------------------------------------------------------------- + +function horizontalVars(theme: Theme) { + const { shape } = theme; + + return { + ...colorVars(theme, 'horizontal'), + '--nav-item-gap': '6px', + '--nav-height': '56px', + '--nav-item-radius': `${shape.borderRadius * 0.75}px`, + // root + '--nav-item-root-height': '32px', + '--nav-item-root-padding': '0 6px', + // sub + '--nav-item-sub-height': '34px', + '--nav-item-sub-padding': '0 8px', + // icon + '--nav-icon-size': '22px', + '--nav-icon-sub-margin': '0 8px 0 0', + '--nav-icon-root-margin': '0 8px 0 0', + }; +} + +// ---------------------------------------------------------------------- + +export const navSectionCssVars = { + mini: miniVars, + vertical: verticalVars, + horizontal: horizontalVars, +}; diff --git a/app/frontend/src/components/nav-section/styles/index.ts b/app/frontend/src/components/nav-section/styles/index.ts new file mode 100644 index 00000000..33ec265f --- /dev/null +++ b/app/frontend/src/components/nav-section/styles/index.ts @@ -0,0 +1,5 @@ +export * from './classes'; + +export * from './css-vars'; + +export * from './nav-item-styles'; diff --git a/app/frontend/src/components/nav-section/styles/nav-item-styles.ts b/app/frontend/src/components/nav-section/styles/nav-item-styles.ts new file mode 100644 index 00000000..03252f8a --- /dev/null +++ b/app/frontend/src/components/nav-section/styles/nav-item-styles.ts @@ -0,0 +1,58 @@ +import type { Theme, CSSObject } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +type NavItemStyles = { + icon: CSSObject; + info: CSSObject; + texts: CSSObject; + disabled: CSSObject; + captionIcon: CSSObject; + title: (theme: Theme) => CSSObject; + arrow: (theme: Theme) => CSSObject; + captionText: (theme: Theme) => CSSObject; +}; + +export const navItemStyles: NavItemStyles = { + icon: { + width: 22, + height: 22, + flexShrink: 0, + display: 'inline-flex', + /** + * As ':first-child' for ssr + * https://github.com/emotion-js/emotion/issues/1105#issuecomment-1126025608 + */ + '& > :first-of-type:not(style):not(:first-of-type ~ *), & > style + *': { + width: '100%', + height: '100%', + }, + }, + texts: { flex: '1 1 auto', display: 'inline-flex', flexDirection: 'column' }, + title: (theme: Theme) => ({ + ...theme.mixins.maxLine({ line: 1 }), + flex: '1 1 auto', + }), + info: { + fontSize: 12, + flexShrink: 0, + fontWeight: 600, + marginLeft: '6px', + lineHeight: 18 / 12, + display: 'inline-flex', + }, + arrow: (theme: Theme) => ({ + width: 16, + height: 16, + flexShrink: 0, + marginLeft: '6px', + display: 'inline-flex', + ...(theme.direction === 'rtl' && { transform: 'scaleX(-1)' }), + }), + captionIcon: { width: 16, height: 16 }, + captionText: (theme: Theme) => ({ + ...theme.mixins.maxLine({ line: 1 }), + ...theme.typography.caption, + }), + disabled: { opacity: 0.48, pointerEvents: 'none' }, +}; diff --git a/app/frontend/src/components/nav-section/types.ts b/app/frontend/src/components/nav-section/types.ts new file mode 100644 index 00000000..3d2a7a46 --- /dev/null +++ b/app/frontend/src/components/nav-section/types.ts @@ -0,0 +1,92 @@ +import type { ButtonBaseProps } from '@mui/material/ButtonBase'; +import type { Theme, SxProps, CSSObject } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +/** + * Item + */ +export type NavItemRenderProps = { + navIcon?: Record; + navInfo?: (val: string) => Record; +}; + +export type NavItemStateProps = { + open?: boolean; + active?: boolean; + disabled?: boolean; +}; + +export type NavItemSlotProps = { + sx?: SxProps; + icon?: SxProps; + texts?: SxProps; + title?: SxProps; + caption?: SxProps; + info?: SxProps; + arrow?: SxProps; +}; + +export type NavSlotProps = { + rootItem?: NavItemSlotProps; + subItem?: NavItemSlotProps; + subheader?: SxProps; + dropdown?: { + paper?: SxProps; + }; +}; + +export type NavItemOptionsProps = { + depth?: number; + hasChild?: boolean; + externalLink?: boolean; + enabledRootRedirect?: boolean; + render?: NavItemRenderProps; + slotProps?: NavItemSlotProps; +}; + +export type NavItemDataProps = Pick & { + path: string; + title: string; + icon?: string | React.ReactNode; + info?: string[] | React.ReactNode; + caption?: string; + roles?: string[]; + children?: NavItemDataProps[]; +}; + +export type NavItemProps = ButtonBaseProps & + NavItemDataProps & + NavItemStateProps & + NavItemOptionsProps; + +/** + * List + */ +export type NavListProps = Pick & { + cssVars?: CSSObject; + data: NavItemDataProps; + slotProps?: NavSlotProps; + currentRole?: string; +}; + +export type NavSubListProps = Omit & { + data: NavItemDataProps[]; +}; + +export type NavGroupProps = Omit & { + subheader?: string; + items: NavItemDataProps[]; +}; + +/** + * Main + */ +export type NavSectionProps = React.ComponentProps<'nav'> & + Omit & { + sx?: SxProps; + data: { + subheader?: string; + items: NavItemDataProps[]; + }[]; + }; diff --git a/app/frontend/src/components/nav-section/utils/create-nav-item.ts b/app/frontend/src/components/nav-section/utils/create-nav-item.ts new file mode 100644 index 00000000..2bd60478 --- /dev/null +++ b/app/frontend/src/components/nav-section/utils/create-nav-item.ts @@ -0,0 +1,73 @@ +import { cloneElement } from 'react'; + +import { RouterLink } from 'src/routes/components'; + +import type { NavItemDataProps, NavItemOptionsProps } from '../types'; + +// ---------------------------------------------------------------------- + +type CreateNavItemReturn = { + subItem: boolean; + rootItem: boolean; + subDeepItem: boolean; + baseProps: Record; + renderIcon: React.ReactNode; + renderInfo: React.ReactNode; +}; + +type CreateNavItemProps = Pick & NavItemOptionsProps; + +export function createNavItem({ + path, + icon, + info, + depth, + render, + hasChild, + externalLink, + enabledRootRedirect, +}: CreateNavItemProps): CreateNavItemReturn { + const rootItem = depth === 1; + const subItem = !rootItem; + const subDeepItem = Number(depth) > 2; + + const linkProps = externalLink + ? { href: path, target: '_blank', rel: 'noopener' } + : { component: RouterLink, href: path }; + + const baseProps = hasChild && !enabledRootRedirect ? { component: 'div' } : linkProps; + + /** + * Render @icon + */ + let renderIcon = null; + + if (icon && render?.navIcon && typeof icon === 'string') { + renderIcon = render?.navIcon[icon]; + } else { + renderIcon = icon; + } + + /** + * Render @info + */ + let renderInfo = null; + + if (info && render?.navInfo && Array.isArray(info)) { + const [key, value] = info; + const element = render.navInfo(value)[key]; + + renderInfo = element ? cloneElement(element) : null; + } else { + renderInfo = info; + } + + return { + subItem, + rootItem, + subDeepItem, + baseProps, + renderIcon, + renderInfo, + }; +} diff --git a/app/frontend/src/components/nav-section/utils/index.ts b/app/frontend/src/components/nav-section/utils/index.ts new file mode 100644 index 00000000..69366bdb --- /dev/null +++ b/app/frontend/src/components/nav-section/utils/index.ts @@ -0,0 +1 @@ +export * from './create-nav-item'; diff --git a/app/frontend/src/components/nav-section/vertical/index.ts b/app/frontend/src/components/nav-section/vertical/index.ts new file mode 100644 index 00000000..9e880efd --- /dev/null +++ b/app/frontend/src/components/nav-section/vertical/index.ts @@ -0,0 +1,3 @@ +export * from './nav-section-vertical'; + +export { NavItem as NavSectionVerticalItem } from './nav-item'; diff --git a/app/frontend/src/components/nav-section/vertical/nav-item.tsx b/app/frontend/src/components/nav-section/vertical/nav-item.tsx new file mode 100644 index 00000000..df68d5ed --- /dev/null +++ b/app/frontend/src/components/nav-section/vertical/nav-item.tsx @@ -0,0 +1,248 @@ +import type { CSSObject } from '@mui/material/styles'; + +import { forwardRef } from 'react'; +import { mergeClasses } from 'minimal-shared/utils'; + +import Tooltip from '@mui/material/Tooltip'; +import { styled } from '@mui/material/styles'; +import ButtonBase from '@mui/material/ButtonBase'; + +import { Iconify } from '../../iconify'; +import { createNavItem } from '../utils'; +import { navItemStyles, navSectionClasses } from '../styles'; + +import type { NavItemProps } from '../types'; + +// ---------------------------------------------------------------------- + +export const NavItem = forwardRef((props, ref) => { + const { + path, + icon, + info, + title, + caption, + /********/ + open, + active, + disabled, + /********/ + depth, + render, + hasChild, + slotProps, + className, + externalLink, + enabledRootRedirect, + ...other + } = props; + + const navItem = createNavItem({ + path, + icon, + info, + depth, + render, + hasChild, + externalLink, + enabledRootRedirect, + }); + + const ownerState: StyledState = { + open, + active, + disabled, + variant: navItem.rootItem ? 'rootItem' : 'subItem', + }; + + return ( + + {icon && ( + + {navItem.renderIcon} + + )} + + {title && ( + + + {title} + + + {caption && ( + + + {caption} + + + )} + + )} + + {info && ( + + {navItem.renderInfo} + + )} + + {hasChild && ( + + )} + + ); +}); + +// ---------------------------------------------------------------------- + +type StyledState = Pick & { + variant: 'rootItem' | 'subItem'; +}; + +const shouldForwardProp = (prop: string) => + !['open', 'active', 'disabled', 'variant', 'sx'].includes(prop); + +/** + * @slot root + */ +const ItemRoot = styled(ButtonBase, { shouldForwardProp })(({ + active, + open, + theme, +}) => { + const bulletSvg = `"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' fill='none' viewBox='0 0 14 14'%3E%3Cpath d='M1 1v4a8 8 0 0 0 8 8h4' stroke='%23efefef' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E"`; + + const bulletStyles: CSSObject = { + left: 0, + content: '""', + position: 'absolute', + width: 'var(--nav-bullet-size)', + height: 'var(--nav-bullet-size)', + backgroundColor: 'var(--nav-bullet-light-color)', + mask: `url(${bulletSvg}) no-repeat 50% 50%/100% auto`, + WebkitMask: `url(${bulletSvg}) no-repeat 50% 50%/100% auto`, + transform: + theme.direction === 'rtl' + ? 'translate(calc(var(--nav-bullet-size) * 1), calc(var(--nav-bullet-size) * -0.4)) scaleX(-1)' + : 'translate(calc(var(--nav-bullet-size) * -1), calc(var(--nav-bullet-size) * -0.4))', + ...theme.applyStyles('dark', { + backgroundColor: 'var(--nav-bullet-dark-color)', + }), + }; + + const rootItemStyles: CSSObject = { + minHeight: 'var(--nav-item-root-height)', + ...(open && { + color: 'var(--nav-item-root-open-color)', + backgroundColor: 'var(--nav-item-root-open-bg)', + }), + ...(active && { + color: 'var(--nav-item-root-active-color)', + backgroundColor: 'var(--nav-item-root-active-bg)', + '&:hover': { backgroundColor: 'var(--nav-item-root-active-hover-bg)' }, + ...theme.applyStyles('dark', { + color: 'var(--nav-item-root-active-color-on-dark)', + }), + }), + }; + + const subItemStyles: CSSObject = { + minHeight: 'var(--nav-item-sub-height)', + '&::before': bulletStyles, + ...(open && { + color: 'var(--nav-item-sub-open-color)', + backgroundColor: 'var(--nav-item-sub-open-bg)', + }), + ...(active && { + color: 'var(--nav-item-sub-active-color)', + backgroundColor: 'var(--nav-item-sub-active-bg)', + }), + }; + + return { + width: '100%', + paddingTop: 'var(--nav-item-pt)', + paddingLeft: 'var(--nav-item-pl)', + paddingRight: 'var(--nav-item-pr)', + paddingBottom: 'var(--nav-item-pb)', + borderRadius: 'var(--nav-item-radius)', + color: 'var(--nav-item-color)', + '&:hover': { backgroundColor: 'var(--nav-item-hover-bg)' }, + variants: [ + { props: { variant: 'rootItem' }, style: rootItemStyles }, + { props: { variant: 'subItem' }, style: subItemStyles }, + { props: { disabled: true }, style: navItemStyles.disabled }, + ], + }; +}); + +/** + * @slot icon + */ +const ItemIcon = styled('span', { shouldForwardProp })(() => ({ + ...navItemStyles.icon, + width: 'var(--nav-icon-size)', + height: 'var(--nav-icon-size)', + margin: 'var(--nav-icon-margin)', +})); + +/** + * @slot texts + */ +const ItemTexts = styled('span', { shouldForwardProp })(() => ({ + ...navItemStyles.texts, +})); + +/** + * @slot title + */ +const ItemTitle = styled('span', { shouldForwardProp })(({ theme }) => ({ + ...navItemStyles.title(theme), + ...theme.typography.body2, + fontWeight: theme.typography.fontWeightMedium, + variants: [ + { props: { active: true }, style: { fontWeight: theme.typography.fontWeightSemiBold } }, + ], +})); + +/** + * @slot caption text + */ +const ItemCaptionText = styled('span', { shouldForwardProp })(({ theme }) => ({ + ...navItemStyles.captionText(theme), + color: 'var(--nav-item-caption-color)', +})); + +/** + * @slot info + */ +const ItemInfo = styled('span', { shouldForwardProp })(({ theme }) => ({ + ...navItemStyles.info, +})); + +/** + * @slot arrow + */ +const ItemArrow = styled(Iconify, { shouldForwardProp })(({ theme }) => ({ + ...navItemStyles.arrow(theme), +})); diff --git a/app/frontend/src/components/nav-section/vertical/nav-list.tsx b/app/frontend/src/components/nav-section/vertical/nav-list.tsx new file mode 100644 index 00000000..f66314a8 --- /dev/null +++ b/app/frontend/src/components/nav-section/vertical/nav-list.tsx @@ -0,0 +1,128 @@ +import { useBoolean } from 'minimal-shared/hooks'; +import { useRef, useEffect, useCallback } from 'react'; +import { isActiveLink, isExternalLink } from 'minimal-shared/utils'; + +import { usePathname } from 'src/routes/hooks'; + +import { NavItem } from './nav-item'; +import { navSectionClasses } from '../styles'; +import { NavUl, NavLi, NavCollapse } from '../components'; + +import type { NavListProps, NavSubListProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function NavList({ + data, + depth, + render, + slotProps, + currentRole, + enabledRootRedirect, +}: NavListProps) { + const pathname = usePathname(); + const navItemRef = useRef(null); + + const isActive = isActiveLink(pathname, data.path, !!data.children); + + const { value: open, onFalse: onClose, onToggle } = useBoolean(isActive); + + useEffect(() => { + if (!isActive) { + onClose(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pathname]); + + const handleToggleMenu = useCallback(() => { + if (data.children) { + onToggle(); + } + }, [data.children, onToggle]); + + const renderNavItem = () => ( + + ); + + const renderCollapse = () => + !!data.children && ( + + + + ); + + // Hidden item by role + if (data.roles && currentRole && !data.roles.includes(currentRole)) { + return null; + } + + return ( + + {renderNavItem()} + {renderCollapse()} + + ); +} + +// ---------------------------------------------------------------------- + +function NavSubList({ + data, + render, + depth = 0, + slotProps, + currentRole, + enabledRootRedirect, +}: NavSubListProps) { + return ( + + {data.map((list) => ( + + ))} + + ); +} diff --git a/app/frontend/src/components/nav-section/vertical/nav-section-vertical.tsx b/app/frontend/src/components/nav-section/vertical/nav-section-vertical.tsx new file mode 100644 index 00000000..4c0f0ec5 --- /dev/null +++ b/app/frontend/src/components/nav-section/vertical/nav-section-vertical.tsx @@ -0,0 +1,101 @@ +import { useBoolean } from 'minimal-shared/hooks'; +import { mergeClasses } from 'minimal-shared/utils'; + +import Collapse from '@mui/material/Collapse'; +import { useTheme } from '@mui/material/styles'; + +import { NavList } from './nav-list'; +import { Nav, NavUl, NavLi, NavSubheader } from '../components'; +import { navSectionClasses, navSectionCssVars } from '../styles'; + +import type { NavGroupProps, NavSectionProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function NavSectionVertical({ + sx, + data, + render, + className, + slotProps, + currentRole, + enabledRootRedirect, + cssVars: overridesVars, + ...other +}: NavSectionProps) { + const theme = useTheme(); + + const cssVars = { ...navSectionCssVars.vertical(theme), ...overridesVars }; + + return ( + + ); +} + +// ---------------------------------------------------------------------- + +function Group({ + items, + render, + subheader, + slotProps, + currentRole, + enabledRootRedirect, +}: NavGroupProps) { + const groupOpen = useBoolean(true); + + const renderContent = () => ( + + {items.map((list) => ( + + ))} + + ); + + return ( + + {subheader ? ( + <> + + {subheader} + + + {renderContent()} + + ) : ( + renderContent() + )} + + ); +} diff --git a/app/frontend/src/components/number-input/index.ts b/app/frontend/src/components/number-input/index.ts new file mode 100644 index 00000000..cfc28e14 --- /dev/null +++ b/app/frontend/src/components/number-input/index.ts @@ -0,0 +1 @@ +export * from './number-input'; diff --git a/app/frontend/src/components/number-input/number-input.tsx b/app/frontend/src/components/number-input/number-input.tsx new file mode 100644 index 00000000..67c916f1 --- /dev/null +++ b/app/frontend/src/components/number-input/number-input.tsx @@ -0,0 +1,184 @@ +import type { BoxProps } from '@mui/material/Box'; +import type { InputBaseProps } from '@mui/material/InputBase'; +import type { ButtonBaseProps } from '@mui/material/ButtonBase'; +import type { FormHelperTextProps } from '@mui/material/FormHelperText'; + +import { varAlpha } from 'minimal-shared/utils'; +import { useId, forwardRef, useCallback } from 'react'; + +import Box from '@mui/material/Box'; + +import { Iconify } from 'src/components/iconify'; + +import { + HelperText, + CaptionText, + CenteredInput, + CounterButton, + InputContainer, + NumberInputRoot, +} from './styles'; + +// ---------------------------------------------------------------------- + +type NumberInputSlotProps = { + wrapper?: BoxProps; + input?: InputBaseProps; + button?: ButtonBaseProps; + inputWrapper?: React.ComponentProps; + captionText?: React.ComponentProps; + helperText?: FormHelperTextProps; +}; + +type EventHandler = + | React.MouseEvent + | React.ChangeEvent; + +export type NumberInputProps = Omit, 'onChange'> & { + min?: number; + max?: number; + error?: boolean; + disabled?: boolean; + value?: number | null; + hideDivider?: boolean; + hideButtons?: boolean; + disableInput?: boolean; + helperText?: React.ReactNode; + captionText?: React.ReactNode; + slotProps?: NumberInputSlotProps; + onChange?: (event: EventHandler, value: number) => void; +}; + +export const NumberInput = forwardRef((props, ref) => { + const { + sx, + error, + value, + onChange, + disabled, + slotProps, + helperText, + captionText, + hideDivider, + hideButtons, + disableInput, + min = 0, + max = 9999, + ...other + } = props; + + const id = useId(); + + const currentValue = value ?? 0; + + const isDecrementDisabled = currentValue <= min || disabled; + const isIncrementDisabled = currentValue >= max || disabled; + + const handleDecrement = useCallback( + (event: React.MouseEvent) => { + if (!isDecrementDisabled) { + onChange?.(event, currentValue - 1); + } + }, + [isDecrementDisabled, onChange, currentValue] + ); + + const handleIncrement = useCallback( + (event: React.MouseEvent) => { + if (!isIncrementDisabled) { + onChange?.(event, currentValue + 1); + } + }, + [isIncrementDisabled, onChange, currentValue] + ); + + const handleChange = useCallback( + (event: React.ChangeEvent) => { + const transformedValue = transformNumberOnChange(event.target.value, { min, max }); + onChange?.(event, transformedValue); + }, + [max, min, onChange] + ); + + return ( + + ({ + '--border-color': varAlpha(theme.vars.palette.grey['500Channel'], 0.2), + '--vertical-divider-color': hideDivider + ? 'transparent' + : varAlpha(theme.vars.palette.grey['500Channel'], 0.2), + '--input-background': + !disabled && error + ? varAlpha(theme.vars.palette.error.mainChannel, 0.08) + : varAlpha(theme.vars.palette.grey['500Channel'], 0.08), + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + {!hideButtons && ( + + + + )} + + + + + {captionText && {captionText}} + + + {!hideButtons && ( + + + + )} + + + {helperText && ( + + {helperText} + + )} + + ); +}); + +// ---------------------------------------------------------------------- + +export function transformNumberOnChange( + value: string, + options?: { min?: number; max?: number } +): number { + const { min = 0, max = 9999 } = options ?? {}; + + if (!value || value.trim() === '') { + return 0; + } + + const numericValue = Number(value.trim()); + + if (!Number.isNaN(numericValue)) { + // Clamp the value between min and max + return Math.min(Math.max(numericValue, min), max); + } + + return 0; +} diff --git a/app/frontend/src/components/number-input/styles.ts b/app/frontend/src/components/number-input/styles.ts new file mode 100644 index 00000000..5c9ba3c1 --- /dev/null +++ b/app/frontend/src/components/number-input/styles.ts @@ -0,0 +1,68 @@ +import { styled } from '@mui/material/styles'; +import ButtonBase from '@mui/material/ButtonBase'; +import FormHelperText from '@mui/material/FormHelperText'; +import InputBase, { inputBaseClasses } from '@mui/material/InputBase'; + +// ---------------------------------------------------------------------- + +export const NumberInputRoot = styled('div')(({ theme }) => ({ + display: 'flex', + overflow: 'hidden', + borderRadius: theme.shape.borderRadius, + border: 'solid 1px var(--border-color)', +})); + +export const InputContainer = styled('div')(() => ({ + display: 'flex', + alignItems: 'center', + flexDirection: 'column', + justifyContent: 'center', + backgroundColor: 'var(--input-background)', + borderLeft: 'solid 1px var(--divider-vertical-color)', + borderRight: 'solid 1px var(--divider-vertical-color)', +})); + +export const CenteredInput = styled(InputBase)(({ theme }) => ({ + [`& .${inputBaseClasses.input}`]: { + ...theme.typography.body2, + minHeight: 24, + textAlign: 'center', + padding: theme.spacing(0.5, 0), + fontWeight: theme.typography.fontWeightMedium, + }, +})); + +export const CounterButton = styled(ButtonBase, { + shouldForwardProp: (prop: string) => !['disabled', 'sx'].includes(prop), +})(() => ({ + width: 32, + flexShrink: 0, + variants: [ + { + props: { disabled: true }, + style: { + opacity: 0.48, + pointerEvents: 'none', + }, + }, + ], +})); + +export const CaptionText = styled('span')(({ theme }) => ({ + ...theme.typography.caption, + width: '100%', + display: 'flex', + textAlign: 'center', + alignItems: 'center', + gap: theme.spacing(0.5), + justifyContent: 'center', + marginTop: theme.spacing(-0.25), + color: theme.vars.palette.text.disabled, + padding: theme.spacing(0, 0.5, 0.5, 0.5), +})); + +export const HelperText = styled(FormHelperText)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.5), +})); diff --git a/app/frontend/src/components/organizational-chart/index.ts b/app/frontend/src/components/organizational-chart/index.ts new file mode 100644 index 00000000..da44f88d --- /dev/null +++ b/app/frontend/src/components/organizational-chart/index.ts @@ -0,0 +1,3 @@ +export * from './organizational-chart'; + +export type * from './types'; diff --git a/app/frontend/src/components/organizational-chart/organizational-chart.tsx b/app/frontend/src/components/organizational-chart/organizational-chart.tsx new file mode 100644 index 00000000..f05b66b1 --- /dev/null +++ b/app/frontend/src/components/organizational-chart/organizational-chart.tsx @@ -0,0 +1,86 @@ +import dynamic from 'next/dynamic'; +import { cloneElement } from 'react'; + +import { useTheme } from '@mui/material/styles'; + +import type { OrgChartProps, OrgChartListProps, OrgChartSubListProps } from './types'; + +// ---------------------------------------------------------------------- + +const Tree = dynamic(() => import('react-organizational-chart').then((mod) => mod.Tree), { + ssr: false, +}); + +const TreeNode = dynamic(() => import('react-organizational-chart').then((mod) => mod.TreeNode), { + ssr: false, +}); + +// ---------------------------------------------------------------------- + +export function OrganizationalChart({ data, nodeItem, ...other }: OrgChartProps) { + const theme = useTheme(); + + const cloneNode = (props: T) => cloneElement(nodeItem(props)); + + const label = cloneNode({ ...data } as T); + + return ( + + {data.children.map((list, index) => ( + + ))} + + ); +} + +// ---------------------------------------------------------------------- + +function TreeList({ data, depth, nodeItem }: OrgChartListProps) { + const childs = (data as any).children; + + const cloneNode = (props: T) => cloneElement(nodeItem(props)); + + const totalChildren = childs ? flattenArray(childs)?.length : 0; + + const label = cloneNode({ ...data, depth, totalChildren } as T); + + return ( + + {childs && } + + ); +} + +// ---------------------------------------------------------------------- + +function TreeSubList({ data, depth, nodeItem }: OrgChartSubListProps) { + return ( + <> + {data.map((list, index) => ( + + ))} + + ); +} + +// ---------------------------------------------------------------------- + +function flattenArray>(list: T[], key: string = 'children'): T[] { + let children: T[] = []; + + const flatten = list.map((item: T) => { + if (Array.isArray(item[key]) && item[key].length) { + children = [...children, ...item[key]]; + } + return item; + }); + + return flatten.concat(children.length ? flattenArray(children, key) : []); +} diff --git a/app/frontend/src/components/organizational-chart/types.ts b/app/frontend/src/components/organizational-chart/types.ts new file mode 100644 index 00000000..d73fd62d --- /dev/null +++ b/app/frontend/src/components/organizational-chart/types.ts @@ -0,0 +1,28 @@ +import type { TreeProps } from 'react-organizational-chart'; + +// ---------------------------------------------------------------------- + +export type OrgChartProps = Omit & { + data: { + name: string; + children: T[]; + }; + nodeItem: (props: T) => React.ReactElement; +}; + +export type OrgChartListProps = { + data: T; + depth: number; + nodeItem: (props: T) => React.ReactElement; +}; + +export type OrgChartSubListProps = { + data: T[]; + depth: number; + nodeItem: (props: T) => React.ReactElement; +}; + +export type OrgChartBaseNode = { + depth?: number; + totalChildren?: number; +}; diff --git a/app/frontend/src/components/phone-input/index.ts b/app/frontend/src/components/phone-input/index.ts new file mode 100644 index 00000000..be12fa5b --- /dev/null +++ b/app/frontend/src/components/phone-input/index.ts @@ -0,0 +1,3 @@ +export * from './phone-input'; + +export type * from './types'; diff --git a/app/frontend/src/components/phone-input/list-popover.tsx b/app/frontend/src/components/phone-input/list-popover.tsx new file mode 100644 index 00000000..8d258987 --- /dev/null +++ b/app/frontend/src/components/phone-input/list-popover.tsx @@ -0,0 +1,204 @@ +import type { Country } from 'react-phone-number-input/input'; + +import { useMemo } from 'react'; +import { usePopover } from 'minimal-shared/hooks'; + +import Box from '@mui/material/Box'; +import Popover from '@mui/material/Popover'; +import MenuList from '@mui/material/MenuList'; +import MenuItem from '@mui/material/MenuItem'; +import TextField from '@mui/material/TextField'; +import ButtonBase from '@mui/material/ButtonBase'; +import IconButton from '@mui/material/IconButton'; +import ListItemText from '@mui/material/ListItemText'; +import InputAdornment from '@mui/material/InputAdornment'; + +import { Iconify } from 'src/components/iconify'; +import { FlagIcon } from 'src/components/flag-icon'; +import { SearchNotFound } from 'src/components/search-not-found'; + +import type { CountryListProps } from './types'; + +// ---------------------------------------------------------------------- + +export function CountryListPopover({ + sx, + countries, + countryCode, + searchCountry, + onClickCountry, + onSearchCountry, +}: CountryListProps) { + const { open, onClose, onOpen, anchorEl } = usePopover(); + + const selectedCountry = useMemo( + () => countries.find((country) => country.code === countryCode), + [countries, countryCode] + ); + + const dataFiltered = useMemo( + () => + applyFilter({ + inputData: countries, + query: searchCountry, + }), + [countries, searchCountry] + ); + + const notFound = dataFiltered.length === 0 && !!searchCountry; + + const btnId = 'country-list-button'; + const menuId = 'country-list-menu'; + + const renderButton = () => ( + + + + + + ({ + height: 20, + ml: 'auto', + width: '1px', + bgcolor: theme.vars.palette.divider, + })} + /> + + ); + + const renderList = () => ( + + {dataFiltered.map((country) => ( + { + onClose(); + onSearchCountry(''); + onClickCountry(country.code as Country); + }} + > + + + + + ))} + + ); + + return ( + <> + {renderButton()} + + { + onClose(); + onSearchCountry(''); + }} + anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} + transformOrigin={{ vertical: 'top', horizontal: 'left' }} + slotProps={{ + paper: { + sx: { + width: 1, + height: 320, + maxWidth: 320, + display: 'flex', + flexDirection: 'column', + }, + }, + }} + > + + onSearchCountry(event.target.value)} + placeholder="Search..." + slotProps={{ + input: { + startAdornment: ( + + + + ), + endAdornment: searchCountry && ( + + onSearchCountry('')}> + + + + ), + }, + }} + /> + + + + {notFound ? : renderList()} + + + + ); +} + +// ---------------------------------------------------------------------- + +type ApplyFilterProps = { + query: string; + inputData: CountryListProps['countries']; +}; + +function applyFilter({ inputData, query }: ApplyFilterProps) { + if (!query) return inputData; + + return inputData.filter(({ label, code, phone }) => + [label, code, phone].some((field) => field?.toLowerCase().includes(query.toLowerCase())) + ); +} diff --git a/app/frontend/src/components/phone-input/phone-input.tsx b/app/frontend/src/components/phone-input/phone-input.tsx new file mode 100644 index 00000000..1b81462a --- /dev/null +++ b/app/frontend/src/components/phone-input/phone-input.tsx @@ -0,0 +1,145 @@ +import type { TextFieldProps } from '@mui/material/TextField'; +import type { Value, Country } from 'react-phone-number-input/input'; + +import { parsePhoneNumber } from 'react-phone-number-input'; +import PhoneNumberInput from 'react-phone-number-input/input'; +import { useState, useEffect, forwardRef, useCallback, startTransition } from 'react'; + +import Box from '@mui/material/Box'; +import TextField from '@mui/material/TextField'; +import IconButton from '@mui/material/IconButton'; +import InputAdornment from '@mui/material/InputAdornment'; +import { inputBaseClasses } from '@mui/material/InputBase'; + +import { countries } from 'src/assets/data/countries'; + +import { Iconify } from '../iconify'; +import { CountryListPopover } from './list-popover'; + +import type { PhoneInputProps } from './types'; + +// ---------------------------------------------------------------------- + +export const PhoneInput = forwardRef((props, ref) => { + const { + sx, + size, + value, + label, + onChange, + placeholder, + disableSelect, + variant = 'outlined', + country: inputCountryCode, + ...other + } = props; + + const defaultCountryCode = getCountryCode(value, inputCountryCode); + + const [searchCountry, setSearchCountry] = useState(''); + const [selectedCountry, setSelectedCountry] = useState(defaultCountryCode); + + const hasLabel = !!label; + + const cleanValue = value.replace(/[\s-]+/g, ''); + + const handleClear = useCallback(() => { + onChange('' as Value); + }, [onChange]); + + useEffect(() => { + if (!selectedCountry) { + setSelectedCountry(defaultCountryCode); + } + }, [defaultCountryCode, selectedCountry]); + + const handleClickCountry = (inputValue: Country) => { + startTransition(() => { + setSelectedCountry(inputValue); + }); + }; + + const handleSearchCountry = (inputValue: string) => { + setSearchCountry(inputValue); + }; + + return ( + ({ + '--popover-button-mr': '12px', + '--popover-button-height': '22px', + '--popover-button-width': variant === 'standard' ? '48px' : '60px', + position: 'relative', + ...(!disableSelect && { + [`& .${inputBaseClasses.input}`]: { + pl: 'calc(var(--popover-button-width) + var(--popover-button-mr))', + }, + }), + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + > + {!disableSelect && ( + + )} + + + + + + + ), + }, + }} + {...other} + /> + + ); +}); + +// ---------------------------------------------------------------------- + +const CustomInput = forwardRef((props, ref) => ( + +)); + +// ---------------------------------------------------------------------- + +function getCountryCode(inputValue: string, countryCode?: Country): Country { + if (inputValue) { + const phoneNumber = parsePhoneNumber(inputValue); + return phoneNumber?.country as Country; + } + + return countryCode as Country; +} diff --git a/app/frontend/src/components/phone-input/types.ts b/app/frontend/src/components/phone-input/types.ts new file mode 100644 index 00000000..62f00f4b --- /dev/null +++ b/app/frontend/src/components/phone-input/types.ts @@ -0,0 +1,21 @@ +import type { Theme, SxProps } from '@mui/material/styles'; +import type { TextFieldProps } from '@mui/material/TextField'; +import type { Value, Country } from 'react-phone-number-input/input'; + +// ---------------------------------------------------------------------- + +export type PhoneInputProps = Omit & { + value: string; + country?: Country; + disableSelect?: boolean; + onChange: (newValue: Value) => void; +}; + +export type CountryListProps = { + sx?: SxProps; + countryCode?: Country; + searchCountry: string; + countries: { label: string; code: string; phone: string }[]; + onClickCountry: (inputValue: Country) => void; + onSearchCountry: (inputValue: string) => void; +}; diff --git a/app/frontend/src/components/progress-bar/index.ts b/app/frontend/src/components/progress-bar/index.ts new file mode 100644 index 00000000..d71d9b1b --- /dev/null +++ b/app/frontend/src/components/progress-bar/index.ts @@ -0,0 +1 @@ +export * from './progress-bar'; diff --git a/app/frontend/src/components/progress-bar/progress-bar.tsx b/app/frontend/src/components/progress-bar/progress-bar.tsx new file mode 100644 index 00000000..846c71ad --- /dev/null +++ b/app/frontend/src/components/progress-bar/progress-bar.tsx @@ -0,0 +1,86 @@ +'use client'; + +import './styles.css'; + +import NProgress from 'nprogress'; +import { Suspense, useEffect } from 'react'; + +import { useRouter, usePathname, useSearchParams } from 'src/routes/hooks'; + +// ---------------------------------------------------------------------- + +type PushStateInput = [data: any, unused: string, url?: string | URL | null | undefined]; + +/** + * Handles anchor click events to start the progress bar if the target URL is different from the current URL. + * @param event - The mouse event triggered by clicking an anchor element. + */ +const handleAnchorClick = (event: MouseEvent) => { + const targetUrl = (event.currentTarget as HTMLAnchorElement).href; + const currentUrl = window.location.href; + + if (targetUrl !== currentUrl) { + NProgress.start(); + } +}; + +/** + * Handles DOM mutations to add click event listeners to anchor elements. + */ +const handleMutation = () => { + const anchorElements: NodeListOf = document.querySelectorAll('a[href]'); + + const filteredAnchors = Array.from(anchorElements).filter((element) => { + const rel = element.getAttribute('rel'); + const href = element.getAttribute('href'); + const target = element.getAttribute('target'); + + return href?.startsWith('/') && target !== '_blank' && rel !== 'noopener'; + }); + + filteredAnchors.forEach((anchor) => anchor.addEventListener('click', handleAnchorClick)); +}; + +export function ProgressBar() { + useEffect(() => { + NProgress.configure({ showSpinner: false }); + + const mutationObserver = new MutationObserver(handleMutation); + + mutationObserver.observe(document, { childList: true, subtree: true }); + + window.history.pushState = new Proxy(window.history.pushState, { + apply: (target, thisArg, argArray: PushStateInput) => { + NProgress.done(); + return target.apply(thisArg, argArray); + }, + }); + + // Cleanup function to remove event listeners and observer + return () => { + mutationObserver.disconnect(); + const anchorElements: NodeListOf = document.querySelectorAll('a[href]'); + anchorElements.forEach((anchor) => anchor.removeEventListener('click', handleAnchorClick)); + }; + }, []); + + return ( + + + + ); +} + +// ---------------------------------------------------------------------- + +function NProgressDone() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + useEffect(() => { + NProgress.done(); + }, [pathname, router, searchParams]); + + return null; +} diff --git a/app/frontend/src/components/progress-bar/styles.css b/app/frontend/src/components/progress-bar/styles.css new file mode 100644 index 00000000..38c3ce0b --- /dev/null +++ b/app/frontend/src/components/progress-bar/styles.css @@ -0,0 +1,26 @@ +#nprogress { + top: 0; + left: 0; + width: 100%; + height: 2.5px; + z-index: 9999; + position: fixed; + pointer-events: none; +} +#nprogress .bar { + height: 100%; + background-color: var(--palette-primary-main); + box-shadow: 0 0 2.5px var(--palette-primary-main); +} +#nprogress .peg { + right: 0; + opacity: 1; + width: 100px; + height: 100%; + display: block; + position: absolute; + transform: rotate(3deg) translate(0px, -4px); + box-shadow: + 0 0 10px var(--palette-primary-main), + 0 0 5px var(--palette-primary-main); +} diff --git a/app/frontend/src/components/scrollbar/classes.ts b/app/frontend/src/components/scrollbar/classes.ts new file mode 100644 index 00000000..8cdeaccf --- /dev/null +++ b/app/frontend/src/components/scrollbar/classes.ts @@ -0,0 +1,7 @@ +import { createClasses } from 'src/theme/create-classes'; + +// ---------------------------------------------------------------------- + +export const scrollbarClasses = { + root: createClasses('scrollbar__root'), +}; diff --git a/app/frontend/src/components/scrollbar/index.ts b/app/frontend/src/components/scrollbar/index.ts new file mode 100644 index 00000000..a483df68 --- /dev/null +++ b/app/frontend/src/components/scrollbar/index.ts @@ -0,0 +1,5 @@ +export * from './classes'; + +export * from './scrollbar'; + +export type * from './types'; diff --git a/app/frontend/src/components/scrollbar/scrollbar.tsx b/app/frontend/src/components/scrollbar/scrollbar.tsx new file mode 100644 index 00000000..3f1a12e4 --- /dev/null +++ b/app/frontend/src/components/scrollbar/scrollbar.tsx @@ -0,0 +1,55 @@ +import { forwardRef } from 'react'; +import SimpleBar from 'simplebar-react'; +import { mergeClasses } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; + +import { scrollbarClasses } from './classes'; + +import type { ScrollbarProps } from './types'; + +// ---------------------------------------------------------------------- + +export const Scrollbar = forwardRef((props, ref) => { + const { slotProps, children, fillContent = true, className, sx, ...other } = props; + + return ( + + {children} + + ); +}); + +// ---------------------------------------------------------------------- + +const ScrollbarRoot = styled(SimpleBar, { + shouldForwardProp: (prop: string) => !['fillContent', 'sx'].includes(prop), +})>(({ fillContent }) => ({ + minWidth: 0, + minHeight: 0, + flexGrow: 1, + display: 'flex', + flexDirection: 'column', + ...(fillContent && { + '& .simplebar-content': { + display: 'flex', + flex: '1 1 auto', + minHeight: '100%', + flexDirection: 'column', + }, + }), +})); diff --git a/app/frontend/src/components/scrollbar/styles.css b/app/frontend/src/components/scrollbar/styles.css new file mode 100644 index 00000000..2fbf4d99 --- /dev/null +++ b/app/frontend/src/components/scrollbar/styles.css @@ -0,0 +1,8 @@ +@import 'simplebar-react/dist/simplebar.min.css'; + +.simplebar-scrollbar:before { + background-color: var(--palette-text-disabled); +} +.simplebar-scrollbar.simplebar-visible:before { + opacity: 0.48; +} diff --git a/app/frontend/src/components/scrollbar/types.ts b/app/frontend/src/components/scrollbar/types.ts new file mode 100644 index 00000000..fa6577fc --- /dev/null +++ b/app/frontend/src/components/scrollbar/types.ts @@ -0,0 +1,15 @@ +import type { Theme, SxProps } from '@mui/material/styles'; +import type { Props as SimplebarProps } from 'simplebar-react'; + +// ---------------------------------------------------------------------- + +export type ScrollbarProps = SimplebarProps & { + sx?: SxProps; + children?: React.ReactNode; + fillContent?: boolean; + slotProps?: { + wrapperSx?: SxProps; + contentSx?: SxProps; + contentWrapperSx?: SxProps; + }; +}; diff --git a/app/frontend/src/components/search-not-found/index.ts b/app/frontend/src/components/search-not-found/index.ts new file mode 100644 index 00000000..7b4e68e3 --- /dev/null +++ b/app/frontend/src/components/search-not-found/index.ts @@ -0,0 +1 @@ +export * from './search-not-found'; diff --git a/app/frontend/src/components/search-not-found/search-not-found.tsx b/app/frontend/src/components/search-not-found/search-not-found.tsx new file mode 100644 index 00000000..9ee9ca28 --- /dev/null +++ b/app/frontend/src/components/search-not-found/search-not-found.tsx @@ -0,0 +1,63 @@ +import type { BoxProps } from '@mui/material/Box'; +import type { Theme, SxProps } from '@mui/material/styles'; +import type { TypographyProps } from '@mui/material/Typography'; + +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; + +// ---------------------------------------------------------------------- + +type SearchNotFoundProps = BoxProps & { + query?: string; + sx?: SxProps; + slotProps?: { + title?: TypographyProps; + description?: TypographyProps; + }; +}; + +export function SearchNotFound({ query, sx, slotProps, ...other }: SearchNotFoundProps) { + if (!query) { + return ( + + Please enter keywords + + ); + } + + return ( + + + Not found + + + + No results found for   + {`"${query}"`} + . +
          Try checking for typos or using complete words. +
          +
          + ); +} diff --git a/app/frontend/src/components/settings/context/index.ts b/app/frontend/src/components/settings/context/index.ts new file mode 100644 index 00000000..b78caa89 --- /dev/null +++ b/app/frontend/src/components/settings/context/index.ts @@ -0,0 +1,5 @@ +export * from './settings-context'; + +export * from './settings-provider'; + +export * from './use-settings-context'; diff --git a/app/frontend/src/components/settings/context/settings-context.ts b/app/frontend/src/components/settings/context/settings-context.ts new file mode 100644 index 00000000..c777a811 --- /dev/null +++ b/app/frontend/src/components/settings/context/settings-context.ts @@ -0,0 +1,9 @@ +'use client'; + +import { createContext } from 'react'; + +import type { SettingsContextValue } from '../types'; + +// ---------------------------------------------------------------------- + +export const SettingsContext = createContext(undefined); diff --git a/app/frontend/src/components/settings/context/settings-provider.tsx b/app/frontend/src/components/settings/context/settings-provider.tsx new file mode 100644 index 00000000..9af3489d --- /dev/null +++ b/app/frontend/src/components/settings/context/settings-provider.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { isEqual } from 'es-toolkit'; +import { getCookie, getStorage } from 'minimal-shared/utils'; +import { useMemo, useState, useEffect, useCallback } from 'react'; +import { useCookies, useLocalStorage } from 'minimal-shared/hooks'; + +import { SettingsContext } from './settings-context'; +import { SETTINGS_STORAGE_KEY } from '../settings-config'; + +import type { SettingsState, SettingsProviderProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function SettingsProvider({ + children, + cookieSettings, + defaultSettings, + storageKey = SETTINGS_STORAGE_KEY, +}: SettingsProviderProps) { + const isCookieEnabled = !!cookieSettings; + const useStorage = isCookieEnabled ? useCookies : useLocalStorage; + const initialSettings = isCookieEnabled ? cookieSettings : defaultSettings; + const getStorageValue = isCookieEnabled ? getCookie : getStorage; + + const { state, setState, resetState, setField } = useStorage( + storageKey, + initialSettings + ); + + const [openDrawer, setOpenDrawer] = useState(false); + + const onToggleDrawer = useCallback(() => { + setOpenDrawer((prev) => !prev); + }, []); + + const onCloseDrawer = useCallback(() => { + setOpenDrawer(false); + }, []); + + const canReset = !isEqual(state, defaultSettings); + + const onReset = useCallback(() => { + resetState(defaultSettings); + }, [defaultSettings, resetState]); + + // Version check and reset handling + useEffect(() => { + const storedValue = getStorageValue(storageKey); + + if (storedValue) { + try { + if (!storedValue.version || storedValue.version !== defaultSettings.version) { + onReset(); + } + } catch { + onReset(); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const memoizedValue = useMemo( + () => ({ + canReset, + onReset, + openDrawer, + onCloseDrawer, + onToggleDrawer, + state, + setState, + setField, + }), + [canReset, onReset, openDrawer, onCloseDrawer, onToggleDrawer, state, setField, setState] + ); + + return {children}; +} diff --git a/app/frontend/src/components/settings/context/use-settings-context.ts b/app/frontend/src/components/settings/context/use-settings-context.ts new file mode 100644 index 00000000..2bdc8a30 --- /dev/null +++ b/app/frontend/src/components/settings/context/use-settings-context.ts @@ -0,0 +1,15 @@ +'use client'; + +import { useContext } from 'react'; + +import { SettingsContext } from './settings-context'; + +// ---------------------------------------------------------------------- + +export function useSettingsContext() { + const context = useContext(SettingsContext); + + if (!context) throw new Error('useSettingsContext must be use inside SettingsProvider'); + + return context; +} diff --git a/app/frontend/src/components/settings/drawer/base-option.tsx b/app/frontend/src/components/settings/drawer/base-option.tsx new file mode 100644 index 00000000..906b903c --- /dev/null +++ b/app/frontend/src/components/settings/drawer/base-option.tsx @@ -0,0 +1,101 @@ +import type { ButtonBaseProps } from '@mui/material/ButtonBase'; + +import { varAlpha } from 'minimal-shared/utils'; + +import Switch from '@mui/material/Switch'; +import Tooltip from '@mui/material/Tooltip'; +import { styled } from '@mui/material/styles'; +import ButtonBase from '@mui/material/ButtonBase'; + +import { CONFIG } from 'src/global-config'; + +import { Iconify } from 'src/components/iconify'; + +import { SvgColor } from '../../svg-color'; + +// ---------------------------------------------------------------------- + +export type BaseOptionProps = ButtonBaseProps & { + icon: string; + label: string; + tooltip?: string; + selected: boolean; + onChangeOption: () => void; +}; + +export function BaseOption({ + sx, + icon, + label, + tooltip, + selected, + onChangeOption, + ...other +}: BaseOptionProps) { + return ( + + + + + + + + {label} + + {tooltip && ( + + + + )} + + + ); +} + +// ---------------------------------------------------------------------- + +const ItemRoot = styled(ButtonBase, { + shouldForwardProp: (prop: string) => !['selected', 'sx'].includes(prop), +})<{ selected: boolean }>(({ selected, theme }) => ({ + cursor: 'pointer', + flexDirection: 'column', + alignItems: 'flex-start', + padding: theme.spacing(2, 2.5), + borderRadius: theme.shape.borderRadius * 2, + border: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.12)}`, + '&:hover': { + backgroundColor: varAlpha(theme.vars.palette.grey['500Channel'], 0.08), + }, + ...(selected && { + backgroundColor: varAlpha(theme.vars.palette.grey['500Channel'], 0.08), + }), +})); + +const TopContainer = styled('div')(({ theme }) => ({ + width: '100%', + display: 'flex', + alignItems: 'center', + marginBottom: theme.spacing(3), + justifyContent: 'space-between', +})); + +const BottomContainer = styled('div')(({ theme }) => ({ + width: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', +})); + +const ItemLabel = styled('span')(({ theme }) => ({ + lineHeight: '18px', + fontSize: theme.typography.pxToRem(13), + fontWeight: theme.typography.fontWeightSemiBold, +})); diff --git a/app/frontend/src/components/settings/drawer/font-options.tsx b/app/frontend/src/components/settings/drawer/font-options.tsx new file mode 100644 index 00000000..940b7ea8 --- /dev/null +++ b/app/frontend/src/components/settings/drawer/font-options.tsx @@ -0,0 +1,114 @@ +import type { BoxProps } from '@mui/material/Box'; +import type { SliderProps } from '@mui/material/Slider'; + +import { setFont } from 'minimal-shared/utils'; + +import Box from '@mui/material/Box'; +import Slider, { sliderClasses } from '@mui/material/Slider'; + +import { CONFIG } from 'src/global-config'; + +import { OptionButton } from './styles'; +import { SvgColor } from '../../svg-color'; + +import type { SettingsState } from '../types'; + +// ---------------------------------------------------------------------- + +export type FontFamilyOptionsProps = BoxProps & { + options: string[]; + value: SettingsState['fontFamily']; + onChangeOption: (newOption: string) => void; +}; + +export function FontFamilyOptions({ + sx, + value, + options, + onChangeOption, + ...other +}: FontFamilyOptionsProps) { + return ( + ({ + gap: 1.5, + display: 'grid', + gridTemplateColumns: 'repeat(2, 1fr)', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + {options.map((option) => { + const selected = value === option; + + return ( + onChangeOption(option)} + sx={(theme) => ({ + py: 2, + gap: 0.75, + flexDirection: 'column', + fontFamily: setFont(option), + fontSize: theme.typography.pxToRem(12), + })} + > + + + {option.endsWith('Variable') ? option.replace(' Variable', '') : option} + + ); + })} + + ); +} + +// ---------------------------------------------------------------------- + +export type FontSizeOptionsProps = SliderProps & { + options: [number, number]; + value: SettingsState['fontSize']; + onChangeOption: (newOption: number) => void; +}; + +export function FontSizeOptions({ + sx, + value, + options, + onChangeOption, + ...other +}: FontSizeOptionsProps) { + return ( + `${val}px`} + value={value} + min={options[0]} + max={options[1]} + onChange={(event: Event, newOption: number | number[]) => onChangeOption(newOption as number)} + sx={[ + (theme) => ({ + [`& .${sliderClasses.rail}`]: { + height: 12, + }, + [`& .${sliderClasses.track}`]: { + height: 12, + background: `linear-gradient(135deg, ${theme.vars.palette.primary.light}, ${theme.vars.palette.primary.dark})`, + }, + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + /> + ); +} diff --git a/app/frontend/src/components/settings/drawer/fullscreen-button.tsx b/app/frontend/src/components/settings/drawer/fullscreen-button.tsx new file mode 100644 index 00000000..53ff06a7 --- /dev/null +++ b/app/frontend/src/components/settings/drawer/fullscreen-button.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { useState, useCallback } from 'react'; + +import Tooltip from '@mui/material/Tooltip'; +import IconButton from '@mui/material/IconButton'; + +import { Iconify } from 'src/components/iconify'; + +// ---------------------------------------------------------------------- + +export function FullScreenButton() { + const [fullscreen, setFullscreen] = useState(false); + + const onToggleFullScreen = useCallback(() => { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen(); + setFullscreen(true); + } else if (document.exitFullscreen) { + document.exitFullscreen(); + setFullscreen(false); + } + }, []); + + return ( + + + + + + ); +} diff --git a/app/frontend/src/components/settings/drawer/index.ts b/app/frontend/src/components/settings/drawer/index.ts new file mode 100644 index 00000000..6bf08164 --- /dev/null +++ b/app/frontend/src/components/settings/drawer/index.ts @@ -0,0 +1 @@ +export * from './settings-drawer'; diff --git a/app/frontend/src/components/settings/drawer/nav-layout-option.tsx b/app/frontend/src/components/settings/drawer/nav-layout-option.tsx new file mode 100644 index 00000000..35ceb384 --- /dev/null +++ b/app/frontend/src/components/settings/drawer/nav-layout-option.tsx @@ -0,0 +1,113 @@ +import type { BoxProps } from '@mui/material/Box'; + +import { varAlpha } from 'minimal-shared/utils'; + +import Box from '@mui/material/Box'; + +import { CONFIG } from 'src/global-config'; + +import { OptionButton } from './styles'; +import { SvgColor } from '../../svg-color'; + +import type { SettingsState } from '../types'; + +// ---------------------------------------------------------------------- + +export type NavLayoutOptionProps = BoxProps & { + value: SettingsState['navLayout']; + options: SettingsState['navLayout'][]; + onChangeOption: (newOption: SettingsState['navLayout']) => void; +}; + +export function NavLayoutOptions({ + sx, + value, + options, + onChangeOption, + ...other +}: NavLayoutOptionProps) { + return ( + ({ + gap: 1.5, + display: 'grid', + gridTemplateColumns: 'repeat(3, 1fr)', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + {options.map((option) => { + const selected = value === option; + + return ( + onChangeOption(option)} + sx={[ + (theme) => ({ + height: 64, + border: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.08)}`, + }), + ]} + > + + + ); + })} + + ); +} + +// ---------------------------------------------------------------------- + +export type NavColorOptionProps = BoxProps & { + value: SettingsState['navColor']; + options: SettingsState['navColor'][]; + onChangeOption: (newOption: SettingsState['navColor']) => void; +}; + +export function NavColorOptions({ + sx, + value, + options, + onChangeOption, + ...other +}: NavColorOptionProps) { + return ( + ({ + gap: 1.5, + display: 'grid', + gridTemplateColumns: 'repeat(2, 1fr)', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + {options.map((option) => { + const selected = value === option; + + return ( + onChangeOption(option)} + sx={{ gap: 1.5, height: 56, textTransform: 'capitalize' }} + > + + {option} + + ); + })} + + ); +} diff --git a/app/frontend/src/components/settings/drawer/presets-options.tsx b/app/frontend/src/components/settings/drawer/presets-options.tsx new file mode 100644 index 00000000..10632dc9 --- /dev/null +++ b/app/frontend/src/components/settings/drawer/presets-options.tsx @@ -0,0 +1,64 @@ +import type { BoxProps } from '@mui/material/Box'; + +import Box from '@mui/material/Box'; +import { alpha as hexAlpha } from '@mui/material/styles'; + +import { CONFIG } from 'src/global-config'; + +import { OptionButton } from './styles'; +import { SvgColor } from '../../svg-color'; + +import type { SettingsState } from '../types'; + +// ---------------------------------------------------------------------- + +export type PresetsOptionsProps = BoxProps & { + value: SettingsState['primaryColor']; + options: { name: SettingsState['primaryColor']; value: string }[]; + onChangeOption: (newOption: SettingsState['primaryColor']) => void; +}; + +export function PresetsOptions({ + sx, + value, + options, + onChangeOption, + ...other +}: PresetsOptionsProps) { + return ( + ({ + gap: 1.5, + display: 'grid', + gridTemplateColumns: 'repeat(3, 1fr)', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + {options.map((option) => { + const selected = value === option.name; + + return ( + onChangeOption(option.name)} + sx={{ + height: 64, + color: option.value, + ...(selected && { + bgcolor: hexAlpha(option.value, 0.08), + }), + }} + > + + + ); + })} + + ); +} diff --git a/app/frontend/src/components/settings/drawer/settings-drawer.tsx b/app/frontend/src/components/settings/drawer/settings-drawer.tsx new file mode 100644 index 00000000..63b6b375 --- /dev/null +++ b/app/frontend/src/components/settings/drawer/settings-drawer.tsx @@ -0,0 +1,270 @@ +'use client'; + +import type { ThemeColorScheme } from 'src/theme/types'; + +import { useEffect, useCallback } from 'react'; +import { hasKeys, varAlpha } from 'minimal-shared/utils'; + +import Box from '@mui/material/Box'; +import Badge from '@mui/material/Badge'; +import Drawer from '@mui/material/Drawer'; +import Tooltip from '@mui/material/Tooltip'; +import IconButton from '@mui/material/IconButton'; +import Typography from '@mui/material/Typography'; +import { useColorScheme } from '@mui/material/styles'; + +import { themeConfig } from 'src/theme/theme-config'; +import { primaryColorPresets } from 'src/theme/with-settings'; + +import { Iconify } from '../../iconify'; +import { BaseOption } from './base-option'; +import { Scrollbar } from '../../scrollbar'; +import { SmallBlock, LargeBlock } from './styles'; +import { PresetsOptions } from './presets-options'; +import { FullScreenButton } from './fullscreen-button'; +import { FontSizeOptions, FontFamilyOptions } from './font-options'; +import { useSettingsContext } from '../context/use-settings-context'; +import { NavColorOptions, NavLayoutOptions } from './nav-layout-option'; + +import type { SettingsState, SettingsDrawerProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function SettingsDrawer({ sx, defaultSettings }: SettingsDrawerProps) { + const settings = useSettingsContext(); + + const { mode, setMode, systemMode } = useColorScheme(); + + useEffect(() => { + if (mode === 'system' && systemMode) { + settings.setState({ colorScheme: systemMode }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode, systemMode]); + + // Visible options by default settings + const isFontFamilyVisible = hasKeys(defaultSettings, ['fontFamily']); + const isCompactLayoutVisible = hasKeys(defaultSettings, ['compactLayout']); + const isDirectionVisible = hasKeys(defaultSettings, ['direction']); + const isColorSchemeVisible = hasKeys(defaultSettings, ['colorScheme']); + const isContrastVisible = hasKeys(defaultSettings, ['contrast']); + const isNavColorVisible = hasKeys(defaultSettings, ['navColor']); + const isNavLayoutVisible = hasKeys(defaultSettings, ['navLayout']); + const isPrimaryColorVisible = hasKeys(defaultSettings, ['primaryColor']); + const isFontSizeVisible = hasKeys(defaultSettings, ['fontSize']); + + const handleReset = useCallback(() => { + settings.onReset(); + setMode(defaultSettings.colorScheme as ThemeColorScheme); + }, [defaultSettings.colorScheme, setMode, settings]); + + const renderHead = () => ( + + + Settings + + + + + + + + + + + + + + + + + + + ); + + const renderMode = () => ( + { + setMode(mode === 'light' ? 'dark' : 'light'); + settings.setState({ colorScheme: mode === 'light' ? 'dark' : 'light' }); + }} + /> + ); + + const renderContrast = () => ( + + settings.setState({ + contrast: settings.state.contrast === 'default' ? 'hight' : 'default', + }) + } + /> + ); + + const renderRtl = () => ( + + settings.setState({ direction: settings.state.direction === 'ltr' ? 'rtl' : 'ltr' }) + } + /> + ); + + const renderCompact = () => ( + settings.setState({ compactLayout: !settings.state.compactLayout })} + /> + ); + + const renderPresets = () => ( + settings.setState({ primaryColor: defaultSettings.primaryColor })} + > + ({ + name: key, + value: primaryColorPresets[key].main, + })) as { name: SettingsState['primaryColor']; value: string }[] + } + value={settings.state.primaryColor} + onChangeOption={(newOption) => settings.setState({ primaryColor: newOption })} + /> + + ); + + const renderNav = () => ( + + {isNavLayoutVisible && ( + settings.setState({ navLayout: defaultSettings.navLayout })} + > + settings.setState({ navLayout: newOption })} + /> + + )} + {isNavColorVisible && ( + settings.setState({ navColor: defaultSettings.navColor })} + > + settings.setState({ navColor: newOption })} + /> + + )} + + ); + + const renderFont = () => ( + + {isFontFamilyVisible && ( + settings.setState({ fontFamily: defaultSettings.fontFamily })} + > + settings.setState({ fontFamily: newOption })} + /> + + )} + {isFontSizeVisible && ( + settings.setState({ fontSize: defaultSettings.fontSize })} + sx={{ gap: 5 }} + > + settings.setState({ fontSize: newOption })} + /> + + )} + + ); + + return ( + ({ + ...theme.mixins.paperStyles(theme, { + color: varAlpha(theme.vars.palette.background.defaultChannel, 0.9), + }), + width: 360, + }), + ...(Array.isArray(sx) ? sx : [sx]), + ], + }} + > + {renderHead()} + + + + + {isColorSchemeVisible && renderMode()} + {isContrastVisible && renderContrast()} + {isDirectionVisible && renderRtl()} + {isCompactLayoutVisible && renderCompact()} + + + {(isNavColorVisible || isNavLayoutVisible) && renderNav()} + {isPrimaryColorVisible && renderPresets()} + {(isFontFamilyVisible || isFontSizeVisible) && renderFont()} + + + + ); +} diff --git a/app/frontend/src/components/settings/drawer/styles.tsx b/app/frontend/src/components/settings/drawer/styles.tsx new file mode 100644 index 00000000..8bf0c81f --- /dev/null +++ b/app/frontend/src/components/settings/drawer/styles.tsx @@ -0,0 +1,174 @@ +import type { ButtonBaseProps } from '@mui/material/ButtonBase'; + +import { varAlpha } from 'minimal-shared/utils'; + +import Tooltip from '@mui/material/Tooltip'; +import { styled } from '@mui/material/styles'; +import ButtonBase from '@mui/material/ButtonBase'; + +import { Iconify } from '../../iconify'; +import { svgColorClasses } from '../../svg-color'; + +// ---------------------------------------------------------------------- + +type LargeBlockProps = React.ComponentProps & { + title: string; + tooltip?: string; + canReset?: boolean; + onReset?: () => void; +}; + +const LargeBlockRoot = styled('div')(({ theme }) => ({ + display: 'flex', + position: 'relative', + flexDirection: 'column', + padding: theme.spacing(4, 2, 2, 2), + borderRadius: theme.shape.borderRadius * 2, + border: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.12)}`, +})); + +const LargeLabel = styled('span')(({ theme }) => ({ + top: -12, + lineHeight: '22px', + borderRadius: '22px', + position: 'absolute', + alignItems: 'center', + display: 'inline-flex', + padding: theme.spacing(0, 1.25), + fontSize: theme.typography.pxToRem(13), + color: theme.vars.palette.common.white, + fontWeight: theme.typography.fontWeightSemiBold, + backgroundColor: theme.vars.palette.text.primary, + ...theme.applyStyles('dark', { + color: theme.vars.palette.grey[800], + }), +})); + +export function LargeBlock({ + sx, + title, + tooltip, + children, + canReset, + onReset, + ...other +}: LargeBlockProps) { + return ( + + + {canReset && ( + + + + )} + {title} + {tooltip && ( + + + + )} + + + {children} + + ); +} + +// ---------------------------------------------------------------------- + +type SmallBlockProps = React.ComponentProps & { + label: string; + canReset?: boolean; + onReset?: () => void; +}; + +const SmallBlockRoot = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1.25), +})); + +const SmallLabel = styled(ButtonBase, { + shouldForwardProp: (prop: string) => !['canReset', 'sx'].includes(prop), +})<{ canReset?: boolean }>(({ theme }) => ({ + cursor: 'default', + lineHeight: '16px', + pointerEvent: 'none', + alignSelf: 'flex-start', + gap: theme.spacing(0.25), + fontSize: theme.typography.pxToRem(11), + color: theme.vars.palette.text.secondary, + fontWeight: theme.typography.fontWeightSemiBold, + transition: theme.transitions.create(['color']), + variants: [ + { + props: { canReset: true }, + style: { + cursor: 'pointer', + pointerEvent: 'auto', + color: theme.vars.palette.text.primary, + fontWeight: theme.typography.fontWeightBold, + '&:hover': { + color: theme.vars.palette.primary.main, + }, + }, + }, + ], +})); + +export function SmallBlock({ label, canReset, onReset, sx, children, ...other }: SmallBlockProps) { + return ( + + + {canReset && } + {label} + + {children} + + ); +} + +// ---------------------------------------------------------------------- + +export type OptionButtonProps = ButtonBaseProps & { + selected?: boolean; +}; + +export function OptionButton({ selected, sx, children, ...other }: OptionButtonProps) { + return ( + ({ + width: 1, + borderRadius: 1.5, + lineHeight: '18px', + color: 'text.disabled', + border: `solid 1px transparent`, + fontWeight: 'fontWeightSemiBold', + fontSize: theme.typography.pxToRem(13), + ...(selected && { + color: 'text.primary', + bgcolor: 'background.paper', + borderColor: varAlpha(theme.vars.palette.grey['500Channel'], 0.08), + boxShadow: `-8px 8px 20px -4px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.12)}`, + ...theme.applyStyles('dark', { + boxShadow: `-8px 8px 20px -4px ${varAlpha(theme.vars.palette.common.blackChannel, 0.12)}`, + }), + [`& .${svgColorClasses.root}`]: { + background: `linear-gradient(135deg, ${theme.vars.palette.primary.light}, ${theme.vars.palette.primary.main})`, + }, + }), + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + {children} + + ); +} diff --git a/app/frontend/src/components/settings/index.ts b/app/frontend/src/components/settings/index.ts new file mode 100644 index 00000000..d8becc4c --- /dev/null +++ b/app/frontend/src/components/settings/index.ts @@ -0,0 +1,7 @@ +export * from './drawer'; + +export * from './context'; + +export * from './settings-config'; + +export type * from './types'; diff --git a/app/frontend/src/components/settings/server.ts b/app/frontend/src/components/settings/server.ts new file mode 100644 index 00000000..9dd03176 --- /dev/null +++ b/app/frontend/src/components/settings/server.ts @@ -0,0 +1,17 @@ +import { cookies } from 'next/headers'; + +import { defaultSettings, SETTINGS_STORAGE_KEY } from './settings-config'; + +import type { SettingsState } from './types'; + +// ---------------------------------------------------------------------- + +export async function detectSettings( + storageKey: string = SETTINGS_STORAGE_KEY +): Promise { + const cookieStore = cookies(); + + const settingsStore = cookieStore.get(storageKey); + + return settingsStore ? JSON.parse(settingsStore?.value) : defaultSettings; +} diff --git a/app/frontend/src/components/settings/settings-config.ts b/app/frontend/src/components/settings/settings-config.ts new file mode 100644 index 00000000..a837d008 --- /dev/null +++ b/app/frontend/src/components/settings/settings-config.ts @@ -0,0 +1,21 @@ +import { CONFIG } from 'src/global-config'; +import { themeConfig } from 'src/theme/theme-config'; + +import type { SettingsState } from './types'; + +// ---------------------------------------------------------------------- + +export const SETTINGS_STORAGE_KEY: string = 'app-settings'; + +export const defaultSettings: SettingsState = { + colorScheme: themeConfig.defaultMode, + direction: themeConfig.direction, + contrast: 'default', + navLayout: 'vertical', + primaryColor: 'default', + navColor: 'integrate', + compactLayout: true, + fontSize: 16, + fontFamily: themeConfig.fontFamily.primary, + version: CONFIG.appVersion, +}; diff --git a/app/frontend/src/components/settings/types.ts b/app/frontend/src/components/settings/types.ts new file mode 100644 index 00000000..ce3a5ec2 --- /dev/null +++ b/app/frontend/src/components/settings/types.ts @@ -0,0 +1,41 @@ +import type { Theme, SxProps } from '@mui/material/styles'; +import type { ThemeDirection, ThemeColorScheme } from 'src/theme/types'; + +// ---------------------------------------------------------------------- + +export type SettingsState = { + version?: string; + fontSize?: number; + fontFamily?: string; + compactLayout?: boolean; + direction?: ThemeDirection; + colorScheme?: ThemeColorScheme; + contrast?: 'default' | 'hight'; + navColor?: 'integrate' | 'apparent'; + navLayout?: 'vertical' | 'horizontal' | 'mini'; + primaryColor?: 'default' | 'preset1' | 'preset2' | 'preset3' | 'preset4' | 'preset5'; +}; + +export type SettingsContextValue = { + state: SettingsState; + canReset: boolean; + onReset: () => void; + setState: (updateValue: Partial) => void; + setField: (name: keyof SettingsState, updateValue: SettingsState[keyof SettingsState]) => void; + // Drawer + openDrawer: boolean; + onCloseDrawer: () => void; + onToggleDrawer: () => void; +}; + +export type SettingsProviderProps = { + cookieSettings?: SettingsState; + defaultSettings: SettingsState; + children: React.ReactNode; + storageKey?: string; +}; + +export type SettingsDrawerProps = { + sx?: SxProps; + defaultSettings: SettingsState; +}; diff --git a/app/frontend/src/components/snackbar/classes.ts b/app/frontend/src/components/snackbar/classes.ts new file mode 100644 index 00000000..87ce83e3 --- /dev/null +++ b/app/frontend/src/components/snackbar/classes.ts @@ -0,0 +1,27 @@ +import { createClasses } from 'src/theme/create-classes'; + +// ---------------------------------------------------------------------- + +export const snackbarClasses = { + root: createClasses('snackbar__root'), + toast: createClasses('snackbar__toast'), + title: createClasses('snackbar__title'), + icon: createClasses('snackbar__icon'), + iconSvg: createClasses('snackbar__icon__svg'), + content: createClasses('snackbar__content'), + description: createClasses('snackbar__description'), + actionButton: createClasses('snackbar__action__button'), + cancelButton: createClasses('snackbar__cancel__button'), + closeButton: createClasses('snackbar__close_button'), + loadingIcon: createClasses('snackbar__loading_icon'), + /********/ + default: createClasses('snackbar__default'), + error: createClasses('snackbar__error'), + success: createClasses('snackbar__success'), + warning: createClasses('snackbar__warning'), + info: createClasses('snackbar__info'), + /********/ + loader: 'sonner-loader', + loaderVisible: '&[data-visible="true"]', + closeBtnVisible: '[data-close-button="true"]', +}; diff --git a/app/frontend/src/components/snackbar/index.ts b/app/frontend/src/components/snackbar/index.ts new file mode 100644 index 00000000..801d8ecd --- /dev/null +++ b/app/frontend/src/components/snackbar/index.ts @@ -0,0 +1,3 @@ +export * from 'sonner'; + +export * from './snackbar'; diff --git a/app/frontend/src/components/snackbar/snackbar.tsx b/app/frontend/src/components/snackbar/snackbar.tsx new file mode 100644 index 00000000..62456fc6 --- /dev/null +++ b/app/frontend/src/components/snackbar/snackbar.tsx @@ -0,0 +1,55 @@ +'use client'; + +import Portal from '@mui/material/Portal'; + +import { Iconify } from '../iconify'; +import { SnackbarRoot } from './styles'; +import { snackbarClasses } from './classes'; + +// ---------------------------------------------------------------------- + +export function Snackbar() { + return ( + + , + info: , + success: , + warning: ( + + ), + error: , + }} + /> + + ); +} diff --git a/app/frontend/src/components/snackbar/styles.tsx b/app/frontend/src/components/snackbar/styles.tsx new file mode 100644 index 00000000..acab8b16 --- /dev/null +++ b/app/frontend/src/components/snackbar/styles.tsx @@ -0,0 +1,165 @@ +import type { CSSObject } from '@mui/material/styles'; + +import { Toaster } from 'sonner'; +import { varAlpha } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; + +import { snackbarClasses } from './classes'; + +// ---------------------------------------------------------------------- + +export const SnackbarRoot = styled(Toaster)(({ theme }) => { + const baseStyles: Record = { + toastDefault: { + padding: theme.spacing(1, 1, 1, 1.5), + boxShadow: theme.vars.customShadows.z8, + color: theme.vars.palette.background.paper, + backgroundColor: theme.vars.palette.text.primary, + }, + toastColor: { + padding: theme.spacing(0.5, 1, 0.5, 0.5), + boxShadow: theme.vars.customShadows.z8, + color: theme.vars.palette.text.primary, + backgroundColor: theme.vars.palette.background.paper, + }, + toastLoader: { + padding: theme.spacing(0.5, 1, 0.5, 0.5), + boxShadow: theme.vars.customShadows.z8, + color: theme.vars.palette.text.primary, + backgroundColor: theme.vars.palette.background.paper, + }, + }; + + const loadingStyles: CSSObject = { + top: 0, + left: 0, + width: '100%', + height: '100%', + display: 'none', + transform: 'none', + overflow: 'hidden', + alignItems: 'center', + position: 'relative', + borderRadius: 'inherit', + justifyContent: 'center', + background: theme.vars.palette.background.neutral, + [`& .${snackbarClasses.loadingIcon}`]: { + zIndex: 9, + width: 24, + height: 24, + borderRadius: '50%', + animation: 'rotate 3s infinite linear', + background: `conic-gradient(transparent, ${varAlpha(theme.vars.palette.text.disabledChannel, 0.64)})`, + }, + [snackbarClasses.loaderVisible]: { display: 'flex' }, + }; + + return { + width: 300, + [`& .${snackbarClasses.toast}`]: { + gap: 12, + width: '100%', + minHeight: 52, + display: 'flex', + borderRadius: 12, + alignItems: 'center', + }, + /** + * Content + */ + [`& .${snackbarClasses.content}`]: { gap: 0, flex: '1 1 auto' }, + [`& .${snackbarClasses.title}`]: { fontSize: theme.typography.subtitle2.fontSize }, + [`& .${snackbarClasses.description}`]: { ...theme.typography.caption, opacity: 0.64 }, + /** + * Buttons + */ + [`& .${snackbarClasses.actionButton}`]: {}, + [`& .${snackbarClasses.cancelButton}`]: {}, + [`& .${snackbarClasses.closeButton}`]: { + top: 0, + right: 0, + left: 'auto', + color: 'currentColor', + backgroundColor: 'transparent', + transform: 'translate(-6px, 6px)', + borderColor: varAlpha(theme.vars.palette.grey['500Channel'], 0.16), + transition: theme.transitions.create(['background-color', 'border-color']), + '&:hover': { + borderColor: varAlpha(theme.vars.palette.grey['500Channel'], 0.24), + backgroundColor: varAlpha(theme.vars.palette.grey['500Channel'], 0.08), + }, + }, + /** + * Icon + */ + [`& .${snackbarClasses.icon}`]: { + margin: 0, + width: 48, + height: 48, + alignItems: 'center', + borderRadius: 'inherit', + justifyContent: 'center', + alignSelf: 'flex-start', + [`& .${snackbarClasses.iconSvg}`]: { width: 24, height: 24, fontSize: 0 }, + }, + + '@keyframes rotate': { to: { transform: 'rotate(1turn)' } }, + + /** + * @variant default + */ + [`& .${snackbarClasses.default}`]: { + ...baseStyles.toastDefault, + [`&:has(${snackbarClasses.closeBtnVisible})`]: { + [`& .${snackbarClasses.content}`]: { paddingRight: 32 }, + }, + [`&:has(.${snackbarClasses.loader})`]: baseStyles.toastLoader, + /** + * @with loader + */ + [`&:has(.${snackbarClasses.loader})`]: baseStyles.toastLoader, + [`& .${snackbarClasses.loader}`]: loadingStyles, + }, + /** + * @variant error + */ + [`& .${snackbarClasses.error}`]: { + ...baseStyles.toastColor, + [`& .${snackbarClasses.icon}`]: { + color: theme.vars.palette.error.main, + backgroundColor: varAlpha(theme.vars.palette.error.mainChannel, 0.08), + }, + }, + /** + * @variant success + */ + [`& .${snackbarClasses.success}`]: { + ...baseStyles.toastColor, + [`& .${snackbarClasses.icon}`]: { + color: theme.vars.palette.success.main, + backgroundColor: varAlpha(theme.vars.palette.success.mainChannel, 0.08), + }, + }, + /** + * @variant warning + */ + [`& .${snackbarClasses.warning}`]: { + ...baseStyles.toastColor, + [`& .${snackbarClasses.icon}`]: { + color: theme.vars.palette.warning.main, + backgroundColor: varAlpha(theme.vars.palette.warning.mainChannel, 0.08), + }, + }, + /** + * @variant info + */ + [`& .${snackbarClasses.info}`]: { + ...baseStyles.toastColor, + [`& .${snackbarClasses.icon}`]: { + color: theme.vars.palette.info.main, + backgroundColor: varAlpha(theme.vars.palette.info.mainChannel, 0.08), + }, + }, + }; +}); diff --git a/app/frontend/src/components/svg-color/classes.ts b/app/frontend/src/components/svg-color/classes.ts new file mode 100644 index 00000000..319b076f --- /dev/null +++ b/app/frontend/src/components/svg-color/classes.ts @@ -0,0 +1,7 @@ +import { createClasses } from 'src/theme/create-classes'; + +// ---------------------------------------------------------------------- + +export const svgColorClasses = { + root: createClasses('svg__color__root'), +}; diff --git a/app/frontend/src/components/svg-color/index.ts b/app/frontend/src/components/svg-color/index.ts new file mode 100644 index 00000000..372c31dd --- /dev/null +++ b/app/frontend/src/components/svg-color/index.ts @@ -0,0 +1,5 @@ +export * from './classes'; + +export * from './svg-color'; + +export type * from './types'; diff --git a/app/frontend/src/components/svg-color/svg-color.tsx b/app/frontend/src/components/svg-color/svg-color.tsx new file mode 100644 index 00000000..27a4a79f --- /dev/null +++ b/app/frontend/src/components/svg-color/svg-color.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { forwardRef } from 'react'; +import { mergeClasses } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; + +import { svgColorClasses } from './classes'; + +import type { SvgColorProps } from './types'; + +// ---------------------------------------------------------------------- + +export const SvgColor = forwardRef((props, ref) => { + const { src, className, sx, ...other } = props; + + return ( + ({ + mask: `url(${src}) no-repeat center / contain`, + WebkitMask: `url(${src}) no-repeat center / contain`, + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + /> + ); +}); + +// ---------------------------------------------------------------------- + +const SvgRoot = styled('span')(() => ({ + width: 24, + height: 24, + flexShrink: 0, + display: 'inline-flex', + backgroundColor: 'currentColor', +})); diff --git a/app/frontend/src/components/svg-color/types.ts b/app/frontend/src/components/svg-color/types.ts new file mode 100644 index 00000000..c790c87e --- /dev/null +++ b/app/frontend/src/components/svg-color/types.ts @@ -0,0 +1,8 @@ +import type { Theme, SxProps } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +export type SvgColorProps = React.ComponentProps<'span'> & { + src: string; + sx?: SxProps; +}; diff --git a/app/frontend/src/components/table/index.ts b/app/frontend/src/components/table/index.ts new file mode 100644 index 00000000..fb69e2b0 --- /dev/null +++ b/app/frontend/src/components/table/index.ts @@ -0,0 +1,15 @@ +export * from './utils'; + +export * from './use-table'; + +export * from './table-no-data'; + +export * from './table-skeleton'; + +export * from './table-empty-rows'; + +export * from './table-head-custom'; + +export * from './table-selected-action'; + +export * from './table-pagination-custom'; diff --git a/app/frontend/src/components/table/table-empty-rows.tsx b/app/frontend/src/components/table/table-empty-rows.tsx new file mode 100644 index 00000000..4ba286f8 --- /dev/null +++ b/app/frontend/src/components/table/table-empty-rows.tsx @@ -0,0 +1,31 @@ +import type { TableRowProps } from '@mui/material/TableRow'; + +import TableRow from '@mui/material/TableRow'; +import TableCell from '@mui/material/TableCell'; + +// ---------------------------------------------------------------------- + +export type TableEmptyRowsProps = TableRowProps & { + height?: number; + emptyRows: number; +}; + +export function TableEmptyRows({ emptyRows, height, sx, ...other }: TableEmptyRowsProps) { + if (!emptyRows) { + return null; + } + + return ( + ({ + ...(height && { height: height * emptyRows }), + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + + + ); +} diff --git a/app/frontend/src/components/table/table-head-custom.tsx b/app/frontend/src/components/table/table-head-custom.tsx new file mode 100644 index 00000000..52d50159 --- /dev/null +++ b/app/frontend/src/components/table/table-head-custom.tsx @@ -0,0 +1,107 @@ +import type { Theme, SxProps, CSSObject } from '@mui/material/styles'; + +import Box from '@mui/material/Box'; +import TableRow from '@mui/material/TableRow'; +import Checkbox from '@mui/material/Checkbox'; +import TableHead from '@mui/material/TableHead'; +import TableCell from '@mui/material/TableCell'; +import TableSortLabel from '@mui/material/TableSortLabel'; + +// ---------------------------------------------------------------------- + +const visuallyHidden: CSSObject = { + border: 0, + padding: 0, + width: '1px', + height: '1px', + margin: '-1px', + overflow: 'hidden', + position: 'absolute', + whiteSpace: 'nowrap', + clip: 'rect(0 0 0 0)', +}; + +// ---------------------------------------------------------------------- + +export type TableHeadCellProps = { + id: string; + label?: string; + width?: CSSObject['width']; + align?: 'left' | 'center' | 'right'; + sx?: SxProps; +}; + +export type TableHeadCustomProps = { + orderBy?: string; + rowCount?: number; + sx?: SxProps; + numSelected?: number; + order?: 'asc' | 'desc'; + headCells: TableHeadCellProps[]; + onSort?: (id: string) => void; + onSelectAllRows?: (checked: boolean) => void; +}; + +export function TableHeadCustom({ + sx, + order, + onSort, + orderBy, + headCells, + rowCount = 0, + numSelected = 0, + onSelectAllRows, +}: TableHeadCustomProps) { + return ( + + + {onSelectAllRows && ( + + ) => + onSelectAllRows(event.target.checked) + } + inputProps={{ + id: `all-row-checkbox`, + 'aria-label': `All row Checkbox`, + }} + /> + + )} + + {headCells.map((headCell) => ( + + {onSort ? ( + onSort(headCell.id)} + > + {headCell.label} + + {orderBy === headCell.id ? ( + + {order === 'desc' ? 'sorted descending' : 'sorted ascending'} + + ) : null} + + ) : ( + headCell.label + )} + + ))} + + + ); +} diff --git a/app/frontend/src/components/table/table-no-data.tsx b/app/frontend/src/components/table/table-no-data.tsx new file mode 100644 index 00000000..e10bc1b1 --- /dev/null +++ b/app/frontend/src/components/table/table-no-data.tsx @@ -0,0 +1,27 @@ +import type { Theme, SxProps } from '@mui/material/styles'; + +import TableRow from '@mui/material/TableRow'; +import TableCell from '@mui/material/TableCell'; + +import { EmptyContent } from '../empty-content'; + +// ---------------------------------------------------------------------- + +export type TableNoDataProps = { + notFound: boolean; + sx?: SxProps; +}; + +export function TableNoData({ notFound, sx }: TableNoDataProps) { + return ( + + {notFound ? ( + + + + ) : ( + + )} + + ); +} diff --git a/app/frontend/src/components/table/table-pagination-custom.tsx b/app/frontend/src/components/table/table-pagination-custom.tsx new file mode 100644 index 00000000..918cc8cd --- /dev/null +++ b/app/frontend/src/components/table/table-pagination-custom.tsx @@ -0,0 +1,49 @@ +import type { Theme, SxProps } from '@mui/material/styles'; +import type { TablePaginationProps } from '@mui/material/TablePagination'; + +import Box from '@mui/material/Box'; +import Switch from '@mui/material/Switch'; +import TablePagination from '@mui/material/TablePagination'; +import FormControlLabel from '@mui/material/FormControlLabel'; + +// ---------------------------------------------------------------------- + +export type TablePaginationCustomProps = TablePaginationProps & { + dense?: boolean; + sx?: SxProps; + onChangeDense?: (event: React.ChangeEvent) => void; +}; + +export function TablePaginationCustom({ + sx, + dense, + onChangeDense, + rowsPerPageOptions = [5, 10, 25], + ...other +}: TablePaginationCustomProps) { + return ( + + + + {onChangeDense && ( + + } + sx={{ + pl: 2, + py: 1.5, + top: 0, + position: { sm: 'absolute' }, + }} + /> + )} + + ); +} diff --git a/app/frontend/src/components/table/table-selected-action.tsx b/app/frontend/src/components/table/table-selected-action.tsx new file mode 100644 index 00000000..337c23cf --- /dev/null +++ b/app/frontend/src/components/table/table-selected-action.tsx @@ -0,0 +1,78 @@ +import type { BoxProps } from '@mui/material/Box'; + +import Box from '@mui/material/Box'; +import Checkbox from '@mui/material/Checkbox'; +import Typography from '@mui/material/Typography'; + +// ---------------------------------------------------------------------- + +export type TableSelectedActionProps = BoxProps & { + dense?: boolean; + rowCount: number; + numSelected: number; + action?: React.ReactNode; + onSelectAllRows: (checked: boolean) => void; +}; + +export function TableSelectedAction({ + sx, + dense, + action, + rowCount, + numSelected, + onSelectAllRows, + ...other +}: TableSelectedActionProps) { + if (!numSelected) { + return null; + } + + return ( + ({ + pl: 1, + pr: 2, + top: 0, + left: 0, + width: 1, + zIndex: 9, + height: 58, + display: 'flex', + position: 'absolute', + alignItems: 'center', + bgcolor: 'primary.lighter', + ...(dense && { height: 38 }), + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + ) => + onSelectAllRows(event.target.checked) + } + inputProps={{ + id: 'deselect-all-checkbox', + 'aria-label': 'Deselect all checkbox', + }} + /> + + + {numSelected} selected + + + {action && action} + + ); +} diff --git a/app/frontend/src/components/table/table-skeleton.tsx b/app/frontend/src/components/table/table-skeleton.tsx new file mode 100644 index 00000000..c2869451 --- /dev/null +++ b/app/frontend/src/components/table/table-skeleton.tsx @@ -0,0 +1,24 @@ +import type { TableRowProps } from '@mui/material/TableRow'; + +import Skeleton from '@mui/material/Skeleton'; +import TableRow from '@mui/material/TableRow'; +import TableCell from '@mui/material/TableCell'; + +// ---------------------------------------------------------------------- + +type TableSkeletonProps = TableRowProps & { + rowCount?: number; + cellCount?: number; +}; + +export function TableSkeleton({ rowCount = 0, cellCount = 0, ...other }: TableSkeletonProps) { + return Array.from({ length: rowCount }, (_, rowIndex) => ( + + {Array.from({ length: cellCount }, (__, cellIndex) => ( + + + + ))} + + )); +} diff --git a/app/frontend/src/components/table/use-table.ts b/app/frontend/src/components/table/use-table.ts new file mode 100644 index 00000000..db3543e6 --- /dev/null +++ b/app/frontend/src/components/table/use-table.ts @@ -0,0 +1,160 @@ +import { useState, useCallback } from 'react'; + +// ---------------------------------------------------------------------- + +export type UseTableReturn = { + dense: boolean; + page: number; + rowsPerPage: number; + order: 'asc' | 'desc'; + orderBy: string; + /********/ + selected: string[]; + onSelectRow: (id: string) => void; + onSelectAllRows: (checked: boolean, newSelecteds: string[]) => void; + /********/ + onResetPage: () => void; + onSort: (id: string) => void; + onChangePage: (event: unknown, newPage: number) => void; + onChangeRowsPerPage: (event: React.ChangeEvent) => void; + onChangeDense: (event: React.ChangeEvent) => void; + onUpdatePageDeleteRow: (totalRowsInPage: number) => void; + onUpdatePageDeleteRows: (totalRowsInPage: number, totalRowsFiltered: number) => void; + /********/ + setPage: React.Dispatch>; + setDense: React.Dispatch>; + setOrderBy: React.Dispatch>; + setSelected: React.Dispatch>; + setRowsPerPage: React.Dispatch>; + setOrder: React.Dispatch>; +}; + +export type UseTableProps = { + defaultDense?: boolean; + defaultOrderBy?: string; + defaultSelected?: string[]; + defaultRowsPerPage?: number; + defaultCurrentPage?: number; + defaultOrder?: 'asc' | 'desc'; +}; + +export function useTable(props?: UseTableProps): UseTableReturn { + const [dense, setDense] = useState(!!props?.defaultDense); + + const [page, setPage] = useState(props?.defaultCurrentPage ?? 0); + + const [orderBy, setOrderBy] = useState(props?.defaultOrderBy ?? 'name'); + + const [rowsPerPage, setRowsPerPage] = useState(props?.defaultRowsPerPage ?? 5); + + const [order, setOrder] = useState<'asc' | 'desc'>(props?.defaultOrder ?? 'asc'); + + const [selected, setSelected] = useState(props?.defaultSelected ?? []); + + const onSort = useCallback( + (id: string) => { + const isAsc = orderBy === id && order === 'asc'; + if (id !== '') { + setOrder(isAsc ? 'desc' : 'asc'); + setOrderBy(id); + } + }, + [order, orderBy] + ); + + const onSelectRow = useCallback( + (inputValue: string) => { + const newSelected = selected.includes(inputValue) + ? selected.filter((value) => value !== inputValue) + : [...selected, inputValue]; + + setSelected(newSelected); + }, + [selected] + ); + + const onChangeRowsPerPage = useCallback((event: React.ChangeEvent) => { + setPage(0); + setRowsPerPage(parseInt(event.target.value, 10)); + }, []); + + const onChangeDense = useCallback((event: React.ChangeEvent) => { + setDense(event.target.checked); + }, []); + + const onSelectAllRows = useCallback((checked: boolean, inputValue: string[]) => { + if (checked) { + setSelected(inputValue); + return; + } + setSelected([]); + }, []); + + const onChangePage = useCallback((event: unknown, newPage: number) => { + setPage(newPage); + }, []); + + const onResetPage = useCallback(() => { + setPage(0); + }, []); + + const onUpdatePageDeleteRow = useCallback( + (totalRowsInPage: number) => { + setSelected([]); + if (page) { + if (totalRowsInPage < 2) { + setPage(page - 1); + } + } + }, + [page] + ); + + const onUpdatePageDeleteRows = useCallback( + (totalRowsInPage: number, totalRowsFiltered: number) => { + const totalSelected = selected.length; + + setSelected([]); + + if (page) { + if (totalSelected === totalRowsInPage) { + setPage(page - 1); + } else if (totalSelected === totalRowsFiltered) { + setPage(0); + } else if (totalSelected > totalRowsInPage) { + const newPage = Math.ceil((totalRowsFiltered - totalSelected) / rowsPerPage) - 1; + + setPage(newPage); + } + } + }, + [page, rowsPerPage, selected.length] + ); + + return { + dense, + order, + page, + orderBy, + rowsPerPage, + /********/ + selected, + onSelectRow, + onSelectAllRows, + /********/ + onSort, + onChangePage, + onChangeDense, + onResetPage, + onChangeRowsPerPage, + onUpdatePageDeleteRow, + onUpdatePageDeleteRows, + /********/ + setPage, + setDense, + setOrder, + setOrderBy, + setSelected, + setRowsPerPage, + }; +} diff --git a/app/frontend/src/components/table/utils.ts b/app/frontend/src/components/table/utils.ts new file mode 100644 index 00000000..227d4258 --- /dev/null +++ b/app/frontend/src/components/table/utils.ts @@ -0,0 +1,62 @@ +// ---------------------------------------------------------------------- + +export function rowInPage(data: T[], page: number, rowsPerPage: number) { + return data.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage); +} + +// ---------------------------------------------------------------------- + +export function emptyRows(page: number, rowsPerPage: number, arrayLength: number) { + return page ? Math.max(0, (1 + page) * rowsPerPage - arrayLength) : 0; +} + +// ---------------------------------------------------------------------- + +/** + * @example + * const data = { + * calories: 360, + * align: 'center', + * more: { + * protein: 42, + * }, + * }; + * + * const ex1 = getNestedProperty(data, 'calories'); + * console.log('ex1', ex1); // output: 360 + * + * const ex2 = getNestedProperty(data, 'align'); + * console.log('ex2', ex2); // output: center + * + * const ex3 = getNestedProperty(data, 'more.protein'); + * console.log('ex3', ex3); // output: 42 + */ +function getNestedProperty(obj: T, key: string): any { + return key.split('.').reduce((acc: any, part: string) => acc && acc[part], obj); +} + +function descendingComparator(a: T, b: T, orderBy: keyof T) { + const aValue = getNestedProperty(a, orderBy as string); + const bValue = getNestedProperty(b, orderBy as string); + + if (bValue < aValue) { + return -1; + } + + if (bValue > aValue) { + return 1; + } + + return 0; +} + +// ---------------------------------------------------------------------- + +export function getComparator( + order: 'asc' | 'desc', + orderBy: Key +): (a: { [key in Key]: number | string }, b: { [key in Key]: number | string }) => number { + return order === 'desc' + ? (a, b) => descendingComparator(a, b, orderBy) + : (a, b) => -descendingComparator(a, b, orderBy); +} diff --git a/app/frontend/src/components/upload/classes.ts b/app/frontend/src/components/upload/classes.ts new file mode 100644 index 00000000..f695dd23 --- /dev/null +++ b/app/frontend/src/components/upload/classes.ts @@ -0,0 +1,12 @@ +import { createClasses } from 'src/theme/create-classes'; + +// ---------------------------------------------------------------------- + +export const uploadClasses = { + upload: createClasses('upload'), + uploadBox: createClasses('upload__box'), + uploadAvatar: createClasses('upload__avatar'), + uploadSinglePreview: createClasses('upload__single__preview'), + uploadMultiPreview: createClasses('upload__multi__preview'), + uploadRejectionFiles: createClasses('upload__rejection__files'), +}; diff --git a/app/frontend/src/components/upload/components/placeholder.tsx b/app/frontend/src/components/upload/components/placeholder.tsx new file mode 100644 index 00000000..a0d99b5e --- /dev/null +++ b/app/frontend/src/components/upload/components/placeholder.tsx @@ -0,0 +1,67 @@ +import type { Theme, SxProps } from '@mui/material/styles'; + +import { mergeClasses } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; + +import { createClasses } from 'src/theme/create-classes'; +import { UploadIllustration } from 'src/assets/illustrations'; + +// ---------------------------------------------------------------------- + +export type UploadPlaceholderProps = React.ComponentProps<'div'> & { + sx?: SxProps; +}; + +const uploadPlaceholderClasses = { + root: createClasses('upload__placeholder__root'), + content: createClasses('upload__placeholder__content'), + title: createClasses('upload__placeholder__title'), + description: createClasses('upload__placeholder__description'), +}; + +export function UploadPlaceholder({ sx, className, ...other }: UploadPlaceholderProps) { + return ( + + + +
          Drop or select file
          +
          + Drop files here or click to + browse + through your machine. +
          +
          +
          + ); +} + +// ---------------------------------------------------------------------- + +const PlaceholderRoot = styled('div')(() => ({ + display: 'flex', + alignItems: 'center', + flexDirection: 'column', + justifyContent: 'center', +})); + +const PlaceholderContent = styled('div')(({ theme }) => ({ + display: 'flex', + textAlign: 'center', + gap: theme.spacing(1), + flexDirection: 'column', + [`& .${uploadPlaceholderClasses.title}`]: { ...theme.typography.h6 }, + [`& .${uploadPlaceholderClasses.description}`]: { + ...theme.typography.body2, + color: theme.vars.palette.text.secondary, + '& span': { + textDecoration: 'underline', + margin: theme.spacing(0, 0.5), + color: theme.vars.palette.primary.main, + }, + }, +})); diff --git a/app/frontend/src/components/upload/components/preview-multi-file.tsx b/app/frontend/src/components/upload/components/preview-multi-file.tsx new file mode 100644 index 00000000..c681376b --- /dev/null +++ b/app/frontend/src/components/upload/components/preview-multi-file.tsx @@ -0,0 +1,114 @@ +import { varAlpha, mergeClasses } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; +import IconButton from '@mui/material/IconButton'; +import ListItemText from '@mui/material/ListItemText'; + +import { fData } from 'src/utils/format-number'; + +import { Iconify } from '../../iconify'; +import { uploadClasses } from '../classes'; +import { fileData, FileThumbnail } from '../../file-thumbnail'; + +import type { MultiFilePreviewProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function MultiFilePreview({ + sx, + onRemove, + lastNode, + thumbnail, + slotProps, + firstNode, + files = [], + className, + ...other +}: MultiFilePreviewProps) { + return ( + + {firstNode && {firstNode}} + + {files.map((file) => { + const { name, size } = fileData(file); + + if (thumbnail) { + return ( + + onRemove?.(file)} + sx={[ + (theme) => ({ + width: 80, + height: 80, + border: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.16)}`, + }), + ]} + slotProps={{ icon: { sx: { width: 36, height: 36 } } }} + {...slotProps?.thumbnail} + /> + + ); + } + + return ( + + + + + + {onRemove && ( + onRemove(file)}> + + + )} + + ); + })} + + {lastNode && {lastNode}} + + ); +} + +// ---------------------------------------------------------------------- + +const ListRoot = styled('ul', { + shouldForwardProp: (prop: string) => !['thumbnail', 'sx'].includes(prop), +})>(({ thumbnail, theme }) => ({ + display: 'flex', + gap: theme.spacing(1), + flexDirection: 'column', + ...(thumbnail && { flexWrap: 'wrap', flexDirection: 'row' }), +})); + +const ItemThumbnail = styled('li')(() => ({ display: 'inline-flex' })); + +const ItemRow = styled('li')(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1.5), + padding: theme.spacing(1, 1, 1, 1.5), + borderRadius: theme.shape.borderRadius, + border: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.16)}`, +})); + +const ItemNode = styled('li', { + shouldForwardProp: (prop: string) => !['thumbnail', 'sx'].includes(prop), +})>(({ thumbnail }) => ({ + ...(thumbnail && { width: 'auto', display: 'inline-flex' }), +})); diff --git a/app/frontend/src/components/upload/components/preview-single-file.tsx b/app/frontend/src/components/upload/components/preview-single-file.tsx new file mode 100644 index 00000000..cddd905c --- /dev/null +++ b/app/frontend/src/components/upload/components/preview-single-file.tsx @@ -0,0 +1,71 @@ +import type { IconButtonProps } from '@mui/material/IconButton'; + +import { varAlpha, mergeClasses } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; +import IconButton from '@mui/material/IconButton'; + +import { Iconify } from '../../iconify'; +import { uploadClasses } from '../classes'; + +import type { SingleFilePreviewProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function SingleFilePreview({ file, sx, className, ...other }: SingleFilePreviewProps) { + const fileName = typeof file === 'string' ? file : file.name; + + const previewUrl = typeof file === 'string' ? file : URL.createObjectURL(file); + + return ( + + {fileName} + + ); +} + +// ---------------------------------------------------------------------- + +const PreviewRoot = styled('div')(({ theme }) => ({ + top: 0, + left: 0, + width: '100%', + height: '100%', + position: 'absolute', + padding: theme.spacing(1), + '& > img': { + width: '100%', + height: '100%', + objectFit: 'cover', + borderRadius: theme.shape.borderRadius, + }, +})); + +// ---------------------------------------------------------------------- + +export function DeleteButton({ sx, ...other }: IconButtonProps) { + return ( + ({ + top: 16, + right: 16, + zIndex: 9, + position: 'absolute', + color: varAlpha(theme.vars.palette.common.whiteChannel, 0.8), + bgcolor: varAlpha(theme.vars.palette.grey['900Channel'], 0.72), + '&:hover': { bgcolor: varAlpha(theme.vars.palette.grey['900Channel'], 0.48) }, + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + + + ); +} diff --git a/app/frontend/src/components/upload/components/rejection-files.tsx b/app/frontend/src/components/upload/components/rejection-files.tsx new file mode 100644 index 00000000..57d98d21 --- /dev/null +++ b/app/frontend/src/components/upload/components/rejection-files.tsx @@ -0,0 +1,61 @@ +import type { FileRejection } from 'react-dropzone'; + +import { varAlpha, mergeClasses } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; + +import { fData } from 'src/utils/format-number'; + +import { uploadClasses } from '../classes'; +import { fileData } from '../../file-thumbnail'; + +// ---------------------------------------------------------------------- + +type RejectionFilesProps = React.ComponentProps & { + files?: readonly FileRejection[]; +}; + +export function RejectionFiles({ files, sx, className, ...other }: RejectionFilesProps) { + return ( + + {files?.map(({ file, errors }) => { + const { path, size } = fileData(file); + + return ( + + + {path} - {size ? fData(size) : ''} + + + {errors.map((error) => ( + - {error.message} + ))} + + ); + })} + + ); +} + +// ---------------------------------------------------------------------- + +const ListRoot = styled('ul')(({ theme }) => ({ + display: 'flex', + gap: theme.spacing(1), + flexDirection: 'column', + padding: theme.spacing(2), + marginTop: theme.spacing(3), + borderRadius: theme.shape.borderRadius, + border: `dashed 1px ${theme.vars.palette.error.main}`, + backgroundColor: varAlpha(theme.vars.palette.error.mainChannel, 0.08), +})); + +const ListItem = styled('li')(() => ({ display: 'flex', flexDirection: 'column' })); + +const ItemTitle = styled('span')(({ theme }) => ({ ...theme.typography.subtitle2 })); + +const ItemCaption = styled('span')(({ theme }) => ({ ...theme.typography.caption })); diff --git a/app/frontend/src/components/upload/index.ts b/app/frontend/src/components/upload/index.ts new file mode 100644 index 00000000..c0d56c93 --- /dev/null +++ b/app/frontend/src/components/upload/index.ts @@ -0,0 +1,13 @@ +export * from './upload'; + +export * from './upload-box'; + +export * from './upload-avatar'; + +export * from './components/rejection-files'; + +export * from './components/preview-multi-file'; + +export * from './components/preview-single-file'; + +export type * from './types'; diff --git a/app/frontend/src/components/upload/types.ts b/app/frontend/src/components/upload/types.ts new file mode 100644 index 00000000..bc548dd8 --- /dev/null +++ b/app/frontend/src/components/upload/types.ts @@ -0,0 +1,41 @@ +import type { DropzoneOptions } from 'react-dropzone'; +import type { Theme, SxProps } from '@mui/material/styles'; + +import type { FileThumbnailProps } from '../file-thumbnail'; + +// ---------------------------------------------------------------------- + +export type FileUploadType = File | string | null; + +export type FilesUploadType = (File | string)[]; + +export type SingleFilePreviewProps = React.ComponentProps<'div'> & { + file: File | string; + sx?: SxProps; +}; + +export type MultiFilePreviewProps = React.ComponentProps<'ul'> & { + sx?: SxProps; + files: FilesUploadType; + lastNode?: React.ReactNode; + firstNode?: React.ReactNode; + onRemove: UploadProps['onRemove']; + thumbnail: UploadProps['thumbnail']; + slotProps?: { + thumbnail?: Omit; + }; +}; + +export type UploadProps = DropzoneOptions & { + error?: boolean; + sx?: SxProps; + className?: string; + thumbnail?: boolean; + helperText?: React.ReactNode; + placeholder?: React.ReactNode; + value?: FileUploadType | FilesUploadType; + onDelete?: () => void; + onUpload?: () => void; + onRemoveAll?: () => void; + onRemove?: (file: File | string) => void; +}; diff --git a/app/frontend/src/components/upload/upload-avatar.tsx b/app/frontend/src/components/upload/upload-avatar.tsx new file mode 100644 index 00000000..368bf212 --- /dev/null +++ b/app/frontend/src/components/upload/upload-avatar.tsx @@ -0,0 +1,143 @@ +import { useState, useEffect } from 'react'; +import { useDropzone } from 'react-dropzone'; +import { varAlpha, mergeClasses } from 'minimal-shared/utils'; + +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; + +import { Image } from '../image'; +import { Iconify } from '../iconify'; +import { uploadClasses } from './classes'; +import { RejectionFiles } from './components/rejection-files'; + +import type { UploadProps } from './types'; + +// ---------------------------------------------------------------------- + +export function UploadAvatar({ + sx, + error, + value, + disabled, + helperText, + className, + ...other +}: UploadProps) { + const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({ + multiple: false, + disabled, + accept: { 'image/*': [] }, + ...other, + }); + + const hasFile = !!value; + + const hasError = isDragReject || !!error; + + const [preview, setPreview] = useState(''); + + useEffect(() => { + if (typeof value === 'string') { + setPreview(value); + } else if (value instanceof File) { + setPreview(URL.createObjectURL(value)); + } + }, [value]); + + const renderPreview = () => + hasFile && ( + Avatar + ); + + const renderPlaceholder = () => ( + ({ + top: 0, + gap: 1, + left: 0, + width: 1, + height: 1, + zIndex: 9, + display: 'flex', + borderRadius: '50%', + position: 'absolute', + alignItems: 'center', + color: 'text.disabled', + flexDirection: 'column', + justifyContent: 'center', + bgcolor: varAlpha(theme.vars.palette.grey['500Channel'], 0.08), + transition: theme.transitions.create(['opacity'], { + duration: theme.transitions.duration.shorter, + }), + '&:hover': { opacity: 0.72 }, + ...(hasError && { + color: 'error.main', + bgcolor: varAlpha(theme.vars.palette.error.mainChannel, 0.08), + }), + ...(hasFile && { + zIndex: 9, + opacity: 0, + color: 'common.white', + bgcolor: varAlpha(theme.vars.palette.grey['900Channel'], 0.64), + }), + })} + > + + + {hasFile ? 'Update photo' : 'Upload photo'} + + ); + + const renderContent = () => ( + + {renderPreview()} + {renderPlaceholder()} + + ); + + return ( + <> + ({ + p: 1, + m: 'auto', + width: 144, + height: 144, + cursor: 'pointer', + overflow: 'hidden', + borderRadius: '50%', + border: `1px dashed ${varAlpha(theme.vars.palette.grey['500Channel'], 0.2)}`, + ...(isDragActive && { opacity: 0.72 }), + ...(disabled && { opacity: 0.48, pointerEvents: 'none' }), + ...(hasError && { borderColor: 'error.main' }), + ...(hasFile && { + ...(hasError && { bgcolor: varAlpha(theme.vars.palette.error.mainChannel, 0.08) }), + '&:hover .upload-placeholder': { opacity: 1 }, + }), + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + > + + + {renderContent()} + + + {helperText && helperText} + + {!!fileRejections.length && } + + ); +} diff --git a/app/frontend/src/components/upload/upload-box.tsx b/app/frontend/src/components/upload/upload-box.tsx new file mode 100644 index 00000000..3c694639 --- /dev/null +++ b/app/frontend/src/components/upload/upload-box.tsx @@ -0,0 +1,55 @@ +import { useDropzone } from 'react-dropzone'; +import { varAlpha, mergeClasses } from 'minimal-shared/utils'; + +import Box from '@mui/material/Box'; + +import { Iconify } from '../iconify'; +import { uploadClasses } from './classes'; + +import type { UploadProps } from './types'; + +// ---------------------------------------------------------------------- + +export function UploadBox({ placeholder, error, disabled, className, sx, ...other }: UploadProps) { + const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({ + disabled, + ...other, + }); + + const hasError = isDragReject || error; + + return ( + ({ + width: 64, + height: 64, + flexShrink: 0, + display: 'flex', + borderRadius: 1, + cursor: 'pointer', + alignItems: 'center', + color: 'text.disabled', + justifyContent: 'center', + bgcolor: varAlpha(theme.vars.palette.grey['500Channel'], 0.08), + border: `dashed 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.16)}`, + ...(isDragActive && { opacity: 0.72 }), + ...(disabled && { opacity: 0.48, pointerEvents: 'none' }), + ...(hasError && { + color: 'error.main', + borderColor: 'error.main', + bgcolor: varAlpha(theme.vars.palette.error.mainChannel, 0.08), + }), + '&:hover': { opacity: 0.72 }, + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + > + + + {placeholder || } + + ); +} diff --git a/app/frontend/src/components/upload/upload.tsx b/app/frontend/src/components/upload/upload.tsx new file mode 100644 index 00000000..01ab69cb --- /dev/null +++ b/app/frontend/src/components/upload/upload.tsx @@ -0,0 +1,126 @@ +import { useDropzone } from 'react-dropzone'; +import { varAlpha, mergeClasses } from 'minimal-shared/utils'; + +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import FormHelperText from '@mui/material/FormHelperText'; + +import { Iconify } from '../iconify'; +import { uploadClasses } from './classes'; +import { UploadPlaceholder } from './components/placeholder'; +import { RejectionFiles } from './components/rejection-files'; +import { MultiFilePreview } from './components/preview-multi-file'; +import { DeleteButton, SingleFilePreview } from './components/preview-single-file'; + +import type { UploadProps } from './types'; + +// ---------------------------------------------------------------------- + +export function Upload({ + sx, + value, + error, + disabled, + onDelete, + onUpload, + onRemove, + thumbnail, + helperText, + onRemoveAll, + className, + multiple = false, + ...other +}: UploadProps) { + const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({ + multiple, + disabled, + ...other, + }); + + const isArray = Array.isArray(value) && multiple; + + const hasFile = !isArray && !!value; + const hasFiles = isArray && !!value.length; + + const hasError = isDragReject || !!error; + + const renderMultiPreview = () => + hasFiles && ( + <> + + + {(onRemoveAll || onUpload) && ( + + {onRemoveAll && ( + + )} + + {onUpload && ( + + )} + + )} + + ); + + return ( + + ({ + p: 5, + outline: 'none', + borderRadius: 1, + cursor: 'pointer', + overflow: 'hidden', + position: 'relative', + bgcolor: varAlpha(theme.vars.palette.grey['500Channel'], 0.08), + border: `1px dashed ${varAlpha(theme.vars.palette.grey['500Channel'], 0.2)}`, + transition: theme.transitions.create(['opacity', 'padding']), + '&:hover': { opacity: 0.72 }, + ...(isDragActive && { opacity: 0.72 }), + ...(disabled && { opacity: 0.48, pointerEvents: 'none' }), + ...(hasError && { + color: 'error.main', + borderColor: 'error.main', + bgcolor: varAlpha(theme.vars.palette.error.mainChannel, 0.08), + }), + ...(hasFile && { padding: '28% 0' }), + }), + ]} + > + + + {/* Single file */} + {hasFile ? : } + + + {/* Single file */} + {hasFile && } + + {helperText && ( + + {helperText} + + )} + + {!!fileRejections.length && } + + {/* Multi files */} + {renderMultiPreview()} + + ); +} diff --git a/app/frontend/src/components/walktour/index.ts b/app/frontend/src/components/walktour/index.ts new file mode 100644 index 00000000..761af558 --- /dev/null +++ b/app/frontend/src/components/walktour/index.ts @@ -0,0 +1,5 @@ +export * from './walktour'; + +export * from './use-walktour'; + +export * from './walktour-tooltip'; diff --git a/app/frontend/src/components/walktour/types.ts b/app/frontend/src/components/walktour/types.ts new file mode 100644 index 00000000..3cf50a13 --- /dev/null +++ b/app/frontend/src/components/walktour/types.ts @@ -0,0 +1,46 @@ +import type { BoxProps } from '@mui/material/Box'; +import type { ButtonProps } from '@mui/material/Button'; +import type { IconButtonProps } from '@mui/material/IconButton'; +import type { TypographyProps } from '@mui/material/Typography'; +import type { LinearProgressProps } from '@mui/material/LinearProgress'; +import type { + Step, + StoreHelpers, + CallBackProps, + TooltipRenderProps, + Props as JoyrideProps, +} from 'react-joyride'; + +// ---------------------------------------------------------------------- + +export type WalktourCustomStep = Step & { + slotProps?: { + root?: BoxProps; + title?: TypographyProps; + content?: BoxProps; + progress?: LinearProgressProps; + closeBtn?: IconButtonProps; + skipBtn?: ButtonProps; + backBtn?: ButtonProps; + nextBtn?: ButtonProps; + }; +}; + +export type WalktourTooltipProps = TooltipRenderProps & { + step: WalktourCustomStep; +}; + +export type WalktourProps = JoyrideProps; + +export type UseWalktourProps = { + defaultRun?: boolean; + steps: WalktourCustomStep[]; +}; + +export type UseWalktourReturn = { + run: boolean; + steps: WalktourCustomStep[]; + setRun: React.Dispatch>; + onCallback: (data: CallBackProps) => void; + setHelpers: (storeHelpers: StoreHelpers) => void; +}; diff --git a/app/frontend/src/components/walktour/use-walktour.tsx b/app/frontend/src/components/walktour/use-walktour.tsx new file mode 100644 index 00000000..a9044a1c --- /dev/null +++ b/app/frontend/src/components/walktour/use-walktour.tsx @@ -0,0 +1,36 @@ +import type { StoreHelpers, CallBackProps } from 'react-joyride'; + +import { STATUS } from 'react-joyride'; +import { useRef, useState } from 'react'; + +import type { UseWalktourProps, UseWalktourReturn } from './types'; + +// ---------------------------------------------------------------------- + +export function useWalktour({ steps, defaultRun }: UseWalktourProps): UseWalktourReturn { + const helpers = useRef(); + + const [run, setRun] = useState(!!defaultRun); + + const setHelpers = (storeHelpers: StoreHelpers) => { + helpers.current = storeHelpers; + }; + + const onCallback = (data: CallBackProps) => { + const { status } = data; + + const finishedStatuses: string[] = [STATUS.FINISHED, STATUS.SKIPPED]; + + if (finishedStatuses.includes(status)) { + setRun(false); + } + }; + + return { + run, + steps, + setRun, + onCallback, + setHelpers, + }; +} diff --git a/app/frontend/src/components/walktour/walktour-tooltip.tsx b/app/frontend/src/components/walktour/walktour-tooltip.tsx new file mode 100644 index 00000000..f6786d54 --- /dev/null +++ b/app/frontend/src/components/walktour/walktour-tooltip.tsx @@ -0,0 +1,183 @@ +'use client'; + +import { varAlpha } from 'minimal-shared/utils'; + +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import LinearProgress, { linearProgressClasses } from '@mui/material/LinearProgress'; + +import { Iconify } from 'src/components/iconify'; + +import type { WalktourTooltipProps } from './types'; + +// ---------------------------------------------------------------------- + +export function WalktourTooltip({ + size, + step, + index, + backProps, + skipProps, + continuous, + closeProps, + isLastStep, + primaryProps, + tooltipProps, +}: WalktourTooltipProps) { + const { + title, + content, + slotProps, + hideFooter, + showProgress, + showSkipButton, + hideBackButton, + hideCloseButton, + } = step; + + const progress = ((index + 1) / size) * 100; + + const renderSkipBtn = () => + index > 0 && + !isLastStep && ( + + ); + + const renderBackBtn = () => + index > 0 && ( + + ); + + const renderNextBtn = () => + continuous && ( + + ); + + const renderCloseBtn = () => + !isLastStep && ( + ({ + p: 0.5, + top: 10, + right: 10, + position: 'absolute', + border: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.12)}`, + }), + ...(Array.isArray(slotProps?.closeBtn?.sx) + ? (slotProps?.closeBtn?.sx ?? []) + : [slotProps?.closeBtn?.sx]), + ]} + > + + + ); + + const renderProgress = () => ( + ({ + height: 2, + borderRadius: 0, + bgcolor: varAlpha(theme.vars.palette.grey['500Channel'], 0.2), + [`& .${linearProgressClasses.bar}`]: { + background: `linear-gradient(135deg, ${theme.vars.palette.primary.light} 0%, ${theme.vars.palette.primary.main} 100%)`, + }, + }), + ...(Array.isArray(slotProps?.progress?.sx) + ? (slotProps?.progress?.sx ?? []) + : [slotProps?.progress?.sx]), + ]} + /> + ); + + return ( + ({ + width: 360, + borderRadius: 2, + bgcolor: 'background.paper', + boxShadow: theme.vars.customShadows.dialog, + }), + ...(Array.isArray(slotProps?.root?.sx) + ? (slotProps?.root?.sx ?? []) + : [slotProps?.root?.sx]), + ]} + > + + {title && ( + + {title} + + )} + + {!hideCloseButton && renderCloseBtn()} + + + {content && ( + + {content} + + )} + + {showProgress && renderProgress()} + + {!hideFooter && ( + ({ + p: 2.5, + gap: 1.5, + display: 'flex', + justifyContent: 'flex-end', + borderTop: `solid 1px ${theme.vars.palette.divider}`, + }), + ]} + > + {showSkipButton && renderSkipBtn()} + + + + {!hideBackButton && renderBackBtn()} + + {renderNextBtn()} + + )} + + ); +} diff --git a/app/frontend/src/components/walktour/walktour.tsx b/app/frontend/src/components/walktour/walktour.tsx new file mode 100644 index 00000000..0aedda83 --- /dev/null +++ b/app/frontend/src/components/walktour/walktour.tsx @@ -0,0 +1,76 @@ +import dynamic from 'next/dynamic'; +import { varAlpha } from 'minimal-shared/utils'; + +import { useTheme } from '@mui/material/styles'; + +import { WalktourTooltip } from './walktour-tooltip'; + +import type { WalktourProps } from './types'; + +// ---------------------------------------------------------------------- + +const Joyride = dynamic(() => import('react-joyride').then((mod) => mod.default), { ssr: false }); + +export function Walktour({ + locale, + continuous = true, + showProgress = true, + scrollDuration = 500, + showSkipButton = true, + disableOverlayClose = true, + ...other +}: WalktourProps) { + const theme = useTheme(); + + const arrowStyles = { + width: 20, + height: 10, + color: theme.vars.palette.background.paper, + } as const; + + return ( + + ); +} diff --git a/app/frontend/src/global-config.ts b/app/frontend/src/global-config.ts new file mode 100644 index 00000000..83354f5f --- /dev/null +++ b/app/frontend/src/global-config.ts @@ -0,0 +1,89 @@ +import { paths } from 'src/routes/paths'; + +import packageJson from '../package.json'; + +// ---------------------------------------------------------------------- + +export type ConfigValue = { + appName: string; + appVersion: string; + serverUrl: string; + assetsDir: string; + isStaticExport: boolean; + auth: { + method: 'jwt' | 'amplify' | 'firebase' | 'supabase' | 'auth0'; + skip: boolean; + redirectPath: string; + }; + mapboxApiKey: string; + firebase: { + appId: string; + apiKey: string; + projectId: string; + authDomain: string; + storageBucket: string; + measurementId: string; + messagingSenderId: string; + }; + amplify: { userPoolId: string; userPoolWebClientId: string; region: string }; + auth0: { clientId: string; domain: string; callbackUrl: string }; + supabase: { url: string; key: string }; +}; + +// ---------------------------------------------------------------------- + +export const CONFIG: ConfigValue = { + appName: 'Minimal UI', + appVersion: packageJson.version, + serverUrl: process.env.NEXT_PUBLIC_SERVER_URL ?? '', + assetsDir: process.env.NEXT_PUBLIC_ASSETS_DIR ?? '', + isStaticExport: JSON.parse(`${process.env.BUILD_STATIC_EXPORT}`), + /** + * Auth + * @method jwt | amplify | firebase | supabase | auth0 + */ + auth: { + method: 'jwt', + skip: true, + redirectPath: paths.dashboard.root, + }, + /** + * Mapbox + */ + mapboxApiKey: process.env.NEXT_PUBLIC_MAPBOX_API_KEY ?? '', + /** + * Firebase + */ + firebase: { + apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY ?? '', + authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN ?? '', + projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID ?? '', + storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET ?? '', + messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID ?? '', + appId: process.env.NEXT_PUBLIC_FIREBASE_APPID ?? '', + measurementId: process.env.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID ?? '', + }, + /** + * Amplify + */ + amplify: { + userPoolId: process.env.NEXT_PUBLIC_AWS_AMPLIFY_USER_POOL_ID ?? '', + userPoolWebClientId: process.env.NEXT_PUBLIC_AWS_AMPLIFY_USER_POOL_WEB_CLIENT_ID ?? '', + region: process.env.NEXT_PUBLIC_AWS_AMPLIFY_REGION ?? '', + }, + /** + * Auth0 + */ + auth0: { + clientId: process.env.NEXT_PUBLIC_AUTH0_CLIENT_ID ?? '', + domain: process.env.NEXT_PUBLIC_AUTH0_DOMAIN ?? '', + callbackUrl: process.env.NEXT_PUBLIC_AUTH0_CALLBACK_URL ?? '', + }, + /** + * Supabase + */ + supabase: { + url: process.env.NEXT_PUBLIC_SUPABASE_URL ?? '', + key: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? '', + }, +}; diff --git a/app/frontend/src/global.css b/app/frontend/src/global.css new file mode 100644 index 00000000..31b18e4a --- /dev/null +++ b/app/frontend/src/global.css @@ -0,0 +1,69 @@ +/** ************************************** +* Fonts: app +*************************************** */ +@import '@fontsource-variable/public-sans'; + +@import '@fontsource/barlow/400.css'; +@import '@fontsource/barlow/500.css'; +@import '@fontsource/barlow/600.css'; +@import '@fontsource/barlow/700.css'; +@import '@fontsource/barlow/800.css'; + +/** ************************************** +* Fonts: options +*************************************** */ +@import '@fontsource-variable/dm-sans'; +@import '@fontsource-variable/inter'; +@import '@fontsource-variable/nunito-sans'; + +/** ************************************** +* Plugins +*************************************** */ +/* scrollbar */ +@import './components/scrollbar/styles.css'; + +/* map */ +@import './components/map/styles.css'; + +/* lightbox */ +@import './components/lightbox/styles.css'; + +/* chart */ +@import './components/chart/styles.css'; + +/** ************************************** +* Baseline +*************************************** */ +html { + height: 100%; + -webkit-overflow-scrolling: touch; +} +body, +#root, +#root__layout { + display: flex; + flex: 1 1 auto; + min-height: 100%; + flex-direction: column; +} +img { + max-width: 100%; + vertical-align: middle; +} +ul { + margin: 0; + padding: 0; + list-style-type: none; +} +input[type='number'] { + -moz-appearance: textfield; + appearance: none; +} +input[type='number']::-webkit-outer-spin-button { + margin: 0; + -webkit-appearance: none; +} +input[type='number']::-webkit-inner-spin-button { + margin: 0; + -webkit-appearance: none; +} diff --git a/app/frontend/src/layouts/auth-centered/content.tsx b/app/frontend/src/layouts/auth-centered/content.tsx new file mode 100644 index 00000000..4358f591 --- /dev/null +++ b/app/frontend/src/layouts/auth-centered/content.tsx @@ -0,0 +1,43 @@ +'use client'; + +import type { BoxProps } from '@mui/material/Box'; + +import { mergeClasses } from 'minimal-shared/utils'; + +import Box from '@mui/material/Box'; + +import { layoutClasses } from '../core/classes'; + +// ---------------------------------------------------------------------- + +export type AuthCenteredContentProps = BoxProps; + +export function AuthCenteredContent({ + sx, + children, + className, + ...other +}: AuthCenteredContentProps) { + return ( + ({ + py: 5, + px: 3, + width: 1, + zIndex: 2, + borderRadius: 2, + display: 'flex', + flexDirection: 'column', + maxWidth: 'var(--layout-auth-content-width)', + bgcolor: theme.vars.palette.background.default, + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + {children} + + ); +} diff --git a/app/frontend/src/layouts/auth-centered/index.ts b/app/frontend/src/layouts/auth-centered/index.ts new file mode 100644 index 00000000..fa1aa0e7 --- /dev/null +++ b/app/frontend/src/layouts/auth-centered/index.ts @@ -0,0 +1,3 @@ +export * from './layout'; + +export * from './content'; diff --git a/app/frontend/src/layouts/auth-centered/layout.tsx b/app/frontend/src/layouts/auth-centered/layout.tsx new file mode 100644 index 00000000..d7fa8edd --- /dev/null +++ b/app/frontend/src/layouts/auth-centered/layout.tsx @@ -0,0 +1,164 @@ +'use client'; + +import type { Theme, CSSObject, Breakpoint } from '@mui/material/styles'; + +import { merge } from 'es-toolkit'; + +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Alert from '@mui/material/Alert'; + +import { paths } from 'src/routes/paths'; +import { RouterLink } from 'src/routes/components'; + +import { CONFIG } from 'src/global-config'; + +import { Logo } from 'src/components/logo'; + +import { AuthCenteredContent } from './content'; +import { MainSection } from '../core/main-section'; +import { LayoutSection } from '../core/layout-section'; +import { HeaderSection } from '../core/header-section'; +import { SettingsButton } from '../components/settings-button'; + +import type { AuthCenteredContentProps } from './content'; +import type { MainSectionProps } from '../core/main-section'; +import type { HeaderSectionProps } from '../core/header-section'; +import type { LayoutSectionProps } from '../core/layout-section'; + +// ---------------------------------------------------------------------- + +type LayoutBaseProps = Pick; + +export type AuthCenteredLayoutProps = LayoutBaseProps & { + layoutQuery?: Breakpoint; + slotProps?: { + header?: HeaderSectionProps; + main?: MainSectionProps; + content?: AuthCenteredContentProps; + }; +}; + +export function AuthCenteredLayout({ + sx, + cssVars, + children, + slotProps, + layoutQuery = 'md', +}: AuthCenteredLayoutProps) { + const renderHeader = () => { + const headerSlotProps: HeaderSectionProps['slotProps'] = { container: { maxWidth: false } }; + + const headerSlots: HeaderSectionProps['slots'] = { + topArea: ( + + This is an info Alert. + + ), + leftArea: ( + <> + {/** @slot Logo */} + + + ), + rightArea: ( + + {/** @slot Help link */} + + Need help? + + + {/** @slot Settings button */} + + + ), + }; + + return ( + + ); + }; + + const renderFooter = () => null; + + const renderMain = () => ( + ({ + alignItems: 'center', + p: theme.spacing(3, 2, 10, 2), + [theme.breakpoints.up(layoutQuery)]: { + justifyContent: 'center', + p: theme.spacing(10, 0, 10, 0), + }, + }), + ...(Array.isArray(slotProps?.main?.sx) + ? (slotProps?.main?.sx ?? []) + : [slotProps?.main?.sx]), + ]} + > + {children} + + ); + + return ( + ({ + position: 'relative', + '&::before': backgroundStyles(theme), + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + > + {renderMain()} + + ); +} + +// ---------------------------------------------------------------------- + +const backgroundStyles = (theme: Theme): CSSObject => ({ + ...theme.mixins.bgGradient({ + images: [`url(${CONFIG.assetsDir}/assets/background/background-3-blur.webp)`], + }), + zIndex: 1, + opacity: 0.24, + width: '100%', + height: '100%', + content: "''", + position: 'absolute', + ...theme.applyStyles('dark', { + opacity: 0.08, + }), +}); diff --git a/app/frontend/src/layouts/auth-split/content.tsx b/app/frontend/src/layouts/auth-split/content.tsx new file mode 100644 index 00000000..833e1ede --- /dev/null +++ b/app/frontend/src/layouts/auth-split/content.tsx @@ -0,0 +1,54 @@ +'use client'; + +import type { BoxProps } from '@mui/material/Box'; +import type { Breakpoint } from '@mui/material/styles'; + +import { mergeClasses } from 'minimal-shared/utils'; + +import Box from '@mui/material/Box'; + +import { layoutClasses } from '../core/classes'; + +// ---------------------------------------------------------------------- + +export type AuthSplitContentProps = BoxProps & { layoutQuery?: Breakpoint }; + +export function AuthSplitContent({ + sx, + children, + className, + layoutQuery = 'md', + ...other +}: AuthSplitContentProps) { + return ( + ({ + display: 'flex', + flex: '1 1 auto', + alignItems: 'center', + flexDirection: 'column', + p: theme.spacing(3, 2, 10, 2), + [theme.breakpoints.up(layoutQuery)]: { + justifyContent: 'center', + p: theme.spacing(10, 2, 10, 2), + }, + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + + {children} + + + ); +} diff --git a/app/frontend/src/layouts/auth-split/index.ts b/app/frontend/src/layouts/auth-split/index.ts new file mode 100644 index 00000000..fa1aa0e7 --- /dev/null +++ b/app/frontend/src/layouts/auth-split/index.ts @@ -0,0 +1,3 @@ +export * from './layout'; + +export * from './content'; diff --git a/app/frontend/src/layouts/auth-split/layout.tsx b/app/frontend/src/layouts/auth-split/layout.tsx new file mode 100644 index 00000000..6068e314 --- /dev/null +++ b/app/frontend/src/layouts/auth-split/layout.tsx @@ -0,0 +1,173 @@ +'use client'; + +import type { Breakpoint } from '@mui/material/styles'; + +import { merge } from 'es-toolkit'; + +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Alert from '@mui/material/Alert'; + +import { paths } from 'src/routes/paths'; +import { RouterLink } from 'src/routes/components'; + +import { CONFIG } from 'src/global-config'; + +import { Logo } from 'src/components/logo'; + +import { AuthSplitSection } from './section'; +import { AuthSplitContent } from './content'; +import { MainSection } from '../core/main-section'; +import { LayoutSection } from '../core/layout-section'; +import { HeaderSection } from '../core/header-section'; +import { SettingsButton } from '../components/settings-button'; + +import type { AuthSplitSectionProps } from './section'; +import type { AuthSplitContentProps } from './content'; +import type { MainSectionProps } from '../core/main-section'; +import type { HeaderSectionProps } from '../core/header-section'; +import type { LayoutSectionProps } from '../core/layout-section'; + +// ---------------------------------------------------------------------- + +type LayoutBaseProps = Pick; + +export type AuthSplitLayoutProps = LayoutBaseProps & { + layoutQuery?: Breakpoint; + slotProps?: { + header?: HeaderSectionProps; + main?: MainSectionProps; + section?: AuthSplitSectionProps; + content?: AuthSplitContentProps; + }; +}; + +export function AuthSplitLayout({ + sx, + cssVars, + children, + slotProps, + layoutQuery = 'md', +}: AuthSplitLayoutProps) { + const renderHeader = () => { + const headerSlotProps: HeaderSectionProps['slotProps'] = { + container: { maxWidth: false }, + }; + + const headerSlots: HeaderSectionProps['slots'] = { + topArea: ( + + This is an info Alert. + + ), + leftArea: ( + <> + {/** @slot Logo */} + + + ), + rightArea: ( + + {/** @slot Help link */} + + Need help? + + + {/** @slot Settings button */} + + + ), + }; + + return ( + + ); + }; + + const renderFooter = () => null; + + const renderMain = () => ( + ({ [theme.breakpoints.up(layoutQuery)]: { flexDirection: 'row' } }), + ...(Array.isArray(slotProps?.main?.sx) + ? (slotProps?.main?.sx ?? []) + : [slotProps?.main?.sx]), + ]} + > + + + {children} + + + ); + + return ( + + {renderMain()} + + ); +} diff --git a/app/frontend/src/layouts/auth-split/section.tsx b/app/frontend/src/layouts/auth-split/section.tsx new file mode 100644 index 00000000..2f7778c5 --- /dev/null +++ b/app/frontend/src/layouts/auth-split/section.tsx @@ -0,0 +1,125 @@ +import type { BoxProps } from '@mui/material/Box'; +import type { Breakpoint } from '@mui/material/styles'; + +import { varAlpha } from 'minimal-shared/utils'; + +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Tooltip from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; + +import { RouterLink } from 'src/routes/components'; + +import { CONFIG } from 'src/global-config'; + +// ---------------------------------------------------------------------- + +export type AuthSplitSectionProps = BoxProps & { + title?: string; + method?: string; + imgUrl?: string; + subtitle?: string; + layoutQuery?: Breakpoint; + methods?: { + path: string; + icon: string; + label: string; + }[]; +}; + +export function AuthSplitSection({ + sx, + method, + methods, + layoutQuery = 'md', + title = 'Manage the job', + imgUrl = `${CONFIG.assetsDir}/assets/illustrations/illustration-dashboard.webp`, + subtitle = 'More effectively with optimized workflows.', + ...other +}: AuthSplitSectionProps) { + return ( + ({ + ...theme.mixins.bgGradient({ + images: [ + `linear-gradient(0deg, ${varAlpha(theme.vars.palette.background.defaultChannel, 0.92)}, ${varAlpha(theme.vars.palette.background.defaultChannel, 0.92)})`, + `url(${CONFIG.assetsDir}/assets/background/background-3-blur.webp)`, + ], + }), + px: 3, + pb: 3, + width: 1, + maxWidth: 480, + display: 'none', + position: 'relative', + pt: 'var(--layout-header-desktop-height)', + [theme.breakpoints.up(layoutQuery)]: { + gap: 8, + display: 'flex', + alignItems: 'center', + flexDirection: 'column', + justifyContent: 'center', + }, + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > +
          + + {title} + + + {subtitle && ( + + {subtitle} + + )} +
          + + + + {!!methods?.length && method && ( + + {methods.map((option) => { + const selected = method === option.label.toLowerCase(); + + return ( + + + + + + + + ); + })} + + )} + + ); +} diff --git a/app/frontend/src/layouts/components/account-button.tsx b/app/frontend/src/layouts/components/account-button.tsx new file mode 100644 index 00000000..fdf9a217 --- /dev/null +++ b/app/frontend/src/layouts/components/account-button.tsx @@ -0,0 +1,66 @@ +import type { IconButtonProps } from '@mui/material/IconButton'; + +import { m } from 'framer-motion'; + +import NoSsr from '@mui/material/NoSsr'; +import Avatar from '@mui/material/Avatar'; +import SvgIcon from '@mui/material/SvgIcon'; +import IconButton from '@mui/material/IconButton'; + +import { varTap, varHover, AnimateBorder, transitionTap } from 'src/components/animate'; + +// ---------------------------------------------------------------------- + +export type AccountButtonProps = IconButtonProps & { + photoURL: string; + displayName: string; +}; + +export function AccountButton({ photoURL, displayName, sx, ...other }: AccountButtonProps) { + const renderFallback = () => ( + ({ + width: 40, + height: 40, + border: `solid 2px ${theme.vars.palette.background.default}`, + }), + ]} + > + + + + + + ); + + return ( + + + + + {displayName?.charAt(0).toUpperCase()} + + + + + ); +} diff --git a/app/frontend/src/layouts/components/account-drawer.tsx b/app/frontend/src/layouts/components/account-drawer.tsx new file mode 100644 index 00000000..49588bd2 --- /dev/null +++ b/app/frontend/src/layouts/components/account-drawer.tsx @@ -0,0 +1,220 @@ +'use client'; + +import type { IconButtonProps } from '@mui/material/IconButton'; + +import { varAlpha } from 'minimal-shared/utils'; +import { useBoolean } from 'minimal-shared/hooks'; + +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Avatar from '@mui/material/Avatar'; +import Drawer from '@mui/material/Drawer'; +import Tooltip from '@mui/material/Tooltip'; +import MenuList from '@mui/material/MenuList'; +import MenuItem from '@mui/material/MenuItem'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; + +import { paths } from 'src/routes/paths'; +import { usePathname } from 'src/routes/hooks'; +import { RouterLink } from 'src/routes/components'; + +import { _mock } from 'src/_mock'; + +import { Label } from 'src/components/label'; +import { Iconify } from 'src/components/iconify'; +import { Scrollbar } from 'src/components/scrollbar'; +import { AnimateBorder } from 'src/components/animate'; + +import { useAuthContext } from 'src/auth/hooks'; + +import { UpgradeBlock } from './nav-upgrade'; +import { AccountButton } from './account-button'; +import { SignOutButton } from './sign-out-button'; + +// ---------------------------------------------------------------------- + +export type AccountDrawerProps = IconButtonProps & { + data?: { + label: string; + href: string; + icon?: React.ReactNode; + info?: React.ReactNode; + }[]; +}; + +export function AccountDrawer({ data = [], sx, ...other }: AccountDrawerProps) { + const pathname = usePathname(); + + const { user } = useAuthContext(); + + const photoUrl = _mock.image.avatar(24) + + const { value: open, onFalse: onClose, onTrue: onOpen } = useBoolean(); + + const renderAvatar = () => ( + + + {user?.username?.charAt(0).toUpperCase()} + + + ); + + const renderList = () => ( + ({ + py: 3, + px: 2.5, + borderTop: `dashed 1px ${theme.vars.palette.divider}`, + borderBottom: `dashed 1px ${theme.vars.palette.divider}`, + '& li': { p: 0 }, + }), + ]} + > + {data.map((option) => { + const rootLabel = pathname.includes('/dashboard') ? 'Home' : 'Dashboard'; + const rootHref = pathname.includes('/dashboard') ? '/' : paths.dashboard.root; + + return ( + + + {option.icon} + + + {option.label === 'Home' ? rootLabel : option.label} + + + {option.info && ( + + )} + + + ); + })} + + ); + + return ( + <> + + + + + + + + + + {renderAvatar()} + + + {user?.displayName} + + + + {user?.email} + + + + + {Array.from({ length: 3 }, (_, index) => ( + + { }} + /> + + ))} + + + ({ + bgcolor: varAlpha(theme.vars.palette.grey['500Channel'], 0.08), + border: `dashed 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.32)}`, + }), + ]} + > + + + + + + {renderList()} + + + + + + + + + + + + ); +} diff --git a/app/frontend/src/layouts/components/account-popover.tsx b/app/frontend/src/layouts/components/account-popover.tsx new file mode 100644 index 00000000..d316dac4 --- /dev/null +++ b/app/frontend/src/layouts/components/account-popover.tsx @@ -0,0 +1,131 @@ +import type { IconButtonProps } from '@mui/material/IconButton'; + +import { usePopover } from 'minimal-shared/hooks'; + +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Divider from '@mui/material/Divider'; +import MenuList from '@mui/material/MenuList'; +import MenuItem from '@mui/material/MenuItem'; +import Typography from '@mui/material/Typography'; + +import { paths } from 'src/routes/paths'; +import { usePathname } from 'src/routes/hooks'; +import { RouterLink } from 'src/routes/components'; + +import { Label } from 'src/components/label'; +import { CustomPopover } from 'src/components/custom-popover'; + +import { useAuthContext } from 'src/auth/hooks'; + +import { AccountButton } from './account-button'; +import { SignOutButton } from './sign-out-button'; + +// ---------------------------------------------------------------------- + +export type AccountPopoverProps = IconButtonProps & { + data?: { + label: string; + href: string; + icon?: React.ReactNode; + info?: React.ReactNode; + }[]; +}; + +export function AccountPopover({ data = [], sx, ...other }: AccountPopoverProps) { + const pathname = usePathname(); + + const { open, anchorEl, onClose, onOpen } = usePopover(); + + const { user } = useAuthContext(); + + console.log('user', user); + + const renderMenuActions = () => ( + + + + {user?.username} + + + + {user?.email} + + + + + + + {data.map((option) => { + const rootLabel = pathname.includes('/dashboard') ? 'Home' : 'Dashboard'; + const rootHref = pathname.includes('/dashboard') ? '/' : paths.dashboard.root; + + return ( + + + {option.icon} + + + {option.label === 'Home' ? rootLabel : option.label} + + + {option.info && ( + + )} + + + ); + })} + + + + + + + + + ); + + return ( + <> + + + {renderMenuActions()} + + ); +} diff --git a/app/frontend/src/layouts/components/contacts-popover.tsx b/app/frontend/src/layouts/components/contacts-popover.tsx new file mode 100644 index 00000000..9ff5f908 --- /dev/null +++ b/app/frontend/src/layouts/components/contacts-popover.tsx @@ -0,0 +1,102 @@ +'use client'; + +import type { BadgeProps } from '@mui/material/Badge'; +import type { IconButtonProps } from '@mui/material/IconButton'; + +import { m } from 'framer-motion'; +import { usePopover } from 'minimal-shared/hooks'; + +import Badge from '@mui/material/Badge'; +import Avatar from '@mui/material/Avatar'; +import SvgIcon from '@mui/material/SvgIcon'; +import MenuItem from '@mui/material/MenuItem'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import ListItemText from '@mui/material/ListItemText'; + +import { fToNow } from 'src/utils/format-time'; + +import { Scrollbar } from 'src/components/scrollbar'; +import { CustomPopover } from 'src/components/custom-popover'; +import { varTap, varHover, transitionTap } from 'src/components/animate'; + +// ---------------------------------------------------------------------- + +export type ContactsPopoverProps = IconButtonProps & { + data?: { + id: string; + role: string; + name: string; + email: string; + status: string; + address: string; + avatarUrl: string; + phoneNumber: string; + lastActivity: string; + }[]; +}; + +export function ContactsPopover({ data = [], sx, ...other }: ContactsPopoverProps) { + const { open, anchorEl, onClose, onOpen } = usePopover(); + + const renderMenuList = () => ( + + + Contacts ({data.length}) + + + + {data.map((contact) => ( + + + + + + + + ))} + + + ); + + return ( + <> + ({ ...(open && { bgcolor: theme.vars.palette.action.selected }) }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + + {/* https://icon-sets.iconify.design/solar/users-group-rounded-bold-duotone/ */} + + + + + + + + {renderMenuList()} + + ); +} diff --git a/app/frontend/src/layouts/components/language-popover.tsx b/app/frontend/src/layouts/components/language-popover.tsx new file mode 100644 index 00000000..491c7e2e --- /dev/null +++ b/app/frontend/src/layouts/components/language-popover.tsx @@ -0,0 +1,86 @@ +'use client'; + +import type { LanguageValue } from 'src/locales'; +import type { IconButtonProps } from '@mui/material/IconButton'; + +import { m } from 'framer-motion'; +import { useCallback } from 'react'; +import { usePopover } from 'minimal-shared/hooks'; + +import MenuList from '@mui/material/MenuList'; +import MenuItem from '@mui/material/MenuItem'; +import IconButton from '@mui/material/IconButton'; + +import { useTranslate } from 'src/locales'; + +import { FlagIcon } from 'src/components/flag-icon'; +import { CustomPopover } from 'src/components/custom-popover'; +import { varTap, varHover, transitionTap } from 'src/components/animate'; + +// ---------------------------------------------------------------------- + +export type LanguagePopoverProps = IconButtonProps & { + data?: { + value: string; + label: string; + countryCode: string; + }[]; +}; + +export function LanguagePopover({ data = [], sx, ...other }: LanguagePopoverProps) { + const { open, anchorEl, onClose, onOpen } = usePopover(); + + const { onChangeLang, currentLang } = useTranslate(); + + const handleChangeLang = useCallback( + (newLang: LanguageValue) => { + onChangeLang(newLang); + onClose(); + }, + [onChangeLang, onClose] + ); + + const renderMenuList = () => ( + + + {data?.map((option) => ( + handleChangeLang(option.value as LanguageValue)} + > + + {option.label} + + ))} + + + ); + + return ( + <> + ({ + p: 0, + width: 40, + height: 40, + ...(open && { bgcolor: theme.vars.palette.action.selected }), + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + + + + {renderMenuList()} + + ); +} diff --git a/app/frontend/src/layouts/components/menu-button.tsx b/app/frontend/src/layouts/components/menu-button.tsx new file mode 100644 index 00000000..d2a65d0e --- /dev/null +++ b/app/frontend/src/layouts/components/menu-button.tsx @@ -0,0 +1,28 @@ +import type { IconButtonProps } from '@mui/material/IconButton'; + +import SvgIcon from '@mui/material/SvgIcon'; +import IconButton from '@mui/material/IconButton'; + +// ---------------------------------------------------------------------- + +export function MenuButton({ sx, ...other }: IconButtonProps) { + return ( + + + + + + + + ); +} diff --git a/app/frontend/src/layouts/components/nav-toggle-button.tsx b/app/frontend/src/layouts/components/nav-toggle-button.tsx new file mode 100644 index 00000000..ab78eec1 --- /dev/null +++ b/app/frontend/src/layouts/components/nav-toggle-button.tsx @@ -0,0 +1,61 @@ +import type { IconButtonProps } from '@mui/material/IconButton'; + +import { varAlpha } from 'minimal-shared/utils'; + +import SvgIcon from '@mui/material/SvgIcon'; +import IconButton from '@mui/material/IconButton'; + +// ---------------------------------------------------------------------- + +export type NavToggleButtonProps = IconButtonProps & { + isNavMini: boolean; +}; + +/* https://icon-sets.iconify.design/eva/arrow-ios-back-fill/ */ +const backArrowSvgPath = + 'M13.83 19a1 1 0 0 1-.78-.37l-4.83-6a1 1 0 0 1 0-1.27l5-6a1 1 0 0 1 1.54 1.28L10.29 12l4.32 5.36a1 1 0 0 1-.78 1.64'; + +/* https://icon-sets.iconify.design/eva/arrow-ios-forward-fill/ */ +const nextArrowSvgPath = + 'M10 19a1 1 0 0 1-.64-.23a1 1 0 0 1-.13-1.41L13.71 12L9.39 6.63a1 1 0 0 1 .15-1.41a1 1 0 0 1 1.46.15l4.83 6a1 1 0 0 1 0 1.27l-5 6A1 1 0 0 1 10 19'; + +export function NavToggleButton({ isNavMini, sx, ...other }: NavToggleButtonProps) { + return ( + ({ + p: 0.5, + position: 'absolute', + color: 'action.active', + bgcolor: 'background.default', + transform: 'translate(-50%, -50%)', + zIndex: 'var(--layout-nav-zIndex)', + top: 'calc(var(--layout-header-desktop-height) / 2)', + left: isNavMini ? 'var(--layout-nav-mini-width)' : 'var(--layout-nav-vertical-width)', + border: `1px solid ${varAlpha(theme.vars.palette.grey['500Channel'], 0.12)}`, + transition: theme.transitions.create(['left'], { + easing: 'var(--layout-transition-easing)', + duration: 'var(--layout-transition-duration)', + }), + '&:hover': { + color: 'text.primary', + bgcolor: 'background.neutral', + }, + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + ({ + width: 16, + height: 16, + ...(theme.direction === 'rtl' && { transform: 'scaleX(-1)' }), + })} + > + + + + ); +} diff --git a/app/frontend/src/layouts/components/nav-upgrade.tsx b/app/frontend/src/layouts/components/nav-upgrade.tsx new file mode 100644 index 00000000..74ae126e --- /dev/null +++ b/app/frontend/src/layouts/components/nav-upgrade.tsx @@ -0,0 +1,160 @@ +import type { BoxProps } from '@mui/material/Box'; + +import { m } from 'framer-motion'; +import { varAlpha } from 'minimal-shared/utils'; + +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Avatar from '@mui/material/Avatar'; +import Typography from '@mui/material/Typography'; + +import { paths } from 'src/routes/paths'; + +import { CONFIG } from 'src/global-config'; + +import { Label } from 'src/components/label'; + +import { useMockedUser } from 'src/auth/hooks'; + +// ---------------------------------------------------------------------- + +export function NavUpgrade({ sx, ...other }: BoxProps) { + const { user } = useMockedUser(); + + return ( + + + + + {user?.displayName?.charAt(0).toUpperCase()} + + + + + + + + {user?.displayName} + + + + {user?.email} + + + + + + + ); +} + +// ---------------------------------------------------------------------- + +export function UpgradeBlock({ sx, ...other }: BoxProps) { + return ( + ({ + ...theme.mixins.bgGradient({ + images: [ + `linear-gradient(135deg, ${varAlpha(theme.vars.palette.error.lightChannel, 0.92)}, ${varAlpha(theme.vars.palette.secondary.darkChannel, 0.92)})`, + `url(${CONFIG.assetsDir}/assets/background/background-7.webp)`, + ], + }), + px: 3, + py: 4, + borderRadius: 2, + position: 'relative', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + ({ + top: 0, + left: 0, + width: 1, + height: 1, + borderRadius: 2, + position: 'absolute', + border: `solid 3px ${varAlpha(theme.vars.palette.common.whiteChannel, 0.16)}`, + })} + /> + + + + + + 35% OFF + + + + Power up Productivity! + + + + + + ); +} diff --git a/app/frontend/src/layouts/components/notifications-drawer/index.tsx b/app/frontend/src/layouts/components/notifications-drawer/index.tsx new file mode 100644 index 00000000..74663700 --- /dev/null +++ b/app/frontend/src/layouts/components/notifications-drawer/index.tsx @@ -0,0 +1,177 @@ +'use client'; + +import type { IconButtonProps } from '@mui/material/IconButton'; + +import { m } from 'framer-motion'; +import { useState, useCallback } from 'react'; +import { useBoolean } from 'minimal-shared/hooks'; + +import Tab from '@mui/material/Tab'; +import Box from '@mui/material/Box'; +import Badge from '@mui/material/Badge'; +import Drawer from '@mui/material/Drawer'; +import Button from '@mui/material/Button'; +import SvgIcon from '@mui/material/SvgIcon'; +import Tooltip from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; + +import { Label } from 'src/components/label'; +import { Iconify } from 'src/components/iconify'; +import { Scrollbar } from 'src/components/scrollbar'; +import { CustomTabs } from 'src/components/custom-tabs'; +import { varTap, varHover, transitionTap } from 'src/components/animate'; + +import { NotificationItem } from './notification-item'; + +import type { NotificationItemProps } from './notification-item'; + +// ---------------------------------------------------------------------- + +const TABS = [ + { value: 'all', label: 'All', count: 22 }, + { value: 'unread', label: 'Unread', count: 12 }, + { value: 'archived', label: 'Archived', count: 10 }, +]; + +// ---------------------------------------------------------------------- + +export type NotificationsDrawerProps = IconButtonProps & { + data?: NotificationItemProps['notification'][]; +}; + +export function NotificationsDrawer({ data = [], sx, ...other }: NotificationsDrawerProps) { + const { value: open, onFalse: onClose, onTrue: onOpen } = useBoolean(); + + const [currentTab, setCurrentTab] = useState('all'); + + const handleChangeTab = useCallback((event: React.SyntheticEvent, newValue: string) => { + setCurrentTab(newValue); + }, []); + + const [notifications, setNotifications] = useState(data); + + const totalUnRead = notifications.filter((item) => item.isUnRead === true).length; + + const handleMarkAllAsRead = () => { + setNotifications(notifications.map((notification) => ({ ...notification, isUnRead: false }))); + }; + + const renderHead = () => ( + + + Notifications + + + {!!totalUnRead && ( + + + + + + )} + + + + + + + + + + ); + + const renderTabs = () => ( + + {TABS.map((tab) => ( + + {tab.count} + + } + /> + ))} + + ); + + const renderList = () => ( + + + {notifications?.map((notification) => ( + + + + ))} + + + ); + + return ( + <> + + + + {/* https://icon-sets.iconify.design/solar/bell-bing-bold-duotone/ */} + + + + + + + + {renderHead()} + {renderTabs()} + {renderList()} + + + + + + + ); +} diff --git a/app/frontend/src/layouts/components/notifications-drawer/notification-item.tsx b/app/frontend/src/layouts/components/notifications-drawer/notification-item.tsx new file mode 100644 index 00000000..e6e3ee05 --- /dev/null +++ b/app/frontend/src/layouts/components/notifications-drawer/notification-item.tsx @@ -0,0 +1,239 @@ +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Avatar from '@mui/material/Avatar'; +import ListItemText from '@mui/material/ListItemText'; +import ListItemAvatar from '@mui/material/ListItemAvatar'; +import ListItemButton from '@mui/material/ListItemButton'; + +import { fToNow } from 'src/utils/format-time'; + +import { CONFIG } from 'src/global-config'; + +import { Label } from 'src/components/label'; +import { FileThumbnail } from 'src/components/file-thumbnail'; + +// ---------------------------------------------------------------------- + +export type NotificationItemProps = { + notification: { + id: string; + type: string; + title: string; + category: string; + isUnRead: boolean; + avatarUrl: string | null; + createdAt: string | number | null; + }; +}; + +const readerContent = (data: string) => ( + +); + +export function NotificationItem({ notification }: NotificationItemProps) { + const renderAvatar = () => ( + + {notification.avatarUrl ? ( + + ) : ( + + + + )} + + ); + + const renderText = () => ( + + {fToNow(notification.createdAt)} + + {notification.category} + + } + slotProps={{ + primary: { + sx: { mb: 0.5 }, + }, + secondary: { + sx: { + gap: 0.5, + display: 'flex', + alignItems: 'center', + typography: 'caption', + color: 'text.disabled', + }, + }, + }} + /> + ); + + const renderUnReadBadge = () => + notification.isUnRead && ( + + ); + + const renderFriendAction = () => ( + + + + + ); + + const renderProjectAction = () => ( + <> + + {readerContent( + `

          @Jaydon Frankie feedback by asking questions or just leave a note of appreciation.

          ` + )} +
          + + + + ); + + const renderFileAction = () => ( + ({ + p: theme.spacing(1.5, 1.5, 1.5, 1), + gap: 1, + mt: 1.5, + display: 'flex', + borderRadius: 1.5, + bgcolor: 'background.neutral', + })} + > + + + ({ + color: 'text.secondary', + fontSize: theme.typography.pxToRem(13), + }), + }, + secondary: { + sx: { + mt: 0.25, + typography: 'caption', + color: 'text.disabled', + }, + }, + }} + /> + + + + ); + + const renderTagsAction = () => ( + + + + + + ); + + const renderPaymentAction = () => ( + + + + + ); + + return ( + ({ + p: 2.5, + alignItems: 'flex-start', + borderBottom: `dashed 1px ${theme.vars.palette.divider}`, + }), + ]} + > + {renderUnReadBadge()} + {renderAvatar()} + + + {renderText()} + {notification.type === 'friend' && renderFriendAction()} + {notification.type === 'project' && renderProjectAction()} + {notification.type === 'file' && renderFileAction()} + {notification.type === 'tags' && renderTagsAction()} + {notification.type === 'payment' && renderPaymentAction()} + + + ); +} diff --git a/app/frontend/src/layouts/components/searchbar/index.tsx b/app/frontend/src/layouts/components/searchbar/index.tsx new file mode 100644 index 00000000..bea34b2a --- /dev/null +++ b/app/frontend/src/layouts/components/searchbar/index.tsx @@ -0,0 +1,218 @@ +'use client'; + +import type { BoxProps } from '@mui/material/Box'; +import type { Breakpoint } from '@mui/material/styles'; +import type { NavSectionProps } from 'src/components/nav-section'; + +import parse from 'autosuggest-highlight/parse'; +import match from 'autosuggest-highlight/match'; +import { varAlpha } from 'minimal-shared/utils'; +import { useBoolean } from 'minimal-shared/hooks'; +import { useState, useEffect, useCallback } from 'react'; + +import Box from '@mui/material/Box'; +import SvgIcon from '@mui/material/SvgIcon'; +import MenuList from '@mui/material/MenuList'; +import { useTheme } from '@mui/material/styles'; +import IconButton from '@mui/material/IconButton'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import InputAdornment from '@mui/material/InputAdornment'; +import Dialog, { dialogClasses } from '@mui/material/Dialog'; +import MenuItem, { menuItemClasses } from '@mui/material/MenuItem'; +import InputBase, { inputBaseClasses } from '@mui/material/InputBase'; + +import { Label } from 'src/components/label'; +import { Iconify } from 'src/components/iconify'; +import { Scrollbar } from 'src/components/scrollbar'; +import { SearchNotFound } from 'src/components/search-not-found'; + +import { ResultItem } from './result-item'; +import { applyFilter, flattenNavSections } from './utils'; + +// ---------------------------------------------------------------------- + +export type SearchbarProps = BoxProps & { + data?: NavSectionProps['data']; +}; + +const breakpoint: Breakpoint = 'sm'; + +export function Searchbar({ data: navItems = [], sx, ...other }: SearchbarProps) { + const theme = useTheme(); + const smUp = useMediaQuery(theme.breakpoints.up(breakpoint)); + + const { value: open, onFalse: onClose, onTrue: onOpen, onToggle } = useBoolean(); + const [searchQuery, setSearchQuery] = useState(''); + + const handleClose = useCallback(() => { + onClose(); + setSearchQuery(''); + }, [onClose]); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.metaKey && event.key.toLowerCase() === 'k') { + onToggle(); + setSearchQuery(''); + } + }, + [onToggle] + ); + + useEffect(() => { + window.addEventListener('keydown', handleKeyDown); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [handleKeyDown]); + + const handleSearch = useCallback((event: React.ChangeEvent) => { + setSearchQuery(event.target.value); + }, []); + + const formattedNavItems = flattenNavSections(navItems); + + const dataFiltered = applyFilter({ + inputData: formattedNavItems, + query: searchQuery, + }); + + const notFound = searchQuery && !dataFiltered.length; + + const renderButton = () => ( + + + {/* https://icon-sets.iconify.design/eva/search-fill/ */} + + + + + + + + ); + + const renderList = () => ( + + {dataFiltered.map((item) => { + const partsTitle = parse(item.title, match(item.title, searchQuery)); + const partsPath = parse(item.path, match(item.path, searchQuery)); + + return ( + + + + ); + })} + + ); + + return ( + <> + {renderButton()} + + + + + + } + endAdornment={} + inputProps={{ id: 'search-input' }} + sx={{ + p: 3, + borderBottom: `solid 1px ${theme.vars.palette.divider}`, + [`& .${inputBaseClasses.input}`]: { typography: 'h6' }, + }} + /> + + {notFound ? ( + + ) : ( + {renderList()} + )} + + + ); +} diff --git a/app/frontend/src/layouts/components/searchbar/result-item.tsx b/app/frontend/src/layouts/components/searchbar/result-item.tsx new file mode 100644 index 00000000..34037941 --- /dev/null +++ b/app/frontend/src/layouts/components/searchbar/result-item.tsx @@ -0,0 +1,86 @@ +import type { ListItemButtonProps } from '@mui/material/ListItemButton'; + +import { varAlpha, isExternalLink } from 'minimal-shared/utils'; + +import Box from '@mui/material/Box'; +import ListItemText from '@mui/material/ListItemText'; +import ListItemButton from '@mui/material/ListItemButton'; + +import { RouterLink } from 'src/routes/components'; + +import { Label } from 'src/components/label'; + +// ---------------------------------------------------------------------- + +type Props = Omit & { + href: string; + labels: string[]; + title: { text: string; highlight: boolean }[]; + path: { text: string; highlight: boolean }[]; +}; + +export function ResultItem({ title, path, labels, href, sx, ...other }: Props) { + const linkProps = isExternalLink(href) + ? { target: '_blank', rel: 'noopener noreferrer', href, component: 'a' } + : { component: RouterLink, href }; + + return ( + ({ + borderWidth: 1, + borderStyle: 'dashed', + borderColor: 'transparent', + borderBottomColor: theme.vars.palette.divider, + '&:hover': { + borderRadius: 1, + borderColor: theme.vars.palette.primary.main, + backgroundColor: varAlpha( + theme.vars.palette.primary.mainChannel, + theme.vars.palette.action.hoverOpacity + ), + }, + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + ( + + {part.text} + + ))} + secondary={path.map((part, index) => ( + + {part.text} + + ))} + slotProps={{ + secondary: { + noWrap: true, + sx: { typography: 'caption' }, + }, + }} + /> + + + {[...labels].reverse().map((label) => ( + + ))} + + + ); +} diff --git a/app/frontend/src/layouts/components/searchbar/utils.ts b/app/frontend/src/layouts/components/searchbar/utils.ts new file mode 100644 index 00000000..48dffc24 --- /dev/null +++ b/app/frontend/src/layouts/components/searchbar/utils.ts @@ -0,0 +1,56 @@ +import type { NavSectionProps } from 'src/components/nav-section'; + +// ---------------------------------------------------------------------- + +type NavItem = { + title: string; + path: string; + children?: NavItem[]; +}; + +type OutputItem = { + title: string; + path: string; + group: string; +}; + +const flattenNavItems = (navItems: NavItem[], parentGroup?: string): OutputItem[] => { + let flattenedItems: OutputItem[] = []; + + navItems.forEach((navItem) => { + const currentGroup = parentGroup ? `${parentGroup}-${navItem.title}` : navItem.title; + const groupArray = currentGroup.split('-'); + + flattenedItems.push({ + title: navItem.title, + path: navItem.path, + group: groupArray.length > 2 ? `${groupArray[0]}.${groupArray[1]}` : groupArray[0], + }); + + if (navItem.children) { + flattenedItems = flattenedItems.concat(flattenNavItems(navItem.children, currentGroup)); + } + }); + return flattenedItems; +}; + +export function flattenNavSections(navSections: NavSectionProps['data']): OutputItem[] { + return navSections.flatMap((navSection) => + flattenNavItems(navSection.items, navSection.subheader) + ); +} + +// ---------------------------------------------------------------------- + +type ApplyFilterProps = { + query: string; + inputData: OutputItem[]; +}; + +export function applyFilter({ inputData, query }: ApplyFilterProps): OutputItem[] { + if (!query) return inputData; + + return inputData.filter(({ title, path, group }) => + [title, path, group].some((field) => field?.toLowerCase().includes(query.toLowerCase())) + ); +} diff --git a/app/frontend/src/layouts/components/settings-button.tsx b/app/frontend/src/layouts/components/settings-button.tsx new file mode 100644 index 00000000..5b1715ac --- /dev/null +++ b/app/frontend/src/layouts/components/settings-button.tsx @@ -0,0 +1,50 @@ +import type { IconButtonProps } from '@mui/material/IconButton'; + +import { m } from 'framer-motion'; + +import Badge from '@mui/material/Badge'; +import SvgIcon from '@mui/material/SvgIcon'; +import IconButton from '@mui/material/IconButton'; + +import { useSettingsContext } from 'src/components/settings'; +import { varTap, varHover, transitionTap } from 'src/components/animate'; + +// ---------------------------------------------------------------------- + +export function SettingsButton({ sx, ...other }: IconButtonProps) { + const settings = useSettingsContext(); + + return ( + + + + {/* https://icon-sets.iconify.design/solar/settings-bold-duotone/ */} + + + + + + ); +} diff --git a/app/frontend/src/layouts/components/sign-in-button.tsx b/app/frontend/src/layouts/components/sign-in-button.tsx new file mode 100644 index 00000000..9d81293a --- /dev/null +++ b/app/frontend/src/layouts/components/sign-in-button.tsx @@ -0,0 +1,23 @@ +import type { ButtonProps } from '@mui/material/Button'; + +import Button from '@mui/material/Button'; + +import { RouterLink } from 'src/routes/components'; + +import { CONFIG } from 'src/global-config'; + +// ---------------------------------------------------------------------- + +export function SignInButton({ sx, ...other }: ButtonProps) { + return ( + + ); +} diff --git a/app/frontend/src/layouts/components/sign-out-button.tsx b/app/frontend/src/layouts/components/sign-out-button.tsx new file mode 100644 index 00000000..ba487252 --- /dev/null +++ b/app/frontend/src/layouts/components/sign-out-button.tsx @@ -0,0 +1,77 @@ +import type { ButtonProps } from '@mui/material/Button'; + +import { useCallback } from 'react'; +import { useAuth0 } from '@auth0/auth0-react'; + +import Button from '@mui/material/Button'; + +import { useRouter } from 'src/routes/hooks'; + +import { CONFIG } from 'src/global-config'; + +import { toast } from 'src/components/snackbar'; + +import { useAuthContext } from 'src/auth/hooks'; +import { signOut as jwtSignOut } from 'src/auth/context/jwt/action'; +import { signOut as amplifySignOut } from 'src/auth/context/amplify/action'; +import { signOut as supabaseSignOut } from 'src/auth/context/supabase/action'; +import { signOut as firebaseSignOut } from 'src/auth/context/firebase/action'; + +// ---------------------------------------------------------------------- + +const signOut = + (CONFIG.auth.method === 'supabase' && supabaseSignOut) || + (CONFIG.auth.method === 'firebase' && firebaseSignOut) || + (CONFIG.auth.method === 'amplify' && amplifySignOut) || + jwtSignOut; + +type Props = ButtonProps & { + onClose?: () => void; +}; + +export function SignOutButton({ onClose, sx, ...other }: Props) { + const router = useRouter(); + + const { checkUserSession } = useAuthContext(); + + const { logout: signOutAuth0 } = useAuth0(); + + const handleLogout = useCallback(async () => { + try { + await signOut(); + await checkUserSession?.(); + + onClose?.(); + router.refresh(); + } catch (error) { + console.error(error); + toast.error('Unable to logout!'); + } + }, [checkUserSession, onClose, router]); + + const handleLogoutAuth0 = useCallback(async () => { + try { + await signOutAuth0(); + + onClose?.(); + router.refresh(); + } catch (error) { + console.error(error); + toast.error('Unable to logout!'); + } + }, [onClose, router, signOutAuth0]); + + return ( + + ); +} diff --git a/app/frontend/src/layouts/components/workspaces-popover.tsx b/app/frontend/src/layouts/components/workspaces-popover.tsx new file mode 100644 index 00000000..1633308e --- /dev/null +++ b/app/frontend/src/layouts/components/workspaces-popover.tsx @@ -0,0 +1,146 @@ +'use client'; + +import type { Theme, SxProps } from '@mui/material/styles'; +import type { ButtonBaseProps } from '@mui/material/ButtonBase'; + +import { useState, useCallback } from 'react'; +import { usePopover } from 'minimal-shared/hooks'; + +import Box from '@mui/material/Box'; +import Avatar from '@mui/material/Avatar'; +import MenuList from '@mui/material/MenuList'; +import MenuItem from '@mui/material/MenuItem'; +import ButtonBase from '@mui/material/ButtonBase'; + +import { Label } from 'src/components/label'; +import { Iconify } from 'src/components/iconify'; +import { CustomPopover } from 'src/components/custom-popover'; + +// ---------------------------------------------------------------------- + +export type WorkspacesPopoverProps = ButtonBaseProps & { + data?: { + id: string; + name: string; + logo: string; + plan: string; + }[]; +}; + +export function WorkspacesPopover({ data = [], sx, ...other }: WorkspacesPopoverProps) { + const mediaQuery = 'sm'; + + const { open, anchorEl, onClose, onOpen } = usePopover(); + + const [workspace, setWorkspace] = useState(data[0]); + + const handleChangeWorkspace = useCallback( + (newValue: (typeof data)[0]) => { + setWorkspace(newValue); + onClose(); + }, + [onClose] + ); + + const buttonBg: SxProps = { + height: 1, + zIndex: -1, + opacity: 0, + content: "''", + borderRadius: 1, + position: 'absolute', + visibility: 'hidden', + bgcolor: 'action.hover', + width: 'calc(100% + 8px)', + transition: (theme) => + theme.transitions.create(['opacity', 'visibility'], { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.shorter, + }), + ...(open && { + opacity: 1, + visibility: 'visible', + }), + }; + + const renderButton = () => ( + + + + + {workspace?.name} + + + + + + + ); + + const renderMenuList = () => ( + + + {data.map((option) => ( + handleChangeWorkspace(option)} + sx={{ height: 48 }} + > + + + + {option.name} + + + + + ))} + + + ); + + return ( + <> + {renderButton()} + {renderMenuList()} + + ); +} diff --git a/app/frontend/src/layouts/core/classes.ts b/app/frontend/src/layouts/core/classes.ts new file mode 100644 index 00000000..7058c094 --- /dev/null +++ b/app/frontend/src/layouts/core/classes.ts @@ -0,0 +1,17 @@ +import { createClasses } from 'src/theme/create-classes'; + +// ---------------------------------------------------------------------- + +export const layoutClasses = { + root: createClasses('layout__root'), + main: createClasses('layout__main'), + header: createClasses('layout__header'), + nav: { + root: createClasses('layout__nav__root'), + mobile: createClasses('layout__nav__mobile'), + vertical: createClasses('layout__nav__vertical'), + horizontal: createClasses('layout__nav__horizontal'), + }, + content: createClasses('layout__main__content'), + sidebarContainer: createClasses('layout__sidebar__container'), +}; diff --git a/app/frontend/src/layouts/core/css-vars.ts b/app/frontend/src/layouts/core/css-vars.ts new file mode 100644 index 00000000..2edd9c5f --- /dev/null +++ b/app/frontend/src/layouts/core/css-vars.ts @@ -0,0 +1,14 @@ +import type { Theme } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +export function layoutSectionVars(theme: Theme) { + return { + '--layout-nav-zIndex': theme.zIndex.drawer + 1, + '--layout-nav-mobile-width': '288px', + '--layout-header-blur': '8px', + '--layout-header-zIndex': theme.zIndex.appBar + 1, + '--layout-header-mobile-height': '64px', + '--layout-header-desktop-height': '72px', + }; +} diff --git a/app/frontend/src/layouts/core/header-section.tsx b/app/frontend/src/layouts/core/header-section.tsx new file mode 100644 index 00000000..d8b30694 --- /dev/null +++ b/app/frontend/src/layouts/core/header-section.tsx @@ -0,0 +1,151 @@ +'use client'; + +import type { AppBarProps } from '@mui/material/AppBar'; +import type { ContainerProps } from '@mui/material/Container'; +import type { Theme, SxProps, CSSObject, Breakpoint } from '@mui/material/styles'; + +import { useScrollOffsetTop } from 'minimal-shared/hooks'; +import { varAlpha, mergeClasses } from 'minimal-shared/utils'; + +import AppBar from '@mui/material/AppBar'; +import { styled } from '@mui/material/styles'; +import Container from '@mui/material/Container'; + +import { layoutClasses } from './classes'; + +// ---------------------------------------------------------------------- + +export type HeaderSectionProps = AppBarProps & { + layoutQuery?: Breakpoint; + disableOffset?: boolean; + disableElevation?: boolean; + slots?: { + leftArea?: React.ReactNode; + rightArea?: React.ReactNode; + topArea?: React.ReactNode; + centerArea?: React.ReactNode; + bottomArea?: React.ReactNode; + }; + slotProps?: { + container?: ContainerProps; + centerArea?: React.ComponentProps<'div'> & { sx?: SxProps }; + }; +}; + +export function HeaderSection({ + sx, + slots, + slotProps, + className, + disableOffset, + disableElevation, + layoutQuery = 'md', + ...other +}: HeaderSectionProps) { + const { offsetTop: isOffset } = useScrollOffsetTop(); + + return ( + ({ + ...(isOffset && { + '--color': `var(--offset-color, ${theme.vars.palette.text.primary})`, + }), + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + {slots?.topArea} + + + {slots?.leftArea} + + {slots?.centerArea} + + {slots?.rightArea} + + + {slots?.bottomArea} + + ); +} + +// ---------------------------------------------------------------------- + +type HeaderRootProps = Pick & { + isOffset: boolean; +}; + +const HeaderRoot = styled(AppBar, { + shouldForwardProp: (prop: string) => + !['isOffset', 'disableOffset', 'disableElevation', 'sx'].includes(prop), +})(({ isOffset, disableOffset, disableElevation, theme }) => { + const pauseZindex = { top: -1, bottom: -2 }; + + const pauseStyles: CSSObject = { + opacity: 0, + content: '""', + visibility: 'hidden', + position: 'absolute', + transition: theme.transitions.create(['opacity', 'visibility'], { + easing: theme.transitions.easing.easeInOut, + duration: theme.transitions.duration.shorter, + }), + }; + + const bgStyles: CSSObject = { + ...theme.mixins.bgBlur({ + color: varAlpha(theme.vars.palette.background.defaultChannel, 0.8), + }), + ...pauseStyles, + top: 0, + left: 0, + width: '100%', + height: '100%', + zIndex: pauseZindex.top, + ...(isOffset && { opacity: 1, visibility: 'visible' }), + }; + + const shadowStyles: CSSObject = { + ...pauseStyles, + left: 0, + right: 0, + bottom: 0, + height: 24, + margin: 'auto', + borderRadius: '50%', + width: `calc(100% - 48px)`, + zIndex: pauseZindex.bottom, + boxShadow: theme.vars.customShadows.z8, + ...(isOffset && { opacity: 0.48, visibility: 'visible' }), + }; + + return { + zIndex: 'var(--layout-header-zIndex)', + ...(!disableOffset && { '&::before': bgStyles }), + ...(!disableElevation && { '&::after': shadowStyles }), + }; +}); + +const HeaderContainer = styled(Container, { + shouldForwardProp: (prop: string) => !['layoutQuery', 'sx'].includes(prop), +})>(({ layoutQuery = 'md', theme }) => ({ + display: 'flex', + alignItems: 'center', + color: 'var(--color)', + height: 'var(--layout-header-mobile-height)', + [theme.breakpoints.up(layoutQuery)]: { height: 'var(--layout-header-desktop-height)' }, +})); + +const HeaderCenterArea = styled('div')(() => ({ + display: 'flex', + flex: '1 1 auto', + justifyContent: 'center', +})); diff --git a/app/frontend/src/layouts/core/index.ts b/app/frontend/src/layouts/core/index.ts new file mode 100644 index 00000000..c68992f6 --- /dev/null +++ b/app/frontend/src/layouts/core/index.ts @@ -0,0 +1,9 @@ +export * from './classes'; + +export * from './css-vars'; + +export * from './main-section'; + +export * from './layout-section'; + +export * from './header-section'; diff --git a/app/frontend/src/layouts/core/layout-section.tsx b/app/frontend/src/layouts/core/layout-section.tsx new file mode 100644 index 00000000..c48b92a7 --- /dev/null +++ b/app/frontend/src/layouts/core/layout-section.tsx @@ -0,0 +1,77 @@ +'use client'; + +import type { Theme, SxProps, CSSObject } from '@mui/material/styles'; + +import { mergeClasses } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; +import GlobalStyles from '@mui/material/GlobalStyles'; + +import { layoutClasses } from './classes'; +import { layoutSectionVars } from './css-vars'; + +// ---------------------------------------------------------------------- + +export type LayoutSectionProps = React.ComponentProps<'div'> & { + sx?: SxProps; + cssVars?: CSSObject; + children?: React.ReactNode; + footerSection?: React.ReactNode; + headerSection?: React.ReactNode; + sidebarSection?: React.ReactNode; +}; + +export function LayoutSection({ + sx, + cssVars, + children, + footerSection, + headerSection, + sidebarSection, + className, + ...other +}: LayoutSectionProps) { + const inputGlobalStyles = ( + ({ body: { ...layoutSectionVars(theme), ...cssVars } })} /> + ); + + return ( + <> + {inputGlobalStyles} + + + {sidebarSection ? ( + <> + {sidebarSection} + + {headerSection} + {children} + {footerSection} + + + ) : ( + <> + {headerSection} + {children} + {footerSection} + + )} + + + ); +} + +// ---------------------------------------------------------------------- + +const LayoutRoot = styled('div')``; + +const LayoutSidebarContainer = styled('div')(() => ({ + display: 'flex', + flex: '1 1 auto', + flexDirection: 'column', +})); diff --git a/app/frontend/src/layouts/core/main-section.tsx b/app/frontend/src/layouts/core/main-section.tsx new file mode 100644 index 00000000..74c5cb7c --- /dev/null +++ b/app/frontend/src/layouts/core/main-section.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { mergeClasses } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; + +import { layoutClasses } from './classes'; + +// ---------------------------------------------------------------------- + +export type MainSectionProps = React.ComponentProps; + +export function MainSection({ children, className, sx, ...other }: MainSectionProps) { + return ( + + {children} + + ); +} + +// ---------------------------------------------------------------------- + +const MainRoot = styled('main')({ + display: 'flex', + flex: '1 1 auto', + flexDirection: 'column', +}); diff --git a/app/frontend/src/layouts/dashboard/content.tsx b/app/frontend/src/layouts/dashboard/content.tsx new file mode 100644 index 00000000..38b19c2d --- /dev/null +++ b/app/frontend/src/layouts/dashboard/content.tsx @@ -0,0 +1,94 @@ +'use client'; + +import type { Breakpoint } from '@mui/material/styles'; +import type { ContainerProps } from '@mui/material/Container'; + +import { mergeClasses } from 'minimal-shared/utils'; + +import { styled } from '@mui/material/styles'; +import Container from '@mui/material/Container'; + +import { useSettingsContext } from 'src/components/settings'; + +import { layoutClasses } from '../core/classes'; + +// ---------------------------------------------------------------------- + +export type DashboardContentProps = ContainerProps & { + layoutQuery?: Breakpoint; + disablePadding?: boolean; +}; + +export function DashboardContent({ + sx, + children, + className, + disablePadding, + maxWidth = 'lg', + layoutQuery = 'lg', + ...other +}: DashboardContentProps) { + const settings = useSettingsContext(); + + const isNavHorizontal = settings.state.navLayout === 'horizontal'; + + return ( + ({ + display: 'flex', + flex: '1 1 auto', + flexDirection: 'column', + pt: 'var(--layout-dashboard-content-pt)', + pb: 'var(--layout-dashboard-content-pb)', + [theme.breakpoints.up(layoutQuery)]: { + px: 'var(--layout-dashboard-content-px)', + ...(isNavHorizontal && { '--layout-dashboard-content-pt': '40px' }), + }, + ...(disablePadding && { + p: { + xs: 0, + sm: 0, + md: 0, + lg: 0, + xl: 0, + }, + }), + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + {...other} + > + {children} + + ); +} + +// ---------------------------------------------------------------------- + +export const VerticalDivider = styled('span')(({ theme }) => ({ + width: 1, + height: 10, + flexShrink: 0, + display: 'none', + position: 'relative', + alignItems: 'center', + flexDirection: 'column', + marginLeft: theme.spacing(2.5), + marginRight: theme.spacing(2.5), + backgroundColor: 'currentColor', + color: theme.vars.palette.divider, + '&::before, &::after': { + top: -5, + width: 3, + height: 3, + content: '""', + flexShrink: 0, + borderRadius: '50%', + position: 'absolute', + backgroundColor: 'currentColor', + }, + '&::after': { bottom: -5, top: 'auto' }, +})); diff --git a/app/frontend/src/layouts/dashboard/css-vars.ts b/app/frontend/src/layouts/dashboard/css-vars.ts new file mode 100644 index 00000000..d6c6487a --- /dev/null +++ b/app/frontend/src/layouts/dashboard/css-vars.ts @@ -0,0 +1,87 @@ +import type { SettingsState } from 'src/components/settings'; +import type { Theme, CSSObject } from '@mui/material/styles'; + +import { varAlpha } from 'minimal-shared/utils'; + +import { bulletColor } from 'src/components/nav-section'; + +// ---------------------------------------------------------------------- + +export function dashboardLayoutVars(theme: Theme) { + return { + '--layout-transition-easing': 'linear', + '--layout-transition-duration': '120ms', + '--layout-nav-mini-width': '88px', + '--layout-nav-vertical-width': '300px', + '--layout-nav-horizontal-height': '64px', + '--layout-dashboard-content-pt': theme.spacing(1), + '--layout-dashboard-content-pb': theme.spacing(8), + '--layout-dashboard-content-px': theme.spacing(5), + }; +} + +// ---------------------------------------------------------------------- + +export function dashboardNavColorVars( + theme: Theme, + navColor: SettingsState['navColor'] = 'integrate', + navLayout: SettingsState['navLayout'] = 'vertical' +): Record<'layout' | 'section', CSSObject | undefined> { + const { + vars: { palette }, + } = theme; + + switch (navColor) { + case 'integrate': + return { + layout: { + '--layout-nav-bg': palette.background.default, + '--layout-nav-horizontal-bg': varAlpha(palette.background.defaultChannel, 0.8), + '--layout-nav-border-color': varAlpha(palette.grey['500Channel'], 0.12), + '--layout-nav-text-primary-color': palette.text.primary, + '--layout-nav-text-secondary-color': palette.text.secondary, + '--layout-nav-text-disabled-color': palette.text.disabled, + ...theme.applyStyles('dark', { + '--layout-nav-border-color': varAlpha(palette.grey['500Channel'], 0.08), + '--layout-nav-horizontal-bg': varAlpha(palette.background.defaultChannel, 0.96), + }), + }, + section: undefined, + }; + case 'apparent': + return { + layout: { + '--layout-nav-bg': palette.grey[900], + '--layout-nav-horizontal-bg': varAlpha(palette.grey['900Channel'], 0.96), + '--layout-nav-border-color': 'transparent', + '--layout-nav-text-primary-color': palette.common.white, + '--layout-nav-text-secondary-color': palette.grey[500], + '--layout-nav-text-disabled-color': palette.grey[600], + ...theme.applyStyles('dark', { + '--layout-nav-bg': palette.grey[800], + '--layout-nav-horizontal-bg': varAlpha(palette.grey['800Channel'], 0.8), + }), + }, + section: { + // caption + '--nav-item-caption-color': palette.grey[600], + // subheader + '--nav-subheader-color': palette.grey[600], + '--nav-subheader-hover-color': palette.common.white, + // item + '--nav-item-color': palette.grey[500], + '--nav-item-root-active-color': palette.primary.light, + '--nav-item-root-open-color': palette.common.white, + // bullet + '--nav-bullet-light-color': bulletColor.dark, + // sub + ...(navLayout === 'vertical' && { + '--nav-item-sub-active-color': palette.common.white, + '--nav-item-sub-open-color': palette.common.white, + }), + }, + }; + default: + throw new Error(`Invalid color: ${navColor}`); + } +} diff --git a/app/frontend/src/layouts/dashboard/index.ts b/app/frontend/src/layouts/dashboard/index.ts new file mode 100644 index 00000000..fa1aa0e7 --- /dev/null +++ b/app/frontend/src/layouts/dashboard/index.ts @@ -0,0 +1,3 @@ +export * from './layout'; + +export * from './content'; diff --git a/app/frontend/src/layouts/dashboard/layout.tsx b/app/frontend/src/layouts/dashboard/layout.tsx new file mode 100644 index 00000000..e5a32e28 --- /dev/null +++ b/app/frontend/src/layouts/dashboard/layout.tsx @@ -0,0 +1,226 @@ +'use client'; + +import type { Breakpoint } from '@mui/material/styles'; +import type { NavSectionProps } from 'src/components/nav-section'; + +import { merge } from 'es-toolkit'; +import { useBoolean } from 'minimal-shared/hooks'; + +import Box from '@mui/material/Box'; +import Alert from '@mui/material/Alert'; +import { useTheme } from '@mui/material/styles'; +import { iconButtonClasses } from '@mui/material/IconButton'; + +import { allLangs } from 'src/locales'; +import { _contacts, _notifications } from 'src/_mock'; + +import { Logo } from 'src/components/logo'; +import { useSettingsContext } from 'src/components/settings'; + +import { NavMobile } from './nav-mobile'; +import { VerticalDivider } from './content'; +import { NavVertical } from './nav-vertical'; +import { layoutClasses } from '../core/classes'; +import { NavHorizontal } from './nav-horizontal'; +import { _account } from '../nav-config-account'; +import { MainSection } from '../core/main-section'; +import { Searchbar } from '../components/searchbar'; +import { _workspaces } from '../nav-config-workspace'; +import { MenuButton } from '../components/menu-button'; +import { HeaderSection } from '../core/header-section'; +import { LayoutSection } from '../core/layout-section'; +import { AccountDrawer } from '../components/account-drawer'; +import { SettingsButton } from '../components/settings-button'; +import { LanguagePopover } from '../components/language-popover'; +import { ContactsPopover } from '../components/contacts-popover'; +import { WorkspacesPopover } from '../components/workspaces-popover'; +import { navData as dashboardNavData } from '../nav-config-dashboard'; +import { dashboardLayoutVars, dashboardNavColorVars } from './css-vars'; +import { NotificationsDrawer } from '../components/notifications-drawer'; + +import type { MainSectionProps } from '../core/main-section'; +import type { HeaderSectionProps } from '../core/header-section'; +import type { LayoutSectionProps } from '../core/layout-section'; + +// ---------------------------------------------------------------------- + +type LayoutBaseProps = Pick; + +export type DashboardLayoutProps = LayoutBaseProps & { + layoutQuery?: Breakpoint; + slotProps?: { + header?: HeaderSectionProps; + nav?: { + data?: NavSectionProps['data']; + }; + main?: MainSectionProps; + }; +}; + +export function DashboardLayout({ + sx, + cssVars, + children, + slotProps, + layoutQuery = 'lg', +}: DashboardLayoutProps) { + const theme = useTheme(); + + const settings = useSettingsContext(); + + const navVars = dashboardNavColorVars(theme, settings.state.navColor, settings.state.navLayout); + + const { value: open, onFalse: onClose, onTrue: onOpen } = useBoolean(); + + const navData = slotProps?.nav?.data ?? dashboardNavData; + + const isNavMini = settings.state.navLayout === 'mini'; + const isNavHorizontal = settings.state.navLayout === 'horizontal'; + const isNavVertical = isNavMini || settings.state.navLayout === 'vertical'; + + const renderHeader = () => { + const headerSlotProps: HeaderSectionProps['slotProps'] = { + container: { + maxWidth: false, + sx: { + ...(isNavVertical && { px: { [layoutQuery]: 5 } }), + ...(isNavHorizontal && { + bgcolor: 'var(--layout-nav-bg)', + height: { [layoutQuery]: 'var(--layout-nav-horizontal-height)' }, + [`& .${iconButtonClasses.root}`]: { color: 'var(--layout-nav-text-secondary-color)' }, + }), + }, + }, + }; + + const headerSlots: HeaderSectionProps['slots'] = { + topArea: ( + + This is an info Alert. + + ), + bottomArea: isNavHorizontal ? ( + + ) : null, + leftArea: ( + <> + {/** @slot Nav mobile */} + + + + {/** @slot Logo */} + {isNavHorizontal && ( + + )} + + {/** @slot Divider */} + {isNavHorizontal && ( + + )} + + {/** @slot Workspace popover */} + + + ), + rightArea: ( + + {/** @slot Searchbar */} + + + {/** @slot Language popover */} + + + {/** @slot Notifications popover */} + + + {/** @slot Contacts popover */} + + + {/** @slot Settings button */} + + + {/** @slot Account drawer */} + + + ), + }; + + return ( + + ); + }; + + const renderSidebar = () => ( + + settings.setField( + 'navLayout', + settings.state.navLayout === 'vertical' ? 'mini' : 'vertical' + ) + } + /> + ); + + const renderFooter = () => null; + + const renderMain = () => {children}; + + return ( + + {renderMain()} + + ); +} diff --git a/app/frontend/src/layouts/dashboard/nav-horizontal.tsx b/app/frontend/src/layouts/dashboard/nav-horizontal.tsx new file mode 100644 index 00000000..d8dea591 --- /dev/null +++ b/app/frontend/src/layouts/dashboard/nav-horizontal.tsx @@ -0,0 +1,64 @@ +import type { Breakpoint } from '@mui/material/styles'; +import type { NavSectionProps } from 'src/components/nav-section'; + +import { varAlpha, mergeClasses } from 'minimal-shared/utils'; + +import Box from '@mui/material/Box'; +import Divider from '@mui/material/Divider'; + +import { NavSectionHorizontal } from 'src/components/nav-section'; + +import { layoutClasses } from '../core/classes'; + +// ---------------------------------------------------------------------- + +export type NavHorizontalProps = NavSectionProps & { + layoutQuery?: Breakpoint; +}; + +export function NavHorizontal({ + sx, + data, + className, + layoutQuery = 'md', + ...other +}: NavHorizontalProps) { + return ( + ({ + width: 1, + position: 'relative', + flexDirection: 'column', + display: { xs: 'none', [layoutQuery]: 'flex' }, + borderBottom: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.08)}`, + }), + ...(Array.isArray(sx) ? sx : [sx]), + ]} + > + + + + + + + ); +} diff --git a/app/frontend/src/layouts/dashboard/nav-mobile.tsx b/app/frontend/src/layouts/dashboard/nav-mobile.tsx new file mode 100644 index 00000000..e320c183 --- /dev/null +++ b/app/frontend/src/layouts/dashboard/nav-mobile.tsx @@ -0,0 +1,69 @@ +import type { NavSectionProps } from 'src/components/nav-section'; + +import { useEffect } from 'react'; +import { mergeClasses } from 'minimal-shared/utils'; + +import Box from '@mui/material/Box'; +import Drawer from '@mui/material/Drawer'; + +import { usePathname } from 'src/routes/hooks'; + +import { Logo } from 'src/components/logo'; +import { Scrollbar } from 'src/components/scrollbar'; +import { NavSectionVertical } from 'src/components/nav-section'; + +import { layoutClasses } from '../core/classes'; +import { NavUpgrade } from '../components/nav-upgrade'; + +// ---------------------------------------------------------------------- + +type NavMobileProps = NavSectionProps & { + open: boolean; + onClose: () => void; + slots?: { + topArea?: React.ReactNode; + bottomArea?: React.ReactNode; + }; +}; + +export function NavMobile({ data, open, onClose, slots, sx, className, ...other }: NavMobileProps) { + const pathname = usePathname(); + + useEffect(() => { + if (open) { + onClose(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pathname]); + + return ( + ({ + overflow: 'unset', + bgcolor: 'var(--layout-nav-bg)', + width: 'var(--layout-nav-mobile-width)', + }), + ...(Array.isArray(sx) ? sx : [sx]), + ], + }} + > + {slots?.topArea ?? ( + + + + )} + + + + + + + {slots?.bottomArea} + + ); +} diff --git a/app/frontend/src/layouts/dashboard/nav-vertical.tsx b/app/frontend/src/layouts/dashboard/nav-vertical.tsx new file mode 100644 index 00000000..787c3201 --- /dev/null +++ b/app/frontend/src/layouts/dashboard/nav-vertical.tsx @@ -0,0 +1,130 @@ +import type { NavSectionProps } from 'src/components/nav-section'; +import type { Theme, SxProps, CSSObject, Breakpoint } from '@mui/material/styles'; + +import { varAlpha, mergeClasses } from 'minimal-shared/utils'; + +import Box from '@mui/material/Box'; +import { styled } from '@mui/material/styles'; + +import { Logo } from 'src/components/logo'; +import { Scrollbar } from 'src/components/scrollbar'; +import { NavSectionMini, NavSectionVertical } from 'src/components/nav-section'; + +import { layoutClasses } from '../core/classes'; +import { NavUpgrade } from '../components/nav-upgrade'; +import { NavToggleButton } from '../components/nav-toggle-button'; + +// ---------------------------------------------------------------------- + +export type NavVerticalProps = React.ComponentProps<'div'> & { + isNavMini: boolean; + sx?: SxProps; + cssVars?: CSSObject; + layoutQuery?: Breakpoint; + onToggleNav: () => void; + data: NavSectionProps['data']; + slots?: { + topArea?: React.ReactNode; + bottomArea?: React.ReactNode; + }; +}; + +export function NavVertical({ + sx, + data, + slots, + cssVars, + className, + isNavMini, + onToggleNav, + layoutQuery = 'md', + ...other +}: NavVerticalProps) { + const renderNavVertical = () => ( + <> + {slots?.topArea ?? ( + + + + )} + + + + + {slots?.bottomArea ?? } + + + ); + + const renderNavMini = () => ( + <> + {slots?.topArea ?? ( + + + + )} + + ({ + ...theme.mixins.hideScrollY, + pb: 2, + px: 0.5, + flex: '1 1 auto', + overflowY: 'auto', + }), + ]} + /> + + {slots?.bottomArea} + + ); + + return ( + + ({ + display: 'none', + [theme.breakpoints.up(layoutQuery)]: { display: 'inline-flex' }, + }), + ]} + /> + {isNavMini ? renderNavMini() : renderNavVertical()} + + ); +} + +// ---------------------------------------------------------------------- + +const NavRoot = styled('div', { + shouldForwardProp: (prop: string) => !['isNavMini', 'layoutQuery', 'sx'].includes(prop), +})>( + ({ isNavMini, layoutQuery = 'md', theme }) => ({ + top: 0, + left: 0, + height: '100%', + display: 'none', + position: 'fixed', + flexDirection: 'column', + zIndex: 'var(--layout-nav-zIndex)', + backgroundColor: 'var(--layout-nav-bg)', + width: isNavMini ? 'var(--layout-nav-mini-width)' : 'var(--layout-nav-vertical-width)', + borderRight: `1px solid var(--layout-nav-border-color, ${varAlpha(theme.vars.palette.grey['500Channel'], 0.12)})`, + transition: theme.transitions.create(['width'], { + easing: 'var(--layout-transition-easing)', + duration: 'var(--layout-transition-duration)', + }), + [theme.breakpoints.up(layoutQuery)]: { display: 'flex' }, + }) +); diff --git a/app/frontend/src/layouts/main/footer.tsx b/app/frontend/src/layouts/main/footer.tsx new file mode 100644 index 00000000..049c240a --- /dev/null +++ b/app/frontend/src/layouts/main/footer.tsx @@ -0,0 +1,186 @@ +import type { Breakpoint } from '@mui/material/styles'; + +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Grid from '@mui/material/Grid2'; +import Divider from '@mui/material/Divider'; +import { styled } from '@mui/material/styles'; +import Container from '@mui/material/Container'; +import IconButton from '@mui/material/IconButton'; +import Typography from '@mui/material/Typography'; + +import { paths } from 'src/routes/paths'; +import { RouterLink } from 'src/routes/components'; + +import { _socials } from 'src/_mock'; +import { TwitterIcon, FacebookIcon, LinkedinIcon, InstagramIcon } from 'src/assets/icons'; + +import { Logo } from 'src/components/logo'; + +// ---------------------------------------------------------------------- + +const LINKS = [ + { + headline: 'Minimal', + children: [ + { name: 'About us', href: paths.about }, + { name: 'Contact us', href: paths.contact }, + { name: 'FAQs', href: paths.faqs }, + ], + }, + { + headline: 'Legal', + children: [ + { name: 'Terms and condition', href: '#' }, + { name: 'Privacy policy', href: '#' }, + ], + }, + { headline: 'Contact', children: [{ name: 'support@minimals.cc', href: '#' }] }, +]; + +// ---------------------------------------------------------------------- + +const FooterRoot = styled('footer')(({ theme }) => ({ + position: 'relative', + backgroundColor: theme.vars.palette.background.default, +})); + +export type FooterProps = React.ComponentProps; + +export function Footer({ + sx, + layoutQuery = 'md', + ...other +}: FooterProps & { layoutQuery?: Breakpoint }) { + return ( + + + + ({ + pb: 5, + pt: 10, + textAlign: 'center', + [theme.breakpoints.up(layoutQuery)]: { textAlign: 'unset' }, + })} + > + + + ({ + mt: 3, + justifyContent: 'center', + [theme.breakpoints.up(layoutQuery)]: { justifyContent: 'space-between' }, + }), + ]} + > + + ({ + mx: 'auto', + maxWidth: 280, + [theme.breakpoints.up(layoutQuery)]: { mx: 'unset' }, + })} + > + The starting point for your next project with Minimal UI Kit, built on the newest + version of Material-UI ©, ready to be customized to your style. + + + ({ + mt: 3, + mb: 5, + display: 'flex', + justifyContent: 'center', + [theme.breakpoints.up(layoutQuery)]: { mb: 0, justifyContent: 'flex-start' }, + })} + > + {_socials.map((social) => ( + + {social.value === 'twitter' && } + {social.value === 'facebook' && } + {social.value === 'instagram' && } + {social.value === 'linkedin' && } + + ))} + + + + + ({ + gap: 5, + display: 'flex', + flexDirection: 'column', + [theme.breakpoints.up(layoutQuery)]: { flexDirection: 'row' }, + })} + > + {LINKS.map((list) => ( + ({ + gap: 2, + width: 1, + display: 'flex', + alignItems: 'center', + flexDirection: 'column', + [theme.breakpoints.up(layoutQuery)]: { alignItems: 'flex-start' }, + })} + > + + {list.headline} + + + {list.children.map((link) => ( + + {link.name} + + ))} + + ))} + + + + + + © All rights reserved. + + + + ); +} + +// ---------------------------------------------------------------------- + +export function HomeFooter({ sx, ...other }: FooterProps) { + return ( + + + + + © All rights reserved. +
          made by + minimals.cc +
          +
          +
          + ); +} diff --git a/app/frontend/src/layouts/main/index.ts b/app/frontend/src/layouts/main/index.ts new file mode 100644 index 00000000..5d15fe1b --- /dev/null +++ b/app/frontend/src/layouts/main/index.ts @@ -0,0 +1 @@ +export * from './layout'; diff --git a/app/frontend/src/layouts/main/layout.tsx b/app/frontend/src/layouts/main/layout.tsx new file mode 100644 index 00000000..269e19ec --- /dev/null +++ b/app/frontend/src/layouts/main/layout.tsx @@ -0,0 +1,163 @@ +'use client'; + +import type { Breakpoint } from '@mui/material/styles'; + +import { useBoolean } from 'minimal-shared/hooks'; + +import Box from '@mui/material/Box'; +import Alert from '@mui/material/Alert'; +import Button from '@mui/material/Button'; + +import { paths } from 'src/routes/paths'; +import { usePathname } from 'src/routes/hooks'; + +import { Logo } from 'src/components/logo'; + +import { NavMobile } from './nav/mobile'; +import { NavDesktop } from './nav/desktop'; +import { Footer, HomeFooter } from './footer'; +import { MainSection } from '../core/main-section'; +import { MenuButton } from '../components/menu-button'; +import { LayoutSection } from '../core/layout-section'; +import { HeaderSection } from '../core/header-section'; +import { navData as mainNavData } from '../nav-config-main'; +import { SignInButton } from '../components/sign-in-button'; +import { SettingsButton } from '../components/settings-button'; + +import type { FooterProps } from './footer'; +import type { NavMainProps } from './nav/types'; +import type { MainSectionProps } from '../core/main-section'; +import type { HeaderSectionProps } from '../core/header-section'; +import type { LayoutSectionProps } from '../core/layout-section'; + +// ---------------------------------------------------------------------- + +type LayoutBaseProps = Pick; + +export type MainLayoutProps = LayoutBaseProps & { + layoutQuery?: Breakpoint; + slotProps?: { + header?: HeaderSectionProps; + nav?: { + data?: NavMainProps['data']; + }; + main?: MainSectionProps; + footer?: FooterProps; + }; +}; + +export function MainLayout({ + sx, + cssVars, + children, + slotProps, + layoutQuery = 'md', +}: MainLayoutProps) { + const pathname = usePathname(); + + const { value: open, onFalse: onClose, onTrue: onOpen } = useBoolean(); + + const isHomePage = pathname === '/'; + + const navData = slotProps?.nav?.data ?? mainNavData; + + const renderHeader = () => { + const headerSlots: HeaderSectionProps['slots'] = { + topArea: ( + + This is an info Alert. + + ), + leftArea: ( + <> + {/** @slot Nav mobile */} + ({ + mr: 1, + ml: -1, + [theme.breakpoints.up(layoutQuery)]: { display: 'none' }, + })} + /> + + + {/** @slot Logo */} + + + ), + rightArea: ( + <> + {/** @slot Nav desktop */} + ({ + display: 'none', + [theme.breakpoints.up(layoutQuery)]: { mr: 2.5, display: 'flex' }, + })} + /> + + + {/** @slot Settings button */} + + + {/** @slot Sign in button */} + + + {/** @slot Purchase button */} + + + + ), + }; + + return ( + + ); + }; + + const renderFooter = () => + isHomePage ? ( + + ) : ( +