diff --git a/.DS_Store b/.DS_Store index d2f61830..bbf79e00 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.env.example b/.env.example index e2c1fe6e..ee78c92c 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,10 @@ RUN_MODE=development # Database migrations -DATABASE_URL="postgres://postgres:postgres@localhost/numeraire" +DATABASE_URL="HERE_YOUR_DATABASE_URL" # Database -SWISSKNIFE_DATABASE__URL="postgres://postgres:postgres@localhost/numeraire" +SWISSKNIFE_DATABASE__URL="HERE_YOUR_DATABASE_URL" # Breez SWISSKNIFE_BREEZ_CONFIG__API_KEY="HERE_YOUR_API_KEY" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 626b4fac..ea86ee20 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,19 +31,32 @@ jobs: - run: make lint - run: make fmt - test: + lint-dashboard: runs-on: ubuntu-latest + defaults: + run: + working-directory: ./dashboard + steps: - - uses: actions/checkout@v4 - - uses: actions/cache@v3 + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - - run: sudo apt-get update && sudo apt-get install -y protobuf-compiler - - uses: dtolnay/rust-toolchain@stable - - run: cargo test --all-features + node-version: "20" + + - name: Enable Corepack + run: corepack enable + + - name: Install dependencies + run: yarn install + + - name: Run ESLint + run: yarn lint + + - name: Run Prettier + run: yarn fm:check + + - name: Run Typecheck + run: yarn ts diff --git a/dashboard/.dockerignore b/dashboard/.dockerignore new file mode 100644 index 00000000..de7d587e --- /dev/null +++ b/dashboard/.dockerignore @@ -0,0 +1,60 @@ +# Node modules directory +node_modules +node_modules/ + +# Dependency directories +bower_components +jspm_packages + +# Log files +*.log +npm-debug.log* + +# Next.js build output +.next +out +dist + +# Production build +/build + +# Miscellaneous +.DS_Store +*.pem +*.pid +*.seed +*.pid.lock + +# dotenv environment variables file +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Editor directories and files +.vscode +.idea +*.suo +*.ntvs* +*.njsproj +*.sln + +# OS-specific files +Thumbs.db +Desktop.ini + +# Coverage directory used by tools like istanbul +coverage +coverage/ + +# Git directories and files +.git +.gitignore +.gitattributes + +# Docker files +Dockerfile +docker-compose.yml + +# Build scripts and configurations +scripts/ \ No newline at end of file diff --git a/dashboard/.editorconfig b/dashboard/.editorconfig new file mode 100644 index 00000000..181aeebb --- /dev/null +++ b/dashboard/.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/dashboard/.env.development b/dashboard/.env.development new file mode 100755 index 00000000..a17a78f1 --- /dev/null +++ b/dashboard/.env.development @@ -0,0 +1,3 @@ +LN_PROVIDER=cln +NEXT_PUBLIC_SERVER_URL=http://localhost:3000 +NEXT_PUBLIC_AUTH_METHOD=jwt diff --git a/dashboard/.env.example b/dashboard/.env.example new file mode 100755 index 00000000..14cf53fe --- /dev/null +++ b/dashboard/.env.example @@ -0,0 +1,18 @@ +LN_PROVIDER=breez + +# App +NEXT_PUBLIC_BASE_PATH= +NEXT_PUBLIC_SERVER_URL= +NEXT_PUBLIC_ASSET_URL= +NEXT_PUBLIC_AUTH_METHOD= +NEXT_PUBLIC_SITENAME= + +# Auth0 +NEXT_PUBLIC_AUTH0_DOMAIN= +NEXT_PUBLIC_AUTH0_CLIENT_ID= +NEXT_PUBLIC_AUTH0_CALLBACK_URL= +NEXT_PUBLIC_AUTH0_AUDIENCE= + +# Supabase +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= diff --git a/dashboard/.env.production b/dashboard/.env.production new file mode 100755 index 00000000..b015dcca --- /dev/null +++ b/dashboard/.env.production @@ -0,0 +1,12 @@ +LN_PROVIDER=cln + +# App +NEXT_PUBLIC_SERVER_URL=https://api.numeraire.tech +NEXT_PUBLIC_AUTH_METHOD=auth0 +NEXT_PUBLIC_SITENAME="Numeraire Dashboard" + +# Auth0 +NEXT_PUBLIC_AUTH0_DOMAIN=auth.numeraire.tech +NEXT_PUBLIC_AUTH0_CLIENT_ID=7Jh0DPs8JoAHNrdTbIYKVDty7YNPWbSM +NEXT_PUBLIC_AUTH0_CALLBACK_URL=https://app.numeraire.tech/login/callback +NEXT_PUBLIC_AUTH0_AUDIENCE=https://swissknife.numeraire.tech/api/v1 diff --git a/dashboard/.eslintignore b/dashboard/.eslintignore new file mode 100644 index 00000000..d0cc6ac4 --- /dev/null +++ b/dashboard/.eslintignore @@ -0,0 +1,48 @@ +# Build directories + +build/_ +dist/_ +public/_ +\*\*/out/_ +**/.next/\* +**/node_modules/\* + +# src/ + +**/reportWebVitals.\* +**/service-worker._ +\*\*/serviceWorkerRegistration._ \*_/setupTests._ + +# eslintrc + +\*_/.eslintrc._ + +# prettier + +**/.prettier.\* +**/prettier.config.\* + +# next + +\*_/next.config._ + +# vite + +\*_/vite.config._ + +# tailwind + +**/postcss.config.\* +**/tailwind.config.\* + +# craco + +\*_/craco.config._ + +# misc + +\*\*/jsconfig.json + +# openapi + +src/lib/swissknife/types.gen.ts diff --git a/dashboard/.eslintrc.js b/dashboard/.eslintrc.js new file mode 100644 index 00000000..270ed58b --- /dev/null +++ b/dashboard/.eslintrc.js @@ -0,0 +1,109 @@ +/** + * @type {import('eslint').ESLint.ConfigData} + */ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + plugins: ['perfectionist', 'unused-imports', '@typescript-eslint', 'prettier'], + extends: ['airbnb', 'airbnb-typescript', 'airbnb/hooks', 'prettier'], + parserOptions: { + sourceType: 'module', + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + project: './tsconfig.json', + }, + settings: { + 'import/resolver': { + typescript: { + project: './tsconfig.json', + }, + }, + }, + /** + * 0 ~ 'off' + * 1 ~ 'warn' + * 2 ~ 'error' + */ + rules: { + // general + 'no-alert': 0, + camelcase: 0, + 'no-console': 0, + 'no-unused-vars': 0, + 'no-nested-ternary': 0, + 'no-param-reassign': 0, + 'no-underscore-dangle': 0, + 'no-restricted-exports': 0, + 'no-promise-executor-return': 0, + 'import/prefer-default-export': 0, + 'prefer-destructuring': [1, { object: true, array: false }], + // typescript + '@typescript-eslint/naming-convention': 0, + '@typescript-eslint/no-use-before-define': 0, + '@typescript-eslint/consistent-type-exports': 1, + '@typescript-eslint/consistent-type-imports': 1, + '@typescript-eslint/no-unused-vars': [1, { args: 'none' }], + // react + 'react/no-children-prop': 0, + 'react/react-in-jsx-scope': 0, + 'react/no-array-index-key': 0, + 'react/require-default-props': 0, + 'react/jsx-props-no-spreading': 0, + 'react/function-component-definition': 0, + 'react/jsx-no-useless-fragment': [1, { allowExpressions: true }], + 'react/no-unstable-nested-components': [1, { allowAsProps: true }], + 'react/jsx-no-duplicate-props': [1, { ignoreCase: false }], + // jsx-a11y + 'jsx-a11y/anchor-is-valid': 0, + 'jsx-a11y/control-has-associated-label': 0, + // unused imports + 'unused-imports/no-unused-imports': 1, + 'unused-imports/no-unused-vars': [ + 0, + { vars: 'all', varsIgnorePattern: '^_', args: 'after-used', argsIgnorePattern: '^_' }, + ], + // perfectionist + 'perfectionist/sort-exports': [1, { order: 'asc', type: 'line-length' }], + 'perfectionist/sort-named-imports': [1, { order: 'asc', type: 'line-length' }], + 'perfectionist/sort-named-exports': [1, { order: 'asc', type: 'line-length' }], + 'perfectionist/sort-imports': [ + 1, + { + order: 'asc', + type: 'line-length', + 'newlines-between': 'always', + groups: [ + 'style', + 'type', + ['builtin', 'external'], + 'custom-mui', + 'custom-routes', + 'custom-hooks', + 'custom-utils', + 'internal', + 'custom-components', + 'custom-sections', + 'custom-auth', + 'custom-types', + ['parent', 'sibling', 'index'], + ['parent-type', 'sibling-type', 'index-type'], + 'object', + 'unknown', + ], + 'custom-groups': { + value: { + ['custom-mui']: '@mui/**', + ['custom-auth']: 'src/auth/**', + ['custom-hooks']: 'src/hooks/**', + ['custom-utils']: 'src/utils/**', + ['custom-types']: 'src/types/**', + ['custom-routes']: 'src/routes/**', + ['custom-sections']: 'src/sections/**', + ['custom-components']: 'src/components/**', + }, + }, + 'internal-pattern': ['src/**'], + }, + ], + }, +}; diff --git a/dashboard/.gitattributes b/dashboard/.gitattributes new file mode 100644 index 00000000..af3ad128 --- /dev/null +++ b/dashboard/.gitattributes @@ -0,0 +1,4 @@ +/.yarn/** linguist-vendored +/.yarn/releases/* binary +/.yarn/plugins/**/* binary +/.pnp.* binary linguist-generated diff --git a/dashboard/.gitignore b/dashboard/.gitignore new file mode 100644 index 00000000..a177fa8b --- /dev/null +++ b/dashboard/.gitignore @@ -0,0 +1,38 @@ +# 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*.local + +# misc +.DS_Store +.vercel +.netlify +.vscode +.eslintcache +.unimportedrc.json +tsconfig.tsbuildinfo + +# Exclude PWA service worker and workbox files +public/sw.js +public/workbox-*.js \ No newline at end of file diff --git a/dashboard/.prettierignore b/dashboard/.prettierignore new file mode 100644 index 00000000..34d65be4 --- /dev/null +++ b/dashboard/.prettierignore @@ -0,0 +1,11 @@ +# Build directories +build/* +dist/* +public/* +**/out/* +**/.next/* +**/node_modules/* + +yarn.lock +package-lock.json +jsconfig.json diff --git a/dashboard/.yarn/install-state.gz b/dashboard/.yarn/install-state.gz new file mode 100644 index 00000000..a0a65bf2 Binary files /dev/null and b/dashboard/.yarn/install-state.gz differ diff --git a/dashboard/.yarnrc.yml b/dashboard/.yarnrc.yml new file mode 100644 index 00000000..3186f3f0 --- /dev/null +++ b/dashboard/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile new file mode 100644 index 00000000..0a93bbb6 --- /dev/null +++ b/dashboard/Dockerfile @@ -0,0 +1,68 @@ +FROM node:20-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .yarnrc.yml* ./ +RUN \ + if [ -f yarn.lock ]; then corepack enable && yarn --frozen-lockfile; \ + elif [ -f package-lock.json ]; then npm ci; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ + else echo "Lockfile not found." && exit 1; \ + fi + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Next.js collects completely anonymous telemetry data about general usage. +# Learn more here: https://nextjs.org/telemetry +# Uncomment the following line in case you want to disable telemetry during the build. +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN \ + if [ -f yarn.lock ]; then corepack enable && yarn run build; \ + elif [ -f package-lock.json ]; then npm run build; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ + else echo "Lockfile not found." && exit 1; \ + fi + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public + +# Set the correct permission for prerender cache +RUN mkdir .next +RUN chown nextjs:nodejs .next +ENV HOSTNAME=0.0.0.0 + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +# Set correct permissions for nextjs user +RUN chown -R nextjs:nodejs /app + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 +# server.js is created by next build from the standalone output +# https://nextjs.org/docs/pages/api-reference/next-config-js/output +CMD ["node", "server.js"] \ No newline at end of file diff --git a/dashboard/README.md b/dashboard/README.md new file mode 100644 index 00000000..0ce43d71 --- /dev/null +++ b/dashboard/README.md @@ -0,0 +1,12 @@ +# Numeraire SwissKnife Dashboard + +NextJS dashboard connecting to the SwissKnife backend. + +## Development + +Run the app with + +``` +yarn install +yarn dev +``` diff --git a/dashboard/next-env.d.ts b/dashboard/next-env.d.ts new file mode 100644 index 00000000..4f11a03d --- /dev/null +++ b/dashboard/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/dashboard/next.config.mjs b/dashboard/next.config.mjs new file mode 100644 index 00000000..9db9e0da --- /dev/null +++ b/dashboard/next.config.mjs @@ -0,0 +1,43 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import withPWA from 'next-pwa'; + +const isStaticExport = 'false'; + +/** + * @type {import('next').NextConfig} + */ +const nextConfig = { + trailingSlash: true, + basePath: process.env.NEXT_PUBLIC_BASE_PATH, + 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; + }, + output: 'standalone', // or 'export' or 'browser' or 'server' or 'experimental-server' + ...(isStaticExport === 'true' && { + output: 'export', + }), +}; + +export default withPWA({ + dest: 'public', + disable: process.env.NODE_ENV === 'development', +})(nextConfig); diff --git a/dashboard/openapi-ts.config.mjs b/dashboard/openapi-ts.config.mjs new file mode 100644 index 00000000..d0026b53 --- /dev/null +++ b/dashboard/openapi-ts.config.mjs @@ -0,0 +1,17 @@ +/** @type {import('@hey-api/openapi-ts').UserConfig} */ +export default { + client: '@hey-api/client-fetch', + input: 'src/lib/openapi.json', + output: { + format: 'prettier', + lint: 'eslint', + path: 'src/lib/swissknife', + }, + types: { + dates: 'types+transform', + enums: 'javascript', + }, + schemas: { + type: 'form', + }, +}; diff --git a/dashboard/package.json b/dashboard/package.json new file mode 100644 index 00000000..8141656c --- /dev/null +++ b/dashboard/package.json @@ -0,0 +1,124 @@ +{ + "name": "swissknife-dashboard", + "author": "Dario Anongba Varela", + "version": "0.1.4", + "email": "dario.varela@numeraire.tech", + "description": "Numeraire Bitcoin SwissKnife Dashboard", + "private": true, + "scripts": { + "dev": "next dev -p 8080", + "start": "next start -p 8080", + "build": "next build", + "lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"", + "lint:fix": "eslint --fix \"src/**/*.{js,jsx,ts,tsx}\"", + "fm:check": "prettier --check \"src/**/*.{js,jsx,ts,tsx}\"", + "fm:fix": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"", + "rm:all": "rm -rf node_modules .next out dist build", + "re:start": "yarn rm:all && yarn install && yarn dev", + "re:build": "yarn rm:all && yarn install && yarn build", + "re:build-npm": "npm run rm:all && npm install && npm run build", + "dev:ts": "yarn dev & yarn ts:watch", + "ts": "tsc --noEmit --incremental", + "ts:watch": "yarn ts --watch", + "start:out": "npx serve@latest out -p 8082", + "openapi-ts": "rm -rf src/lib/swissknife && openapi-ts" + }, + "dependencies": { + "@auth0/auth0-react": "^2.2.4", + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@emotion/cache": "^11.11.0", + "@emotion/react": "^11.11.4", + "@emotion/styled": "^11.11.5", + "@fontsource/inter": "^5.0.18", + "@hey-api/client-fetch": "^0.4.2", + "@hookform/resolvers": "^3.9.0", + "@iconify/react": "^5.0.1", + "@mui/lab": "^5.0.0-alpha.170", + "@mui/material": "^5.15.20", + "@mui/material-nextjs": "^5.15.11", + "@mui/x-data-grid": "^7.7.0", + "@mui/x-date-pickers": "^7.7.0", + "@mui/x-tree-view": "^7.7.0", + "@react-pdf/renderer": "^3.4.4", + "@supabase/supabase-js": "^2.43.4", + "@yudiel/react-qr-scanner": "^2.0.4", + "ajv": "^8.17.1", + "ajv-errors": "^3.0.0", + "ajv-formats": "^3.0.1", + "apexcharts": "^3.49.1", + "autosuggest-highlight": "^3.3.4", + "bech32": "^2.0.0", + "dayjs": "^1.11.11", + "embla-carousel": "^8.1.5", + "embla-carousel-auto-height": "^8.1.5", + "embla-carousel-auto-scroll": "^8.1.5", + "embla-carousel-autoplay": "^8.1.5", + "embla-carousel-react": "^8.1.5", + "framer-motion": "^11.2.10", + "i18next": "^23.11.5", + "i18next-browser-languagedetector": "^8.0.0", + "i18next-resources-to-backend": "^1.2.1", + "jwt-decode": "^4.0.0", + "light-bolt11-decoder": "^3.1.1", + "mui-one-time-password-input": "^2.0.2", + "next": "^14.2.4", + "next-pwa": "^5.6.0", + "nprogress": "^0.2.0", + "react": "^18.3.1", + "react-apexcharts": "^1.4.1", + "react-dom": "^18.3.1", + "react-dropzone": "^14.2.3", + "react-hook-form": "^7.53.0", + "react-i18next": "^14.1.2", + "react-joyride": "^2.8.2", + "react-lazy-load-image-component": "^1.6.0", + "react-markdown": "^9.0.1", + "react-phone-number-input": "^3.4.3", + "react-qrcode-logo": "^3.0.0", + "rehype-highlight": "^7.0.0", + "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.0", + "simplebar-react": "^3.2.5", + "sonner": "^1.5.0", + "stylis": "^4.3.2", + "stylis-plugin-rtl": "^2.1.1", + "swr": "^2.2.5", + "turndown": "^7.2.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@hey-api/openapi-ts": "^0.53.11", + "@svgr/webpack": "^8.1.0", + "@types/autosuggest-highlight": "^3.2.3", + "@types/next-pwa": "^5.6.9", + "@types/node": "^20.14.2", + "@types/nprogress": "^0.2.3", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@types/react-lazy-load-image-component": "^1.6.4", + "@types/stylis": "^4.2.6", + "@types/turndown": "^5.0.4", + "@typescript-eslint/eslint-plugin": "^7.13.0", + "@typescript-eslint/parser": "^7.13.0", + "eslint": "^8.57.0", + "eslint-config-airbnb": "^19.0.4", + "eslint-config-airbnb-typescript": "^18.0.0", + "eslint-config-prettier": "^9.1.0", + "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-import": "^2.29.1", + "eslint-plugin-jsx-a11y": "^6.8.0", + "eslint-plugin-perfectionist": "^2.11.0", + "eslint-plugin-prettier": "^5.1.3", + "eslint-plugin-react": "^7.34.2", + "eslint-plugin-react-hooks": "^4.6.2", + "eslint-plugin-unused-imports": "^3.2.0", + "prettier": "^3.3.2", + "typescript": "^5.4.5" + }, + "packageManager": "yarn@4.4.0", + "engines": { + "node": "20.x" + } +} diff --git a/dashboard/prettier.config.mjs b/dashboard/prettier.config.mjs new file mode 100644 index 00000000..bb0123c6 --- /dev/null +++ b/dashboard/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: 140, + singleQuote: true, + trailingComma: 'es5', +}; + +export default config; diff --git a/dashboard/public/_redirects b/dashboard/public/_redirects new file mode 100644 index 00000000..50a46335 --- /dev/null +++ b/dashboard/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 \ No newline at end of file diff --git a/dashboard/public/assets/background/background-3-blur.webp b/dashboard/public/assets/background/background-3-blur.webp new file mode 100644 index 00000000..106fdca9 Binary files /dev/null and b/dashboard/public/assets/background/background-3-blur.webp differ diff --git a/dashboard/public/assets/background/background-4.jpg b/dashboard/public/assets/background/background-4.jpg new file mode 100644 index 00000000..7a26aad7 Binary files /dev/null and b/dashboard/public/assets/background/background-4.jpg differ diff --git a/dashboard/public/assets/background/overlay.svg b/dashboard/public/assets/background/overlay.svg new file mode 100644 index 00000000..2729ddc7 --- /dev/null +++ b/dashboard/public/assets/background/overlay.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/dashboard/public/assets/cyan-blur.png b/dashboard/public/assets/cyan-blur.png new file mode 100644 index 00000000..b5dbc95d Binary files /dev/null and b/dashboard/public/assets/cyan-blur.png differ diff --git a/dashboard/public/assets/icons/bitcoin/ic-bitcoin-lightning.svg b/dashboard/public/assets/icons/bitcoin/ic-bitcoin-lightning.svg new file mode 100644 index 00000000..7809da95 --- /dev/null +++ b/dashboard/public/assets/icons/bitcoin/ic-bitcoin-lightning.svg @@ -0,0 +1,16 @@ + + + 编组 31 + + + + + + + + + + + + + \ No newline at end of file diff --git a/dashboard/public/assets/icons/bitcoin/ic-bitcoin.svg b/dashboard/public/assets/icons/bitcoin/ic-bitcoin.svg new file mode 100644 index 00000000..063d6ccd --- /dev/null +++ b/dashboard/public/assets/icons/bitcoin/ic-bitcoin.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/dashboard/public/assets/icons/bitcoin/ic-lightning.svg b/dashboard/public/assets/icons/bitcoin/ic-lightning.svg new file mode 100644 index 00000000..d9e946e7 --- /dev/null +++ b/dashboard/public/assets/icons/bitcoin/ic-lightning.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/dashboard/public/assets/icons/brands/ic-brand-breez.svg b/dashboard/public/assets/icons/brands/ic-brand-breez.svg new file mode 100644 index 00000000..166fb81b --- /dev/null +++ b/dashboard/public/assets/icons/brands/ic-brand-breez.svg @@ -0,0 +1,45 @@ + + + + + + diff --git a/dashboard/public/assets/icons/brands/ic-brand-cln-emblem.svg b/dashboard/public/assets/icons/brands/ic-brand-cln-emblem.svg new file mode 100644 index 00000000..116d8adf --- /dev/null +++ b/dashboard/public/assets/icons/brands/ic-brand-cln-emblem.svg @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/dashboard/public/assets/icons/brands/ic-brand-cln.svg b/dashboard/public/assets/icons/brands/ic-brand-cln.svg new file mode 100644 index 00000000..5371e98b --- /dev/null +++ b/dashboard/public/assets/icons/brands/ic-brand-cln.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/assets/icons/brands/ic-brand-greenlight.svg b/dashboard/public/assets/icons/brands/ic-brand-greenlight.svg new file mode 100644 index 00000000..6c2e5d50 --- /dev/null +++ b/dashboard/public/assets/icons/brands/ic-brand-greenlight.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/assets/icons/brands/ic-brand-lnd.png b/dashboard/public/assets/icons/brands/ic-brand-lnd.png new file mode 100644 index 00000000..ab7f0466 Binary files /dev/null and b/dashboard/public/assets/icons/brands/ic-brand-lnd.png differ diff --git a/dashboard/public/assets/icons/components/ic-accordion.svg b/dashboard/public/assets/icons/components/ic-accordion.svg new file mode 100644 index 00000000..28aceb4d --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-accordion.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-alert.svg b/dashboard/public/assets/icons/components/ic-alert.svg new file mode 100644 index 00000000..dd9293dc --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-alert.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-autocomplete.svg b/dashboard/public/assets/icons/components/ic-autocomplete.svg new file mode 100644 index 00000000..bdca6b87 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-autocomplete.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-avatar.svg b/dashboard/public/assets/icons/components/ic-avatar.svg new file mode 100644 index 00000000..58383b21 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-avatar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-badge.svg b/dashboard/public/assets/icons/components/ic-badge.svg new file mode 100644 index 00000000..facf08b4 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-badge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-breadcrumbs.svg b/dashboard/public/assets/icons/components/ic-breadcrumbs.svg new file mode 100644 index 00000000..169b414f --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-breadcrumbs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-buttons.svg b/dashboard/public/assets/icons/components/ic-buttons.svg new file mode 100644 index 00000000..d89e1204 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-buttons.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-checkbox.svg b/dashboard/public/assets/icons/components/ic-checkbox.svg new file mode 100644 index 00000000..77101ec4 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-checkbox.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-chip.svg b/dashboard/public/assets/icons/components/ic-chip.svg new file mode 100644 index 00000000..d8f7666c --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-chip.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-colors.svg b/dashboard/public/assets/icons/components/ic-colors.svg new file mode 100644 index 00000000..07f08bbb --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-colors.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-data-grid.svg b/dashboard/public/assets/icons/components/ic-data-grid.svg new file mode 100644 index 00000000..f33bad86 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-data-grid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-dialog.svg b/dashboard/public/assets/icons/components/ic-dialog.svg new file mode 100644 index 00000000..a5f18ecb --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-dialog.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-extra-animate.svg b/dashboard/public/assets/icons/components/ic-extra-animate.svg new file mode 100644 index 00000000..146b15cb --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-extra-animate.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-extra-carousel.svg b/dashboard/public/assets/icons/components/ic-extra-carousel.svg new file mode 100644 index 00000000..78fa295d --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-extra-carousel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-extra-chart.svg b/dashboard/public/assets/icons/components/ic-extra-chart.svg new file mode 100644 index 00000000..cd5d678e --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-extra-chart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-extra-dnd.svg b/dashboard/public/assets/icons/components/ic-extra-dnd.svg new file mode 100644 index 00000000..35d60370 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-extra-dnd.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-extra-editor.svg b/dashboard/public/assets/icons/components/ic-extra-editor.svg new file mode 100644 index 00000000..355c02fd --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-extra-editor.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-extra-form-validation.svg b/dashboard/public/assets/icons/components/ic-extra-form-validation.svg new file mode 100644 index 00000000..dd37949e --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-extra-form-validation.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-extra-form-wizard.svg b/dashboard/public/assets/icons/components/ic-extra-form-wizard.svg new file mode 100644 index 00000000..781e26ce --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-extra-form-wizard.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/assets/icons/components/ic-extra-image.svg b/dashboard/public/assets/icons/components/ic-extra-image.svg new file mode 100644 index 00000000..5145bc00 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-extra-image.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-extra-label.svg b/dashboard/public/assets/icons/components/ic-extra-label.svg new file mode 100644 index 00000000..37c0c122 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-extra-label.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-extra-lightbox.svg b/dashboard/public/assets/icons/components/ic-extra-lightbox.svg new file mode 100644 index 00000000..b0b61406 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-extra-lightbox.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-extra-map.svg b/dashboard/public/assets/icons/components/ic-extra-map.svg new file mode 100644 index 00000000..684acf09 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-extra-map.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-extra-markdown.svg b/dashboard/public/assets/icons/components/ic-extra-markdown.svg new file mode 100644 index 00000000..2e95c947 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-extra-markdown.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-extra-mega-menu.svg b/dashboard/public/assets/icons/components/ic-extra-mega-menu.svg new file mode 100644 index 00000000..fa83f3f5 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-extra-mega-menu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-extra-multi-language.svg b/dashboard/public/assets/icons/components/ic-extra-multi-language.svg new file mode 100644 index 00000000..fa34fed1 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-extra-multi-language.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-extra-navigation-bar.svg b/dashboard/public/assets/icons/components/ic-extra-navigation-bar.svg new file mode 100644 index 00000000..be234fde --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-extra-navigation-bar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-extra-organization-chart.svg b/dashboard/public/assets/icons/components/ic-extra-organization-chart.svg new file mode 100644 index 00000000..0b6f30a6 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-extra-organization-chart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-extra-scroll-progress.svg b/dashboard/public/assets/icons/components/ic-extra-scroll-progress.svg new file mode 100644 index 00000000..0d3ca1d4 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-extra-scroll-progress.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-extra-scroll.svg b/dashboard/public/assets/icons/components/ic-extra-scroll.svg new file mode 100644 index 00000000..a88d886a --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-extra-scroll.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-extra-snackbar.svg b/dashboard/public/assets/icons/components/ic-extra-snackbar.svg new file mode 100644 index 00000000..269bba89 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-extra-snackbar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-extra-upload.svg b/dashboard/public/assets/icons/components/ic-extra-upload.svg new file mode 100644 index 00000000..105cc34e --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-extra-upload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-extra-utilities.svg b/dashboard/public/assets/icons/components/ic-extra-utilities.svg new file mode 100644 index 00000000..0bd84d27 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-extra-utilities.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-extra-walktour.svg b/dashboard/public/assets/icons/components/ic-extra-walktour.svg new file mode 100644 index 00000000..05973eef --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-extra-walktour.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-grid.svg b/dashboard/public/assets/icons/components/ic-grid.svg new file mode 100644 index 00000000..9c7a38bb --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-grid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-icons.svg b/dashboard/public/assets/icons/components/ic-icons.svg new file mode 100644 index 00000000..7c70ed35 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-icons.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-list.svg b/dashboard/public/assets/icons/components/ic-list.svg new file mode 100644 index 00000000..01854b46 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-list.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-menu.svg b/dashboard/public/assets/icons/components/ic-menu.svg new file mode 100644 index 00000000..df9f13ff --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-menu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-pagination.svg b/dashboard/public/assets/icons/components/ic-pagination.svg new file mode 100644 index 00000000..0176fa99 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-pagination.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-pickers.svg b/dashboard/public/assets/icons/components/ic-pickers.svg new file mode 100644 index 00000000..b7c6de15 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-pickers.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-popover.svg b/dashboard/public/assets/icons/components/ic-popover.svg new file mode 100644 index 00000000..8e8bda11 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-popover.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-progress.svg b/dashboard/public/assets/icons/components/ic-progress.svg new file mode 100644 index 00000000..e736c5a3 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-progress.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-radio-button.svg b/dashboard/public/assets/icons/components/ic-radio-button.svg new file mode 100644 index 00000000..155cce3d --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-radio-button.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-rating.svg b/dashboard/public/assets/icons/components/ic-rating.svg new file mode 100644 index 00000000..7891a50d --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-rating.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-shadows.svg b/dashboard/public/assets/icons/components/ic-shadows.svg new file mode 100644 index 00000000..8fb65c83 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-shadows.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-slider.svg b/dashboard/public/assets/icons/components/ic-slider.svg new file mode 100644 index 00000000..6ded8915 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-slider.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-stepper.svg b/dashboard/public/assets/icons/components/ic-stepper.svg new file mode 100644 index 00000000..d26e70d5 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-stepper.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-switch.svg b/dashboard/public/assets/icons/components/ic-switch.svg new file mode 100644 index 00000000..acc9fd1e --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-switch.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-table.svg b/dashboard/public/assets/icons/components/ic-table.svg new file mode 100644 index 00000000..b11b4ce9 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-table.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-tabs.svg b/dashboard/public/assets/icons/components/ic-tabs.svg new file mode 100644 index 00000000..1eb82085 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-tabs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-textfield.svg b/dashboard/public/assets/icons/components/ic-textfield.svg new file mode 100644 index 00000000..9f6522c4 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-textfield.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-timeline.svg b/dashboard/public/assets/icons/components/ic-timeline.svg new file mode 100644 index 00000000..e0a459fc --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-timeline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-tooltip.svg b/dashboard/public/assets/icons/components/ic-tooltip.svg new file mode 100644 index 00000000..2210cfa2 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-tooltip.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-transfer-list.svg b/dashboard/public/assets/icons/components/ic-transfer-list.svg new file mode 100644 index 00000000..0164eb8d --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-transfer-list.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-tree-view.svg b/dashboard/public/assets/icons/components/ic-tree-view.svg new file mode 100644 index 00000000..3e3b3595 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-tree-view.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/components/ic-typography.svg b/dashboard/public/assets/icons/components/ic-typography.svg new file mode 100644 index 00000000..db2cc1d0 --- /dev/null +++ b/dashboard/public/assets/icons/components/ic-typography.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dashboard/public/assets/icons/empty/ic-content.svg b/dashboard/public/assets/icons/empty/ic-content.svg new file mode 100644 index 00000000..fd62b34e --- /dev/null +++ b/dashboard/public/assets/icons/empty/ic-content.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/assets/icons/files/ic-ai.svg b/dashboard/public/assets/icons/files/ic-ai.svg new file mode 100644 index 00000000..4d8098a8 --- /dev/null +++ b/dashboard/public/assets/icons/files/ic-ai.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/assets/icons/files/ic-audio.svg b/dashboard/public/assets/icons/files/ic-audio.svg new file mode 100644 index 00000000..329f232a --- /dev/null +++ b/dashboard/public/assets/icons/files/ic-audio.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/assets/icons/files/ic-document.svg b/dashboard/public/assets/icons/files/ic-document.svg new file mode 100644 index 00000000..5a53ca0c --- /dev/null +++ b/dashboard/public/assets/icons/files/ic-document.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/assets/icons/files/ic-excel.svg b/dashboard/public/assets/icons/files/ic-excel.svg new file mode 100644 index 00000000..cb80eb2b --- /dev/null +++ b/dashboard/public/assets/icons/files/ic-excel.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/assets/icons/files/ic-file.svg b/dashboard/public/assets/icons/files/ic-file.svg new file mode 100644 index 00000000..f5295c2e --- /dev/null +++ b/dashboard/public/assets/icons/files/ic-file.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/assets/icons/files/ic-folder.svg b/dashboard/public/assets/icons/files/ic-folder.svg new file mode 100644 index 00000000..01f6671e --- /dev/null +++ b/dashboard/public/assets/icons/files/ic-folder.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/dashboard/public/assets/icons/files/ic-img.svg b/dashboard/public/assets/icons/files/ic-img.svg new file mode 100644 index 00000000..a95194a9 --- /dev/null +++ b/dashboard/public/assets/icons/files/ic-img.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/assets/icons/files/ic-js.svg b/dashboard/public/assets/icons/files/ic-js.svg new file mode 100644 index 00000000..266b12ec --- /dev/null +++ b/dashboard/public/assets/icons/files/ic-js.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/dashboard/public/assets/icons/files/ic-pdf.svg b/dashboard/public/assets/icons/files/ic-pdf.svg new file mode 100644 index 00000000..8ed54c94 --- /dev/null +++ b/dashboard/public/assets/icons/files/ic-pdf.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/assets/icons/files/ic-power_point.svg b/dashboard/public/assets/icons/files/ic-power_point.svg new file mode 100644 index 00000000..f2d7f144 --- /dev/null +++ b/dashboard/public/assets/icons/files/ic-power_point.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/assets/icons/files/ic-pts.svg b/dashboard/public/assets/icons/files/ic-pts.svg new file mode 100644 index 00000000..7ecbee0c --- /dev/null +++ b/dashboard/public/assets/icons/files/ic-pts.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/assets/icons/files/ic-txt.svg b/dashboard/public/assets/icons/files/ic-txt.svg new file mode 100644 index 00000000..1d34c348 --- /dev/null +++ b/dashboard/public/assets/icons/files/ic-txt.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/assets/icons/files/ic-video.svg b/dashboard/public/assets/icons/files/ic-video.svg new file mode 100644 index 00000000..fb6eca6c --- /dev/null +++ b/dashboard/public/assets/icons/files/ic-video.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/assets/icons/files/ic-word.svg b/dashboard/public/assets/icons/files/ic-word.svg new file mode 100644 index 00000000..b112fe58 --- /dev/null +++ b/dashboard/public/assets/icons/files/ic-word.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/assets/icons/files/ic-zip.svg b/dashboard/public/assets/icons/files/ic-zip.svg new file mode 100644 index 00000000..f34001e8 --- /dev/null +++ b/dashboard/public/assets/icons/files/ic-zip.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/assets/icons/flagpack/ad.webp b/dashboard/public/assets/icons/flagpack/ad.webp new file mode 100644 index 00000000..97cebc8d Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ad.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ae.webp b/dashboard/public/assets/icons/flagpack/ae.webp new file mode 100644 index 00000000..5e751d1e Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ae.webp differ diff --git a/dashboard/public/assets/icons/flagpack/af.webp b/dashboard/public/assets/icons/flagpack/af.webp new file mode 100644 index 00000000..bae07e08 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/af.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ag.webp b/dashboard/public/assets/icons/flagpack/ag.webp new file mode 100644 index 00000000..47754239 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ag.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ai.webp b/dashboard/public/assets/icons/flagpack/ai.webp new file mode 100644 index 00000000..52a6dfe2 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ai.webp differ diff --git a/dashboard/public/assets/icons/flagpack/al.webp b/dashboard/public/assets/icons/flagpack/al.webp new file mode 100644 index 00000000..92959187 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/al.webp differ diff --git a/dashboard/public/assets/icons/flagpack/am.webp b/dashboard/public/assets/icons/flagpack/am.webp new file mode 100644 index 00000000..d0ead64f Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/am.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ao.webp b/dashboard/public/assets/icons/flagpack/ao.webp new file mode 100644 index 00000000..e2570fbd Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ao.webp differ diff --git a/dashboard/public/assets/icons/flagpack/aq.webp b/dashboard/public/assets/icons/flagpack/aq.webp new file mode 100644 index 00000000..a3a91ab3 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/aq.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ar.webp b/dashboard/public/assets/icons/flagpack/ar.webp new file mode 100644 index 00000000..7f76fceb Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ar.webp differ diff --git a/dashboard/public/assets/icons/flagpack/as.webp b/dashboard/public/assets/icons/flagpack/as.webp new file mode 100644 index 00000000..af0c1800 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/as.webp differ diff --git a/dashboard/public/assets/icons/flagpack/at.webp b/dashboard/public/assets/icons/flagpack/at.webp new file mode 100644 index 00000000..ad36affe Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/at.webp differ diff --git a/dashboard/public/assets/icons/flagpack/au.webp b/dashboard/public/assets/icons/flagpack/au.webp new file mode 100644 index 00000000..b4a10bdd Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/au.webp differ diff --git a/dashboard/public/assets/icons/flagpack/aw.webp b/dashboard/public/assets/icons/flagpack/aw.webp new file mode 100644 index 00000000..b411d8f3 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/aw.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ax.webp b/dashboard/public/assets/icons/flagpack/ax.webp new file mode 100644 index 00000000..6a393622 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ax.webp differ diff --git a/dashboard/public/assets/icons/flagpack/az.webp b/dashboard/public/assets/icons/flagpack/az.webp new file mode 100644 index 00000000..754bea39 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/az.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ba.webp b/dashboard/public/assets/icons/flagpack/ba.webp new file mode 100644 index 00000000..b2ee088f Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ba.webp differ diff --git a/dashboard/public/assets/icons/flagpack/bb.webp b/dashboard/public/assets/icons/flagpack/bb.webp new file mode 100644 index 00000000..cada3b48 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/bb.webp differ diff --git a/dashboard/public/assets/icons/flagpack/bd.webp b/dashboard/public/assets/icons/flagpack/bd.webp new file mode 100644 index 00000000..149536c4 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/bd.webp differ diff --git a/dashboard/public/assets/icons/flagpack/be.webp b/dashboard/public/assets/icons/flagpack/be.webp new file mode 100644 index 00000000..4152ef77 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/be.webp differ diff --git a/dashboard/public/assets/icons/flagpack/bf.webp b/dashboard/public/assets/icons/flagpack/bf.webp new file mode 100644 index 00000000..24b9c089 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/bf.webp differ diff --git a/dashboard/public/assets/icons/flagpack/bg.webp b/dashboard/public/assets/icons/flagpack/bg.webp new file mode 100644 index 00000000..1414d6ab Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/bg.webp differ diff --git a/dashboard/public/assets/icons/flagpack/bh.webp b/dashboard/public/assets/icons/flagpack/bh.webp new file mode 100644 index 00000000..2818a599 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/bh.webp differ diff --git a/dashboard/public/assets/icons/flagpack/bi.webp b/dashboard/public/assets/icons/flagpack/bi.webp new file mode 100644 index 00000000..15dd9f88 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/bi.webp differ diff --git a/dashboard/public/assets/icons/flagpack/bj.webp b/dashboard/public/assets/icons/flagpack/bj.webp new file mode 100644 index 00000000..007ca43f Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/bj.webp differ diff --git a/dashboard/public/assets/icons/flagpack/bl.webp b/dashboard/public/assets/icons/flagpack/bl.webp new file mode 100644 index 00000000..da3c5140 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/bl.webp differ diff --git a/dashboard/public/assets/icons/flagpack/bm.webp b/dashboard/public/assets/icons/flagpack/bm.webp new file mode 100644 index 00000000..04557878 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/bm.webp differ diff --git a/dashboard/public/assets/icons/flagpack/bn.webp b/dashboard/public/assets/icons/flagpack/bn.webp new file mode 100644 index 00000000..df923fd4 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/bn.webp differ diff --git a/dashboard/public/assets/icons/flagpack/bo.webp b/dashboard/public/assets/icons/flagpack/bo.webp new file mode 100644 index 00000000..5702bd4b Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/bo.webp differ diff --git a/dashboard/public/assets/icons/flagpack/bq-bo.webp b/dashboard/public/assets/icons/flagpack/bq-bo.webp new file mode 100644 index 00000000..479dbcd8 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/bq-bo.webp differ diff --git a/dashboard/public/assets/icons/flagpack/bq-sa.webp b/dashboard/public/assets/icons/flagpack/bq-sa.webp new file mode 100644 index 00000000..e04408e1 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/bq-sa.webp differ diff --git a/dashboard/public/assets/icons/flagpack/bq-se.webp b/dashboard/public/assets/icons/flagpack/bq-se.webp new file mode 100644 index 00000000..6c38cdaf Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/bq-se.webp differ diff --git a/dashboard/public/assets/icons/flagpack/br.webp b/dashboard/public/assets/icons/flagpack/br.webp new file mode 100644 index 00000000..cf9a3f6d Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/br.webp differ diff --git a/dashboard/public/assets/icons/flagpack/bs.webp b/dashboard/public/assets/icons/flagpack/bs.webp new file mode 100644 index 00000000..1472f824 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/bs.webp differ diff --git a/dashboard/public/assets/icons/flagpack/bt.webp b/dashboard/public/assets/icons/flagpack/bt.webp new file mode 100644 index 00000000..83a2d233 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/bt.webp differ diff --git a/dashboard/public/assets/icons/flagpack/bv.webp b/dashboard/public/assets/icons/flagpack/bv.webp new file mode 100644 index 00000000..f6d301fb Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/bv.webp differ diff --git a/dashboard/public/assets/icons/flagpack/bw.webp b/dashboard/public/assets/icons/flagpack/bw.webp new file mode 100644 index 00000000..db6d29ef Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/bw.webp differ diff --git a/dashboard/public/assets/icons/flagpack/by.webp b/dashboard/public/assets/icons/flagpack/by.webp new file mode 100644 index 00000000..6793dd83 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/by.webp differ diff --git a/dashboard/public/assets/icons/flagpack/bz.webp b/dashboard/public/assets/icons/flagpack/bz.webp new file mode 100644 index 00000000..29c3fdd2 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/bz.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ca.webp b/dashboard/public/assets/icons/flagpack/ca.webp new file mode 100644 index 00000000..eee39fc7 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ca.webp differ diff --git a/dashboard/public/assets/icons/flagpack/cc.webp b/dashboard/public/assets/icons/flagpack/cc.webp new file mode 100644 index 00000000..f95eaa94 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/cc.webp differ diff --git a/dashboard/public/assets/icons/flagpack/cd.webp b/dashboard/public/assets/icons/flagpack/cd.webp new file mode 100644 index 00000000..1d71aaa5 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/cd.webp differ diff --git a/dashboard/public/assets/icons/flagpack/cf.webp b/dashboard/public/assets/icons/flagpack/cf.webp new file mode 100644 index 00000000..05b3d02c Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/cf.webp differ diff --git a/dashboard/public/assets/icons/flagpack/cg.webp b/dashboard/public/assets/icons/flagpack/cg.webp new file mode 100644 index 00000000..00dd12e2 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/cg.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ch.webp b/dashboard/public/assets/icons/flagpack/ch.webp new file mode 100644 index 00000000..450fa7bd Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ch.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ci.webp b/dashboard/public/assets/icons/flagpack/ci.webp new file mode 100644 index 00000000..fa3def9e Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ci.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ck.webp b/dashboard/public/assets/icons/flagpack/ck.webp new file mode 100644 index 00000000..bad2344b Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ck.webp differ diff --git a/dashboard/public/assets/icons/flagpack/cl.webp b/dashboard/public/assets/icons/flagpack/cl.webp new file mode 100644 index 00000000..16bc2388 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/cl.webp differ diff --git a/dashboard/public/assets/icons/flagpack/cm.webp b/dashboard/public/assets/icons/flagpack/cm.webp new file mode 100644 index 00000000..a670f9d3 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/cm.webp differ diff --git a/dashboard/public/assets/icons/flagpack/cn.webp b/dashboard/public/assets/icons/flagpack/cn.webp new file mode 100644 index 00000000..05383567 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/cn.webp differ diff --git a/dashboard/public/assets/icons/flagpack/co.webp b/dashboard/public/assets/icons/flagpack/co.webp new file mode 100644 index 00000000..766cebc4 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/co.webp differ diff --git a/dashboard/public/assets/icons/flagpack/cr.webp b/dashboard/public/assets/icons/flagpack/cr.webp new file mode 100644 index 00000000..c6c28578 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/cr.webp differ diff --git a/dashboard/public/assets/icons/flagpack/cu.webp b/dashboard/public/assets/icons/flagpack/cu.webp new file mode 100644 index 00000000..82aa0457 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/cu.webp differ diff --git a/dashboard/public/assets/icons/flagpack/cv.webp b/dashboard/public/assets/icons/flagpack/cv.webp new file mode 100644 index 00000000..d813b6d1 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/cv.webp differ diff --git a/dashboard/public/assets/icons/flagpack/cw.webp b/dashboard/public/assets/icons/flagpack/cw.webp new file mode 100644 index 00000000..6a78f7cb Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/cw.webp differ diff --git a/dashboard/public/assets/icons/flagpack/cx.webp b/dashboard/public/assets/icons/flagpack/cx.webp new file mode 100644 index 00000000..b9e490ec Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/cx.webp differ diff --git a/dashboard/public/assets/icons/flagpack/cy.webp b/dashboard/public/assets/icons/flagpack/cy.webp new file mode 100644 index 00000000..2e99fe12 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/cy.webp differ diff --git a/dashboard/public/assets/icons/flagpack/cz.webp b/dashboard/public/assets/icons/flagpack/cz.webp new file mode 100644 index 00000000..2f1a6f77 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/cz.webp differ diff --git a/dashboard/public/assets/icons/flagpack/de.webp b/dashboard/public/assets/icons/flagpack/de.webp new file mode 100644 index 00000000..e2f11ffc Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/de.webp differ diff --git a/dashboard/public/assets/icons/flagpack/dj.webp b/dashboard/public/assets/icons/flagpack/dj.webp new file mode 100644 index 00000000..fea287c0 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/dj.webp differ diff --git a/dashboard/public/assets/icons/flagpack/dk.webp b/dashboard/public/assets/icons/flagpack/dk.webp new file mode 100644 index 00000000..13553716 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/dk.webp differ diff --git a/dashboard/public/assets/icons/flagpack/dm.webp b/dashboard/public/assets/icons/flagpack/dm.webp new file mode 100644 index 00000000..275a5483 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/dm.webp differ diff --git a/dashboard/public/assets/icons/flagpack/do.webp b/dashboard/public/assets/icons/flagpack/do.webp new file mode 100644 index 00000000..c1089845 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/do.webp differ diff --git a/dashboard/public/assets/icons/flagpack/dz.webp b/dashboard/public/assets/icons/flagpack/dz.webp new file mode 100644 index 00000000..c7114f72 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/dz.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ec.webp b/dashboard/public/assets/icons/flagpack/ec.webp new file mode 100644 index 00000000..47fdbf3b Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ec.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ee.webp b/dashboard/public/assets/icons/flagpack/ee.webp new file mode 100644 index 00000000..d9588f8e Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ee.webp differ diff --git a/dashboard/public/assets/icons/flagpack/eg.webp b/dashboard/public/assets/icons/flagpack/eg.webp new file mode 100644 index 00000000..aeee56b6 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/eg.webp differ diff --git a/dashboard/public/assets/icons/flagpack/eh.webp b/dashboard/public/assets/icons/flagpack/eh.webp new file mode 100644 index 00000000..197f16d9 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/eh.webp differ diff --git a/dashboard/public/assets/icons/flagpack/er.webp b/dashboard/public/assets/icons/flagpack/er.webp new file mode 100644 index 00000000..3b6da974 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/er.webp differ diff --git a/dashboard/public/assets/icons/flagpack/es.webp b/dashboard/public/assets/icons/flagpack/es.webp new file mode 100644 index 00000000..538f47a6 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/es.webp differ diff --git a/dashboard/public/assets/icons/flagpack/et.webp b/dashboard/public/assets/icons/flagpack/et.webp new file mode 100644 index 00000000..22e707a8 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/et.webp differ diff --git a/dashboard/public/assets/icons/flagpack/fi.webp b/dashboard/public/assets/icons/flagpack/fi.webp new file mode 100644 index 00000000..6f08baa9 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/fi.webp differ diff --git a/dashboard/public/assets/icons/flagpack/fj.webp b/dashboard/public/assets/icons/flagpack/fj.webp new file mode 100644 index 00000000..cbdd22a1 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/fj.webp differ diff --git a/dashboard/public/assets/icons/flagpack/fk.webp b/dashboard/public/assets/icons/flagpack/fk.webp new file mode 100644 index 00000000..ec33df26 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/fk.webp differ diff --git a/dashboard/public/assets/icons/flagpack/fm.webp b/dashboard/public/assets/icons/flagpack/fm.webp new file mode 100644 index 00000000..9b137770 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/fm.webp differ diff --git a/dashboard/public/assets/icons/flagpack/fo.webp b/dashboard/public/assets/icons/flagpack/fo.webp new file mode 100644 index 00000000..dc56c801 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/fo.webp differ diff --git a/dashboard/public/assets/icons/flagpack/fr.webp b/dashboard/public/assets/icons/flagpack/fr.webp new file mode 100644 index 00000000..da3c5140 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/fr.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ga.webp b/dashboard/public/assets/icons/flagpack/ga.webp new file mode 100644 index 00000000..eed6d325 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ga.webp differ diff --git a/dashboard/public/assets/icons/flagpack/gb-eng.webp b/dashboard/public/assets/icons/flagpack/gb-eng.webp new file mode 100644 index 00000000..eb9cf17d Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/gb-eng.webp differ diff --git a/dashboard/public/assets/icons/flagpack/gb-nir.webp b/dashboard/public/assets/icons/flagpack/gb-nir.webp new file mode 100644 index 00000000..02edac17 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/gb-nir.webp differ diff --git a/dashboard/public/assets/icons/flagpack/gb-sct.webp b/dashboard/public/assets/icons/flagpack/gb-sct.webp new file mode 100644 index 00000000..636cb811 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/gb-sct.webp differ diff --git a/dashboard/public/assets/icons/flagpack/gb-wls.webp b/dashboard/public/assets/icons/flagpack/gb-wls.webp new file mode 100644 index 00000000..dc84a95a Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/gb-wls.webp differ diff --git a/dashboard/public/assets/icons/flagpack/gb.webp b/dashboard/public/assets/icons/flagpack/gb.webp new file mode 100644 index 00000000..02edac17 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/gb.webp differ diff --git a/dashboard/public/assets/icons/flagpack/gd.webp b/dashboard/public/assets/icons/flagpack/gd.webp new file mode 100644 index 00000000..24596c12 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/gd.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ge.webp b/dashboard/public/assets/icons/flagpack/ge.webp new file mode 100644 index 00000000..7863551e Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ge.webp differ diff --git a/dashboard/public/assets/icons/flagpack/gf.webp b/dashboard/public/assets/icons/flagpack/gf.webp new file mode 100644 index 00000000..d3615449 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/gf.webp differ diff --git a/dashboard/public/assets/icons/flagpack/gg.webp b/dashboard/public/assets/icons/flagpack/gg.webp new file mode 100644 index 00000000..6abd48e7 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/gg.webp differ diff --git a/dashboard/public/assets/icons/flagpack/gh.webp b/dashboard/public/assets/icons/flagpack/gh.webp new file mode 100644 index 00000000..c860a073 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/gh.webp differ diff --git a/dashboard/public/assets/icons/flagpack/gi.webp b/dashboard/public/assets/icons/flagpack/gi.webp new file mode 100644 index 00000000..35725167 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/gi.webp differ diff --git a/dashboard/public/assets/icons/flagpack/gl.webp b/dashboard/public/assets/icons/flagpack/gl.webp new file mode 100644 index 00000000..5c4ff67c Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/gl.webp differ diff --git a/dashboard/public/assets/icons/flagpack/gm.webp b/dashboard/public/assets/icons/flagpack/gm.webp new file mode 100644 index 00000000..4f139e62 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/gm.webp differ diff --git a/dashboard/public/assets/icons/flagpack/gn.webp b/dashboard/public/assets/icons/flagpack/gn.webp new file mode 100644 index 00000000..6773f86e Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/gn.webp differ diff --git a/dashboard/public/assets/icons/flagpack/gp.webp b/dashboard/public/assets/icons/flagpack/gp.webp new file mode 100644 index 00000000..da3c5140 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/gp.webp differ diff --git a/dashboard/public/assets/icons/flagpack/gq.webp b/dashboard/public/assets/icons/flagpack/gq.webp new file mode 100644 index 00000000..15e41748 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/gq.webp differ diff --git a/dashboard/public/assets/icons/flagpack/gr.webp b/dashboard/public/assets/icons/flagpack/gr.webp new file mode 100644 index 00000000..12ae4ad2 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/gr.webp differ diff --git a/dashboard/public/assets/icons/flagpack/gs.webp b/dashboard/public/assets/icons/flagpack/gs.webp new file mode 100644 index 00000000..01caa5f8 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/gs.webp differ diff --git a/dashboard/public/assets/icons/flagpack/gt.webp b/dashboard/public/assets/icons/flagpack/gt.webp new file mode 100644 index 00000000..a08468e2 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/gt.webp differ diff --git a/dashboard/public/assets/icons/flagpack/gu.webp b/dashboard/public/assets/icons/flagpack/gu.webp new file mode 100644 index 00000000..733fbd4a Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/gu.webp differ diff --git a/dashboard/public/assets/icons/flagpack/gw.webp b/dashboard/public/assets/icons/flagpack/gw.webp new file mode 100644 index 00000000..2aabff06 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/gw.webp differ diff --git a/dashboard/public/assets/icons/flagpack/gy.webp b/dashboard/public/assets/icons/flagpack/gy.webp new file mode 100644 index 00000000..06da8e93 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/gy.webp differ diff --git a/dashboard/public/assets/icons/flagpack/hk.webp b/dashboard/public/assets/icons/flagpack/hk.webp new file mode 100644 index 00000000..b233a102 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/hk.webp differ diff --git a/dashboard/public/assets/icons/flagpack/hm.webp b/dashboard/public/assets/icons/flagpack/hm.webp new file mode 100644 index 00000000..b4a10bdd Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/hm.webp differ diff --git a/dashboard/public/assets/icons/flagpack/hn.webp b/dashboard/public/assets/icons/flagpack/hn.webp new file mode 100644 index 00000000..112fc457 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/hn.webp differ diff --git a/dashboard/public/assets/icons/flagpack/hr.webp b/dashboard/public/assets/icons/flagpack/hr.webp new file mode 100644 index 00000000..f235b379 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/hr.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ht.webp b/dashboard/public/assets/icons/flagpack/ht.webp new file mode 100644 index 00000000..e826e67e Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ht.webp differ diff --git a/dashboard/public/assets/icons/flagpack/hu.webp b/dashboard/public/assets/icons/flagpack/hu.webp new file mode 100644 index 00000000..d32f8cf0 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/hu.webp differ diff --git a/dashboard/public/assets/icons/flagpack/id.webp b/dashboard/public/assets/icons/flagpack/id.webp new file mode 100644 index 00000000..de95e2f7 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/id.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ie.webp b/dashboard/public/assets/icons/flagpack/ie.webp new file mode 100644 index 00000000..179c6578 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ie.webp differ diff --git a/dashboard/public/assets/icons/flagpack/il.webp b/dashboard/public/assets/icons/flagpack/il.webp new file mode 100644 index 00000000..74c298c5 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/il.webp differ diff --git a/dashboard/public/assets/icons/flagpack/im.webp b/dashboard/public/assets/icons/flagpack/im.webp new file mode 100644 index 00000000..c841455c Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/im.webp differ diff --git a/dashboard/public/assets/icons/flagpack/in.webp b/dashboard/public/assets/icons/flagpack/in.webp new file mode 100644 index 00000000..df5763e5 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/in.webp differ diff --git a/dashboard/public/assets/icons/flagpack/io.webp b/dashboard/public/assets/icons/flagpack/io.webp new file mode 100644 index 00000000..7ad27110 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/io.webp differ diff --git a/dashboard/public/assets/icons/flagpack/iq.webp b/dashboard/public/assets/icons/flagpack/iq.webp new file mode 100644 index 00000000..731e16df Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/iq.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ir.webp b/dashboard/public/assets/icons/flagpack/ir.webp new file mode 100644 index 00000000..0f9a5aa5 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ir.webp differ diff --git a/dashboard/public/assets/icons/flagpack/is.webp b/dashboard/public/assets/icons/flagpack/is.webp new file mode 100644 index 00000000..76fa677e Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/is.webp differ diff --git a/dashboard/public/assets/icons/flagpack/it.webp b/dashboard/public/assets/icons/flagpack/it.webp new file mode 100644 index 00000000..67e159fd Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/it.webp differ diff --git a/dashboard/public/assets/icons/flagpack/je.webp b/dashboard/public/assets/icons/flagpack/je.webp new file mode 100644 index 00000000..ca330e5c Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/je.webp differ diff --git a/dashboard/public/assets/icons/flagpack/jm.webp b/dashboard/public/assets/icons/flagpack/jm.webp new file mode 100644 index 00000000..3f611045 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/jm.webp differ diff --git a/dashboard/public/assets/icons/flagpack/jo.webp b/dashboard/public/assets/icons/flagpack/jo.webp new file mode 100644 index 00000000..30b6e0d5 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/jo.webp differ diff --git a/dashboard/public/assets/icons/flagpack/jp.webp b/dashboard/public/assets/icons/flagpack/jp.webp new file mode 100644 index 00000000..948ebbef Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/jp.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ke.webp b/dashboard/public/assets/icons/flagpack/ke.webp new file mode 100644 index 00000000..53016e48 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ke.webp differ diff --git a/dashboard/public/assets/icons/flagpack/kg.webp b/dashboard/public/assets/icons/flagpack/kg.webp new file mode 100644 index 00000000..ff01c29f Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/kg.webp differ diff --git a/dashboard/public/assets/icons/flagpack/kh.webp b/dashboard/public/assets/icons/flagpack/kh.webp new file mode 100644 index 00000000..b082bc50 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/kh.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ki.webp b/dashboard/public/assets/icons/flagpack/ki.webp new file mode 100644 index 00000000..a2398dc1 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ki.webp differ diff --git a/dashboard/public/assets/icons/flagpack/km.webp b/dashboard/public/assets/icons/flagpack/km.webp new file mode 100644 index 00000000..4cb6b11b Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/km.webp differ diff --git a/dashboard/public/assets/icons/flagpack/kn.webp b/dashboard/public/assets/icons/flagpack/kn.webp new file mode 100644 index 00000000..1ed34fb7 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/kn.webp differ diff --git a/dashboard/public/assets/icons/flagpack/kp.webp b/dashboard/public/assets/icons/flagpack/kp.webp new file mode 100644 index 00000000..e899556f Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/kp.webp differ diff --git a/dashboard/public/assets/icons/flagpack/kr.webp b/dashboard/public/assets/icons/flagpack/kr.webp new file mode 100644 index 00000000..e95fb871 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/kr.webp differ diff --git a/dashboard/public/assets/icons/flagpack/kw.webp b/dashboard/public/assets/icons/flagpack/kw.webp new file mode 100644 index 00000000..1f02f031 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/kw.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ky.webp b/dashboard/public/assets/icons/flagpack/ky.webp new file mode 100644 index 00000000..5adf53d5 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ky.webp differ diff --git a/dashboard/public/assets/icons/flagpack/kz.webp b/dashboard/public/assets/icons/flagpack/kz.webp new file mode 100644 index 00000000..92766739 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/kz.webp differ diff --git a/dashboard/public/assets/icons/flagpack/la.webp b/dashboard/public/assets/icons/flagpack/la.webp new file mode 100644 index 00000000..780a29bd Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/la.webp differ diff --git a/dashboard/public/assets/icons/flagpack/lb.webp b/dashboard/public/assets/icons/flagpack/lb.webp new file mode 100644 index 00000000..3664062c Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/lb.webp differ diff --git a/dashboard/public/assets/icons/flagpack/lc.webp b/dashboard/public/assets/icons/flagpack/lc.webp new file mode 100644 index 00000000..66a000a1 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/lc.webp differ diff --git a/dashboard/public/assets/icons/flagpack/li.webp b/dashboard/public/assets/icons/flagpack/li.webp new file mode 100644 index 00000000..ac146913 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/li.webp differ diff --git a/dashboard/public/assets/icons/flagpack/lk.webp b/dashboard/public/assets/icons/flagpack/lk.webp new file mode 100644 index 00000000..587b281e Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/lk.webp differ diff --git a/dashboard/public/assets/icons/flagpack/lr.webp b/dashboard/public/assets/icons/flagpack/lr.webp new file mode 100644 index 00000000..a4ec59be Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/lr.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ls.webp b/dashboard/public/assets/icons/flagpack/ls.webp new file mode 100644 index 00000000..3cb3d403 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ls.webp differ diff --git a/dashboard/public/assets/icons/flagpack/lt.webp b/dashboard/public/assets/icons/flagpack/lt.webp new file mode 100644 index 00000000..9e441bee Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/lt.webp differ diff --git a/dashboard/public/assets/icons/flagpack/lu.webp b/dashboard/public/assets/icons/flagpack/lu.webp new file mode 100644 index 00000000..6e8ebfa4 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/lu.webp differ diff --git a/dashboard/public/assets/icons/flagpack/lv.webp b/dashboard/public/assets/icons/flagpack/lv.webp new file mode 100644 index 00000000..f9dc4e3f Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/lv.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ly.webp b/dashboard/public/assets/icons/flagpack/ly.webp new file mode 100644 index 00000000..77b04792 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ly.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ma.webp b/dashboard/public/assets/icons/flagpack/ma.webp new file mode 100644 index 00000000..39c900e7 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ma.webp differ diff --git a/dashboard/public/assets/icons/flagpack/mc.webp b/dashboard/public/assets/icons/flagpack/mc.webp new file mode 100644 index 00000000..163c64ee Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/mc.webp differ diff --git a/dashboard/public/assets/icons/flagpack/md.webp b/dashboard/public/assets/icons/flagpack/md.webp new file mode 100644 index 00000000..a9ed9ee5 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/md.webp differ diff --git a/dashboard/public/assets/icons/flagpack/me.webp b/dashboard/public/assets/icons/flagpack/me.webp new file mode 100644 index 00000000..4ebd531e Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/me.webp differ diff --git a/dashboard/public/assets/icons/flagpack/mf.webp b/dashboard/public/assets/icons/flagpack/mf.webp new file mode 100644 index 00000000..da3c5140 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/mf.webp differ diff --git a/dashboard/public/assets/icons/flagpack/mg.webp b/dashboard/public/assets/icons/flagpack/mg.webp new file mode 100644 index 00000000..032c28e5 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/mg.webp differ diff --git a/dashboard/public/assets/icons/flagpack/mh.webp b/dashboard/public/assets/icons/flagpack/mh.webp new file mode 100644 index 00000000..f85ed389 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/mh.webp differ diff --git a/dashboard/public/assets/icons/flagpack/mk.webp b/dashboard/public/assets/icons/flagpack/mk.webp new file mode 100644 index 00000000..e4e81974 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/mk.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ml.webp b/dashboard/public/assets/icons/flagpack/ml.webp new file mode 100644 index 00000000..5d5aca03 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ml.webp differ diff --git a/dashboard/public/assets/icons/flagpack/mm.webp b/dashboard/public/assets/icons/flagpack/mm.webp new file mode 100644 index 00000000..cdd0ff43 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/mm.webp differ diff --git a/dashboard/public/assets/icons/flagpack/mn.webp b/dashboard/public/assets/icons/flagpack/mn.webp new file mode 100644 index 00000000..4e2e10dd Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/mn.webp differ diff --git a/dashboard/public/assets/icons/flagpack/mo.webp b/dashboard/public/assets/icons/flagpack/mo.webp new file mode 100644 index 00000000..d9a346d7 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/mo.webp differ diff --git a/dashboard/public/assets/icons/flagpack/mp.webp b/dashboard/public/assets/icons/flagpack/mp.webp new file mode 100644 index 00000000..3bbadd70 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/mp.webp differ diff --git a/dashboard/public/assets/icons/flagpack/mq.webp b/dashboard/public/assets/icons/flagpack/mq.webp new file mode 100644 index 00000000..da3c5140 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/mq.webp differ diff --git a/dashboard/public/assets/icons/flagpack/mr.webp b/dashboard/public/assets/icons/flagpack/mr.webp new file mode 100644 index 00000000..69cc56dd Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/mr.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ms.webp b/dashboard/public/assets/icons/flagpack/ms.webp new file mode 100644 index 00000000..482a9037 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ms.webp differ diff --git a/dashboard/public/assets/icons/flagpack/mt.webp b/dashboard/public/assets/icons/flagpack/mt.webp new file mode 100644 index 00000000..790e914f Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/mt.webp differ diff --git a/dashboard/public/assets/icons/flagpack/mu.webp b/dashboard/public/assets/icons/flagpack/mu.webp new file mode 100644 index 00000000..66baab32 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/mu.webp differ diff --git a/dashboard/public/assets/icons/flagpack/mv.webp b/dashboard/public/assets/icons/flagpack/mv.webp new file mode 100644 index 00000000..4f691cc2 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/mv.webp differ diff --git a/dashboard/public/assets/icons/flagpack/mw.webp b/dashboard/public/assets/icons/flagpack/mw.webp new file mode 100644 index 00000000..0ff51d1a Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/mw.webp differ diff --git a/dashboard/public/assets/icons/flagpack/mx.webp b/dashboard/public/assets/icons/flagpack/mx.webp new file mode 100644 index 00000000..980a6cc5 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/mx.webp differ diff --git a/dashboard/public/assets/icons/flagpack/my.webp b/dashboard/public/assets/icons/flagpack/my.webp new file mode 100644 index 00000000..b93cc694 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/my.webp differ diff --git a/dashboard/public/assets/icons/flagpack/mz.webp b/dashboard/public/assets/icons/flagpack/mz.webp new file mode 100644 index 00000000..7453d3e4 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/mz.webp differ diff --git a/dashboard/public/assets/icons/flagpack/na.webp b/dashboard/public/assets/icons/flagpack/na.webp new file mode 100644 index 00000000..8276694d Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/na.webp differ diff --git a/dashboard/public/assets/icons/flagpack/nc.webp b/dashboard/public/assets/icons/flagpack/nc.webp new file mode 100644 index 00000000..6af8b9ee Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/nc.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ne.webp b/dashboard/public/assets/icons/flagpack/ne.webp new file mode 100644 index 00000000..bf1ffb99 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ne.webp differ diff --git a/dashboard/public/assets/icons/flagpack/nf.webp b/dashboard/public/assets/icons/flagpack/nf.webp new file mode 100644 index 00000000..36cc27df Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/nf.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ng.webp b/dashboard/public/assets/icons/flagpack/ng.webp new file mode 100644 index 00000000..a062e09d Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ng.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ni.webp b/dashboard/public/assets/icons/flagpack/ni.webp new file mode 100644 index 00000000..2225c42f Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ni.webp differ diff --git a/dashboard/public/assets/icons/flagpack/nl.webp b/dashboard/public/assets/icons/flagpack/nl.webp new file mode 100644 index 00000000..deeb5455 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/nl.webp differ diff --git a/dashboard/public/assets/icons/flagpack/no.webp b/dashboard/public/assets/icons/flagpack/no.webp new file mode 100644 index 00000000..f6d301fb Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/no.webp differ diff --git a/dashboard/public/assets/icons/flagpack/np.webp b/dashboard/public/assets/icons/flagpack/np.webp new file mode 100644 index 00000000..31837e65 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/np.webp differ diff --git a/dashboard/public/assets/icons/flagpack/nr.webp b/dashboard/public/assets/icons/flagpack/nr.webp new file mode 100644 index 00000000..cc781f38 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/nr.webp differ diff --git a/dashboard/public/assets/icons/flagpack/nu.webp b/dashboard/public/assets/icons/flagpack/nu.webp new file mode 100644 index 00000000..894ad76f Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/nu.webp differ diff --git a/dashboard/public/assets/icons/flagpack/nz.webp b/dashboard/public/assets/icons/flagpack/nz.webp new file mode 100644 index 00000000..d5bd6200 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/nz.webp differ diff --git a/dashboard/public/assets/icons/flagpack/om.webp b/dashboard/public/assets/icons/flagpack/om.webp new file mode 100644 index 00000000..50862414 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/om.webp differ diff --git a/dashboard/public/assets/icons/flagpack/pa.webp b/dashboard/public/assets/icons/flagpack/pa.webp new file mode 100644 index 00000000..30e9303d Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/pa.webp differ diff --git a/dashboard/public/assets/icons/flagpack/pe.webp b/dashboard/public/assets/icons/flagpack/pe.webp new file mode 100644 index 00000000..4c2016de Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/pe.webp differ diff --git a/dashboard/public/assets/icons/flagpack/pf.webp b/dashboard/public/assets/icons/flagpack/pf.webp new file mode 100644 index 00000000..74eb4345 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/pf.webp differ diff --git a/dashboard/public/assets/icons/flagpack/pg.webp b/dashboard/public/assets/icons/flagpack/pg.webp new file mode 100644 index 00000000..2e537d37 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/pg.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ph.webp b/dashboard/public/assets/icons/flagpack/ph.webp new file mode 100644 index 00000000..454d7410 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ph.webp differ diff --git a/dashboard/public/assets/icons/flagpack/pk.webp b/dashboard/public/assets/icons/flagpack/pk.webp new file mode 100644 index 00000000..67e40a75 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/pk.webp differ diff --git a/dashboard/public/assets/icons/flagpack/pl.webp b/dashboard/public/assets/icons/flagpack/pl.webp new file mode 100644 index 00000000..c38d0e63 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/pl.webp differ diff --git a/dashboard/public/assets/icons/flagpack/pm.webp b/dashboard/public/assets/icons/flagpack/pm.webp new file mode 100644 index 00000000..0f6aab17 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/pm.webp differ diff --git a/dashboard/public/assets/icons/flagpack/pn.webp b/dashboard/public/assets/icons/flagpack/pn.webp new file mode 100644 index 00000000..d3e11af1 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/pn.webp differ diff --git a/dashboard/public/assets/icons/flagpack/pr.webp b/dashboard/public/assets/icons/flagpack/pr.webp new file mode 100644 index 00000000..2e17875d Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/pr.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ps.webp b/dashboard/public/assets/icons/flagpack/ps.webp new file mode 100644 index 00000000..d02b0bba Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ps.webp differ diff --git a/dashboard/public/assets/icons/flagpack/pt.webp b/dashboard/public/assets/icons/flagpack/pt.webp new file mode 100644 index 00000000..b2b64469 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/pt.webp differ diff --git a/dashboard/public/assets/icons/flagpack/pw.webp b/dashboard/public/assets/icons/flagpack/pw.webp new file mode 100644 index 00000000..66b4d6a2 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/pw.webp differ diff --git a/dashboard/public/assets/icons/flagpack/py.webp b/dashboard/public/assets/icons/flagpack/py.webp new file mode 100644 index 00000000..9866931b Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/py.webp differ diff --git a/dashboard/public/assets/icons/flagpack/qa.webp b/dashboard/public/assets/icons/flagpack/qa.webp new file mode 100644 index 00000000..8966f1b3 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/qa.webp differ diff --git a/dashboard/public/assets/icons/flagpack/re.webp b/dashboard/public/assets/icons/flagpack/re.webp new file mode 100644 index 00000000..6af8b9ee Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/re.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ro.webp b/dashboard/public/assets/icons/flagpack/ro.webp new file mode 100644 index 00000000..f99502f1 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ro.webp differ diff --git a/dashboard/public/assets/icons/flagpack/rs.webp b/dashboard/public/assets/icons/flagpack/rs.webp new file mode 100644 index 00000000..8b177db4 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/rs.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ru.webp b/dashboard/public/assets/icons/flagpack/ru.webp new file mode 100644 index 00000000..c7bce9f7 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ru.webp differ diff --git a/dashboard/public/assets/icons/flagpack/rw.webp b/dashboard/public/assets/icons/flagpack/rw.webp new file mode 100644 index 00000000..d5a1cb8d Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/rw.webp differ diff --git a/dashboard/public/assets/icons/flagpack/sa.webp b/dashboard/public/assets/icons/flagpack/sa.webp new file mode 100644 index 00000000..562492c3 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/sa.webp differ diff --git a/dashboard/public/assets/icons/flagpack/sb.webp b/dashboard/public/assets/icons/flagpack/sb.webp new file mode 100644 index 00000000..fea0fe84 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/sb.webp differ diff --git a/dashboard/public/assets/icons/flagpack/sc.webp b/dashboard/public/assets/icons/flagpack/sc.webp new file mode 100644 index 00000000..a01d0e5a Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/sc.webp differ diff --git a/dashboard/public/assets/icons/flagpack/sd.webp b/dashboard/public/assets/icons/flagpack/sd.webp new file mode 100644 index 00000000..e599e68f Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/sd.webp differ diff --git a/dashboard/public/assets/icons/flagpack/se.webp b/dashboard/public/assets/icons/flagpack/se.webp new file mode 100644 index 00000000..c04c04ac Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/se.webp differ diff --git a/dashboard/public/assets/icons/flagpack/sg.webp b/dashboard/public/assets/icons/flagpack/sg.webp new file mode 100644 index 00000000..bc0bd592 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/sg.webp differ diff --git a/dashboard/public/assets/icons/flagpack/sh.webp b/dashboard/public/assets/icons/flagpack/sh.webp new file mode 100644 index 00000000..66c855d6 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/sh.webp differ diff --git a/dashboard/public/assets/icons/flagpack/si.webp b/dashboard/public/assets/icons/flagpack/si.webp new file mode 100644 index 00000000..a7721954 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/si.webp differ diff --git a/dashboard/public/assets/icons/flagpack/sj.webp b/dashboard/public/assets/icons/flagpack/sj.webp new file mode 100644 index 00000000..f6d301fb Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/sj.webp differ diff --git a/dashboard/public/assets/icons/flagpack/sk.webp b/dashboard/public/assets/icons/flagpack/sk.webp new file mode 100644 index 00000000..15917b92 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/sk.webp differ diff --git a/dashboard/public/assets/icons/flagpack/sl.webp b/dashboard/public/assets/icons/flagpack/sl.webp new file mode 100644 index 00000000..b4ee9c7a Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/sl.webp differ diff --git a/dashboard/public/assets/icons/flagpack/sm.webp b/dashboard/public/assets/icons/flagpack/sm.webp new file mode 100644 index 00000000..7bb0d17e Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/sm.webp differ diff --git a/dashboard/public/assets/icons/flagpack/sn.webp b/dashboard/public/assets/icons/flagpack/sn.webp new file mode 100644 index 00000000..07461621 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/sn.webp differ diff --git a/dashboard/public/assets/icons/flagpack/so.webp b/dashboard/public/assets/icons/flagpack/so.webp new file mode 100644 index 00000000..b08d6e9a Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/so.webp differ diff --git a/dashboard/public/assets/icons/flagpack/sr.webp b/dashboard/public/assets/icons/flagpack/sr.webp new file mode 100644 index 00000000..299e1495 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/sr.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ss.webp b/dashboard/public/assets/icons/flagpack/ss.webp new file mode 100644 index 00000000..2d8a323b Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ss.webp differ diff --git a/dashboard/public/assets/icons/flagpack/st.webp b/dashboard/public/assets/icons/flagpack/st.webp new file mode 100644 index 00000000..6d563a0f Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/st.webp differ diff --git a/dashboard/public/assets/icons/flagpack/sv.webp b/dashboard/public/assets/icons/flagpack/sv.webp new file mode 100644 index 00000000..e5c7e711 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/sv.webp differ diff --git a/dashboard/public/assets/icons/flagpack/sx.webp b/dashboard/public/assets/icons/flagpack/sx.webp new file mode 100644 index 00000000..86780b0e Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/sx.webp differ diff --git a/dashboard/public/assets/icons/flagpack/sy.webp b/dashboard/public/assets/icons/flagpack/sy.webp new file mode 100644 index 00000000..fa46871d Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/sy.webp differ diff --git a/dashboard/public/assets/icons/flagpack/sz.webp b/dashboard/public/assets/icons/flagpack/sz.webp new file mode 100644 index 00000000..43d56b23 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/sz.webp differ diff --git a/dashboard/public/assets/icons/flagpack/tc.webp b/dashboard/public/assets/icons/flagpack/tc.webp new file mode 100644 index 00000000..82dc3ff5 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/tc.webp differ diff --git a/dashboard/public/assets/icons/flagpack/td.webp b/dashboard/public/assets/icons/flagpack/td.webp new file mode 100644 index 00000000..3231c390 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/td.webp differ diff --git a/dashboard/public/assets/icons/flagpack/tf.webp b/dashboard/public/assets/icons/flagpack/tf.webp new file mode 100644 index 00000000..ae0e0ea7 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/tf.webp differ diff --git a/dashboard/public/assets/icons/flagpack/tg.webp b/dashboard/public/assets/icons/flagpack/tg.webp new file mode 100644 index 00000000..5ad0771d Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/tg.webp differ diff --git a/dashboard/public/assets/icons/flagpack/th.webp b/dashboard/public/assets/icons/flagpack/th.webp new file mode 100644 index 00000000..20aac2cd Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/th.webp differ diff --git a/dashboard/public/assets/icons/flagpack/tj.webp b/dashboard/public/assets/icons/flagpack/tj.webp new file mode 100644 index 00000000..c5261d83 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/tj.webp differ diff --git a/dashboard/public/assets/icons/flagpack/tk.webp b/dashboard/public/assets/icons/flagpack/tk.webp new file mode 100644 index 00000000..8cbcd619 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/tk.webp differ diff --git a/dashboard/public/assets/icons/flagpack/tl.webp b/dashboard/public/assets/icons/flagpack/tl.webp new file mode 100644 index 00000000..39647ba3 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/tl.webp differ diff --git a/dashboard/public/assets/icons/flagpack/tm.webp b/dashboard/public/assets/icons/flagpack/tm.webp new file mode 100644 index 00000000..51afd465 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/tm.webp differ diff --git a/dashboard/public/assets/icons/flagpack/tn.webp b/dashboard/public/assets/icons/flagpack/tn.webp new file mode 100644 index 00000000..9e0006f1 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/tn.webp differ diff --git a/dashboard/public/assets/icons/flagpack/to.webp b/dashboard/public/assets/icons/flagpack/to.webp new file mode 100644 index 00000000..2606f866 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/to.webp differ diff --git a/dashboard/public/assets/icons/flagpack/tr.webp b/dashboard/public/assets/icons/flagpack/tr.webp new file mode 100644 index 00000000..891d31de Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/tr.webp differ diff --git a/dashboard/public/assets/icons/flagpack/tt.webp b/dashboard/public/assets/icons/flagpack/tt.webp new file mode 100644 index 00000000..8c9b6803 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/tt.webp differ diff --git a/dashboard/public/assets/icons/flagpack/tv.webp b/dashboard/public/assets/icons/flagpack/tv.webp new file mode 100644 index 00000000..73407d18 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/tv.webp differ diff --git a/dashboard/public/assets/icons/flagpack/tw.webp b/dashboard/public/assets/icons/flagpack/tw.webp new file mode 100644 index 00000000..3190a68d Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/tw.webp differ diff --git a/dashboard/public/assets/icons/flagpack/tz.webp b/dashboard/public/assets/icons/flagpack/tz.webp new file mode 100644 index 00000000..7181e188 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/tz.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ua.webp b/dashboard/public/assets/icons/flagpack/ua.webp new file mode 100644 index 00000000..d0659257 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ua.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ug.webp b/dashboard/public/assets/icons/flagpack/ug.webp new file mode 100644 index 00000000..e033bb40 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ug.webp differ diff --git a/dashboard/public/assets/icons/flagpack/um.webp b/dashboard/public/assets/icons/flagpack/um.webp new file mode 100644 index 00000000..2f80afae Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/um.webp differ diff --git a/dashboard/public/assets/icons/flagpack/us.webp b/dashboard/public/assets/icons/flagpack/us.webp new file mode 100644 index 00000000..2f80afae Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/us.webp differ diff --git a/dashboard/public/assets/icons/flagpack/uy.webp b/dashboard/public/assets/icons/flagpack/uy.webp new file mode 100644 index 00000000..0c45363d Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/uy.webp differ diff --git a/dashboard/public/assets/icons/flagpack/uz.webp b/dashboard/public/assets/icons/flagpack/uz.webp new file mode 100644 index 00000000..ef8f615b Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/uz.webp differ diff --git a/dashboard/public/assets/icons/flagpack/va.webp b/dashboard/public/assets/icons/flagpack/va.webp new file mode 100644 index 00000000..0b39cf26 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/va.webp differ diff --git a/dashboard/public/assets/icons/flagpack/vc.webp b/dashboard/public/assets/icons/flagpack/vc.webp new file mode 100644 index 00000000..19fbeca6 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/vc.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ve.webp b/dashboard/public/assets/icons/flagpack/ve.webp new file mode 100644 index 00000000..ce5760bc Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ve.webp differ diff --git a/dashboard/public/assets/icons/flagpack/vg.webp b/dashboard/public/assets/icons/flagpack/vg.webp new file mode 100644 index 00000000..482a9037 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/vg.webp differ diff --git a/dashboard/public/assets/icons/flagpack/vi.webp b/dashboard/public/assets/icons/flagpack/vi.webp new file mode 100644 index 00000000..7c14bb62 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/vi.webp differ diff --git a/dashboard/public/assets/icons/flagpack/vn.webp b/dashboard/public/assets/icons/flagpack/vn.webp new file mode 100644 index 00000000..30c76bec Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/vn.webp differ diff --git a/dashboard/public/assets/icons/flagpack/vu.webp b/dashboard/public/assets/icons/flagpack/vu.webp new file mode 100644 index 00000000..c86fe442 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/vu.webp differ diff --git a/dashboard/public/assets/icons/flagpack/wf.webp b/dashboard/public/assets/icons/flagpack/wf.webp new file mode 100644 index 00000000..0f6aab17 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/wf.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ws.webp b/dashboard/public/assets/icons/flagpack/ws.webp new file mode 100644 index 00000000..e9985a34 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ws.webp differ diff --git a/dashboard/public/assets/icons/flagpack/xk.webp b/dashboard/public/assets/icons/flagpack/xk.webp new file mode 100644 index 00000000..b544db30 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/xk.webp differ diff --git a/dashboard/public/assets/icons/flagpack/ye.webp b/dashboard/public/assets/icons/flagpack/ye.webp new file mode 100644 index 00000000..6f2f6074 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/ye.webp differ diff --git a/dashboard/public/assets/icons/flagpack/yt.webp b/dashboard/public/assets/icons/flagpack/yt.webp new file mode 100644 index 00000000..d233847b Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/yt.webp differ diff --git a/dashboard/public/assets/icons/flagpack/za.webp b/dashboard/public/assets/icons/flagpack/za.webp new file mode 100644 index 00000000..d5bfd11a Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/za.webp differ diff --git a/dashboard/public/assets/icons/flagpack/zm.webp b/dashboard/public/assets/icons/flagpack/zm.webp new file mode 100644 index 00000000..3e34d088 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/zm.webp differ diff --git a/dashboard/public/assets/icons/flagpack/zw.webp b/dashboard/public/assets/icons/flagpack/zw.webp new file mode 100644 index 00000000..51ba5379 Binary files /dev/null and b/dashboard/public/assets/icons/flagpack/zw.webp differ diff --git a/dashboard/public/assets/icons/navbar/ic-analytics.svg b/dashboard/public/assets/icons/navbar/ic-analytics.svg new file mode 100644 index 00000000..a0182209 --- /dev/null +++ b/dashboard/public/assets/icons/navbar/ic-analytics.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/dashboard/public/assets/icons/navbar/ic-banking.svg b/dashboard/public/assets/icons/navbar/ic-banking.svg new file mode 100644 index 00000000..8e1934ee --- /dev/null +++ b/dashboard/public/assets/icons/navbar/ic-banking.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/dashboard/public/assets/icons/navbar/ic-blank.svg b/dashboard/public/assets/icons/navbar/ic-blank.svg new file mode 100644 index 00000000..3b21175a --- /dev/null +++ b/dashboard/public/assets/icons/navbar/ic-blank.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/public/assets/icons/navbar/ic-blog.svg b/dashboard/public/assets/icons/navbar/ic-blog.svg new file mode 100644 index 00000000..4d54ea9e --- /dev/null +++ b/dashboard/public/assets/icons/navbar/ic-blog.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/dashboard/public/assets/icons/navbar/ic-calendar.svg b/dashboard/public/assets/icons/navbar/ic-calendar.svg new file mode 100644 index 00000000..e24979ab --- /dev/null +++ b/dashboard/public/assets/icons/navbar/ic-calendar.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/dashboard/public/assets/icons/navbar/ic-chat.svg b/dashboard/public/assets/icons/navbar/ic-chat.svg new file mode 100644 index 00000000..2cf385c9 --- /dev/null +++ b/dashboard/public/assets/icons/navbar/ic-chat.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/dashboard/public/assets/icons/navbar/ic-course.svg b/dashboard/public/assets/icons/navbar/ic-course.svg new file mode 100644 index 00000000..22b1133f --- /dev/null +++ b/dashboard/public/assets/icons/navbar/ic-course.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/dashboard/public/assets/icons/navbar/ic-dashboard.svg b/dashboard/public/assets/icons/navbar/ic-dashboard.svg new file mode 100644 index 00000000..30840517 --- /dev/null +++ b/dashboard/public/assets/icons/navbar/ic-dashboard.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/public/assets/icons/navbar/ic-disabled.svg b/dashboard/public/assets/icons/navbar/ic-disabled.svg new file mode 100644 index 00000000..ac241ab4 --- /dev/null +++ b/dashboard/public/assets/icons/navbar/ic-disabled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/public/assets/icons/navbar/ic-external.svg b/dashboard/public/assets/icons/navbar/ic-external.svg new file mode 100644 index 00000000..fb726f77 --- /dev/null +++ b/dashboard/public/assets/icons/navbar/ic-external.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/public/assets/icons/navbar/ic-file.svg b/dashboard/public/assets/icons/navbar/ic-file.svg new file mode 100644 index 00000000..161b0a5e --- /dev/null +++ b/dashboard/public/assets/icons/navbar/ic-file.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/dashboard/public/assets/icons/navbar/ic-folder.svg b/dashboard/public/assets/icons/navbar/ic-folder.svg new file mode 100644 index 00000000..71595ed0 --- /dev/null +++ b/dashboard/public/assets/icons/navbar/ic-folder.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/public/assets/icons/navbar/ic-invoice.svg b/dashboard/public/assets/icons/navbar/ic-invoice.svg new file mode 100644 index 00000000..1f68e967 --- /dev/null +++ b/dashboard/public/assets/icons/navbar/ic-invoice.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/dashboard/public/assets/icons/navbar/ic-label.svg b/dashboard/public/assets/icons/navbar/ic-label.svg new file mode 100644 index 00000000..0488f1c4 --- /dev/null +++ b/dashboard/public/assets/icons/navbar/ic-label.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/public/assets/icons/navbar/ic-lock.svg b/dashboard/public/assets/icons/navbar/ic-lock.svg new file mode 100644 index 00000000..ebe9002c --- /dev/null +++ b/dashboard/public/assets/icons/navbar/ic-lock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/public/assets/icons/navbar/ic-menu-item.svg b/dashboard/public/assets/icons/navbar/ic-menu-item.svg new file mode 100644 index 00000000..f3ffb176 --- /dev/null +++ b/dashboard/public/assets/icons/navbar/ic-menu-item.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/dashboard/public/assets/icons/navbar/ic-nostr.svg b/dashboard/public/assets/icons/navbar/ic-nostr.svg new file mode 100644 index 00000000..2100b9e2 --- /dev/null +++ b/dashboard/public/assets/icons/navbar/ic-nostr.svg @@ -0,0 +1,3 @@ + + + diff --git a/dashboard/public/assets/icons/navbar/ic-parameter.svg b/dashboard/public/assets/icons/navbar/ic-parameter.svg new file mode 100644 index 00000000..289e44a1 --- /dev/null +++ b/dashboard/public/assets/icons/navbar/ic-parameter.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/dashboard/public/assets/icons/navbar/ic-user.svg b/dashboard/public/assets/icons/navbar/ic-user.svg new file mode 100644 index 00000000..261a3a3a --- /dev/null +++ b/dashboard/public/assets/icons/navbar/ic-user.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/public/assets/icons/notification/ic-chat.svg b/dashboard/public/assets/icons/notification/ic-chat.svg new file mode 100644 index 00000000..2b1afc32 --- /dev/null +++ b/dashboard/public/assets/icons/notification/ic-chat.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/assets/icons/notification/ic-delivery.svg b/dashboard/public/assets/icons/notification/ic-delivery.svg new file mode 100644 index 00000000..f7960c65 --- /dev/null +++ b/dashboard/public/assets/icons/notification/ic-delivery.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/assets/icons/notification/ic-mail.svg b/dashboard/public/assets/icons/notification/ic-mail.svg new file mode 100644 index 00000000..6183ee9e --- /dev/null +++ b/dashboard/public/assets/icons/notification/ic-mail.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/dashboard/public/assets/icons/notification/ic-order.svg b/dashboard/public/assets/icons/notification/ic-order.svg new file mode 100644 index 00000000..568193a0 --- /dev/null +++ b/dashboard/public/assets/icons/notification/ic-order.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/assets/icons/payments/success.png b/dashboard/public/assets/icons/payments/success.png new file mode 100644 index 00000000..376587bd Binary files /dev/null and b/dashboard/public/assets/icons/payments/success.png differ diff --git a/dashboard/public/assets/icons/payments/verify.png b/dashboard/public/assets/icons/payments/verify.png new file mode 100644 index 00000000..6eb13ade Binary files /dev/null and b/dashboard/public/assets/icons/payments/verify.png differ diff --git a/dashboard/public/assets/icons/platforms/ic-auth0.svg b/dashboard/public/assets/icons/platforms/ic-auth0.svg new file mode 100644 index 00000000..d02ff5fd --- /dev/null +++ b/dashboard/public/assets/icons/platforms/ic-auth0.svg @@ -0,0 +1,5 @@ + + + diff --git a/dashboard/public/assets/icons/platforms/ic-jwt.svg b/dashboard/public/assets/icons/platforms/ic-jwt.svg new file mode 100644 index 00000000..4085270b --- /dev/null +++ b/dashboard/public/assets/icons/platforms/ic-jwt.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/dashboard/public/assets/icons/platforms/ic-supabase.svg b/dashboard/public/assets/icons/platforms/ic-supabase.svg new file mode 100644 index 00000000..ac43e170 --- /dev/null +++ b/dashboard/public/assets/icons/platforms/ic-supabase.svg @@ -0,0 +1,99 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/assets/icons/setting/ic-align-left.svg b/dashboard/public/assets/icons/setting/ic-align-left.svg new file mode 100644 index 00000000..ee0a763b --- /dev/null +++ b/dashboard/public/assets/icons/setting/ic-align-left.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/dashboard/public/assets/icons/setting/ic-align-right.svg b/dashboard/public/assets/icons/setting/ic-align-right.svg new file mode 100644 index 00000000..c915aa12 --- /dev/null +++ b/dashboard/public/assets/icons/setting/ic-align-right.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/dashboard/public/assets/icons/setting/ic-autofit-width.svg b/dashboard/public/assets/icons/setting/ic-autofit-width.svg new file mode 100644 index 00000000..bd9a6c90 --- /dev/null +++ b/dashboard/public/assets/icons/setting/ic-autofit-width.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/public/assets/icons/setting/ic-collapse.svg b/dashboard/public/assets/icons/setting/ic-collapse.svg new file mode 100644 index 00000000..5902b8b8 --- /dev/null +++ b/dashboard/public/assets/icons/setting/ic-collapse.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/public/assets/icons/setting/ic-contrast-bold.svg b/dashboard/public/assets/icons/setting/ic-contrast-bold.svg new file mode 100644 index 00000000..67cc1d29 --- /dev/null +++ b/dashboard/public/assets/icons/setting/ic-contrast-bold.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/public/assets/icons/setting/ic-contrast.svg b/dashboard/public/assets/icons/setting/ic-contrast.svg new file mode 100644 index 00000000..9d190320 --- /dev/null +++ b/dashboard/public/assets/icons/setting/ic-contrast.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/dashboard/public/assets/icons/setting/ic-exit-full-screen.svg b/dashboard/public/assets/icons/setting/ic-exit-full-screen.svg new file mode 100644 index 00000000..cb9b60ad --- /dev/null +++ b/dashboard/public/assets/icons/setting/ic-exit-full-screen.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/dashboard/public/assets/icons/setting/ic-font.svg b/dashboard/public/assets/icons/setting/ic-font.svg new file mode 100644 index 00000000..91abc012 --- /dev/null +++ b/dashboard/public/assets/icons/setting/ic-font.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/public/assets/icons/setting/ic-full-screen.svg b/dashboard/public/assets/icons/setting/ic-full-screen.svg new file mode 100644 index 00000000..f7dfe887 --- /dev/null +++ b/dashboard/public/assets/icons/setting/ic-full-screen.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/dashboard/public/assets/icons/setting/ic-moon.svg b/dashboard/public/assets/icons/setting/ic-moon.svg new file mode 100644 index 00000000..f8519e4d --- /dev/null +++ b/dashboard/public/assets/icons/setting/ic-moon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/public/assets/icons/setting/ic-sidebar-filled.svg b/dashboard/public/assets/icons/setting/ic-sidebar-filled.svg new file mode 100644 index 00000000..d45bfb69 --- /dev/null +++ b/dashboard/public/assets/icons/setting/ic-sidebar-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/dashboard/public/assets/icons/setting/ic-sidebar-outline.svg b/dashboard/public/assets/icons/setting/ic-sidebar-outline.svg new file mode 100644 index 00000000..aca6be99 --- /dev/null +++ b/dashboard/public/assets/icons/setting/ic-sidebar-outline.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/dashboard/public/assets/icons/setting/ic-sidebar.svg b/dashboard/public/assets/icons/setting/ic-sidebar.svg new file mode 100644 index 00000000..e2375da6 --- /dev/null +++ b/dashboard/public/assets/icons/setting/ic-sidebar.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/public/assets/icons/setting/ic-siderbar-duotone.svg b/dashboard/public/assets/icons/setting/ic-siderbar-duotone.svg new file mode 100644 index 00000000..46f51d70 --- /dev/null +++ b/dashboard/public/assets/icons/setting/ic-siderbar-duotone.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/public/assets/icons/setting/ic-sun.svg b/dashboard/public/assets/icons/setting/ic-sun.svg new file mode 100644 index 00000000..6863e5c7 --- /dev/null +++ b/dashboard/public/assets/icons/setting/ic-sun.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/dashboard/public/assets/icons/setting/ic_moon.svg b/dashboard/public/assets/icons/setting/ic_moon.svg new file mode 100644 index 00000000..b1d85eb9 --- /dev/null +++ b/dashboard/public/assets/icons/setting/ic_moon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/dashboard/public/assets/icons/setting/ic_setting.svg b/dashboard/public/assets/icons/setting/ic_setting.svg new file mode 100644 index 00000000..f0dfb667 --- /dev/null +++ b/dashboard/public/assets/icons/setting/ic_setting.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/dashboard/public/assets/icons/setting/ic_sun.svg b/dashboard/public/assets/icons/setting/ic_sun.svg new file mode 100644 index 00000000..507910ef --- /dev/null +++ b/dashboard/public/assets/icons/setting/ic_sun.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/dashboard/public/assets/illustrations/characters/character-4.webp b/dashboard/public/assets/illustrations/characters/character-4.webp new file mode 100644 index 00000000..685d7ce6 Binary files /dev/null and b/dashboard/public/assets/illustrations/characters/character-4.webp differ diff --git a/dashboard/public/assets/illustrations/characters/character-6.webp b/dashboard/public/assets/illustrations/characters/character-6.webp new file mode 100644 index 00000000..88bff2bc Binary files /dev/null and b/dashboard/public/assets/illustrations/characters/character-6.webp differ diff --git a/dashboard/public/assets/placeholder.svg b/dashboard/public/assets/placeholder.svg new file mode 100644 index 00000000..bfa46208 --- /dev/null +++ b/dashboard/public/assets/placeholder.svg @@ -0,0 +1,8 @@ + + + + + + + diff --git a/dashboard/public/assets/red-blur.png b/dashboard/public/assets/red-blur.png new file mode 100644 index 00000000..a0df012f Binary files /dev/null and b/dashboard/public/assets/red-blur.png differ diff --git a/dashboard/public/assets/transparent.png b/dashboard/public/assets/transparent.png new file mode 100644 index 00000000..c953550c Binary files /dev/null and b/dashboard/public/assets/transparent.png differ diff --git a/dashboard/public/browserconfig.xml b/dashboard/public/browserconfig.xml new file mode 100644 index 00000000..70cb989d --- /dev/null +++ b/dashboard/public/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #da532c + + + diff --git a/dashboard/public/favicon.ico b/dashboard/public/favicon.ico new file mode 100644 index 00000000..00d3e7d8 Binary files /dev/null and b/dashboard/public/favicon.ico differ diff --git a/dashboard/public/favicon/android-chrome-192x192.png b/dashboard/public/favicon/android-chrome-192x192.png new file mode 100644 index 00000000..9ff5f37b Binary files /dev/null and b/dashboard/public/favicon/android-chrome-192x192.png differ diff --git a/dashboard/public/favicon/android-chrome-512x512.png b/dashboard/public/favicon/android-chrome-512x512.png new file mode 100644 index 00000000..d68188b1 Binary files /dev/null and b/dashboard/public/favicon/android-chrome-512x512.png differ diff --git a/dashboard/public/favicon/apple-touch-icon.png b/dashboard/public/favicon/apple-touch-icon.png new file mode 100644 index 00000000..42a42c19 Binary files /dev/null and b/dashboard/public/favicon/apple-touch-icon.png differ diff --git a/dashboard/public/favicon/favicon-16x16.png b/dashboard/public/favicon/favicon-16x16.png new file mode 100644 index 00000000..8ac913a6 Binary files /dev/null and b/dashboard/public/favicon/favicon-16x16.png differ diff --git a/dashboard/public/favicon/favicon-32x32.png b/dashboard/public/favicon/favicon-32x32.png new file mode 100644 index 00000000..0b0a7a5f Binary files /dev/null and b/dashboard/public/favicon/favicon-32x32.png differ diff --git a/dashboard/public/favicon/favicon.ico b/dashboard/public/favicon/favicon.ico new file mode 100644 index 00000000..00d3e7d8 Binary files /dev/null and b/dashboard/public/favicon/favicon.ico differ diff --git a/dashboard/public/favicon/mstile-144x144.png b/dashboard/public/favicon/mstile-144x144.png new file mode 100644 index 00000000..5506c5d5 Binary files /dev/null and b/dashboard/public/favicon/mstile-144x144.png differ diff --git a/dashboard/public/favicon/mstile-150x150.png b/dashboard/public/favicon/mstile-150x150.png new file mode 100644 index 00000000..42502830 Binary files /dev/null and b/dashboard/public/favicon/mstile-150x150.png differ diff --git a/dashboard/public/favicon/mstile-310x150.png b/dashboard/public/favicon/mstile-310x150.png new file mode 100644 index 00000000..bea82a78 Binary files /dev/null and b/dashboard/public/favicon/mstile-310x150.png differ diff --git a/dashboard/public/favicon/mstile-310x310.png b/dashboard/public/favicon/mstile-310x310.png new file mode 100644 index 00000000..be5e503f Binary files /dev/null and b/dashboard/public/favicon/mstile-310x310.png differ diff --git a/dashboard/public/favicon/mstile-70x70.png b/dashboard/public/favicon/mstile-70x70.png new file mode 100644 index 00000000..4135e149 Binary files /dev/null and b/dashboard/public/favicon/mstile-70x70.png differ diff --git a/dashboard/public/favicon/safari-pinned-tab.svg b/dashboard/public/favicon/safari-pinned-tab.svg new file mode 100644 index 00000000..48276bce --- /dev/null +++ b/dashboard/public/favicon/safari-pinned-tab.svg @@ -0,0 +1,47 @@ + + + + +Created by potrace 1.14, written by Peter Selinger 2001-2017 + + + + + + + diff --git a/dashboard/public/logo/logo.png b/dashboard/public/logo/logo.png new file mode 100644 index 00000000..b58b8cce Binary files /dev/null and b/dashboard/public/logo/logo.png differ diff --git a/dashboard/public/logo/logo.svg b/dashboard/public/logo/logo.svg new file mode 100644 index 00000000..f9450325 --- /dev/null +++ b/dashboard/public/logo/logo.svg @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/logo/logo_font.png b/dashboard/public/logo/logo_font.png new file mode 100644 index 00000000..9b74b621 Binary files /dev/null and b/dashboard/public/logo/logo_font.png differ diff --git a/dashboard/public/logo/logo_font.svg b/dashboard/public/logo/logo_font.svg new file mode 100644 index 00000000..85d432f1 --- /dev/null +++ b/dashboard/public/logo/logo_font.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/logo/logo_font_negative.png b/dashboard/public/logo/logo_font_negative.png new file mode 100644 index 00000000..f9f6923b Binary files /dev/null and b/dashboard/public/logo/logo_font_negative.png differ diff --git a/dashboard/public/logo/logo_font_negative.svg b/dashboard/public/logo/logo_font_negative.svg new file mode 100644 index 00000000..f6b6a97e --- /dev/null +++ b/dashboard/public/logo/logo_font_negative.svg @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/logo/logo_negative.png b/dashboard/public/logo/logo_negative.png new file mode 100644 index 00000000..e9fb5096 Binary files /dev/null and b/dashboard/public/logo/logo_negative.png differ diff --git a/dashboard/public/logo/logo_negative.svg b/dashboard/public/logo/logo_negative.svg new file mode 100644 index 00000000..360c0de6 --- /dev/null +++ b/dashboard/public/logo/logo_negative.svg @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/logo/logo_single.png b/dashboard/public/logo/logo_single.png new file mode 100644 index 00000000..58673a0e Binary files /dev/null and b/dashboard/public/logo/logo_single.png differ diff --git a/dashboard/public/logo/logo_single.svg b/dashboard/public/logo/logo_single.svg new file mode 100644 index 00000000..f7c23182 --- /dev/null +++ b/dashboard/public/logo/logo_single.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/logo/logo_single_negative.png b/dashboard/public/logo/logo_single_negative.png new file mode 100644 index 00000000..dbce4e1c Binary files /dev/null and b/dashboard/public/logo/logo_single_negative.png differ diff --git a/dashboard/public/logo/logo_single_negative.svg b/dashboard/public/logo/logo_single_negative.svg new file mode 100644 index 00000000..70220bee --- /dev/null +++ b/dashboard/public/logo/logo_single_negative.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/logo/logo_square.png b/dashboard/public/logo/logo_square.png new file mode 100644 index 00000000..9bc47d61 Binary files /dev/null and b/dashboard/public/logo/logo_square.png differ diff --git a/dashboard/public/logo/logo_square.svg b/dashboard/public/logo/logo_square.svg new file mode 100644 index 00000000..26f4a023 --- /dev/null +++ b/dashboard/public/logo/logo_square.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/logo/logo_square_negative.png b/dashboard/public/logo/logo_square_negative.png new file mode 100644 index 00000000..93735b09 Binary files /dev/null and b/dashboard/public/logo/logo_square_negative.png differ diff --git a/dashboard/public/logo/logo_square_negative.svg b/dashboard/public/logo/logo_square_negative.svg new file mode 100644 index 00000000..9b75f40b --- /dev/null +++ b/dashboard/public/logo/logo_square_negative.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dashboard/public/logo/social.png b/dashboard/public/logo/social.png new file mode 100644 index 00000000..245ca81b Binary files /dev/null and b/dashboard/public/logo/social.png differ diff --git a/dashboard/public/robots.txt b/dashboard/public/robots.txt new file mode 100644 index 00000000..e9e57dc4 --- /dev/null +++ b/dashboard/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/dashboard/public/site.webmanifest b/dashboard/public/site.webmanifest new file mode 100644 index 00000000..1f92ee9a --- /dev/null +++ b/dashboard/public/site.webmanifest @@ -0,0 +1,20 @@ +{ + "name": "Numeraire Dashboard", + "short_name": "Numeraire", + "start_url": "/", + "icons": [ + { + "src": "/favicon/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/favicon/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/dashboard/src/actions/api-key.ts b/dashboard/src/actions/api-key.ts new file mode 100644 index 00000000..71408a13 --- /dev/null +++ b/dashboard/src/actions/api-key.ts @@ -0,0 +1,40 @@ +import type { ListApiKeysResponse } from 'src/lib/swissknife'; + +import useSWR from 'swr'; +import { useMemo } from 'react'; + +import { listApiKeys } from 'src/lib/swissknife'; + +import { endpointKeys } from './keys'; + +// ---------------------------------------------------------------------- + +type IListApiKeys = { + apiKeys?: ListApiKeysResponse; + apiKeysLoading: boolean; + apiKeysError?: any; + apiKeysValidating: boolean; +}; + +export function useListApiKeys(): IListApiKeys { + const fetcher = async () => { + const { data, error } = await listApiKeys(); + if (error) { + throw Error(error.reason); + } + + return data; + }; + + const { data, error, isLoading, isValidating } = useSWR(endpointKeys.apiKeys.list, fetcher); + + return useMemo( + () => ({ + apiKeys: data, + apiKeysLoading: isLoading, + apiKeysError: error, + apiKeysValidating: isValidating, + }), + [data, error, isLoading, isValidating] + ); +} diff --git a/dashboard/src/actions/invoices.ts b/dashboard/src/actions/invoices.ts new file mode 100644 index 00000000..0b8e8b1f --- /dev/null +++ b/dashboard/src/actions/invoices.ts @@ -0,0 +1,67 @@ +import type { InvoiceResponse, ListInvoicesResponse } from 'src/lib/swissknife'; + +import useSWR from 'swr'; +import { useMemo } from 'react'; + +import { getInvoice, listInvoices } from 'src/lib/swissknife'; + +import { endpointKeys } from './keys'; + +// ---------------------------------------------------------------------- + +type IListInvoices = { + invoices?: ListInvoicesResponse; + invoicesLoading: boolean; + invoicesError?: any; + invoicesValidating: boolean; +}; + +export function useListInvoices(limit?: number, offset?: number): IListInvoices { + const fetcher = async () => { + const { data, error } = await listInvoices({ query: { limit, offset } }); + if (error) { + throw Error(error.reason); + } + + return data; + }; + + const { data, error, isLoading, isValidating } = useSWR(endpointKeys.invoices.list, fetcher); + + return useMemo( + () => ({ + invoices: data, + invoicesLoading: isLoading, + invoicesError: error, + invoicesValidating: isValidating, + }), + [data, error, isLoading, isValidating] + ); +} + +type IGetInvoice = { + invoice?: InvoiceResponse; + invoiceLoading: boolean; + invoiceError?: any; + invoiceValidating: boolean; +}; + +export function useGetInvoice(id: string): IGetInvoice { + const fetcher = async () => { + const { data, error } = await getInvoice({ path: { id } }); + if (error) { + throw Error(error.reason); + } + + return data; + }; + + const { data, error, isLoading, isValidating } = useSWR(endpointKeys.invoices.get, fetcher); + + return { + invoice: data, + invoiceLoading: isLoading, + invoiceError: error, + invoiceValidating: isValidating, + }; +} diff --git a/dashboard/src/actions/keys.ts b/dashboard/src/actions/keys.ts new file mode 100644 index 00000000..516e3c23 --- /dev/null +++ b/dashboard/src/actions/keys.ts @@ -0,0 +1,46 @@ +export const endpointKeys = { + auth: { + me: '/api/auth/me', + signIn: 'signIn', + signUp: '/api/auth/sign-up', + }, + mempoolSpace: { + prices: 'mempoolSpacePrices', + }, + userWallet: { + get: 'userWallet', + balance: 'userWalletBalance', + lnAddress: { get: 'userWalletGetAddress' }, + payments: { list: 'userWalletListPayments', get: 'userWalletGetPayment' }, + invoices: { list: 'userWalletListInvoices', get: 'userWalletGetInvoice' }, + contacts: { list: 'userWalletListContacts' }, + apiKeys: { list: 'userWalletListApiKeys' }, + }, + wallets: { + list: 'listWallets', + get: 'getWallet', + listOverviews: 'listWalletOverviews', + }, + invoices: { + get: 'getInvoice', + list: 'listInvoices', + }, + payments: { + get: 'getPayment', + list: 'listPayments', + }, + lightning: { + node: { + info: 'nodeInfo', + lspInfo: 'lspIinfo', + lsps: 'lsps', + }, + addresses: { + list: 'listLnAddresses', + get: 'getLnAddress', + }, + }, + apiKeys: { + list: 'listApiKeys', + }, +}; diff --git a/dashboard/src/actions/ln-addresses.ts b/dashboard/src/actions/ln-addresses.ts new file mode 100644 index 00000000..dff20cc6 --- /dev/null +++ b/dashboard/src/actions/ln-addresses.ts @@ -0,0 +1,65 @@ +import type { LnAddress, ListAddressesResponse } from 'src/lib/swissknife'; + +import useSWR from 'swr'; +import { useMemo } from 'react'; + +import { getAddress, listAddresses } from 'src/lib/swissknife'; + +import { endpointKeys } from './keys'; + +interface IGetLnAddresses { + lnAddresses?: ListAddressesResponse; + lnAddressesLoading: boolean; + lnAddressesError?: any; + lnAddressesValidating: boolean; +} + +export function useListLnAddresses(limit?: number, offset?: number): IGetLnAddresses { + const fetcher = async () => { + const { data, error } = await listAddresses({ query: { limit, offset } }); + if (error) { + throw Error(error.reason); + } + + return data; + }; + + const { data, error, isLoading, isValidating } = useSWR(endpointKeys.lightning.addresses.list, fetcher); + + return useMemo( + () => ({ + lnAddresses: data, + lnAddressesLoading: isLoading, + lnAddressesError: error, + lnAddressesValidating: isValidating, + }), + [data, error, isLoading, isValidating] + ); +} + +interface IGetLnAddress { + lnAddress?: LnAddress; + lnAddressLoading: boolean; + lnAddressError?: any; + lnAddressValidating: boolean; +} + +export function useGetLnAddress(id: string): IGetLnAddress { + const fetcher = async () => { + const { data, error } = await getAddress({ path: { id } }); + if (error) { + throw Error(error.reason); + } + + return data; + }; + + const { data, error, isLoading, isValidating } = useSWR(endpointKeys.lightning.addresses.get, fetcher); + + return { + lnAddress: data, + lnAddressLoading: isLoading, + lnAddressError: error, + lnAddressValidating: isValidating, + }; +} diff --git a/dashboard/src/actions/ln-node.ts b/dashboard/src/actions/ln-node.ts new file mode 100644 index 00000000..a4a7ce5e --- /dev/null +++ b/dashboard/src/actions/ln-node.ts @@ -0,0 +1,100 @@ +import type { IBreezLSP, IBreezNodeInfo } from 'src/types/breez-node'; + +import useSWR from 'swr'; +import { useMemo } from 'react'; + +import { lspInfo, listLsps, nodeInfo } from 'src/lib/swissknife'; + +import { endpointKeys } from './keys'; + +// ---------------------------------------------------------------------- + +type IGetNodeInfo = { + nodeInfo?: IBreezNodeInfo; + nodeInfoLoading: boolean; + nodeInfoError: any; + nodeInfoValidating: boolean; +}; + +export function useGetNodeInfo(): IGetNodeInfo { + const fetcher = async () => { + const { data, error } = await nodeInfo(); + if (error) { + throw Error(error.reason); + } + + return data; + }; + + const { data, isLoading, error, isValidating } = useSWR(endpointKeys.lightning.node.info, fetcher); + + return useMemo( + () => ({ + nodeInfo: data as IBreezNodeInfo, + nodeInfoLoading: isLoading, + nodeInfoError: error, + nodeInfoValidating: isValidating, + }), + [data, error, isLoading, isValidating] + ); +} + +type IGetCurrentLSP = { + currentLSP?: IBreezLSP; + currentLSPLoading: boolean; + currentLSPError: any; + currentLSPValidating: boolean; +}; + +export function useGetCurrentLSP(): IGetCurrentLSP { + const fetcher = async () => { + const { data, error } = await lspInfo(); + if (error) { + throw Error(error.reason); + } + + return data; + }; + + const { data, isLoading, error, isValidating } = useSWR(endpointKeys.lightning.node.lspInfo, fetcher); + + return useMemo( + () => ({ + currentLSP: data as IBreezLSP, + currentLSPLoading: isLoading, + currentLSPError: error, + currentLSPValidating: isValidating, + }), + [data, error, isLoading, isValidating] + ); +} + +type IGetLSPs = { + lsps?: IBreezLSP[]; + lspsLoading: boolean; + lspsError: any; + lspsValidating: boolean; +}; + +export function useGetLSPs(): IGetLSPs { + const fetcher = async () => { + const { data, error } = await listLsps(); + if (error) { + throw Error(error.reason); + } + + return data; + }; + + const { data, isLoading, error, isValidating } = useSWR(endpointKeys.lightning.node.lsps, fetcher); + + return useMemo( + () => ({ + lsps: data as IBreezLSP[], + lspsLoading: isLoading, + lspsError: error, + lspsValidating: isValidating, + }), + [data, error, isLoading, isValidating] + ); +} diff --git a/dashboard/src/actions/mempool-space.ts b/dashboard/src/actions/mempool-space.ts new file mode 100644 index 00000000..6dd144d7 --- /dev/null +++ b/dashboard/src/actions/mempool-space.ts @@ -0,0 +1,34 @@ +import type { IFiatPrices } from 'src/types/bitcoin'; + +import useSWR from 'swr'; +import { useMemo } from 'react'; + +import { CONFIG } from 'src/config-global'; + +import { endpointKeys } from './keys'; + +interface IGetFiatPrices { + fiatPrices?: IFiatPrices; + fiatPricesLoading: boolean; + fiatPricesError: any; + fiatPricesValidating: boolean; +} + +export function useFetchFiatPrices(): IGetFiatPrices { + const fetcher = async (): Promise => { + const data = await fetch(`${CONFIG.site.mempoolSpace}/prices`); + return data.json(); + }; + + const { data, isLoading, error, isValidating } = useSWR(endpointKeys.mempoolSpace.prices, fetcher); + + return useMemo( + () => ({ + fiatPrices: data, + fiatPricesLoading: isLoading, + fiatPricesError: error, + fiatPricesValidating: isValidating, + }), + [data, error, isLoading, isValidating] + ); +} diff --git a/dashboard/src/actions/payments.ts b/dashboard/src/actions/payments.ts new file mode 100644 index 00000000..4522aa85 --- /dev/null +++ b/dashboard/src/actions/payments.ts @@ -0,0 +1,67 @@ +import type { PaymentResponse, ListPaymentsResponse } from 'src/lib/swissknife'; + +import useSWR from 'swr'; +import { useMemo } from 'react'; + +import { getPayment, listPayments } from 'src/lib/swissknife'; + +import { endpointKeys } from './keys'; + +// ---------------------------------------------------------------------- + +interface IGetPayments { + payments?: ListPaymentsResponse; + paymentsLoading: boolean; + paymentsError?: any; + paymentsValidating: boolean; +} + +export function useListPayments(limit?: number, offset?: number): IGetPayments { + const fetcher = async () => { + const { data, error } = await listPayments({ query: { limit, offset } }); + if (error) { + throw Error(error.reason); + } + + return data; + }; + + const { data, error, isLoading, isValidating } = useSWR(endpointKeys.payments.list, fetcher); + + return useMemo( + () => ({ + payments: data, + paymentsLoading: isLoading, + paymentsError: error, + paymentsValidating: isValidating, + }), + [data, error, isLoading, isValidating] + ); +} + +interface IGetPayment { + payment?: PaymentResponse; + paymentLoading: boolean; + paymentError?: any; + paymentValidating: boolean; +} + +export function useGetPayment(id: string): IGetPayment { + const fetcher = async () => { + const { data, error } = await getPayment({ path: { id } }); + if (error) { + throw Error(error.reason); + } + + return data; + }; + + const { data, error, isLoading, isValidating } = useSWR(endpointKeys.payments.get, fetcher); + + return { + payment: data, + paymentLoading: isLoading, + paymentError: error, + paymentValidating: isValidating, + }; +} diff --git a/dashboard/src/actions/user-wallet.ts b/dashboard/src/actions/user-wallet.ts new file mode 100644 index 00000000..10c8d26a --- /dev/null +++ b/dashboard/src/actions/user-wallet.ts @@ -0,0 +1,293 @@ +import type { + Balance, + LnAddress, + InvoiceResponse, + PaymentResponse, + ListContactsResponse, + ListWalletApiKeysData, + ListWalletInvoicesData, + ListWalletApiKeysResponse, + ListWalletInvoicesResponse, + ListWalletPaymentsResponse, +} from 'src/lib/swissknife'; + +import useSWR from 'swr'; +import { useMemo } from 'react'; + +import { + listContacts, + getUserWallet, + getWalletAddress, + getWalletBalance, + getWalletInvoice, + getWalletPayment, + listWalletApiKeys, + listWalletInvoices, + listWalletPayments, +} from 'src/lib/swissknife'; + +import { endpointKeys } from './keys'; + +import type { IGetWallet } from './wallet'; + +// ---------------------------------------------------------------------- + +export function useGetUserWallet(): IGetWallet { + const fetcher = async () => { + const { data, error } = await getUserWallet(); + if (error) { + throw Error(error.reason); + } + + return data; + }; + + const { data, error, isLoading, isValidating } = useSWR(endpointKeys.userWallet.get, fetcher); + + return useMemo( + () => ({ + wallet: data, + walletLoading: isLoading, + walletError: error, + walletValidating: isValidating, + }), + [data, error, isLoading, isValidating] + ); +} + +type IGetUserBalance = { + userBalance?: Balance; + userBalanceLoading: boolean; + userBalanceError?: any; + userBalanceValidating: boolean; +}; + +export function useGetWalletBalance(): IGetUserBalance { + const fetcher = async () => { + const { data, error } = await getWalletBalance(); + if (error) { + throw Error(error.reason); + } + + return data; + }; + + const { data, error, isLoading, isValidating } = useSWR(endpointKeys.userWallet.balance, fetcher); + + return useMemo( + () => ({ + userBalance: data, + userBalanceLoading: isLoading, + userBalanceError: error, + userBalanceValidating: isValidating, + }), + [data, error, isLoading, isValidating] + ); +} + +type IListInvoices = { + invoices?: ListWalletInvoicesResponse; + invoicesLoading: boolean; + invoicesError?: any; + invoicesValidating: boolean; +}; + +export function useListWalletInvoices(query?: ListWalletInvoicesData): IListInvoices { + const fetcher = async () => { + const { data, error } = await listWalletInvoices(query); + if (error) { + throw Error(error.reason); + } + + return data; + }; + + const { data, error, isLoading, isValidating } = useSWR(endpointKeys.userWallet.invoices.list, fetcher); + + return useMemo( + () => ({ + invoices: data, + invoicesLoading: isLoading, + invoicesError: error, + invoicesValidating: isValidating, + }), + [data, error, isLoading, isValidating] + ); +} + +type IGetInvoice = { + invoice?: InvoiceResponse; + invoiceLoading: boolean; + invoiceError?: any; + invoiceValidating: boolean; +}; + +export function useGetWalletInvoice(id: string): IGetInvoice { + const fetcher = async () => { + const { data, error } = await getWalletInvoice({ path: { id } }); + if (error) { + throw Error(error.reason); + } + + return data; + }; + + const { data, error, isLoading, isValidating } = useSWR(endpointKeys.userWallet.invoices.get, fetcher); + + return { + invoice: data, + invoiceLoading: isLoading, + invoiceError: error, + invoiceValidating: isValidating, + }; +} + +type IListPayments = { + payments?: ListWalletPaymentsResponse; + paymentsLoading: boolean; + paymentsError?: any; + paymentsValidating: boolean; +}; + +export function useListWalletPayments(limit?: number, offset?: number): IListPayments { + const fetcher = async () => { + const { data, error } = await listWalletPayments({ query: { limit, offset } }); + if (error) { + throw Error(error.reason); + } + + return data; + }; + + const { data, error, isLoading, isValidating } = useSWR(endpointKeys.userWallet.payments.list, fetcher); + + return useMemo( + () => ({ + payments: data, + paymentsLoading: isLoading, + paymentsError: error, + paymentsValidating: isValidating, + }), + [data, error, isLoading, isValidating] + ); +} + +type IGetPayment = { + payment?: PaymentResponse; + paymentLoading: boolean; + paymentError?: any; + paymentValidating: boolean; +}; + +export function useGetWalletPayment(id: string): IGetPayment { + const fetcher = async () => { + const { data, error } = await getWalletPayment({ path: { id } }); + if (error) { + throw Error(error.reason); + } + + return data; + }; + + const { data, error, isLoading, isValidating } = useSWR(endpointKeys.userWallet.payments.get, fetcher); + + return { + payment: data, + paymentLoading: isLoading, + paymentError: error, + paymentValidating: isValidating, + }; +} + +type IGetLnAddress = { + lnAddress?: LnAddress; + lnAddressLoading: boolean; + lnAddressError?: any; + lnAddressValidating: boolean; +}; + +export function useGetWalletLnAddress(shouldRetryOnError: boolean = false): IGetLnAddress { + const fetcher = async () => { + const { data, error, response } = await getWalletAddress(); + if (error) { + if (response.status === 404) { + return undefined; + } + + throw Error(error.reason); + } + + return data; + }; + + const { data, error, isLoading, isValidating } = useSWR(endpointKeys.userWallet.lnAddress.get, fetcher, { + shouldRetryOnError, + }); + + return { + lnAddress: data, + lnAddressLoading: isLoading, + lnAddressError: error, + lnAddressValidating: isValidating, + }; +} + +type IListContacts = { + contacts?: ListContactsResponse; + contactsLoading: boolean; + contactsError?: any; + contactsValidating: boolean; +}; + +export function useListWalletContacts(): IListContacts { + const fetcher = async () => { + const { data, error } = await listContacts(); + if (error) { + throw Error(error.reason); + } + + return data; + }; + + const { data, error, isLoading, isValidating } = useSWR(endpointKeys.userWallet.contacts.list, fetcher); + + return useMemo( + () => ({ + contacts: data, + contactsLoading: isLoading, + contactsError: error, + contactsValidating: isValidating, + }), + [data, error, isLoading, isValidating] + ); +} + +type IListApiKeys = { + apiKeys?: ListWalletApiKeysResponse; + apiKeysLoading: boolean; + apiKeysError?: any; + apiKeysValidating: boolean; +}; + +export function useListWalletApiKeys(query?: ListWalletApiKeysData): IListApiKeys { + const fetcher = async () => { + const { data, error } = await listWalletApiKeys(query); + if (error) { + throw Error(error.reason); + } + + return data; + }; + + const { data, error, isLoading, isValidating } = useSWR(endpointKeys.userWallet.apiKeys.list, fetcher); + + return useMemo( + () => ({ + apiKeys: data, + apiKeysLoading: isLoading, + apiKeysError: error, + apiKeysValidating: isValidating, + }), + [data, error, isLoading, isValidating] + ); +} diff --git a/dashboard/src/actions/wallet.ts b/dashboard/src/actions/wallet.ts new file mode 100644 index 00000000..f170f9da --- /dev/null +++ b/dashboard/src/actions/wallet.ts @@ -0,0 +1,70 @@ +import type { WalletResponse, ListWalletOverviewsResponse } from 'src/lib/swissknife'; + +import useSWR from 'swr'; +import { useMemo } from 'react'; + +import { getWallet, listWalletOverviews } from 'src/lib/swissknife'; + +import { endpointKeys } from './keys'; + +// ---------------------------------------------------------------------- + +export type IGetWallet = { + wallet?: WalletResponse; + walletLoading: boolean; + walletError?: any; + walletValidating: boolean; +}; + +export function useGetWallet(id: string): IGetWallet { + const fetcher = async () => { + const { data, error } = await getWallet({ path: { id } }); + if (error) { + throw Error(error.reason); + } + + return data; + }; + + const { data, error, isLoading, isValidating } = useSWR(endpointKeys.wallets.get, fetcher); + + return useMemo( + () => ({ + wallet: data, + walletLoading: isLoading, + walletError: error, + walletValidating: isValidating, + }), + [data, error, isLoading, isValidating] + ); +} + +type IListWallets = { + walletOverviews?: ListWalletOverviewsResponse; + walletOverviewsLoading: boolean; + walletOverviewsError?: any; + walletOverviewsValidating: boolean; +}; + +export function useListWalletOverviews(): IListWallets { + const fetcher = async () => { + const { data, error } = await listWalletOverviews(); + if (error) { + throw Error(error.reason); + } + + return data; + }; + + const { data, error, isLoading, isValidating } = useSWR(endpointKeys.wallets.listOverviews, fetcher); + + return useMemo( + () => ({ + walletOverviews: data, + walletOverviewsLoading: isLoading, + walletOverviewsError: error, + walletOverviewsValidating: isValidating, + }), + [data, error, isLoading, isValidating] + ); +} diff --git a/dashboard/src/app/(index)/admin/api-keys/page.tsx b/dashboard/src/app/(index)/admin/api-keys/page.tsx new file mode 100644 index 00000000..f75473d1 --- /dev/null +++ b/dashboard/src/app/(index)/admin/api-keys/page.tsx @@ -0,0 +1,13 @@ +import { appTitle } from 'src/utils/format-string'; + +import { ApiKeyListView } from 'src/sections/api-key/view'; + +// ---------------------------------------------------------------------- + +export const metadata = { + title: appTitle('API Keys Management'), +}; + +export default function AdminApiKeyListPage() { + return ; +} diff --git a/dashboard/src/app/(index)/admin/invoices/[id]/page.tsx b/dashboard/src/app/(index)/admin/invoices/[id]/page.tsx new file mode 100644 index 00000000..a8ff1503 --- /dev/null +++ b/dashboard/src/app/(index)/admin/invoices/[id]/page.tsx @@ -0,0 +1,21 @@ +import { appTitle } from 'src/utils/format-string'; + +import { AdminInvoiceDetailsView } from 'src/sections/transaction/view'; + +// ---------------------------------------------------------------------- + +export const metadata = { + title: appTitle('Invoice Details'), +}; + +type Props = { + params: { + id: string; + }; +}; + +export default function AdminInvoiceDetailsPage({ params }: Props) { + const { id } = params; + + return ; +} diff --git a/dashboard/src/app/(index)/admin/invoices/page.tsx b/dashboard/src/app/(index)/admin/invoices/page.tsx new file mode 100644 index 00000000..5f6feda2 --- /dev/null +++ b/dashboard/src/app/(index)/admin/invoices/page.tsx @@ -0,0 +1,13 @@ +import { appTitle } from 'src/utils/format-string'; + +import { AdminInvoiceListView } from 'src/sections/transaction/view'; + +// ---------------------------------------------------------------------- + +export const metadata = { + title: appTitle('Invoices Management'), +}; + +export default function AdminInvoiceListPage() { + return ; +} diff --git a/dashboard/src/app/(index)/admin/lightning-addresses/[id]/page.tsx b/dashboard/src/app/(index)/admin/lightning-addresses/[id]/page.tsx new file mode 100644 index 00000000..8471d3dd --- /dev/null +++ b/dashboard/src/app/(index)/admin/lightning-addresses/[id]/page.tsx @@ -0,0 +1,21 @@ +import { appTitle } from 'src/utils/format-string'; + +import { AdminLnAddressDetailsView } from 'src/sections/ln-address/view'; + +// ---------------------------------------------------------------------- + +export const metadata = { + title: appTitle('Lightning Address Details'), +}; + +type Props = { + params: { + id: string; + }; +}; + +export default function AdminLnAddressDetailsPage({ params }: Props) { + const { id } = params; + + return ; +} diff --git a/dashboard/src/app/(index)/admin/lightning-addresses/page.tsx b/dashboard/src/app/(index)/admin/lightning-addresses/page.tsx new file mode 100644 index 00000000..623b68da --- /dev/null +++ b/dashboard/src/app/(index)/admin/lightning-addresses/page.tsx @@ -0,0 +1,13 @@ +import { appTitle } from 'src/utils/format-string'; + +import { LnAddressListView } from 'src/sections/ln-address/view'; + +// ---------------------------------------------------------------------- + +export const metadata = { + title: appTitle('Lightning Addresses Management'), +}; + +export default function LnAddressListPage() { + return ; +} diff --git a/dashboard/src/app/(index)/admin/lightning-node/page.tsx b/dashboard/src/app/(index)/admin/lightning-node/page.tsx new file mode 100644 index 00000000..4d69e912 --- /dev/null +++ b/dashboard/src/app/(index)/admin/lightning-node/page.tsx @@ -0,0 +1,30 @@ +import { Alert } from '@mui/material'; + +import { appTitle } from 'src/utils/format-string'; + +import { DashboardContent } from 'src/layouts/dashboard'; + +import { NodeView, BreezNodeView } from 'src/sections/node/view'; + +// ---------------------------------------------------------------------- + +export const metadata = { + title: appTitle('Node management'), +}; + +export default function OverviewBankingPage() { + switch (process.env.LN_PROVIDER) { + case 'breez': + return ; + case 'cln': + return ; + case 'lnd': + return ; + default: + return ( + + Page not available for Non Breez Lightning Provider + + ); + } +} diff --git a/dashboard/src/app/(index)/admin/payments/[id]/page.tsx b/dashboard/src/app/(index)/admin/payments/[id]/page.tsx new file mode 100644 index 00000000..4f5261dc --- /dev/null +++ b/dashboard/src/app/(index)/admin/payments/[id]/page.tsx @@ -0,0 +1,21 @@ +import { appTitle } from 'src/utils/format-string'; + +import { AdminPaymentDetailsView } from 'src/sections/transaction/view'; + +// ---------------------------------------------------------------------- + +export const metadata = { + title: appTitle('Payment Details'), +}; + +type Props = { + params: { + id: string; + }; +}; + +export default function AdminPaymentDetailsPage({ params }: Props) { + const { id } = params; + + return ; +} diff --git a/dashboard/src/app/(index)/admin/payments/page.tsx b/dashboard/src/app/(index)/admin/payments/page.tsx new file mode 100644 index 00000000..f3d40be9 --- /dev/null +++ b/dashboard/src/app/(index)/admin/payments/page.tsx @@ -0,0 +1,13 @@ +import { appTitle } from 'src/utils/format-string'; + +import { AdminPaymentListView } from 'src/sections/transaction/view'; + +// ---------------------------------------------------------------------- + +export const metadata = { + title: appTitle('Payments Management'), +}; + +export default function AdminPaymentListPage() { + return ; +} diff --git a/dashboard/src/app/(index)/admin/wallets/page.tsx b/dashboard/src/app/(index)/admin/wallets/page.tsx new file mode 100644 index 00000000..eb42d417 --- /dev/null +++ b/dashboard/src/app/(index)/admin/wallets/page.tsx @@ -0,0 +1,13 @@ +import { appTitle } from 'src/utils/format-string'; + +import { WalletListView } from 'src/sections/wallet/view'; + +// ---------------------------------------------------------------------- + +export const metadata = { + title: appTitle('Wallets Management'), +}; + +export default function AdminWalletListPage() { + return ; +} diff --git a/dashboard/src/app/(index)/layout.tsx b/dashboard/src/app/(index)/layout.tsx new file mode 100644 index 00000000..641ef797 --- /dev/null +++ b/dashboard/src/app/(index)/layout.tsx @@ -0,0 +1,22 @@ +import { CONFIG } from 'src/config-global'; +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/dashboard/src/app/(index)/loading.tsx b/dashboard/src/app/(index)/loading.tsx new file mode 100644 index 00000000..5bfa202d --- /dev/null +++ b/dashboard/src/app/(index)/loading.tsx @@ -0,0 +1,7 @@ +import { LoadingScreen } from 'src/components/loading-screen'; + +// ---------------------------------------------------------------------- + +export default function Loading() { + return ; +} diff --git a/dashboard/src/app/(index)/page.tsx b/dashboard/src/app/(index)/page.tsx new file mode 100644 index 00000000..65beec78 --- /dev/null +++ b/dashboard/src/app/(index)/page.tsx @@ -0,0 +1,7 @@ +import WalletPage from './wallet/page'; + +// ---------------------------------------------------------------------- + +export default function OverviewAppPage() { + return ; +} diff --git a/dashboard/src/app/(index)/settings/page.tsx b/dashboard/src/app/(index)/settings/page.tsx new file mode 100644 index 00000000..4ea4abc3 --- /dev/null +++ b/dashboard/src/app/(index)/settings/page.tsx @@ -0,0 +1,13 @@ +import { appTitle } from 'src/utils/format-string'; + +import { SettingsView } from 'src/sections/settings/view'; + +// ---------------------------------------------------------------------- + +export const metadata = { + title: appTitle('Settings'), +}; + +export default function Page() { + return ; +} diff --git a/dashboard/src/app/(index)/wallet/contacts/page.tsx b/dashboard/src/app/(index)/wallet/contacts/page.tsx new file mode 100644 index 00000000..253f2a85 --- /dev/null +++ b/dashboard/src/app/(index)/wallet/contacts/page.tsx @@ -0,0 +1,13 @@ +import { appTitle } from 'src/utils/format-string'; + +import { ContactListView } from 'src/sections/contact/view'; + +// ---------------------------------------------------------------------- + +export const metadata = { + title: appTitle('Contacts'), +}; + +export default function LnAddressDetailsPage() { + return ; +} diff --git a/dashboard/src/app/(index)/wallet/invoices/[id]/page.tsx b/dashboard/src/app/(index)/wallet/invoices/[id]/page.tsx new file mode 100644 index 00000000..744b9036 --- /dev/null +++ b/dashboard/src/app/(index)/wallet/invoices/[id]/page.tsx @@ -0,0 +1,21 @@ +import { appTitle } from 'src/utils/format-string'; + +import { InvoiceDetailsView } from 'src/sections/transaction/view'; + +// ---------------------------------------------------------------------- + +export const metadata = { + title: appTitle('Invoice Details'), +}; + +type Props = { + params: { + id: string; + }; +}; + +export default function InvoiceDetailsPage({ params }: Props) { + const { id } = params; + + return ; +} diff --git a/dashboard/src/app/(index)/wallet/invoices/page.tsx b/dashboard/src/app/(index)/wallet/invoices/page.tsx new file mode 100644 index 00000000..ba3eb230 --- /dev/null +++ b/dashboard/src/app/(index)/wallet/invoices/page.tsx @@ -0,0 +1,13 @@ +import { appTitle } from 'src/utils/format-string'; + +import { InvoiceListView } from 'src/sections/transaction/view'; + +// ---------------------------------------------------------------------- + +export const metadata = { + title: appTitle('Invoices'), +}; + +export default function InvoiceListPage() { + return ; +} diff --git a/dashboard/src/app/(index)/wallet/lightning-address/page.tsx b/dashboard/src/app/(index)/wallet/lightning-address/page.tsx new file mode 100644 index 00000000..bcd20583 --- /dev/null +++ b/dashboard/src/app/(index)/wallet/lightning-address/page.tsx @@ -0,0 +1,13 @@ +import { appTitle } from 'src/utils/format-string'; + +import { LnAddressDetailsView } from 'src/sections/ln-address/view'; + +// ---------------------------------------------------------------------- + +export const metadata = { + title: appTitle('Lightning Address'), +}; + +export default function LnAddressDetailsPage() { + return ; +} diff --git a/dashboard/src/app/(index)/wallet/nostr-address/page.tsx b/dashboard/src/app/(index)/wallet/nostr-address/page.tsx new file mode 100644 index 00000000..814fbf02 --- /dev/null +++ b/dashboard/src/app/(index)/wallet/nostr-address/page.tsx @@ -0,0 +1,13 @@ +import { appTitle } from 'src/utils/format-string'; + +import { NostrDetailsView } from 'src/sections/nostr/view'; + +// ---------------------------------------------------------------------- + +export const metadata = { + title: appTitle('Nostr Address'), +}; + +export default function LnAddressDetailsPage() { + return ; +} diff --git a/dashboard/src/app/(index)/wallet/page.tsx b/dashboard/src/app/(index)/wallet/page.tsx new file mode 100644 index 00000000..02ceba4a --- /dev/null +++ b/dashboard/src/app/(index)/wallet/page.tsx @@ -0,0 +1,13 @@ +import { appTitle } from 'src/utils/format-string'; + +import { WalletView } from 'src/sections/wallet/view'; + +// ---------------------------------------------------------------------- + +export const metadata = { + title: appTitle('Wallet'), +}; + +export default function WalletPage() { + return ; +} diff --git a/dashboard/src/app/(index)/wallet/payments/[id]/page.tsx b/dashboard/src/app/(index)/wallet/payments/[id]/page.tsx new file mode 100644 index 00000000..9340f4a2 --- /dev/null +++ b/dashboard/src/app/(index)/wallet/payments/[id]/page.tsx @@ -0,0 +1,21 @@ +import { appTitle } from 'src/utils/format-string'; + +import { PaymentDetailsView } from 'src/sections/transaction/view'; + +// ---------------------------------------------------------------------- + +export const metadata = { + title: appTitle('Payment Details'), +}; + +type Props = { + params: { + id: string; + }; +}; + +export default function PaymentDetailsPage({ params }: Props) { + const { id } = params; + + return ; +} diff --git a/dashboard/src/app/(index)/wallet/payments/page.tsx b/dashboard/src/app/(index)/wallet/payments/page.tsx new file mode 100644 index 00000000..95b187da --- /dev/null +++ b/dashboard/src/app/(index)/wallet/payments/page.tsx @@ -0,0 +1,13 @@ +import { appTitle } from 'src/utils/format-string'; + +import { PaymentListView } from 'src/sections/transaction/view'; + +// ---------------------------------------------------------------------- + +export const metadata = { + title: appTitle('Payments'), +}; + +export default function PaymentListPage() { + return ; +} diff --git a/dashboard/src/app/layout.tsx b/dashboard/src/app/layout.tsx new file mode 100644 index 00000000..e5999bd0 --- /dev/null +++ b/dashboard/src/app/layout.tsx @@ -0,0 +1,101 @@ +import 'src/global.css'; + +// ---------------------------------------------------------------------- + +import type { Viewport } from 'next'; + +import { CONFIG } from 'src/config-global'; +import { primary } from 'src/theme/core/palette'; +import { LocalizationProvider } from 'src/locales'; +import { detectLanguage } from 'src/locales/server'; +import { I18nProvider } from 'src/locales/i18n-provider'; +import { ThemeProvider } from 'src/theme/theme-provider'; +import { getInitColorSchemeScript } from 'src/theme/color-scheme-script'; + +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 SupabaseAuthProvider } from 'src/auth/context/supabase'; + +// ---------------------------------------------------------------------- + +const AuthProvider = + (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 = { + title: CONFIG.site.name, + description: `${CONFIG.site.name}, your assistant to handle everything Bitcoin`, + keywords: 'bitcoin,numeraire,swissknife,blockchain,lightning,rgb,protocol,smartcontract,decentralised,network,taproot-assets', + manifest: '/site.webmanifest', + icons: [ + { rel: 'icon', url: `${CONFIG.site.basePath}/favicon/favicon.ico` }, + { + rel: 'icon', + type: 'image/png', + sizes: '16x16', + url: `${CONFIG.site.basePath}/favicon/favicon-16x16.png`, + }, + { + rel: 'icon', + type: 'image/png', + sizes: '32x32', + url: `${CONFIG.site.basePath}/favicon/favicon-32x32.png`, + }, + { + rel: 'apple-touch-icon', + sizes: '180x180', + url: `${CONFIG.site.basePath}/favicon/apple-touch-icon.png`, + }, + { + rel: 'mask-icon', + color: '#5bbad5', + url: `${CONFIG.site.basePath}/favicon/safari-pinned-tab.svg`, + }, + ], +}; + +type Props = { + children: React.ReactNode; +}; + +export default async function RootLayout({ children }: Props) { + const lang = CONFIG.isStaticExport ? 'en' : await detectLanguage(); + + const settings = CONFIG.isStaticExport ? defaultSettings : await detectSettings(); + + return ( + + + {getInitColorSchemeScript} + + + + + + + + + + + {children} + + + + + + + + + ); +} diff --git a/dashboard/src/app/loading.tsx b/dashboard/src/app/loading.tsx new file mode 100644 index 00000000..7d29a81a --- /dev/null +++ b/dashboard/src/app/loading.tsx @@ -0,0 +1,7 @@ +import { SplashScreen } from 'src/components/loading-screen'; + +// ---------------------------------------------------------------------- + +export default function Loading() { + return ; +} diff --git a/dashboard/src/app/login/callback/page.tsx b/dashboard/src/app/login/callback/page.tsx new file mode 100644 index 00000000..a7a78fd4 --- /dev/null +++ b/dashboard/src/app/login/callback/page.tsx @@ -0,0 +1,7 @@ +import { SplashScreen } from 'src/components/loading-screen'; + +// ---------------------------------------------------------------------- + +export default function CallbackPage() { + return ; +} diff --git a/dashboard/src/app/login/layout.tsx b/dashboard/src/app/login/layout.tsx new file mode 100644 index 00000000..af074b0a --- /dev/null +++ b/dashboard/src/app/login/layout.tsx @@ -0,0 +1,17 @@ +import { AuthCenteredLayout } from 'src/layouts/auth-centered'; + +import { GuestGuard } from 'src/auth/guard'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return ( + + {children} + + ); +} diff --git a/dashboard/src/app/login/page.tsx b/dashboard/src/app/login/page.tsx new file mode 100644 index 00000000..5194a831 --- /dev/null +++ b/dashboard/src/app/login/page.tsx @@ -0,0 +1,22 @@ +import { appTitle } from 'src/utils/format-string'; + +import { CONFIG } from 'src/config-global'; + +import { JwtSignInView } from 'src/sections/auth/jwt'; +import { Auth0SignInView } from 'src/sections/auth/auth0'; +import { SupabaseSignInView } from 'src/sections/auth/supabase'; + +// ---------------------------------------------------------------------- + +export const metadata = { title: appTitle('Sign In') }; + +export default function Page() { + switch (CONFIG.auth.method) { + case 'auth0': + return ; + case 'supabase': + return ; + default: + return ; + } +} diff --git a/dashboard/src/app/not-found.tsx b/dashboard/src/app/not-found.tsx new file mode 100644 index 00000000..9b66413a --- /dev/null +++ b/dashboard/src/app/not-found.tsx @@ -0,0 +1,13 @@ +import { appTitle } from 'src/utils/format-string'; + +import { CONFIG } from 'src/config-global'; + +import { NotFoundView } from 'src/sections/error'; + +// ---------------------------------------------------------------------- + +export const metadata = { title: appTitle(`404 page not found! | Error - ${CONFIG.site.name}`) }; + +export default function NotFoundPage() { + return ; +} diff --git a/dashboard/src/app/reset-password/layout.tsx b/dashboard/src/app/reset-password/layout.tsx new file mode 100644 index 00000000..9726184f --- /dev/null +++ b/dashboard/src/app/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/dashboard/src/app/reset-password/page.tsx b/dashboard/src/app/reset-password/page.tsx new file mode 100644 index 00000000..bc7ea719 --- /dev/null +++ b/dashboard/src/app/reset-password/page.tsx @@ -0,0 +1,11 @@ +import { CONFIG } from 'src/config-global'; + +import { SupabaseResetPasswordView } from 'src/sections/auth/supabase'; + +// ---------------------------------------------------------------------- + +export const metadata = { title: `Reset password | Supabase - ${CONFIG.site.name}` }; + +export default function Page() { + return ; +} diff --git a/dashboard/src/app/sign-up/layout.tsx b/dashboard/src/app/sign-up/layout.tsx new file mode 100644 index 00000000..af074b0a --- /dev/null +++ b/dashboard/src/app/sign-up/layout.tsx @@ -0,0 +1,17 @@ +import { AuthCenteredLayout } from 'src/layouts/auth-centered'; + +import { GuestGuard } from 'src/auth/guard'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export default function Layout({ children }: Props) { + return ( + + {children} + + ); +} diff --git a/dashboard/src/app/sign-up/page.tsx b/dashboard/src/app/sign-up/page.tsx new file mode 100644 index 00000000..688fd213 --- /dev/null +++ b/dashboard/src/app/sign-up/page.tsx @@ -0,0 +1,19 @@ +import { appTitle } from 'src/utils/format-string'; + +import { CONFIG } from 'src/config-global'; + +import { JwtSignUpView } from 'src/sections/auth/jwt'; +import { SupabaseSignUpView } from 'src/sections/auth/supabase'; + +// ---------------------------------------------------------------------- + +export const metadata = { title: appTitle('Sign Up') }; + +export default function Page() { + switch (CONFIG.auth.method) { + case 'supabase': + return ; + default: + return ; + } +} diff --git a/dashboard/src/app/update-password/layout.tsx b/dashboard/src/app/update-password/layout.tsx new file mode 100644 index 00000000..9726184f --- /dev/null +++ b/dashboard/src/app/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/dashboard/src/app/update-password/page.tsx b/dashboard/src/app/update-password/page.tsx new file mode 100644 index 00000000..cc7a9b5d --- /dev/null +++ b/dashboard/src/app/update-password/page.tsx @@ -0,0 +1,11 @@ +import { CONFIG } from 'src/config-global'; + +import { SupabaseUpdatePasswordView } from 'src/sections/auth/supabase'; + +// ---------------------------------------------------------------------- + +export const metadata = { title: `Update password | Supabase - ${CONFIG.site.name}` }; + +export default function Page() { + return ; +} diff --git a/dashboard/src/app/verify/layout.tsx b/dashboard/src/app/verify/layout.tsx new file mode 100644 index 00000000..9726184f --- /dev/null +++ b/dashboard/src/app/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/dashboard/src/app/verify/page.tsx b/dashboard/src/app/verify/page.tsx new file mode 100644 index 00000000..a6e0ea1b --- /dev/null +++ b/dashboard/src/app/verify/page.tsx @@ -0,0 +1,11 @@ +import { CONFIG } from 'src/config-global'; + +import { SupabaseVerifyView } from 'src/sections/auth/supabase'; + +// ---------------------------------------------------------------------- + +export const metadata = { title: `Verify | Supabase - ${CONFIG.site.name}` }; + +export default function Page() { + return ; +} diff --git a/dashboard/src/assets/data/countries.ts b/dashboard/src/assets/data/countries.ts new file mode 100644 index 00000000..1fbd01e4 --- /dev/null +++ b/dashboard/src/assets/data/countries.ts @@ -0,0 +1,251 @@ +export const countries = [ + { code: '', label: '', phone: '' }, + { 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/dashboard/src/assets/data/currencies.ts b/dashboard/src/assets/data/currencies.ts new file mode 100644 index 00000000..73b01819 --- /dev/null +++ b/dashboard/src/assets/data/currencies.ts @@ -0,0 +1,3 @@ +import type { CurrencyValue } from 'src/types/currency'; + +export const currencies: Array = ['USD', 'CHF', 'EUR', 'GBP', 'CAD', 'AUD', 'JPY']; diff --git a/dashboard/src/assets/data/index.ts b/dashboard/src/assets/data/index.ts new file mode 100644 index 00000000..21d4fc6e --- /dev/null +++ b/dashboard/src/assets/data/index.ts @@ -0,0 +1,2 @@ +export * from './countries'; +export * from './currencies'; diff --git a/dashboard/src/assets/icons/email-inbox-icon.tsx b/dashboard/src/assets/icons/email-inbox-icon.tsx new file mode 100644 index 00000000..bf73f104 --- /dev/null +++ b/dashboard/src/assets/icons/email-inbox-icon.tsx @@ -0,0 +1,127 @@ +import type { BoxProps } from '@mui/material/Box'; + +import { memo } from 'react'; + +import Box from '@mui/material/Box'; +import { useTheme } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +function EmailInboxIcon({ sx, ...other }: BoxProps) { + const theme = useTheme(); + + const PRIMARY_MAIN = theme.vars.palette.primary.main; + + const WARNING_LIGHT = theme.vars.palette.warning.light; + + const WARNING_DARK = theme.vars.palette.warning.dark; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default memo(EmailInboxIcon); diff --git a/dashboard/src/assets/icons/index.ts b/dashboard/src/assets/icons/index.ts new file mode 100644 index 00000000..a6cab8e3 --- /dev/null +++ b/dashboard/src/assets/icons/index.ts @@ -0,0 +1,4 @@ +export { default as SentIcon } from './sent-icon'; +export { default as PasswordIcon } from './password-icon'; +export { default as EmailInboxIcon } from './email-inbox-icon'; +export { default as NewPasswordIcon } from './new-password-icon'; diff --git a/dashboard/src/assets/icons/new-password-icon.tsx b/dashboard/src/assets/icons/new-password-icon.tsx new file mode 100644 index 00000000..08f08789 --- /dev/null +++ b/dashboard/src/assets/icons/new-password-icon.tsx @@ -0,0 +1,106 @@ +import type { BoxProps } from '@mui/material/Box'; + +import { memo } from 'react'; + +import Box from '@mui/material/Box'; +import { useTheme } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +function NewPasswordIcon({ sx, ...other }: BoxProps) { + const theme = useTheme(); + + const PRIMARY_MAIN = theme.vars.palette.primary.main; + + const WARNING_LIGHT = theme.vars.palette.warning.light; + + const WARNING_DARK = theme.vars.palette.warning.dark; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default memo(NewPasswordIcon); diff --git a/dashboard/src/assets/icons/password-icon.tsx b/dashboard/src/assets/icons/password-icon.tsx new file mode 100644 index 00000000..c76e908a --- /dev/null +++ b/dashboard/src/assets/icons/password-icon.tsx @@ -0,0 +1,102 @@ +import type { BoxProps } from '@mui/material/Box'; + +import { memo } from 'react'; + +import Box from '@mui/material/Box'; +import { useTheme } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +function PasswordIcon({ sx, ...other }: BoxProps) { + const theme = useTheme(); + + const PRIMARY_MAIN = theme.vars.palette.primary.main; + + const WARNING_LIGHT = theme.vars.palette.warning.light; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default memo(PasswordIcon); diff --git a/dashboard/src/assets/icons/sent-icon.tsx b/dashboard/src/assets/icons/sent-icon.tsx new file mode 100644 index 00000000..95380711 --- /dev/null +++ b/dashboard/src/assets/icons/sent-icon.tsx @@ -0,0 +1,67 @@ +import type { BoxProps } from '@mui/material/Box'; + +import { memo } from 'react'; + +import Box from '@mui/material/Box'; +import { useTheme } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +function SentIcon({ sx, ...other }: BoxProps) { + const theme = useTheme(); + + const PRIMARY_MAIN = theme.vars.palette.primary.main; + + const PRIMARY_DARK = theme.vars.palette.primary.dark; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default memo(SentIcon); diff --git a/dashboard/src/assets/illustrations/avatar-shape.tsx b/dashboard/src/assets/illustrations/avatar-shape.tsx new file mode 100644 index 00000000..93ca1220 --- /dev/null +++ b/dashboard/src/assets/illustrations/avatar-shape.tsx @@ -0,0 +1,30 @@ +import type { BoxProps } from '@mui/material/Box'; + +import { memo } from 'react'; + +import Box from '@mui/material/Box'; + +// ---------------------------------------------------------------------- + +function AvatarShape({ sx, ...other }: BoxProps) { + return ( + + + + ); +} + +export default memo(AvatarShape); diff --git a/dashboard/src/assets/illustrations/background-shape.tsx b/dashboard/src/assets/illustrations/background-shape.tsx new file mode 100644 index 00000000..5723d952 --- /dev/null +++ b/dashboard/src/assets/illustrations/background-shape.tsx @@ -0,0 +1,31 @@ +import { useId } from 'react'; + +import { useTheme } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +export function BackgroundShape() { + const theme = useTheme(); + + const gradientId = useId(); + + const PRIMARY_MAIN = theme.vars.palette.primary.main; + + return ( + <> + + + + + + + + + + ); +} diff --git a/dashboard/src/assets/illustrations/forbidden-illustration.tsx b/dashboard/src/assets/illustrations/forbidden-illustration.tsx new file mode 100644 index 00000000..89538bde --- /dev/null +++ b/dashboard/src/assets/illustrations/forbidden-illustration.tsx @@ -0,0 +1,86 @@ +import type { BoxProps } from '@mui/material/Box'; + +import { memo } from 'react'; + +import Box from '@mui/material/Box'; +import { useTheme } from '@mui/material/styles'; + +import { CONFIG } from 'src/config-global'; + +import { BackgroundShape } from './background-shape'; + +// ---------------------------------------------------------------------- + +type Props = BoxProps & { + hideBackground?: boolean; +}; + +function ForbiddenIllustration({ hideBackground, sx, ...other }: Props) { + const theme = useTheme(); + + const PRIMARY_LIGHT = theme.vars.palette.primary.light; + + const PRIMARY_MAIN = theme.vars.palette.primary.main; + + const PRIMARY_DARK = theme.vars.palette.primary.dark; + + const PRIMARY_DARKER = theme.vars.palette.primary.darker; + + return ( + + {!hideBackground && } + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default memo(ForbiddenIllustration); diff --git a/dashboard/src/assets/illustrations/index.ts b/dashboard/src/assets/illustrations/index.ts new file mode 100644 index 00000000..8cc6c16f --- /dev/null +++ b/dashboard/src/assets/illustrations/index.ts @@ -0,0 +1,4 @@ +export { default as AvatarShape } from './avatar-shape'; +export { default as UploadIllustration } from './upload-illustration'; +export { default as ForbiddenIllustration } from './forbidden-illustration'; +export { default as PageNotFoundIllustration } from './page-not-found-illustration'; diff --git a/dashboard/src/assets/illustrations/page-not-found-illustration.tsx b/dashboard/src/assets/illustrations/page-not-found-illustration.tsx new file mode 100644 index 00000000..ba114930 --- /dev/null +++ b/dashboard/src/assets/illustrations/page-not-found-illustration.tsx @@ -0,0 +1,72 @@ +import type { BoxProps } from '@mui/material/Box'; + +import { memo } from 'react'; + +import Box from '@mui/material/Box'; +import { useTheme } from '@mui/material/styles'; + +import { CONFIG } from 'src/config-global'; + +import { BackgroundShape } from './background-shape'; + +// ---------------------------------------------------------------------- + +type Props = BoxProps & { + hideBackground?: boolean; +}; + +function PageNotFoundIllustration({ hideBackground, sx, ...other }: Props) { + const theme = useTheme(); + + const PRIMARY_LIGHT = theme.vars.palette.primary.light; + + const PRIMARY_MAIN = theme.vars.palette.primary.main; + + const PRIMARY_DARK = theme.vars.palette.primary.dark; + + const PRIMARY_DARKER = theme.vars.palette.primary.darker; + + return ( + + {!hideBackground && } + + + + + + + + + + + + + + + + + + + + ); +} + +export default memo(PageNotFoundIllustration); diff --git a/dashboard/src/assets/illustrations/upload-illustration.tsx b/dashboard/src/assets/illustrations/upload-illustration.tsx new file mode 100644 index 00000000..26431904 --- /dev/null +++ b/dashboard/src/assets/illustrations/upload-illustration.tsx @@ -0,0 +1,483 @@ +import type { BoxProps } from '@mui/material/Box'; + +import { memo } from 'react'; + +import Box from '@mui/material/Box'; +import { useTheme } from '@mui/material/styles'; + +import { BackgroundShape } from './background-shape'; + +// ---------------------------------------------------------------------- + +type Props = BoxProps & { + hideBackground?: boolean; +}; + +function UploadIllustration({ hideBackground, sx, ...other }: Props) { + const theme = useTheme(); + + const PRIMARY_MAIN = theme.vars.palette.primary.main; + + const PRIMARY_DARK = theme.vars.palette.primary.dark; + + const PRIMARY_DARKER = theme.vars.palette.primary.darker; + + return ( + + {!hideBackground && } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +export default memo(UploadIllustration); diff --git a/dashboard/src/auth/context/auth-context.tsx b/dashboard/src/auth/context/auth-context.tsx new file mode 100644 index 00000000..abd1d526 --- /dev/null +++ b/dashboard/src/auth/context/auth-context.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { createContext } from 'react'; + +import type { AuthContextValue } from '../types'; + +// ---------------------------------------------------------------------- + +export const AuthContext = createContext(undefined); + +export const AuthConsumer = AuthContext.Consumer; diff --git a/dashboard/src/auth/context/auth0/auth-provider.tsx b/dashboard/src/auth/context/auth0/auth-provider.tsx new file mode 100644 index 00000000..bf323503 --- /dev/null +++ b/dashboard/src/auth/context/auth0/auth-provider.tsx @@ -0,0 +1,122 @@ +'use client'; + +import type { AppState } from '@auth0/auth0-react'; +import type { DecodedToken } from 'src/auth/types'; + +import { jwtDecode } from 'jwt-decode'; +import { useAuth0, Auth0Provider } from '@auth0/auth0-react'; +import { useMemo, useState, useEffect, useCallback } from 'react'; + +import { CONFIG } from 'src/config-global'; +import { client } from 'src/lib/swissknife'; + +import { AuthContext } from '../auth-context'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export function AuthProvider({ children }: Props) { + const { domain, clientId, callbackUrl, audience } = 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, loginWithRedirect, logout } = useAuth0(); + const { audience } = CONFIG.auth0; + + const [accessToken, setAccessToken] = useState(null); + const [permissions, setPermissions] = useState([]); + + const getAccessToken = useCallback(async () => { + try { + if (isAuthenticated) { + const token = await getAccessTokenSilently({ authorizationParams: { audience } }); + + setAccessToken(token); + setPermissions(jwtDecode(token).permissions || []); + + client.interceptors.request.use(async (request) => { + try { + const t = await getAccessTokenSilently({ authorizationParams: { audience } }); + request.headers.set('Authorization', `Bearer ${t}`); + } catch (e) { + console.error('Token expired or missing, redirecting to login'); + loginWithRedirect(); + } + + return request; + }); + } else { + setAccessToken(null); + setPermissions([]); + } + } catch (e) { + console.error('Failed to get token:', e); + + setAccessToken(null); + setPermissions([]); + + if (e.error === 'missing_refresh_token' || e.error === 'invalid_grant') { + loginWithRedirect(); + } else { + logout(); + } + } + }, [getAccessTokenSilently, isAuthenticated, audience, loginWithRedirect, logout]); + + 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, + permissions, + } + : null, + loading: status === 'loading', + authenticated: status === 'authenticated', + unauthenticated: status === 'unauthenticated', + }), + [accessToken, status, user, permissions] + ); + + return {children}; +} diff --git a/dashboard/src/auth/context/auth0/index.ts b/dashboard/src/auth/context/auth0/index.ts new file mode 100644 index 00000000..99c7072d --- /dev/null +++ b/dashboard/src/auth/context/auth0/index.ts @@ -0,0 +1 @@ +export * from './auth-provider'; diff --git a/dashboard/src/auth/context/jwt/action.ts b/dashboard/src/auth/context/jwt/action.ts new file mode 100644 index 00000000..ec90f79c --- /dev/null +++ b/dashboard/src/auth/context/jwt/action.ts @@ -0,0 +1,40 @@ +'use client'; + +import { endpointKeys } from 'src/actions/keys'; + +import { STORAGE_KEY } from './constant'; + +// ---------------------------------------------------------------------- + +export type SignUpParams = { + email: string; + password: string; + firstName: string; + lastName: string; +}; + +/** ************************************** + * Sign up + *************************************** */ +export const signUp = async ({ email, password, firstName, lastName }: SignUpParams): Promise => { + const res = await fetch(endpointKeys.auth.signUp, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password, firstName, lastName }), + }); + + const { accessToken } = await res.json(); + + if (!accessToken) { + throw new Error('Access token not found in response'); + } + + sessionStorage.setItem(STORAGE_KEY, accessToken); +}; + +/** ************************************** + * Sign out + *************************************** */ +export const signOut = async (): Promise => { + sessionStorage.removeItem(STORAGE_KEY); +}; diff --git a/dashboard/src/auth/context/jwt/auth-provider.tsx b/dashboard/src/auth/context/jwt/auth-provider.tsx new file mode 100644 index 00000000..8faa8a4f --- /dev/null +++ b/dashboard/src/auth/context/jwt/auth-provider.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { jwtDecode } from 'jwt-decode'; +import { useMemo, useEffect, useCallback } from 'react'; + +import { useSetState } from 'src/hooks/use-set-state'; + +import { STORAGE_KEY } from './constant'; +import { AuthContext } from '../auth-context'; +import { setSession, isValidToken } from './utils'; + +import type { AuthState, DecodedToken } from '../../types'; + +// ---------------------------------------------------------------------- + +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(STORAGE_KEY); + + if (accessToken && isValidToken(accessToken)) { + setSession(accessToken); + + const decodedToken: DecodedToken = jwtDecode(accessToken); + + setState({ + user: { + sub: decodedToken.sub, + displayName: decodedToken.sub, + permissions: decodedToken.permissions || [], + 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, + checkUserSession, + loading: status === 'loading', + authenticated: status === 'authenticated', + unauthenticated: status === 'unauthenticated', + }), + [checkUserSession, state.user, status] + ); + + return {children}; +} diff --git a/dashboard/src/auth/context/jwt/constant.ts b/dashboard/src/auth/context/jwt/constant.ts new file mode 100644 index 00000000..14d75cbc --- /dev/null +++ b/dashboard/src/auth/context/jwt/constant.ts @@ -0,0 +1 @@ +export const STORAGE_KEY = 'jwt_access_token'; diff --git a/dashboard/src/auth/context/jwt/index.ts b/dashboard/src/auth/context/jwt/index.ts new file mode 100644 index 00000000..0ec4a9b7 --- /dev/null +++ b/dashboard/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/dashboard/src/auth/context/jwt/utils.ts b/dashboard/src/auth/context/jwt/utils.ts new file mode 100644 index 00000000..8358832e --- /dev/null +++ b/dashboard/src/auth/context/jwt/utils.ts @@ -0,0 +1,55 @@ +import { jwtDecode } from 'jwt-decode'; + +import { paths } from 'src/routes/paths'; + +import { client } from 'src/lib/swissknife'; + +import { STORAGE_KEY } from './constant'; + +// ---------------------------------------------------------------------- + +export function isValidToken(accessToken: string) { + if (!accessToken) { + return false; + } + + try { + const decoded = jwtDecode(accessToken); + + if (!decoded || !decoded.exp) { + return false; + } + + const currentTime = Date.now() / 1000; + + return decoded.exp > currentTime; + } catch (error) { + console.error('Error during token validation:', error); + return false; + } +} + +// ---------------------------------------------------------------------- + +export async function setSession(accessToken: string) { + try { + sessionStorage.setItem(STORAGE_KEY, accessToken); + + client.interceptors.request.use((request, _) => { + request.headers.set('Authorization', `Bearer ${accessToken}`); + return request; + }); + + client.interceptors.error.use((error, response) => { + if (response.status === 401) { + sessionStorage.removeItem(STORAGE_KEY); + window.location.href = paths.auth.jwt.signIn; + } + + return Promise.reject(error); + }); + } catch (error) { + console.error('Error during set session:', error); + throw error; + } +} diff --git a/dashboard/src/auth/context/supabase/action.tsx b/dashboard/src/auth/context/supabase/action.tsx new file mode 100644 index 00000000..9a6abfb7 --- /dev/null +++ b/dashboard/src/auth/context/supabase/action.tsx @@ -0,0 +1,132 @@ +'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.wallet.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/dashboard/src/auth/context/supabase/auth-provider.tsx b/dashboard/src/auth/context/supabase/auth-provider.tsx new file mode 100644 index 00000000..f89003d9 --- /dev/null +++ b/dashboard/src/auth/context/supabase/auth-provider.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { useMemo, useEffect, useCallback } from 'react'; + +import { useSetState } from 'src/hooks/use-set-state'; + +import { supabase } from 'src/lib/supabase'; +import { client } from 'src/lib/swissknife'; + +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 }); + client.interceptors.request.use((request, _) => { + request.headers.set('Authorization', `Bearer ${accessToken}`); + return request; + }); + } 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, + 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/dashboard/src/auth/context/supabase/index.ts b/dashboard/src/auth/context/supabase/index.ts new file mode 100644 index 00000000..1597ff83 --- /dev/null +++ b/dashboard/src/auth/context/supabase/index.ts @@ -0,0 +1,3 @@ +export * from './action'; + +export * from './auth-provider'; diff --git a/dashboard/src/auth/guard/auth-guard.tsx b/dashboard/src/auth/guard/auth-guard.tsx new file mode 100644 index 00000000..33c8c14f --- /dev/null +++ b/dashboard/src/auth/guard/auth-guard.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; + +import { paths } from 'src/routes/paths'; +import { useRouter, usePathname, useSearchParams } from 'src/routes/hooks'; + +import { CONFIG } from 'src/config-global'; + +import { SplashScreen } from 'src/components/loading-screen'; + +import { useAuthContext } from '../hooks'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export function AuthGuard({ children }: Props) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const { authenticated, loading } = useAuthContext(); + const [isChecking, setIsChecking] = useState(true); + + const createQueryString = useCallback( + (name: string, value: string) => { + const params = new URLSearchParams(searchParams.toString()); + params.set(name, value); + return params.toString(); + }, + [searchParams] + ); + + const checkPermissions = useCallback(async (): Promise => { + if (loading) { + return; + } + + if (!authenticated) { + const { method } = CONFIG.auth; + const signInPath = { + jwt: paths.auth.jwt.signIn, + auth0: paths.auth.auth0.signIn, + supabase: paths.auth.supabase.signIn, + }[method]; + + const href = `${signInPath}?${createQueryString('returnTo', pathname)}`; + router.replace(href); + } else { + setIsChecking(false); + } + }, [authenticated, loading, router, pathname, createQueryString]); + + useEffect(() => { + checkPermissions(); + }, [checkPermissions]); + + if (isChecking || loading) { + return ; + } + + return <>{children}; +} diff --git a/dashboard/src/auth/guard/guest-guard.tsx b/dashboard/src/auth/guard/guest-guard.tsx new file mode 100644 index 00000000..d19ccdb0 --- /dev/null +++ b/dashboard/src/auth/guard/guest-guard.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { useState, useEffect } from 'react'; + +import { useRouter, useSearchParams } from 'src/routes/hooks'; + +import { CONFIG } from 'src/config-global'; + +import { SplashScreen } from 'src/components/loading-screen'; + +import { useAuthContext } from '../hooks'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +export function GuestGuard({ children }: Props) { + const router = useRouter(); + + const searchParams = useSearchParams(); + + const { loading, authenticated } = useAuthContext(); + + const [isChecking, setIsChecking] = useState(true); + + const returnTo = searchParams.get('returnTo') || CONFIG.auth.redirectPath; + + const checkPermissions = async (): Promise => { + if (loading) { + return; + } + + if (authenticated) { + router.replace(returnTo); + return; + } + + setIsChecking(false); + }; + + useEffect(() => { + checkPermissions(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [authenticated, loading]); + + if (isChecking) { + return ; + } + + return <>{children}; +} diff --git a/dashboard/src/auth/guard/index.ts b/dashboard/src/auth/guard/index.ts new file mode 100644 index 00000000..a85224fb --- /dev/null +++ b/dashboard/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/dashboard/src/auth/guard/role-based-guard.tsx b/dashboard/src/auth/guard/role-based-guard.tsx new file mode 100644 index 00000000..2df4627e --- /dev/null +++ b/dashboard/src/auth/guard/role-based-guard.tsx @@ -0,0 +1,55 @@ +'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 { CONFIG } from 'src/config-global'; +import { ForbiddenIllustration } from 'src/assets/illustrations'; + +import { varBounce, MotionContainer } from 'src/components/animate'; + +import { useAuthContext } from '../hooks'; +import { hasAllPermissions } from '../permissions'; + +// ---------------------------------------------------------------------- + +export type RoleBasedGuardProp = { + sx?: SxProps; + hasContent?: boolean; + permissions?: string[]; + children: React.ReactNode; +}; + +export function RoleBasedGuard({ sx, children, hasContent, permissions }: RoleBasedGuardProp) { + const { user } = useAuthContext(); + + if (CONFIG.auth.skip) { + return <> {children} ; + } + + if (typeof permissions !== 'undefined' && !hasAllPermissions(permissions, user?.permissions)) { + return hasContent ? ( + + + + Permission Denied + + + + + You do not have permission to access this page. + + + + + + + ) : null; + } + + return <> {children} ; +} diff --git a/dashboard/src/auth/hooks/index.ts b/dashboard/src/auth/hooks/index.ts new file mode 100644 index 00000000..2000f7f4 --- /dev/null +++ b/dashboard/src/auth/hooks/index.ts @@ -0,0 +1 @@ +export { useAuthContext } from './use-auth-context'; diff --git a/dashboard/src/auth/hooks/use-auth-context.ts b/dashboard/src/auth/hooks/use-auth-context.ts new file mode 100644 index 00000000..91433bbf --- /dev/null +++ b/dashboard/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/dashboard/src/auth/permissions.ts b/dashboard/src/auth/permissions.ts new file mode 100644 index 00000000..63bb2b44 --- /dev/null +++ b/dashboard/src/auth/permissions.ts @@ -0,0 +1,2 @@ +export const hasAllPermissions = (requiredPermissions: string[] = [], userPermissions: string[] = []) => + requiredPermissions.every((permission) => userPermissions?.includes(permission)); diff --git a/dashboard/src/auth/types.ts b/dashboard/src/auth/types.ts new file mode 100644 index 00000000..cbcfe8e7 --- /dev/null +++ b/dashboard/src/auth/types.ts @@ -0,0 +1,21 @@ +import type { JwtPayload } from 'jwt-decode'; +import type { Permission } from 'src/lib/swissknife'; + +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; +}; + +export type DecodedToken = JwtPayload & { + permissions: Permission[]; +}; diff --git a/dashboard/src/components/analytic/index.ts b/dashboard/src/components/analytic/index.ts new file mode 100644 index 00000000..5164f260 --- /dev/null +++ b/dashboard/src/components/analytic/index.ts @@ -0,0 +1 @@ +export * from './item-analytic'; diff --git a/dashboard/src/components/analytic/item-analytic.tsx b/dashboard/src/components/analytic/item-analytic.tsx new file mode 100644 index 00000000..a17b088a --- /dev/null +++ b/dashboard/src/components/analytic/item-analytic.tsx @@ -0,0 +1,58 @@ +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import { alpha } from '@mui/material/styles'; +import Typography from '@mui/material/Typography'; +import CircularProgress from '@mui/material/CircularProgress'; + +import { fShortenNumber } from 'src/utils/format-number'; + +import { Iconify } from 'src/components/iconify'; +import { SatsWithIcon } from 'src/components/bitcoin'; + +// ---------------------------------------------------------------------- + +type Props = { + icon: string; + title: string; + total: number; + percent: number; + price?: number; + color?: string; + countSuffix?: string; +}; + +export function ItemAnalytic({ title, total, icon, color, percent, price, countSuffix }: Props) { + return ( + + + + + + + alpha(theme.palette.grey[500], 0.16), + }} + /> + + + + {title} + + + {fShortenNumber(total)} {countSuffix} + + + {price && } + + + ); +} diff --git a/dashboard/src/components/animate/animate-avatar.tsx b/dashboard/src/components/animate/animate-avatar.tsx new file mode 100644 index 00000000..7bbd89a8 --- /dev/null +++ b/dashboard/src/components/animate/animate-avatar.tsx @@ -0,0 +1,84 @@ +import type { Transition } from 'framer-motion'; +import type { BoxProps } from '@mui/material/Box'; +import type { AvatarProps } from '@mui/material/Avatar'; + +import { m } from 'framer-motion'; + +import Box from '@mui/material/Box'; +import Avatar from '@mui/material/Avatar'; + +// ---------------------------------------------------------------------- + +export type AnimateAvatarProps = BoxProps & { + slotProps?: { + avatar?: AvatarProps; + animate?: { transition?: Transition }; + overlay?: { + color?: string; + border?: number; + spacing?: number; + }; + }; +}; + +export function AnimateAvatar({ sx, slotProps, children, width = 40, ...other }: AnimateAvatarProps) { + const borderWidth = slotProps?.overlay?.border ?? 2; + + const spacing = slotProps?.overlay?.spacing ?? 2; + + return ( + + + {children} + + + + + ); +} diff --git a/dashboard/src/components/animate/animate-border.tsx b/dashboard/src/components/animate/animate-border.tsx new file mode 100644 index 00000000..fa49ef34 --- /dev/null +++ b/dashboard/src/components/animate/animate-border.tsx @@ -0,0 +1,169 @@ +'use client'; + +import type { BoxProps } from '@mui/material/Box'; +import type { Easing, RepeatType } from 'framer-motion'; + +import { m } from 'framer-motion'; +import { useRef, useState, useEffect } from 'react'; + +import Box from '@mui/material/Box'; + +import { borderGradient } from 'src/theme/styles'; + +// ---------------------------------------------------------------------- + +/** + * Source: + * https://gradientborder.framer.website/ + */ + +export type AnimateBorderProps = BoxProps & { + animate?: { + outline?: string; + color?: string | string[]; + width?: string; // width `2px` | `2px 4px 0 0` (as padding) + angle?: number; // angle: min: 0, max: 360, step: 1 + loop?: boolean; + length?: number; // length: min: 1, max: 100, step: 1 + distance?: number; // distance: min: 1, max: 100, step: 1 + ease?: Easing; + delay?: number; + duration?: number; // duration: min: 1, max: 20, step: 1 + repeatType?: RepeatType; // repeatType: ["loop", "reverse", "mirror" + disable?: boolean; // disable animate + disableDoubleline?: boolean; // show 1 line + }; +}; + +export function AnimateBorder({ animate, sx }: AnimateBorderProps) { + const rootRef = useRef(null); + + const animateRef = useRef(null); + + const [aspectRatio, setAspectRatio] = useState(1); + + const [animateStyle, setAnimateStyle] = useState(null); + + const values = { + disable: animate?.disable, + delay: animate?.delay ?? 0, + loop: animate?.loop ?? true, + angle: animate?.angle ?? 315, + length: animate?.length ?? 40, + width: animate?.width ?? '2px', + color: animate?.color ?? '#000', + ease: animate?.ease ?? 'linear', + duration: animate?.duration ?? 8, + distance: animate?.distance ?? 20, + repeatType: animate?.repeatType ?? 'loop', + disableDoubleline: animate?.disableDoubleline, + outline: animate?.outline ?? `135deg, rgba(0,0,0,0.08), rgba(0,0,0,0.08)`, + }; + + useEffect(() => { + if (!values.disable) { + if (rootRef.current) { + const { width, height } = rootRef.current.getBoundingClientRect(); + + setAspectRatio(width / height); + } + + if (!values.disableDoubleline && animateRef.current) { + const style = getComputedStyle(animateRef.current); + + setAnimateStyle({ + paddingLeft: style.paddingLeft, + paddingRight: style.paddingRight, + paddingBottom: style.paddingBottom, + paddingTop: style.paddingTop, + borderTopLeftRadius: style.borderTopLeftRadius, + borderTopRightRadius: style.borderTopRightRadius, + borderBottomLeftRadius: style.borderBottomLeftRadius, + borderBottomRightRadius: style.borderBottomRightRadius, + }); + } + } + }, [values.disable, values.disableDoubleline]); + + const background = (color: string) => { + const degs = [-55, 35, 125, 215, 305]; + + const end = `transparent ${values.angle - (2 + values.length!)}deg, ${color} ${values.angle}deg, transparent ${values.angle + values.length}deg`; + + return [ + `conic-gradient(from ${degs[0]}deg at ${values.distance! / aspectRatio}% ${values.distance}% , ${end})`, + `conic-gradient(from ${degs[1]}deg at ${100 - values.distance! / aspectRatio}% ${values.distance}% , ${end})`, + `conic-gradient(from ${degs[2]}deg at ${100 - values.distance / aspectRatio}% ${100 - values.distance}% , ${end})`, + `conic-gradient(from ${degs[3]}deg at ${values.distance / aspectRatio}% ${100 - values.distance}% , ${end})`, + `conic-gradient(from ${degs[4]}deg at ${values.distance / aspectRatio}% ${values.distance}% , ${end})`, + ]; + }; + + const transition = { + ease: values.ease, + delay: values.delay, + duration: values.duration, + repeatType: values.repeatType, + repeat: values.loop ? Infinity : 1, + times: + aspectRatio > 1 + ? [0, 0.25 + 0.25 / aspectRatio, 0.5, 0.75 + 0.25 / aspectRatio, 1] + : [0, aspectRatio * 0.25, 0.5, 0.5 + aspectRatio * 0.25, 1], + }; + + return ( + + + + {!values.disable && !values.disableDoubleline && ( + + )} + + ); +} diff --git a/dashboard/src/components/animate/animate-count-up.tsx b/dashboard/src/components/animate/animate-count-up.tsx new file mode 100644 index 00000000..c9e95a92 --- /dev/null +++ b/dashboard/src/components/animate/animate-count-up.tsx @@ -0,0 +1,62 @@ +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, + unit = '', + toFixed = 0, + duration = 2, + once = true, + amount = 0.5, + component = 'p', + ...other +}: AnimateCountUpProps) { + const ref = useRef(null); + + const inView = useInView(ref, { once, amount }); + + const count = useMotionValue(from); + + const rounded = useTransform(count, (latest) => latest.toFixed(toFixed)); + + useEffect(() => { + if (inView) { + animate(count, to, { duration }); + } + }, [count, duration, inView, to]); + + return ( + + {rounded} + {unit} + + ); +} diff --git a/dashboard/src/components/animate/animate-logo.tsx b/dashboard/src/components/animate/animate-logo.tsx new file mode 100644 index 00000000..b08617a5 --- /dev/null +++ b/dashboard/src/components/animate/animate-logo.tsx @@ -0,0 +1,123 @@ +import type { BoxProps } from '@mui/material/Box'; + +import { m } from 'framer-motion'; + +import Box from '@mui/material/Box'; + +import { varAlpha } from 'src/theme/styles'; + +import { Logo } from '../logo'; + +// ---------------------------------------------------------------------- + +export type AnimateLogoProps = BoxProps & { + logo?: React.ReactNode; +}; + +export function AnimateLogo1({ logo, sx, ...other }: AnimateLogoProps) { + return ( + + + {logo ?? } + + + `solid 3px ${varAlpha(theme.vars.palette.primary.darkChannel, 0.24)}`, + }} + /> + + `solid 8px ${varAlpha(theme.vars.palette.primary.darkChannel, 0.24)}`, + }} + /> + + ); +} + +// ---------------------------------------------------------------------- + +export function AnimateLogo2({ logo, sx, ...other }: AnimateLogoProps) { + return ( + + {logo ?? } + + + theme.transitions.create(['opacity'], { + easing: theme.transitions.easing.easeInOut, + duration: theme.transitions.duration.shorter, + }), + background: (theme) => + `linear-gradient(135deg, ${varAlpha(theme.vars.palette.primary.mainChannel, 0)} 50%, ${theme.vars.palette.primary.main} 100%)`, + }} + /> + + ); +} diff --git a/dashboard/src/components/animate/animate-text.tsx b/dashboard/src/components/animate/animate-text.tsx new file mode 100644 index 00000000..d352e06d --- /dev/null +++ b/dashboard/src/components/animate/animate-text.tsx @@ -0,0 +1,157 @@ +import type { TypographyProps } from '@mui/material/Typography'; +import type { Variants, UseInViewOptions } from 'framer-motion'; + +import { useRef, useEffect } from 'react'; +import { m, useInView, useAnimation } from 'framer-motion'; + +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; + +import { varFade, varContainer } from './variants'; + +// ---------------------------------------------------------------------- + +export const animateTextClasses = { + root: 'animate-text-root', + lines: 'animate-text-lines', + line: 'animate-text-line', + word: 'animate-text-word', + char: 'animate-text-char', + space: 'animate-text-space', + srOnly: 'sr-only', + dataIndex: '[data-columns="3"]', +}; + +export type AnimateTextProps = TypographyProps & { + variants?: Variants; + repeatDelay?: number; + text: string | string[]; + once?: UseInViewOptions['once']; + amount?: UseInViewOptions['amount']; +}; + +export function AnimateText({ + sx, + text, + variants, + once = true, + amount = 1 / 3, + component = 'p', + repeatDelay = 500, // 1000 = 1s + ...other +}: AnimateTextProps) { + const ref = useRef(null); + + const controls = useAnimation(); + + const textArray = Array.isArray(text) ? text : [text]; + + const isInView = useInView(ref, { once, amount }); + + useEffect(() => { + let timeout: NodeJS.Timeout; + + const show = () => { + if (repeatDelay) { + timeout = setTimeout(async () => { + await controls.start('initial'); + controls.start('animate'); + }, repeatDelay); + } else { + controls.start('animate'); + } + }; + + if (isInView) { + show(); + } else { + controls.start('initial'); + } + + return () => clearTimeout(timeout); + }, [controls, isInView, repeatDelay]); + + 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 && ( + +   + + )} + + ); + })} + + ))} + + + ); +} diff --git a/dashboard/src/components/animate/back-to-top/back-to-top.tsx b/dashboard/src/components/animate/back-to-top/back-to-top.tsx new file mode 100644 index 00000000..4b3db294 --- /dev/null +++ b/dashboard/src/components/animate/back-to-top/back-to-top.tsx @@ -0,0 +1,51 @@ +import type { FabProps } from '@mui/material/Fab'; + +import { useState } from 'react'; +import { useScroll, useMotionValueEvent } from 'framer-motion'; + +import Fab from '@mui/material/Fab'; + +import { Iconify } from 'src/components/iconify'; + +// ---------------------------------------------------------------------- + +export type BackToTopProps = FabProps & { + value?: number; +}; + +export function BackToTop({ value = 90, sx, ...other }: BackToTopProps) { + const { scrollYProgress } = useScroll(); + + const [show, setShow] = useState(false); + + const backToTop = () => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + useMotionValueEvent(scrollYProgress, 'change', (latest) => { + const isEnd = Math.floor(latest * 100) > value; // unit is % + setShow(isEnd); + }); + + return ( + theme.zIndex.speedDial, + transition: (theme) => theme.transitions.create(['transform']), + ...(show && { transform: 'scale(1)' }), + ...sx, + }} + {...other} + > + + + ); +} diff --git a/dashboard/src/components/animate/back-to-top/index.ts b/dashboard/src/components/animate/back-to-top/index.ts new file mode 100644 index 00000000..fa6b1c0f --- /dev/null +++ b/dashboard/src/components/animate/back-to-top/index.ts @@ -0,0 +1 @@ +export * from './back-to-top'; diff --git a/dashboard/src/components/animate/features.ts b/dashboard/src/components/animate/features.ts new file mode 100644 index 00000000..9e51e8f3 --- /dev/null +++ b/dashboard/src/components/animate/features.ts @@ -0,0 +1,3 @@ +import { domMax } from 'framer-motion'; + +export default domMax; diff --git a/dashboard/src/components/animate/index.ts b/dashboard/src/components/animate/index.ts new file mode 100644 index 00000000..3f1f15d7 --- /dev/null +++ b/dashboard/src/components/animate/index.ts @@ -0,0 +1,19 @@ +export * from './variants'; + +export * from './back-to-top'; + +export * from './animate-text'; + +export * from './animate-logo'; + +export * from './animate-avatar'; + +export * from './animate-border'; + +export * from './motion-viewport'; + +export * from './scroll-progress'; + +export * from './animate-count-up'; + +export * from './motion-container'; diff --git a/dashboard/src/components/animate/motion-container.tsx b/dashboard/src/components/animate/motion-container.tsx new file mode 100644 index 00000000..907315db --- /dev/null +++ b/dashboard/src/components/animate/motion-container.tsx @@ -0,0 +1,31 @@ +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(({ animate, action = false, children, ...other }, ref) => { + const commonProps = { + ref, + component: m.div, + variants: varContainer(), + initial: action ? false : 'initial', + animate: action ? (animate ? 'animate' : 'exit') : 'animate', + exit: action ? undefined : 'exit', + ...other, + }; + + return {children}; +}); diff --git a/dashboard/src/components/animate/motion-lazy.tsx b/dashboard/src/components/animate/motion-lazy.tsx new file mode 100644 index 00000000..151af03f --- /dev/null +++ b/dashboard/src/components/animate/motion-lazy.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { LazyMotion } from 'framer-motion'; + +// ---------------------------------------------------------------------- + +type Props = { + children: React.ReactNode; +}; + +const loadFeaturesAsync = async () => import('./features').then((res) => res.default); + +export function MotionLazy({ children }: Props) { + return ( + + {children} + + ); +} diff --git a/dashboard/src/components/animate/motion-viewport.tsx b/dashboard/src/components/animate/motion-viewport.tsx new file mode 100644 index 00000000..c6505503 --- /dev/null +++ b/dashboard/src/components/animate/motion-viewport.tsx @@ -0,0 +1,40 @@ +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 { useResponsive } from 'src/hooks/use-responsive'; + +import { varContainer } from './variants'; + +// ---------------------------------------------------------------------- + +export type MotionViewportProps = BoxProps & + MotionProps & { + disableAnimate?: boolean; + }; + +export const MotionViewport = forwardRef(({ children, disableAnimate = true, ...other }, ref) => { + const smDown = useResponsive('down', 'sm'); + + const disabled = smDown && disableAnimate; + + const props = disabled + ? {} + : { + component: m.div, + initial: 'initial', + whileInView: 'animate', + variants: varContainer(), + viewport: { once: true, amount: 0.3 }, + }; + + return ( + + {children} + + ); +}); diff --git a/dashboard/src/components/animate/scroll-progress/index.ts b/dashboard/src/components/animate/scroll-progress/index.ts new file mode 100644 index 00000000..1521b571 --- /dev/null +++ b/dashboard/src/components/animate/scroll-progress/index.ts @@ -0,0 +1,3 @@ +export * from './scroll-progress'; + +export * from './use-scroll-progress'; diff --git a/dashboard/src/components/animate/scroll-progress/scroll-progress.tsx b/dashboard/src/components/animate/scroll-progress/scroll-progress.tsx new file mode 100644 index 00000000..7c2bf98a --- /dev/null +++ b/dashboard/src/components/animate/scroll-progress/scroll-progress.tsx @@ -0,0 +1,89 @@ +import type { MotionValue } from 'framer-motion'; +import type { BoxProps } from '@mui/material/Box'; + +import { m, useSpring } from 'framer-motion'; + +import Box from '@mui/material/Box'; + +// ---------------------------------------------------------------------- + +export interface ScrollProgressProps extends BoxProps { + size?: number; + thickness?: number; + progress: MotionValue; + variant: 'linear' | 'circular'; + color?: 'inherit' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'; +} + +export function ScrollProgress({ size, variant, progress, thickness = 3.6, color = 'primary', sx, ...other }: ScrollProgressProps) { + const scaleX = useSpring(progress, { stiffness: 100, damping: 30, restDelta: 0.001 }); + + const progressSize = variant === 'circular' ? (size ?? 64) : (size ?? 3); + + const renderCircular = ( + theme.vars.palette.text.primary, + ...(color !== 'inherit' && { + color: (theme) => theme.vars.palette[color].main, + }), + circle: { + fill: 'none', + strokeDashoffset: 0, + strokeWidth: thickness, + stroke: 'currentColor', + }, + ...sx, + }} + {...other} + > + + + + ); + + const renderLinear = ( + `linear-gradient(135deg, ${theme.vars.palette[color].light}, ${theme.vars.palette[color].main})`, + }), + ...sx, + }} + style={{ scaleX }} + {...other} + /> + ); + + return {variant === 'circular' ? renderCircular : renderLinear}; +} diff --git a/dashboard/src/components/animate/scroll-progress/use-scroll-progress.ts b/dashboard/src/components/animate/scroll-progress/use-scroll-progress.ts new file mode 100644 index 00000000..0282a503 --- /dev/null +++ b/dashboard/src/components/animate/scroll-progress/use-scroll-progress.ts @@ -0,0 +1,28 @@ +'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/dashboard/src/components/animate/types.ts b/dashboard/src/components/animate/types.ts new file mode 100644 index 00000000..812cdb9e --- /dev/null +++ b/dashboard/src/components/animate/types.ts @@ -0,0 +1,32 @@ +import type { Easing } from 'framer-motion'; + +// ---------------------------------------------------------------------- + +export type VariantsType = { + distance?: number; + durationIn?: number; + durationOut?: number; + easeIn?: Easing; + easeOut?: Easing; +}; + +export type TranHoverType = { + duration?: number; + ease?: Easing; +}; + +export type TranEnterType = { + durationIn?: number; + easeIn?: Easing; +}; + +export type TranExitType = { + durationOut?: number; + easeOut?: Easing; +}; + +export type BackgroundType = { + colors?: string[]; + duration?: number; + ease?: Easing; +}; diff --git a/dashboard/src/components/animate/variants/actions.ts b/dashboard/src/components/animate/variants/actions.ts new file mode 100644 index 00000000..e9100447 --- /dev/null +++ b/dashboard/src/components/animate/variants/actions.ts @@ -0,0 +1,6 @@ +// ---------------------------------------------------------------------- + +export const varHover = (hover = 1.09, tap = 0.97) => ({ + hover: { scale: hover }, + tap: { scale: tap }, +}); diff --git a/dashboard/src/components/animate/variants/background.ts b/dashboard/src/components/animate/variants/background.ts new file mode 100644 index 00000000..bfd1fce1 --- /dev/null +++ b/dashboard/src/components/animate/variants/background.ts @@ -0,0 +1,100 @@ +import type { BackgroundType } from '../types'; + +// ---------------------------------------------------------------------- + +export const varBgColor = (props?: BackgroundType) => { + const colors = props?.colors || ['#19dcea', '#b22cff']; + const duration = props?.duration || 5; + const ease = props?.ease || 'linear'; + + return { animate: { background: colors, transition: { duration, ease } } }; +}; + +// ---------------------------------------------------------------------- + +export const varBgKenburns = (props?: BackgroundType) => { + const duration = props?.duration || 5; + const ease = props?.ease || 'easeOut'; + + return { + top: { + animate: { + scale: [1, 1.25], + y: [0, -15], + transformOrigin: ['50% 16%', '50% top'], + transition: { duration, ease }, + }, + }, + bottom: { + animate: { + scale: [1, 1.25], + y: [0, 15], + transformOrigin: ['50% 84%', '50% bottom'], + transition: { duration, ease }, + }, + }, + left: { + animate: { + scale: [1, 1.25], + x: [0, 20], + y: [0, 15], + transformOrigin: ['16% 50%', '0% left'], + transition: { duration, ease }, + }, + }, + right: { + animate: { + scale: [1, 1.25], + x: [0, -20], + y: [0, -15], + transformOrigin: ['84% 50%', '0% right'], + transition: { duration, ease }, + }, + }, + }; +}; + +// ---------------------------------------------------------------------- + +export const varBgPan = (props?: BackgroundType) => { + const colors = props?.colors || ['#ee7752', '#e73c7e', '#23a6d5', '#23d5ab']; + const duration = props?.duration || 5; + const ease = props?.ease || 'linear'; + + const gradient = (deg: number) => `linear-gradient(${deg}deg, ${colors})`; + + return { + top: { + animate: { + backgroundImage: [gradient(0), gradient(0)], + backgroundPosition: ['center 99%', 'center 1%'], + backgroundSize: ['100% 600%', '100% 600%'], + transition: { duration, ease }, + }, + }, + right: { + animate: { + backgroundPosition: ['1% center', '99% center'], + backgroundImage: [gradient(270), gradient(270)], + backgroundSize: ['600% 100%', '600% 100%'], + transition: { duration, ease }, + }, + }, + bottom: { + animate: { + backgroundImage: [gradient(0), gradient(0)], + backgroundPosition: ['center 1%', 'center 99%'], + backgroundSize: ['100% 600%', '100% 600%'], + transition: { duration, ease }, + }, + }, + left: { + animate: { + backgroundPosition: ['99% center', '1% center'], + backgroundImage: [gradient(270), gradient(270)], + backgroundSize: ['600% 100%', '600% 100%'], + transition: { duration, ease }, + }, + }, + }; +}; diff --git a/dashboard/src/components/animate/variants/bounce.ts b/dashboard/src/components/animate/variants/bounce.ts new file mode 100644 index 00000000..782ec43f --- /dev/null +++ b/dashboard/src/components/animate/variants/bounce.ts @@ -0,0 +1,92 @@ +import { varTranExit, varTranEnter } from './transition'; + +import type { VariantsType } from '../types'; + +// ---------------------------------------------------------------------- + +export const varBounce = (props?: VariantsType) => { + const durationIn = props?.durationIn; + const durationOut = props?.durationOut; + const easeIn = props?.easeIn; + const easeOut = props?.easeOut; + + return { + // IN + in: { + initial: {}, + animate: { + scale: [0.3, 1.1, 0.9, 1.03, 0.97, 1], + opacity: [0, 1, 1, 1, 1, 1], + transition: varTranEnter({ durationIn, easeIn }), + }, + exit: { scale: [0.9, 1.1, 0.3], opacity: [1, 1, 0] }, + }, + inUp: { + initial: {}, + animate: { + y: [720, -24, 12, -4, 0], + scaleY: [4, 0.9, 0.95, 0.985, 1], + opacity: [0, 1, 1, 1, 1], + transition: { ...varTranEnter({ durationIn, easeIn }) }, + }, + exit: { + y: [12, -24, 720], + scaleY: [0.985, 0.9, 3], + opacity: [1, 1, 0], + transition: varTranExit({ durationOut, easeOut }), + }, + }, + inDown: { + initial: {}, + animate: { + y: [-720, 24, -12, 4, 0], + scaleY: [4, 0.9, 0.95, 0.985, 1], + opacity: [0, 1, 1, 1, 1], + transition: varTranEnter({ durationIn, easeIn }), + }, + exit: { + y: [-12, 24, -720], + scaleY: [0.985, 0.9, 3], + opacity: [1, 1, 0], + transition: varTranExit({ durationOut, easeOut }), + }, + }, + inLeft: { + initial: {}, + animate: { + x: [-720, 24, -12, 4, 0], + scaleX: [3, 1, 0.98, 0.995, 1], + opacity: [0, 1, 1, 1, 1], + transition: varTranEnter({ durationIn, easeIn }), + }, + exit: { + x: [0, 24, -720], + scaleX: [1, 0.9, 2], + opacity: [1, 1, 0], + transition: varTranExit({ durationOut, easeOut }), + }, + }, + inRight: { + initial: {}, + animate: { + x: [720, -24, 12, -4, 0], + scaleX: [3, 1, 0.98, 0.995, 1], + opacity: [0, 1, 1, 1, 1], + transition: varTranEnter({ durationIn, easeIn }), + }, + exit: { + x: [0, -24, 720], + scaleX: [1, 0.9, 2], + opacity: [1, 1, 0], + transition: varTranExit({ durationOut, easeOut }), + }, + }, + + // OUT + out: { animate: { scale: [0.9, 1.1, 0.3], opacity: [1, 1, 0] } }, + outUp: { animate: { y: [-12, 24, -720], scaleY: [0.985, 0.9, 3], opacity: [1, 1, 0] } }, + outDown: { animate: { y: [12, -24, 720], scaleY: [0.985, 0.9, 3], opacity: [1, 1, 0] } }, + outLeft: { animate: { x: [0, 24, -720], scaleX: [1, 0.9, 2], opacity: [1, 1, 0] } }, + outRight: { animate: { x: [0, -24, 720], scaleX: [1, 0.9, 2], opacity: [1, 1, 0] } }, + }; +}; diff --git a/dashboard/src/components/animate/variants/container.ts b/dashboard/src/components/animate/variants/container.ts new file mode 100644 index 00000000..715ee667 --- /dev/null +++ b/dashboard/src/components/animate/variants/container.ts @@ -0,0 +1,18 @@ +// ---------------------------------------------------------------------- + +export type Props = { + staggerIn?: number; + delayIn?: number; + staggerOut?: number; +}; + +export const varContainer = (props?: Props) => { + const staggerIn = props?.staggerIn || 0.05; + const delayIn = props?.staggerIn || 0.05; + const staggerOut = props?.staggerIn || 0.05; + + return { + animate: { transition: { staggerChildren: staggerIn, delayChildren: delayIn } }, + exit: { transition: { staggerChildren: staggerOut, staggerDirection: -1 } }, + }; +}; diff --git a/dashboard/src/components/animate/variants/fade.ts b/dashboard/src/components/animate/variants/fade.ts new file mode 100644 index 00000000..8fb58741 --- /dev/null +++ b/dashboard/src/components/animate/variants/fade.ts @@ -0,0 +1,69 @@ +import { varTranExit, varTranEnter } from './transition'; + +import type { VariantsType } from '../types'; + +// ---------------------------------------------------------------------- + +export const varFade = (props?: VariantsType) => { + const distance = props?.distance || 120; + const durationIn = props?.durationIn; + const durationOut = props?.durationOut; + const easeIn = props?.easeIn; + const easeOut = props?.easeOut; + + return { + // IN + in: { + initial: { opacity: 0 }, + animate: { opacity: 1, transition: varTranEnter }, + exit: { opacity: 0, transition: varTranExit }, + }, + inUp: { + initial: { y: distance, opacity: 0 }, + animate: { y: 0, opacity: 1, transition: varTranEnter({ durationIn, easeIn }) }, + exit: { y: distance, opacity: 0, transition: varTranExit({ durationOut, easeOut }) }, + }, + inDown: { + initial: { y: -distance, opacity: 0 }, + animate: { y: 0, opacity: 1, transition: varTranEnter({ durationIn, easeIn }) }, + exit: { y: -distance, opacity: 0, transition: varTranExit({ durationOut, easeOut }) }, + }, + inLeft: { + initial: { x: -distance, opacity: 0 }, + animate: { x: 0, opacity: 1, transition: varTranEnter({ durationIn, easeIn }) }, + exit: { x: -distance, opacity: 0, transition: varTranExit({ durationOut, easeOut }) }, + }, + inRight: { + initial: { x: distance, opacity: 0 }, + animate: { x: 0, opacity: 1, transition: varTranEnter({ durationIn, easeIn }) }, + exit: { x: distance, opacity: 0, transition: varTranExit({ durationOut, easeOut }) }, + }, + + // OUT + out: { + initial: { opacity: 1 }, + animate: { opacity: 0, transition: varTranEnter({ durationIn, easeIn }) }, + exit: { opacity: 1, transition: varTranExit({ durationOut, easeOut }) }, + }, + outUp: { + initial: { y: 0, opacity: 1 }, + animate: { y: -distance, opacity: 0, transition: varTranEnter({ durationIn, easeIn }) }, + exit: { y: 0, opacity: 1, transition: varTranExit({ durationOut, easeOut }) }, + }, + outDown: { + initial: { y: 0, opacity: 1 }, + animate: { y: distance, opacity: 0, transition: varTranEnter({ durationIn, easeIn }) }, + exit: { y: 0, opacity: 1, transition: varTranExit({ durationOut, easeOut }) }, + }, + outLeft: { + initial: { x: 0, opacity: 1 }, + animate: { x: -distance, opacity: 0, transition: varTranEnter({ durationIn, easeIn }) }, + exit: { x: 0, opacity: 1, transition: varTranExit({ durationOut, easeOut }) }, + }, + outRight: { + initial: { x: 0, opacity: 1 }, + animate: { x: distance, opacity: 0, transition: varTranEnter({ durationIn, easeIn }) }, + exit: { x: 0, opacity: 1, transition: varTranExit({ durationOut, easeOut }) }, + }, + }; +}; diff --git a/dashboard/src/components/animate/variants/flip.ts b/dashboard/src/components/animate/variants/flip.ts new file mode 100644 index 00000000..c8736bcb --- /dev/null +++ b/dashboard/src/components/animate/variants/flip.ts @@ -0,0 +1,36 @@ +import { varTranExit, varTranEnter } from './transition'; + +import type { VariantsType } from '../types'; + +// ---------------------------------------------------------------------- + +export const varFlip = (props?: VariantsType) => { + const durationIn = props?.durationIn; + const durationOut = props?.durationOut; + const easeIn = props?.easeIn; + const easeOut = props?.easeOut; + + return { + // IN + inX: { + initial: { rotateX: -180, opacity: 0 }, + animate: { rotateX: 0, opacity: 1, transition: varTranEnter({ durationIn, easeIn }) }, + exit: { rotateX: -180, opacity: 0, transition: varTranExit({ durationOut, easeOut }) }, + }, + inY: { + initial: { rotateY: -180, opacity: 0 }, + animate: { rotateY: 0, opacity: 1, transition: varTranEnter({ durationIn, easeIn }) }, + exit: { rotateY: -180, opacity: 0, transition: varTranExit({ durationOut, easeOut }) }, + }, + + // OUT + outX: { + initial: { rotateX: 0, opacity: 1 }, + animate: { rotateX: 70, opacity: 0, transition: varTranExit({ durationOut, easeOut }) }, + }, + outY: { + initial: { rotateY: 0, opacity: 1 }, + animate: { rotateY: 70, opacity: 0, transition: varTranExit({ durationOut, easeOut }) }, + }, + }; +}; diff --git a/dashboard/src/components/animate/variants/index.ts b/dashboard/src/components/animate/variants/index.ts new file mode 100644 index 00000000..a7d86d04 --- /dev/null +++ b/dashboard/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/dashboard/src/components/animate/variants/path.ts b/dashboard/src/components/animate/variants/path.ts new file mode 100644 index 00000000..a17dd66c --- /dev/null +++ b/dashboard/src/components/animate/variants/path.ts @@ -0,0 +1,7 @@ +// ---------------------------------------------------------------------- + +export const TRANSITION = { duration: 2, ease: [0.43, 0.13, 0.23, 0.96] }; + +export const varPath = { + animate: { fillOpacity: [0, 0, 1], pathLength: [1, 0.4, 0], transition: TRANSITION }, +}; diff --git a/dashboard/src/components/animate/variants/rotate.ts b/dashboard/src/components/animate/variants/rotate.ts new file mode 100644 index 00000000..3d9f225c --- /dev/null +++ b/dashboard/src/components/animate/variants/rotate.ts @@ -0,0 +1,27 @@ +import { varTranExit, varTranEnter } from './transition'; + +import type { VariantsType } from '../types'; + +// ---------------------------------------------------------------------- + +export const varRotate = (props?: VariantsType) => { + const durationIn = props?.durationIn; + const durationOut = props?.durationOut; + const easeIn = props?.easeIn; + const easeOut = props?.easeOut; + + return { + // IN + in: { + initial: { opacity: 0, rotate: -360 }, + animate: { opacity: 1, rotate: 0, transition: varTranEnter({ durationIn, easeIn }) }, + exit: { opacity: 0, rotate: -360, transition: varTranExit({ durationOut, easeOut }) }, + }, + + // OUT + out: { + initial: { opacity: 1, rotate: 0 }, + animate: { opacity: 0, rotate: -360, transition: varTranExit({ durationOut, easeOut }) }, + }, + }; +}; diff --git a/dashboard/src/components/animate/variants/scale.ts b/dashboard/src/components/animate/variants/scale.ts new file mode 100644 index 00000000..f6c46395 --- /dev/null +++ b/dashboard/src/components/animate/variants/scale.ts @@ -0,0 +1,45 @@ +import { varTranExit, varTranEnter } from './transition'; + +import type { VariantsType } from '../types'; + +// ---------------------------------------------------------------------- + +export const varScale = (props?: VariantsType) => { + const durationIn = props?.durationIn; + const durationOut = props?.durationOut; + const easeIn = props?.easeIn; + const easeOut = props?.easeOut; + + return { + // IN + in: { + initial: { scale: 0, opacity: 0 }, + animate: { scale: 1, opacity: 1, transition: varTranEnter({ durationIn, easeIn }) }, + exit: { scale: 0, opacity: 0, transition: varTranExit({ durationOut, easeOut }) }, + }, + inX: { + initial: { scaleX: 0, opacity: 0 }, + animate: { scaleX: 1, opacity: 1, transition: varTranEnter({ durationIn, easeIn }) }, + exit: { scaleX: 0, opacity: 0, transition: varTranExit({ durationOut, easeOut }) }, + }, + inY: { + initial: { scaleY: 0, opacity: 0 }, + animate: { scaleY: 1, opacity: 1, transition: varTranEnter({ durationIn, easeIn }) }, + exit: { scaleY: 0, opacity: 0, transition: varTranExit({ durationOut, easeOut }) }, + }, + + // OUT + out: { + initial: { scale: 1, opacity: 1 }, + animate: { scale: 0, opacity: 0, transition: varTranEnter({ durationIn, easeIn }) }, + }, + outX: { + initial: { scaleX: 1, opacity: 1 }, + animate: { scaleX: 0, opacity: 0, transition: varTranEnter({ durationIn, easeIn }) }, + }, + outY: { + initial: { scaleY: 1, opacity: 1 }, + animate: { scaleY: 0, opacity: 0, transition: varTranEnter({ durationIn, easeIn }) }, + }, + }; +}; diff --git a/dashboard/src/components/animate/variants/slide.ts b/dashboard/src/components/animate/variants/slide.ts new file mode 100644 index 00000000..ffbee883 --- /dev/null +++ b/dashboard/src/components/animate/variants/slide.ts @@ -0,0 +1,59 @@ +import { varTranExit, varTranEnter } from './transition'; + +import type { VariantsType } from '../types'; + +// ---------------------------------------------------------------------- + +export const varSlide = (props?: VariantsType) => { + const distance = props?.distance || 160; + const durationIn = props?.durationIn; + const durationOut = props?.durationOut; + const easeIn = props?.easeIn; + const easeOut = props?.easeOut; + + return { + // IN + inUp: { + initial: { y: distance }, + animate: { y: 0, transition: varTranEnter({ durationIn, easeIn }) }, + exit: { y: distance, transition: varTranExit({ durationOut, easeOut }) }, + }, + inDown: { + initial: { y: -distance }, + animate: { y: 0, transition: varTranEnter({ durationIn, easeIn }) }, + exit: { y: -distance, transition: varTranExit({ durationOut, easeOut }) }, + }, + inLeft: { + initial: { x: -distance }, + animate: { x: 0, transition: varTranEnter({ durationIn, easeIn }) }, + exit: { x: -distance, transition: varTranExit({ durationOut, easeOut }) }, + }, + inRight: { + initial: { x: distance }, + animate: { x: 0, transition: varTranEnter({ durationIn, easeIn }) }, + exit: { x: distance, transition: varTranExit({ durationOut, easeOut }) }, + }, + + // OUT + outUp: { + initial: { y: 0 }, + animate: { y: -distance, transition: varTranEnter({ durationIn, easeIn }) }, + exit: { y: 0, transition: varTranExit({ durationOut, easeOut }) }, + }, + outDown: { + initial: { y: 0 }, + animate: { y: distance, transition: varTranEnter({ durationIn, easeIn }) }, + exit: { y: 0, transition: varTranExit({ durationOut, easeOut }) }, + }, + outLeft: { + initial: { x: 0 }, + animate: { x: -distance, transition: varTranEnter({ durationIn, easeIn }) }, + exit: { x: 0, transition: varTranExit({ durationOut, easeOut }) }, + }, + outRight: { + initial: { x: 0 }, + animate: { x: distance, transition: varTranEnter({ durationIn, easeIn }) }, + exit: { x: 0, transition: varTranExit({ durationOut, easeOut }) }, + }, + }; +}; diff --git a/dashboard/src/components/animate/variants/transition.ts b/dashboard/src/components/animate/variants/transition.ts new file mode 100644 index 00000000..623f046d --- /dev/null +++ b/dashboard/src/components/animate/variants/transition.ts @@ -0,0 +1,24 @@ +import type { TranExitType, TranHoverType, TranEnterType } from '../types'; + +// ---------------------------------------------------------------------- + +export const varTranHover = (props?: TranHoverType) => { + const duration = props?.duration || 0.32; + const ease = props?.ease || [0.43, 0.13, 0.23, 0.96]; + + return { duration, ease }; +}; + +export const varTranEnter = (props?: TranEnterType) => { + const duration = props?.durationIn || 0.64; + const ease = props?.easeIn || [0.43, 0.13, 0.23, 0.96]; + + return { duration, ease }; +}; + +export const varTranExit = (props?: TranExitType) => { + const duration = props?.durationOut || 0.48; + const ease = props?.easeOut || [0.43, 0.13, 0.23, 0.96]; + + return { duration, ease }; +}; diff --git a/dashboard/src/components/animate/variants/zoom.ts b/dashboard/src/components/animate/variants/zoom.ts new file mode 100644 index 00000000..23aebd84 --- /dev/null +++ b/dashboard/src/components/animate/variants/zoom.ts @@ -0,0 +1,124 @@ +import { varTranExit, varTranEnter } from './transition'; + +import type { VariantsType } from '../types'; + +// ---------------------------------------------------------------------- + +export const varZoom = (props?: VariantsType) => { + const distance = props?.distance || 720; + const durationIn = props?.durationIn; + const durationOut = props?.durationOut; + const easeIn = props?.easeIn; + const easeOut = props?.easeOut; + + return { + // IN + in: { + initial: { scale: 0, opacity: 0 }, + animate: { scale: 1, opacity: 1, transition: varTranEnter({ durationIn, easeIn }) }, + exit: { scale: 0, opacity: 0, transition: varTranExit({ durationOut, easeOut }) }, + }, + inUp: { + initial: { scale: 0, opacity: 0, translateY: distance }, + animate: { + scale: 1, + opacity: 1, + translateY: 0, + transition: varTranEnter({ durationIn, easeIn }), + }, + exit: { + scale: 0, + opacity: 0, + translateY: distance, + transition: varTranExit({ durationOut, easeOut }), + }, + }, + inDown: { + initial: { scale: 0, opacity: 0, translateY: -distance }, + animate: { + scale: 1, + opacity: 1, + translateY: 0, + transition: varTranEnter({ durationIn, easeIn }), + }, + exit: { + scale: 0, + opacity: 0, + translateY: -distance, + transition: varTranExit({ durationOut, easeOut }), + }, + }, + inLeft: { + initial: { scale: 0, opacity: 0, translateX: -distance }, + animate: { + scale: 1, + opacity: 1, + translateX: 0, + transition: varTranEnter({ durationIn, easeIn }), + }, + exit: { + scale: 0, + opacity: 0, + translateX: -distance, + transition: varTranExit({ durationOut, easeOut }), + }, + }, + inRight: { + initial: { scale: 0, opacity: 0, translateX: distance }, + animate: { + scale: 1, + opacity: 1, + translateX: 0, + transition: varTranEnter({ durationIn, easeIn }), + }, + exit: { + scale: 0, + opacity: 0, + translateX: distance, + transition: varTranExit({ durationOut, easeOut }), + }, + }, + + // OUT + out: { + initial: { scale: 1, opacity: 1 }, + animate: { scale: 0, opacity: 0, transition: varTranEnter({ durationIn, easeIn }) }, + }, + outUp: { + initial: { scale: 1, opacity: 1 }, + animate: { + scale: 0, + opacity: 0, + translateY: -distance, + transition: varTranEnter({ durationIn, easeIn }), + }, + }, + outDown: { + initial: { scale: 1, opacity: 1 }, + animate: { + scale: 0, + opacity: 0, + translateY: distance, + transition: varTranEnter({ durationIn, easeIn }), + }, + }, + outLeft: { + initial: { scale: 1, opacity: 1 }, + animate: { + scale: 0, + opacity: 0, + translateX: -distance, + transition: varTranEnter({ durationIn, easeIn }), + }, + }, + outRight: { + initial: { scale: 1, opacity: 1 }, + animate: { + scale: 0, + opacity: 0, + translateX: distance, + transition: varTranEnter({ durationIn, easeIn }), + }, + }, + }; +}; diff --git a/dashboard/src/components/api-key/create-api-key-dialog.tsx b/dashboard/src/components/api-key/create-api-key-dialog.tsx new file mode 100644 index 00000000..725a1231 --- /dev/null +++ b/dashboard/src/components/api-key/create-api-key-dialog.tsx @@ -0,0 +1,45 @@ +import type { DialogProps } from '@mui/material/Dialog'; + +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 { Link, Typography, DialogContent } from '@mui/material'; + +import { useTranslate } from 'src/locales'; + +import { CreateApiKeyForm } from './create-api-key-form'; + +// ---------------------------------------------------------------------- + +type Props = DialogProps & { + onClose: VoidFunction; + title?: string; + onSuccess: VoidFunction; + isAdmin?: boolean; +}; + +export function CreateApiKeyDialog({ title, isAdmin, open, onClose, onSuccess }: Props) { + const { t } = useTranslate(); + + return ( + + {title || 'Create API Key'} + + + + Read our{' '} + + documentation + {' '} + for information on API tokens. + + + + + + + + + ); +} diff --git a/dashboard/src/components/api-key/create-api-key-form.tsx b/dashboard/src/components/api-key/create-api-key-form.tsx new file mode 100644 index 00000000..f1bae166 --- /dev/null +++ b/dashboard/src/components/api-key/create-api-key-form.tsx @@ -0,0 +1,162 @@ +import type { Permission, ApiKeyResponse, CreateApiKeyRequest } from 'src/lib/swissknife'; + +import { useState } from 'react'; +import { ajvResolver } from '@hookform/resolvers/ajv'; +import { useForm, FormProvider } from 'react-hook-form'; + +import { LoadingButton } from '@mui/lab'; +import { Link, Stack, Alert, Divider, MenuItem, TextField, Typography, InputAdornment } from '@mui/material'; + +import { ajvOptions } from 'src/utils/ajv'; +import { fDate } from 'src/utils/format-time'; + +import { useTranslate } from 'src/locales'; +import { CONFIG } from 'src/config-global'; +import { createApiKey, PermissionSchema, createWalletApiKey, CreateApiKeyRequestSchema } from 'src/lib/swissknife'; + +import { toast } from 'src/components/snackbar'; +import { RHFSelect, RHFTextField, RHFWalletSelect, RHFMultiCheckbox } from 'src/components/hook-form'; + +import { useAuthContext } from 'src/auth/hooks'; + +import { CopyButton } from '../copy'; + +// ---------------------------------------------------------------------- + +const expiryOptions = [ + { label: '30 days', value: 30 * 24 * 60 * 60 }, + { label: '60 days', value: 60 * 24 * 60 * 60 }, + { label: '90 days', value: 90 * 24 * 60 * 60 }, + { label: '1 year', value: 365 * 24 * 60 * 60 }, +]; + +type Props = { + onSuccess: VoidFunction; + isAdmin?: boolean; +}; + +// @ts-ignore +const resolver = ajvResolver(CreateApiKeyRequestSchema, { + ...ajvOptions, + schemas: [{ ...PermissionSchema, $id: '#/components/schemas/Permission' }], +}); + +const permissionOptions = (permissions: Permission[]) => permissions.map((value) => ({ label: value, value })); + +export function CreateApiKeyForm({ onSuccess, isAdmin }: Props) { + const { t } = useTranslate(); + const { user } = useAuthContext(); + const [apiKey, setApiKey] = useState(); + + const methods = useForm({ + resolver, + defaultValues: { + name: '', + wallet: null, + expiry: expiryOptions[2].value, + permissions: [], + description: '', + }, + }); + + const { + reset, + handleSubmit, + formState: { isSubmitting }, + } = methods; + + const onSubmit = async (body: any) => { + const submissionData: CreateApiKeyRequest = { + ...body, + expiry: body.expiry || undefined, + description: body.description || undefined, + user_id: body.wallet?.user_id, + }; + + try { + if (isAdmin) { + const { data } = await createApiKey({ body: submissionData }); + setApiKey(data); + } else { + const { data } = await createWalletApiKey({ body: submissionData }); + setApiKey(data); + } + toast.success(t('create_api_key_form.create_success')); + reset(); + onSuccess(); + } catch (error) { + toast.error(error.reason); + } + }; + + return apiKey && apiKey.key ? ( + + {t('create_api_key_form.key_display_message')} + + + + ), + }} + /> + + ) : ( + +
+ + + + + + Never expires + + + + {expiryOptions.map((option) => ( + +
+ {option.label} + + Expires {fDate(Date.now() + option.value * 1000, 'DD MMMM YYYY')} + +
+
+ ))} +
+ + {user!.permissions.length > 0 ? ( + + ) : ( + + {t('create_api_key_form.no_permissions')}:{' '} + + See Docs + + + )} + + {isAdmin && } + + + {t('create_api_key_form.create_button')} + +
+
+
+ ); +} diff --git a/dashboard/src/components/api-key/index.ts b/dashboard/src/components/api-key/index.ts new file mode 100644 index 00000000..c9840b8e --- /dev/null +++ b/dashboard/src/components/api-key/index.ts @@ -0,0 +1,2 @@ +export * from './create-api-key-form'; +export * from './create-api-key-dialog'; diff --git a/dashboard/src/components/app/index.ts b/dashboard/src/components/app/index.ts new file mode 100644 index 00000000..d405cec0 --- /dev/null +++ b/dashboard/src/components/app/index.ts @@ -0,0 +1 @@ +export * from './welcome'; diff --git a/dashboard/src/components/app/welcome.tsx b/dashboard/src/components/app/welcome.tsx new file mode 100644 index 00000000..fe809204 --- /dev/null +++ b/dashboard/src/components/app/welcome.tsx @@ -0,0 +1,66 @@ +import type { StackProps } from '@mui/material/Stack'; + +import Stack from '@mui/material/Stack'; +import { useTheme } from '@mui/material/styles'; +import Typography from '@mui/material/Typography'; + +// ---------------------------------------------------------------------- + +type Props = StackProps & { + title?: string; + description?: string; + img?: React.ReactNode; + action?: React.ReactNode; +}; + +export function Welcome({ title, description, action, img, ...other }: Props) { + const theme = useTheme(); + + return ( + + + + {title} + + + {description} + + {action && action} + + + {img && ( + + {img} + + )} + + ); +} diff --git a/dashboard/src/components/bitcoin/index.ts b/dashboard/src/components/bitcoin/index.ts new file mode 100644 index 00000000..e7c579a7 --- /dev/null +++ b/dashboard/src/components/bitcoin/index.ts @@ -0,0 +1 @@ +export * from './sats-with-icon'; diff --git a/dashboard/src/components/bitcoin/sats-with-icon.tsx b/dashboard/src/components/bitcoin/sats-with-icon.tsx new file mode 100644 index 00000000..e00bd681 --- /dev/null +++ b/dashboard/src/components/bitcoin/sats-with-icon.tsx @@ -0,0 +1,25 @@ +import type { TypographyProps } from '@mui/material'; + +import React from 'react'; + +import { Tooltip, Typography } from '@mui/material'; + +import { fSats } from 'src/utils/format-number'; + +interface Props extends TypographyProps { + amountMSats: number; + placement?: 'top-start' | 'top' | 'bottom' | 'left' | 'right'; + children?: React.ReactNode; +} + +export function SatsWithIcon({ amountMSats, placement = 'top-start', children, variant, ...other }: Props) { + return ( + + + {fSats(amountMSats / 1000)} + + {children} + + + ); +} diff --git a/dashboard/src/components/carousel/breakpoints.ts b/dashboard/src/components/carousel/breakpoints.ts new file mode 100644 index 00000000..2af83d9b --- /dev/null +++ b/dashboard/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/dashboard/src/components/carousel/carousel.tsx b/dashboard/src/components/carousel/carousel.tsx new file mode 100644 index 00000000..121f02fe --- /dev/null +++ b/dashboard/src/components/carousel/carousel.tsx @@ -0,0 +1,91 @@ +import { Children, isValidElement } from 'react'; + +import Box from '@mui/material/Box'; +import { styled } from '@mui/material/styles'; + +import { carouselClasses } from './classes'; +import { CarouselSlide } from './components/carousel-slide'; + +import type { CarouselProps, CarouselOptions } from './types'; + +// ---------------------------------------------------------------------- + +type StyledProps = Pick; + +export const StyledRoot = styled(Box, { + shouldForwardProp: (prop) => prop !== 'axis', +})(({ axis }) => ({ + margin: 'auto', + maxWidth: '100%', + overflow: 'hidden', + position: 'relative', + ...(axis === 'y' && { + height: '100%', + }), +})); + +export const StyledContainer = styled(Box, { + shouldForwardProp: (prop) => prop !== 'axis' && prop !== 'slideSpacing', +})(({ axis, slideSpacing }) => ({ + display: 'flex', + backfaceVisibility: 'hidden', + ...(axis === 'x' && { + touchAction: 'pan-y pinch-zoom', + marginLeft: `calc(${slideSpacing} * -1)`, + }), + ...(axis === 'y' && { + height: '100%', + flexDirection: 'column', + touchAction: 'pan-x pinch-zoom', + marginTop: `calc(${slideSpacing} * -1)`, + }), +})); + +// ---------------------------------------------------------------------- + +export function Carousel({ carousel, children, sx, slotProps }: CarouselProps) { + const { mainRef, options } = carousel; + + const axis = options?.axis ?? 'x'; + + const slideSpacing = options?.slideSpacing ?? '0px'; + + const direction = options?.direction ?? 'ltr'; + + const renderChildren = Children.map(children, (child) => { + if (isValidElement(child)) { + const reactChild = child as React.ReactElement<{ key?: React.Key }>; + + return ( + + {child} + + ); + } + return null; + }); + + return ( + + + theme.transitions.create(['height'], { + easing: theme.transitions.easing.easeInOut, + duration: theme.transitions.duration.shorter, + }), + }), + ...slotProps?.container, + }} + > + {renderChildren} + + + ); +} diff --git a/dashboard/src/components/carousel/classes.ts b/dashboard/src/components/carousel/classes.ts new file mode 100644 index 00000000..a26a5b1e --- /dev/null +++ b/dashboard/src/components/carousel/classes.ts @@ -0,0 +1,27 @@ +// ---------------------------------------------------------------------- + +export const carouselClasses = { + root: 'mnl__carousel__root', + container: 'mnl__carousel__container', + // dot + dots: 'mnl__carousel__dots', + dot: 'mnl__carousel__dot', + // arrow + arrows: 'mnl__carousel__arrows', + arrowsLabel: 'mnl__carousel__arrows_label', + arrowPrev: 'mnl__carousel__btn--prev', + arrowNext: 'mnl__carousel__btn--next', + arrowSvg: 'mnl__carousel__btn__svg', + // slide + slide: 'mnl__carousel__slide', + slideContent: 'mnl__carousel__slide__content', + // thumb + thumbs: 'mnl__carousel__thumbs', + thumb: 'mnl__carousel__thumb', + thumbContainer: 'mnl__carousel__thumbs__container', + thumbImage: 'mnl__carousel__thumb__image', + // progress + progress: 'mnl__carousel__progress', + progressBar: 'mnl__carousel__progress__bar', + state: { selected: 'state--selected', disabled: 'state--disabled' }, +}; diff --git a/dashboard/src/components/carousel/components/carousel-arrow-buttons.tsx b/dashboard/src/components/carousel/components/carousel-arrow-buttons.tsx new file mode 100644 index 00000000..2dc9d608 --- /dev/null +++ b/dashboard/src/components/carousel/components/carousel-arrow-buttons.tsx @@ -0,0 +1,238 @@ +import type { StackProps } from '@mui/material/Stack'; +import type { CSSObject } from '@mui/material/styles'; +import type { ButtonBaseProps } from '@mui/material/ButtonBase'; + +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import SvgIcon from '@mui/material/SvgIcon'; +import { useTheme } from '@mui/material/styles'; +import ButtonBase, { buttonBaseClasses } from '@mui/material/ButtonBase'; + +import { varAlpha, stylesMode } from 'src/theme/styles'; + +import { carouselClasses } from '../classes'; + +import type { CarouselArrowButtonProps, CarouselArrowButtonsProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function CarouselArrowBasicButtons({ + options, + slotProps, + totalSlides, + selectedIndex, + // + onClickPrev, + onClickNext, + disablePrev, + disableNext, + sx, + ...other +}: StackProps & CarouselArrowButtonsProps) { + return ( + + + + + + ); +} + +// ---------------------------------------------------------------------- + +export function CarouselArrowNumberButtons({ + options, + slotProps, + totalSlides, + selectedIndex, + // + onClickPrev, + onClickNext, + disablePrev, + disableNext, + sx, + ...other +}: StackProps & CarouselArrowButtonsProps) { + const theme = useTheme(); + + return ( + + + + + {selectedIndex}/{totalSlides} + + + + + ); +} + +// ---------------------------------------------------------------------- + +export function CarouselArrowFloatButtons({ + options, + slotProps, + onClickPrev, + onClickNext, + disablePrev, + disableNext, +}: StackProps & CarouselArrowButtonsProps) { + const baseStyles: CSSObject = { + zIndex: 9, + top: '50%', + borderRadius: 1.5, + position: 'absolute', + color: 'common.white', + bgcolor: 'text.primary', + transform: 'translateY(-50%)', + '&:hover': { opacity: 0.8 }, + [stylesMode.dark]: { color: 'grey.800' }, + }; + + return ( + <> + + + + + ); +} + +// ---------------------------------------------------------------------- + +export function ArrowButton({ sx, svgIcon, svgSize, options, variant, ...other }: ButtonBaseProps & CarouselArrowButtonProps) { + const arrowPrev = variant === 'prev'; + const arrowNext = variant === 'next'; + + const prevSvg = svgIcon || ( + + ); + + const nextSvg = svgIcon || ( + + ); + + return ( + + theme.transitions.create(['all'], { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.short, + }), + [`&.${buttonBaseClasses.disabled}`]: { + opacity: 0.4, + }, + ...sx, + ...(options?.direction === 'rtl' && { + ...(arrowPrev && { right: -16, left: 'auto' }), + ...(arrowNext && { left: -16, right: 'auto' }), + }), + }} + {...other} + > + + {arrowPrev ? prevSvg : nextSvg} + + + ); +} diff --git a/dashboard/src/components/carousel/components/carousel-dot-buttons.tsx b/dashboard/src/components/carousel/components/carousel-dot-buttons.tsx new file mode 100644 index 00000000..3d991a72 --- /dev/null +++ b/dashboard/src/components/carousel/components/carousel-dot-buttons.tsx @@ -0,0 +1,146 @@ +import Box from '@mui/material/Box'; +import NoSsr from '@mui/material/NoSsr'; +import { useTheme } from '@mui/material/styles'; +import ButtonBase from '@mui/material/ButtonBase'; + +import { varAlpha, stylesMode } from 'src/theme/styles'; + +import { carouselClasses } from '../classes'; + +import type { CarouselDotButtonsProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function CarouselDotButtons({ + sx, + gap, + slotProps, + onClickDot, + scrollSnaps, + selectedIndex, + fallbackCount = 1, + variant = 'circular', + fallback = false, + ...other +}: CarouselDotButtonsProps) { + const theme = useTheme(); + + const GAPS = { + number: gap ?? 6, + rounded: gap ?? 2, + circular: gap ?? 2, + }; + + const SIZES = { + circular: slotProps?.dot?.size ?? 18, + number: slotProps?.dot?.size ?? 28, + }; + + const renderFallback = ( + + ); + + const dotStyles = { + circular: (selected: boolean) => ({ + width: SIZES.circular, + height: SIZES.circular, + '&::before': { + width: 8, + height: 8, + content: '""', + opacity: 0.24, + borderRadius: '50%', + bgcolor: 'currentColor', + transition: theme.transitions.create(['opacity'], { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.short, + }), + ...(selected && { opacity: 1 }), + }, + }), + rounded: (selected: boolean) => ({ + width: SIZES.circular, + height: SIZES.circular, + '&::before': { + width: 8, + height: 8, + content: '""', + opacity: 0.24, + borderRadius: '50%', + bgcolor: 'currentColor', + transition: theme.transitions.create(['width', 'opacity'], { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.short, + }), + ...(selected && { width: 'calc(100% - 4px)', opacity: 1, borderRadius: 1 }), + }, + }), + number: (selected: boolean) => ({ + width: SIZES.number, + height: SIZES.number, + borderRadius: '50%', + typography: 'caption', + color: 'text.disabled', + border: `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.16)}`, + ...(selected && { + color: 'common.white', + bgcolor: 'text.primary', + fontWeight: 'fontWeightSemiBold', + [stylesMode.dark]: { color: 'grey.800' }, + }), + }), + }; + + return ( + + + {scrollSnaps.map((_, index) => { + const selected = index === selectedIndex; + + return ( + + onClickDot(index)} + sx={{ + ...(variant === 'circular' && dotStyles.circular(selected)), + ...(variant === 'rounded' && dotStyles.rounded(selected)), + ...(variant === 'number' && dotStyles.number(selected)), + [`&.${carouselClasses.state.selected}`]: { + ...slotProps?.dot?.selected, + }, + ...slotProps?.dot?.sx, + }} + > + {variant === 'number' && index + 1} + + + ); + })} + + + ); +} diff --git a/dashboard/src/components/carousel/components/carousel-progress-bar.tsx b/dashboard/src/components/carousel/components/carousel-progress-bar.tsx new file mode 100644 index 00000000..93c1464e --- /dev/null +++ b/dashboard/src/components/carousel/components/carousel-progress-bar.tsx @@ -0,0 +1,47 @@ +import type { BoxProps } from '@mui/material/Box'; + +import Box from '@mui/material/Box'; +import { styled } from '@mui/material/styles'; + +import { varAlpha } from 'src/theme/styles'; + +import { carouselClasses } from '../classes'; + +import type { CarouselProgressBarProps } from '../types'; + +// ---------------------------------------------------------------------- + +const StyledRoot = styled(Box)(({ 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 StyledProgress = styled(Box)(() => ({ + top: 0, + bottom: 0, + width: '100%', + left: '-100%', + position: 'absolute', + backgroundColor: 'currentColor', +})); + +// ---------------------------------------------------------------------- + +export function CarouselProgressBar({ value, sx, ...other }: BoxProps & CarouselProgressBarProps) { + return ( + + + + ); +} diff --git a/dashboard/src/components/carousel/components/carousel-slide.tsx b/dashboard/src/components/carousel/components/carousel-slide.tsx new file mode 100644 index 00000000..b08d9f40 --- /dev/null +++ b/dashboard/src/components/carousel/components/carousel-slide.tsx @@ -0,0 +1,99 @@ +import type { BoxProps } from '@mui/material/Box'; + +import Box from '@mui/material/Box'; +import { styled } from '@mui/material/styles'; + +import { carouselClasses } from '../classes'; + +import type { CarouselOptions, CarouselSlideProps } from '../types'; + +// ---------------------------------------------------------------------- + +type StyledProps = Pick; + +const StyledRoot = styled(Box, { + shouldForwardProp: (prop) => prop !== 'axis' && prop !== 'slideSpacing', +})(({ axis, slideSpacing }) => ({ + display: 'block', + position: 'relative', + ...(axis === 'x' && { + minWidth: 0, + paddingLeft: slideSpacing, + }), + ...(axis === 'y' && { + minHeight: 0, + paddingTop: slideSpacing, + }), +})); + +const StyledContent = styled(Box)(() => ({ + overflow: 'hidden', + position: 'relative', + borderRadius: 'inherit', +})); + +// ---------------------------------------------------------------------- + +export function CarouselSlide({ sx, options, children, ...other }: BoxProps & CarouselSlideProps) { + const slideSize = getSize(options?.slidesToShow); + + return ( + + {options?.parallax ? ( + +
{children}
+
+ ) : ( + children + )} +
+ ); +} + +// ---------------------------------------------------------------------- + +type ObjectValue = { + [key: string]: string | number; +}; + +type InputValue = CarouselOptions['slidesToShow']; + +function getSize(slidesToShow: InputValue): InputValue { + if (slidesToShow && typeof slidesToShow === 'object') { + return Object.keys(slidesToShow).reduce((acc, key) => { + const sizeByKey = slidesToShow[key]; + 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/dashboard/src/components/carousel/components/carousel-thumbs.tsx b/dashboard/src/components/carousel/components/carousel-thumbs.tsx new file mode 100644 index 00000000..4898386a --- /dev/null +++ b/dashboard/src/components/carousel/components/carousel-thumbs.tsx @@ -0,0 +1,162 @@ +import type { BoxProps } from '@mui/material/Box'; +import type { CSSObject } from '@mui/material/styles'; +import type { ButtonBaseProps } from '@mui/material/ButtonBase'; + +import { Children, forwardRef, isValidElement } from 'react'; + +import Box from '@mui/material/Box'; +import { useTheme } from '@mui/material/styles'; +import ButtonBase from '@mui/material/ButtonBase'; + +import { varAlpha } from 'src/theme/styles'; + +import { carouselClasses } from '../classes'; +import { CarouselSlide } from './carousel-slide'; +import { StyledRoot, StyledContainer } from '../carousel'; + +import type { CarouselOptions, CarouselThumbProps, CarouselThumbsProps } from '../types'; + +// ---------------------------------------------------------------------- + +export const CarouselThumbs = forwardRef( + ({ children, slotProps, options, sx, ...other }, ref) => { + const axis = options?.axis ?? 'x'; + + const slideSpacing = options?.slideSpacing ?? '12px'; + + const maskStyles = useMaskStyle(axis); + + const renderChildren = Children.map(children, (child) => { + if (isValidElement(child)) { + const reactChild = child as React.ReactElement<{ key?: React.Key }>; + + return ( + + {child} + + ); + } + return null; + }); + + return ( + + + {renderChildren} + + + ); + } +); + +// ---------------------------------------------------------------------- + +export function CarouselThumb({ sx, src, index, selected, ...other }: ButtonBaseProps & CarouselThumbProps) { + return ( + + theme.transitions.create(['opacity', 'box-shadow'], { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.short, + }), + ...(selected && { + opacity: 1, + boxShadow: (theme) => `0 0 0 2px ${theme.vars.palette.primary.main}`, + }), + ...sx, + }} + {...other} + > + + + ); +} + +// ---------------------------------------------------------------------- + +function useMaskStyle(axis: CarouselOptions['axis']): CSSObject { + const theme = useTheme(); + + const baseStyles = { + zIndex: 9, + content: '""', + position: 'absolute', + }; + + const bgcolor = `${theme.vars.palette.background.paper} 20%, ${varAlpha(theme.vars.palette.background.paperChannel, 0)} 100%)`; + + if (axis === 'y') { + return { + '&::before, &::after': { + ...baseStyles, + left: 0, + height: 40, + width: '100%', + }, + '&::before': { + top: -8, + background: `linear-gradient(to bottom, ${bgcolor}`, + }, + '&::after': { + bottom: -8, + background: `linear-gradient(to top, ${bgcolor}`, + }, + }; + } + + return { + '&::before, &::after': { + ...baseStyles, + top: 0, + width: 40, + height: '100%', + }, + '&::before': { + left: -8, + background: `linear-gradient(to right, ${bgcolor}`, + }, + '&::after': { + right: -8, + background: `linear-gradient(to left, ${bgcolor}`, + }, + }; +} diff --git a/dashboard/src/components/carousel/hooks/use-carousel-arrows.ts b/dashboard/src/components/carousel/hooks/use-carousel-arrows.ts new file mode 100644 index 00000000..3ac361fc --- /dev/null +++ b/dashboard/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/dashboard/src/components/carousel/hooks/use-carousel-auto-play.ts b/dashboard/src/components/carousel/hooks/use-carousel-auto-play.ts new file mode 100644 index 00000000..b8c6384b --- /dev/null +++ b/dashboard/src/components/carousel/hooks/use-carousel-auto-play.ts @@ -0,0 +1,47 @@ +// @ts-nocheck + +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/dashboard/src/components/carousel/hooks/use-carousel-auto-scroll.ts b/dashboard/src/components/carousel/hooks/use-carousel-auto-scroll.ts new file mode 100644 index 00000000..ab6f5d45 --- /dev/null +++ b/dashboard/src/components/carousel/hooks/use-carousel-auto-scroll.ts @@ -0,0 +1,47 @@ +// @ts-nocheck + +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/dashboard/src/components/carousel/hooks/use-carousel-dots.ts b/dashboard/src/components/carousel/hooks/use-carousel-dots.ts new file mode 100644 index 00000000..b4a96fe8 --- /dev/null +++ b/dashboard/src/components/carousel/hooks/use-carousel-dots.ts @@ -0,0 +1,50 @@ +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); + setSelectedIndex(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/dashboard/src/components/carousel/hooks/use-carousel-parallax.ts b/dashboard/src/components/carousel/hooks/use-carousel-parallax.ts new file mode 100644 index 00000000..5119ecef --- /dev/null +++ b/dashboard/src/components/carousel/hooks/use-carousel-parallax.ts @@ -0,0 +1,84 @@ +import type { EmblaEventType, EmblaCarouselType } from 'embla-carousel'; + +import { useRef, useEffect, useCallback } from 'react'; + +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('.slide__parallax__layer') 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/dashboard/src/components/carousel/hooks/use-carousel-progress.ts b/dashboard/src/components/carousel/hooks/use-carousel-progress.ts new file mode 100644 index 00000000..cff35bf4 --- /dev/null +++ b/dashboard/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/dashboard/src/components/carousel/hooks/use-carousel.ts b/dashboard/src/components/carousel/hooks/use-carousel.ts new file mode 100644 index 00000000..6e5a4126 --- /dev/null +++ b/dashboard/src/components/carousel/hooks/use-carousel.ts @@ -0,0 +1,81 @@ +import type { EmblaPluginType } from 'embla-carousel'; + +import { useMemo } from 'react'; +import useEmblaCarousel from 'embla-carousel-react'; + +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 [mainRef, mainApi] = useEmblaCarousel(options, 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]); + + return { + options: { + ...options, + ...mainApi?.internalEngine().options, + }, + 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/dashboard/src/components/carousel/hooks/use-thumbs.ts b/dashboard/src/components/carousel/hooks/use-thumbs.ts new file mode 100644 index 00000000..f6c8bd6c --- /dev/null +++ b/dashboard/src/components/carousel/hooks/use-thumbs.ts @@ -0,0 +1,46 @@ +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/dashboard/src/components/carousel/index.ts b/dashboard/src/components/carousel/index.ts new file mode 100644 index 00000000..92f7acd7 --- /dev/null +++ b/dashboard/src/components/carousel/index.ts @@ -0,0 +1,19 @@ +export * from './classes'; + +export * from './carousel'; + +export type * from './types'; + +export * from './breakpoints'; + +export * from './hooks/use-carousel'; + +export * from './components/carousel-slide'; + +export * from './components/carousel-thumbs'; + +export * from './components/carousel-dot-buttons'; + +export * from './components/carousel-progress-bar'; + +export * from './components/carousel-arrow-buttons'; diff --git a/dashboard/src/components/carousel/types.ts b/dashboard/src/components/carousel/types.ts new file mode 100644 index 00000000..385a5d8d --- /dev/null +++ b/dashboard/src/components/carousel/types.ts @@ -0,0 +1,162 @@ +import type { Theme, SxProps } from '@mui/material/styles'; +import type { UseEmblaCarouselType } from 'embla-carousel-react'; +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 = Omit & { + gap?: number; + sx?: SxProps; + fallback?: boolean; + fallbackCount?: number; + variant?: 'circular' | 'rounded' | 'number'; + slotProps?: { + dot?: { + size?: number; + sx?: SxProps; + selected?: SxProps; + }; + }; +}; + +// ---------------------------------------------------------------------- + +/** + * Prev & Next Buttons + */ +export type UseCarouselArrowsReturn = { + disablePrev: boolean; + disableNext: boolean; + onClickPrev: () => void; + onClickNext: () => void; +}; + +export type CarouselArrowButtonProps = { + svgSize?: number; + variant: 'prev' | 'next'; + svgIcon?: React.ReactNode; + options?: CarouselArrowButtonsProps['options']; +}; + +export type CarouselArrowButtonsProps = UseCarouselArrowsReturn & { + 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 = { + src: string; + index: number; + selected: boolean; +}; + +export type CarouselThumbsProps = { + options?: Partial; + slotProps?: { + slide?: SxProps; + container?: SxProps; + disableMask?: boolean; + }; +}; + +// ---------------------------------------------------------------------- + +/** + * Progress + */ +export type UseCarouselProgressReturn = { + value: number; +}; + +export type CarouselProgressBarProps = UseCarouselProgressReturn; + +// ---------------------------------------------------------------------- + +/** + * Autoplay + */ +export type UseCarouselAutoPlayReturn = { + isPlaying: boolean; + onTogglePlay: () => void; + onClickAutoplay: (callback: () => void) => void; +}; + +// ---------------------------------------------------------------------- + +/** + * Slide + */ +export type CarouselSlideProps = { + options?: Partial; +}; + +// ---------------------------------------------------------------------- + +/** + * Carousel + */ +export type CarouselBaseOptions = EmblaOptionsType & { + slideSpacing?: string; + parallax?: boolean | number; + slidesToShow?: string | number | { [key: string]: string | number }; +}; + +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 = { + carousel: UseCarouselReturn; + children: React.ReactNode; + sx?: SxProps; + slotProps?: { + container?: SxProps; + slide?: SxProps; + }; +}; diff --git a/dashboard/src/components/chart/chart-legends.tsx b/dashboard/src/components/chart/chart-legends.tsx new file mode 100644 index 00000000..39265f2e --- /dev/null +++ b/dashboard/src/components/chart/chart-legends.tsx @@ -0,0 +1,65 @@ +import type { StackProps } from '@mui/material/Stack'; + +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import { styled } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +export const StyledLegend = styled(Box)(({ theme }) => ({ + gap: 6, + alignItems: 'center', + display: 'inline-flex', + justifyContent: 'flex-start', + fontSize: theme.typography.pxToRem(13), + fontWeight: theme.typography.fontWeightMedium, +})); + +export const StyledDot = styled(Box)(() => ({ + width: 12, + height: 12, + flexShrink: 0, + display: 'flex', + borderRadius: '50%', + position: 'relative', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'currentColor', +})); + +// ---------------------------------------------------------------------- + +type Props = StackProps & { + labels?: string[]; + colors?: string[]; + values?: string[]; + sublabels?: string[]; + icons?: React.ReactNode[]; +}; + +export function ChartLegends({ labels = [], colors = [], values, sublabels, icons, ...other }: Props) { + return ( + + {labels?.map((series, index) => ( + + + {icons?.length ? ( + + {icons?.[index]} + + ) : ( + + )} + + + {series} + {sublabels && <> {` (${sublabels[index]})`}} + + + + {values && {values[index]}} + + ))} + + ); +} diff --git a/dashboard/src/components/chart/chart-loading.tsx b/dashboard/src/components/chart/chart-loading.tsx new file mode 100644 index 00000000..3b259631 --- /dev/null +++ b/dashboard/src/components/chart/chart-loading.tsx @@ -0,0 +1,48 @@ +import type { BoxProps } from '@mui/material/Box'; + +import Box from '@mui/material/Box'; +import Skeleton from '@mui/material/Skeleton'; + +import type { ChartBaseProps } from './types'; + +// ---------------------------------------------------------------------- + +type Props = BoxProps & { + type: ChartBaseProps['type']; +}; + +export function ChartLoading({ sx, type, ...other }: Props) { + const circularTypes = ['donut', 'radialBar', 'pie', 'polarArea']; + + return ( + + + + ); +} diff --git a/dashboard/src/components/chart/chart-select.tsx b/dashboard/src/components/chart/chart-select.tsx new file mode 100644 index 00000000..cb99bf5c --- /dev/null +++ b/dashboard/src/components/chart/chart-select.tsx @@ -0,0 +1,67 @@ +import type { Theme, SxProps } from '@mui/material/styles'; + +import MenuItem from '@mui/material/MenuItem'; +import MenuList from '@mui/material/MenuList'; +import ButtonBase from '@mui/material/ButtonBase'; + +import { varAlpha } from 'src/theme/styles'; + +import { Iconify } from 'src/components/iconify'; + +import { usePopover, CustomPopover } from '../custom-popover'; + +// ---------------------------------------------------------------------- + +type Props = { + options: string[]; + value: string; + onChange: (newValue: string) => void; + slotProps?: { + button?: SxProps; + popover?: SxProps; + }; +}; + +export function ChartSelect({ options, value, onChange, slotProps, ...other }: Props) { + const popover = usePopover(); + + return ( + <> + `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.24)}`, + ...slotProps?.button, + }} + {...other} + > + {value} + + + + + + + {options.map((option) => ( + { + popover.onClose(); + onChange(option); + }} + > + {option} + + ))} + + + + ); +} diff --git a/dashboard/src/components/chart/chart.tsx b/dashboard/src/components/chart/chart.tsx new file mode 100644 index 00000000..ea27a135 --- /dev/null +++ b/dashboard/src/components/chart/chart.tsx @@ -0,0 +1,47 @@ +import type { BoxProps } from '@mui/material/Box'; + +import dynamic from 'next/dynamic'; + +import Box from '@mui/material/Box'; + +import { withLoadingProps } from 'src/utils/with-loading-props'; + +import { ChartLoading } from './chart-loading'; + +import type { ChartProps, ChartBaseProps, ChartLoadingProps } from './types'; + +// ---------------------------------------------------------------------- + +type WithLoadingProps = ChartBaseProps & { + loading?: ChartLoadingProps; +}; + +const ApexChart = withLoadingProps((props) => + dynamic(() => import('react-apexcharts').then((mod) => mod.default), { + ssr: false, + loading: () => { + const { loading, type } = props(); + + return loading?.disabled ? null : ; + }, + }) +); + +export function Chart({ sx, type, series, height, options, loadingProps, width = '100%', ...other }: BoxProps & ChartProps) { + return ( + + + + ); +} diff --git a/dashboard/src/components/chart/index.ts b/dashboard/src/components/chart/index.ts new file mode 100644 index 00000000..0414cb2a --- /dev/null +++ b/dashboard/src/components/chart/index.ts @@ -0,0 +1,11 @@ +export * from './chart'; + +export * from './use-chart'; + +export type * from './types'; + +export * from './chart-select'; + +export * from './chart-legends'; + +export * from './chart-loading'; diff --git a/dashboard/src/components/chart/styles.css b/dashboard/src/components/chart/styles.css new file mode 100644 index 00000000..0082b6d8 --- /dev/null +++ b/dashboard/src/components/chart/styles.css @@ -0,0 +1,59 @@ +.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-series { + font-size: 0; + } + .apexcharts-legend-marker { + margin-right: 6px; + vertical-align: top; + transform: translateY(3px); + } + .apexcharts-legend-text { + line-height: 18px; + } +} diff --git a/dashboard/src/components/chart/types.ts b/dashboard/src/components/chart/types.ts new file mode 100644 index 00000000..f34da206 --- /dev/null +++ b/dashboard/src/components/chart/types.ts @@ -0,0 +1,20 @@ +import type { Props } from 'react-apexcharts'; +import type { Theme, SxProps } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +export type ChartProps = { + type: Props['type']; + series: Props['series']; + options: Props['options']; + loadingProps?: ChartLoadingProps; +}; + +export type ChartBaseProps = Props; + +export type ChartOptions = Props['options']; + +export type ChartLoadingProps = { + disabled?: boolean; + sx?: SxProps; +}; diff --git a/dashboard/src/components/chart/use-chart.ts b/dashboard/src/components/chart/use-chart.ts new file mode 100644 index 00000000..6d0948e2 --- /dev/null +++ b/dashboard/src/components/chart/use-chart.ts @@ -0,0 +1,323 @@ +import { useTheme } from '@mui/material/styles'; + +import { varAlpha } from 'src/theme/styles'; + +import type { ChartOptions } from './types'; + +// ---------------------------------------------------------------------- + +export function useChart(options?: ChartOptions): ChartOptions { + const theme = useTheme(); + + 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, + }; + + const 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%', + }, + }, + }, + }, + ...(options?.responsive ?? []), + ]; + + return { + ...options, + + /** ************************************** + * Chart + *************************************** */ + chart: { + toolbar: { + show: false, + }, + zoom: { + enabled: false, + }, + parentHeightOffset: 0, + fontFamily: theme.typography.fontFamily, + foreColor: theme.vars.palette.text.disabled, + ...options?.chart, + animations: { + enabled: true, + speed: 360, + animateGradually: { enabled: true, delay: 120 }, + dynamicAnimation: { enabled: true, speed: 360 }, + ...options?.chart?.animations, + }, + }, + + /** ************************************** + * Colors + *************************************** */ + colors: options?.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 + *************************************** */ + states: { + ...options?.states, + hover: { + ...options?.states?.hover, + filter: { type: 'darken', value: 0.88, ...options?.states?.hover?.filter }, + }, + active: { + ...options?.states?.active, + filter: { type: 'darken', value: 0.88, ...options?.states?.active?.filter }, + }, + }, + + /** ************************************** + * Fill + *************************************** */ + fill: { + opacity: 1, + ...options?.fill, + gradient: { + type: 'vertical', + shadeIntensity: 0, + opacityFrom: 0.4, + opacityTo: 0, + stops: [0, 100], + ...options?.fill?.gradient, + }, + }, + + /** ************************************** + * Data labels + *************************************** */ + dataLabels: { + enabled: false, + ...options?.dataLabels, + }, + + /** ************************************** + * Stroke + *************************************** */ + stroke: { + width: 2.5, + curve: 'smooth', + lineCap: 'round', + ...options?.stroke, + }, + + /** ************************************** + * Grid + *************************************** */ + grid: { + strokeDashArray: 3, + borderColor: theme.vars.palette.divider, + ...options?.grid, + padding: { + top: 0, + right: 0, + bottom: 0, + ...options?.grid?.padding, + }, + xaxis: { + lines: { + show: false, + }, + ...options?.grid?.xaxis, + }, + }, + + /** ************************************** + * Axis + *************************************** */ + xaxis: { + axisBorder: { + show: false, + }, + axisTicks: { + show: false, + }, + ...options?.xaxis, + }, + yaxis: { + tickAmount: 5, + ...options?.yaxis, + }, + + /** ************************************** + * Markers + *************************************** */ + markers: { + size: 0, + strokeColors: theme.vars.palette.background.paper, + ...options?.markers, + }, + + /** ************************************** + * Tooltip + *************************************** */ + tooltip: { + theme: 'false', + fillSeriesColor: false, + x: { + show: true, + }, + ...options?.tooltip, + }, + + /** ************************************** + * Legend + *************************************** */ + legend: { + show: false, + position: 'top', + fontWeight: 500, + fontSize: '13px', + horizontalAlign: 'right', + markers: { size: 12 }, + labels: { + colors: theme.vars.palette.text.primary, + }, + ...options?.legend, + itemMargin: { + horizontal: 8, + vertical: 8, + ...options?.legend?.itemMargin, + }, + }, + + /** ************************************** + * plotOptions + *************************************** */ + plotOptions: { + ...options?.plotOptions, + // plotOptions: Bar + bar: { + borderRadius: 4, + columnWidth: '48%', + borderRadiusApplication: 'end', + ...options?.plotOptions?.bar, + }, + + // plotOptions: Pie + Donut + pie: { + ...options?.plotOptions?.pie, + donut: { + ...options?.plotOptions?.pie?.donut, + labels: { + show: true, + ...options?.plotOptions?.pie?.donut?.labels, + value: { + ...LABEL_VALUE, + ...options?.plotOptions?.pie?.donut?.labels?.value, + }, + total: { + ...LABEL_TOTAL, + ...options?.plotOptions?.pie?.donut?.labels?.total, + }, + }, + }, + }, + + // plotOptions: Radialbar + radialBar: { + ...options?.plotOptions?.radialBar, + hollow: { + margin: -8, + size: '100%', + ...options?.plotOptions?.radialBar?.hollow, + }, + track: { + margin: -8, + strokeWidth: '50%', + background: varAlpha(theme.vars.palette.grey['500Channel'], 0.16), + ...options?.plotOptions?.radialBar?.track, + }, + dataLabels: { + ...options?.plotOptions?.radialBar?.dataLabels, + value: { + ...LABEL_VALUE, + ...options?.plotOptions?.radialBar?.dataLabels?.value, + }, + total: { + ...LABEL_TOTAL, + ...options?.plotOptions?.radialBar?.dataLabels?.total, + }, + }, + }, + + // plotOptions: Radar + radar: { + ...options?.plotOptions?.radar, + polygons: { + fill: { + colors: ['transparent'], + }, + strokeColors: theme.vars.palette.divider, + connectorColors: theme.vars.palette.divider, + ...options?.plotOptions?.radar?.polygons, + }, + }, + + // plotOptions: polarArea + polarArea: { + rings: { + strokeColor: theme.vars.palette.divider, + }, + spokes: { + connectorColors: theme.vars.palette.divider, + }, + ...options?.plotOptions?.polarArea, + }, + + // plotOptions: heatmap + heatmap: { + distributed: true, + ...options?.plotOptions?.heatmap, + }, + }, + + /** ************************************** + * Responsive + *************************************** */ + responsive: RESPONSIVE.reduce((acc: typeof RESPONSIVE, cur) => { + if (!acc.some((item) => item.breakpoint === cur.breakpoint)) { + acc.push(cur); + } + return acc; + }, []), + }; +} diff --git a/dashboard/src/components/color-utils/color-picker.tsx b/dashboard/src/components/color-utils/color-picker.tsx new file mode 100644 index 00000000..de341107 --- /dev/null +++ b/dashboard/src/components/color-utils/color-picker.tsx @@ -0,0 +1,107 @@ +import type { BoxProps } from '@mui/material/Box'; + +import { forwardRef, useCallback } from 'react'; + +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import ButtonBase from '@mui/material/ButtonBase'; +import { alpha as hexAlpha } from '@mui/material/styles'; + +import { varAlpha } from 'src/theme/styles'; + +import { Iconify } from '../iconify'; + +import type { ColorPickerProps } from './types'; + +// ---------------------------------------------------------------------- + +export const ColorPicker = forwardRef( + ({ colors, selected, onSelectColor, limit = 'auto', sx, slotProps, ...other }, ref) => { + const singleSelect = typeof selected === 'string'; + + const handleSelect = useCallback( + (color: string) => { + if (singleSelect) { + if (color !== selected) { + onSelectColor(color); + } + } else { + const newSelected = selected.includes(color) ? selected.filter((value) => value !== color) : [...selected, color]; + + onSelectColor(newSelected); + } + }, + [onSelectColor, selected, singleSelect] + ); + + return ( + + {colors.map((color) => { + const hasSelected = singleSelect ? selected === color : selected.includes(color); + + return ( + + handleSelect(color)} + sx={{ + width: 36, + height: 36, + borderRadius: '50%', + ...slotProps?.button, + }} + > + `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.16)}`, + ...(hasSelected && { + transform: 'scale(1.3)', + boxShadow: `4px 4px 8px 0 ${hexAlpha(color, 0.48)}`, + outline: `solid 2px ${hexAlpha(color, 0.08)}`, + transition: (theme) => + theme.transitions.create('all', { + duration: theme.transitions.duration.shortest, + }), + }), + }} + > + theme.palette.getContrastText(color), + transition: (theme) => + theme.transitions.create('all', { + duration: theme.transitions.duration.shortest, + }), + }} + /> + + + + ); + })} + + ); + } +); diff --git a/dashboard/src/components/color-utils/color-preview.tsx b/dashboard/src/components/color-utils/color-preview.tsx new file mode 100644 index 00000000..85432bb9 --- /dev/null +++ b/dashboard/src/components/color-utils/color-preview.tsx @@ -0,0 +1,48 @@ +import type { BoxProps } from '@mui/material/Box'; + +import { forwardRef } from 'react'; + +import Box from '@mui/material/Box'; + +import { varAlpha } from 'src/theme/styles'; + +import type { ColorPreviewProps } from './types'; + +// ---------------------------------------------------------------------- + +export const ColorPreview = forwardRef(({ colors, limit = 3, sx, ...other }, ref) => { + const colorsRange = colors.slice(0, limit); + + const restColors = colors.length - limit; + + return ( + + {colorsRange.map((color, index) => ( + `solid 2px ${theme.vars.palette.background.paper}`, + boxShadow: (theme) => `inset -1px 1px 2px ${varAlpha(theme.vars.palette.common.blackChannel, 0.24)}`, + }} + /> + ))} + + {colors.length > limit && {`+${restColors}`}} + + ); +}); diff --git a/dashboard/src/components/color-utils/index.ts b/dashboard/src/components/color-utils/index.ts new file mode 100644 index 00000000..24877b48 --- /dev/null +++ b/dashboard/src/components/color-utils/index.ts @@ -0,0 +1,5 @@ +export type * from './types'; + +export * from './color-picker'; + +export * from './color-preview'; diff --git a/dashboard/src/components/color-utils/types.ts b/dashboard/src/components/color-utils/types.ts new file mode 100644 index 00000000..cf61ae25 --- /dev/null +++ b/dashboard/src/components/color-utils/types.ts @@ -0,0 +1,19 @@ +import type { Theme, SxProps } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +export type ColorPickerProps = { + multi?: boolean; + colors: string[]; + selected: string | string[]; + limit?: 'auto' | number; + onSelectColor: (color: string | string[]) => void; + slotProps?: { + button?: SxProps; + }; +}; + +export type ColorPreviewProps = { + limit?: number; + colors: ColorPickerProps['colors']; +}; diff --git a/dashboard/src/components/copy/copy-button.tsx b/dashboard/src/components/copy/copy-button.tsx new file mode 100644 index 00000000..7be7f402 --- /dev/null +++ b/dashboard/src/components/copy/copy-button.tsx @@ -0,0 +1,40 @@ +import { useCallback } from 'react'; + +import { Tooltip, IconButton } from '@mui/material'; + +import { useCopyToClipboard } from 'src/hooks/use-copy-to-clipboard'; + +import { useTranslate } from 'src/locales'; + +import { toast } from 'src/components/snackbar'; +import { Iconify } from 'src/components/iconify'; + +// ---------------------------------------------------------------------- + +type Props = { + value?: string; + title?: string; +}; + +export default function CopyButton({ value, title }: Props) { + const { t } = useTranslate(); + const { copy } = useCopyToClipboard(); + + const onCopy = useCallback( + (text?: string) => { + if (text) { + copy(text); + toast.success(t('copied_to_clipboard')); + } + }, + [copy, t] + ); + + return ( + + onCopy(value)}> + + + + ); +} diff --git a/dashboard/src/components/copy/copy-menu-item.tsx b/dashboard/src/components/copy/copy-menu-item.tsx new file mode 100644 index 00000000..a718a996 --- /dev/null +++ b/dashboard/src/components/copy/copy-menu-item.tsx @@ -0,0 +1,40 @@ +import { useCallback } from 'react'; + +import { MenuItem } from '@mui/material'; + +import { useCopyToClipboard } from 'src/hooks/use-copy-to-clipboard'; + +import { useTranslate } from 'src/locales'; + +import { toast } from 'src/components/snackbar'; + +import { Iconify } from '../iconify'; + +// ---------------------------------------------------------------------- + +interface Props { + value: string; + title?: string; +} + +export default function CopyMenuItem({ value, title }: Props) { + const { t } = useTranslate(); + const { copy } = useCopyToClipboard(); + + const onCopy = useCallback( + (text?: string) => { + if (text) { + copy(text); + toast.success(t('copied_to_clipboard')); + } + }, + [copy, t] + ); + + return ( + onCopy(value)}> + + {title || t('copy')} + + ); +} diff --git a/dashboard/src/components/copy/index.ts b/dashboard/src/components/copy/index.ts new file mode 100644 index 00000000..cd65e551 --- /dev/null +++ b/dashboard/src/components/copy/index.ts @@ -0,0 +1,2 @@ +export { default as CopyButton } from './copy-button'; +export { default as CopyMenuItem } from './copy-menu-item'; diff --git a/dashboard/src/components/country-select/country-select.tsx b/dashboard/src/components/country-select/country-select.tsx new file mode 100644 index 00000000..a183cf71 --- /dev/null +++ b/dashboard/src/components/country-select/country-select.tsx @@ -0,0 +1,133 @@ +import type { AutocompleteProps, AutocompleteRenderInputParams, AutocompleteRenderGetTagProps } from '@mui/material/Autocomplete'; + +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 { countries } from 'src/assets/data'; + +import { FlagIcon, iconifyClasses } from 'src/components/iconify'; + +import { getCountry, displayValueByCountryCode } from './utils'; + +// ---------------------------------------------------------------------- + +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; +}; + +export function CountrySelect({ + id, + label, + error, + multiple, + helperText, + hiddenLabel, + placeholder, + getValue = 'label', + ...other +}: CountrySelectProps) { + const options = countries.map((country) => (getValue === 'label' ? country.label : country.code)); + + const renderOption = (props: React.HTMLAttributes, option: Value) => { + const country = getCountry(option); + + if (!country.label) { + return null; + } + + return ( +
  • + + {country.label} ({country.code}) +{country.phone} +
  • + ); + }; + + const renderInput = (params: AutocompleteRenderInputParams) => { + const country = getCountry(params.inputProps.value as Value); + + const baseField = { + ...params, + label, + placeholder, + helperText, + hiddenLabel, + error: !!error, + inputProps: { + ...params.inputProps, + autoComplete: 'new-password', + }, + }; + + if (multiple) { + return ; + } + + return ( + + + + ), + }} + sx={{ + ...(!hiddenLabel && { + [`& .${filledInputClasses.root}`]: { [`& .${iconifyClasses.root}`]: { mt: -2 } }, + }), + }} + /> + ); + }; + + const renderTags = (selected: Value[], getTagProps: AutocompleteRenderGetTagProps) => + selected.map((option, index) => { + const country = getCountry(option); + + return ( + } + /> + ); + }); + + const getOptionLabel = (option: Value) => (getValue === 'label' ? option : displayValueByCountryCode(option)); + + return ( + + ); +} diff --git a/dashboard/src/components/country-select/index.ts b/dashboard/src/components/country-select/index.ts new file mode 100644 index 00000000..f1634a4f --- /dev/null +++ b/dashboard/src/components/country-select/index.ts @@ -0,0 +1,3 @@ +export * from './utils'; + +export * from './country-select'; diff --git a/dashboard/src/components/country-select/utils.ts b/dashboard/src/components/country-select/utils.ts new file mode 100644 index 00000000..d067ff95 --- /dev/null +++ b/dashboard/src/components/country-select/utils.ts @@ -0,0 +1,17 @@ +import { countries } from 'src/assets/data'; + +// ---------------------------------------------------------------------- + +export function getCountry(inputValue: string) { + const option = countries.filter((country) => country.label === inputValue || country.code === inputValue)[0]; + + return { code: option?.code, label: option?.label, phone: option?.phone }; +} + +// ---------------------------------------------------------------------- + +export function displayValueByCountryCode(inputValue: string) { + const option = countries.filter((country) => country.code === inputValue)[0]; + + return option.label; +} diff --git a/dashboard/src/components/custom-breadcrumbs/breadcrumb-link.tsx b/dashboard/src/components/custom-breadcrumbs/breadcrumb-link.tsx new file mode 100644 index 00000000..78e42c15 --- /dev/null +++ b/dashboard/src/components/custom-breadcrumbs/breadcrumb-link.tsx @@ -0,0 +1,56 @@ +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; + +import { RouterLink } from 'src/routes/components'; + +import type { BreadcrumbsLinkProps } from './types'; + +// ---------------------------------------------------------------------- + +type Props = { + disabled: boolean; + activeLast?: boolean; + link: BreadcrumbsLinkProps; +}; + +export function BreadcrumbsLink({ link, activeLast, disabled }: Props) { + const styles = { + typography: 'body2', + alignItems: 'center', + color: 'text.primary', + display: 'inline-flex', + ...(disabled && !activeLast && { cursor: 'default', pointerEvents: 'none', color: 'text.disabled' }), + }; + + const renderContent = ( + <> + {link.icon && ( + + {link.icon} + + )} + + {link.name} + + ); + + if (link.href) { + return ( + + {renderContent} + + ); + } + + return {renderContent} ; +} diff --git a/dashboard/src/components/custom-breadcrumbs/custom-breadcrumbs.tsx b/dashboard/src/components/custom-breadcrumbs/custom-breadcrumbs.tsx new file mode 100644 index 00000000..9d7b1592 --- /dev/null +++ b/dashboard/src/components/custom-breadcrumbs/custom-breadcrumbs.tsx @@ -0,0 +1,75 @@ +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import Breadcrumbs from '@mui/material/Breadcrumbs'; + +import { BreadcrumbsLink } from './breadcrumb-link'; + +import type { CustomBreadcrumbsProps } from './types'; + +// ---------------------------------------------------------------------- + +export function CustomBreadcrumbs({ links, action, heading, moreLink, icon, activeLast, slotProps, sx, ...other }: CustomBreadcrumbsProps) { + const lastLink = links[links.length - 1].name; + + const renderHeading = ( + + {heading} {icon} + + ); + + const renderLinks = ( + } sx={slotProps?.breadcrumbs} {...other}> + {links.map((link, index) => ( + + ))} + + ); + + const renderAction = {action} ; + + const renderMoreLink = ( + + {moreLink?.map((href) => ( + + + {href} + + + ))} + + ); + + return ( + + + + {heading && renderHeading} + + {!!links.length && renderLinks} + + + {action && renderAction} + + + {!!moreLink && renderMoreLink} + + ); +} + +// ---------------------------------------------------------------------- + +function Separator() { + return ( + + ); +} diff --git a/dashboard/src/components/custom-breadcrumbs/index.ts b/dashboard/src/components/custom-breadcrumbs/index.ts new file mode 100644 index 00000000..b3cb547d --- /dev/null +++ b/dashboard/src/components/custom-breadcrumbs/index.ts @@ -0,0 +1,3 @@ +export type * from './types'; + +export * from './custom-breadcrumbs'; diff --git a/dashboard/src/components/custom-breadcrumbs/types.ts b/dashboard/src/components/custom-breadcrumbs/types.ts new file mode 100644 index 00000000..b9988bb0 --- /dev/null +++ b/dashboard/src/components/custom-breadcrumbs/types.ts @@ -0,0 +1,26 @@ +import type { Theme, SxProps } from '@mui/material/styles'; +import type { BreadcrumbsProps } from '@mui/material/Breadcrumbs'; + +// ---------------------------------------------------------------------- + +export type BreadcrumbsLinkProps = { + name?: string; + href?: string; + icon?: React.ReactElement; +}; + +export type CustomBreadcrumbsProps = BreadcrumbsProps & { + heading?: string; + moreLink?: string[]; + activeLast?: boolean; + action?: React.ReactNode; + links: BreadcrumbsLinkProps[]; + icon?: React.ReactElement; + sx?: SxProps; + slotProps?: { + action: SxProps; + heading: SxProps; + moreLink: SxProps; + breadcrumbs: SxProps; + }; +}; diff --git a/dashboard/src/components/custom-date-range-picker/custom-date-range-picker.tsx b/dashboard/src/components/custom-date-range-picker/custom-date-range-picker.tsx new file mode 100644 index 00000000..541725a3 --- /dev/null +++ b/dashboard/src/components/custom-date-range-picker/custom-date-range-picker.tsx @@ -0,0 +1,87 @@ +import Paper from '@mui/material/Paper'; +import Stack from '@mui/material/Stack'; +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 FormHelperText from '@mui/material/FormHelperText'; +import { DatePicker } from '@mui/x-date-pickers/DatePicker'; +import { DateCalendar } from '@mui/x-date-pickers/DateCalendar'; + +import { useResponsive } from 'src/hooks/use-responsive'; + +import type { UseDateRangePickerReturn } from './types'; + +// ---------------------------------------------------------------------- + +export function CustomDateRangePicker({ + open, + error, + endDate, + onClose, + startDate, + onChangeEndDate, + variant = 'input', + onChangeStartDate, + title = 'Select date range', +}: UseDateRangePickerReturn) { + const mdUp = useResponsive('up', 'md'); + + const isCalendarView = variant === 'calendar'; + + return ( + + {title} + + + + {isCalendarView ? ( + <> + + + + + + + + + ) : ( + <> + + + + + )} + + + {error && ( + + End date must be later than start date + + )} + + + + + + + + + ); +} diff --git a/dashboard/src/components/custom-date-range-picker/index.ts b/dashboard/src/components/custom-date-range-picker/index.ts new file mode 100644 index 00000000..e659c9c7 --- /dev/null +++ b/dashboard/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/dashboard/src/components/custom-date-range-picker/types.ts b/dashboard/src/components/custom-date-range-picker/types.ts new file mode 100644 index 00000000..85c8c5d3 --- /dev/null +++ b/dashboard/src/components/custom-date-range-picker/types.ts @@ -0,0 +1,27 @@ +import type { IDatePickerControl } from 'src/types/common'; + +// ---------------------------------------------------------------------- + +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>; +}; diff --git a/dashboard/src/components/custom-date-range-picker/use-date-range-picker.ts b/dashboard/src/components/custom-date-range-picker/use-date-range-picker.ts new file mode 100644 index 00000000..97a37923 --- /dev/null +++ b/dashboard/src/components/custom-date-range-picker/use-date-range-picker.ts @@ -0,0 +1,67 @@ +import type { IDatePickerControl } from 'src/types/common'; + +import { useState, useCallback } from 'react'; + +import { fIsAfter, fDateRangeShortLabel } from 'src/utils/format-time'; + +import type { UseDateRangePickerReturn } from './types'; + +// ---------------------------------------------------------------------- + +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, + // + selected: !!startDate && !!endDate, + error, + // + label: fDateRangeShortLabel(startDate, endDate, true), + shortLabel: fDateRangeShortLabel(startDate, endDate), + // + setStartDate, + setEndDate, + }; +} diff --git a/dashboard/src/components/custom-dialog/confirm-dialog.tsx b/dashboard/src/components/custom-dialog/confirm-dialog.tsx new file mode 100644 index 00000000..a5f3e307 --- /dev/null +++ b/dashboard/src/components/custom-dialog/confirm-dialog.tsx @@ -0,0 +1,27 @@ +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({ title, content, action, open, onClose, ...other }: ConfirmDialogProps) { + return ( + + {title} + + {content && {content} } + + + {action} + + + + + ); +} diff --git a/dashboard/src/components/custom-dialog/index.ts b/dashboard/src/components/custom-dialog/index.ts new file mode 100644 index 00000000..ff0d9972 --- /dev/null +++ b/dashboard/src/components/custom-dialog/index.ts @@ -0,0 +1 @@ +export * from './confirm-dialog'; diff --git a/dashboard/src/components/custom-dialog/types.ts b/dashboard/src/components/custom-dialog/types.ts new file mode 100644 index 00000000..97a44136 --- /dev/null +++ b/dashboard/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/dashboard/src/components/custom-popover/custom-popover.tsx b/dashboard/src/components/custom-popover/custom-popover.tsx new file mode 100644 index 00000000..51b547f5 --- /dev/null +++ b/dashboard/src/components/custom-popover/custom-popover.tsx @@ -0,0 +1,52 @@ +import type { PaperProps } from '@mui/material/Paper'; + +import Popover from '@mui/material/Popover'; +import { listClasses } from '@mui/material/List'; +import { menuItemClasses } from '@mui/material/MenuItem'; + +import { StyledArrow } from './styles'; +import { calculateAnchorOrigin } from './utils'; + +import type { CustomPopoverProps } from './types'; + +// ---------------------------------------------------------------------- + +export function CustomPopover({ open, onClose, children, anchorEl, slotProps, ...other }: CustomPopoverProps) { + const arrowPlacement = slotProps?.arrow?.placement ?? 'top-right'; + + const arrowSize = slotProps?.arrow?.size ?? 14; + + const arrowOffset = slotProps?.arrow?.offset ?? 17; + + const { paperStyles, anchorOrigin, transformOrigin } = calculateAnchorOrigin(arrowPlacement); + + return ( + + {!slotProps?.arrow?.hide && ( + + )} + + {children} + + ); +} diff --git a/dashboard/src/components/custom-popover/index.ts b/dashboard/src/components/custom-popover/index.ts new file mode 100644 index 00000000..fa93d8e0 --- /dev/null +++ b/dashboard/src/components/custom-popover/index.ts @@ -0,0 +1,5 @@ +export type * from './types'; + +export * from './use-popover'; + +export * from './custom-popover'; diff --git a/dashboard/src/components/custom-popover/styles.tsx b/dashboard/src/components/custom-popover/styles.tsx new file mode 100644 index 00000000..da798044 --- /dev/null +++ b/dashboard/src/components/custom-popover/styles.tsx @@ -0,0 +1,115 @@ +import { styled } from '@mui/material/styles'; + +import { CONFIG } from 'src/config-global'; +import { varAlpha, stylesMode } from 'src/theme/styles'; + +import type { PopoverArrow } from './types'; + +// ---------------------------------------------------------------------- + +export const StyledArrow = styled('span', { + shouldForwardProp: (prop) => prop !== 'size' && prop !== 'placement' && prop !== 'offset', +})(({ placement, offset = 0, size = 0, theme }) => { + const POSITION = -(size / 2) + 0.5; + + const alignmentStyles = { + top: { top: POSITION, transform: 'rotate(135deg)' }, + bottom: { bottom: POSITION, transform: 'rotate(-45deg)' }, + left: { left: POSITION, transform: 'rotate(45deg)' }, + right: { right: POSITION, transform: 'rotate(-135deg)' }, + hCenter: { left: 0, right: 0, margin: 'auto' }, + vCenter: { top: 0, bottom: 0, margin: 'auto' }, + }; + + const backgroundStyles = (color: 'cyan' | 'red') => ({ + backgroundRepeat: 'no-repeat', + backgroundSize: `${size * 3}px ${size * 3}px`, + backgroundImage: `url(${CONFIG.site.basePath}/assets/${color}-blur.png)`, + ...(color === 'cyan' && { + backgroundPosition: 'top right', + }), + ...(color === 'red' && { + backgroundPosition: 'bottom left', + }), + }); + + 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)}`, + [stylesMode.dark]: { + border: `solid 1px ${varAlpha(theme.vars.palette.common.blackChannel, 0.12)}`, + }, + /** + * Top + */ + ...(placement === 'top-left' && { + ...alignmentStyles.top, + left: offset, + }), + ...(placement === 'top-center' && { + ...alignmentStyles.top, + ...alignmentStyles.hCenter, + }), + ...(placement === 'top-right' && { + ...backgroundStyles('cyan'), + ...alignmentStyles.top, + right: offset, + }), + /** + * Bottom + */ + ...(placement === 'bottom-left' && { + ...backgroundStyles('red'), + ...alignmentStyles.bottom, + left: offset, + }), + ...(placement === 'bottom-center' && { + ...alignmentStyles.bottom, + ...alignmentStyles.hCenter, + }), + ...(placement === 'bottom-right' && { + ...alignmentStyles.bottom, + right: offset, + }), + /** + * Left + */ + ...(placement === 'left-top' && { + ...alignmentStyles.left, + top: offset, + }), + ...(placement === 'left-center' && { + ...backgroundStyles('red'), + ...alignmentStyles.left, + ...alignmentStyles.vCenter, + }), + ...(placement === 'left-bottom' && { + ...backgroundStyles('red'), + ...alignmentStyles.left, + bottom: offset, + }), + /** + * Right + */ + ...(placement === 'right-top' && { + ...backgroundStyles('cyan'), + ...alignmentStyles.right, + top: offset, + }), + ...(placement === 'right-center' && { + ...backgroundStyles('cyan'), + ...alignmentStyles.right, + ...alignmentStyles.vCenter, + }), + ...(placement === 'right-bottom' && { + ...alignmentStyles.right, + bottom: offset, + }), + }; +}); diff --git a/dashboard/src/components/custom-popover/types.ts b/dashboard/src/components/custom-popover/types.ts new file mode 100644 index 00000000..e75487ce --- /dev/null +++ b/dashboard/src/components/custom-popover/types.ts @@ -0,0 +1,38 @@ +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 UsePopoverReturn = { + open: PopoverProps['open']; + anchorEl: PopoverProps['anchorEl']; + onClose: () => void; + onOpen: (event: React.MouseEvent) => void; + setAnchorEl: React.Dispatch>; +}; + +export type CustomPopoverProps = PopoverProps & { + slotProps?: PopoverProps['slotProps'] & { + arrow?: PopoverArrow; + }; +}; diff --git a/dashboard/src/components/custom-popover/use-popover.ts b/dashboard/src/components/custom-popover/use-popover.ts new file mode 100644 index 00000000..31de5f6b --- /dev/null +++ b/dashboard/src/components/custom-popover/use-popover.ts @@ -0,0 +1,27 @@ +import type { PopoverProps } from '@mui/material/Popover'; + +import { useState, useCallback } from 'react'; + +import type { UsePopoverReturn } from './types'; + +// ---------------------------------------------------------------------- + +export function usePopover(): UsePopoverReturn { + const [anchorEl, setAnchorEl] = useState(null); + + const onOpen = useCallback((event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }, []); + + const onClose = useCallback(() => { + setAnchorEl(null); + }, []); + + return { + open: !!anchorEl, + anchorEl, + onOpen, + onClose, + setAnchorEl, + }; +} diff --git a/dashboard/src/components/custom-popover/utils.ts b/dashboard/src/components/custom-popover/utils.ts new file mode 100644 index 00000000..28ca71e3 --- /dev/null +++ b/dashboard/src/components/custom-popover/utils.ts @@ -0,0 +1,127 @@ +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/dashboard/src/components/custom-tabs/custom-tabs.tsx b/dashboard/src/components/custom-tabs/custom-tabs.tsx new file mode 100644 index 00000000..98644c0f --- /dev/null +++ b/dashboard/src/components/custom-tabs/custom-tabs.tsx @@ -0,0 +1,87 @@ +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 { useTheme } from '@mui/material/styles'; +import Tabs, { tabsClasses } from '@mui/material/Tabs'; + +import { stylesMode } from 'src/theme/styles'; + +// ---------------------------------------------------------------------- + +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) { + const theme = useTheme(); + + return ( + span': { + width: 1, + height: 1, + borderRadius: 1, + display: 'block', + bgcolor: 'common.white', + boxShadow: theme.customShadows.z1, + [stylesMode.dark]: { bgcolor: 'grey.900' }, + ...slotProps?.indicator, + }, + }, + [`& .${tabClasses.root}`]: { + py: 1, + px: 2, + zIndex: 1, + minHeight: 'auto', + ...slotProps?.tab, + [`&.${tabClasses.selected}`]: { + ...slotProps?.selected, + }, + }, + ...sx, + }} + {...other} + TabIndicatorProps={{ + children: ( + + + + ), + }} + > + {children} + + ); +} diff --git a/dashboard/src/components/custom-tabs/index.ts b/dashboard/src/components/custom-tabs/index.ts new file mode 100644 index 00000000..3daee9aa --- /dev/null +++ b/dashboard/src/components/custom-tabs/index.ts @@ -0,0 +1 @@ +export * from './custom-tabs'; diff --git a/dashboard/src/components/delete/delete-button.tsx b/dashboard/src/components/delete/delete-button.tsx new file mode 100644 index 00000000..9bc77b12 --- /dev/null +++ b/dashboard/src/components/delete/delete-button.tsx @@ -0,0 +1,54 @@ +import { LoadingButton } from '@mui/lab'; +import Tooltip from '@mui/material/Tooltip'; +import IconButton from '@mui/material/IconButton'; + +import { useBoolean } from 'src/hooks/use-boolean'; + +import { useTranslate } from 'src/locales'; + +import { Iconify } from 'src/components/iconify'; +import { ConfirmDialog } from 'src/components/custom-dialog'; + +// ---------------------------------------------------------------------- + +type Props = { + id: string; + onDelete: (id: string) => Promise; +}; + +export function DeleteButton({ id, onDelete }: Props) { + const { t } = useTranslate(); + const confirm = useBoolean(); + const isDeleting = useBoolean(); + + return ( + <> + + + + + + + { + isDeleting.onTrue(); + await onDelete(id); + isDeleting.onFalse(); + }} + loading={isDeleting.value} + > + {t('delete')} + + } + /> + + ); +} diff --git a/dashboard/src/components/delete/index.ts b/dashboard/src/components/delete/index.ts new file mode 100644 index 00000000..b7ab2a9a --- /dev/null +++ b/dashboard/src/components/delete/index.ts @@ -0,0 +1 @@ +export * from './delete-button'; diff --git a/dashboard/src/components/empty-content/empty-content.tsx b/dashboard/src/components/empty-content/empty-content.tsx new file mode 100644 index 00000000..3f8b4ad7 --- /dev/null +++ b/dashboard/src/components/empty-content/empty-content.tsx @@ -0,0 +1,66 @@ +import type { StackProps } from '@mui/material/Stack'; +import type { Theme, SxProps } from '@mui/material/styles'; + +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; + +import { CONFIG } from 'src/config-global'; +import { varAlpha } from 'src/theme/styles'; + +// ---------------------------------------------------------------------- + +export type EmptyContentProps = StackProps & { + title?: string; + imgUrl?: string; + filled?: boolean; + description?: string; + action?: React.ReactNode; + slotProps?: { + img?: SxProps; + title?: SxProps; + description?: SxProps; + }; +}; + +export function EmptyContent({ sx, imgUrl, action, filled, slotProps, description, title = 'No data', ...other }: EmptyContentProps) { + return ( + varAlpha(theme.vars.palette.grey['500Channel'], 0.04), + border: (theme) => `dashed 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.08)}`, + }), + ...sx, + }} + {...other} + > + + + {title && ( + + {title} + + )} + + {description && ( + + {description} + + )} + + {action && action} + + ); +} diff --git a/dashboard/src/components/empty-content/index.ts b/dashboard/src/components/empty-content/index.ts new file mode 100644 index 00000000..026ff327 --- /dev/null +++ b/dashboard/src/components/empty-content/index.ts @@ -0,0 +1 @@ +export * from './empty-content'; diff --git a/dashboard/src/components/error/error-view.tsx b/dashboard/src/components/error/error-view.tsx new file mode 100644 index 00000000..568234df --- /dev/null +++ b/dashboard/src/components/error/error-view.tsx @@ -0,0 +1,26 @@ +import { Alert } from '@mui/material'; + +import { LoadingScreen } from '../loading-screen'; + +// ---------------------------------------------------------------------- + +type Props = { + errors?: any[]; + data?: (object | null | undefined)[]; + isLoading?: boolean[]; +}; + +export function ErrorView({ errors = [], data = [], isLoading = [] }: Props) { + if (isLoading.some((loading) => loading)) { + return ; + } + + const error = errors.find((err) => err !== null); + if (error) { + return Error while fetching data: {error.message}; + } + + if (data.some((d) => d == null)) { + return Failed to fetch data. Please contact your administrator; + } +} diff --git a/dashboard/src/components/error/index.ts b/dashboard/src/components/error/index.ts new file mode 100644 index 00000000..78edae89 --- /dev/null +++ b/dashboard/src/components/error/index.ts @@ -0,0 +1 @@ +export * from './error-view'; diff --git a/dashboard/src/components/file-thumbnail/action-buttons.tsx b/dashboard/src/components/file-thumbnail/action-buttons.tsx new file mode 100644 index 00000000..03bdefda --- /dev/null +++ b/dashboard/src/components/file-thumbnail/action-buttons.tsx @@ -0,0 +1,65 @@ +import type { ButtonBaseProps } from '@mui/material/ButtonBase'; +import type { IconButtonProps } from '@mui/material/IconButton'; + +import { useTheme } from '@mui/material/styles'; +import ButtonBase from '@mui/material/ButtonBase'; +import IconButton from '@mui/material/IconButton'; + +import { bgBlur, varAlpha } from 'src/theme/styles'; + +import { Iconify } from '../iconify'; + +// ---------------------------------------------------------------------- + +export function DownloadButton({ sx, ...other }: ButtonBaseProps) { + const theme = useTheme(); + + return ( + + + + ); +} + +// ---------------------------------------------------------------------- + +export function RemoveButton({ sx, ...other }: IconButtonProps) { + return ( + varAlpha(theme.vars.palette.grey['900Channel'], 0.48), + '&:hover': { bgcolor: (theme) => varAlpha(theme.vars.palette.grey['900Channel'], 0.72) }, + ...sx, + }} + {...other} + > + + + ); +} diff --git a/dashboard/src/components/file-thumbnail/classes.ts b/dashboard/src/components/file-thumbnail/classes.ts new file mode 100644 index 00000000..da284b4f --- /dev/null +++ b/dashboard/src/components/file-thumbnail/classes.ts @@ -0,0 +1,9 @@ +// ---------------------------------------------------------------------- + +export const fileThumbnailClasses = { + root: 'mnl__file__thumbnail__root', + img: 'mnl__file__thumbnail__img', + icon: 'mnl__file__thumbnail__icon', + removeBtn: 'mnl__file__thumbnail__remove__button', + downloadBtn: 'mnl__file__thumbnail__download__button', +}; diff --git a/dashboard/src/components/file-thumbnail/file-thumbnail.tsx b/dashboard/src/components/file-thumbnail/file-thumbnail.tsx new file mode 100644 index 00000000..9b5819d7 --- /dev/null +++ b/dashboard/src/components/file-thumbnail/file-thumbnail.tsx @@ -0,0 +1,73 @@ +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Tooltip from '@mui/material/Tooltip'; + +import { fileThumbnailClasses } from './classes'; +import { fileData, fileThumb, fileFormat } from './utils'; +import { RemoveButton, DownloadButton } from './action-buttons'; + +import type { FileThumbnailProps } from './types'; + +// ---------------------------------------------------------------------- + +export function FileThumbnail({ sx, file, tooltip, onRemove, imageView, slotProps, onDownload, ...other }: FileThumbnailProps) { + const previewUrl = typeof file === 'string' ? file : URL.createObjectURL(file); + + const { name, path } = fileData(file); + + const format = fileFormat(path || previewUrl); + + const renderImg = ( + + ); + + const renderIcon = ( + + ); + + const renderContent = ( + + {format === 'image' && imageView ? renderImg : renderIcon} + + {onRemove && } + + {onDownload && } + + ); + + if (tooltip) { + return ( + + {renderContent} + + ); + } + + return renderContent; +} diff --git a/dashboard/src/components/file-thumbnail/index.ts b/dashboard/src/components/file-thumbnail/index.ts new file mode 100644 index 00000000..833d600f --- /dev/null +++ b/dashboard/src/components/file-thumbnail/index.ts @@ -0,0 +1,7 @@ +export * from './utils'; + +export type * from './types'; + +export * from './action-buttons'; + +export * from './file-thumbnail'; diff --git a/dashboard/src/components/file-thumbnail/types.ts b/dashboard/src/components/file-thumbnail/types.ts new file mode 100644 index 00000000..45a986e2 --- /dev/null +++ b/dashboard/src/components/file-thumbnail/types.ts @@ -0,0 +1,25 @@ +import type { StackProps } from '@mui/material/Stack'; +import type { Theme, SxProps } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +export interface ExtendFile extends File { + path?: string; + preview?: string; + lastModifiedDate?: Date; +} + +export type FileThumbnailProps = StackProps & { + tooltip?: boolean; + file: File | string; + imageView?: boolean; + sx?: SxProps; + onDownload?: () => void; + onRemove?: () => void; + slotProps?: { + img?: SxProps; + icon?: SxProps; + removeBtn?: SxProps; + downloadBtn?: SxProps; + }; +}; diff --git a/dashboard/src/components/file-thumbnail/utils.ts b/dashboard/src/components/file-thumbnail/utils.ts new file mode 100644 index 00000000..6c6872e4 --- /dev/null +++ b/dashboard/src/components/file-thumbnail/utils.ts @@ -0,0 +1,156 @@ +import { CONFIG } from 'src/config-global'; + +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.site.basePath}/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/dashboard/src/components/filters-result/filters-block.tsx b/dashboard/src/components/filters-result/filters-block.tsx new file mode 100644 index 00000000..6cb2ae1e --- /dev/null +++ b/dashboard/src/components/filters-result/filters-block.tsx @@ -0,0 +1,47 @@ +import type { Theme, SxProps } from '@mui/material/styles'; + +import Box from '@mui/material/Box'; + +// ---------------------------------------------------------------------- + +export type FilterBlockProps = { + label: string; + isShow: boolean; + sx?: SxProps; + children: React.ReactNode; +}; + +export function FiltersBlock({ label, children, isShow, sx }: FilterBlockProps) { + if (!isShow) { + return null; + } + + return ( + `dashed 1px ${theme.vars.palette.divider}`, + ...sx, + }} + > + theme.typography.subtitle2.fontSize, + fontWeight: (theme) => theme.typography.subtitle2.fontWeight, + }} + > + {label} + + + {children} + + + ); +} diff --git a/dashboard/src/components/filters-result/filters-result.tsx b/dashboard/src/components/filters-result/filters-result.tsx new file mode 100644 index 00000000..2508ee42 --- /dev/null +++ b/dashboard/src/components/filters-result/filters-result.tsx @@ -0,0 +1,42 @@ +import type { ChipProps } from '@mui/material/Chip'; +import type { Theme, SxProps } from '@mui/material/styles'; + +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; + +import { Iconify } from 'src/components/iconify'; + +// ---------------------------------------------------------------------- + +export const chipProps: ChipProps = { + size: 'small', + variant: 'soft', +}; + +type FiltersResultProps = { + totalResults: number; + onReset: () => void; + sx?: SxProps; + children: React.ReactNode; +}; + +export function FiltersResult({ totalResults, onReset, sx, children }: FiltersResultProps) { + return ( + + + {totalResults} + + results found + + + + + {children} + + + + + ); +} diff --git a/dashboard/src/components/filters-result/index.ts b/dashboard/src/components/filters-result/index.ts new file mode 100644 index 00000000..edbcbbe6 --- /dev/null +++ b/dashboard/src/components/filters-result/index.ts @@ -0,0 +1,3 @@ +export * from './filters-block'; + +export * from './filters-result'; diff --git a/dashboard/src/components/hook-form/fields.tsx b/dashboard/src/components/hook-form/fields.tsx new file mode 100644 index 00000000..c86232a6 --- /dev/null +++ b/dashboard/src/components/hook-form/fields.tsx @@ -0,0 +1,33 @@ +import { RHFCode } from './rhf-code'; +import { RHFRating } from './rhf-rating'; +import { RHFSlider } from './rhf-slider'; +import { RHFTextField } from './rhf-text-field'; +import { RHFRadioGroup } from './rhf-radio-group'; +import { RHFPhoneInput } from './rhf-phone-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 { RHFDatePicker, RHFMobileDateTimePicker } from './rhf-date-picker'; + +// ---------------------------------------------------------------------- + +export const Field = { + Code: RHFCode, + Select: RHFSelect, + Switch: RHFSwitch, + Slider: RHFSlider, + Rating: RHFRating, + Text: RHFTextField, + Phone: RHFPhoneInput, + Checkbox: RHFCheckbox, + RadioGroup: RHFRadioGroup, + DatePicker: RHFDatePicker, + MultiSelect: RHFMultiSelect, + MultiSwitch: RHFMultiSwitch, + Autocomplete: RHFAutocomplete, + MultiCheckbox: RHFMultiCheckbox, + CountrySelect: RHFCountrySelect, + MobileDateTimePicker: RHFMobileDateTimePicker, +}; diff --git a/dashboard/src/components/hook-form/form-provider.tsx b/dashboard/src/components/hook-form/form-provider.tsx new file mode 100644 index 00000000..b91d4ee3 --- /dev/null +++ b/dashboard/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/dashboard/src/components/hook-form/index.ts b/dashboard/src/components/hook-form/index.ts new file mode 100644 index 00000000..57fd49b2 --- /dev/null +++ b/dashboard/src/components/hook-form/index.ts @@ -0,0 +1,31 @@ +export * from './fields'; + +export * from './rhf-code'; + +export * from './rhf-select'; + +export * from './rhf-rating'; + +export * from './rhf-switch'; + +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-autocomplete'; + +export * from './rhf-wallet-select'; + +export * from './rhf-country-select'; diff --git a/dashboard/src/components/hook-form/rhf-autocomplete.tsx b/dashboard/src/components/hook-form/rhf-autocomplete.tsx new file mode 100644 index 00000000..63efe98e --- /dev/null +++ b/dashboard/src/components/hook-form/rhf-autocomplete.tsx @@ -0,0 +1,68 @@ +import type { AutocompleteProps } from '@mui/material/Autocomplete'; + +import { Controller, useFormContext } from 'react-hook-form'; + +import TextField from '@mui/material/TextField'; +import { CircularProgress } from '@mui/material'; +import Autocomplete from '@mui/material/Autocomplete'; + +// ---------------------------------------------------------------------- + +export type AutocompleteBaseProps = Omit, 'renderInput'>; + +export type RHFAutocompleteProps = AutocompleteBaseProps & { + name: string; + label?: string; + placeholder?: string; + hiddenLabel?: boolean; + helperText?: React.ReactNode; + loading?: boolean; + selectedField?: string; +}; + +export function RHFAutocomplete({ + name, + label, + helperText, + hiddenLabel, + loading, + selectedField = 'id', + placeholder, + ...other +}: RHFAutocompleteProps) { + const { control, setValue } = useFormContext(); + + return ( + ( + setValue(name, newValue, { shouldValidate: true })} + renderInput={(params) => ( + + {loading ? : null} + {params.InputProps.endAdornment} + + ), + }} + /> + )} + {...other} + /> + )} + /> + ); +} diff --git a/dashboard/src/components/hook-form/rhf-checkbox.tsx b/dashboard/src/components/hook-form/rhf-checkbox.tsx new file mode 100644 index 00000000..5e7d1238 --- /dev/null +++ b/dashboard/src/components/hook-form/rhf-checkbox.tsx @@ -0,0 +1,137 @@ +import type { Theme, SxProps } from '@mui/material/styles'; +import type { CheckboxProps } from '@mui/material/Checkbox'; +import type { FormGroupProps } from '@mui/material/FormGroup'; +import type { FormLabelProps } from '@mui/material/FormLabel'; +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 FormHelperText from '@mui/material/FormHelperText'; +import FormControlLabel from '@mui/material/FormControlLabel'; + +// ---------------------------------------------------------------------- + +type RHFCheckboxProps = Omit & { + name: string; + helperText?: React.ReactNode; + slotProps?: { + wrap?: SxProps; + checkbox?: CheckboxProps; + formHelperText?: FormHelperTextProps; + }; +}; + +export function RHFCheckbox({ name, helperText, label, slotProps, ...other }: RHFCheckboxProps) { + const { control } = useFormContext(); + + const ariaLabel = `Checkbox ${name}`; + + return ( + ( + + + } + label={label} + {...other} + /> + + {(!!error || helperText) && ( + + {error ? error?.message : helperText} + + )} + + )} + /> + ); +} + +// ---------------------------------------------------------------------- + +type RHFMultiCheckboxProps = FormGroupProps & { + name: string; + label?: string; + helperText?: React.ReactNode; + slotProps?: { + wrap?: SxProps; + checkbox?: CheckboxProps; + formLabel?: FormLabelProps; + formHelperText?: FormHelperTextProps; + }; + options: { + label: string; + value: string; + }[]; +}; + +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]; + + const accessibility = (val: string) => val; + const ariaLabel = (val: string) => `Checkbox ${val}`; + + return ( + ( + + {label && ( + + {label} + + )} + + + {options.map((option) => ( + field.onChange(getSelected(field.value, option.value))} + name={accessibility(option.label)} + {...slotProps?.checkbox} + inputProps={{ + ...(!option.label && { 'aria-label': ariaLabel(option.label) }), + ...slotProps?.checkbox?.inputProps, + }} + /> + } + label={option.label} + /> + ))} + + + {(!!error || helperText) && ( + + {error ? error?.message : helperText} + + )} + + )} + /> + ); +} diff --git a/dashboard/src/components/hook-form/rhf-code.tsx b/dashboard/src/components/hook-form/rhf-code.tsx new file mode 100644 index 00000000..21631e3d --- /dev/null +++ b/dashboard/src/components/hook-form/rhf-code.tsx @@ -0,0 +1,34 @@ +import type { MuiOtpInputProps } from 'mui-one-time-password-input'; + +import { MuiOtpInput } from 'mui-one-time-password-input'; +import { Controller, useFormContext } from 'react-hook-form'; + +import FormHelperText from '@mui/material/FormHelperText'; + +// ---------------------------------------------------------------------- + +type RHFCodesProps = MuiOtpInputProps & { + name: string; +}; + +export function RHFCode({ name, ...other }: RHFCodesProps) { + const { control } = useFormContext(); + + return ( + ( +
    + + + {error && ( + + {error.message} + + )} +
    + )} + /> + ); +} diff --git a/dashboard/src/components/hook-form/rhf-country-select.tsx b/dashboard/src/components/hook-form/rhf-country-select.tsx new file mode 100644 index 00000000..c58ea22c --- /dev/null +++ b/dashboard/src/components/hook-form/rhf-country-select.tsx @@ -0,0 +1,34 @@ +import type { CountrySelectProps } from 'src/components/country-select'; + +import { Controller, useFormContext } from 'react-hook-form'; + +import { CountrySelect } from 'src/components/country-select'; + +// ---------------------------------------------------------------------- + +export function RHFCountrySelect({ + name, + helperText, + ...other +}: CountrySelectProps & { + name: string; +}) { + const { control, setValue } = useFormContext(); + + return ( + ( + setValue(name, newValue, { shouldValidate: true })} + error={!!error} + helperText={error?.message ?? helperText} + {...other} + /> + )} + /> + ); +} diff --git a/dashboard/src/components/hook-form/rhf-date-picker.tsx b/dashboard/src/components/hook-form/rhf-date-picker.tsx new file mode 100644 index 00000000..75ec5592 --- /dev/null +++ b/dashboard/src/components/hook-form/rhf-date-picker.tsx @@ -0,0 +1,82 @@ +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 { formatStr } 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={formatStr.split.date} + slotProps={{ + textField: { + fullWidth: true, + error: !!error, + helperText: error?.message ?? (slotProps?.textField as TextFieldProps)?.helperText, + ...slotProps?.textField, + }, + ...slotProps, + }} + {...other} + /> + )} + /> + ); +} + +// ---------------------------------------------------------------------- + +type RHFMobileDateTimePickerProps = MobileDateTimePickerProps & { + name: string; +}; + +export function RHFMobileDateTimePicker({ name, slotProps, ...other }: RHFMobileDateTimePickerProps) { + const { control } = useFormContext(); + + return ( + ( + field.onChange(dayjs(newValue).format())} + format={formatStr.split.dateTime} + slotProps={{ + textField: { + fullWidth: true, + error: !!error, + helperText: error?.message ?? (slotProps?.textField as TextFieldProps)?.helperText, + ...slotProps?.textField, + }, + ...slotProps, + }} + {...other} + /> + )} + /> + ); +} diff --git a/dashboard/src/components/hook-form/rhf-phone-input.tsx b/dashboard/src/components/hook-form/rhf-phone-input.tsx new file mode 100644 index 00000000..3844ca95 --- /dev/null +++ b/dashboard/src/components/hook-form/rhf-phone-input.tsx @@ -0,0 +1,33 @@ +import { Controller, useFormContext } from 'react-hook-form'; + +import { PhoneInput } from '../phone-input'; + +import type { PhoneInputProps } from '../phone-input'; + +// ---------------------------------------------------------------------- + +type Props = Omit & { + name: string; +}; + +export function RHFPhoneInput({ name, helperText, ...other }: Props) { + const { control, setValue } = useFormContext(); + + return ( + ( + setValue(name, newValue, { shouldValidate: true })} + error={!!error} + helperText={error ? error?.message : helperText} + {...other} + /> + )} + /> + ); +} diff --git a/dashboard/src/components/hook-form/rhf-radio-group.tsx b/dashboard/src/components/hook-form/rhf-radio-group.tsx new file mode 100644 index 00000000..f0044464 --- /dev/null +++ b/dashboard/src/components/hook-form/rhf-radio-group.tsx @@ -0,0 +1,85 @@ +import type { RadioProps } from '@mui/material/Radio'; +import type { Theme, SxProps } from '@mui/material/styles'; +import type { FormLabelProps } from '@mui/material/FormLabel'; +import type { RadioGroupProps } from '@mui/material/RadioGroup'; +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 FormHelperText from '@mui/material/FormHelperText'; +import FormControlLabel from '@mui/material/FormControlLabel'; + +// ---------------------------------------------------------------------- + +type Props = RadioGroupProps & { + name: string; + label?: string; + helperText?: React.ReactNode; + slotProps?: { + wrap?: SxProps; + radio: RadioProps; + formLabel: FormLabelProps; + formHelperText: FormHelperTextProps; + }; + options: { + label: string; + value: string; + }[]; +}; + +export function RHFRadioGroup({ name, label, options, helperText, slotProps, ...other }: Props) { + const { control } = useFormContext(); + + const labelledby = `${name}-radio-buttons-group-label`; + const ariaLabel = (val: string) => `Radio ${val}`; + + return ( + ( + + {label && ( + + {label} + + )} + + + {options.map((option) => ( + + } + label={option.label} + /> + ))} + + + {(!!error || helperText) && ( + + {error ? error?.message : helperText} + + )} + + )} + /> + ); +} diff --git a/dashboard/src/components/hook-form/rhf-rating.tsx b/dashboard/src/components/hook-form/rhf-rating.tsx new file mode 100644 index 00000000..e94f13e2 --- /dev/null +++ b/dashboard/src/components/hook-form/rhf-rating.tsx @@ -0,0 +1,48 @@ +import type { RatingProps } from '@mui/material/Rating'; +import type { Theme, SxProps } from '@mui/material/styles'; +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 FormHelperText from '@mui/material/FormHelperText'; + +// ---------------------------------------------------------------------- + +type Props = RatingProps & { + name: string; + helperText?: React.ReactNode; + slotProps?: { + wrap?: SxProps; + formHelperText?: FormHelperTextProps; + }; +}; + +export function RHFRating({ name, helperText, slotProps, ...other }: Props) { + const { control } = useFormContext(); + + return ( + ( + + { + field.onChange(Number(newValue)); + }} + {...other} + /> + + {(error?.message || helperText) && ( + + {error?.message ?? helperText} + + )} + + )} + /> + ); +} diff --git a/dashboard/src/components/hook-form/rhf-select.tsx b/dashboard/src/components/hook-form/rhf-select.tsx new file mode 100644 index 00000000..60de1d92 --- /dev/null +++ b/dashboard/src/components/hook-form/rhf-select.tsx @@ -0,0 +1,159 @@ +import type { ChipProps } from '@mui/material/Chip'; +import type { SelectProps } from '@mui/material/Select'; +import type { Theme, SxProps } from '@mui/material/styles'; +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 { 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 FormHelperText from '@mui/material/FormHelperText'; + +// ---------------------------------------------------------------------- + +type RHFSelectProps = TextFieldProps & { + name: string; + native?: boolean; + children: React.ReactNode; + slotProps?: { + paper?: SxProps; + }; +}; + +export function RHFSelect({ name, native, children, slotProps, helperText, inputProps, InputLabelProps, ...other }: RHFSelectProps) { + const { control } = useFormContext(); + + const labelId = `${name}-select-label`; + + 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; + formHelperText?: FormHelperTextProps; + }; +}; + +export function RHFMultiSelect({ + name, + chip, + label, + options, + checkbox, + placeholder, + slotProps, + helperText, + ...other +}: RHFMultiSelectProps) { + const { control } = useFormContext(); + + const labelId = `${name}-select-label`; + + return ( + ( + + {label && ( + + {label} + + )} + + + + {(!!error || helperText) && ( + + {error ? error?.message : helperText} + + )} + + )} + /> + ); +} diff --git a/dashboard/src/components/hook-form/rhf-slider.tsx b/dashboard/src/components/hook-form/rhf-slider.tsx new file mode 100644 index 00000000..4b56f21f --- /dev/null +++ b/dashboard/src/components/hook-form/rhf-slider.tsx @@ -0,0 +1,31 @@ +import type { SliderProps } from '@mui/material/Slider'; + +import { Controller, useFormContext } from 'react-hook-form'; + +import Slider from '@mui/material/Slider'; +import FormHelperText from '@mui/material/FormHelperText'; + +// ---------------------------------------------------------------------- + +type Props = SliderProps & { + name: string; + helperText?: React.ReactNode; +}; + +export function RHFSlider({ name, helperText, ...other }: Props) { + const { control } = useFormContext(); + + return ( + ( + <> + + + {(!!error || helperText) && {error ? error?.message : helperText}} + + )} + /> + ); +} diff --git a/dashboard/src/components/hook-form/rhf-switch.tsx b/dashboard/src/components/hook-form/rhf-switch.tsx new file mode 100644 index 00000000..b455aaed --- /dev/null +++ b/dashboard/src/components/hook-form/rhf-switch.tsx @@ -0,0 +1,137 @@ +import type { SwitchProps } from '@mui/material/Switch'; +import type { Theme, SxProps } from '@mui/material/styles'; +import type { FormGroupProps } from '@mui/material/FormGroup'; +import type { FormLabelProps } from '@mui/material/FormLabel'; +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 FormHelperText from '@mui/material/FormHelperText'; +import FormControlLabel from '@mui/material/FormControlLabel'; + +// ---------------------------------------------------------------------- + +export type RHFSwitchProps = Omit & { + name: string; + helperText?: React.ReactNode; + slotProps?: { + wrap?: SxProps; + switch: SwitchProps; + formHelperText?: FormHelperTextProps; + }; +}; + +export function RHFSwitch({ name, helperText, label, slotProps, ...other }: RHFSwitchProps) { + const { control } = useFormContext(); + + const ariaLabel = `Switch ${name}`; + + return ( + ( + + + } + label={label} + {...other} + /> + + {(!!error || helperText) && ( + + {error ? error?.message : helperText} + + )} + + )} + /> + ); +} + +// ---------------------------------------------------------------------- + +type RHFMultiSwitchProps = FormGroupProps & { + name: string; + label?: string; + helperText?: React.ReactNode; + options: { + label: string; + value: string; + }[]; + slotProps?: { + wrap?: SxProps; + switch: SwitchProps; + formLabel?: FormLabelProps; + formHelperText?: 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]; + + const accessibility = (val: string) => val; + const ariaLabel = (val: string) => `Switch ${val}`; + + return ( + ( + + {label && ( + + {label} + + )} + + + {options.map((option) => ( + field.onChange(getSelected(field.value, option.value))} + name={accessibility(option.label)} + {...slotProps?.switch} + inputProps={{ + ...(!option.label && { 'aria-label': ariaLabel(option.label) }), + ...slotProps?.switch?.inputProps, + }} + /> + } + label={option.label} + /> + ))} + + + {(!!error || helperText) && ( + + {error ? error?.message : helperText} + + )} + + )} + /> + ); +} diff --git a/dashboard/src/components/hook-form/rhf-text-field.tsx b/dashboard/src/components/hook-form/rhf-text-field.tsx new file mode 100644 index 00000000..ea5c7b4f --- /dev/null +++ b/dashboard/src/components/hook-form/rhf-text-field.tsx @@ -0,0 +1,43 @@ +import type { TextFieldProps } from '@mui/material/TextField'; + +import { Controller, useFormContext } from 'react-hook-form'; + +import TextField from '@mui/material/TextField'; + +// ---------------------------------------------------------------------- + +type Props = TextFieldProps & { + name: string; +}; + +export function RHFTextField({ name, helperText, type, ...other }: Props) { + const { control } = useFormContext(); + + return ( + ( + { + if (type === 'number') { + field.onChange(Number(event.target.value)); + } else { + field.onChange(event.target.value); + } + }} + error={!!error} + helperText={error?.message ?? helperText} + inputProps={{ + autoComplete: 'off', + }} + {...other} + /> + )} + /> + ); +} diff --git a/dashboard/src/components/hook-form/rhf-wallet-select.tsx b/dashboard/src/components/hook-form/rhf-wallet-select.tsx new file mode 100644 index 00000000..1518a946 --- /dev/null +++ b/dashboard/src/components/hook-form/rhf-wallet-select.tsx @@ -0,0 +1,50 @@ +import type { ListWalletsResponse } from 'src/lib/swissknife'; + +import { useState } from 'react'; + +import { useBoolean } from 'src/hooks/use-boolean'; + +import { truncateText } from 'src/utils/format-string'; + +import { listWallets } from 'src/lib/swissknife'; + +import { toast } from 'src/components/snackbar'; + +import { RHFAutocomplete } from './rhf-autocomplete'; + +// ---------------------------------------------------------------------- + +export function RHFWalletSelect() { + const [options, setOptions] = useState([]); + const loading = useBoolean(); + + const fetchOptions = async () => { + loading.onTrue(); + try { + const { error, data } = await listWallets(); + if (error) throw new Error(error.reason); + setOptions(data); + } catch (error) { + toast.error(error.message); + } finally { + loading.onFalse(); + } + }; + + return ( + option.ln_address == null)} + loading={loading.value} + getOptionLabel={(option) => `${option.user_id} (${option.id})`} + isOptionEqualToValue={(option, value) => option.id === value.id} + onOpen={fetchOptions} + renderOption={(props, option) => ( +
  • + {option.user_id} ({truncateText(option.id, 15)}) +
  • + )} + /> + ); +} diff --git a/dashboard/src/components/hook-form/schema-helper.ts b/dashboard/src/components/hook-form/schema-helper.ts new file mode 100644 index 00000000..a60def1e --- /dev/null +++ b/dashboard/src/components/hook-form/schema-helper.ts @@ -0,0 +1,126 @@ +import dayjs from 'dayjs'; +import { z as zod } from 'zod'; + +// ---------------------------------------------------------------------- + +// const isSsr = typeof window === 'undefined'; + +type InputProps = { + message?: { + required_error?: string; + invalid_type_error?: string; + }; + minFiles?: number; + isValidPhoneNumber?: (text: string) => boolean; +}; + +export const schemaHelper = { + /** + * Phone number + * defaultValue === null + */ + phoneNumber: (props?: InputProps) => + zod + .string() + .min(1, { message: props?.message?.required_error ?? 'Phone number is required!' }) + .refine((data) => props?.isValidPhoneNumber?.(data), { + message: props?.message?.invalid_type_error ?? 'Invalid phone number!', + }), + /** + * date + * defaultValue === null + */ + date: (props?: InputProps) => + 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_error ?? 'Date is required!', + }); + return null; + } + + if (!stringToDate.safeParse(date).success) { + ctx.addIssue({ + code: zod.ZodIssueCode.invalid_date, + message: props?.message?.invalid_type_error ?? 'Invalid Date!!', + }); + } + + return date; + }) + .pipe(zod.union([zod.number(), zod.string(), zod.date(), zod.null()])), + /** + * editor + * defaultValue === '' |

    + */ + editor: (props?: InputProps) => zod.string().min(8, { message: props?.message?.required_error ?? 'Editor is required!' }), + /** + * object + * defaultValue === null + */ + objectOrNull: (props?: InputProps) => + zod + .custom() + .refine((data) => data !== null, { + message: props?.message?.required_error ?? 'Field is required!', + }) + .refine((data) => data !== '', { + message: props?.message?.required_error ?? 'Field is required!', + }), + /** + * boolean + * defaultValue === false + */ + boolean: (props?: InputProps) => + zod.coerce.boolean().refine((bool) => bool === true, { + message: props?.message?.required_error ?? 'Switch is required!', + }), + /** + * file + * defaultValue === '' || null + */ + file: (props?: InputProps) => + 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?.required_error ?? 'File is required!', + }); + return null; + } + + return data; + }), + /** + * files + * defaultValue === [] + */ + files: (props?: InputProps) => + zod.array(zod.custom()).transform((data, ctx) => { + const minFiles = props?.minFiles ?? 2; + + if (!data.length) { + ctx.addIssue({ + code: zod.ZodIssueCode.custom, + message: props?.message?.required_error ?? '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/dashboard/src/components/iconify/classes.ts b/dashboard/src/components/iconify/classes.ts new file mode 100644 index 00000000..39aa6705 --- /dev/null +++ b/dashboard/src/components/iconify/classes.ts @@ -0,0 +1,3 @@ +export const iconifyClasses = { + root: 'mnl__icon__root', +}; diff --git a/dashboard/src/components/iconify/flag-icon.tsx b/dashboard/src/components/iconify/flag-icon.tsx new file mode 100644 index 00000000..2d5f8e5d --- /dev/null +++ b/dashboard/src/components/iconify/flag-icon.tsx @@ -0,0 +1,46 @@ +import type { Theme, SxProps } from '@mui/material/styles'; + +import { forwardRef } from 'react'; + +import Box from '@mui/material/Box'; +import NoSsr from '@mui/material/NoSsr'; + +import { CONFIG } from 'src/config-global'; + +// ---------------------------------------------------------------------- + +export type FlagIconProps = { + code?: string; + sx?: SxProps; +}; + +export const FlagIcon = forwardRef(({ code, sx, ...other }, ref) => { + const baseStyles = { + width: 26, + height: 20, + flexShrink: 0, + overflow: 'hidden', + borderRadius: '5px', + display: 'inline-flex', + bgcolor: 'background.neutral', + }; + + const renderFallback = ; + + if (!code) { + return null; + } + + return ( + + + + + + ); +}); diff --git a/dashboard/src/components/iconify/iconify.tsx b/dashboard/src/components/iconify/iconify.tsx new file mode 100644 index 00000000..a1ddaa62 --- /dev/null +++ b/dashboard/src/components/iconify/iconify.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { forwardRef } from 'react'; +import { Icon, disableCache } from '@iconify/react'; + +import Box from '@mui/material/Box'; +import NoSsr from '@mui/material/NoSsr'; + +import { iconifyClasses } from './classes'; + +import type { IconifyProps } from './types'; + +// ---------------------------------------------------------------------- + +export const Iconify = forwardRef(({ className, width = 20, sx, ...other }, ref) => { + const baseStyles = { + width, + height: width, + flexShrink: 0, + display: 'inline-flex', + }; + + const renderFallback = ( + + ); + + return ( + + + + ); +}); + +// https://iconify.design/docs/iconify-icon/disable-cache.html +disableCache('local'); diff --git a/dashboard/src/components/iconify/index.ts b/dashboard/src/components/iconify/index.ts new file mode 100644 index 00000000..a9d5646b --- /dev/null +++ b/dashboard/src/components/iconify/index.ts @@ -0,0 +1,9 @@ +export * from './classes'; + +export * from './iconify'; + +export * from './flag-icon'; + +export type * from './types'; + +export * from './social-icon'; diff --git a/dashboard/src/components/iconify/social-icon.tsx b/dashboard/src/components/iconify/social-icon.tsx new file mode 100644 index 00000000..55c5ad91 --- /dev/null +++ b/dashboard/src/components/iconify/social-icon.tsx @@ -0,0 +1,118 @@ +import type { Theme, SxProps } from '@mui/material/styles'; + +import { forwardRef } from 'react'; + +import SvgIcon from '@mui/material/SvgIcon'; + +// ---------------------------------------------------------------------- + +export type SocialIconProps = { + icon?: 'google' | 'twitter' | 'linkedin' | 'instagram' | 'facebook' | 'github' | string; + width?: number; + sx?: SxProps; +}; + +export const SocialIcon = forwardRef(({ icon, width = 20, sx, ...other }, ref) => { + const socialName = icon?.trim().toLowerCase(); + + return ( + + {socialName === 'google' && googleSVG} + {socialName === 'facebook' && facebookSVG} + {socialName === 'linkedin' && linkedinSVG} + {socialName === 'twitter' && twitterSVG} + {socialName === 'instagram' && instagramSVG} + {socialName === 'github' && githubSVG} + + ); +}); + +// ---------------------------------------------------------------------- + +const githubSVG = ( + +); + +const googleSVG = ( + <> + + + + + +); + +const facebookSVG = ( + +); + +const linkedinSVG = ( + +); + +const twitterSVG = ( + +); + +const instagramSVG = ( + <> + + + + + + + + + + + + + + + + + + + + +); diff --git a/dashboard/src/components/iconify/types.ts b/dashboard/src/components/iconify/types.ts new file mode 100644 index 00000000..53ea7529 --- /dev/null +++ b/dashboard/src/components/iconify/types.ts @@ -0,0 +1,6 @@ +import type { IconProps } from '@iconify/react'; +import type { BoxProps } from '@mui/material/Box'; + +// ---------------------------------------------------------------------- + +export type IconifyProps = BoxProps & IconProps; diff --git a/dashboard/src/components/image/classes.ts b/dashboard/src/components/image/classes.ts new file mode 100644 index 00000000..76a60598 --- /dev/null +++ b/dashboard/src/components/image/classes.ts @@ -0,0 +1,7 @@ +// ---------------------------------------------------------------------- + +export const imageClasses = { + root: 'mnl__image__root', + wrapper: 'mnl__image__wrapper', + overlay: 'mnl__image__overlay', +}; diff --git a/dashboard/src/components/image/image.tsx b/dashboard/src/components/image/image.tsx new file mode 100644 index 00000000..35abbf41 --- /dev/null +++ b/dashboard/src/components/image/image.tsx @@ -0,0 +1,104 @@ +import { forwardRef } from 'react'; +import { LazyLoadImage } from 'react-lazy-load-image-component'; + +import Box from '@mui/material/Box'; +import { styled } from '@mui/material/styles'; + +import { CONFIG } from 'src/config-global'; + +import { imageClasses } from './classes'; + +import type { ImageProps } from './types'; + +// ---------------------------------------------------------------------- + +const ImageWrapper = styled(Box)({ + overflow: 'hidden', + position: 'relative', + verticalAlign: 'bottom', + display: 'inline-block', + [`& .${imageClasses.wrapper}`]: { + width: '100%', + height: '100%', + verticalAlign: 'bottom', + backgroundSize: 'cover !important', + }, +}); + +const Overlay = styled('span')({ + top: 0, + left: 0, + zIndex: 1, + width: '100%', + height: '100%', + position: 'absolute', +}); + +// ---------------------------------------------------------------------- + +export const Image = forwardRef( + ( + { + ratio, + disabledEffect = false, + // + alt, + src, + delayTime, + threshold, + beforeLoad, + delayMethod, + placeholder, + wrapperProps, + scrollPosition, + effect = 'blur', + visibleByDefault, + wrapperClassName, + useIntersectionObserver, + // + slotProps, + sx, + ...other + }, + ref + ) => { + const content = ( + + ); + + return ( + + {slotProps?.overlay && } + + {content} + + ); + } +); diff --git a/dashboard/src/components/image/index.ts b/dashboard/src/components/image/index.ts new file mode 100644 index 00000000..7e7e67e8 --- /dev/null +++ b/dashboard/src/components/image/index.ts @@ -0,0 +1,5 @@ +export * from './image'; + +export * from './classes'; + +export type * from './types'; diff --git a/dashboard/src/components/image/styles.css b/dashboard/src/components/image/styles.css new file mode 100644 index 00000000..07362102 --- /dev/null +++ b/dashboard/src/components/image/styles.css @@ -0,0 +1 @@ +@import 'react-lazy-load-image-component/src/effects/blur.css'; diff --git a/dashboard/src/components/image/types.ts b/dashboard/src/components/image/types.ts new file mode 100644 index 00000000..6b20ef3d --- /dev/null +++ b/dashboard/src/components/image/types.ts @@ -0,0 +1,18 @@ +import type { BoxProps } from '@mui/material/Box'; +import type { Theme, SxProps } from '@mui/material/styles'; +import type { LazyLoadImageProps } from 'react-lazy-load-image-component'; + +// ---------------------------------------------------------------------- + +type BaseRatioType = '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 ImageRatioType = BaseRatioType | { [key: string]: string }; + +export type ImageProps = BoxProps & + LazyLoadImageProps & { + ratio?: ImageRatioType; + disabledEffect?: boolean; + slotProps?: { + overlay: SxProps; + }; + }; diff --git a/dashboard/src/components/image/utils.ts b/dashboard/src/components/image/utils.ts new file mode 100644 index 00000000..61abac45 --- /dev/null +++ b/dashboard/src/components/image/utils.ts @@ -0,0 +1,15 @@ +// ---------------------------------------------------------------------- + +export function getRatio(ratio = '1/1') { + return { + '4/3': 'calc(100% / 4 * 3)', + '3/4': 'calc(100% / 3 * 4)', + '6/4': 'calc(100% / 6 * 4)', + '4/6': 'calc(100% / 4 * 6)', + '16/9': 'calc(100% / 16 * 9)', + '9/16': 'calc(100% / 9 * 16)', + '21/9': 'calc(100% / 21 * 9)', + '9/21': 'calc(100% / 9 * 21)', + '1/1': '100%', + }[ratio]; +} diff --git a/dashboard/src/components/label/classes.ts b/dashboard/src/components/label/classes.ts new file mode 100644 index 00000000..59db7caa --- /dev/null +++ b/dashboard/src/components/label/classes.ts @@ -0,0 +1,3 @@ +// ---------------------------------------------------------------------- + +export const labelClasses = { root: 'mnl__label__root', icon: 'mnl__label__icon' }; diff --git a/dashboard/src/components/label/index.ts b/dashboard/src/components/label/index.ts new file mode 100644 index 00000000..71f3c7e6 --- /dev/null +++ b/dashboard/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/dashboard/src/components/label/label.tsx b/dashboard/src/components/label/label.tsx new file mode 100644 index 00000000..80c0060c --- /dev/null +++ b/dashboard/src/components/label/label.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { forwardRef } from 'react'; + +import Box from '@mui/material/Box'; +import { useTheme } from '@mui/material/styles'; + +import { StyledLabel } from './styles'; +import { labelClasses } from './classes'; + +import type { LabelProps } from './types'; + +// ---------------------------------------------------------------------- + +export const Label = forwardRef( + ({ children, color = 'default', variant = 'soft', startIcon, endIcon, sx, ...other }, ref) => { + const theme = useTheme(); + + const iconStyles = { + width: 16, + height: 16, + '& svg, img': { + width: 1, + height: 1, + objectFit: 'cover', + }, + }; + + return ( + + {startIcon && ( + + {startIcon} + + )} + + {children} + + {endIcon && ( + + {endIcon} + + )} + + ); + } +); diff --git a/dashboard/src/components/label/styles.ts b/dashboard/src/components/label/styles.ts new file mode 100644 index 00000000..bd73f590 --- /dev/null +++ b/dashboard/src/components/label/styles.ts @@ -0,0 +1,113 @@ +'use client'; + +import type { Theme } from '@mui/material/styles'; + +import Box from '@mui/material/Box'; +import { styled } from '@mui/material/styles'; + +import { varAlpha, stylesMode } from 'src/theme/styles'; + +import type { LabelColor, LabelVariant } from './types'; + +// ---------------------------------------------------------------------- + +export const StyledLabel = styled(Box)(({ + theme, + ownerState: { color, variant }, +}: { + theme: Theme; + ownerState: { + color: LabelColor; + variant: LabelVariant; + }; +}) => { + const defaultColor = { + ...(color === 'default' && { + /** + * @variant filled + */ + ...(variant === 'filled' && { + color: theme.vars.palette.common.white, + backgroundColor: theme.vars.palette.text.primary, + [stylesMode.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 styleColors = { + ...(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), + [stylesMode.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', + 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, + }), + ...defaultColor, + ...styleColors, + }; +}); diff --git a/dashboard/src/components/label/types.ts b/dashboard/src/components/label/types.ts new file mode 100644 index 00000000..bb022c06 --- /dev/null +++ b/dashboard/src/components/label/types.ts @@ -0,0 +1,14 @@ +import type { BoxProps } from '@mui/material/Box'; + +// ---------------------------------------------------------------------- + +export type LabelColor = 'default' | 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'; + +export type LabelVariant = 'filled' | 'outlined' | 'soft' | 'inverted'; + +export interface LabelProps extends BoxProps { + color?: LabelColor; + variant?: LabelVariant; + endIcon?: React.ReactElement | null; + startIcon?: React.ReactElement | null; +} diff --git a/dashboard/src/components/ln-address/index.ts b/dashboard/src/components/ln-address/index.ts new file mode 100644 index 00000000..1953aa85 --- /dev/null +++ b/dashboard/src/components/ln-address/index.ts @@ -0,0 +1,2 @@ +export * from './register-ln-address-form'; +export * from './register-ln-address-dialog'; diff --git a/dashboard/src/components/ln-address/register-ln-address-dialog.tsx b/dashboard/src/components/ln-address/register-ln-address-dialog.tsx new file mode 100644 index 00000000..1fe72901 --- /dev/null +++ b/dashboard/src/components/ln-address/register-ln-address-dialog.tsx @@ -0,0 +1,38 @@ +import type { DialogProps } from '@mui/material/Dialog'; + +import Box from '@mui/material/Box'; +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 { useTranslate } from 'src/locales'; + +import { RegisterLnAddressForm } from './register-ln-address-form'; + +// ---------------------------------------------------------------------- + +type Props = DialogProps & { + onClose: VoidFunction; + title?: string; + onSuccess: VoidFunction; + isAdmin?: boolean; +}; + +export function RegisterLnAddressDialog({ title, isAdmin, open, onClose, onSuccess }: Props) { + const { t } = useTranslate(); + + return ( + + {title || 'Register Lightning Address'} + + + + + + + + + + ); +} diff --git a/dashboard/src/components/ln-address/register-ln-address-form.tsx b/dashboard/src/components/ln-address/register-ln-address-form.tsx new file mode 100644 index 00000000..31ac114d --- /dev/null +++ b/dashboard/src/components/ln-address/register-ln-address-form.tsx @@ -0,0 +1,101 @@ +import type { RegisterLnAddressRequest } from 'src/lib/swissknife'; + +import { useForm } from 'react-hook-form'; +import { ajvResolver } from '@hookform/resolvers/ajv'; + +import { Stack } from '@mui/material'; +import { LoadingButton } from '@mui/lab'; +import InputAdornment from '@mui/material/InputAdornment'; + +import { ajvOptions } from 'src/utils/ajv'; + +import { CONFIG } from 'src/config-global'; +import { useTranslate } from 'src/locales'; +import { registerAddress, registerWalletAddress, RegisterLnAddressRequestSchema } from 'src/lib/swissknife'; + +import { toast } from 'src/components/snackbar'; +import { Form, RHFTextField, RHFWalletSelect } from 'src/components/hook-form'; + +// ---------------------------------------------------------------------- + +type Props = { + onSuccess: VoidFunction; + isAdmin?: boolean; +}; + +// @ts-ignore +const resolver = ajvResolver(RegisterLnAddressRequestSchema, ajvOptions); + +export function RegisterLnAddressForm({ onSuccess, isAdmin }: Props) { + const { t } = useTranslate(); + + const methods = useForm({ + resolver, + defaultValues: { + username: '', + wallet: null, + }, + }); + + const { + reset, + handleSubmit, + formState: { isSubmitting }, + watch, + } = methods; + + const username = watch('username'); + const wallet = watch('wallet'); + + const onSubmit = async (body: any) => { + const submissionData: RegisterLnAddressRequest = { + ...body, + wallet_id: body.wallet?.id, + }; + + try { + if (isAdmin) { + await registerAddress({ body: submissionData }); + } else { + await registerWalletAddress({ body: submissionData }); + } + toast.success(t('register_ln_address.success_lightning_address_registration')); + reset(); + onSuccess(); + } catch (error) { + toast.error(error.reason); + } + }; + + return ( +
    + + { + const value = e.target.value.toLowerCase(); + methods.setValue('username', value, { shouldValidate: true }); + }} + InputProps={{ + endAdornment: @{CONFIG.site.domain}, + }} + /> + + {isAdmin && } + + + {t('register')} + + +
    + ); +} diff --git a/dashboard/src/components/loading-screen/index.ts b/dashboard/src/components/loading-screen/index.ts new file mode 100644 index 00000000..0744cffe --- /dev/null +++ b/dashboard/src/components/loading-screen/index.ts @@ -0,0 +1,3 @@ +export * from './splash-screen'; + +export * from './loading-screen'; diff --git a/dashboard/src/components/loading-screen/loading-screen.tsx b/dashboard/src/components/loading-screen/loading-screen.tsx new file mode 100644 index 00000000..d3233aef --- /dev/null +++ b/dashboard/src/components/loading-screen/loading-screen.tsx @@ -0,0 +1,39 @@ +'use client'; + +import type { BoxProps } from '@mui/material/Box'; + +import Box from '@mui/material/Box'; +import Portal from '@mui/material/Portal'; +import LinearProgress from '@mui/material/LinearProgress'; + +// ---------------------------------------------------------------------- + +type Props = BoxProps & { + portal?: boolean; +}; + +export function LoadingScreen({ portal, sx, ...other }: Props) { + const content = ( + + + + ); + + if (portal) { + return {content}; + } + + return content; +} diff --git a/dashboard/src/components/loading-screen/splash-screen.tsx b/dashboard/src/components/loading-screen/splash-screen.tsx new file mode 100644 index 00000000..32723cc6 --- /dev/null +++ b/dashboard/src/components/loading-screen/splash-screen.tsx @@ -0,0 +1,45 @@ +'use client'; + +import type { BoxProps } from '@mui/material/Box'; + +import Box from '@mui/material/Box'; +import Portal from '@mui/material/Portal'; + +import { AnimateLogo1 } from 'src/components/animate'; + +// ---------------------------------------------------------------------- + +type Props = BoxProps & { + portal?: boolean; +}; + +export function SplashScreen({ portal = true, sx, ...other }: Props) { + const content = ( + + + + + + ); + + if (portal) { + return {content}; + } + + return content; +} diff --git a/dashboard/src/components/logo/classes.ts b/dashboard/src/components/logo/classes.ts new file mode 100644 index 00000000..7b7fdc2f --- /dev/null +++ b/dashboard/src/components/logo/classes.ts @@ -0,0 +1,3 @@ +export const logoClasses = { + root: 'mnl__logo__root', +}; diff --git a/dashboard/src/components/logo/index.ts b/dashboard/src/components/logo/index.ts new file mode 100644 index 00000000..b86e7ad3 --- /dev/null +++ b/dashboard/src/components/logo/index.ts @@ -0,0 +1,3 @@ +export * from './logo'; + +export * from './classes'; diff --git a/dashboard/src/components/logo/logo.tsx b/dashboard/src/components/logo/logo.tsx new file mode 100644 index 00000000..469d3c3d --- /dev/null +++ b/dashboard/src/components/logo/logo.tsx @@ -0,0 +1,79 @@ +'use client'; + +import type { BoxProps } from '@mui/material/Box'; + +import { forwardRef } from 'react'; + +import Box from '@mui/material/Box'; +import NoSsr from '@mui/material/NoSsr'; +import { useTheme } from '@mui/material/styles'; + +import { RouterLink } from 'src/routes/components'; + +import { CONFIG } from 'src/config-global'; + +import { logoClasses } from './classes'; + +// ---------------------------------------------------------------------- + +export type LogoProps = BoxProps & { + href?: string; + disableLink?: boolean; + type?: 'single' | 'full' | 'font'; +}; + +export const Logo = forwardRef( + ({ width = 40, height = 40, disableLink = false, className, href = '/', type = 'single', sx, ...other }, ref) => { + const theme = useTheme(); + + let filename = 'logo_single'; + if (type === 'full') { + filename = 'logo'; + } else if (type === 'font') { + filename = 'logo_font'; + } + + const logo = ( + + ); + + return ( + + } + > + + {logo} + + + ); + } +); diff --git a/dashboard/src/components/markdown/classes.ts b/dashboard/src/components/markdown/classes.ts new file mode 100644 index 00000000..0739ea7d --- /dev/null +++ b/dashboard/src/components/markdown/classes.ts @@ -0,0 +1,12 @@ +// ---------------------------------------------------------------------- + +export const markdownClasses = { + root: 'nml__markdown__root', + content: { + pre: 'nml__editor__content__pre', + codeInline: 'nml__editor__content__codeInline', + codeBlock: 'nml__editor__content__codeBlock', + image: 'nml__editor__content__image', + link: 'nml__editor__content__link', + }, +}; diff --git a/dashboard/src/components/markdown/code-highlight-block.css b/dashboard/src/components/markdown/code-highlight-block.css new file mode 100644 index 00000000..8d8cf379 --- /dev/null +++ b/dashboard/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/dashboard/src/components/markdown/html-tags.ts b/dashboard/src/components/markdown/html-tags.ts new file mode 100644 index 00000000..44c86bcb --- /dev/null +++ b/dashboard/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/dashboard/src/components/markdown/html-to-markdown.ts b/dashboard/src/components/markdown/html-to-markdown.ts new file mode 100644 index 00000000..17fe0a87 --- /dev/null +++ b/dashboard/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/dashboard/src/components/markdown/index.ts b/dashboard/src/components/markdown/index.ts new file mode 100644 index 00000000..12b07fd5 --- /dev/null +++ b/dashboard/src/components/markdown/index.ts @@ -0,0 +1,3 @@ +export * from './markdown'; + +export type * from './types'; diff --git a/dashboard/src/components/markdown/markdown.tsx b/dashboard/src/components/markdown/markdown.tsx new file mode 100644 index 00000000..be0c301f --- /dev/null +++ b/dashboard/src/components/markdown/markdown.tsx @@ -0,0 +1,87 @@ +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 Link from '@mui/material/Link'; + +import { isExternalLink } from 'src/routes/utils'; +import { RouterLink } from 'src/routes/components'; + +import { Image } from '../image'; +import { StyledRoot } from './styles'; +import { markdownClasses } from './classes'; +import { htmlToMarkdown, isMarkdownContent } from './html-to-markdown'; + +import type { MarkdownProps } from './types'; + +// ---------------------------------------------------------------------- + +export function Markdown({ children, sx, ...other }: MarkdownProps) { + const content = useMemo(() => { + if (isMarkdownContent(`${children}`)) { + return children; + } + return htmlToMarkdown(`${children}`.trim()); + }, [children]); + + return ( + value} + */ + className={markdownClasses.root} + sx={sx} + {...other} + /> + ); +} + +// ---------------------------------------------------------------------- + +type ComponentTag = { + [key: string]: any; +}; + +const rehypePlugins = [rehypeRaw, rehypeHighlight, [remarkGfm, { singleTilde: false }]]; + +const components = { + img: ({ node, ...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/dashboard/src/components/markdown/styles.ts b/dashboard/src/components/markdown/styles.ts new file mode 100644 index 00000000..7775f65d --- /dev/null +++ b/dashboard/src/components/markdown/styles.ts @@ -0,0 +1,165 @@ +import ReactMarkdown from 'react-markdown'; + +import { styled } from '@mui/material/styles'; + +import { varAlpha, stylesMode } from 'src/theme/styles'; + +import { markdownClasses } from './classes'; + +// ---------------------------------------------------------------------- + +const MARGIN = '0.75em'; + +export const StyledRoot = 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, + backgroundColor: theme.vars.palette.grey[900], + fontFamily: "'JetBrainsMono', monospace", + '& code': { fontSize: theme.typography.body2.fontSize }, + }, + }, + /** + * 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], + [stylesMode.dark]: { backgroundColor: theme.vars.palette.grey[700] }, + }, + '&:checked': { + '&:before': { backgroundColor: theme.vars.palette.primary.main }, + '&:after': { + content: '""', + top: 1, + left: 5, + width: 4, + height: 9, + position: 'absolute', + transform: 'rotate(45deg)', + msTransform: 'rotate(45deg)', + WebkitTransform: 'rotate(45deg)', + border: `solid ${theme.vars.palette.common.white}`, + borderWidth: '0 2px 2px 0', + }, + }, + }, + }, +})); diff --git a/dashboard/src/components/markdown/types.ts b/dashboard/src/components/markdown/types.ts new file mode 100644 index 00000000..1ab8d839 --- /dev/null +++ b/dashboard/src/components/markdown/types.ts @@ -0,0 +1,9 @@ +import type { Options } from 'react-markdown'; +import type { Theme, SxProps } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +export interface MarkdownProps extends Options { + asHtml?: boolean; + sx?: SxProps; +} diff --git a/dashboard/src/components/nav-basic/classes.ts b/dashboard/src/components/nav-basic/classes.ts new file mode 100644 index 00000000..5287e623 --- /dev/null +++ b/dashboard/src/components/nav-basic/classes.ts @@ -0,0 +1,10 @@ +// ---------------------------------------------------------------------- + +export const navBasicClasses = { + desktop: { + root: 'nav__basic__desktop', + }, + mobile: { + root: 'nav__basic__mobile', + }, +}; diff --git a/dashboard/src/components/nav-basic/css-vars.ts b/dashboard/src/components/nav-basic/css-vars.ts new file mode 100644 index 00000000..700941b4 --- /dev/null +++ b/dashboard/src/components/nav-basic/css-vars.ts @@ -0,0 +1,81 @@ +import type { Theme } from '@mui/material/styles'; + +import { varAlpha } from 'src/theme/styles'; + +// ---------------------------------------------------------------------- + +function desktopVars(theme: Theme) { + const { + shape, + spacing, + vars: { palette }, + } = theme; + + return { + '--nav-item-gap': spacing(3), + '--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': spacing(0.75, 1, 0.75, 1), + '--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': spacing(0, 1, 0, 0), + }; +} + +// ---------------------------------------------------------------------- + +function mobileVars(theme: Theme) { + const { + shape, + spacing, + vars: { palette }, + } = theme; + + return { + '--nav-item-gap': spacing(0.5), + '--nav-item-radius': `${shape.borderRadius}px`, + '--nav-item-pt': spacing(0.5), + '--nav-item-pl': spacing(1.5), + '--nav-item-pr': spacing(1), + '--nav-item-pb': spacing(0.5), + '--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': spacing(0, 2, 0, 0), + }; +} + +// ---------------------------------------------------------------------- + +export const navBasicCssVars = { + desktop: desktopVars, + mobile: mobileVars, +}; diff --git a/dashboard/src/components/nav-basic/desktop/index.ts b/dashboard/src/components/nav-basic/desktop/index.ts new file mode 100644 index 00000000..ad390e35 --- /dev/null +++ b/dashboard/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/dashboard/src/components/nav-basic/desktop/nav-basic-desktop.tsx b/dashboard/src/components/nav-basic/desktop/nav-basic-desktop.tsx new file mode 100644 index 00000000..6bacde48 --- /dev/null +++ b/dashboard/src/components/nav-basic/desktop/nav-basic-desktop.tsx @@ -0,0 +1,38 @@ +import Stack from '@mui/material/Stack'; +import { useTheme } from '@mui/material/styles'; + +import { NavList } from './nav-list'; +import { NavUl } from '../../nav-section'; +import { navBasicClasses } from '../classes'; +import { navBasicCssVars } from '../css-vars'; + +import type { NavBasicProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function NavBasicDesktop({ sx, data, render, slotProps, enabledRootRedirect, cssVars: overridesVars, ...other }: NavBasicProps) { + const theme = useTheme(); + + const cssVars = { + ...navBasicCssVars.desktop(theme), + ...overridesVars, + }; + + return ( + + + {data.map((list) => ( + + ))} + + + ); +} diff --git a/dashboard/src/components/nav-basic/desktop/nav-item.tsx b/dashboard/src/components/nav-basic/desktop/nav-item.tsx new file mode 100644 index 00000000..689d34b6 --- /dev/null +++ b/dashboard/src/components/nav-basic/desktop/nav-item.tsx @@ -0,0 +1,207 @@ +import { forwardRef } from 'react'; + +import Box from '@mui/material/Box'; +import { styled } from '@mui/material/styles'; +import ButtonBase from '@mui/material/ButtonBase'; + +import { Iconify } from '../../iconify'; +import { useNavItem, stateClasses, sharedStyles, navSectionClasses } from '../../nav-section'; + +import type { NavItemProps, NavItemStateProps } from '../types'; + +// ---------------------------------------------------------------------- + +export const NavItem = forwardRef( + ( + { + path, + icon, + info, + title, + caption, + // + open, + depth, + render, + active, + disabled, + hasChild, + slotProps, + externalLink, + enabledRootRedirect, + ...other + }, + ref + ) => { + const navItem = useNavItem({ + path, + icon, + info, + depth, + render, + hasChild, + externalLink, + enabledRootRedirect, + }); + + return ( + + {icon && ( + + {navItem.renderIcon} + + )} + + {title && ( + + + {title} + + {caption && navItem.subItem && ( + + {caption} + + )} + + )} + + {info && ( + + {navItem.renderInfo} + + )} + + {hasChild && ( + + )} + + ); + } +); + +// ---------------------------------------------------------------------- + +const StyledNavItem = styled(ButtonBase, { + shouldForwardProp: (prop) => prop !== 'active' && prop !== 'open' && prop !== 'disabled' && prop !== 'depth', +})(({ active, open, disabled, depth, theme }) => { + const rootItem = depth === 1; + + const subItem = depth !== 1; + + const baseStyles = { + item: {}, + icon: { + ...sharedStyles.icon, + width: 'var(--nav-icon-size)', + height: 'var(--nav-icon-size)', + margin: 'var(--nav-icon-margin)', + }, + texts: { + display: 'flex', + flex: '1 1 auto', + flexDirection: 'column', + }, + title: { + ...theme.typography.body2, + fontWeight: active ? theme.typography.fontWeightSemiBold : theme.typography.fontWeightMedium, + }, + caption: { + ...theme.typography.caption, + color: 'var(--nav-item-caption-color)', + }, + arrow: { + ...sharedStyles.arrow, + }, + info: { + ...sharedStyles.info, + }, + } as const; + + return { + /** + * Root item + */ + ...(rootItem && { + ...baseStyles.item, + 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 }, + [`& .${navSectionClasses.item.icon}`]: { ...baseStyles.icon }, + [`& .${navSectionClasses.item.texts}`]: { ...baseStyles.texts }, + [`& .${navSectionClasses.item.title}`]: { ...baseStyles.title }, + [`& .${navSectionClasses.item.arrow}`]: { ...baseStyles.arrow }, + [`& .${navSectionClasses.item.info}`]: { ...baseStyles.info }, + // State + ...(active && { + color: 'var(--nav-item-root-active-color)', + }), + ...(open && { + opacity: 0.64, + }), + }), + + /** + * Sub item + */ + ...(subItem && { + ...baseStyles.item, + fontSize: theme.typography.pxToRem(13), + borderRadius: 'var(--nav-item-sub-radius)', + padding: 'var(--nav-item-sub-padding)', + '&:hover': { + color: 'var(--nav-item-sub-hover-color)', + backgroundColor: 'var(--nav-item-sub-hover-bg)', + }, + color: 'var(--nav-item-sub-color)', + [`& .${navSectionClasses.item.icon}`]: { ...baseStyles.icon }, + [`& .${navSectionClasses.item.texts}`]: { ...baseStyles.texts }, + [`& .${navSectionClasses.item.title}`]: { ...baseStyles.title }, + [`& .${navSectionClasses.item.caption}`]: { ...baseStyles.caption }, + [`& .${navSectionClasses.item.arrow}`]: { + ...baseStyles.arrow, + marginRight: theme.spacing(-0.5), + }, + [`& .${navSectionClasses.item.info}`]: { ...baseStyles.info }, + // State + ...(active && { + color: 'var(--nav-item-sub-active-color)', + backgroundColor: 'var(--nav-item-sub-active-bg)', + }), + ...(open && { + color: 'var(--nav-item-sub-open-color)', + backgroundColor: 'var(--nav-item-sub-open-bg)', + }), + }), + + /** + * Disabled + */ + ...(disabled && sharedStyles.disabled), + }; +}); diff --git a/dashboard/src/components/nav-basic/desktop/nav-list.tsx b/dashboard/src/components/nav-basic/desktop/nav-list.tsx new file mode 100644 index 00000000..6fa4f137 --- /dev/null +++ b/dashboard/src/components/nav-basic/desktop/nav-list.tsx @@ -0,0 +1,138 @@ +import { useRef, useState, useEffect, useCallback } from 'react'; + +import Paper from '@mui/material/Paper'; +import Popover from '@mui/material/Popover'; +import { useTheme } from '@mui/material/styles'; + +import { usePathname } from 'src/routes/hooks'; +import { isExternalLink } from 'src/routes/utils'; +import { useActiveLink } from 'src/routes/hooks/use-active-link'; + +import { paper } from 'src/theme/styles'; + +import { NavItem } from './nav-item'; +import { NavLi, NavUl, navSectionClasses } from '../../nav-section'; + +import type { NavListProps, NavSubListProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function NavList({ data, depth, render, cssVars, slotProps, enabledRootRedirect }: NavListProps) { + const theme = useTheme(); + + const pathname = usePathname(); + + const navItemRef = useRef(null); + + const active = useActiveLink(data.path, !!data.children); + + const [openMenu, setOpenMenu] = useState(false); + + useEffect(() => { + if (openMenu) { + handleCloseMenu(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pathname]); + + const handleOpenMenu = useCallback(() => { + if (data.children) { + setOpenMenu(true); + } + }, [data.children]); + + const handleCloseMenu = useCallback(() => { + setOpenMenu(false); + }, []); + + const renderNavItem = ( + + ); + + if (data.children) { + return ( + + {renderNavItem} + + + + + + + + ); + } + + return {renderNavItem}; +} + +// ---------------------------------------------------------------------- + +function NavSubList({ data, depth, render, cssVars, slotProps, enabledRootRedirect }: NavSubListProps) { + return ( + + {data.map((list) => ( + + ))} + + ); +} diff --git a/dashboard/src/components/nav-basic/index.ts b/dashboard/src/components/nav-basic/index.ts new file mode 100644 index 00000000..5df7397c --- /dev/null +++ b/dashboard/src/components/nav-basic/index.ts @@ -0,0 +1,9 @@ +export * from './mobile'; + +export * from './classes'; + +export * from './desktop'; + +export * from './css-vars'; + +export type * from './types'; diff --git a/dashboard/src/components/nav-basic/mobile/index.ts b/dashboard/src/components/nav-basic/mobile/index.ts new file mode 100644 index 00000000..c25aff0e --- /dev/null +++ b/dashboard/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/dashboard/src/components/nav-basic/mobile/nav-basic-mobile.tsx b/dashboard/src/components/nav-basic/mobile/nav-basic-mobile.tsx new file mode 100644 index 00000000..bd84016c --- /dev/null +++ b/dashboard/src/components/nav-basic/mobile/nav-basic-mobile.tsx @@ -0,0 +1,30 @@ +import Stack from '@mui/material/Stack'; +import { useTheme } from '@mui/material/styles'; + +import { NavList } from './nav-list'; +import { NavUl } from '../../nav-section'; +import { navBasicClasses } from '../classes'; +import { navBasicCssVars } from '../css-vars'; + +import type { NavBasicProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function NavBasicMobile({ sx, data, render, slotProps, enabledRootRedirect, cssVars: overridesVars, ...other }: NavBasicProps) { + const theme = useTheme(); + + const cssVars = { + ...navBasicCssVars.mobile(theme), + ...overridesVars, + }; + + return ( + + + {data.map((list) => ( + + ))} + + + ); +} diff --git a/dashboard/src/components/nav-basic/mobile/nav-item.tsx b/dashboard/src/components/nav-basic/mobile/nav-item.tsx new file mode 100644 index 00000000..ad7d4320 --- /dev/null +++ b/dashboard/src/components/nav-basic/mobile/nav-item.tsx @@ -0,0 +1,235 @@ +import { forwardRef } from 'react'; + +import Box from '@mui/material/Box'; +import Tooltip from '@mui/material/Tooltip'; +import { styled } from '@mui/material/styles'; +import ButtonBase from '@mui/material/ButtonBase'; + +import { stylesMode } from 'src/theme/styles'; + +import { Iconify } from '../../iconify'; +import { useNavItem, stateClasses, sharedStyles, navSectionClasses } from '../../nav-section'; + +import type { NavItemProps, NavItemStateProps } from '../types'; + +// ---------------------------------------------------------------------- + +export const NavItem = forwardRef( + ( + { + path, + icon, + info, + title, + caption, + // + open, + depth, + render, + active, + disabled, + hasChild, + slotProps, + externalLink, + enabledRootRedirect, + ...other + }, + ref + ) => { + const navItem = useNavItem({ + path, + icon, + info, + depth, + render, + hasChild, + externalLink, + enabledRootRedirect, + }); + + return ( + + {icon && ( + + {navItem.renderIcon} + + )} + + {title && ( + + + {title} + + + {caption && ( + + + {caption} + + + )} + + )} + + {info && ( + + {navItem.renderInfo} + + )} + + {hasChild && ( + + )} + + ); + } +); + +// ---------------------------------------------------------------------- + +const StyledNavItem = styled(ButtonBase, { + shouldForwardProp: (prop) => prop !== 'active' && prop !== 'open' && prop !== 'disabled' && prop !== 'depth', +})(({ active, open, disabled, depth, theme }) => { + const rootItem = depth === 1; + + const subItem = !rootItem; + + const baseStyles = { + item: { + 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)', + }, + }, + icon: { + ...sharedStyles.icon, + width: 'var(--nav-icon-size)', + height: 'var(--nav-icon-size)', + margin: 'var(--nav-icon-margin)', + }, + texts: { + minWidth: 0, + flex: '1 1 auto', + }, + title: { + ...sharedStyles.noWrap, + ...theme.typography.body2, + fontWeight: active ? theme.typography.fontWeightSemiBold : theme.typography.fontWeightMedium, + }, + caption: { + ...sharedStyles.noWrap, + ...theme.typography.caption, + color: 'var(--nav-item-caption-color)', + }, + arrow: { + ...sharedStyles.arrow, + }, + info: { + ...sharedStyles.info, + }, + } as const; + + return { + /** + * Root item + */ + ...(rootItem && { + ...baseStyles.item, + minHeight: 'var(--nav-item-root-height)', + [`& .${navSectionClasses.item.icon}`]: { ...baseStyles.icon }, + [`& .${navSectionClasses.item.texts}`]: { ...baseStyles.texts }, + [`& .${navSectionClasses.item.title}`]: { ...baseStyles.title }, + [`& .${navSectionClasses.item.caption}`]: { ...baseStyles.caption }, + [`& .${navSectionClasses.item.arrow}`]: { ...baseStyles.arrow }, + [`& .${navSectionClasses.item.info}`]: { ...baseStyles.info }, + // State + ...(active && { + color: 'var(--nav-item-root-active-color)', + backgroundColor: 'var(--nav-item-root-active-bg)', + '&:hover': { + backgroundColor: 'var(--nav-item-root-active-hover-bg)', + }, + [stylesMode.dark]: { + color: 'var(--nav-item-root-active-color-on-dark)', + }, + }), + ...(open && { + color: 'var(--nav-item-root-open-color)', + backgroundColor: 'var(--nav-item-root-open-bg)', + }), + }), + + /** + * Sub item + */ + ...(subItem && { + ...baseStyles.item, + minHeight: 'var(--nav-item-sub-height)', + [`& .${navSectionClasses.item.icon}`]: { ...baseStyles.icon }, + [`& .${navSectionClasses.item.texts}`]: { ...baseStyles.texts }, + [`& .${navSectionClasses.item.title}`]: { ...baseStyles.title }, + [`& .${navSectionClasses.item.caption}`]: { ...baseStyles.caption }, + [`& .${navSectionClasses.item.arrow}`]: { ...baseStyles.arrow }, + [`& .${navSectionClasses.item.info}`]: { ...baseStyles.info }, + // Shape + '&::before': { + width: 3, + left: -13, + height: 16, + content: '""', + borderRadius: 3, + position: 'absolute', + transform: 'scale(0)', + transition: theme.transitions.create(['transform'], { + duration: theme.transitions.duration.short, + }), + ...(active && { + transform: 'scale(1)', + backgroundColor: 'currentColor', + }), + }, + // State + ...(active && { + color: 'var(--nav-item-sub-active-color)', + backgroundColor: 'var(--nav-item-sub-active-bg)', + }), + ...(open && { + color: 'var(--nav-item-sub-open-color)', + backgroundColor: 'var(--nav-item-sub-open-bg)', + }), + }), + + /** + * Disabled + */ + ...(disabled && sharedStyles.disabled), + }; +}); diff --git a/dashboard/src/components/nav-basic/mobile/nav-list.tsx b/dashboard/src/components/nav-basic/mobile/nav-list.tsx new file mode 100644 index 00000000..5ef239c2 --- /dev/null +++ b/dashboard/src/components/nav-basic/mobile/nav-list.tsx @@ -0,0 +1,122 @@ +import { useState, useEffect, useCallback } from 'react'; + +import Collapse from '@mui/material/Collapse'; + +import { usePathname } from 'src/routes/hooks'; +import { isExternalLink } from 'src/routes/utils'; +import { useActiveLink } from 'src/routes/hooks/use-active-link'; + +import { NavItem } from './nav-item'; +import { NavLi, NavUl, navSectionClasses } from '../../nav-section'; + +import type { NavListProps, NavSubListProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function NavList({ data, render, depth, slotProps, enabledRootRedirect }: NavListProps) { + const pathname = usePathname(); + + const active = useActiveLink(data.path, !!data.children); + + const [openMenu, setOpenMenu] = useState(active); + + useEffect(() => { + if (!active) { + handleCloseMenu(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pathname]); + + const handleToggleMenu = useCallback(() => { + if (data.children) { + setOpenMenu((prev) => !prev); + } + }, [data.children]); + + const handleCloseMenu = useCallback(() => { + setOpenMenu(false); + }, []); + + const renderNavItem = ( + + ); + + if (data.children) { + return ( + + {renderNavItem} + + + + + + ); + } + + return {renderNavItem}; +} + +// ---------------------------------------------------------------------- + +function NavSubList({ data, render, depth, slotProps, enabledRootRedirect }: NavSubListProps) { + return ( + + {data.map((list) => ( + + ))} + + ); +} diff --git a/dashboard/src/components/nav-basic/types.ts b/dashboard/src/components/nav-basic/types.ts new file mode 100644 index 00000000..e9437e41 --- /dev/null +++ b/dashboard/src/components/nav-basic/types.ts @@ -0,0 +1,68 @@ +import type { StackProps } from '@mui/material/Stack'; +import type { ButtonBaseProps } from '@mui/material/ButtonBase'; +import type { Theme, SxProps, CSSObject } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +export type SlotProps = { + rootItem?: NavItemSlotProps; + subItem?: NavItemSlotProps; + paper?: SxProps; +}; + +export type NavItemRenderProps = { + navIcon?: Record; + navInfo?: (val: string) => Record; +}; + +export type NavItemSlotProps = { + sx?: SxProps; + icon?: SxProps; + texts?: SxProps; + title?: SxProps; + caption?: SxProps; + info?: SxProps; + arrow?: SxProps; +}; + +export type NavItemStateProps = { + depth?: number; + open?: boolean; + active?: boolean; + disabled?: boolean; + hasChild?: boolean; + externalLink?: boolean; + enabledRootRedirect?: boolean; +}; + +export type NavItemBaseProps = { + path: string; + title: string; + children?: any; + caption?: string; + disabled?: boolean; + render?: NavItemRenderProps; + slotProps?: NavItemSlotProps; + icon?: string | React.ReactNode; + info?: string[] | React.ReactNode; +}; + +export type NavItemProps = ButtonBaseProps & NavItemBaseProps & NavItemStateProps; + +export type NavListProps = { + depth: number; + cssVars?: CSSObject; + slotProps?: SlotProps; + data: NavItemBaseProps; + render?: NavItemBaseProps['render']; + enabledRootRedirect?: NavItemStateProps['enabledRootRedirect']; +}; + +export type NavSubListProps = Omit & { + data: NavItemBaseProps[]; +}; + +export type NavBasicProps = StackProps & + Omit & { + data: NavItemBaseProps[]; + }; diff --git a/dashboard/src/components/nav-section/classes.ts b/dashboard/src/components/nav-section/classes.ts new file mode 100644 index 00000000..db9647e8 --- /dev/null +++ b/dashboard/src/components/nav-section/classes.ts @@ -0,0 +1,31 @@ +// ---------------------------------------------------------------------- + +export const navSectionClasses = { + mini: { + root: 'nav__section__mini', + }, + horizontal: { + root: 'nav__section__horizontal', + }, + vertical: { + root: 'nav__section__vertical', + }, + item: { + root: 'mnl__nav__item', + icon: 'mnl__nav__item__icon', + info: 'mnl__nav__item__info', + texts: 'mnl__nav__item__texts', + title: 'mnl__nav__item__title', + arrow: 'mnl__nav__item__arrow', + caption: 'mnl__nav__item__caption', + }, + li: 'mnl__nav__li', + ul: 'mnl__nav__ul', + paper: 'mnl__nav__paper', + subheader: 'mnl__nav__subheader', + state: { + open: 'state--open', + active: 'state--active', + disabled: 'state--disabled', + }, +}; diff --git a/dashboard/src/components/nav-section/css-vars.ts b/dashboard/src/components/nav-section/css-vars.ts new file mode 100644 index 00000000..52af6b82 --- /dev/null +++ b/dashboard/src/components/nav-section/css-vars.ts @@ -0,0 +1,119 @@ +import type { Theme } from '@mui/material/styles'; + +import { varAlpha } from 'src/theme/styles'; + +// ---------------------------------------------------------------------- + +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, spacing } = theme; + + return { + ...colorVars(theme, 'vertical'), + '--nav-item-gap': spacing(0.5), + '--nav-item-radius': `${shape.borderRadius}px`, + '--nav-item-pt': spacing(0.5), + '--nav-item-pr': spacing(1), + '--nav-item-pb': spacing(0.5), + '--nav-item-pl': spacing(1.5), + // root + '--nav-item-root-height': '44px', + // sub + '--nav-item-sub-height': '36px', + // icon + '--nav-icon-size': '24px', + '--nav-icon-margin': spacing(0, 1.5, 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, spacing } = theme; + + return { + ...colorVars(theme, 'mini'), + '--nav-item-gap': spacing(0.5), + '--nav-item-radius': `${shape.borderRadius}px`, + // root + '--nav-item-root-height': '56px', + '--nav-item-root-padding': spacing(1, 0.5, 0.75, 0.5), + // sub + '--nav-item-sub-height': '34px', + '--nav-item-sub-padding': spacing(0, 1), + // icon + '--nav-icon-size': '22px', + '--nav-icon-root-margin': spacing(0, 0, 0.75, 0), + '--nav-icon-sub-margin': spacing(0, 1, 0, 0), + }; +} + +// ---------------------------------------------------------------------- + +function horizontalVars(theme: Theme) { + const { shape, spacing } = theme; + + return { + ...colorVars(theme, 'horizontal'), + '--nav-item-gap': spacing(0.75), + '--nav-height': '56px', + '--nav-item-radius': `${shape.borderRadius * 0.75}px`, + // root + '--nav-item-root-height': '32px', + '--nav-item-root-padding': spacing(0, 0.75), + // sub + '--nav-item-sub-height': '34px', + '--nav-item-sub-padding': spacing(0, 1), + // icon + '--nav-icon-size': '22px', + '--nav-icon-sub-margin': spacing(0, 1, 0, 0), + '--nav-icon-root-margin': spacing(0, 1, 0, 0), + }; +} + +// ---------------------------------------------------------------------- + +export const navSectionCssVars = { + mini: miniVars, + vertical: verticalVars, + horizontal: horizontalVars, +}; diff --git a/dashboard/src/components/nav-section/hooks.tsx b/dashboard/src/components/nav-section/hooks.tsx new file mode 100644 index 00000000..a21c0ce2 --- /dev/null +++ b/dashboard/src/components/nav-section/hooks.tsx @@ -0,0 +1,82 @@ +import { cloneElement } from 'react'; + +import { RouterLink } from 'src/routes/components'; + +import type { NavItemProps } from './types'; + +// ---------------------------------------------------------------------- + +export type UseNavItemReturn = { + subItem: boolean; + rootItem: boolean; + subDeepItem: boolean; + baseProps: Record; + renderIcon: React.ReactNode; + renderInfo: React.ReactNode; +}; + +export type UseNavItemProps = { + path: NavItemProps['path']; + icon?: NavItemProps['icon']; + info?: NavItemProps['info']; + depth?: NavItemProps['depth']; + render?: NavItemProps['render']; + hasChild?: NavItemProps['hasChild']; + externalLink?: NavItemProps['externalLink']; + enabledRootRedirect?: NavItemProps['enabledRootRedirect']; +}; + +export function useNavItem({ + path, + icon, + info, + depth, + render, + hasChild, + externalLink, + enabledRootRedirect, +}: UseNavItemProps): UseNavItemReturn { + 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/dashboard/src/components/nav-section/horizontal/index.ts b/dashboard/src/components/nav-section/horizontal/index.ts new file mode 100644 index 00000000..d1fbe2db --- /dev/null +++ b/dashboard/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/dashboard/src/components/nav-section/horizontal/nav-item.tsx b/dashboard/src/components/nav-section/horizontal/nav-item.tsx new file mode 100644 index 00000000..12957785 --- /dev/null +++ b/dashboard/src/components/nav-section/horizontal/nav-item.tsx @@ -0,0 +1,212 @@ +import { forwardRef } from 'react'; + +import Box from '@mui/material/Box'; +import Tooltip from '@mui/material/Tooltip'; +import { styled } from '@mui/material/styles'; +import ButtonBase from '@mui/material/ButtonBase'; + +import { stylesMode } from 'src/theme/styles'; + +import { useNavItem } from '../hooks'; +import { Iconify } from '../../iconify'; +import { navSectionClasses } from '../classes'; +import { stateClasses, sharedStyles } from '../styles'; + +import type { NavItemProps, NavItemStateProps } from '../types'; + +// ---------------------------------------------------------------------- + +export const NavItem = forwardRef( + ( + { + path, + icon, + info, + title, + caption, + // + open, + depth, + render, + active, + disabled, + hasChild, + slotProps, + externalLink, + enabledRootRedirect, + ...other + }, + ref + ) => { + const navItem = useNavItem({ + path, + icon, + info, + depth, + render, + hasChild, + externalLink, + enabledRootRedirect, + }); + + return ( + + {icon && ( + + {navItem.renderIcon} + + )} + + {title && ( + + {title} + + )} + + {caption && ( + + + + )} + + {info && ( + + {navItem.renderInfo} + + )} + + {hasChild && ( + + )} + + ); + } +); + +// ---------------------------------------------------------------------- + +const StyledNavItem = styled(ButtonBase, { + shouldForwardProp: (prop) => prop !== 'active' && prop !== 'open' && prop !== 'disabled' && prop !== 'depth', +})(({ active, open, disabled, depth, theme }) => { + const rootItem = depth === 1; + + const subItem = !rootItem; + + const baseStyles = { + item: { + flexShrink: 0, + color: 'var(--nav-item-color)', + borderRadius: 'var(--nav-item-radius)', + '&:hover': { + backgroundColor: 'var(--nav-item-hover-bg)', + }, + }, + title: { + ...theme.typography.body2, + fontWeight: active ? theme.typography.fontWeightSemiBold : theme.typography.fontWeightMedium, + }, + caption: { + width: 16, + height: 16, + color: 'var(--nav-item-caption-color)', + }, + icon: { + ...sharedStyles.icon, + width: 'var(--nav-icon-size)', + height: 'var(--nav-icon-size)', + }, + arrow: { ...sharedStyles.arrow }, + info: { ...sharedStyles.info }, + } as const; + + return { + /** + * Root item + */ + ...(rootItem && { + ...baseStyles.item, + padding: 'var(--nav-item-root-padding)', + minHeight: 'var(--nav-item-root-height)', + [`& .${navSectionClasses.item.icon}`]: { + ...baseStyles.icon, + margin: 'var(--nav-icon-root-margin)', + }, + [`& .${navSectionClasses.item.title}`]: { ...baseStyles.title, whiteSpace: 'nowrap' }, + [`& .${navSectionClasses.item.caption}`]: { + ...baseStyles.caption, + marginLeft: theme.spacing(0.75), + }, + [`& .${navSectionClasses.item.arrow}`]: { ...baseStyles.arrow }, + [`& .${navSectionClasses.item.info}`]: { ...baseStyles.info }, + // State + ...(active && { + color: 'var(--nav-item-root-active-color)', + backgroundColor: 'var(--nav-item-root-active-bg)', + '&:hover': { + backgroundColor: 'var(--nav-item-root-active-hover-bg)', + }, + [stylesMode.dark]: { + color: 'var(--nav-item-root-active-color-on-dark)', + }, + }), + ...(open && { + color: 'var(--nav-item-root-open-color)', + backgroundColor: 'var(--nav-item-root-open-bg)', + }), + }), + + /** + * Sub item + */ + ...(subItem && { + ...baseStyles.item, + padding: 'var(--nav-item-sub-padding)', + minHeight: 'var(--nav-item-sub-height)', + color: theme.vars.palette.text.secondary, + [`& .${navSectionClasses.item.icon}`]: { + ...baseStyles.icon, + margin: 'var(--nav-icon-sub-margin)', + }, + [`& .${navSectionClasses.item.title}`]: { ...baseStyles.title, flexGrow: 1 }, + [`& .${navSectionClasses.item.caption}`]: { ...baseStyles.caption }, + [`& .${navSectionClasses.item.arrow}`]: { + ...baseStyles.arrow, + marginRight: theme.spacing(-0.5), + }, + [`& .${navSectionClasses.item.info}`]: { ...baseStyles.info }, + // State + ...(active && { + color: 'var(--nav-item-sub-active-color)', + backgroundColor: 'var(--nav-item-sub-active-bg)', + }), + ...(open && { + color: 'var(--nav-item-sub-open-color)', + backgroundColor: 'var(--nav-item-sub-open-bg)', + }), + }), + + /* Disabled */ + ...(disabled && sharedStyles.disabled), + }; +}); diff --git a/dashboard/src/components/nav-section/horizontal/nav-list.tsx b/dashboard/src/components/nav-section/horizontal/nav-list.tsx new file mode 100644 index 00000000..6bb8ded3 --- /dev/null +++ b/dashboard/src/components/nav-section/horizontal/nav-list.tsx @@ -0,0 +1,150 @@ +import { useRef, useState, useEffect, useCallback } from 'react'; + +import Paper from '@mui/material/Paper'; +import Popover from '@mui/material/Popover'; +import { useTheme } from '@mui/material/styles'; + +import { usePathname } from 'src/routes/hooks'; +import { isExternalLink } from 'src/routes/utils'; +import { useActiveLink } from 'src/routes/hooks/use-active-link'; + +import { paper } from 'src/theme/styles'; +import { useTranslate } from 'src/locales'; + +import { useAuthContext } from 'src/auth/hooks'; +import { hasAllPermissions } from 'src/auth/permissions'; + +import { NavItem } from './nav-item'; +import { NavUl, NavLi } from '../styles'; +import { navSectionClasses } from '../classes'; + +import type { NavListProps, NavSubListProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function NavList({ data, depth, render, cssVars, slotProps, enabledRootRedirect }: NavListProps) { + const { user } = useAuthContext(); + const { t } = useTranslate(); + const theme = useTheme(); + const pathname = usePathname(); + const navItemRef = useRef(null); + const active = useActiveLink(data.path, !!data.children); + const [openMenu, setOpenMenu] = useState(false); + + useEffect(() => { + if (openMenu) { + handleCloseMenu(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pathname]); + + const handleOpenMenu = useCallback(() => { + if (data.children) { + setOpenMenu(true); + } + }, [data.children]); + + const handleCloseMenu = useCallback(() => { + setOpenMenu(false); + }, []); + + const renderNavItem = ( + + ); + + // Hidden item by role + if (data.permissions && user) { + if (!hasAllPermissions(data.permissions, user.permissions)) { + return null; + } + } + + // Has children + if (data.children) { + return ( + + {renderNavItem} + + + + + + + + ); + } + + // Default + return {renderNavItem}; +} + +// ---------------------------------------------------------------------- + +function NavSubList({ data, depth, render, cssVars, slotProps, enabledRootRedirect }: NavSubListProps) { + return ( + + {data.map((list) => ( + + ))} + + ); +} diff --git a/dashboard/src/components/nav-section/horizontal/nav-section-horizontal.tsx b/dashboard/src/components/nav-section/horizontal/nav-section-horizontal.tsx new file mode 100644 index 00000000..68466c00 --- /dev/null +++ b/dashboard/src/components/nav-section/horizontal/nav-section-horizontal.tsx @@ -0,0 +1,79 @@ +import Stack from '@mui/material/Stack'; +import { useTheme } from '@mui/material/styles'; + +import { NavList } from './nav-list'; +import { NavUl, NavLi } from '../styles'; +import { Scrollbar } from '../../scrollbar'; +import { navSectionClasses } from '../classes'; +import { navSectionCssVars } from '../css-vars'; + +import type { NavGroupProps, NavSectionProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function NavSectionHorizontal({ sx, data, render, slotProps, enabledRootRedirect, cssVars: overridesVars }: NavSectionProps) { + const theme = useTheme(); + + const cssVars = { + ...navSectionCssVars.horizontal(theme), + ...overridesVars, + }; + + return ( + + + + {data.map((group) => ( + + ))} + + + + ); +} + +// ---------------------------------------------------------------------- + +function Group({ items, render, slotProps, enabledRootRedirect, cssVars }: NavGroupProps) { + return ( + + + {items.map((list) => ( + + ))} + + + ); +} diff --git a/dashboard/src/components/nav-section/index.ts b/dashboard/src/components/nav-section/index.ts new file mode 100644 index 00000000..5c1c14df --- /dev/null +++ b/dashboard/src/components/nav-section/index.ts @@ -0,0 +1,15 @@ +export * from './mini'; + +export * from './hooks'; + +export * from './styles'; + +export * from './classes'; + +export * from './css-vars'; + +export * from './vertical'; + +export type * from './types'; + +export * from './horizontal'; diff --git a/dashboard/src/components/nav-section/mini/index.ts b/dashboard/src/components/nav-section/mini/index.ts new file mode 100644 index 00000000..bead531b --- /dev/null +++ b/dashboard/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/dashboard/src/components/nav-section/mini/nav-item.tsx b/dashboard/src/components/nav-section/mini/nav-item.tsx new file mode 100644 index 00000000..e09046e8 --- /dev/null +++ b/dashboard/src/components/nav-section/mini/nav-item.tsx @@ -0,0 +1,224 @@ +import { forwardRef } from 'react'; + +import Box from '@mui/material/Box'; +import Tooltip from '@mui/material/Tooltip'; +import { styled } from '@mui/material/styles'; +import ButtonBase from '@mui/material/ButtonBase'; + +import { stylesMode } from 'src/theme/styles'; + +import { useNavItem } from '../hooks'; +import { Iconify } from '../../iconify'; +import { navSectionClasses } from '../classes'; +import { stateClasses, sharedStyles } from '../styles'; + +import type { NavItemProps, NavItemStateProps } from '../types'; + +// ---------------------------------------------------------------------- + +export const NavItem = forwardRef( + ( + { + path, + icon, + info, + title, + caption, + // + open, + depth, + render, + active, + disabled, + hasChild, + slotProps, + externalLink, + enabledRootRedirect, + ...other + }, + ref + ) => { + const navItem = useNavItem({ + path, + icon, + info, + depth, + render, + hasChild, + externalLink, + enabledRootRedirect, + }); + + return ( + + {icon && ( + + {navItem.renderIcon} + + )} + + {title && ( + + {title} + + )} + + {caption && ( + + + + )} + + {info && navItem.subItem && ( + + {navItem.renderInfo} + + )} + + {hasChild && } + + ); + } +); + +// ---------------------------------------------------------------------- + +const StyledNavItem = styled(ButtonBase, { + shouldForwardProp: (prop) => prop !== 'active' && prop !== 'open' && prop !== 'disabled' && prop !== 'depth', +})(({ active, open, disabled, depth, theme }) => { + const rootItem = depth === 1; + + const subItem = !rootItem; + + const baseStyles = { + item: { + width: '100%', + borderRadius: 'var(--nav-item-radius)', + color: 'var(--nav-item-color)', + '&:hover': { + backgroundColor: 'var(--nav-item-hover-bg)', + }, + }, + title: {}, + caption: { + width: 16, + height: 16, + color: 'var(--nav-item-caption-color)', + }, + icon: { + ...sharedStyles.icon, + width: 'var(--nav-icon-size)', + height: 'var(--nav-icon-size)', + }, + arrow: { ...sharedStyles.arrow }, + info: { ...sharedStyles.info }, + } as const; + + return { + /** + * Root item + */ + ...(rootItem && { + ...baseStyles.item, + textAlign: 'center', + flexDirection: 'column', + minHeight: 'var(--nav-item-root-height)', + padding: 'var(--nav-item-root-padding)', + [`& .${navSectionClasses.item.icon}`]: { + ...baseStyles.icon, + margin: 'var(--nav-icon-root-margin)', + }, + [`& .${navSectionClasses.item.title}`]: { + ...baseStyles.title, + ...sharedStyles.noWrap, + lineHeight: '16px', + fontSize: theme.typography.pxToRem(10), + fontWeight: active ? theme.typography.fontWeightBold : theme.typography.fontWeightSemiBold, + }, + [`& .${navSectionClasses.item.caption}`]: { + ...baseStyles.caption, + top: 11, + left: 6, + position: 'absolute', + }, + [`& .${navSectionClasses.item.arrow}`]: { + ...baseStyles.arrow, + top: 11, + right: 6, + position: 'absolute', + }, + [`& .${navSectionClasses.item.info}`]: { ...baseStyles.info }, + // State + ...(active && { + color: 'var(--nav-item-root-active-color)', + backgroundColor: 'var(--nav-item-root-active-bg)', + '&:hover': { + backgroundColor: 'var(--nav-item-root-active-hover-bg)', + }, + [stylesMode.dark]: { + color: 'var(--nav-item-root-active-color-on-dark)', + }, + }), + ...(open && { + color: 'var(--nav-item-root-open-color)', + backgroundColor: 'var(--nav-item-root-open-bg)', + }), + }), + + /** + * Sub item + */ + ...(subItem && { + ...baseStyles.item, + color: theme.vars.palette.text.secondary, + minHeight: 'var(--nav-item-sub-height)', + padding: 'var(--nav-item-sub-padding)', + [`& .${navSectionClasses.item.icon}`]: { + ...baseStyles.icon, + margin: 'var(--nav-icon-sub-margin)', + }, + [`& .${navSectionClasses.item.title}`]: { + ...baseStyles.title, + ...theme.typography.body2, + fontWeight: active ? theme.typography.fontWeightSemiBold : theme.typography.fontWeightMedium, + flex: '1 1 auto', + }, + [`& .${navSectionClasses.item.caption}`]: { ...baseStyles.caption }, + [`& .${navSectionClasses.item.arrow}`]: { + ...baseStyles.arrow, + marginRight: theme.spacing(-0.5), + }, + [`& .${navSectionClasses.item.info}`]: { ...baseStyles.info }, + // State + ...(active && { + color: 'var(--nav-item-sub-active-color)', + backgroundColor: 'var(--nav-item-sub-active-bg)', + }), + ...(open && { + color: 'var(--nav-item-sub-open-color)', + backgroundColor: 'var(--nav-item-sub-open-bg)', + }), + }), + + /* Disabled */ + ...(disabled && sharedStyles.disabled), + }; +}); diff --git a/dashboard/src/components/nav-section/mini/nav-list.tsx b/dashboard/src/components/nav-section/mini/nav-list.tsx new file mode 100644 index 00000000..276f893c --- /dev/null +++ b/dashboard/src/components/nav-section/mini/nav-list.tsx @@ -0,0 +1,150 @@ +import { useRef, useState, useEffect, useCallback } from 'react'; + +import Paper from '@mui/material/Paper'; +import Popover from '@mui/material/Popover'; +import { useTheme } from '@mui/material/styles'; + +import { usePathname } from 'src/routes/hooks'; +import { isExternalLink } from 'src/routes/utils'; +import { useActiveLink } from 'src/routes/hooks/use-active-link'; + +import { paper } from 'src/theme/styles'; +import { useTranslate } from 'src/locales'; + +import { useAuthContext } from 'src/auth/hooks'; +import { hasAllPermissions } from 'src/auth/permissions'; + +import { NavItem } from './nav-item'; +import { NavUl, NavLi } from '../styles'; +import { navSectionClasses } from '../classes'; + +import type { NavListProps, NavSubListProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function NavList({ data, depth, render, cssVars, slotProps, enabledRootRedirect }: NavListProps) { + const { t } = useTranslate(); + const { user } = useAuthContext(); + const theme = useTheme(); + const pathname = usePathname(); + const navItemRef = useRef(null); + const active = useActiveLink(data.path, !!data.children); + const [openMenu, setOpenMenu] = useState(false); + + useEffect(() => { + if (openMenu) { + handleCloseMenu(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pathname]); + + const handleOpenMenu = useCallback(() => { + if (data.children) { + setOpenMenu(true); + } + }, [data.children]); + + const handleCloseMenu = useCallback(() => { + setOpenMenu(false); + }, []); + + const renderNavItem = ( + + ); + + // Hidden item by role + if (data.permissions && user) { + if (!hasAllPermissions(data.permissions, user.permissions)) { + return null; + } + } + + // Has children + if (data.children) { + return ( + + {renderNavItem} + + 1 && { mt: -1 }), + ...(openMenu && { pointerEvents: 'auto' }), + }, + }, + }} + sx={{ ...cssVars, pointerEvents: 'none' }} + > + + + + + + ); + } + + // Default + return {renderNavItem}; +} + +// ---------------------------------------------------------------------- + +function NavSubList({ data, render, depth, slotProps, enabledRootRedirect, cssVars }: NavSubListProps) { + return ( + + {data.map((list) => ( + + ))} + + ); +} diff --git a/dashboard/src/components/nav-section/mini/nav-section-mini.tsx b/dashboard/src/components/nav-section/mini/nav-section-mini.tsx new file mode 100644 index 00000000..bff65343 --- /dev/null +++ b/dashboard/src/components/nav-section/mini/nav-section-mini.tsx @@ -0,0 +1,59 @@ +import Stack from '@mui/material/Stack'; +import { useTheme } from '@mui/material/styles'; + +import { NavList } from './nav-list'; +import { NavUl, NavLi } from '../styles'; +import { navSectionClasses } from '../classes'; +import { navSectionCssVars } from '../css-vars'; + +import type { NavGroupProps, NavSectionProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function NavSectionMini({ sx, data, render, slotProps, enabledRootRedirect, cssVars: overridesVars }: NavSectionProps) { + const theme = useTheme(); + + const cssVars = { + ...navSectionCssVars.mini(theme), + ...overridesVars, + }; + + return ( + + + {data.map((group) => ( + + ))} + + + ); +} + +// ---------------------------------------------------------------------- + +function Group({ items, render, slotProps, enabledRootRedirect, cssVars }: NavGroupProps) { + return ( + + + {items.map((list) => ( + + ))} + + + ); +} diff --git a/dashboard/src/components/nav-section/styles.tsx b/dashboard/src/components/nav-section/styles.tsx new file mode 100644 index 00000000..e1cd351f --- /dev/null +++ b/dashboard/src/components/nav-section/styles.tsx @@ -0,0 +1,211 @@ +import type { BoxProps } from '@mui/material/Box'; +import type { CollapseProps } from '@mui/material/Collapse'; +import type { ListSubheaderProps } from '@mui/material/ListSubheader'; + +import Box from '@mui/material/Box'; +import Collapse from '@mui/material/Collapse'; +import ListSubheader from '@mui/material/ListSubheader'; + +import { stylesMode } from 'src/theme/styles'; + +import { navSectionClasses } from './classes'; +import { svgColorClasses } from '../svg-color'; +import { Iconify, iconifyClasses } from '../iconify'; + +// ---------------------------------------------------------------------- + +export function stateClasses({ open, active, disabled }: { open?: boolean; active?: boolean; disabled?: boolean }) { + let classes = navSectionClasses.item.root; + + if (active) { + classes += ` ${navSectionClasses.state.active}`; + } else if (open) { + classes += ` ${navSectionClasses.state.open}`; + } else if (disabled) { + classes += ` ${navSectionClasses.state.disabled}`; + } + + return classes; +} + +// ---------------------------------------------------------------------- + +export const sharedStyles = { + icon: { + flexShrink: 0, + display: 'inline-flex', + [`& svg, & img, & .${iconifyClasses.root}, & .${svgColorClasses.root}`]: { + width: '100%', + height: '100%', + }, + }, + arrow: { + width: 16, + height: 16, + flexShrink: 0, + marginLeft: '6px', + display: 'inline-flex', + }, + info: { + fontSize: 12, + flexShrink: 0, + fontWeight: 600, + marginLeft: '6px', + lineHeight: 18 / 12, + display: 'inline-flex', + }, + noWrap: { + width: '100%', + maxWidth: '100%', + display: 'block', + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + }, + disabled: { opacity: 0.48, pointerEvents: 'none' }, +} as const; + +// ---------------------------------------------------------------------- + +export function Subheader({ + sx, + open, + children, + ...other +}: ListSubheaderProps & { + open?: boolean; +}) { + return ( + theme.spacing(2, 1, 1, 1.5), + fontSize: (theme) => theme.typography.pxToRem(11), + transition: (theme) => + theme.transitions.create(['color', 'padding-left'], { + duration: theme.transitions.duration.standard, + }), + '&:hover': { + pl: 2, + color: 'var(--nav-subheader-hover-color)', + [`& .${iconifyClasses.root}`]: { opacity: 1 }, + }, + ...sx, + }} + {...other} + > + + theme.transitions.create(['opacity'], { + duration: theme.transitions.duration.standard, + }), + }} + /> + + {children} + + ); +} + +// ---------------------------------------------------------------------- + +export function NavCollapse({ + sx, + depth, + children, + ...other +}: CollapseProps & { + depth: number; +}) { + return ( + + {children} + + ); +} + +// ---------------------------------------------------------------------- + +export function NavLi({ + sx, + children, + disabled, + ...other +}: BoxProps & { + disabled?: boolean; +}) { + return ( + + {children} + + ); +} + +// ---------------------------------------------------------------------- + +export function NavUl({ children, sx, ...other }: BoxProps) { + return ( + + {children} + + ); +} diff --git a/dashboard/src/components/nav-section/types.ts b/dashboard/src/components/nav-section/types.ts new file mode 100644 index 00000000..a04521d8 --- /dev/null +++ b/dashboard/src/components/nav-section/types.ts @@ -0,0 +1,74 @@ +import type { ButtonBaseProps } from '@mui/material/ButtonBase'; +import type { Theme, SxProps, CSSObject } from '@mui/material/styles'; + +// ---------------------------------------------------------------------- + +export type SlotProps = { + rootItem?: NavItemSlotProps; + subItem?: NavItemSlotProps; + subheader?: SxProps; + paper?: SxProps; + currentRole?: string; +}; + +export type NavItemRenderProps = { + navIcon?: Record; + navInfo?: (val: string) => Record; +}; + +export type NavItemSlotProps = { + sx?: SxProps; + icon?: SxProps; + texts?: SxProps; + title?: SxProps; + caption?: SxProps; + info?: SxProps; + arrow?: SxProps; +}; + +export type NavItemStateProps = { + depth?: number; + open?: boolean; + active?: boolean; + hasChild?: boolean; + externalLink?: boolean; + enabledRootRedirect?: boolean; +}; + +export type NavItemBaseProps = { + path: string; + title: string; + children?: any; + caption?: string; + permissions?: string[]; + disabled?: boolean; + render?: NavItemRenderProps; + slotProps?: NavItemSlotProps; + icon?: string | React.ReactNode; + info?: string[] | React.ReactNode; +}; + +export type NavItemProps = ButtonBaseProps & NavItemStateProps & NavItemBaseProps; + +export type NavListProps = { + depth: number; + cssVars?: CSSObject; + slotProps?: SlotProps; + data: NavItemBaseProps; + render?: NavItemBaseProps['render']; + enabledRootRedirect?: NavItemStateProps['enabledRootRedirect']; +}; + +export type NavSubListProps = Omit & { + data: NavItemBaseProps[]; +}; + +export type NavGroupProps = Omit & { + subheader?: string; + items: NavItemBaseProps[]; +}; + +export type NavSectionProps = Omit & { + sx?: SxProps; + data: NavGroupProps[]; +}; diff --git a/dashboard/src/components/nav-section/vertical/index.ts b/dashboard/src/components/nav-section/vertical/index.ts new file mode 100644 index 00000000..9e880efd --- /dev/null +++ b/dashboard/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/dashboard/src/components/nav-section/vertical/nav-item.tsx b/dashboard/src/components/nav-section/vertical/nav-item.tsx new file mode 100644 index 00000000..3262bf65 --- /dev/null +++ b/dashboard/src/components/nav-section/vertical/nav-item.tsx @@ -0,0 +1,223 @@ +import { forwardRef } from 'react'; + +import Box from '@mui/material/Box'; +import Tooltip from '@mui/material/Tooltip'; +import { styled } from '@mui/material/styles'; +import ButtonBase from '@mui/material/ButtonBase'; + +import { stylesMode } from 'src/theme/styles'; + +import { useNavItem } from '../hooks'; +import { Iconify } from '../../iconify'; +import { navSectionClasses } from '../classes'; +import { stateClasses, sharedStyles } from '../styles'; + +import type { NavItemProps, NavItemStateProps } from '../types'; + +// ---------------------------------------------------------------------- + +export const NavItem = forwardRef( + ( + { + path, + icon, + info, + title, + caption, + // + open, + depth, + render, + active, + disabled, + hasChild, + slotProps, + externalLink, + enabledRootRedirect, + ...other + }, + ref + ) => { + const navItem = useNavItem({ + path, + icon, + info, + depth, + render, + hasChild, + externalLink, + enabledRootRedirect, + }); + + return ( + + {icon && ( + + {navItem.renderIcon} + + )} + + {title && ( + + + {title} + + + {caption && ( + + + {caption} + + + )} + + )} + + {info && ( + + {navItem.renderInfo} + + )} + + {hasChild && ( + + )} + + ); + } +); + +// ---------------------------------------------------------------------- + +const StyledNavItem = styled(ButtonBase, { + shouldForwardProp: (prop) => prop !== 'active' && prop !== 'open' && prop !== 'disabled' && prop !== 'depth', +})(({ active, open, disabled, depth, theme }) => { + const rootItem = depth === 1; + + const subItem = !rootItem; + + const baseStyles = { + item: { + 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)', + }, + }, + texts: { minWidth: 0, flex: '1 1 auto' }, + title: { + ...sharedStyles.noWrap, + ...theme.typography.body2, + fontWeight: active ? theme.typography.fontWeightSemiBold : theme.typography.fontWeightMedium, + }, + caption: { + ...sharedStyles.noWrap, + ...theme.typography.caption, + color: 'var(--nav-item-caption-color)', + }, + icon: { + ...sharedStyles.icon, + width: 'var(--nav-icon-size)', + height: 'var(--nav-icon-size)', + margin: 'var(--nav-icon-margin)', + }, + arrow: { ...sharedStyles.arrow }, + info: { ...sharedStyles.info }, + } as const; + + return { + /** + * Root item + */ + ...(rootItem && { + ...baseStyles.item, + minHeight: 'var(--nav-item-root-height)', + [`& .${navSectionClasses.item.icon}`]: { ...baseStyles.icon }, + [`& .${navSectionClasses.item.texts}`]: { ...baseStyles.texts }, + [`& .${navSectionClasses.item.title}`]: { ...baseStyles.title }, + [`& .${navSectionClasses.item.caption}`]: { ...baseStyles.caption }, + [`& .${navSectionClasses.item.arrow}`]: { ...baseStyles.arrow }, + [`& .${navSectionClasses.item.info}`]: { ...baseStyles.info }, + // State + ...(active && { + color: 'var(--nav-item-root-active-color)', + backgroundColor: 'var(--nav-item-root-active-bg)', + '&:hover': { + backgroundColor: 'var(--nav-item-root-active-hover-bg)', + }, + [stylesMode.dark]: { + color: 'var(--nav-item-root-active-color-on-dark)', + }, + }), + ...(open && { + color: 'var(--nav-item-root-open-color)', + backgroundColor: 'var(--nav-item-root-open-bg)', + }), + }), + /** + * Sub item + */ + ...(subItem && { + ...baseStyles.item, + minHeight: 'var(--nav-item-sub-height)', + [`& .${navSectionClasses.item.icon}`]: { ...baseStyles.icon }, + [`& .${navSectionClasses.item.texts}`]: { ...baseStyles.texts }, + [`& .${navSectionClasses.item.title}`]: { ...baseStyles.title }, + [`& .${navSectionClasses.item.caption}`]: { ...baseStyles.caption }, + [`& .${navSectionClasses.item.arrow}`]: { ...baseStyles.arrow }, + [`& .${navSectionClasses.item.info}`]: { ...baseStyles.info }, + // Shape + '&::before': { + left: 0, + content: '""', + position: 'absolute', + width: 'var(--nav-bullet-size)', + height: 'var(--nav-bullet-size)', + transform: 'translate(calc(var(--nav-bullet-size) * -1), calc(var(--nav-bullet-size) * -0.4))', + backgroundColor: 'var(--nav-bullet-light-color)', + mask: `url("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") no-repeat 50% 50%/100% auto`, + WebkitMask: `url("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") no-repeat 50% 50%/100% auto`, + [stylesMode.dark]: { + backgroundColor: 'var(--nav-bullet-dark-color)', + }, + }, + // State + ...(active && { + color: 'var(--nav-item-sub-active-color)', + backgroundColor: 'var(--nav-item-sub-active-bg)', + }), + ...(open && { + color: 'var(--nav-item-sub-open-color)', + backgroundColor: 'var(--nav-item-sub-open-bg)', + }), + }), + /** + * Disabled + */ + ...(disabled && sharedStyles.disabled), + }; +}); diff --git a/dashboard/src/components/nav-section/vertical/nav-list.tsx b/dashboard/src/components/nav-section/vertical/nav-list.tsx new file mode 100644 index 00000000..d235a81f --- /dev/null +++ b/dashboard/src/components/nav-section/vertical/nav-list.tsx @@ -0,0 +1,118 @@ +import { useState, useEffect, useCallback } from 'react'; + +import { usePathname } from 'src/routes/hooks'; +import { isExternalLink } from 'src/routes/utils'; +import { useActiveLink } from 'src/routes/hooks/use-active-link'; + +import { useTranslate } from 'src/locales'; + +import { useAuthContext } from 'src/auth/hooks'; +import { hasAllPermissions } from 'src/auth/permissions'; + +import { NavItem } from './nav-item'; +import { navSectionClasses } from '../classes'; +import { NavUl, NavLi, NavCollapse } from '../styles'; + +import type { NavListProps, NavSubListProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function NavList({ data, render, depth, slotProps, enabledRootRedirect }: NavListProps) { + const { user } = useAuthContext(); + const { t } = useTranslate(); + const pathname = usePathname(); + + const active = useActiveLink(data.path, !!data.children); + + const [openMenu, setOpenMenu] = useState(active); + + useEffect(() => { + if (!active) { + handleCloseMenu(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pathname]); + + const handleToggleMenu = useCallback(() => { + if (data.children) { + setOpenMenu((prev) => !prev); + } + }, [data.children]); + + const handleCloseMenu = useCallback(() => { + setOpenMenu(false); + }, []); + + const renderNavItem = ( + + ); + + // Hidden item by role + if (data.permissions && user) { + if (!hasAllPermissions(data.permissions, user.permissions)) { + return null; + } + } + + // Has children + if (data.children) { + return ( + + {renderNavItem} + + + + + + ); + } + + // Default + return {renderNavItem}; +} + +// ---------------------------------------------------------------------- + +function NavSubList({ data, render, depth, slotProps, enabledRootRedirect }: NavSubListProps) { + return ( + + {data.map((list) => ( + + ))} + + ); +} diff --git a/dashboard/src/components/nav-section/vertical/nav-section-vertical.tsx b/dashboard/src/components/nav-section/vertical/nav-section-vertical.tsx new file mode 100644 index 00000000..0fad0764 --- /dev/null +++ b/dashboard/src/components/nav-section/vertical/nav-section-vertical.tsx @@ -0,0 +1,84 @@ +import { useState, useCallback } from 'react'; + +import Stack from '@mui/material/Stack'; +import Collapse from '@mui/material/Collapse'; +import { useTheme } from '@mui/material/styles'; + +import { useAuthContext } from 'src/auth/hooks'; +import { hasAllPermissions } from 'src/auth/permissions'; + +import { NavList } from './nav-list'; +import { navSectionClasses } from '../classes'; +import { navSectionCssVars } from '../css-vars'; +import { NavUl, NavLi, Subheader } from '../styles'; + +import type { NavGroupProps, NavSectionProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function NavSectionVertical({ sx, data, render, slotProps, enabledRootRedirect, cssVars: overridesVars }: NavSectionProps) { + const theme = useTheme(); + + const cssVars = { + ...navSectionCssVars.vertical(theme), + ...overridesVars, + }; + + return ( + + + {data.map((group) => ( + + ))} + + + ); +} + +// ---------------------------------------------------------------------- + +function Group({ items, render, subheader, slotProps, enabledRootRedirect }: NavGroupProps) { + const [open, setOpen] = useState(true); + const { user } = useAuthContext(); + + const handleToggle = useCallback(() => { + setOpen((prev) => !prev); + }, []); + + const filteredItems = items.filter((item) => !item.permissions || hasAllPermissions(item.permissions, user?.permissions || [])); + + if (filteredItems.length === 0) { + return null; + } + + const renderContent = ( + + {filteredItems.map((list) => ( + + ))} + + ); + + return ( + + {subheader ? ( + <> + + {subheader} + + + {renderContent} + + ) : ( + renderContent + )} + + ); +} diff --git a/dashboard/src/components/phone-input/index.ts b/dashboard/src/components/phone-input/index.ts new file mode 100644 index 00000000..5992c809 --- /dev/null +++ b/dashboard/src/components/phone-input/index.ts @@ -0,0 +1,3 @@ +export type * from './types'; + +export * from './phone-input'; diff --git a/dashboard/src/components/phone-input/list.tsx b/dashboard/src/components/phone-input/list.tsx new file mode 100644 index 00000000..a0955f06 --- /dev/null +++ b/dashboard/src/components/phone-input/list.tsx @@ -0,0 +1,133 @@ +import type { Country } from 'react-phone-number-input/input'; + +import { useState, useCallback } from 'react'; + +import Box from '@mui/material/Box'; +import Popover from '@mui/material/Popover'; +import Divider from '@mui/material/Divider'; +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 ListItemText from '@mui/material/ListItemText'; +import InputAdornment from '@mui/material/InputAdornment'; + +import { countries } from 'src/assets/data/countries'; + +import { Iconify, FlagIcon } from 'src/components/iconify'; +import { SearchNotFound } from 'src/components/search-not-found'; + +import { usePopover } from '../custom-popover'; +import { getCountry, applyFilter } from './utils'; + +import type { CountryListProps } from './types'; + +// ---------------------------------------------------------------------- + +export function CountryListPopover({ countryCode, onClickCountry }: CountryListProps) { + const popover = usePopover(); + + const selectedCountry = getCountry(countryCode); + + const [searchCountry, setSearchCountry] = useState(''); + + const handleSearchCountry = useCallback((event: React.ChangeEvent) => { + setSearchCountry(event.target.value); + }, []); + + const dataFiltered = applyFilter({ inputData: countries, query: searchCountry }); + + const notFound = !dataFiltered.length && !!setSearchCountry; + + const renderButton = ( + + + + + + + + ); + + const renderList = ( + + {dataFiltered.map((country) => { + if (!country.code) { + return null; + } + + return ( + { + popover.onClose(); + setSearchCountry(''); + onClickCountry(country.code as Country); + }} + > + + + + + ); + })} + + ); + + return ( + <> + {renderButton} + + { + popover.onClose(); + setSearchCountry(''); + }} + anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} + transformOrigin={{ vertical: 'top', horizontal: 'left' }} + slotProps={{ + paper: { + sx: { + width: 1, + height: 320, + maxWidth: 320, + display: 'flex', + flexDirection: 'column', + }, + }, + }} + > + + + + + ), + }} + /> + + + + {notFound ? : renderList} + + + + ); +} diff --git a/dashboard/src/components/phone-input/phone-input.tsx b/dashboard/src/components/phone-input/phone-input.tsx new file mode 100644 index 00000000..94eff7f8 --- /dev/null +++ b/dashboard/src/components/phone-input/phone-input.tsx @@ -0,0 +1,53 @@ +import type { TextFieldProps } from '@mui/material/TextField'; +import type { Country } from 'react-phone-number-input/input'; + +import { useState, forwardRef } from 'react'; +import PhoneNumberInput from 'react-phone-number-input/input'; + +import TextField from '@mui/material/TextField'; +import InputAdornment from '@mui/material/InputAdornment'; + +import { getCountryCode } from './utils'; +import { CountryListPopover } from './list'; + +import type { PhoneInputProps } from './types'; + +// ---------------------------------------------------------------------- + +export const PhoneInput = forwardRef( + ({ value, onChange, placeholder, country: inputCountryCode, disableSelect, ...other }, ref) => { + const defaultCountryCode = getCountryCode(value, inputCountryCode); + + const [selectedCountry, setSelectedCountry] = useState(defaultCountryCode); + + return ( + + setSelectedCountry(inputValue)} + /> + + ), + } + } + {...other} + /> + ); + } +); + +// ---------------------------------------------------------------------- + +const CustomInput = forwardRef(({ ...props }, ref) => ); diff --git a/dashboard/src/components/phone-input/types.ts b/dashboard/src/components/phone-input/types.ts new file mode 100644 index 00000000..7b6b4ff5 --- /dev/null +++ b/dashboard/src/components/phone-input/types.ts @@ -0,0 +1,16 @@ +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 = { + countryCode?: Country; + onClickCountry: (inputValue: Country) => void; +}; diff --git a/dashboard/src/components/phone-input/utils.ts b/dashboard/src/components/phone-input/utils.ts new file mode 100644 index 00000000..03cb58ae --- /dev/null +++ b/dashboard/src/components/phone-input/utils.ts @@ -0,0 +1,46 @@ +import type { Country } from 'react-phone-number-input'; + +import { parsePhoneNumber } from 'react-phone-number-input'; + +import { countries } from 'src/assets/data/countries'; + +// ---------------------------------------------------------------------- + +export function getCountryCode(inputValue: string, countryCode?: Country) { + if (inputValue) { + const phoneNumber = parsePhoneNumber(inputValue); + + if (phoneNumber) { + return phoneNumber?.country; + } + } + + return countryCode ?? 'US'; +} + +// ---------------------------------------------------------------------- + +export function getCountry(countryCode?: Country) { + const option = countries.filter((country) => country.code === countryCode)[0]; + return option; +} + +// ---------------------------------------------------------------------- + +type ApplyFilterProps = { + query: string; + inputData: typeof countries; +}; + +export function applyFilter({ inputData, query }: ApplyFilterProps) { + if (query) { + return inputData.filter( + (country) => + country.label.toLowerCase().indexOf(query.toLowerCase()) !== -1 || + country.code.toLowerCase().indexOf(query.toLowerCase()) !== -1 || + country.phone.toLowerCase().indexOf(query.toLowerCase()) !== -1 + ); + } + + return inputData; +} diff --git a/dashboard/src/components/progress-bar/index.ts b/dashboard/src/components/progress-bar/index.ts new file mode 100644 index 00000000..d71d9b1b --- /dev/null +++ b/dashboard/src/components/progress-bar/index.ts @@ -0,0 +1 @@ +export * from './progress-bar'; diff --git a/dashboard/src/components/progress-bar/progress-bar.tsx b/dashboard/src/components/progress-bar/progress-bar.tsx new file mode 100644 index 00000000..c96111f7 --- /dev/null +++ b/dashboard/src/components/progress-bar/progress-bar.tsx @@ -0,0 +1,77 @@ +'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]; + +export function ProgressBar() { + useEffect(() => { + NProgress.configure({ showSpinner: false }); + + const handleAnchorClick = (event: MouseEvent) => { + const targetUrl = (event.currentTarget as HTMLAnchorElement).href; + + const currentUrl = window.location.href; + + if (targetUrl !== currentUrl) { + NProgress.start(); + } + }; + + 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)); + }; + + 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); + }, + }); + }); + + return ( + + + + ); +} + +// ---------------------------------------------------------------------- + +function NProgressDone() { + const pathname = usePathname(); + + const router = useRouter(); + + const searchParams = useSearchParams(); + + useEffect(() => { + NProgress.done(); + }, [pathname, router, searchParams]); + + return null; +} diff --git a/dashboard/src/components/progress-bar/styles.css b/dashboard/src/components/progress-bar/styles.css new file mode 100644 index 00000000..38c3ce0b --- /dev/null +++ b/dashboard/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/dashboard/src/components/qr/index.ts b/dashboard/src/components/qr/index.ts new file mode 100644 index 00000000..cc8ed83a --- /dev/null +++ b/dashboard/src/components/qr/index.ts @@ -0,0 +1 @@ +export * from './qr-dialog'; diff --git a/dashboard/src/components/qr/qr-dialog.tsx b/dashboard/src/components/qr/qr-dialog.tsx new file mode 100644 index 00000000..9b608e06 --- /dev/null +++ b/dashboard/src/components/qr/qr-dialog.tsx @@ -0,0 +1,79 @@ +import type { DialogProps } from '@mui/material/Dialog'; + +import { useCallback } from 'react'; +import { QRCode } from 'react-qrcode-logo'; + +import Box from '@mui/material/Box'; +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 { useCopyToClipboard } from 'src/hooks/use-copy-to-clipboard'; + +import { toast } from 'src/components/snackbar'; +import { Iconify } from 'src/components/iconify'; + +interface QRDialogProps extends DialogProps { + value: string; + onClose: VoidFunction; +} + +export function QRDialog({ open, value, title, onClose }: QRDialogProps) { + const { copy } = useCopyToClipboard(); + + const onCopy = useCallback(() => { + if (value) { + copy(value); + toast.success('Copied to clipboard!'); + } + }, [copy, value]); + + return ( + + {title || 'Receive Bitcoin'} + + + canvas': { + width: '100% !important', + height: 'auto !important', + }, + }} + > + + + + + + + + + + + ); +} diff --git a/dashboard/src/components/scrollbar/classes.ts b/dashboard/src/components/scrollbar/classes.ts new file mode 100644 index 00000000..ac2cf913 --- /dev/null +++ b/dashboard/src/components/scrollbar/classes.ts @@ -0,0 +1,3 @@ +// ---------------------------------------------------------------------- + +export const scrollbarClasses = { root: 'mnl__scrollbar__root' }; diff --git a/dashboard/src/components/scrollbar/index.ts b/dashboard/src/components/scrollbar/index.ts new file mode 100644 index 00000000..a483df68 --- /dev/null +++ b/dashboard/src/components/scrollbar/index.ts @@ -0,0 +1,5 @@ +export * from './classes'; + +export * from './scrollbar'; + +export type * from './types'; diff --git a/dashboard/src/components/scrollbar/scrollbar.tsx b/dashboard/src/components/scrollbar/scrollbar.tsx new file mode 100644 index 00000000..e056eb48 --- /dev/null +++ b/dashboard/src/components/scrollbar/scrollbar.tsx @@ -0,0 +1,43 @@ +import { forwardRef } from 'react'; +import SimpleBar from 'simplebar-react'; + +import Box from '@mui/material/Box'; + +import { scrollbarClasses } from './classes'; + +import type { ScrollbarProps } from './types'; + +// ---------------------------------------------------------------------- + +export const Scrollbar = forwardRef( + ({ slotProps, children, fillContent, naturalScroll, sx, ...other }, ref) => ( + + {children} + + ) +); diff --git a/dashboard/src/components/scrollbar/styles.css b/dashboard/src/components/scrollbar/styles.css new file mode 100644 index 00000000..2fbf4d99 --- /dev/null +++ b/dashboard/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/dashboard/src/components/scrollbar/types.ts b/dashboard/src/components/scrollbar/types.ts new file mode 100644 index 00000000..1a9e1607 --- /dev/null +++ b/dashboard/src/components/scrollbar/types.ts @@ -0,0 +1,16 @@ +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; + naturalScroll?: boolean; + slotProps?: { + wrapper?: SxProps; + contentWrapper?: SxProps; + content?: Partial>; + }; +}; diff --git a/dashboard/src/components/search-not-found/index.ts b/dashboard/src/components/search-not-found/index.ts new file mode 100644 index 00000000..7b4e68e3 --- /dev/null +++ b/dashboard/src/components/search-not-found/index.ts @@ -0,0 +1 @@ +export * from './search-not-found'; diff --git a/dashboard/src/components/search-not-found/search-not-found.tsx b/dashboard/src/components/search-not-found/search-not-found.tsx new file mode 100644 index 00000000..bead4402 --- /dev/null +++ b/dashboard/src/components/search-not-found/search-not-found.tsx @@ -0,0 +1,33 @@ +import type { BoxProps } from '@mui/material/Box'; + +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; + +// ---------------------------------------------------------------------- + +type SearchNotFoundProps = BoxProps & { + query?: string; +}; + +export function SearchNotFound({ query, sx, ...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/dashboard/src/components/settings/config-settings.ts b/dashboard/src/components/settings/config-settings.ts new file mode 100644 index 00000000..4d0ba558 --- /dev/null +++ b/dashboard/src/components/settings/config-settings.ts @@ -0,0 +1,19 @@ +import { defaultFont } from 'src/theme/core/typography'; + +import type { SettingsState } from './types'; + +// ---------------------------------------------------------------------- + +export const STORAGE_KEY = 'app-settings'; + +export const defaultSettings: SettingsState = { + colorScheme: 'dark', + direction: 'ltr', + contrast: 'hight', + navLayout: 'vertical', + primaryColor: 'default', + navColor: 'integrate', + compactLayout: true, + fontFamily: defaultFont, + currency: 'USD', +} as const; diff --git a/dashboard/src/components/settings/context/index.ts b/dashboard/src/components/settings/context/index.ts new file mode 100644 index 00000000..02e1eea7 --- /dev/null +++ b/dashboard/src/components/settings/context/index.ts @@ -0,0 +1,3 @@ +export * from './settings-provider'; + +export * from './use-settings-context'; diff --git a/dashboard/src/components/settings/context/settings-provider.tsx b/dashboard/src/components/settings/context/settings-provider.tsx new file mode 100644 index 00000000..f18f1c91 --- /dev/null +++ b/dashboard/src/components/settings/context/settings-provider.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { useMemo, useState, useCallback, createContext } from 'react'; + +import { useCookies } from 'src/hooks/use-cookies'; +import { useLocalStorage } from 'src/hooks/use-local-storage'; + +import { STORAGE_KEY, defaultSettings } from '../config-settings'; + +import type { SettingsState, SettingsContextValue, SettingsProviderProps } from '../types'; + +// ---------------------------------------------------------------------- + +export const SettingsContext = createContext(undefined); + +export const SettingsConsumer = SettingsContext.Consumer; + +// ---------------------------------------------------------------------- + +export function SettingsProvider({ children, settings, caches = 'localStorage' }: SettingsProviderProps) { + const cookies = useCookies(STORAGE_KEY, settings, defaultSettings); + + const localStorage = useLocalStorage(STORAGE_KEY, settings); + + const values = caches === 'cookie' ? cookies : localStorage; + + const [openDrawer, setOpenDrawer] = useState(false); + + const onToggleDrawer = useCallback(() => { + setOpenDrawer((prev) => !prev); + }, []); + + const onCloseDrawer = useCallback(() => { + setOpenDrawer(false); + }, []); + + const memoizedValue = useMemo( + () => ({ + ...values.state, + canReset: values.canReset, + onReset: values.resetState, + onUpdate: values.setState, + onUpdateField: values.setField, + openDrawer, + onCloseDrawer, + onToggleDrawer, + }), + [values.canReset, values.resetState, values.setField, values.setState, values.state, openDrawer, onCloseDrawer, onToggleDrawer] + ); + + return {children}; +} diff --git a/dashboard/src/components/settings/context/use-settings-context.ts b/dashboard/src/components/settings/context/use-settings-context.ts new file mode 100644 index 00000000..90c5e4b4 --- /dev/null +++ b/dashboard/src/components/settings/context/use-settings-context.ts @@ -0,0 +1,15 @@ +'use client'; + +import { useContext } from 'react'; + +import { SettingsContext } from './settings-provider'; + +// ---------------------------------------------------------------------- + +export function useSettingsContext() { + const context = useContext(SettingsContext); + + if (!context) throw new Error('useSettingsContext must be use inside SettingsProvider'); + + return context; +} diff --git a/dashboard/src/components/settings/drawer/base-option.tsx b/dashboard/src/components/settings/drawer/base-option.tsx new file mode 100644 index 00000000..4dc2eb90 --- /dev/null +++ b/dashboard/src/components/settings/drawer/base-option.tsx @@ -0,0 +1,74 @@ +import type { ButtonBaseProps } from '@mui/material/ButtonBase'; + +import Box from '@mui/material/Box'; +import Switch from '@mui/material/Switch'; +import Tooltip from '@mui/material/Tooltip'; +import ButtonBase from '@mui/material/ButtonBase'; + +import { CONFIG } from 'src/config-global'; +import { varAlpha } from 'src/theme/styles'; + +import { Iconify } from 'src/components/iconify'; + +import { SvgColor } from '../../svg-color'; + +// ---------------------------------------------------------------------- + +type Props = ButtonBaseProps & { + icon: string; + label: string; + selected: boolean; + tooltip?: string; +}; + +export function BaseOption({ icon, label, tooltip, selected, ...other }: Props) { + return ( + `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.12)}`, + '&:hover': { bgcolor: (theme) => varAlpha(theme.vars.palette.grey['500Channel'], 0.08) }, + ...(selected && { + bgcolor: (theme) => varAlpha(theme.vars.palette.grey['500Channel'], 0.08), + }), + }} + {...other} + > + + + + + + + theme.typography.pxToRem(13), + }} + > + {label} + + + {tooltip && ( + + + + )} + + + ); +} diff --git a/dashboard/src/components/settings/drawer/font-options.tsx b/dashboard/src/components/settings/drawer/font-options.tsx new file mode 100644 index 00000000..e505b89c --- /dev/null +++ b/dashboard/src/components/settings/drawer/font-options.tsx @@ -0,0 +1,75 @@ +import Box from '@mui/material/Box'; +import ButtonBase from '@mui/material/ButtonBase'; + +import { CONFIG } from 'src/config-global'; +import { setFont, varAlpha, stylesMode } from 'src/theme/styles'; + +import { Block } from './styles'; +import { SvgColor } from '../../svg-color'; + +// ---------------------------------------------------------------------- + +type Props = { + value: string; + options: string[]; + onClickOption: (newValue: string) => void; +}; + +export function FontOptions({ value, options, onClickOption }: Props) { + return ( + + + {options.map((option) => { + const selected = value === option; + + return ( + + onClickOption(option)} + sx={{ + py: 2, + width: 1, + gap: 0.75, + borderWidth: 1, + borderRadius: 1.5, + borderStyle: 'solid', + display: 'inline-flex', + flexDirection: 'column', + borderColor: 'transparent', + fontFamily: setFont(option), + fontWeight: 'fontWeightMedium', + fontSize: (theme) => theme.typography.pxToRem(12), + color: (theme) => theme.vars.palette.text.disabled, + ...(selected && { + color: (theme) => theme.vars.palette.text.primary, + borderColor: (theme) => varAlpha(theme.vars.palette.grey['500Channel'], 0.08), + boxShadow: (theme) => `-8px 8px 20px -4px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.12)}`, + [stylesMode.dark]: { + boxShadow: (theme) => `-8px 8px 20px -4px ${varAlpha(theme.vars.palette.common.blackChannel, 0.12)}`, + }, + }), + }} + > + + `linear-gradient(135deg, ${theme.vars.palette.primary.light}, ${theme.vars.palette.primary.main})`, + }), + }} + /> + + {option} + + + ); + })} + + + ); +} diff --git a/dashboard/src/components/settings/drawer/fullscreen-button.tsx b/dashboard/src/components/settings/drawer/fullscreen-button.tsx new file mode 100644 index 00000000..0cdf1bda --- /dev/null +++ b/dashboard/src/components/settings/drawer/fullscreen-button.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { useState, useCallback } from 'react'; + +import Tooltip from '@mui/material/Tooltip'; +import IconButton from '@mui/material/IconButton'; + +import { CONFIG } from 'src/config-global'; + +import { SvgColor, svgColorClasses } from '../../svg-color'; + +// ---------------------------------------------------------------------- + +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 ( + + `linear-gradient(135deg, ${theme.vars.palette.grey[500]} 0%, ${theme.vars.palette.grey[600]} 100%)`, + ...(fullscreen && { + background: (theme) => + `linear-gradient(135deg, ${theme.vars.palette.primary.light} 0%, ${theme.vars.palette.primary.main} 100%)`, + }), + }, + }} + > + + + + ); +} diff --git a/dashboard/src/components/settings/drawer/index.ts b/dashboard/src/components/settings/drawer/index.ts new file mode 100644 index 00000000..6bf08164 --- /dev/null +++ b/dashboard/src/components/settings/drawer/index.ts @@ -0,0 +1 @@ +export * from './settings-drawer'; diff --git a/dashboard/src/components/settings/drawer/nav-options.tsx b/dashboard/src/components/settings/drawer/nav-options.tsx new file mode 100644 index 00000000..ec71150e --- /dev/null +++ b/dashboard/src/components/settings/drawer/nav-options.tsx @@ -0,0 +1,258 @@ +import type { ButtonBaseProps } from '@mui/material/ButtonBase'; + +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import { useTheme } from '@mui/material/styles'; +import ButtonBase from '@mui/material/ButtonBase'; + +import { CONFIG } from 'src/config-global'; +import { varAlpha, stylesMode } from 'src/theme/styles'; + +import { Block } from './styles'; +import { SvgColor, svgColorClasses } from '../../svg-color'; + +import type { SettingsState } from '../types'; + +// ---------------------------------------------------------------------- + +type Props = { + value: { + color: SettingsState['navColor']; + layout: SettingsState['navLayout']; + }; + options: { + colors: SettingsState['navColor'][]; + layouts: SettingsState['navLayout'][]; + }; + onClickOption: { + color: (newValue: SettingsState['navColor']) => void; + layout: (newValue: SettingsState['navLayout']) => void; + }; + hideNavColor?: boolean; + hideNavLayout?: boolean; +}; + +export function NavOptions({ options, value, onClickOption, hideNavColor, hideNavLayout }: Props) { + const theme = useTheme(); + + const cssVars = { + '--item-radius': '12px', + '--item-bg': theme.vars.palette.grey[500], + '--item-border-color': varAlpha(theme.vars.palette.grey['500Channel'], 0.08), + '--item-active-color': `linear-gradient(135deg, ${theme.vars.palette.primary.light} 0%, ${theme.vars.palette.primary.main} 100%)`, + '--item-active-shadow-light': `-8px 8px 20px -4px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.12)}`, + '--item-active-shadow-dark': `-8px 8px 20px -4px ${varAlpha(theme.vars.palette.common.blackChannel, 0.12)}`, + }; + + const labelStyles: React.CSSProperties = { + display: 'block', + lineHeight: '14px', + color: 'text.secondary', + fontWeight: 'fontWeightSemiBold', + fontSize: theme.typography.pxToRem(11), + }; + + const renderLayout = ( +
    + + Layout + + + {options.layouts.map((option) => ( + onClickOption.layout(option)} /> + ))} + +
    + ); + + const renderColor = ( +
    + + Color + + + {options.colors.map((option) => ( + onClickOption.color(option)} /> + ))} + +
    + ); + + return ( + + {!hideNavLayout && renderLayout} + {!hideNavColor && renderColor} + + ); +} + +// ---------------------------------------------------------------------- + +type OptionProps = ButtonBaseProps & { + option: string; + selected: boolean; +}; + +export function LayoutOption({ option, selected, sx, ...other }: OptionProps) { + const renderNav = () => { + const baseStyles = { flexShrink: 0, borderRadius: 1, bgcolor: 'var(--item-bg)' }; + + const circle = ( + + ); + + const primaryItem = ( + + ); + + const secondaryItem = ( + + ); + + return ( + + {circle} + {primaryItem} + {secondaryItem} + + ); + }; + + const renderContent = ( + + + + ); + + return ( + + {renderNav()} + {renderContent} + + ); +} + +// ---------------------------------------------------------------------- + +export function ColorOption({ option, selected, sx, ...other }: OptionProps) { + return ( + + + + theme.typography.pxToRem(13), + }} + > + {option} + + + ); +} diff --git a/dashboard/src/components/settings/drawer/presets-options.tsx b/dashboard/src/components/settings/drawer/presets-options.tsx new file mode 100644 index 00000000..2f0efbb3 --- /dev/null +++ b/dashboard/src/components/settings/drawer/presets-options.tsx @@ -0,0 +1,54 @@ +import Box from '@mui/material/Box'; +import ButtonBase from '@mui/material/ButtonBase'; +import { alpha as hexAlpha } from '@mui/material/styles'; + +import { CONFIG } from 'src/config-global'; + +import { Block } from './styles'; +import { SvgColor } from '../../svg-color'; + +import type { SettingsState } from '../types'; + +// ---------------------------------------------------------------------- + +type Value = SettingsState['primaryColor']; + +type Props = { + value: Value; + options: { name: Value; value: string }[]; + onClickOption: (newValue: Value) => void; +}; + +export function PresetsOptions({ value, options, onClickOption }: Props) { + return ( + + + {options.map((option) => { + const selected = value === option.name; + + return ( + + onClickOption(option.name)} + sx={{ + width: 1, + height: 64, + borderRadius: 1.5, + color: option.value, + ...(selected && { + bgcolor: hexAlpha(option.value, 0.08), + }), + }} + > + + + + ); + })} + + + ); +} diff --git a/dashboard/src/components/settings/drawer/settings-drawer.tsx b/dashboard/src/components/settings/drawer/settings-drawer.tsx new file mode 100644 index 00000000..55173a6e --- /dev/null +++ b/dashboard/src/components/settings/drawer/settings-drawer.tsx @@ -0,0 +1,193 @@ +'use client'; + +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Badge from '@mui/material/Badge'; +import Tooltip from '@mui/material/Tooltip'; +import IconButton from '@mui/material/IconButton'; +import Typography from '@mui/material/Typography'; +import Drawer, { drawerClasses } from '@mui/material/Drawer'; +import { useTheme, useColorScheme } from '@mui/material/styles'; + +import COLORS from 'src/theme/core/colors.json'; +import { paper, varAlpha } from 'src/theme/styles'; +import { defaultFont } from 'src/theme/core/typography'; +import PRIMARY_COLOR from 'src/theme/with-settings/primary-color.json'; + +import { Iconify } from '../../iconify'; +import { BaseOption } from './base-option'; +import { NavOptions } from './nav-options'; +import { Scrollbar } from '../../scrollbar'; +import { FontOptions } from './font-options'; +import { useSettingsContext } from '../context'; +import { PresetsOptions } from './presets-options'; +import { defaultSettings } from '../config-settings'; +import { FullScreenButton } from './fullscreen-button'; + +import type { SettingsDrawerProps } from '../types'; + +// ---------------------------------------------------------------------- + +export function SettingsDrawer({ + sx, + hideFont, + hideCompact, + hidePresets, + hideNavColor, + hideContrast, + hideNavLayout, + hideDirection, + hideColorScheme, +}: SettingsDrawerProps) { + const theme = useTheme(); + + const settings = useSettingsContext(); + + const { mode, setMode } = useColorScheme(); + + const renderHead = ( + + + Theme + + + + + + { + settings.onReset(); + setMode(defaultSettings.colorScheme); + }} + > + + + + + + + + + + + + + ); + + const renderMode = ( + { + settings.onUpdateField('colorScheme', mode === 'light' ? 'dark' : 'light'); + setMode(mode === 'light' ? 'dark' : 'light'); + }} + /> + ); + + const renderContrast = ( + settings.onUpdateField('contrast', settings.contrast === 'default' ? 'hight' : 'default')} + /> + ); + + const renderRTL = ( + settings.onUpdateField('direction', settings.direction === 'ltr' ? 'rtl' : 'ltr')} + /> + ); + + const renderCompact = ( + settings.onUpdateField('compactLayout', !settings.compactLayout)} + /> + ); + + const renderPresets = ( + settings.onUpdateField('primaryColor', newValue)} + options={[ + { name: 'default', value: COLORS.primary.main }, + { name: 'cyan', value: PRIMARY_COLOR.cyan.main }, + { name: 'purple', value: PRIMARY_COLOR.purple.main }, + { name: 'blue', value: PRIMARY_COLOR.blue.main }, + { name: 'orange', value: PRIMARY_COLOR.orange.main }, + { name: 'red', value: PRIMARY_COLOR.red.main }, + ]} + /> + ); + + const renderNav = ( + settings.onUpdateField('navColor', newValue), + layout: (newValue) => settings.onUpdateField('navLayout', newValue), + }} + options={{ + colors: ['integrate', 'apparent'], + layouts: ['vertical', 'horizontal', 'mini'], + }} + hideNavColor={hideNavColor} + hideNavLayout={hideNavLayout} + /> + ); + + const renderFont = ( + settings.onUpdateField('fontFamily', newValue)} + options={[defaultFont, 'Inter']} + /> + ); + + return ( + + {renderHead} + + + + + {!hideColorScheme && renderMode} + {!hideContrast && renderContrast} + {!hideDirection && renderRTL} + {!hideCompact && renderCompact} + + {!(hideNavLayout && hideNavColor) && renderNav} + {!hidePresets && renderPresets} + {!hideFont && renderFont} + + + + ); +} diff --git a/dashboard/src/components/settings/drawer/styles.tsx b/dashboard/src/components/settings/drawer/styles.tsx new file mode 100644 index 00000000..f192ddc5 --- /dev/null +++ b/dashboard/src/components/settings/drawer/styles.tsx @@ -0,0 +1,63 @@ +import type { Theme, SxProps } from '@mui/material/styles'; + +import Box from '@mui/material/Box'; +import Tooltip from '@mui/material/Tooltip'; + +import { varAlpha, stylesMode } from 'src/theme/styles'; + +import { Iconify } from 'src/components/iconify'; + +// ---------------------------------------------------------------------- + +type Props = { + title: string; + tooltip?: string; + sx?: SxProps; + children: React.ReactNode; +}; + +export function Block({ title, tooltip, children, sx }: Props) { + return ( + `solid 1px ${varAlpha(theme.vars.palette.grey['500Channel'], 0.12)}`, + ...sx, + }} + > + + {title} + + {tooltip && ( + + + + )} + + + {children} + + ); +} diff --git a/dashboard/src/components/settings/index.ts b/dashboard/src/components/settings/index.ts new file mode 100644 index 00000000..22824208 --- /dev/null +++ b/dashboard/src/components/settings/index.ts @@ -0,0 +1,7 @@ +export * from './drawer'; + +export * from './context'; + +export type * from './types'; + +export * from './config-settings'; diff --git a/dashboard/src/components/settings/server.ts b/dashboard/src/components/settings/server.ts new file mode 100644 index 00000000..0cab1462 --- /dev/null +++ b/dashboard/src/components/settings/server.ts @@ -0,0 +1,13 @@ +import { cookies } from 'next/headers'; + +import { STORAGE_KEY, defaultSettings } from './config-settings'; + +// ---------------------------------------------------------------------- + +export async function detectSettings() { + const cookieStore = cookies(); + + const settingsStore = cookieStore.get(STORAGE_KEY); + + return settingsStore ? JSON.parse(settingsStore?.value) : defaultSettings; +} diff --git a/dashboard/src/components/settings/types.ts b/dashboard/src/components/settings/types.ts new file mode 100644 index 00000000..f3e70b63 --- /dev/null +++ b/dashboard/src/components/settings/types.ts @@ -0,0 +1,48 @@ +import type { CurrencyValue } from 'src/types/currency'; +import type { Theme, SxProps } from '@mui/material/styles'; +import type { ThemeDirection, ThemeColorScheme } from 'src/theme/types'; + +// ---------------------------------------------------------------------- + +export type SettingsCaches = 'localStorage' | 'cookie'; + +export type SettingsDrawerProps = { + sx?: SxProps; + hideFont?: boolean; + hideCompact?: boolean; + hidePresets?: boolean; + hideNavColor?: boolean; + hideContrast?: boolean; + hideDirection?: boolean; + hideNavLayout?: boolean; + hideColorScheme?: boolean; +}; + +export type SettingsState = { + fontFamily: string; + compactLayout: boolean; + direction: ThemeDirection; + colorScheme: ThemeColorScheme; + contrast: 'default' | 'hight'; + navColor: 'integrate' | 'apparent'; + navLayout: 'vertical' | 'horizontal' | 'mini'; + primaryColor: 'default' | 'cyan' | 'purple' | 'blue' | 'orange' | 'red'; + currency: CurrencyValue; +}; + +export type SettingsContextValue = SettingsState & { + canReset: boolean; + onReset: () => void; + onUpdate: (updateValue: Partial) => void; + onUpdateField: (name: keyof SettingsState, updateValue: SettingsState[keyof SettingsState]) => void; + // Drawer + openDrawer: boolean; + onCloseDrawer: () => void; + onToggleDrawer: () => void; +}; + +export type SettingsProviderProps = { + settings: SettingsState; + caches?: SettingsCaches; + children: React.ReactNode; +}; diff --git a/dashboard/src/components/snackbar/classes.ts b/dashboard/src/components/snackbar/classes.ts new file mode 100644 index 00000000..e4c0e519 --- /dev/null +++ b/dashboard/src/components/snackbar/classes.ts @@ -0,0 +1,25 @@ +// ---------------------------------------------------------------------- + +export const toasterClasses = { + root: 'toaster__root', + toast: 'toaster__toast', + title: 'toaster__title', + icon: 'toaster__icon', + iconSvg: 'toaster__icon__svg', + content: 'toaster__content', + description: 'toaster__description', + actionButton: 'toaster__action__button', + cancelButton: 'toaster__cancel__button', + closeButton: 'toaster__close_button', + loadingIcon: 'toaster__loading_icon', + // + default: 'toaster__default', + error: 'toaster__error', + success: 'toaster__success', + warning: 'toaster__warning', + info: 'toaster__info', + // + loader: 'sonner-loader', + loaderVisible: '&[data-visible="true"]', + closeBtnVisible: '[data-close-button="true"]', +}; diff --git a/dashboard/src/components/snackbar/index.ts b/dashboard/src/components/snackbar/index.ts new file mode 100644 index 00000000..801d8ecd --- /dev/null +++ b/dashboard/src/components/snackbar/index.ts @@ -0,0 +1,3 @@ +export * from 'sonner'; + +export * from './snackbar'; diff --git a/dashboard/src/components/snackbar/snackbar.tsx b/dashboard/src/components/snackbar/snackbar.tsx new file mode 100644 index 00000000..cc95e490 --- /dev/null +++ b/dashboard/src/components/snackbar/snackbar.tsx @@ -0,0 +1,53 @@ +'use client'; + +import Portal from '@mui/material/Portal'; + +import { Iconify } from '../iconify'; +import { StyledToaster } from './styles'; +import { toasterClasses } from './classes'; + +// ---------------------------------------------------------------------- + +export function Snackbar() { + return ( + + , + info: , + success: , + warning: , + error: , + }} + /> + + ); +} diff --git a/dashboard/src/components/snackbar/styles.tsx b/dashboard/src/components/snackbar/styles.tsx new file mode 100644 index 00000000..2c4660b7 --- /dev/null +++ b/dashboard/src/components/snackbar/styles.tsx @@ -0,0 +1,178 @@ +import { Toaster } from 'sonner'; + +import { styled } from '@mui/material/styles'; + +import { varAlpha } from 'src/theme/styles'; + +import { toasterClasses } from './classes'; + +// ---------------------------------------------------------------------- + +export const StyledToaster = styled(Toaster)(({ theme }) => { + const baseStyles = { + toastDefault: { + padding: theme.spacing(1, 1, 1, 1.5), + boxShadow: theme.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.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.customShadows.z8, + color: theme.vars.palette.text.primary, + backgroundColor: theme.vars.palette.background.paper, + }, + }; + + const loadingStyles = { + 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, + [`& .${toasterClasses.loadingIcon}`]: { + zIndex: 9, + width: 24, + height: 24, + borderRadius: '50%', + animation: 'rotate 3s infinite linear', + background: `conic-gradient(${varAlpha(theme.vars.palette.text.primaryChannel, 0)}, ${varAlpha(theme.vars.palette.text.disabledChannel, 0.64)})`, + }, + [toasterClasses.loaderVisible]: { display: 'flex' }, + }; + + return { + width: 300, + [`& .${toasterClasses.toast}`]: { + gap: 12, + width: '100%', + minHeight: 52, + display: 'flex', + borderRadius: 12, + alignItems: 'center', + }, + /* + * Content + */ + [`& .${toasterClasses.content}`]: { + gap: 0, + flex: '1 1 auto', + }, + [`& .${toasterClasses.title}`]: { + fontSize: theme.typography.subtitle2.fontSize, + }, + [`& .${toasterClasses.description}`]: { + ...theme.typography.caption, + opacity: 0.64, + }, + /* + * Buttons + */ + [`& .${toasterClasses.actionButton}`]: {}, + [`& .${toasterClasses.cancelButton}`]: {}, + [`& .${toasterClasses.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 + */ + [`& .${toasterClasses.icon}`]: { + margin: 0, + width: 48, + height: 48, + alignItems: 'center', + borderRadius: 'inherit', + justifyContent: 'center', + alignSelf: 'flex-start', + [`& .${toasterClasses.iconSvg}`]: { + width: 24, + height: 24, + fontSize: 0, + }, + }, + + /* + * Default + */ + '@keyframes rotate': { to: { transform: 'rotate(1turn)' } }, + + [`& .${toasterClasses.default}`]: { + ...baseStyles.toastDefault, + [`&:has(${toasterClasses.closeBtnVisible})`]: { + [`& .${toasterClasses.content}`]: { + paddingRight: 32, + }, + }, + [`&:has(.${toasterClasses.loader})`]: baseStyles.toastLoader, + /* + * With loader + */ + [`&:has(.${toasterClasses.loader})`]: baseStyles.toastLoader, + [`& .${toasterClasses.loader}`]: loadingStyles, + }, + /* + * Error + */ + [`& .${toasterClasses.error}`]: { + ...baseStyles.toastColor, + [`& .${toasterClasses.icon}`]: { + color: theme.vars.palette.error.main, + backgroundColor: varAlpha(theme.vars.palette.error.mainChannel, 0.08), + }, + }, + /* + * Success + */ + [`& .${toasterClasses.success}`]: { + ...baseStyles.toastColor, + [`& .${toasterClasses.icon}`]: { + color: theme.vars.palette.success.main, + backgroundColor: varAlpha(theme.vars.palette.success.mainChannel, 0.08), + }, + }, + /* + * Warning + */ + [`& .${toasterClasses.warning}`]: { + ...baseStyles.toastColor, + [`& .${toasterClasses.icon}`]: { + color: theme.vars.palette.warning.main, + backgroundColor: varAlpha(theme.vars.palette.warning.mainChannel, 0.08), + }, + }, + /* + * Info + */ + [`& .${toasterClasses.info}`]: { + ...baseStyles.toastColor, + [`& .${toasterClasses.icon}`]: { + color: theme.vars.palette.info.main, + backgroundColor: varAlpha(theme.vars.palette.info.mainChannel, 0.08), + }, + }, + }; +}); diff --git a/dashboard/src/components/svg-color/classes.ts b/dashboard/src/components/svg-color/classes.ts new file mode 100644 index 00000000..2d56bc83 --- /dev/null +++ b/dashboard/src/components/svg-color/classes.ts @@ -0,0 +1,3 @@ +// ---------------------------------------------------------------------- + +export const svgColorClasses = { root: 'mnl__svg__color__root' }; diff --git a/dashboard/src/components/svg-color/index.ts b/dashboard/src/components/svg-color/index.ts new file mode 100644 index 00000000..372c31dd --- /dev/null +++ b/dashboard/src/components/svg-color/index.ts @@ -0,0 +1,5 @@ +export * from './classes'; + +export * from './svg-color'; + +export type * from './types'; diff --git a/dashboard/src/components/svg-color/svg-color.tsx b/dashboard/src/components/svg-color/svg-color.tsx new file mode 100644 index 00000000..51092fbe --- /dev/null +++ b/dashboard/src/components/svg-color/svg-color.tsx @@ -0,0 +1,28 @@ +import { forwardRef } from 'react'; + +import Box from '@mui/material/Box'; + +import { svgColorClasses } from './classes'; + +import type { SvgColorProps } from './types'; + +// ---------------------------------------------------------------------- + +export const SvgColor = forwardRef(({ src, width = 24, className, sx, ...other }, ref) => ( + +)); diff --git a/dashboard/src/components/svg-color/types.ts b/dashboard/src/components/svg-color/types.ts new file mode 100644 index 00000000..c795d7b3 --- /dev/null +++ b/dashboard/src/components/svg-color/types.ts @@ -0,0 +1,7 @@ +import type { BoxProps } from '@mui/material/Box'; + +// ---------------------------------------------------------------------- + +export type SvgColorProps = BoxProps & { + src: string; +}; diff --git a/dashboard/src/components/table/index.ts b/dashboard/src/components/table/index.ts new file mode 100644 index 00000000..aa4dcd7c --- /dev/null +++ b/dashboard/src/components/table/index.ts @@ -0,0 +1,17 @@ +export * from './utils'; + +export * from './use-table'; + +export type * from './types'; + +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/dashboard/src/components/table/table-empty-rows.tsx b/dashboard/src/components/table/table-empty-rows.tsx new file mode 100644 index 00000000..62dc9816 --- /dev/null +++ b/dashboard/src/components/table/table-empty-rows.tsx @@ -0,0 +1,21 @@ +import TableRow from '@mui/material/TableRow'; +import TableCell from '@mui/material/TableCell'; + +// ---------------------------------------------------------------------- + +export type TableEmptyRowsProps = { + height?: number; + emptyRows: number; +}; + +export function TableEmptyRows({ emptyRows, height }: TableEmptyRowsProps) { + if (!emptyRows) { + return null; + } + + return ( + + + + ); +} diff --git a/dashboard/src/components/table/table-head-custom.tsx b/dashboard/src/components/table/table-head-custom.tsx new file mode 100644 index 00000000..5c057d6b --- /dev/null +++ b/dashboard/src/components/table/table-head-custom.tsx @@ -0,0 +1,92 @@ +import type { Theme, SxProps } 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 = { + border: 0, + margin: -1, + padding: 0, + width: '1px', + height: '1px', + overflow: 'hidden', + position: 'absolute', + whiteSpace: 'nowrap', + clip: 'rect(0 0 0 0)', +} as const; + +// ---------------------------------------------------------------------- + +export type TableHeadCustomProps = { + orderBy?: string; + rowCount?: number; + sx?: SxProps; + numSelected?: number; + order?: 'asc' | 'desc'; + onSort?: (id: string) => void; + headLabel: Record[]; + onSelectAllRows?: (checked: boolean) => void; +}; + +export function TableHeadCustom({ + sx, + order, + onSort, + orderBy, + headLabel, + rowCount = 0, + numSelected = 0, + onSelectAllRows, +}: TableHeadCustomProps) { + return ( + + + {onSelectAllRows && ( + + ) => onSelectAllRows(event.target.checked)} + inputProps={{ + name: 'select-all-rows', + 'aria-label': 'select all rows', + }} + /> + + )} + + {headLabel.map((headCell) => ( + + {onSort ? ( + onSort(headCell.id)} + > + {headCell.label} + + {orderBy === headCell.id ? ( + {order === 'desc' ? 'sorted descending' : 'sorted ascending'} + ) : null} + + ) : ( + headCell.label + )} + + ))} + + + ); +} diff --git a/dashboard/src/components/table/table-no-data.tsx b/dashboard/src/components/table/table-no-data.tsx new file mode 100644 index 00000000..6fc8caa1 --- /dev/null +++ b/dashboard/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/dashboard/src/components/table/table-pagination-custom.tsx b/dashboard/src/components/table/table-pagination-custom.tsx new file mode 100644 index 00000000..83c77268 --- /dev/null +++ b/dashboard/src/components/table/table-pagination-custom.tsx @@ -0,0 +1,42 @@ +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/dashboard/src/components/table/table-selected-action.tsx b/dashboard/src/components/table/table-selected-action.tsx new file mode 100644 index 00000000..803311e3 --- /dev/null +++ b/dashboard/src/components/table/table-selected-action.tsx @@ -0,0 +1,62 @@ +import type { StackProps } from '@mui/material/Stack'; + +import Stack from '@mui/material/Stack'; +import Checkbox from '@mui/material/Checkbox'; +import Typography from '@mui/material/Typography'; + +// ---------------------------------------------------------------------- + +export type TableSelectedActionProps = StackProps & { + dense?: boolean; + rowCount: number; + numSelected: number; + action?: React.ReactNode; + onSelectAllRows: (checked: boolean) => void; +}; + +export function TableSelectedAction({ dense, action, rowCount, numSelected, onSelectAllRows, sx, ...other }: TableSelectedActionProps) { + if (!numSelected) { + return null; + } + + return ( + + ) => onSelectAllRows(event.target.checked)} + /> + + + {numSelected} selected + + + {action && action} + + ); +} diff --git a/dashboard/src/components/table/table-skeleton.tsx b/dashboard/src/components/table/table-skeleton.tsx new file mode 100644 index 00000000..f6532f12 --- /dev/null +++ b/dashboard/src/components/table/table-skeleton.tsx @@ -0,0 +1,32 @@ +import type { TableRowProps } from '@mui/material/TableRow'; + +import Stack from '@mui/material/Stack'; +import Skeleton from '@mui/material/Skeleton'; +import TableRow from '@mui/material/TableRow'; +import TableCell from '@mui/material/TableCell'; + +// ---------------------------------------------------------------------- + +export function TableSkeleton({ ...other }: TableRowProps) { + return ( + + + + + + + + + + + + + ); +} diff --git a/dashboard/src/components/table/types.ts b/dashboard/src/components/table/types.ts new file mode 100644 index 00000000..550e72e4 --- /dev/null +++ b/dashboard/src/components/table/types.ts @@ -0,0 +1,28 @@ +// ---------------------------------------------------------------------- + +export type TableProps = { + 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, totalRowsFiltered }: { totalRowsInPage: number; totalRowsFiltered: number }) => void; + // + setPage: React.Dispatch>; + setDense: React.Dispatch>; + setOrder: React.Dispatch>; + setOrderBy: React.Dispatch>; + setSelected: React.Dispatch>; + setRowsPerPage: React.Dispatch>; +}; diff --git a/dashboard/src/components/table/use-table.ts b/dashboard/src/components/table/use-table.ts new file mode 100644 index 00000000..fb39ad34 --- /dev/null +++ b/dashboard/src/components/table/use-table.ts @@ -0,0 +1,135 @@ +import { useState, useCallback } from 'react'; + +import type { TableProps } from './types'; + +// ---------------------------------------------------------------------- + +type UseTableReturn = TableProps; + +export type UseTableProps = { + defaultDense?: boolean; + defaultOrder?: 'asc' | 'desc'; + defaultOrderBy?: string; + defaultSelected?: string[]; + defaultRowsPerPage?: number; + defaultCurrentPage?: number; +}; + +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 || 10); + + const [order, setOrder] = useState<'asc' | 'desc'>(props?.defaultOrder || 'desc'); + + 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, totalRowsFiltered }: { 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/dashboard/src/components/table/utils.ts b/dashboard/src/components/table/utils.ts new file mode 100644 index 00000000..d9382971 --- /dev/null +++ b/dashboard/src/components/table/utils.ts @@ -0,0 +1,51 @@ +// ---------------------------------------------------------------------- + +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; +} + +// ---------------------------------------------------------------------- + +function descendingComparator(a: T, b: T, orderBy: keyof T) { + if (a[orderBy] === undefined) { + return 1; + } + if (b[orderBy] === undefined) { + return -1; + } + if (a[orderBy] === null) { + return 1; + } + if (b[orderBy] === null) { + return -1; + } + if (b[orderBy] < a[orderBy]) { + return -1; + } + if (b[orderBy] > a[orderBy]) { + return 1; + } + return 0; +} + +// ---------------------------------------------------------------------- + +export function getComparator( + order: 'asc' | 'desc', + orderBy: Key +): ( + a: { + [key in Key]: number | string | Date; + }, + b: { + [key in Key]: number | string | Date; + } +) => number { + return order === 'desc' ? (a, b) => descendingComparator(a, b, orderBy) : (a, b) => -descendingComparator(a, b, orderBy); +} diff --git a/dashboard/src/components/transactions/clean-transactions-button.tsx b/dashboard/src/components/transactions/clean-transactions-button.tsx new file mode 100644 index 00000000..eaa4f747 --- /dev/null +++ b/dashboard/src/components/transactions/clean-transactions-button.tsx @@ -0,0 +1,65 @@ +import type { LoadingButtonProps } from '@mui/lab'; + +import { LoadingButton } from '@mui/lab'; + +import { useBoolean } from 'src/hooks/use-boolean'; + +import { useTranslate } from 'src/locales'; +import { deleteFailedPayments, deleteExpiredInvoices } from 'src/lib/swissknife'; + +import { toast } from 'src/components/snackbar'; + +import { TransactionType } from 'src/types/transaction'; + +// ---------------------------------------------------------------------- + +interface Props { + onSuccess: VoidFunction; + buttonProps?: LoadingButtonProps; + children?: React.ReactNode; + transactionType?: TransactionType; +} + +export function CleanTransactionsButton({ onSuccess, buttonProps, transactionType, children }: Props) { + const { t } = useTranslate(); + const isDeleting = useBoolean(); + + const handleCleanTransactions = async () => { + try { + isDeleting.onTrue(); + + let nInvoicesDeleted = 0; + let nPaymentsDeleted = 0; + + if (transactionType === TransactionType.INVOICE || !transactionType) { + const { data } = await deleteExpiredInvoices(); + nInvoicesDeleted = data!; + } + + if (transactionType === TransactionType.PAYMENT || !transactionType) { + const { data } = await deleteFailedPayments(); + nPaymentsDeleted = data!; + } + + if (nInvoicesDeleted > 0) { + toast.success(t('clean_transactions_button.invoices_deleted_success', { count: nInvoicesDeleted })); + onSuccess(); + } + + if (nPaymentsDeleted > 0) { + toast.success(t('clean_transactions_button.payments_deleted_success', { count: nPaymentsDeleted })); + onSuccess(); + } + } catch (error) { + toast.error(error.reason); + } finally { + isDeleting.onFalse(); + } + }; + + return ( + + {children || t('clean_transactions_button.clean_transactions')}{' '} + + ); +} diff --git a/dashboard/src/components/transactions/confirm-payment-dialog.tsx b/dashboard/src/components/transactions/confirm-payment-dialog.tsx new file mode 100644 index 00000000..0815f8e9 --- /dev/null +++ b/dashboard/src/components/transactions/confirm-payment-dialog.tsx @@ -0,0 +1,339 @@ +import type { IFiatPrices } from 'src/types/bitcoin'; +import type { InputProps } from '@mui/material/Input'; +import type { DialogProps } from '@mui/material/Dialog'; +import type { PaymentResponse, SendPaymentRequest } from 'src/lib/swissknife'; + +import { m } from 'framer-motion'; +import { useForm } from 'react-hook-form'; +import { ajvResolver } from '@hookform/resolvers/ajv'; +import { useState, useEffect, useCallback } from 'react'; + +import Box from '@mui/material/Box'; +import { Link } from '@mui/material'; +import Stack from '@mui/material/Stack'; +import { LoadingButton } from '@mui/lab'; +import Button from '@mui/material/Button'; +import Avatar from '@mui/material/Avatar'; +import Dialog from '@mui/material/Dialog'; +import Tooltip from '@mui/material/Tooltip'; +import Typography from '@mui/material/Typography'; +import DialogTitle from '@mui/material/DialogTitle'; +import ListItemText from '@mui/material/ListItemText'; +import DialogActions from '@mui/material/DialogActions'; +import Input, { inputClasses } from '@mui/material/Input'; + +import { ajvOptions } from 'src/utils/ajv'; +import { satsToFiat } from 'src/utils/fiat'; +import { fCurrency } from 'src/utils/format-number'; +import { truncateText } from 'src/utils/format-string'; + +import { maxLine } from 'src/theme/styles'; +import { CONFIG } from 'src/config-global'; +import { useTranslate } from 'src/locales'; +import { pay, walletPay, SendPaymentRequestSchema } from 'src/lib/swissknife'; + +import { toast } from 'src/components/snackbar'; +import { SatsWithIcon } from 'src/components/bitcoin'; +import { Form } from 'src/components/hook-form/form-provider'; +import { varBounce, MotionContainer } from 'src/components/animate'; +import { RHFTextField, RHFWalletSelect } from 'src/components/hook-form'; + +import { useSettingsContext } from '../settings'; + +// ---------------------------------------------------------------------- + +const MIN_AMOUNT = 0; +const MAX_AMOUNT = 200000; + +// ---------------------------------------------------------------------- + +type ConfirmPaymentDialogProps = DialogProps & { + input: string; + fiatPrices: IFiatPrices; + bolt11?: any; + onClose: () => void; + onSuccess?: () => void; + isAdmin?: boolean; + walletId?: string; +}; + +// @ts-ignore +const resolver = ajvResolver(SendPaymentRequestSchema, ajvOptions); + +export function ConfirmPaymentDialog({ + open, + input, + isAdmin, + walletId, + fiatPrices, + bolt11, + onClose, + onSuccess, +}: ConfirmPaymentDialogProps) { + const { t } = useTranslate(); + + const [autoWidth, setAutoWidth] = useState(24); + const [payment, setPayment] = useState(undefined); + const { currency } = useSettingsContext(); + + const methods = useForm({ + resolver, + defaultValues: { + amount_msat: MIN_AMOUNT, + comment: '', + wallet: null, + input, + }, + }); + + const { + watch, + handleSubmit, + setValue, + formState: { isSubmitting }, + reset, + } = methods; + + const amount = watch('amount_msat'); + const wallet = watch('wallet'); + + const onSubmit = async (body: any) => { + try { + let paymentResponse; + const reqBody: SendPaymentRequest = { + wallet_id: walletId || body.wallet?.id, + amount_msat: body.amount_msat! * 1000, + comment: body.comment || undefined, + input: body.input, + }; + + if (isAdmin) { + const { data } = await pay({ body: reqBody }); + paymentResponse = data; + } else { + const { data } = await walletPay({ body: reqBody }); + paymentResponse = data; + } + + reset(); + setPayment(paymentResponse); + onSuccess?.(); + } catch (error) { + toast.error(error.reason); + } + }; + + useEffect(() => { + if (bolt11) { + const amountSection = bolt11.sections.find((s: any) => s.name === 'amount'); + const satsAmount = amountSection ? amountSection.value / 1000 : MIN_AMOUNT; + const comment = bolt11.description || ''; + + reset({ + amount_msat: satsAmount, + comment, + wallet: null, + input, + }); + } else { + reset({ + amount_msat: MIN_AMOUNT, + comment: '', + wallet: null, + input, + }); + } + }, [input, bolt11, reset]); + + const handleAutoWidth = useCallback(() => { + const getNumberLength = amount.toString().length; + setAutoWidth(getNumberLength * 24); + }, [amount]); + + useEffect(() => { + handleAutoWidth(); + }, [handleAutoWidth, amount]); + + const handleBlur = useCallback(() => { + if (amount !== undefined) { + if (amount < 0) { + setValue('amount_msat', 0); + } else if (amount > MAX_AMOUNT) { + setValue('amount_msat', MAX_AMOUNT); + } + } + }, [amount, setValue]); + + const handleChangeAmount = useCallback( + (event: React.ChangeEvent) => { + setValue('amount_msat', Number(event.target.value)); + }, + [setValue] + ); + + const handleClose = () => { + reset(); + setPayment(undefined); + onClose(); + }; + + const invoiceType = () => { + if (bolt11) { + return t('confirm_payment_dialog.bolt11_transfer'); + } + if (input.includes(CONFIG.site.domain)) { + return t('confirm_payment_dialog.internal_transfer'); + } + if (input.toLowerCase().startsWith('lnurl')) { + return t('confirm_payment_dialog.lnurl_transfer'); + } + return t('confirm_payment_dialog.lightning_transfer'); + }; + + return ( + + {t('confirm_payment_dialog.title')} + + {payment !== undefined ? ( + <> + + + + + + + + {t('confirm_payment_dialog.success_message')} {truncateText(input, 30)} + + + {payment.success_action?.message && ( + + + {payment.success_action.message} + + + )} + + + + + + + ) : ( +
    + + + + {input?.charAt(0).toUpperCase()} + + {truncateText(input, 30)}} + secondary={invoiceType()} + /> + + + + {fCurrency(satsToFiat(amount!, fiatPrices, currency), { currency })} + + + + + + + {walletId ? ( + + ) : ( + isAdmin && + )} + + + + + + {t('confirm_payment_dialog.confirm_send')} + + +
    + )} +
    + ); +} + +// ---------------------------------------------------------------------- + +type InputAmountProps = InputProps & { + autoWidth: number; + amount: number | number[]; +}; + +function InputAmount({ autoWidth, amount, disabled, onBlur, onChange, sx, ...other }: InputAmountProps) { + return ( + + + + + + + + ); +} diff --git a/dashboard/src/components/transactions/index.ts b/dashboard/src/components/transactions/index.ts new file mode 100644 index 00000000..34532323 --- /dev/null +++ b/dashboard/src/components/transactions/index.ts @@ -0,0 +1,10 @@ +export * from './new-invoice-form'; +export * from './new-invoice-card'; +export * from './new-payment-form'; + +export * from './new-payment-card'; +export * from './new-invoice-dialog'; +export * from './new-payment-dialog'; +export * from './confirm-payment-dialog'; + +export * from './clean-transactions-button'; diff --git a/dashboard/src/components/transactions/new-invoice-card.tsx b/dashboard/src/components/transactions/new-invoice-card.tsx new file mode 100644 index 00000000..8f88f60a --- /dev/null +++ b/dashboard/src/components/transactions/new-invoice-card.tsx @@ -0,0 +1,27 @@ +import type { CardProps } from '@mui/material'; + +import Box from '@mui/material/Box'; +import { Card, CardHeader } from '@mui/material'; + +import { NewInvoiceForm } from './new-invoice-form'; + +import type { NewInvoiceFormProps } from './new-invoice-form'; + +// ---------------------------------------------------------------------- + +type Props = CardProps & + NewInvoiceFormProps & { + subheader?: string; + }; + +export function NewInvoiceCard({ onSuccess, title, subheader, lnAddress, fiatPrices, sx, ...other }: Props) { + return ( + + + + + + + + ); +} diff --git a/dashboard/src/components/transactions/new-invoice-dialog.tsx b/dashboard/src/components/transactions/new-invoice-dialog.tsx new file mode 100644 index 00000000..f706b759 --- /dev/null +++ b/dashboard/src/components/transactions/new-invoice-dialog.tsx @@ -0,0 +1,38 @@ +import type { DialogProps } from '@mui/material/Dialog'; + +import Box from '@mui/material/Box'; +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 { useTranslate } from 'src/locales'; + +import { NewInvoiceForm } from './new-invoice-form'; + +import type { NewInvoiceFormProps } from './new-invoice-form'; + +// ---------------------------------------------------------------------- + +type Props = DialogProps & + NewInvoiceFormProps & { + onClose: VoidFunction; + }; + +export function NewInvoiceDialog({ title, open, onClose, ...other }: Props) { + const { t } = useTranslate(); + + return ( + + {title || t('new_invoice.generate_invoice')} + + + + + + + + + + ); +} diff --git a/dashboard/src/components/transactions/new-invoice-form.tsx b/dashboard/src/components/transactions/new-invoice-form.tsx new file mode 100644 index 00000000..c8003b72 --- /dev/null +++ b/dashboard/src/components/transactions/new-invoice-form.tsx @@ -0,0 +1,243 @@ +import type { IFiatPrices } from 'src/types/bitcoin'; +import type { InputProps } from '@mui/material/Input'; +import type { LnAddress, NewInvoiceRequest } from 'src/lib/swissknife'; + +import { useForm } from 'react-hook-form'; +import { ajvResolver } from '@hookform/resolvers/ajv'; +import { useState, useEffect, useCallback } from 'react'; + +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import { LoadingButton } from '@mui/lab'; +import Button from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; +import Input, { inputClasses } from '@mui/material/Input'; + +import { useBoolean } from 'src/hooks/use-boolean'; + +import { ajvOptions } from 'src/utils/ajv'; +import { satsToFiat } from 'src/utils/fiat'; +import { displayLnAddress } from 'src/utils/lnurl'; +import { fCurrency } from 'src/utils/format-number'; + +import { useTranslate } from 'src/locales'; +import { generateInvoice, newWalletInvoice, NewInvoiceRequestSchema } from 'src/lib/swissknife'; + +import { toast } from 'src/components/snackbar'; +import { Iconify } from 'src/components/iconify'; +import { Form } from 'src/components/hook-form/form-provider'; +import { RHFSlider, RHFTextField, RHFWalletSelect } from 'src/components/hook-form'; + +import { QRDialog } from '../qr'; +import { useSettingsContext } from '../settings'; + +// ---------------------------------------------------------------------- + +const MIN_AMOUNT = 0; +const MAX_AMOUNT = 200000; + +// ---------------------------------------------------------------------- + +export type NewInvoiceFormProps = { + lnAddress?: LnAddress | null; + fiatPrices: IFiatPrices; + onSuccess?: VoidFunction; + isAdmin?: boolean; + walletId?: string; +}; + +// @ts-ignore +const resolver = ajvResolver(NewInvoiceRequestSchema, ajvOptions); + +export function NewInvoiceForm({ fiatPrices, isAdmin, walletId, lnAddress, onSuccess }: NewInvoiceFormProps) { + const { t } = useTranslate(); + const [autoWidth, setAutoWidth] = useState(24); + const [qrValue, setQrValue] = useState(''); + const confirm = useBoolean(); + const { currency } = useSettingsContext(); + + const methods = useForm({ + resolver, + defaultValues: { + amount_msat: MIN_AMOUNT, + description: '', + wallet: null, + }, + }); + + const { + watch, + setValue, + handleSubmit, + reset, + formState: { isSubmitting }, + } = methods; + + const amount = watch('amount_msat'); + const wallet = watch('wallet'); + + const handleAutoWidth = useCallback(() => { + const getNumberLength = amount.toString().length; + setAutoWidth(getNumberLength * 24); + }, [amount]); + + useEffect(() => { + handleAutoWidth(); + }, [amount, handleAutoWidth]); + + const handleChangeSlider = useCallback( + (_: Event, newValue: number | number[]) => { + setValue('amount_msat', newValue as number); + }, + [setValue] + ); + + const handleChangeAmount = useCallback( + (event: React.ChangeEvent) => { + setValue('amount_msat', Number(event.target.value)); + }, + [setValue] + ); + + const handleBlur = useCallback(() => { + if (amount < 0) { + setValue('amount_msat', 0); + } else if (amount > MAX_AMOUNT) { + setValue('amount_msat', MAX_AMOUNT); + } + }, [amount, setValue]); + + const onSubmit = async (body: any) => { + try { + let invoice; + const reqBody: NewInvoiceRequest = { + amount_msat: body.amount_msat * 1000, + description: body.description || undefined, + wallet_id: walletId || body.wallet?.id, + }; + + if (isAdmin) { + const { data } = await generateInvoice({ body: reqBody }); + invoice = data!; + } else { + const { data } = await newWalletInvoice({ body: reqBody }); + invoice = data!; + } + + setQrValue(invoice.ln_invoice!.bolt11); + confirm.onTrue(); + reset(); + onSuccess?.(); + } catch (error) { + toast.error(error.reason); + } + }; + + return ( + <> +
    + + + {t('new_invoice.insert_amount')} + + + + + + + + + {t('new_invoice.btc_exchange_rate', { rate: fCurrency(fiatPrices[currency], { currency }) })} + + {fCurrency(satsToFiat(amount, fiatPrices, currency), { currency })} + + + + + {walletId ? ( + + ) : ( + isAdmin && + )} + + + + {t('new_invoice.receive')} + + + {lnAddress && ( + + )} + + +
    + + + ); +} + +// ---------------------------------------------------------------------- + +type InputAmountProps = InputProps & { + autoWidth: number; + amount: number | number[]; +}; + +function InputAmount({ autoWidth, amount, onBlur, onChange, sx, ...other }: InputAmountProps) { + return ( + + + + + + + + ); +} diff --git a/dashboard/src/components/transactions/new-payment-card.tsx b/dashboard/src/components/transactions/new-payment-card.tsx new file mode 100644 index 00000000..e6e31d44 --- /dev/null +++ b/dashboard/src/components/transactions/new-payment-card.tsx @@ -0,0 +1,30 @@ +import type { CardProps } from '@mui/material'; +import type { IFiatPrices } from 'src/types/bitcoin'; + +import Box from '@mui/material/Box'; +import { Card, CardHeader } from '@mui/material'; + +import { NewPaymentForm } from './new-payment-form'; + +import type { NewPaymentFormProps } from './new-payment-form'; + +// ---------------------------------------------------------------------- + +interface Props extends CardProps, NewPaymentFormProps { + title?: string; + subheader?: string; + fiatPrices: IFiatPrices; + onSuccess: VoidFunction; +} + +export function NewPaymentCard({ title, subheader, sx, fiatPrices, onSuccess, ...other }: Props) { + return ( + + + + + + + + ); +} diff --git a/dashboard/src/components/transactions/new-payment-dialog.tsx b/dashboard/src/components/transactions/new-payment-dialog.tsx new file mode 100644 index 00000000..7efae329 --- /dev/null +++ b/dashboard/src/components/transactions/new-payment-dialog.tsx @@ -0,0 +1,38 @@ +import type { DialogProps } from '@mui/material/Dialog'; + +import Box from '@mui/material/Box'; +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 { useTranslate } from 'src/locales'; + +import { NewPaymentForm } from './new-payment-form'; + +import type { NewPaymentFormProps } from './new-payment-form'; + +// ---------------------------------------------------------------------- + +type Props = DialogProps & + NewPaymentFormProps & { + onClose: VoidFunction; + }; + +export function NewPaymentDialog({ title, open, onClose, ...other }: Props) { + const { t } = useTranslate(); + + return ( + + {title || t('new_payment.send_payment')} + + + + + + + + + + ); +} diff --git a/dashboard/src/components/transactions/new-payment-form.tsx b/dashboard/src/components/transactions/new-payment-form.tsx new file mode 100644 index 00000000..de1808e9 --- /dev/null +++ b/dashboard/src/components/transactions/new-payment-form.tsx @@ -0,0 +1,243 @@ +import type { Contact } from 'src/lib/swissknife'; +import type { IFiatPrices } from 'src/types/bitcoin'; +import type { DialogProps } from '@mui/material/Dialog'; +import type { IDetectedBarcode } from '@yudiel/react-qr-scanner'; + +import { decode } from 'light-bolt11-decoder'; +import { useState, useCallback } from 'react'; +import { Scanner } from '@yudiel/react-qr-scanner'; + +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Button from '@mui/material/Button'; +import Avatar from '@mui/material/Avatar'; +import Dialog from '@mui/material/Dialog'; +import Tooltip from '@mui/material/Tooltip'; +import { useTheme } from '@mui/material/styles'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import DialogActions from '@mui/material/DialogActions'; + +import { paths } from 'src/routes/paths'; + +import { useBoolean } from 'src/hooks/use-boolean'; + +import { useTranslate } from 'src/locales'; +import { varAlpha, stylesMode } from 'src/theme/styles'; + +import { Iconify } from 'src/components/iconify'; +import { SatsWithIcon } from 'src/components/bitcoin'; +import { Carousel, useCarousel, CarouselArrowFloatButtons } from 'src/components/carousel'; + +import { ConfirmPaymentDialog } from './confirm-payment-dialog'; + +// ---------------------------------------------------------------------- + +export type NewPaymentFormProps = { + contacts?: Contact[]; + balance?: number; + fiatPrices: IFiatPrices; + onSuccess?: () => void; + isAdmin?: boolean; + walletId?: string; +}; + +export function NewPaymentForm({ balance, fiatPrices, isAdmin, walletId, contacts, onSuccess }: NewPaymentFormProps) { + const { t } = useTranslate(); + const theme = useTheme(); + const [input, setInput] = useState(''); + const [bolt11, setBolt11] = useState(undefined); + const confirm = useBoolean(); + const scanQR = useBoolean(); + + const carousel = useCarousel({ + loop: true, + dragFree: true, + slidesToShow: 'auto', + slideSpacing: '20px', + }); + + const handleChangeInput = useCallback((event: React.ChangeEvent) => { + setInput(event.target.value); + }, []); + + const handleConfirm = useCallback( + (event: any) => { + event.preventDefault(); + try { + const decodedBolt11 = decode(input); + setBolt11(decodedBolt11); + } catch (_) { + setBolt11(undefined); + } + confirm.onTrue(); + }, + [input, setBolt11, confirm] + ); + + const handlerClickDot = useCallback( + (index: number) => { + if (contacts === undefined) return; + + carousel.dots.onClickDot(index); + setInput(contacts[index].ln_address); + }, + [contacts, carousel.dots] + ); + + const handleClose = () => { + setInput(''); + setBolt11(undefined); + confirm.onFalse(); + }; + + return ( + <> + {contacts && contacts.length > 0 && ( + <> + + + {t('new_payment.recent')} + + + + + + + + + + {contacts.map((contact, index) => ( + + handlerClickDot(index)} + sx={{ + mx: 'auto', + opacity: 0.48, + cursor: 'pointer', + transition: theme.transitions.create('all'), + ...(index === carousel.dots.selectedIndex && { + opacity: 1, + transform: 'scale(1.25)', + boxShadow: `-4px 12px 24px 0 ${varAlpha(theme.vars.palette.common.blackChannel, 0.12)}`, + [stylesMode.dark]: { + boxShadow: `-4px 12px 24px 0 ${varAlpha(theme.vars.palette.common.blackChannel, 0.24)}`, + }, + }), + }} + > + {contact.ln_address?.charAt(0).toUpperCase()} + + + ))} + + + + )} + + + + + {balance != null && ( + + + {t('new_payment.your_balance')}{' '} + + + + )} + + + + + + + + + + + + ); +} + +// ---------------------------------------------------------------------- + +type ScanQRDialogProps = DialogProps & { + onClose: () => void; + onResult: (result: string) => void; +}; + +function ScanQRDialog({ open, onClose, onResult }: ScanQRDialogProps) { + const { t } = useTranslate(); + + const handleScannerResult = (detectedCodes: IDetectedBarcode[]) => { + const text = detectedCodes[0].rawValue; + onResult(text); + onClose(); + }; + + return ( + + + + + + + + ); +} diff --git a/dashboard/src/components/wallet/index.ts b/dashboard/src/components/wallet/index.ts new file mode 100644 index 00000000..b0b21524 --- /dev/null +++ b/dashboard/src/components/wallet/index.ts @@ -0,0 +1,2 @@ +export * from './register-wallet-form'; +export * from './register-wallet-dialog'; diff --git a/dashboard/src/components/wallet/register-wallet-dialog.tsx b/dashboard/src/components/wallet/register-wallet-dialog.tsx new file mode 100644 index 00000000..c599774d --- /dev/null +++ b/dashboard/src/components/wallet/register-wallet-dialog.tsx @@ -0,0 +1,38 @@ +import type { DialogProps } from '@mui/material/Dialog'; + +import Box from '@mui/material/Box'; +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 { useTranslate } from 'src/locales'; + +import { RegisterWalletForm } from './register-wallet-form'; + +import type { NewWalletFormProps } from './register-wallet-form'; + +// ---------------------------------------------------------------------- + +type Props = DialogProps & + NewWalletFormProps & { + onClose: VoidFunction; + }; + +export function RegisterWalletDialog({ title, open, onClose, onSuccess }: Props) { + const { t } = useTranslate(); + + return ( + + {title || t('register_wallet.title')} + + + + + + + + + + ); +} diff --git a/dashboard/src/components/wallet/register-wallet-form.tsx b/dashboard/src/components/wallet/register-wallet-form.tsx new file mode 100644 index 00000000..aa2dff57 --- /dev/null +++ b/dashboard/src/components/wallet/register-wallet-form.tsx @@ -0,0 +1,74 @@ +import type { RegisterWalletRequest } from 'src/lib/swissknife'; + +import { useForm } from 'react-hook-form'; +import { ajvResolver } from '@hookform/resolvers/ajv'; + +import { Stack } from '@mui/material'; +import { LoadingButton } from '@mui/lab'; + +import { ajvOptions } from 'src/utils/ajv'; + +import { useTranslate } from 'src/locales'; +import { registerWallet, RegisterWalletRequestSchema } from 'src/lib/swissknife'; + +import { toast } from 'src/components/snackbar'; +import { Form, RHFTextField } from 'src/components/hook-form'; + +// ---------------------------------------------------------------------- + +export type NewWalletFormProps = { + onSuccess: VoidFunction; +}; + +// @ts-ignore +const resolver = ajvResolver(RegisterWalletRequestSchema, ajvOptions); + +export function RegisterWalletForm({ onSuccess }: NewWalletFormProps) { + const { t } = useTranslate(); + + const methods = useForm({ + resolver, + defaultValues: { + user_id: '', + }, + }); + + const { + reset, + handleSubmit, + formState: { isSubmitting }, + watch, + } = methods; + + const user = watch('user_id'); + + const onSubmit = async (body: RegisterWalletRequest) => { + try { + await registerWallet({ body }); + toast.success(t('register_wallet.success_wallet_registration')); + reset(); + onSuccess(); + } catch (error) { + toast.error(error.reason); + } + }; + + return ( +
    + + + + + {t('register')} + + +
    + ); +} diff --git a/dashboard/src/config-global.ts b/dashboard/src/config-global.ts new file mode 100644 index 00000000..069c8f8f --- /dev/null +++ b/dashboard/src/config-global.ts @@ -0,0 +1,73 @@ +import { paths } from 'src/routes/paths'; + +import packageJson from '../package.json'; +import { client } from './lib/swissknife'; + +// ---------------------------------------------------------------------- + +export type ConfigValue = { + isStaticExport: boolean; + site: { + name: string; + serverUrl: string; + assetURL: string; + basePath: string; + version: string; + domain: string; + mempoolSpace: string; + }; + auth: { + method: 'jwt' | 'supabase' | 'auth0'; + skip: boolean; + redirectPath: string; + }; + auth0: { clientId: string; domain: string; callbackUrl: string; audience: string }; + supabase: { url: string; key: string }; +}; + +export type AuthMethod = 'jwt' | 'supabase' | 'auth0'; + +// ---------------------------------------------------------------------- + +export const CONFIG: ConfigValue = { + site: { + name: process.env.NEXT_PUBLIC_SITENAME ?? 'Numeraire SwissKnife', + serverUrl: process.env.NEXT_PUBLIC_SERVER_URL ?? '', + assetURL: process.env.NEXT_PUBLIC_ASSET_URL ?? '', + basePath: process.env.NEXT_PUBLIC_BASE_PATH ?? '', + domain: process.env.NEXT_PUBLIC_DOMAIN ?? 'numeraire.tech', + mempoolSpace: process.env.NEXT_PUBLIC_MEMPOOL_SPACE_URL ?? 'https://mempool.space/api/v1', + version: packageJson.version, + }, + isStaticExport: JSON.parse(`${process.env.BUILD_STATIC_EXPORT}`), + /** + * Auth + * @method {AuthMethod} + */ + auth: { + method: (process.env.NEXT_PUBLIC_AUTH_METHOD as AuthMethod) ?? 'jwt', + skip: false, + redirectPath: paths.wallet.root, + }, + /** + * 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 ?? '', + audience: process.env.NEXT_PUBLIC_AUTH0_AUDIENCE ?? 'https://swissknife.numeraire.tech/api/v1', + }, + /** + * Supabase + */ + supabase: { + url: process.env.NEXT_PUBLIC_SUPABASE_URL ?? '', + key: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? '', + }, +}; + +client.setConfig({ + baseUrl: CONFIG.site.serverUrl, + throwOnError: true, +}); diff --git a/dashboard/src/global.css b/dashboard/src/global.css new file mode 100644 index 00000000..d4dbfb11 --- /dev/null +++ b/dashboard/src/global.css @@ -0,0 +1,64 @@ +/** ************************************** +* Fonts: app +*************************************** */ + +@import '@fontsource/inter/400.css'; +@import '@fontsource/inter/500.css'; +@import '@fontsource/inter/600.css'; +@import '@fontsource/inter/700.css'; +@import '@fontsource/inter/800.css'; + +/** ************************************** +* Plugins +*************************************** */ +/* scrollbar */ +@import './components/scrollbar/styles.css'; + +/* image */ +@import './components/image/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/dashboard/src/hooks/use-boolean.ts b/dashboard/src/hooks/use-boolean.ts new file mode 100644 index 00000000..d10ebd4b --- /dev/null +++ b/dashboard/src/hooks/use-boolean.ts @@ -0,0 +1,42 @@ +'use client'; + +import { useMemo, useState, useCallback } from 'react'; + +// ---------------------------------------------------------------------- + +export type UseBooleanReturn = { + value: boolean; + onTrue: () => void; + onFalse: () => void; + onToggle: () => void; + setValue: React.Dispatch>; +}; + +export function useBoolean(defaultValue: boolean = false): UseBooleanReturn { + const [value, setValue] = useState(defaultValue); + + const onTrue = useCallback(() => { + setValue(true); + }, []); + + const onFalse = useCallback(() => { + setValue(false); + }, []); + + const onToggle = useCallback(() => { + setValue((prev) => !prev); + }, []); + + const memoizedValue = useMemo( + () => ({ + value, + onTrue, + onFalse, + onToggle, + setValue, + }), + [value, onTrue, onFalse, onToggle, setValue] + ); + + return memoizedValue; +} diff --git a/dashboard/src/hooks/use-client-rect.ts b/dashboard/src/hooks/use-client-rect.ts new file mode 100644 index 00000000..fd2b1dd6 --- /dev/null +++ b/dashboard/src/hooks/use-client-rect.ts @@ -0,0 +1,76 @@ +import { useRef, useMemo, useState, useEffect, useCallback, useLayoutEffect } from 'react'; + +import { useEventListener } from './use-event-listener'; + +// ---------------------------------------------------------------------- + +type ScrollElValue = { + scrollWidth: number; + scrollHeight: number; +}; + +type DOMRectValue = { + top: number; + right: number; + bottom: number; + left: number; + x: number; + y: number; + width: number; + height: number; +}; + +export type UseClientRectReturn = DOMRectValue & + ScrollElValue & { + elementRef: React.RefObject; + }; + +export function useClientRect(inputRef?: React.RefObject): UseClientRectReturn { + const initialRef = useRef(null); + + const elementRef = inputRef || initialRef; + + const [rect, setRect] = useState(undefined); + + const [scroll, setScroll] = useState(undefined); + + const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect; + + const handleResize = useCallback(() => { + if (elementRef?.current) { + const clientRect = elementRef.current.getBoundingClientRect(); + + setRect(clientRect); + + setScroll({ + scrollWidth: elementRef.current?.scrollWidth, + scrollHeight: elementRef.current?.scrollHeight, + }); + } + }, [elementRef]); + + useEventListener('resize', handleResize); + + useIsomorphicLayoutEffect(() => { + handleResize(); + }, []); + + const memoizedRectValue = useMemo(() => rect, [rect]); + const memoizedScrollValue = useMemo(() => scroll, [scroll]); + + return { + elementRef, + // + top: memoizedRectValue?.top ?? 0, + right: memoizedRectValue?.right ?? 0, + bottom: memoizedRectValue?.bottom ?? 0, + left: memoizedRectValue?.left ?? 0, + x: memoizedRectValue?.x ?? 0, + y: memoizedRectValue?.y ?? 0, + width: memoizedRectValue?.width ?? 0, + height: memoizedRectValue?.height ?? 0, + // + scrollWidth: memoizedScrollValue?.scrollWidth ?? 0, + scrollHeight: memoizedScrollValue?.scrollHeight ?? 0, + }; +} diff --git a/dashboard/src/hooks/use-cookies.ts b/dashboard/src/hooks/use-cookies.ts new file mode 100644 index 00000000..38cfcba8 --- /dev/null +++ b/dashboard/src/hooks/use-cookies.ts @@ -0,0 +1,137 @@ +import { useMemo, useState, useEffect, useCallback } from 'react'; + +import { isEqual } from 'src/utils/helper'; + +// ---------------------------------------------------------------------- + +export type UseCookiesReturn = { + state: T; + canReset: boolean; + resetState: () => void; + setState: (updateState: T | Partial) => void; + setField: (name: keyof T, updateValue: T[keyof T]) => void; +}; + +export function useCookies( + key: string, + initialState: T, + defaultValues: T, + options?: { + daysUntilExpiration?: number; + } +): UseCookiesReturn { + const [state, set] = useState(initialState); + + const multiValue = initialState && typeof initialState === 'object'; + + const canReset = !isEqual(state, defaultValues); + + useEffect(() => { + const restoredValue: T = getStorage(key); + + if (restoredValue) { + if (multiValue) { + set((prevValue) => ({ ...prevValue, ...restoredValue })); + } else { + set(restoredValue); + } + } + }, [key, multiValue]); + + const setState = useCallback( + (updateState: T | Partial) => { + if (multiValue) { + set((prevValue) => { + setStorage(key, { ...prevValue, ...updateState }, options?.daysUntilExpiration); + return { ...prevValue, ...updateState }; + }); + } else { + setStorage(key, updateState as T, options?.daysUntilExpiration); + set(updateState as T); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [key, multiValue] + ); + + const setField = useCallback( + (name: keyof T, updateValue: T[keyof T]) => { + if (multiValue) { + setState({ [name]: updateValue } as Partial); + } + }, + [multiValue, setState] + ); + + const resetState = useCallback(() => { + removeStorage(key); + set(defaultValues); + }, [defaultValues, key]); + + const memoizedValue = useMemo( + () => ({ + state, + setState, + setField, + resetState, + canReset, + }), + [canReset, resetState, setField, setState, state] + ); + + return memoizedValue; +} + +// ---------------------------------------------------------------------- + +function getStorage(key: string) { + try { + const keyName = `${key}=`; + + const cDecoded = decodeURIComponent(document.cookie); + + const cArr = cDecoded.split('; '); + + let res; + + cArr.forEach((val) => { + if (val.indexOf(keyName) === 0) res = val.substring(keyName.length); + }); + + if (res) { + return JSON.parse(res); + } + } catch (error) { + console.error('Error while getting from cookies:', error); + } + + return null; +} + +// ---------------------------------------------------------------------- + +function setStorage(key: string, value: T, daysUntilExpiration: number = 0) { + try { + const serializedValue = encodeURIComponent(JSON.stringify(value)); + let cookieOptions = `${key}=${serializedValue}; path=/`; + + if (daysUntilExpiration > 0) { + const expirationDate = new Date(Date.now() + daysUntilExpiration * 24 * 60 * 60 * 1000); + cookieOptions += `; expires=${expirationDate.toUTCString()}`; + } + + document.cookie = cookieOptions; + } catch (error) { + console.error('Error while setting cookie:', error); + } +} + +// ---------------------------------------------------------------------- + +function removeStorage(key: string) { + try { + document.cookie = `${key}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; + } catch (error) { + console.error('Error while removing cookie:', error); + } +} diff --git a/dashboard/src/hooks/use-copy-to-clipboard.ts b/dashboard/src/hooks/use-copy-to-clipboard.ts new file mode 100644 index 00000000..267d7ec5 --- /dev/null +++ b/dashboard/src/hooks/use-copy-to-clipboard.ts @@ -0,0 +1,40 @@ +import { useMemo, useState, useCallback } from 'react'; + +// ---------------------------------------------------------------------- + +export type UseCopyToClipboardReturn = { + copy: CopyFn; + copiedText: CopiedValue; +}; + +export type CopiedValue = string | null; + +export type CopyFn = (text: string) => Promise; + +export function useCopyToClipboard(): UseCopyToClipboardReturn { + const [copiedText, setCopiedText] = useState(null); + + const copy: CopyFn = useCallback( + async (text) => { + if (!navigator?.clipboard) { + console.warn('Clipboard not supported'); + return false; + } + + try { + await navigator.clipboard.writeText(text); + setCopiedText(text); + return true; + } catch (error) { + console.warn('Copy failed', error); + setCopiedText(null); + return false; + } + }, + [setCopiedText] + ); + + const memoizedValue = useMemo(() => ({ copy, copiedText }), [copy, copiedText]); + + return memoizedValue; +} diff --git a/dashboard/src/hooks/use-countdown.ts b/dashboard/src/hooks/use-countdown.ts new file mode 100644 index 00000000..7c7ddb44 --- /dev/null +++ b/dashboard/src/hooks/use-countdown.ts @@ -0,0 +1,96 @@ +import { useRef, useState, useEffect, useCallback } from 'react'; + +// ---------------------------------------------------------------------- + +export type UseCountdownDateReturn = { + days: string; + hours: string; + minutes: string; + seconds: string; +}; + +export function useCountdownDate(date: Date): UseCountdownDateReturn { + const [countdown, setCountdown] = useState({ + days: '00', + hours: '00', + minutes: '00', + seconds: '00', + }); + + useEffect(() => { + setNewTime(); + const interval = setInterval(setNewTime, 1000); + return () => clearInterval(interval); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const setNewTime = () => { + const startTime = date; + + const endTime = new Date(); + + const distanceToNow = startTime.valueOf() - endTime.valueOf(); + + const getDays = Math.floor(distanceToNow / (1000 * 60 * 60 * 24)); + + const getHours = `0${Math.floor((distanceToNow % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))}`.slice(-2); + + const getMinutes = `0${Math.floor((distanceToNow % (1000 * 60 * 60)) / (1000 * 60))}`.slice(-2); + + const getSeconds = `0${Math.floor((distanceToNow % (1000 * 60)) / 1000)}`.slice(-2); + + setCountdown({ + days: getDays < 10 ? `0${getDays}` : `${getDays}`, + hours: getHours, + minutes: getMinutes, + seconds: getSeconds, + }); + }; + + return countdown; +} + +// Usage +// const countdown = useCountdown(new Date('07/07/2022 21:30')); + +// ---------------------------------------------------------------------- + +export type UseCountdownSecondsReturn = { + counting: boolean; + countdown: number; + startCountdown: () => void; + setCountdown: React.Dispatch>; +}; + +export function useCountdownSeconds(initCountdown: number): UseCountdownSecondsReturn { + const [countdown, setCountdown] = useState(initCountdown); + + const remainingSecondsRef = useRef(countdown); + + const startCountdown = useCallback(() => { + remainingSecondsRef.current = countdown; + + const intervalId = setInterval(() => { + remainingSecondsRef.current -= 1; + + if (remainingSecondsRef.current === 0) { + clearInterval(intervalId); + setCountdown(initCountdown); + } else { + setCountdown(remainingSecondsRef.current); + } + }, 1000); + }, [initCountdown, countdown]); + + const counting = initCountdown > countdown; + + return { + counting, + countdown, + startCountdown, + setCountdown, + }; +} + +// Usage +// const { countdown, startCountdown, counting } = useCountdownSeconds(30); diff --git a/dashboard/src/hooks/use-debounce.ts b/dashboard/src/hooks/use-debounce.ts new file mode 100644 index 00000000..1a5cc551 --- /dev/null +++ b/dashboard/src/hooks/use-debounce.ts @@ -0,0 +1,27 @@ +import { useMemo, useState, useEffect, useCallback } from 'react'; + +// ---------------------------------------------------------------------- + +export type UseDebounceReturn = string; + +export function useDebounce(value: string, delay = 500): UseDebounceReturn { + const [debouncedValue, setDebouncedValue] = useState(value); + + const debounceHandler = useCallback(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + useEffect(() => { + debounceHandler(); + }, [debounceHandler]); + + const memoizedValue = useMemo(() => debouncedValue, [debouncedValue]); + + return memoizedValue; +} diff --git a/dashboard/src/hooks/use-double-click.ts b/dashboard/src/hooks/use-double-click.ts new file mode 100644 index 00000000..5d5b6ead --- /dev/null +++ b/dashboard/src/hooks/use-double-click.ts @@ -0,0 +1,41 @@ +import { useRef, useMemo, useCallback } from 'react'; + +// ---------------------------------------------------------------------- + +export type UseDoubleClickReturn = (event: React.MouseEvent) => void; + +type UseDoubleClickProps = { + timeout?: number; + click?: (e: React.SyntheticEvent) => void; + doubleClick: (e: React.SyntheticEvent) => void; +}; + +export function useDoubleClick({ click, doubleClick, timeout = 250 }: UseDoubleClickProps): UseDoubleClickReturn { + const clickTimeout = useRef(null); + + const clearClickTimeout = useCallback(() => { + if (clickTimeout.current) { + clearTimeout(clickTimeout.current); + clickTimeout.current = null; + } + }, []); + + const handleEvent = useCallback( + (event: React.MouseEvent) => { + clearClickTimeout(); + if (click && event.detail === 1) { + clickTimeout.current = setTimeout(() => { + click(event); + }, timeout); + } + if (event.detail % 2 === 0) { + doubleClick(event); + } + }, + [click, doubleClick, timeout, clearClickTimeout] + ); + + const memoizedValue = useMemo(() => handleEvent, [handleEvent]); + + return memoizedValue; +} diff --git a/dashboard/src/hooks/use-event-listener.ts b/dashboard/src/hooks/use-event-listener.ts new file mode 100644 index 00000000..e8c32da8 --- /dev/null +++ b/dashboard/src/hooks/use-event-listener.ts @@ -0,0 +1,68 @@ +import type { RefObject } from 'react'; + +import { useRef, useEffect, useLayoutEffect } from 'react'; + +// ---------------------------------------------------------------------- + +const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect; + +// Window Event based useEventListener interface +export function useEventListener( + eventName: K, + handler: (event: WindowEventMap[K]) => void, + element?: undefined, + options?: boolean | AddEventListenerOptions +): void; + +// Element Event based useEventListener interface +export function useEventListener( + eventName: K, + handler: (event: HTMLElementEventMap[K]) => void, + element: RefObject, + options?: boolean | AddEventListenerOptions +): void; + +// Document Event based useEventListener interface +export function useEventListener( + eventName: K, + handler: (event: DocumentEventMap[K]) => void, + element: RefObject, + options?: boolean | AddEventListenerOptions +): void; + +export function useEventListener< + KW extends keyof WindowEventMap, + KH extends keyof HTMLElementEventMap, + T extends HTMLElement | void = void, +>( + eventName: KW | KH, + handler: (event: WindowEventMap[KW] | HTMLElementEventMap[KH] | Event) => void, + element?: RefObject, + options?: boolean | AddEventListenerOptions +) { + // Create a ref that stores handler + const savedHandler = useRef(handler); + + useIsomorphicLayoutEffect(() => { + savedHandler.current = handler; + }, [handler]); + + useEffect(() => { + // Define the listening target + const targetElement: T | Window = element?.current || window; + if (!(targetElement && targetElement.addEventListener)) { + return; + } + + // Create event listener that calls handler function stored in ref + const eventListener: typeof handler = (event) => savedHandler.current(event); + + targetElement.addEventListener(eventName, eventListener, options); + + // Remove event listener on cleanup + // eslint-disable-next-line consistent-return + return () => { + targetElement.removeEventListener(eventName, eventListener); + }; + }, [eventName, element, options]); +} diff --git a/dashboard/src/hooks/use-local-storage.ts b/dashboard/src/hooks/use-local-storage.ts new file mode 100644 index 00000000..141331b1 --- /dev/null +++ b/dashboard/src/hooks/use-local-storage.ts @@ -0,0 +1,109 @@ +import { useMemo, useState, useEffect, useCallback } from 'react'; + +import { isEqual } from 'src/utils/helper'; +import { localStorageGetItem } from 'src/utils/storage-available'; + +// ---------------------------------------------------------------------- + +export type UseLocalStorageReturn = { + state: T; + canReset: boolean; + resetState: () => void; + setState: (updateState: T | Partial) => void; + setField: (name: keyof T, updateValue: T[keyof T]) => void; +}; + +export function useLocalStorage(key: string, initialState: T): UseLocalStorageReturn { + const [state, set] = useState(initialState); + + const multiValue = initialState && typeof initialState === 'object'; + + const canReset = !isEqual(state, initialState); + + useEffect(() => { + const restoredValue: T = getStorage(key); + + if (restoredValue) { + if (multiValue) { + set((prevValue) => ({ ...prevValue, ...restoredValue })); + } else { + set(restoredValue); + } + } + }, [key, multiValue]); + + const setState = useCallback( + (updateState: T | Partial) => { + if (multiValue) { + set((prevValue) => { + setStorage(key, { ...prevValue, ...updateState }); + return { ...prevValue, ...updateState }; + }); + } else { + setStorage(key, updateState as T); + set(updateState as T); + } + }, + [key, multiValue] + ); + + const setField = useCallback( + (name: keyof T, updateValue: T[keyof T]) => { + if (multiValue) { + setState({ [name]: updateValue } as Partial); + } + }, + [multiValue, setState] + ); + + const resetState = useCallback(() => { + set(initialState); + removeStorage(key); + }, [initialState, key]); + + const memoizedValue = useMemo( + () => ({ + state, + setState, + setField, + resetState, + canReset, + }), + [canReset, resetState, setField, setState, state] + ); + + return memoizedValue; +} + +// ---------------------------------------------------------------------- + +export function getStorage(key: string) { + try { + const result = localStorageGetItem(key); + + if (result) { + return JSON.parse(result); + } + } catch (error) { + console.error('Error while getting from storage:', error); + } + + return null; +} + +export function setStorage(key: string, value: T) { + try { + const serializedValue = JSON.stringify(value); + window.localStorage.setItem(key, serializedValue); + } catch (error) { + console.error('Error while setting storage:', error); + } +} + +export function removeStorage(key: string) { + try { + window.localStorage.removeItem(key); + } catch (error) { + console.error('Error while removing from storage:', error); + } +} diff --git a/dashboard/src/hooks/use-negative-logo.ts b/dashboard/src/hooks/use-negative-logo.ts new file mode 100644 index 00000000..0a3cb90a --- /dev/null +++ b/dashboard/src/hooks/use-negative-logo.ts @@ -0,0 +1,7 @@ +import { useTheme } from '@mui/material'; + +export function useNegativeLogo(filename: string): string { + const theme = useTheme(); + + return theme.palette.mode === 'dark' ? filename : `${filename}_negative`; +} diff --git a/dashboard/src/hooks/use-responsive.ts b/dashboard/src/hooks/use-responsive.ts new file mode 100644 index 00000000..7e0ca017 --- /dev/null +++ b/dashboard/src/hooks/use-responsive.ts @@ -0,0 +1,56 @@ +import type { Breakpoint } from '@mui/material/styles'; + +import { useMemo } from 'react'; + +import { useTheme } from '@mui/material/styles'; +import useMediaQuery from '@mui/material/useMediaQuery'; + +// ---------------------------------------------------------------------- + +type UseResponsiveReturn = boolean; + +export type Query = 'up' | 'down' | 'between' | 'only'; + +export type Value = Breakpoint | number; + +export function useResponsive(query: Query, start?: Value, end?: Value): UseResponsiveReturn { + const theme = useTheme(); + + const getQuery = useMemo(() => { + switch (query) { + case 'up': + return theme.breakpoints.up(start as Value); + case 'down': + return theme.breakpoints.down(start as Value); + case 'between': + return theme.breakpoints.between(start as Value, end as Value); + case 'only': + return theme.breakpoints.only(start as Breakpoint); + default: + return theme.breakpoints.up('xs'); + } + }, [theme, query, start, end]); + + const mediaQueryResult = useMediaQuery(getQuery); + + return mediaQueryResult; +} + +// ---------------------------------------------------------------------- + +type UseWidthReturn = Breakpoint; + +export function useWidth(): UseWidthReturn { + const theme = useTheme(); + + const keys = useMemo(() => [...theme.breakpoints.keys].reverse(), [theme]); + + const width = keys.reduce((output: Breakpoint | null, key: Breakpoint) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const matches = useMediaQuery(theme.breakpoints.up(key)); + + return !output && matches ? key : output; + }, null); + + return width || 'xs'; +} diff --git a/dashboard/src/hooks/use-scroll-offset-top.ts b/dashboard/src/hooks/use-scroll-offset-top.ts new file mode 100644 index 00000000..4ea328f0 --- /dev/null +++ b/dashboard/src/hooks/use-scroll-offset-top.ts @@ -0,0 +1,57 @@ +'use client'; + +import { useScroll, useMotionValueEvent } from 'framer-motion'; +import { useRef, useMemo, useState, useCallback } from 'react'; + +// ---------------------------------------------------------------------- + +export type UseScrollOffSetTopReturn = { + offsetTop: boolean; + elementRef: React.RefObject; +}; + +export function useScrollOffSetTop(top = 0): UseScrollOffSetTopReturn { + const elementRef = useRef(null); + + const { scrollY } = useScroll(); + + const [offsetTop, setOffsetTop] = useState(false); + + const handleScrollChange = useCallback( + (val: number) => { + const scrollHeight = Math.round(val); + + if (elementRef?.current) { + const rect = elementRef.current.getBoundingClientRect(); + const elementTop = Math.round(rect.top); + + setOffsetTop(elementTop < top); + } else { + setOffsetTop(scrollHeight > top); + } + }, + [elementRef, top] + ); + + useMotionValueEvent( + scrollY, + 'change', + useMemo(() => handleScrollChange, [handleScrollChange]) + ); + + const memoizedValue = useMemo(() => ({ elementRef, offsetTop }), [offsetTop]); + + return memoizedValue; +} + +/* + * 1: Applies to top
    + * const { offsetTop } = useScrollOffSetTop(80); + * + * Or + * + * 2: Applies to element + * const { offsetTop, elementRef } = useScrollOffSetTop(80); + *
    + * + */ diff --git a/dashboard/src/hooks/use-set-state.ts b/dashboard/src/hooks/use-set-state.ts new file mode 100644 index 00000000..aaae28f9 --- /dev/null +++ b/dashboard/src/hooks/use-set-state.ts @@ -0,0 +1,47 @@ +import { useMemo, useState, useCallback } from 'react'; + +import { isEqual } from 'src/utils/helper'; + +// ---------------------------------------------------------------------- + +export type UseSetStateReturn = { + state: T; + canReset: boolean; + onResetState: () => void; + setState: (updateState: T | Partial) => void; + setField: (name: keyof T, updateValue: T[keyof T]) => void; +}; + +export function useSetState(initialState: T): UseSetStateReturn { + const [state, set] = useState(initialState); + + const canReset = !isEqual(state, initialState); + + const setState = useCallback((updateState: T | Partial) => { + set((prevValue) => ({ ...prevValue, ...updateState })); + }, []); + + const setField = useCallback( + (name: keyof T, updateValue: T[keyof T]) => { + setState({ [name]: updateValue } as Partial); + }, + [setState] + ); + + const onResetState = useCallback(() => { + set(initialState); + }, [initialState]); + + const memoizedValue = useMemo( + () => ({ + state, + setState, + setField, + onResetState, + canReset, + }), + [canReset, onResetState, setField, setState, state] + ); + + return memoizedValue; +} diff --git a/dashboard/src/hooks/use-tabs.ts b/dashboard/src/hooks/use-tabs.ts new file mode 100644 index 00000000..2774e4e3 --- /dev/null +++ b/dashboard/src/hooks/use-tabs.ts @@ -0,0 +1,47 @@ +import { useCallback } from 'react'; +import { useRouter, usePathname, useSearchParams } from 'next/navigation'; + +export type UseTabsReturn = { + value: string; + setValue: (newValue: string) => void; + onChange: (event: React.SyntheticEvent, newValue: string) => void; +}; + +export function useTabs(defaultValue: string): UseTabsReturn { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + // Get the 'tab' parameter from the URL + const tab = searchParams.get('tab'); + + // Determine the current tab value + const value = tab || defaultValue; + + // Function to set a new tab value + const setValue = useCallback( + (newValue: string) => { + // Create a new URLSearchParams object to manipulate query parameters + const params = new URLSearchParams(searchParams.toString()); + params.set('tab', newValue); + + // Update the URL without reloading the page + router.push(`${pathname}?${params.toString()}`); + }, + [router, pathname, searchParams] + ); + + // Handle tab change event + const onChange = useCallback( + (event: React.SyntheticEvent, newValue: string) => { + setValue(newValue); + }, + [setValue] + ); + + return { + value, + setValue, + onChange, + }; +} diff --git a/dashboard/src/layouts/auth-centered/index.ts b/dashboard/src/layouts/auth-centered/index.ts new file mode 100644 index 00000000..a7173130 --- /dev/null +++ b/dashboard/src/layouts/auth-centered/index.ts @@ -0,0 +1,3 @@ +export * from './main'; + +export * from './layout'; diff --git a/dashboard/src/layouts/auth-centered/layout.tsx b/dashboard/src/layouts/auth-centered/layout.tsx new file mode 100644 index 00000000..e8bf852b --- /dev/null +++ b/dashboard/src/layouts/auth-centered/layout.tsx @@ -0,0 +1,95 @@ +'use client'; + +import type { Theme, SxProps, Breakpoint } from '@mui/material/styles'; + +import Alert from '@mui/material/Alert'; + +import { useBoolean } from 'src/hooks/use-boolean'; + +import { allLangs } from 'src/locales'; +import { CONFIG } from 'src/config-global'; +import { stylesMode } from 'src/theme/styles'; + +import { Main } from './main'; +import { HeaderBase } from '../core/header-base'; +import { LayoutSection } from '../core/layout-section'; + +// ---------------------------------------------------------------------- + +export type AuthCenteredLayoutProps = { + sx?: SxProps; + children: React.ReactNode; +}; + +export function AuthCenteredLayout({ sx, children }: AuthCenteredLayoutProps) { + const mobileNavOpen = useBoolean(); + + const layoutQuery: Breakpoint = 'md'; + + return ( + + This is an info Alert. + + ), + }} + slotProps={{ container: { maxWidth: false } }} + sx={{ position: { [layoutQuery]: 'fixed' } }} + /> + } + /** ************************************** + * Footer + *************************************** */ + footerSection={null} + /** ************************************** + * Style + *************************************** */ + cssVars={{ + '--layout-auth-content-width': '420px', + }} + sx={{ + '&::before': { + width: 1, + height: 1, + zIndex: 1, + content: "''", + opacity: 0.24, + position: 'fixed', + backgroundSize: 'cover', + backgroundRepeat: 'no-repeat', + backgroundPosition: 'center center', + backgroundImage: `url(${CONFIG.site.basePath}/assets/background/background-3-blur.webp)`, + [stylesMode.dark]: { opacity: 0.08 }, + }, + ...sx, + }} + > +
    {children}
    +
    + ); +} diff --git a/dashboard/src/layouts/auth-centered/main.tsx b/dashboard/src/layouts/auth-centered/main.tsx new file mode 100644 index 00000000..5e8c539d --- /dev/null +++ b/dashboard/src/layouts/auth-centered/main.tsx @@ -0,0 +1,60 @@ +import type { BoxProps } from '@mui/material/Box'; +import type { Breakpoint } from '@mui/material/styles'; + +import Box from '@mui/material/Box'; +import { useTheme } from '@mui/material/styles'; + +import { layoutClasses } from 'src/layouts/classes'; + +// ---------------------------------------------------------------------- + +type MainProps = BoxProps & { + layoutQuery: Breakpoint; +}; + +export function Main({ sx, children, layoutQuery, ...other }: MainProps) { + const theme = useTheme(); + + const renderContent = ( + + {children} + + ); + + return ( + + {renderContent} + + ); +} diff --git a/dashboard/src/layouts/auth-split/index.ts b/dashboard/src/layouts/auth-split/index.ts new file mode 100644 index 00000000..a7173130 --- /dev/null +++ b/dashboard/src/layouts/auth-split/index.ts @@ -0,0 +1,3 @@ +export * from './main'; + +export * from './layout'; diff --git a/dashboard/src/layouts/auth-split/layout.tsx b/dashboard/src/layouts/auth-split/layout.tsx new file mode 100644 index 00000000..0e020e8a --- /dev/null +++ b/dashboard/src/layouts/auth-split/layout.tsx @@ -0,0 +1,108 @@ +'use client'; + +import type { Theme, SxProps, Breakpoint } from '@mui/material/styles'; + +import Alert from '@mui/material/Alert'; + +import { paths } from 'src/routes/paths'; + +import { useBoolean } from 'src/hooks/use-boolean'; + +import { CONFIG } from 'src/config-global'; + +import { Section } from './section'; +import { Main, Content } from './main'; +import { HeaderBase } from '../core/header-base'; +import { LayoutSection } from '../core/layout-section'; + +// ---------------------------------------------------------------------- + +export type AuthSplitLayoutProps = { + sx?: SxProps; + children: React.ReactNode; + section?: { + title?: string; + imgUrl?: string; + subtitle?: string; + }; +}; + +export function AuthSplitLayout({ sx, section, children }: AuthSplitLayoutProps) { + const mobileNavOpen = useBoolean(); + + const layoutQuery: Breakpoint = 'md'; + + return ( + + This is an info Alert. + + ), + }} + slotProps={{ container: { maxWidth: false } }} + sx={{ position: { [layoutQuery]: 'fixed' } }} + /> + } + /** ************************************** + * Footer + *************************************** */ + footerSection={null} + /** ************************************** + * Style + *************************************** */ + sx={sx} + cssVars={{ + '--layout-auth-content-width': '420px', + }} + > +
    +
    + {children} +
    +
    + ); +} diff --git a/dashboard/src/layouts/auth-split/main.tsx b/dashboard/src/layouts/auth-split/main.tsx new file mode 100644 index 00000000..637a53da --- /dev/null +++ b/dashboard/src/layouts/auth-split/main.tsx @@ -0,0 +1,78 @@ +import type { BoxProps } from '@mui/material/Box'; +import type { Breakpoint } from '@mui/material/styles'; + +import Box from '@mui/material/Box'; +import { useTheme } from '@mui/material/styles'; + +import { layoutClasses } from 'src/layouts/classes'; + +// ---------------------------------------------------------------------- + +type MainProps = BoxProps & { + layoutQuery: Breakpoint; +}; + +export function Main({ sx, children, layoutQuery, ...other }: MainProps) { + const theme = useTheme(); + + return ( + + {children} + + ); +} + +// ---------------------------------------------------------------------- + +export function Content({ sx, children, layoutQuery, ...other }: MainProps) { + const theme = useTheme(); + + const renderContent = ( + + {children} + + ); + + return ( + + {renderContent} + + ); +} diff --git a/dashboard/src/layouts/auth-split/section.tsx b/dashboard/src/layouts/auth-split/section.tsx new file mode 100644 index 00000000..057afb5b --- /dev/null +++ b/dashboard/src/layouts/auth-split/section.tsx @@ -0,0 +1,111 @@ +import type { BoxProps } from '@mui/material/Box'; +import type { Breakpoint } from '@mui/material/styles'; + +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Tooltip from '@mui/material/Tooltip'; +import { useTheme } from '@mui/material/styles'; +import Typography from '@mui/material/Typography'; + +import { RouterLink } from 'src/routes/components'; + +import { CONFIG } from 'src/config-global'; +import { varAlpha, bgGradient } from 'src/theme/styles'; + +// ---------------------------------------------------------------------- + +type SectionProps = BoxProps & { + title?: string; + method?: string; + imgUrl?: string; + subtitle?: string; + layoutQuery: Breakpoint; + methods?: { + path: string; + icon: string; + label: string; + }[]; +}; + +export function Section({ + sx, + method, + layoutQuery, + methods, + title = 'Manage the job', + imgUrl = `${CONFIG.site.basePath}/assets/illustrations/illustration-dashboard.webp`, + subtitle = 'More effectively with optimized workflows.', + ...other +}: SectionProps) { + const theme = useTheme(); + + return ( + +
    + + {title} + + + {subtitle && {subtitle}} +
    + + + + {!!methods?.length && method && ( + + {methods.map((option) => { + const selected = method === option.label.toLowerCase(); + + return ( + + + + + + + + ); + })} + + )} + + ); +} diff --git a/dashboard/src/layouts/classes.ts b/dashboard/src/layouts/classes.ts new file mode 100644 index 00000000..5ea88e79 --- /dev/null +++ b/dashboard/src/layouts/classes.ts @@ -0,0 +1,9 @@ +// ---------------------------------------------------------------------- + +export const layoutClasses = { + root: 'layout__root', + main: 'layout__main', + header: 'layout__header', + content: 'layout__main__content', + hasSidebar: 'layout__has__sidebar', +}; diff --git a/dashboard/src/layouts/components/account-button.tsx b/dashboard/src/layouts/components/account-button.tsx new file mode 100644 index 00000000..348a4cc2 --- /dev/null +++ b/dashboard/src/layouts/components/account-button.tsx @@ -0,0 +1,57 @@ +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 { useTheme } from '@mui/material/styles'; +import IconButton from '@mui/material/IconButton'; + +import { varHover, AnimateAvatar } from 'src/components/animate'; + +// ---------------------------------------------------------------------- + +export type AccountButtonProps = IconButtonProps & { + open: boolean; + photoURL: string; + displayName: string; +}; + +export function AccountButton({ open, photoURL, displayName, sx, ...other }: AccountButtonProps) { + const theme = useTheme(); + + const renderFallback = ( + + + + + + + ); + + return ( + + + + {displayName?.charAt(0).toUpperCase()} + + + + ); +} diff --git a/dashboard/src/layouts/components/account-drawer.tsx b/dashboard/src/layouts/components/account-drawer.tsx new file mode 100644 index 00000000..b36a2750 --- /dev/null +++ b/dashboard/src/layouts/components/account-drawer.tsx @@ -0,0 +1,161 @@ +'use client'; + +import type { IconButtonProps } from '@mui/material/IconButton'; + +import { useCallback } from 'react'; + +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Drawer from '@mui/material/Drawer'; +import MenuItem from '@mui/material/MenuItem'; +import { useTheme } from '@mui/material/styles'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; + +import { useRouter } from 'src/routes/hooks'; + +import { useBoolean } from 'src/hooks/use-boolean'; + +import { CONFIG } from 'src/config-global'; +import { varAlpha } from 'src/theme/styles'; + +import { Label } from 'src/components/label'; +import { Iconify } from 'src/components/iconify'; +import { Scrollbar } from 'src/components/scrollbar'; +import { AnimateAvatar } from 'src/components/animate'; + +import { useAuthContext } from 'src/auth/hooks'; + +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; + target?: string; + }[]; +}; + +export function AccountDrawer({ data = [], sx, ...other }: AccountDrawerProps) { + const theme = useTheme(); + + const router = useRouter(); + + const { user } = useAuthContext(); + + const open = useBoolean(false); + + const handleClickItem = useCallback( + (path: string, target: string = '_self') => { + open.onFalse(); + if (target === '_blank') { + window.open(path, target); + } else { + router.push(path); + } + }, + [open, router] + ); + + const renderAvatar = ( + + {user?.displayName?.charAt(0).toUpperCase()} + + ); + + return ( + <> + + + + + + + + + + {renderAvatar} + + + {user?.displayName} + + + + {user?.email} + + + + + {data.map((option) => ( + handleClickItem(option.label === 'Home' ? '/' : option.href, option.target)} + sx={{ + py: 1, + color: 'text.secondary', + '& svg': { width: 24, height: 24 }, + '&:hover': { color: 'text.primary' }, + }} + > + {option.icon} + + + {option.label === 'Home' ? 'Home' : option.label} + + + {option.info && ( + + )} + + ))} + + + + + + Version: {CONFIG.site.version} + + + + Built with from Switzerland + + + + + + + + + + ); +} diff --git a/dashboard/src/layouts/components/account-popover.tsx b/dashboard/src/layouts/components/account-popover.tsx new file mode 100644 index 00000000..4de7cbc4 --- /dev/null +++ b/dashboard/src/layouts/components/account-popover.tsx @@ -0,0 +1,107 @@ +import type { IconButtonProps } from '@mui/material/IconButton'; + +import Box from '@mui/material/Box'; +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 { useRouter } from 'src/routes/hooks'; + +import { Label } from 'src/components/label'; +import { usePopover, 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 router = useRouter(); + + const popover = usePopover(); + + const { user } = useAuthContext(); + + const handleClickItem = (path: string) => { + popover.onClose(); + router.push(path); + }; + + return ( + <> + + + + + + {user?.displayName} + + + + {user?.email} + + + + + + + {data.map((option) => ( + handleClickItem(option.label === 'Home' ? '/' : option.href)} + sx={{ + py: 1, + color: 'text.secondary', + '& svg': { width: 24, height: 24 }, + '&:hover': { color: 'text.primary' }, + }} + > + {option.icon} + + {option.label === 'Home' ? 'Home' : option.label} + + {option.info && ( + + )} + + ))} + + + + + + + + + + ); +} diff --git a/dashboard/src/layouts/components/contacts-popover.tsx b/dashboard/src/layouts/components/contacts-popover.tsx new file mode 100644 index 00000000..40a6c85f --- /dev/null +++ b/dashboard/src/layouts/components/contacts-popover.tsx @@ -0,0 +1,98 @@ +'use client'; + +import type { IconButtonProps } from '@mui/material/IconButton'; + +import { m } from 'framer-motion'; + +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 { fFromNow } from 'src/utils/format-time'; + +import { varHover } from 'src/components/animate'; +import { Scrollbar } from 'src/components/scrollbar'; +import { usePopover, CustomPopover } from 'src/components/custom-popover'; + +// ---------------------------------------------------------------------- + +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 popover = usePopover(); + + return ( + <> + theme.vars.palette.action.selected }), + ...sx, + }} + {...other} + > + + {/* https://icon-sets.iconify.design/solar/users-group-rounded-bold-duotone/ */} + + + + + + + + + + Contacts ({data.length}) + + + + {data.map((contact) => ( + + + + + + + + ))} + + + + ); +} diff --git a/dashboard/src/layouts/components/currency-popover.tsx b/dashboard/src/layouts/components/currency-popover.tsx new file mode 100644 index 00000000..12fb6648 --- /dev/null +++ b/dashboard/src/layouts/components/currency-popover.tsx @@ -0,0 +1,65 @@ +'use client'; + +import type { CurrencyValue } from 'src/types/currency'; +import type { IconButtonProps } from '@mui/material/IconButton'; + +import { m } from 'framer-motion'; +import { useCallback } from 'react'; + +import MenuList from '@mui/material/MenuList'; +import MenuItem from '@mui/material/MenuItem'; +import IconButton from '@mui/material/IconButton'; + +import { Label } from 'src/components/label'; +import { varHover } from 'src/components/animate'; +import { useSettingsContext } from 'src/components/settings'; +import { usePopover, CustomPopover } from 'src/components/custom-popover'; + +// ---------------------------------------------------------------------- + +export type CurrencyPopoverProps = IconButtonProps & { + data?: Array; +}; + +export function CurrencyPopover({ data = [], sx, ...other }: CurrencyPopoverProps) { + const popover = usePopover(); + const { currency, onUpdateField } = useSettingsContext(); + + const handleChangeCurrency = useCallback( + (newCurr: CurrencyValue) => { + if (newCurr !== currency) { + onUpdateField('currency', newCurr); + } + popover.onClose(); + }, + [popover, currency, onUpdateField] + ); + + return ( + <> + + + + + + + {data?.map((option) => ( + handleChangeCurrency(option)}> + {option} + + ))} + + + + ); +} diff --git a/dashboard/src/layouts/components/language-popover.tsx b/dashboard/src/layouts/components/language-popover.tsx new file mode 100644 index 00000000..284309c5 --- /dev/null +++ b/dashboard/src/layouts/components/language-popover.tsx @@ -0,0 +1,78 @@ +'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 MenuList from '@mui/material/MenuList'; +import MenuItem from '@mui/material/MenuItem'; +import IconButton from '@mui/material/IconButton'; + +import { useTranslate } from 'src/locales'; + +import { varHover } from 'src/components/animate'; +import { FlagIcon } from 'src/components/iconify'; +import { usePopover, CustomPopover } from 'src/components/custom-popover'; + +// ---------------------------------------------------------------------- + +export type LanguagePopoverProps = IconButtonProps & { + data?: { + value: string; + label: string; + countryCode: string; + }[]; +}; + +export function LanguagePopover({ data = [], sx, ...other }: LanguagePopoverProps) { + const popover = usePopover(); + + const { onChangeLang, currentLang } = useTranslate(); + + const handleChangeLang = useCallback( + (newLang: LanguageValue) => { + onChangeLang(newLang); + popover.onClose(); + }, + [onChangeLang, popover] + ); + + return ( + <> + + + + + + + {data?.map((option) => ( + handleChangeLang(option.value as LanguageValue)} + > + + {option.label} + + ))} + + + + ); +} diff --git a/dashboard/src/layouts/components/menu-button.tsx b/dashboard/src/layouts/components/menu-button.tsx new file mode 100644 index 00000000..8548c26e --- /dev/null +++ b/dashboard/src/layouts/components/menu-button.tsx @@ -0,0 +1,30 @@ +import type { IconButtonProps } from '@mui/material/IconButton'; + +import SvgIcon from '@mui/material/SvgIcon'; +import IconButton from '@mui/material/IconButton'; + +// ---------------------------------------------------------------------- + +export type MenuButtonProps = IconButtonProps; + +export function MenuButton({ sx, ...other }: IconButtonProps) { + return ( + + + + + + + + ); +} diff --git a/dashboard/src/layouts/components/nav-toggle-button.tsx b/dashboard/src/layouts/components/nav-toggle-button.tsx new file mode 100644 index 00000000..d71be533 --- /dev/null +++ b/dashboard/src/layouts/components/nav-toggle-button.tsx @@ -0,0 +1,58 @@ +import type { IconButtonProps } from '@mui/material/IconButton'; + +import SvgIcon from '@mui/material/SvgIcon'; +import IconButton from '@mui/material/IconButton'; + +import { varAlpha } from 'src/theme/styles'; + +// ---------------------------------------------------------------------- + +export type NavToggleButtonProps = IconButtonProps & { + isNavMini: boolean; +}; + +export function NavToggleButton({ isNavMini, sx, ...other }: NavToggleButtonProps) { + return ( + `1px solid ${varAlpha(theme.vars.palette.grey['500Channel'], 0.12)}`, + transition: (theme) => + theme.transitions.create(['left'], { + easing: 'var(--layout-transition-easing)', + duration: 'var(--layout-transition-duration)', + }), + '&:hover': { + color: 'text.primary', + bgcolor: 'background.neutral', + }, + ...sx, + }} + {...other} + > + + {/* https://icon-sets.iconify.design/eva/arrow-ios-back-fill/ */} + + + + ); +} diff --git a/dashboard/src/layouts/components/notifications-drawer/index.tsx b/dashboard/src/layouts/components/notifications-drawer/index.tsx new file mode 100644 index 00000000..e116e0d5 --- /dev/null +++ b/dashboard/src/layouts/components/notifications-drawer/index.tsx @@ -0,0 +1,167 @@ +'use client'; + +import type { IconButtonProps } from '@mui/material/IconButton'; + +import { m } from 'framer-motion'; +import { useState, useCallback } from 'react'; + +import Tab from '@mui/material/Tab'; +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +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 { useBoolean } from 'src/hooks/use-boolean'; + +import { Label } from 'src/components/label'; +import { Iconify } from 'src/components/iconify'; +import { varHover } from 'src/components/animate'; +import { Scrollbar } from 'src/components/scrollbar'; +import { CustomTabs } from 'src/components/custom-tabs'; + +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[]; +}; + +export function NotificationsDrawer({ data = [], sx, ...other }: NotificationsDrawerProps) { + const drawer = 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/dashboard/src/layouts/components/notifications-drawer/notification-item.tsx b/dashboard/src/layouts/components/notifications-drawer/notification-item.tsx new file mode 100644 index 00000000..730085b9 --- /dev/null +++ b/dashboard/src/layouts/components/notifications-drawer/notification-item.tsx @@ -0,0 +1,237 @@ +import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; +import Button from '@mui/material/Button'; +import Avatar from '@mui/material/Avatar'; +import Typography from '@mui/material/Typography'; +import ListItemText from '@mui/material/ListItemText'; +import ListItemAvatar from '@mui/material/ListItemAvatar'; +import ListItemButton from '@mui/material/ListItemButton'; + +import { fFromNow } from 'src/utils/format-time'; + +import { CONFIG } from 'src/config-global'; + +import { Label } from 'src/components/label'; +import { FileThumbnail } from 'src/components/file-thumbnail'; + +// ---------------------------------------------------------------------- + +export type NotificationItemProps = { + id: string; + type: string; + title: string; + category: string; + isUnRead: boolean; + avatarUrl: string | null; + createdAt: string | number | null; +}; + +export function NotificationItem({ notification }: { notification: NotificationItemProps }) { + const renderAvatar = ( + + {notification.avatarUrl ? ( + + ) : ( + + + + )} + + ); + + const renderText = ( + + } + > + {fFromNow(notification.createdAt)} + {notification.category} + + } + /> + ); + + const renderUnReadBadge = notification.isUnRead && ( + + ); + + const friendAction = ( + + + + + ); + + const projectAction = ( + + + {reader(`

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

    `)} +
    + + +
    + ); + + const fileAction = ( + + + + + + design-suriname-2015.mp3 + + } + secondary={ + + } + > + 2.3 GB + 30 min ago + + } + /> + + + + + ); + + const tagsAction = ( + + + + + + ); + + const paymentAction = ( + + + + + ); + + return ( + `dashed 1px ${theme.vars.palette.divider}`, + }} + > + {renderUnReadBadge} + + {renderAvatar} + + + {renderText} + {notification.type === 'friend' && friendAction} + {notification.type === 'project' && projectAction} + {notification.type === 'file' && fileAction} + {notification.type === 'tags' && tagsAction} + {notification.type === 'payment' && paymentAction} + + + ); +} + +// ---------------------------------------------------------------------- + +function reader(data: string) { + return ( + + ); +} diff --git a/dashboard/src/layouts/components/searchbar/index.tsx b/dashboard/src/layouts/components/searchbar/index.tsx new file mode 100644 index 00000000..336d3dd7 --- /dev/null +++ b/dashboard/src/layouts/components/searchbar/index.tsx @@ -0,0 +1,190 @@ +'use client'; + +import type { BoxProps } from '@mui/material/Box'; +import type { NavSectionProps } from 'src/components/nav-section'; + +import { useState, useCallback } from 'react'; +import parse from 'autosuggest-highlight/parse'; +import match from 'autosuggest-highlight/match'; + +import Box from '@mui/material/Box'; +import SvgIcon from '@mui/material/SvgIcon'; +import InputBase from '@mui/material/InputBase'; +import { useTheme } from '@mui/material/styles'; +import IconButton from '@mui/material/IconButton'; +import InputAdornment from '@mui/material/InputAdornment'; +import Dialog, { dialogClasses } from '@mui/material/Dialog'; + +import { useRouter } from 'src/routes/hooks'; +import { isExternalLink } from 'src/routes/utils'; + +import { useBoolean } from 'src/hooks/use-boolean'; +import { useEventListener } from 'src/hooks/use-event-listener'; + +import { varAlpha } from 'src/theme/styles'; + +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 { useAuthContext } from 'src/auth/hooks'; + +import { ResultItem } from './result-item'; +import { groupItems, applyFilter, getAllItems } from './utils'; + +// ---------------------------------------------------------------------- + +export type SearchbarProps = BoxProps & { + data?: NavSectionProps['data']; +}; + +export function Searchbar({ data: navItems = [], sx, ...other }: SearchbarProps) { + const theme = useTheme(); + const { user } = useAuthContext(); + + const router = useRouter(); + + const search = useBoolean(); + + const [searchQuery, setSearchQuery] = useState(''); + + const handleClose = useCallback(() => { + search.onFalse(); + setSearchQuery(''); + }, [search]); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'k' && event.metaKey) { + search.onToggle(); + setSearchQuery(''); + } + }; + + useEventListener('keydown', handleKeyDown); + + const handleClick = useCallback( + (path: string) => { + if (isExternalLink(path)) { + window.open(path); + } else { + router.push(path); + } + handleClose(); + }, + [handleClose, router] + ); + + const handleSearch = useCallback((event: React.ChangeEvent) => { + setSearchQuery(event.target.value); + }, []); + + const dataFiltered = applyFilter({ + inputData: getAllItems({ data: navItems, permissions: user?.permissions }), + query: searchQuery, + }); + + const notFound = searchQuery && !dataFiltered.length; + + const renderItems = () => { + const dataGroups = groupItems(dataFiltered); + + return Object.keys(dataGroups) + .sort((a, b) => -b.localeCompare(a)) + .map((group, index) => ( + + {dataGroups[group].map((item) => { + const { title, path } = item; + + const partsTitle = parse(title, match(title, searchQuery)); + + const partsPath = parse(path, match(path, searchQuery)); + + return ( + + handleClick(path)} /> + + ); + })} + + )); + }; + + const renderButton = ( + + + {/* https://icon-sets.iconify.design/eva/search-fill/ */} + + + + + + + + ); + + return ( + <> + {renderButton} + + + + + + + } + endAdornment={} + inputProps={{ sx: { typography: 'h6' } }} + /> + + + {notFound ? ( + + ) : ( + {renderItems()} + )} + + + ); +} diff --git a/dashboard/src/layouts/components/searchbar/result-item.tsx b/dashboard/src/layouts/components/searchbar/result-item.tsx new file mode 100644 index 00000000..7b72845f --- /dev/null +++ b/dashboard/src/layouts/components/searchbar/result-item.tsx @@ -0,0 +1,61 @@ +import Box from '@mui/material/Box'; +import ListItemText from '@mui/material/ListItemText'; +import ListItemButton from '@mui/material/ListItemButton'; + +import { useTranslate } from 'src/locales'; +import { varAlpha } from 'src/theme/styles'; + +import { Label } from 'src/components/label'; + +// ---------------------------------------------------------------------- + +type Props = { + title: { + text: string; + highlight: boolean; + }[]; + path: { + text: string; + highlight: boolean; + }[]; + groupLabel: string; + onClickItem: () => void; +}; + +export function ResultItem({ title, path, groupLabel, onClickItem }: Props) { + const { t } = useTranslate(); + + return ( + theme.vars.palette.divider, + '&:hover': { + borderRadius: 1, + borderColor: (theme) => theme.vars.palette.primary.main, + backgroundColor: (theme) => varAlpha(theme.vars.palette.primary.mainChannel, theme.vars.palette.action.hoverOpacity), + }, + }} + > + ( + + {t(part.text)} + + ))} + secondary={path.map((part, index) => ( + + {part.text} + + ))} + /> + + {groupLabel && } + + ); +} diff --git a/dashboard/src/layouts/components/searchbar/utils.ts b/dashboard/src/layouts/components/searchbar/utils.ts new file mode 100644 index 00000000..6e384e44 --- /dev/null +++ b/dashboard/src/layouts/components/searchbar/utils.ts @@ -0,0 +1,103 @@ +import type { NavSectionProps, NavItemBaseProps } from 'src/components/nav-section'; + +import { flattenArray } from 'src/utils/helper'; + +import { hasAllPermissions } from 'src/auth/permissions'; + +// ---------------------------------------------------------------------- + +type ItemProps = { + group: string; + title: string; + path: string; +}; + +export function getAllItems({ data, permissions = [] }: { data: NavSectionProps['data']; permissions: string[] }) { + const reduceItems = data.map((list) => handleLoop(list.items, list.subheader)).flat(); + + const items = flattenArray(reduceItems) + .filter((option) => !option.permissions || hasAllPermissions(option.permissions, permissions)) + .map((option) => { + const group = splitPath(reduceItems, option.path); + + return { + group: group && group.length > 1 ? group[0] : option.subheader, + title: option.title, + path: option.path, + }; + }); + + return items; +} + +// ---------------------------------------------------------------------- + +type ApplyFilterProps = { + inputData: ItemProps[]; + query: string; +}; + +export function applyFilter({ inputData, query }: ApplyFilterProps) { + if (query) { + inputData = inputData.filter( + (item) => item.title.toLowerCase().indexOf(query.toLowerCase()) !== -1 || item.path.toLowerCase().indexOf(query.toLowerCase()) !== -1 + ); + } + + return inputData; +} + +// ---------------------------------------------------------------------- + +export function splitPath(array: NavItemBaseProps[], key: string) { + let stack = array.map((item) => ({ path: [item.title], currItem: item })); + + while (stack.length) { + const { path, currItem } = stack.pop() as { + path: string[]; + currItem: NavItemBaseProps; + }; + + if (currItem.path === key) { + return path; + } + + if (currItem.children?.length) { + stack = stack.concat( + currItem.children.map((item: NavItemBaseProps) => ({ + path: path.concat(item.title), + currItem: item, + })) + ); + } + } + return null; +} + +// ---------------------------------------------------------------------- + +export function handleLoop(array: any, subheader?: string) { + return array?.map((list: any) => ({ + subheader, + ...list, + ...(list.children && { children: handleLoop(list.children, subheader) }), + })); +} + +// ---------------------------------------------------------------------- + +type GroupsProps = { + [key: string]: ItemProps[]; +}; + +export function groupItems(array: ItemProps[]) { + const group = array.reduce((groups: GroupsProps, item) => { + groups[item.group] = groups[item.group] || []; + + groups[item.group].push(item); + + return groups; + }, {}); + + return group; +} diff --git a/dashboard/src/layouts/components/settings-button.tsx b/dashboard/src/layouts/components/settings-button.tsx new file mode 100644 index 00000000..6025b6d7 --- /dev/null +++ b/dashboard/src/layouts/components/settings-button.tsx @@ -0,0 +1,38 @@ +'use client'; + +import type { IconButtonProps } from '@mui/material/IconButton'; + +import Badge from '@mui/material/Badge'; +import SvgIcon from '@mui/material/SvgIcon'; +import IconButton from '@mui/material/IconButton'; + +import { useSettingsContext } from 'src/components/settings/context'; + +// ---------------------------------------------------------------------- + +export type SettingsButtonProps = IconButtonProps; + +export function SettingsButton({ sx, ...other }: SettingsButtonProps) { + const settings = useSettingsContext(); + + return ( + + + + {/* https://yesicon.app/solar/pallete-2-bold-duotone */} + + + + + + ); +} diff --git a/dashboard/src/layouts/components/sign-in-button.tsx b/dashboard/src/layouts/components/sign-in-button.tsx new file mode 100644 index 00000000..7aa1b968 --- /dev/null +++ b/dashboard/src/layouts/components/sign-in-button.tsx @@ -0,0 +1,17 @@ +import type { ButtonProps } from '@mui/material/Button'; + +import Button from '@mui/material/Button'; + +import { RouterLink } from 'src/routes/components'; + +import { CONFIG } from 'src/config-global'; + +// ---------------------------------------------------------------------- + +export function SignInButton({ sx, ...other }: ButtonProps) { + return ( + + ); +} diff --git a/dashboard/src/layouts/components/sign-out-button.tsx b/dashboard/src/layouts/components/sign-out-button.tsx new file mode 100644 index 00000000..76de6c12 --- /dev/null +++ b/dashboard/src/layouts/components/sign-out-button.tsx @@ -0,0 +1,72 @@ +import type { ButtonProps } from '@mui/material/Button'; +import type { Theme, SxProps } from '@mui/material/styles'; + +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/config-global'; + +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 supabaseSignOut } from 'src/auth/context/supabase/action'; + +// ---------------------------------------------------------------------- + +const signOut = (CONFIG.auth.method === 'supabase' && supabaseSignOut) || jwtSignOut; + +type Props = ButtonProps & { + sx?: SxProps; + onClose: () => void; +}; + +export function SignOutButton({ onClose, ...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({ logoutParams: { returnTo: window.location.origin } }); + + onClose?.(); + router.refresh(); + } catch (error) { + console.error(error); + toast.error('Unable to logout!'); + } + }, [onClose, router, signOutAuth0]); + + return ( + + ); +} diff --git a/dashboard/src/layouts/components/workspaces-popover.tsx b/dashboard/src/layouts/components/workspaces-popover.tsx new file mode 100644 index 00000000..aba0c4db --- /dev/null +++ b/dashboard/src/layouts/components/workspaces-popover.tsx @@ -0,0 +1,107 @@ +'use client'; + +import type { ButtonBaseProps } from '@mui/material/ButtonBase'; + +import { useState, useCallback } from 'react'; + +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 { usePopover, 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 popover = usePopover(); + + const mediaQuery = 'sm'; + + const [workspace, setWorkspace] = useState(data[0]); + + const handleChangeWorkspace = useCallback( + (newValue: (typeof data)[0]) => { + setWorkspace(newValue); + popover.onClose(); + }, + [popover] + ); + + return ( + <> + + + + + {workspace?.name} + + + + + + + + + + {data.map((option) => ( + handleChangeWorkspace(option)} + sx={{ height: 48 }} + > + + + + {option.name} + + + + + ))} + + + + ); +} diff --git a/dashboard/src/layouts/config-nav-account.tsx b/dashboard/src/layouts/config-nav-account.tsx new file mode 100644 index 00000000..f83410f7 --- /dev/null +++ b/dashboard/src/layouts/config-nav-account.tsx @@ -0,0 +1,40 @@ +import { paths } from 'src/routes/paths'; + +import { CONFIG } from 'src/config-global'; + +import { Iconify } from 'src/components/iconify'; + +import type { AccountDrawerProps } from './components/account-drawer'; + +// ---------------------------------------------------------------------- + +export const accountNavData: AccountDrawerProps['data'] = [ + { + label: 'Home', + href: paths.wallet.root, + icon: , + }, + { + label: 'Settings', + href: paths.settings.root, + icon: , + }, + { + label: 'Documentation', + href: 'https://docs.numeraire.tech', + icon: , + target: '_blank', + }, + { + label: 'API Reference', + href: `${CONFIG.site.serverUrl}/docs`, + icon: , + target: '_blank', + }, + { + label: 'Support', + href: 'https://numeraire.tech/contact', + icon: , + target: '_blank', + }, +]; diff --git a/dashboard/src/layouts/config-nav-dashboard.tsx b/dashboard/src/layouts/config-nav-dashboard.tsx new file mode 100644 index 00000000..9c80bd93 --- /dev/null +++ b/dashboard/src/layouts/config-nav-dashboard.tsx @@ -0,0 +1,120 @@ +import type { NavGroupProps } from 'src/components/nav-section'; + +import { paths } from 'src/routes/paths'; + +import { CONFIG } from 'src/config-global'; +import { Permission } from 'src/lib/swissknife'; + +import { Iconify } from 'src/components/iconify'; +import { SvgColor } from 'src/components/svg-color'; + +// ---------------------------------------------------------------------- + +const icon = (name: string) => ; +const iconify = (name: string) => ; + +const ICONS = { + user: icon('ic-user'), + lock: icon('ic-lock'), + label: icon('ic-label'), + disabled: icon('ic-disabled'), + external: icon('ic-external'), + menuItem: icon('ic-menu-item'), + dashboard: icon('ic-dashboard'), + parameter: icon('ic-parameter'), + wallet: iconify('solar:wallet-bold-duotone'), + node: iconify('solar:server-minimalistic-bold-duotone'), + invoice: iconify('eva:diagonal-arrow-left-down-fill'), + payment: iconify('eva:diagonal-arrow-right-up-fill'), + lightning: iconify('solar:bolt-bold-duotone'), + nostr: , + contacts: iconify('solar:users-group-rounded-bold-duotone'), + apiKeys: iconify('solar:code-bold-duotone'), +}; + +// ---------------------------------------------------------------------- + +export const navData: Array = [ + /** + * User Wallet + */ + { + subheader: 'wallet', + items: [ + { + title: 'overview', + path: paths.wallet.root, + icon: ICONS.wallet, + }, + { + title: 'payments', + path: paths.wallet.payments, + icon: ICONS.payment, + }, + { + title: 'invoices', + path: paths.wallet.invoices, + icon: ICONS.invoice, + }, + { + title: 'lightning_address', + path: paths.wallet.lightningAddress, + icon: ICONS.lightning, + }, + { + title: 'nostr_address', + path: paths.wallet.nostrAddress, + icon: ICONS.nostr, + }, + { + title: 'contacts', + path: paths.wallet.contacts, + icon: ICONS.contacts, + }, + ], + }, + /** + * Administration + */ + { + subheader: 'administration', + items: [ + { + title: 'node', + path: paths.admin.node, + icon: ICONS.node, + permissions: [Permission.READ_TRANSACTION, Permission.READ_LN_NODE, Permission.READ_LN_ADDRESS], + }, + { + title: 'wallets', + path: paths.admin.wallets, + icon: ICONS.wallet, + permissions: [Permission.READ_WALLET], + }, + { + title: 'payments', + path: paths.admin.payments, + icon: ICONS.payment, + permissions: [Permission.READ_TRANSACTION], + }, + { + title: 'invoices', + path: paths.admin.invoices, + icon: ICONS.invoice, + permissions: [Permission.READ_TRANSACTION], + }, + { + title: 'lightning_addresses', + path: paths.admin.lnAddresses, + icon: ICONS.lightning, + permissions: [Permission.READ_LN_ADDRESS], + }, + { + title: 'api_keys', + path: paths.admin.apiKeys, + icon: ICONS.apiKeys, + permissions: [Permission.READ_API_KEY], + }, + ], + }, +]; diff --git a/dashboard/src/layouts/config-nav-workspace.tsx b/dashboard/src/layouts/config-nav-workspace.tsx new file mode 100644 index 00000000..7d3f38b4 --- /dev/null +++ b/dashboard/src/layouts/config-nav-workspace.tsx @@ -0,0 +1,12 @@ +import { CONFIG } from 'src/config-global'; + +// ---------------------------------------------------------------------- + +export const _workspaces = [ + { + id: 'main', + name: 'Main', + logo: `${CONFIG.site.basePath}/assets/icons/bitcoin/ic-bitcoin.svg`, + plan: 'BTC', + }, +]; diff --git a/dashboard/src/layouts/core/header-base.tsx b/dashboard/src/layouts/core/header-base.tsx new file mode 100644 index 00000000..8b63bf36 --- /dev/null +++ b/dashboard/src/layouts/core/header-base.tsx @@ -0,0 +1,227 @@ +import type { NavSectionProps } from 'src/components/nav-section'; + +import Box from '@mui/material/Box'; +import Link from '@mui/material/Link'; +import Button from '@mui/material/Button'; +import { styled, useTheme } from '@mui/material/styles'; + +import { paths } from 'src/routes/paths'; +import { RouterLink } from 'src/routes/components'; + +import { Logo } from 'src/components/logo'; + +import { HeaderSection } from './header-section'; +import { Searchbar } from '../components/searchbar'; +import { MenuButton } from '../components/menu-button'; +import { SignInButton } from '../components/sign-in-button'; +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 { CurrencyPopover } from '../components/currency-popover'; +import { WorkspacesPopover } from '../components/workspaces-popover'; +import { NotificationsDrawer } from '../components/notifications-drawer'; + +import type { HeaderSectionProps } from './header-section'; +import type { AccountDrawerProps } from '../components/account-drawer'; +import type { CurrencyPopoverProps } from '../components/currency-popover'; +import type { ContactsPopoverProps } from '../components/contacts-popover'; +import type { LanguagePopoverProps } from '../components/language-popover'; +import type { WorkspacesPopoverProps } from '../components/workspaces-popover'; +import type { NotificationsDrawerProps } from '../components/notifications-drawer'; + +// ---------------------------------------------------------------------- + +const StyledDivider = 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' }, +})); + +// ---------------------------------------------------------------------- + +export type HeaderBaseProps = HeaderSectionProps & { + onOpenNav: () => void; + data?: { + nav?: NavSectionProps['data']; + account?: AccountDrawerProps['data']; + langs?: LanguagePopoverProps['data']; + currencies?: CurrencyPopoverProps['data']; + contacts?: ContactsPopoverProps['data']; + workspaces?: WorkspacesPopoverProps['data']; + notifications?: NotificationsDrawerProps['data']; + }; + slots?: { + navMobile?: { + topArea?: React.ReactNode; + bottomArea?: React.ReactNode; + }; + }; + slotsDisplay?: { + signIn?: boolean; + account?: boolean; + helpLink?: boolean; + settings?: boolean; + purchase?: boolean; + contacts?: boolean; + searchbar?: boolean; + workspaces?: boolean; + menuButton?: boolean; + localization?: boolean; + currencies?: boolean; + notifications?: boolean; + }; +}; + +export function HeaderBase({ + sx, + data, + slots, + slotProps, + onOpenNav, + layoutQuery, + slotsDisplay: { + signIn = true, + account = true, + helpLink = true, + settings = true, + purchase = true, + contacts = true, + searchbar = true, + workspaces = true, + menuButton = true, + localization = true, + currencies = true, + notifications = true, + } = {}, + ...other +}: HeaderBaseProps) { + const theme = useTheme(); + + return ( + + {slots?.leftAreaStart} + + {/* -- Menu button -- */} + {menuButton && ( + + )} + + {/* -- Logo -- */} + + + {/* -- Divider -- */} + + + {/* -- Workspace popover -- */} + {workspaces && } + + {slots?.leftAreaEnd} + + ), + rightArea: ( + <> + {slots?.rightAreaStart} + + + {/* -- Help link -- */} + {helpLink && ( + + Need help? + + )} + + {/* -- Searchbar -- */} + {searchbar && } + + {/* -- Language popover -- */} + {localization && } + + {/* -- Currencies popover -- */} + {currencies && } + + {/* -- Notifications popover -- */} + {notifications && } + + {/* -- Contacts popover -- */} + {contacts && } + + {/* -- Settings button -- */} + {settings && } + + {/* -- Account drawer -- */} + {account && } + + {/* -- Sign in button -- */} + {signIn && } + + {/* -- Purchase button -- */} + {purchase && ( + + )} + + + {slots?.rightAreaEnd} + + ), + }} + slotProps={slotProps} + {...other} + /> + ); +} diff --git a/dashboard/src/layouts/core/header-section.tsx b/dashboard/src/layouts/core/header-section.tsx new file mode 100644 index 00000000..08c32125 --- /dev/null +++ b/dashboard/src/layouts/core/header-section.tsx @@ -0,0 +1,125 @@ +import type { Breakpoint } from '@mui/material/styles'; +import type { AppBarProps } from '@mui/material/AppBar'; +import type { ToolbarProps } from '@mui/material/Toolbar'; +import type { ContainerProps } from '@mui/material/Container'; + +import Box from '@mui/material/Box'; +import AppBar from '@mui/material/AppBar'; +import Toolbar from '@mui/material/Toolbar'; +import Container from '@mui/material/Container'; +import { styled, useTheme } from '@mui/material/styles'; + +import { useScrollOffSetTop } from 'src/hooks/use-scroll-offset-top'; + +import { bgBlur, varAlpha } from 'src/theme/styles'; + +import { layoutClasses } from '../classes'; + +// ---------------------------------------------------------------------- + +const StyledElevation = styled('span')(({ theme }) => ({ + left: 0, + right: 0, + bottom: 0, + m: 'auto', + height: 24, + zIndex: -1, + opacity: 0.48, + borderRadius: '50%', + position: 'absolute', + width: `calc(100% - 48px)`, + boxShadow: theme.customShadows.z8, +})); + +// ---------------------------------------------------------------------- + +export type HeaderSectionProps = AppBarProps & { + layoutQuery: Breakpoint; + disableOffset?: boolean; + disableElevation?: boolean; + slots?: { + leftArea?: React.ReactNode; + leftAreaEnd?: React.ReactNode; + leftAreaStart?: React.ReactNode; + rightArea?: React.ReactNode; + rightAreaEnd?: React.ReactNode; + rightAreaStart?: React.ReactNode; + topArea?: React.ReactNode; + centerArea?: React.ReactNode; + bottomArea?: React.ReactNode; + }; + slotProps?: { + toolbar?: ToolbarProps; + container?: ContainerProps; + }; +}; + +export function HeaderSection({ sx, slots, slotProps, disableOffset, disableElevation, layoutQuery = 'md', ...other }: HeaderSectionProps) { + const theme = useTheme(); + + const { offsetTop } = useScrollOffSetTop(); + + const toolbarStyles = { + default: { + minHeight: 'auto', + height: 'var(--layout-header-mobile-height)', + transition: theme.transitions.create(['height', 'background-color'], { + easing: theme.transitions.easing.easeInOut, + duration: theme.transitions.duration.shorter, + }), + [theme.breakpoints.up('sm')]: { + minHeight: 'auto', + }, + [theme.breakpoints.up(layoutQuery)]: { + height: 'var(--layout-header-desktop-height)', + }, + }, + offset: { + ...bgBlur({ color: varAlpha(theme.vars.palette.background.defaultChannel, 0.8) }), + }, + }; + + return ( + + {slots?.topArea} + + + + {slots?.leftArea} + + {slots?.centerArea} + + {slots?.rightArea} + + + + {slots?.bottomArea} + + {!disableElevation && offsetTop && } + + ); +} diff --git a/dashboard/src/layouts/core/layout-section.tsx b/dashboard/src/layouts/core/layout-section.tsx new file mode 100644 index 00000000..0235f02f --- /dev/null +++ b/dashboard/src/layouts/core/layout-section.tsx @@ -0,0 +1,62 @@ +'use client'; + +import type { Theme, SxProps, CSSObject } from '@mui/material/styles'; + +import Box from '@mui/material/Box'; +import GlobalStyles from '@mui/material/GlobalStyles'; + +import { layoutClasses } from '../classes'; + +// ---------------------------------------------------------------------- + +export type LayoutSectionProps = { + 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 }: LayoutSectionProps) { + const inputGlobalStyles = ( + + ); + + return ( + <> + {inputGlobalStyles} + + + {sidebarSection ? ( + <> + {sidebarSection} + + {headerSection} + {children} + {footerSection} + + + ) : ( + <> + {headerSection} + {children} + {footerSection} + + )} + + + ); +} diff --git a/dashboard/src/layouts/dashboard/index.ts b/dashboard/src/layouts/dashboard/index.ts new file mode 100644 index 00000000..a7173130 --- /dev/null +++ b/dashboard/src/layouts/dashboard/index.ts @@ -0,0 +1,3 @@ +export * from './main'; + +export * from './layout'; diff --git a/dashboard/src/layouts/dashboard/layout.tsx b/dashboard/src/layouts/dashboard/layout.tsx new file mode 100644 index 00000000..ca35f85c --- /dev/null +++ b/dashboard/src/layouts/dashboard/layout.tsx @@ -0,0 +1,268 @@ +'use client'; + +import type { SettingsState } from 'src/components/settings'; +import type { NavSectionProps } from 'src/components/nav-section'; +import type { Theme, SxProps, CSSObject, Breakpoint } from '@mui/material/styles'; + +import { useMemo } from 'react'; +import Script from 'next/script'; + +import Alert from '@mui/material/Alert'; +import { useTheme } from '@mui/material/styles'; +import { iconButtonClasses } from '@mui/material/IconButton'; + +import { useBoolean } from 'src/hooks/use-boolean'; + +import { allLangs } from 'src/locales'; +import { currencies } from 'src/assets/data'; +import { varAlpha, stylesMode } from 'src/theme/styles'; + +import { bulletColor } from 'src/components/nav-section'; +import { useSettingsContext } from 'src/components/settings'; + +import { Main } from './main'; +import { NavMobile } from './nav-mobile'; +import { layoutClasses } from '../classes'; +import { NavVertical } from './nav-vertical'; +import { NavHorizontal } from './nav-horizontal'; +import { HeaderBase } from '../core/header-base'; +import { _workspaces } from '../config-nav-workspace'; +import { accountNavData } from '../config-nav-account'; +import { LayoutSection } from '../core/layout-section'; +import { navData as dashboardNavData } from '../config-nav-dashboard'; + +// ---------------------------------------------------------------------- + +export type DashboardLayoutProps = { + sx?: SxProps; + children: React.ReactNode; + data?: { + nav?: NavSectionProps['data']; + }; +}; + +export function DashboardLayout({ sx, children, data }: DashboardLayoutProps) { + const theme = useTheme(); + + const mobileNavOpen = useBoolean(); + + const settings = useSettingsContext(); + + const navColorVars = useNavColorVars(theme, settings); + + const layoutQuery: Breakpoint = 'lg'; + + const navData = data?.nav ?? dashboardNavData; + + const isNavMini = settings.navLayout === 'mini'; + + const isNavHorizontal = settings.navLayout === 'horizontal'; + + const isNavVertical = isNavMini || settings.navLayout === 'vertical'; + + return ( + <> +