├── .env ├── .eslintignore ├── .eslintrc.json ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── config.yml ├── dependabot.yml ├── release-draft-template.yml ├── scripts │ └── check-release.sh └── workflows │ ├── publish-build.yml │ ├── release-drafter.yml │ └── tests.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── .storybook ├── main.js └── preview.js ├── .yarn └── releases │ └── yarn-4.6.0.cjs ├── .yarnrc.yml ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── assets ├── logo.svg ├── storybook.png └── trumen_quick_loop.gif ├── bors.toml ├── cypress.config.js ├── cypress ├── e2e │ ├── side-panel.cy.js │ ├── test-api-key-query-param.cy.js │ ├── test-api-key-required.cy.js │ ├── test-interface.cy.js │ ├── test-no-api-key-required.cy.js │ ├── test-no-meilisearch.cy.js │ ├── test-search.cy.js │ └── test-select-indexes.cy.js ├── fixtures │ ├── movies.json │ └── pokemon.json └── support │ ├── commands.js │ └── e2e.js ├── jsconfig.json ├── package.json ├── public ├── favicon-32x32.png ├── fonts │ ├── Barlow │ │ └── regular.woff2 │ └── Work_Sans │ │ ├── bold.woff2 │ │ ├── light.woff2 │ │ ├── medium.woff2 │ │ └── regular.woff2 ├── index.html ├── logo.svg ├── manifest.json └── robots.txt ├── src ├── App.js ├── GlobalStyle.js ├── components │ ├── ApiKeyAwarenessBanner.js │ ├── ApiKeyModalContent.js │ ├── Badge.js │ ├── Body.js │ ├── BodyWrapper.js │ ├── Box.js │ ├── Button.js │ ├── Card.js │ ├── Checkbox.js │ ├── Container.js │ ├── EmptyView.js │ ├── Header │ │ ├── HelpCenter.js │ │ └── index.js │ ├── IconButton.js │ ├── Input.js │ ├── Link.js │ ├── Modal.js │ ├── NewsletterForm.jsx │ ├── NoMeilisearchRunning.js │ ├── NoSelectOption.js │ ├── OnBoarding.js │ ├── Results │ │ ├── Highlight.js │ │ ├── Hit.js │ │ ├── InfiniteHits.js │ │ ├── NoResultForRequest.js │ │ └── index.js │ ├── RightPanel.jsx │ ├── ScrollToTop.js │ ├── SearchBox.js │ ├── Select.js │ ├── Sidebar.js │ ├── Stats.js │ ├── Toggle.js │ ├── Typography.js │ └── icons │ │ ├── AlertCircle.js │ │ ├── ArrowDown.js │ │ ├── Cross.js │ │ ├── DiscordLogo.js │ │ ├── DocumentBig.js │ │ ├── DocumentMedium.js │ │ ├── GithubLogo.js │ │ ├── Indexes.js │ │ ├── InterrogationMark.js │ │ ├── Key.js │ │ ├── KeyBig.js │ │ ├── LogoText.js │ │ ├── MeilisearchLogo.js │ │ ├── Picture.js │ │ ├── SearchMedium.js │ │ ├── SearchSmall.js │ │ ├── SettingsBig.js │ │ ├── SettingsMedium.js │ │ ├── Speed.js │ │ ├── heroicons │ │ ├── AcademicHatIcon.jsx │ │ ├── ArrowPathIcon.jsx │ │ ├── ChatBubbleIcon.jsx │ │ ├── CheckIcon.jsx │ │ ├── CloseIcon.jsx │ │ ├── LifebuoyIcon.jsx │ │ └── MenuBarsIcon.jsx │ │ ├── index.js │ │ └── svg │ │ ├── alert-circle.svg │ │ ├── arrow_down.svg │ │ ├── cross.svg │ │ ├── discord-logo.svg │ │ ├── document_big.svg │ │ ├── document_medium.svg │ │ ├── github_logo.svg │ │ ├── indexes.svg │ │ ├── interrogation_mark.svg │ │ ├── key.svg │ │ ├── key_big.svg │ │ ├── logo_text.svg │ │ ├── meilisearch_logo.svg │ │ ├── picture.svg │ │ ├── search_medium.svg │ │ ├── search_small.svg │ │ ├── settings_big.svg │ │ ├── settings_medium.svg │ │ └── speed.svg ├── context │ ├── ApiKeyContext.js │ └── MeilisearchClientContext.js ├── hooks │ ├── useLocalStorage.js │ └── useNewsletter.js ├── index.js ├── stories │ ├── Badge.stories.js │ ├── Button.stories.js │ ├── Card.stories.js │ ├── Checkbox.stories.js │ ├── Container.stories.js │ ├── EmptyView.stories.js │ ├── IconButton.stories.js │ ├── Icons.stories.js │ ├── Input.stories.js │ ├── Link.stories.js │ ├── Modal.stories.js │ ├── Select.stories.js │ ├── Sidebar.stories.js │ ├── Stats.stories.js │ ├── Toggle.stories.js │ └── Typography.stories.js ├── theme.js ├── utils │ ├── getIndexesListWithStats.js │ ├── hasAnApiKeySet.js │ ├── isCloudBannerEnabled.js │ └── shouldDisplayApiKeyModal.js └── version │ ├── client-agents.js │ └── version.js ├── validate-env.js ├── version-script.js └── yarn.lock /.env: -------------------------------------------------------------------------------- 1 | # Hubspot newsletter 2 | REACT_APP_HUBSPOT_PORTAL_ID=25945010 3 | REACT_APP_HUBSPOT_FORM_GUID=991e2a09-77c2-4428-9242-ebf26bfc6c64 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build/ 2 | data.ms/ 3 | public/ 4 | node_modules/ 5 | build/* 6 | src/components/icons/* -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "browser": true, 5 | "es2021": true 6 | }, 7 | "parserOptions": { 8 | "ecmaVersion": 2020 9 | }, 10 | "extends": [ 11 | "react-app", 12 | "airbnb", 13 | "plugin:jsx-a11y/recommended", 14 | "plugin:cypress/recommended", 15 | "eslint-config-prettier" 16 | ], 17 | "plugins": ["jsx-a11y", "prettier"], 18 | "settings": { 19 | "import/resolver": { 20 | "node": { 21 | "paths": ["src"] 22 | } 23 | } 24 | }, 25 | "rules": { 26 | "semi": 0, 27 | "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }], 28 | "prettier/prettier": [ 29 | "error", 30 | { 31 | "semi": false 32 | } 33 | ], 34 | "react/prop-types": 0, 35 | "jsx-indent-props": 0, 36 | "react/jsx-props-no-spreading": 0, 37 | "react/no-array-index-key": 0, 38 | "no-underscore-dangle": 0, 39 | "react-hooks/exhaustive-deps": 0, 40 | "react/function-component-definition": [ 41 | 2, 42 | { "namedComponents": "arrow-function" } 43 | ], 44 | "react/no-arrow-function-lifecycle": 0, 45 | "react/no-invalid-html-attribute": 0, 46 | "react/jsx-no-useless-fragment": "off", 47 | "react/no-unused-class-component-methods": 0, 48 | "react/require-default-props": "off" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 🐞 3 | about: Create a report to help us improve. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | 10 | 11 | **Description** 12 | Description of what the bug is about. 13 | 14 | **Expected behavior** 15 | What you expected to happen. 16 | 17 | **Current behavior** 18 | What happened. 19 | 20 | **Screenshots or Logs** 21 | If applicable, add screenshots or logs to help explain your problem. 22 | 23 | **Environment (please complete the following information):** 24 | - Meilisearch version: [e.g. v.0.24.0] 25 | - Browser: [e.g. Chrome version 90.0] 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Meilisearch's bug 4 | url: https://github.com/meilisearch/meilisearch/issues 5 | about: Please report issues regarding Meilisearch's code base here 6 | - name: Feature request & feedback about Meilisearch 7 | url: https://github.com/meilisearch/product/discussions/categories/feedback-feature-proposal 8 | about: Please open a discussion or comment an already-existing one in our dedicated product repository 9 | - name: Support questions & other 10 | url: https://discord.meilisearch.com/ 11 | about: Support is not handled here but on our Discord 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | schedule: 6 | interval: 'monthly' 7 | labels: 8 | - skip-changelog 9 | - dependencies 10 | -------------------------------------------------------------------------------- /.github/release-draft-template.yml: -------------------------------------------------------------------------------- 1 | name-template: 'v$RESOLVED_VERSION 🌻' 2 | tag-template: 'v$RESOLVED_VERSION' 3 | exclude-labels: 4 | - 'skip-changelog' 5 | version-resolver: 6 | minor: 7 | labels: 8 | - 'breaking-change' 9 | default: patch 10 | categories: 11 | - title: '⚠️ Breaking changes' 12 | label: 'breaking-change' 13 | - title: '🚀 Enhancements' 14 | label: 'enhancement' 15 | - title: '🐛 Bug Fixes' 16 | label: 'bug' 17 | - title: '🔒 Security' 18 | label: 'security' 19 | - title: '⚙️ Maintenance/misc' 20 | label: 21 | - 'maintenance' 22 | - 'documentation' 23 | template: | 24 | ## Changes 25 | 26 | $CHANGES 27 | 28 | Thanks again to $CONTRIBUTORS! 🎉 29 | no-changes-template: 'Changes are coming soon 😎' 30 | sort-direction: 'ascending' 31 | replacers: 32 | - search: '/(?:and )?@dependabot-preview(?:\[bot\])?,?/g' 33 | replace: '' 34 | - search: '/(?:and )?@dependabot(?:\[bot\])?,?/g' 35 | replace: '' 36 | - search: '/(?:and )?@meili-bors(?:\[bot\])?,?/g' 37 | replace: '' 38 | - search: '/(?:and )?@meili-bot,?/g' 39 | replace: '' 40 | -------------------------------------------------------------------------------- /.github/scripts/check-release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Checking if current tag matches the package version 4 | current_tag=$(echo $GITHUB_REF | tr -d 'refs/tags/v') 5 | file_tag=$(grep '"version":' package.json | cut -d ':' -f 2- | tr -d ' ' | tr -d '"' | tr -d ',') 6 | 7 | package_file_tag=$(grep '"version":' package.json | cut -d ':' -f 2- | tr -d ' ' | tr -d '"' | tr -d ',') 8 | package_file_name='package.json' 9 | version_file_tag=$(grep "export default" src/version/version.js | cut -d " " -f 3- | tr -d " " | tr -d "'") 10 | version_file_name='src/version/version.js' 11 | 12 | if [ "$current_tag" != "$file_tag" ]; then 13 | echo "Error: the current tag does not match the version in package file(s)." 14 | echo "$package_file_name: $current_tag vs $package_file_tag" 15 | echo "$version_file_name: $current_tag vs $version_file_tag" 16 | exit 1 17 | fi 18 | 19 | echo 'OK' 20 | exit 0 21 | -------------------------------------------------------------------------------- /.github/workflows/publish-build.yml: -------------------------------------------------------------------------------- 1 | name: publish-build 2 | on: 3 | release: 4 | types: [released] 5 | 6 | jobs: 7 | build-project: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: Setup node and cache 12 | uses: actions/setup-node@v4 13 | with: 14 | node-version: 18 15 | cache: "yarn" 16 | cache-dependency-path: yarn.lock 17 | - name: Check release validity 18 | run: sh .github/scripts/check-release.sh 19 | - name: Install project dependencies 20 | run: yarn install 21 | - name: Build project 22 | run: yarn build 23 | - name: Create ZIP folder 24 | uses: thedoctor0/zip-release@master 25 | with: 26 | filename: 'build.zip' 27 | directory: 'build' 28 | - name: Upload build to release 29 | uses: svenstaro/upload-release-action@v2 30 | with: 31 | repo_token: ${{ secrets.GITHUB_TOKEN }} 32 | file: build/build.zip 33 | tag: ${{ github.ref }} 34 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | update_release_draft: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: release-drafter/release-drafter@v5 13 | with: 14 | config-name: release-draft-template.yml 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.RELEASE_DRAFTER_TOKEN }} 17 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | # trying and staging branches are for BORS config 7 | branches: 8 | - trying 9 | - staging 10 | - main 11 | merge_group: 12 | 13 | jobs: 14 | linter_check: 15 | runs-on: ubuntu-latest 16 | name: linter-check 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version: 18 22 | cache: "yarn" 23 | cache-dependency-path: yarn.lock 24 | - name: Enable Corepack 25 | run: corepack enable 26 | - name: Install dev dependencies 27 | run: yarn --frozen-lockfile 28 | - name: Run style check 29 | run: yarn lint && yarn prettier-check 30 | cypress_no_meilisearch: 31 | runs-on: ubuntu-latest 32 | container: 33 | image: cypress/browsers:latest 34 | options: --user 1001 35 | steps: 36 | - uses: actions/checkout@v4 37 | - name: Setup node and cache 38 | uses: actions/setup-node@v4 39 | with: 40 | node-version: 18 41 | cache: "yarn" 42 | cache-dependency-path: yarn.lock 43 | - name: Enable Corepack 44 | run: corepack enable 45 | - name: Install dependencies 46 | run: yarn --frozen-lockfile 47 | - name: Test no meilisearch running 48 | uses: cypress-io/github-action@v6 49 | with: 50 | start: yarn start:ci 51 | wait-on: 'http://0.0.0.0:3000' 52 | command: yarn cy:run:test-no-meilisearch 53 | config-file: cypress.config.js 54 | - uses: actions/upload-artifact@v4 55 | if: failure() 56 | with: 57 | name: cypress-screenshots 58 | path: cypress/screenshots 59 | - uses: actions/upload-artifact@v4 60 | if: failure() 61 | with: 62 | name: cypress-videos 63 | path: cypress/videos 64 | cypress_meilisearch-no-api-key: 65 | runs-on: ubuntu-latest 66 | container: 67 | image: cypress/browsers:latest 68 | options: --user 1001 69 | services: 70 | meilisearch: 71 | image: getmeili/meilisearch:latest 72 | env: 73 | MEILI_NO_ANALYTICS: 'true' 74 | ports: 75 | - '7700:7700' 76 | steps: 77 | - uses: actions/checkout@v4 78 | - name: Setup node and cache 79 | uses: actions/setup-node@v4 80 | with: 81 | node-version: 18 82 | cache: "yarn" 83 | cache-dependency-path: yarn.lock 84 | - name: Enable Corepack 85 | run: corepack enable 86 | - name: Install dependencies 87 | run: yarn --frozen-lockfile 88 | - name: Test 89 | uses: cypress-io/github-action@v6 90 | env: 91 | CYPRESS_host: http://meilisearch:7700 92 | with: 93 | start: yarn start:ci 94 | wait-on: 'http://0.0.0.0:3000' 95 | wait-on-timeout: 120 96 | command: yarn cy:run 97 | config-file: cypress.config.js 98 | - uses: actions/upload-artifact@v4 99 | if: failure() 100 | with: 101 | name: cypress-screenshots 102 | path: cypress/screenshots 103 | - uses: actions/upload-artifact@v4 104 | if: failure() 105 | with: 106 | name: cypress-videos 107 | path: cypress/videos 108 | cypress_meilisearch-api-key: 109 | runs-on: ubuntu-latest 110 | container: 111 | image: cypress/browsers:latest 112 | options: --user 1001 113 | services: 114 | meilisearch: 115 | image: getmeili/meilisearch:latest 116 | env: 117 | MEILI_MASTER_KEY: 'masterKey' 118 | MEILI_NO_ANALYTICS: 'true' 119 | ports: 120 | - '7700:7700' 121 | steps: 122 | - uses: actions/checkout@v4 123 | - name: Setup node and cache 124 | uses: actions/setup-node@v4 125 | with: 126 | node-version: 18 127 | cache: "yarn" 128 | cache-dependency-path: yarn.lock 129 | - name: Enable Corepack 130 | run: corepack enable 131 | - name: Install dependencies 132 | run: yarn --frozen-lockfile 133 | - name: Test 134 | uses: cypress-io/github-action@v6 135 | env: 136 | CYPRESS_host: http://meilisearch:7700 137 | with: 138 | start: yarn start:ci 139 | wait-on: 'http://0.0.0.0:3000' 140 | command: yarn cy:run:test-api-key-required 141 | config-file: cypress.config.js 142 | - uses: actions/upload-artifact@v4 143 | if: failure() 144 | with: 145 | name: cypress-screenshots 146 | path: cypress/screenshots 147 | - uses: actions/upload-artifact@v4 148 | if: failure() 149 | with: 150 | name: cypress-videos 151 | path: cypress/videos 152 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/sdks 12 | !.yarn/versions 13 | 14 | # testing 15 | /coverage 16 | 17 | # production 18 | /build 19 | 20 | # misc 21 | .DS_Store 22 | .env.local 23 | .env.development.local 24 | .env.test.local 25 | .env.production.local 26 | 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | 31 | # meilisearch 32 | /data.ms 33 | meilisearch 34 | 35 | # Cypress 36 | cypress/screenshots 37 | cypress/videos 38 | cypress/plugins 39 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.20.5 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build/ 2 | data.ms/ 3 | assets/ 4 | public/ 5 | node_modules/ 6 | .github/ 7 | .storybook/ 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ["../src/**/*.stories.@(js|jsx|ts|tsx)"], 3 | 4 | addons: [ 5 | "@storybook/addon-links", 6 | "@storybook/addon-essentials", 7 | "@storybook/preset-create-react-app" 8 | ], 9 | 10 | staticDirs: ['../public'], 11 | 12 | framework: { 13 | name: "@storybook/react-webpack5", 14 | options: {} 15 | }, 16 | 17 | docs: { 18 | autodocs: true 19 | } 20 | } -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import theme from 'theme' 3 | import GlobalStyle from 'GlobalStyle' 4 | 5 | import { ThemeProvider } from 'styled-components' 6 | 7 | export const decorators = [ 8 | (Story) => ( 9 | 10 | 11 | 12 | 13 | ), 14 | ] 15 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | yarnPath: .yarn/releases/yarn-4.6.0.cjs 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine 2 | 3 | WORKDIR /home/node/app 4 | 5 | RUN chown -R node:node /home/node/app 6 | 7 | USER node 8 | 9 | COPY package*.json ./ 10 | 11 | COPY --chown=node:node . . 12 | RUN yarn install 13 | 14 | CMD ["yarn", "start"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2025 Meili SAS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Meilisearch logo 3 |

4 | 5 |

Mini Dashboard

6 | 7 | --- 8 | 9 | 🚨 IMPORTANT NOTICE: Reduced Maintenance & Support 🚨 10 | 11 | *Dear Community,* 12 | 13 | *We'd like to share some updates regarding the future maintenance of this repository:* 14 | 15 | *Our team is small, and our availability will be reduced in the upcoming times. As such, response times might be slower, and we will not be accepting enhancements for this repository moving forward.* 16 | 17 | *If you're looking for reliable alternatives, consider using [Meilisearch Cloud](https://meilisearch.com/cloud?utm_campaign=oss&utm_source=github&utm_medium=minidashboard). For instance, it offers a convenient solution for managing your index settings.* 18 | 19 | *Seeking immediate support? Please join us on [our Discord server](https://discord.meilisearch.com).* 20 | 21 | --- 22 | 23 |

24 | Website | 25 | Meilisearch Cloud | 26 | Blog | 27 | Documentation | 28 | Discord 29 |

30 | 31 | > Meilisearch is an open-source search engine that offers fast, relevant search out of the box. 32 | 33 | 👉 [Meilisearch repository](https://github.com/meilisearch/meilisearch) 34 | 35 |

Meilisearch's mini-dashboard. A web-app served by the engine with a minimal search experience on your data.

36 | 37 |
38 |

39 | Web interface gif 40 |

41 |
42 | 43 | **Table of Contents**: 44 | 45 | - [Setup](#setup) 46 | - [Run](#run) 47 | - [Build](#build) 48 | - [Generate build](#generate-build) 49 | - [Specify Meilisearch's server URL](#specify-meilisearchs-server-url) 50 | - [Run your build](#run-your-build) 51 | - [Storybook](#storybook) 52 | - [Contributing](#contributing) 53 | 54 |
55 | 56 | This repository uses [Yarn 4.x](https://yarnpkg.com/) to manage dependencies and [NVM](https://github.com/nvm-sh/nvm) to manage Node version. See [current version](.nvmrc). 57 | 58 | ## Setup 59 | 60 | ```bash 61 | yarn 62 | ``` 63 | 64 | ## Run 65 | 66 | ```bash 67 | yarn start 68 | ``` 69 | 70 | Go to `http://localhost:3000/` and enjoy ! 🎉 71 | 72 | ## Build 73 | 74 | ### Generate build 75 | 76 | You can generate a build of this project with the following command: 77 | 78 | ```bash 79 | yarn build 80 | ``` 81 | 82 | ### Specify Meilisearch's server URL 83 | 84 | ⚠️ By default, the application will call Meilisearch at the exact same address as it is running. 85 | Example: if your app is running at `http://localhost:5000`, it will try to call `http://localhost:5000/indexes` to retrieve the list of your indexes. 86 | 87 | If you want to specify the URL where your Meilisearch is running, use the `REACT_APP_MEILI_SERVER_ADDRESS` environment variable. 88 | 89 | Example: 90 | 91 | ```bash 92 | REACT_APP_MEILI_SERVER_ADDRESS=http://0.0.0.0:7700 yarn build 93 | ``` 94 | 95 | ### Run your build 96 | 97 | The above commands will generate an optimized version of the app, inside the `build` folder. 98 | 99 | You can then serve it with any web server of your choice. 100 | 101 | Example: 102 | 103 | ```bash 104 | serve build 105 | ``` 106 | 107 | ## Storybook 108 | 109 | Storybook is a development environment for UI components. It allows you to browse a component library, view the different states of each component, and interactively test components. 110 | 111 | ![Storybook](assets/storybook.png) 112 | 113 | ```bash 114 | yarn storybook 115 | ``` 116 | 117 | ## Docker 118 | 119 | You can also run the mini-dashboard with Docker. 120 | 121 | ```bash 122 | docker build -t meilisearch-mini-dashboard . 123 | docker run -it -e REACT_APP_MEILI_SERVER_ADDRESS=http://localhost:7700 -p 3000:3000 meilisearch-mini-dashboard 124 | ``` 125 | 126 | ## Contributing 127 | 128 | If you want to contribute to this project, please make sure to read [the contributing guidelines](./CONTRIBUTING.md) 129 | 130 | ## Compatibility with Meilisearch 131 | 132 | This package guarantees compatibility with [version v1.x of Meilisearch](https://github.com/meilisearch/meilisearch/releases/latest), but some features may not be present. Please check the [issues](https://github.com/meilisearch/mini-dashboard/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22+label%3Aenhancement) for more info. 133 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /assets/storybook.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meilisearch/mini-dashboard/986ec2fd237d6854a6c58e81c8ea024792939433/assets/storybook.png -------------------------------------------------------------------------------- /assets/trumen_quick_loop.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meilisearch/mini-dashboard/986ec2fd237d6854a6c58e81c8ea024792939433/assets/trumen_quick_loop.gif -------------------------------------------------------------------------------- /bors.toml: -------------------------------------------------------------------------------- 1 | 2 | status = [ 3 | 'linter-check', 4 | 'cypress_no_meilisearch', 5 | 'cypress_meilisearch-no-api-key', 6 | 'cypress_meilisearch-api-key', 7 | ] 8 | # 1 hour timeout 9 | timeout-sec = 3600 10 | -------------------------------------------------------------------------------- /cypress.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | const { defineConfig } = require('cypress') 3 | 4 | module.exports = defineConfig({ 5 | watchForFileChanges: true, 6 | retries: 2, 7 | viewportWidth: 1440, 8 | viewportHeight: 900, 9 | env: { 10 | host: 'http://0.0.0.0:7700', 11 | apiKey: 'masterKey', 12 | wrongApiKey: 'wrongApiKey', 13 | waitingTime: 1000, 14 | }, 15 | e2e: { 16 | baseUrl: 'http://localhost:3000', 17 | specPattern: 'cypress/e2e/**/*.{js,jsx,ts,tsx}', 18 | }, 19 | }) 20 | -------------------------------------------------------------------------------- /cypress/e2e/side-panel.cy.js: -------------------------------------------------------------------------------- 1 | const WAITING_TIME = Cypress.env('waitingTime') 2 | 3 | describe(`Right side panel`, () => { 4 | beforeEach(() => { 5 | cy.saveApiTokenCookie() 6 | cy.visit('/') 7 | }) 8 | 9 | it('Should be opened by default', () => { 10 | cy.get('[data-testid="right-panel"]').should('be.visible') 11 | }) 12 | 13 | it('Should be closed when clicking on the close button', () => { 14 | cy.get('button[aria-label="Close Panel"]').click() 15 | cy.get('[data-testid="right-panel"]').should('not.be.visible') 16 | }) 17 | 18 | it('Should be opened when clicking on the menu bars button', () => { 19 | cy.get('button[aria-label="Close Panel"]').click() 20 | cy.get('button[aria-label="Open Panel"]').click() 21 | cy.get('[data-testid="right-panel"]').should('be.visible') 22 | }) 23 | 24 | it('Should allow subscribing to the newsletter', () => { 25 | cy.get('input[placeholder="Enter your email"]').type('kero@meilisearch.com') 26 | cy.get('button[aria-label="Subscribe"]').click() 27 | cy.wait(WAITING_TIME) 28 | cy.get('[data-testid="right-panel"]').within(() => { 29 | cy.get('p').should('contain', 'Thanks for subscribing!') 30 | }) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /cypress/e2e/test-api-key-query-param.cy.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable cypress/no-unnecessary-waiting */ 2 | const API_KEY = Cypress.env('apiKey') 3 | const WAITING_TIME = Cypress.env('waitingTime') 4 | 5 | describe(`Test API key required with query params`, () => { 6 | before(() => { 7 | cy.deleteAllIndexes() 8 | 9 | cy.wait(WAITING_TIME) 10 | cy.createIndex('movies') 11 | cy.wait(WAITING_TIME) 12 | cy.fixture('movies.json').then((movies) => { 13 | cy.addDocuments('movies', movies) 14 | cy.wait(WAITING_TIME) 15 | }) 16 | }) 17 | 18 | beforeEach(() => { 19 | cy.visit(`/?api_key=${API_KEY}`) 20 | }) 21 | 22 | it('Should display the movies', () => { 23 | cy.wait(WAITING_TIME) 24 | cy.get('ul') 25 | .children() 26 | .should(($p) => { 27 | expect($p).to.have.length(20) 28 | }) 29 | }) 30 | 31 | it('Should have the api key written in the modal', () => { 32 | // Test if the query parameter is written in the modal 33 | // meaning it is added in the local storage 34 | cy.get('span').contains('Api Key').parent().click() 35 | cy.get('div[aria-label=settings-api-key]').within(() => { 36 | cy.get('input[name="apiKey"]').should('have.value', API_KEY) 37 | }) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /cypress/e2e/test-api-key-required.cy.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable cypress/no-unnecessary-waiting */ 2 | const API_KEY = Cypress.env('apiKey') 3 | const WRONG_APIKEY = Cypress.env('wrongApiKey') 4 | const WAITING_TIME = Cypress.env('waitingTime') 5 | 6 | describe(`Test API key required`, () => { 7 | before(() => { 8 | cy.deleteAllIndexes() 9 | }) 10 | 11 | beforeEach(() => { 12 | cy.visit('/') 13 | }) 14 | 15 | it('Should visit the dashboard', () => { 16 | cy.url().should('match', /\//) 17 | }) 18 | 19 | it('Should find a text in modal requesting API key', () => { 20 | cy.contains('Enter your admin API key') 21 | }) 22 | 23 | it('Should fail on wrong API key triggered with mouse click', () => { 24 | cy.get('div[aria-label=ask-for-api-key]').within(() => { 25 | cy.get('input[name="apiKey"]').as('apiKeyInput') 26 | cy.get('@apiKeyInput').type(WRONG_APIKEY) 27 | cy.get('@apiKeyInput').should('have.value', WRONG_APIKEY) 28 | cy.get('button').contains('Go').click() 29 | cy.wait(WAITING_TIME) 30 | cy.contains('The provided API key is invalid.') 31 | }) 32 | }) 33 | 34 | it('Should fail on wrong API key triggered with enter key', () => { 35 | cy.get('div[aria-label=ask-for-api-key]').within(() => { 36 | cy.get('input[name="apiKey"]').as('apiKeyInput') 37 | cy.get('@apiKeyInput').type(WRONG_APIKEY) 38 | cy.get('@apiKeyInput').should('have.value', WRONG_APIKEY) 39 | cy.get('@apiKeyInput').type('{enter}') 40 | cy.wait(WAITING_TIME) 41 | cy.contains('The provided API key is invalid.') 42 | }) 43 | }) 44 | 45 | it('Should accept valid API key', () => { 46 | cy.get('div[aria-label=ask-for-api-key]').within(() => { 47 | cy.get('input[name="apiKey"]').as('apiKeyInput') 48 | cy.get('@apiKeyInput').clear() 49 | cy.get('@apiKeyInput').type(API_KEY) 50 | cy.get('@apiKeyInput').should('have.value', API_KEY) 51 | cy.get('button').contains('Go').click() 52 | cy.wait(WAITING_TIME) 53 | }) 54 | cy.contains('Welcome to') 55 | }) 56 | 57 | it('Should display a modal with API key inside the API key modal button', () => { 58 | // Fill the first API key request 59 | cy.get('div[aria-label=ask-for-api-key]').within(() => { 60 | cy.get('input[name="apiKey"]').as('apiKeyInput') 61 | cy.get('@apiKeyInput').clear() 62 | cy.get('@apiKeyInput').type(API_KEY) 63 | cy.get('button').contains('Go').click() 64 | }) 65 | cy.visit('/') 66 | cy.wait(WAITING_TIME) 67 | 68 | // Test the value of the Api Key Modal 69 | cy.get('span').contains('Api Key').parent().click() 70 | cy.get('div[aria-label=settings-api-key]').within(() => { 71 | cy.get('input[name="apiKey"]').should('have.value', API_KEY) 72 | }) 73 | }) 74 | 75 | it('Should fail on API Key change inside the API key modal button', () => { 76 | // Fill the first API key request 77 | cy.get('div[aria-label=ask-for-api-key]').within(() => { 78 | cy.get('input[name="apiKey"]').as('apiKeyInput') 79 | cy.get('@apiKeyInput').clear() 80 | cy.get('@apiKeyInput').type(API_KEY) 81 | cy.get('button').contains('Go').click() 82 | }) 83 | cy.visit('/') 84 | cy.wait(WAITING_TIME) 85 | 86 | // Test the change of API key inside the API Key Modal 87 | cy.get('span').contains('Api Key').parent().click() 88 | cy.get('div[aria-label=settings-api-key]').within(() => { 89 | cy.get('input[name="apiKey"]').as('apiKeyInput') 90 | cy.get('@apiKeyInput').clear() 91 | cy.get('@apiKeyInput').type(WRONG_APIKEY) 92 | cy.get('@apiKeyInput').should('have.value', WRONG_APIKEY) 93 | cy.get('button').contains('Go').click() 94 | cy.wait(WAITING_TIME) 95 | cy.contains('The provided API key is invalid.') 96 | }) 97 | }) 98 | }) 99 | -------------------------------------------------------------------------------- /cypress/e2e/test-interface.cy.js: -------------------------------------------------------------------------------- 1 | const WAITING_TIME = Cypress.env('waitingTime') 2 | 3 | describe(`Test interface`, () => { 4 | before(() => { 5 | // Recreate the movies index with documents in it 6 | cy.deleteAllIndexes() 7 | cy.wait(WAITING_TIME) 8 | cy.createIndex('movies') 9 | cy.wait(WAITING_TIME) 10 | cy.fixture('movies.json').then((movies) => { 11 | cy.addDocuments('movies', movies) 12 | cy.wait(WAITING_TIME) 13 | }) 14 | cy.createIndex('pokemon') 15 | cy.wait(WAITING_TIME) 16 | cy.fixture('pokemon.json').then((pokemon) => { 17 | cy.addDocuments('pokemon', pokemon) 18 | cy.wait(WAITING_TIME) 19 | }) 20 | }) 21 | 22 | beforeEach(() => { 23 | cy.visit('/') 24 | }) 25 | 26 | it('Should contain a "Show more" button if a document has more than 6 fields', () => { 27 | cy.get('ul') 28 | .children() 29 | .first() 30 | .within(() => { 31 | cy.get('button').contains('Show more') 32 | }) 33 | }) 34 | 35 | it('Shouldn’t contain a "Show more" button if a document has less than 6 fields', () => { 36 | cy.get('button[aria-haspopup=menu]').click() 37 | cy.wait(WAITING_TIME) 38 | cy.get('button[role=menuitem]').contains('pokemon').click() 39 | cy.get('ul') 40 | .children() 41 | .first() 42 | .within(() => { 43 | cy.get('button').contains('Show more').should('not.exist') 44 | }) 45 | }) 46 | 47 | it('Should display more fields if the user clicks on the "Show more" button', () => { 48 | cy.get('button[aria-haspopup=menu]').click() 49 | cy.wait(WAITING_TIME) 50 | cy.get('button[role=menuitem]').contains('movies').click() 51 | cy.get('ul') 52 | .children() 53 | .first() 54 | .within(() => { 55 | cy.get('button').contains('Show more').click() 56 | cy.get('>div > div').should('have.length', 8) 57 | }) 58 | }) 59 | it('Should display a json button', () => { 60 | cy.get('ul') 61 | .children() 62 | .first() 63 | .within(() => { 64 | cy.get('button').contains('Show more').click() 65 | cy.get('button').contains('json') 66 | }) 67 | }) 68 | 69 | it('Should display json on click on the "json" button', () => { 70 | cy.get('ul') 71 | .children() 72 | .first() 73 | .within(() => { 74 | cy.get('button').contains('Show more').click() 75 | cy.get('button').contains('json').click() 76 | cy.get('div').should('have.class', 'react-json-view') 77 | cy.get('span').contains('Apple iTunes') 78 | }) 79 | }) 80 | 81 | it('Should display an array button', () => { 82 | cy.get('ul') 83 | .children() 84 | .first() 85 | .within(() => { 86 | cy.get('button').contains('Show more').click() 87 | cy.get('button').contains('array') 88 | }) 89 | }) 90 | 91 | it('Should display an array on click on the "array" button', () => { 92 | cy.get('ul') 93 | .children() 94 | .first() 95 | .within(() => { 96 | cy.get('button').contains('Show more').click() 97 | cy.get('button').contains('array').click() 98 | cy.get('div').should('have.class', 'react-json-view') 99 | cy.get('span').contains('Action') 100 | }) 101 | }) 102 | }) 103 | -------------------------------------------------------------------------------- /cypress/e2e/test-no-api-key-required.cy.js: -------------------------------------------------------------------------------- 1 | const WAITING_TIME = Cypress.env('waitingTime') 2 | 3 | describe(`Test no API key required`, () => { 4 | before(() => { 5 | cy.deleteAllIndexes() 6 | cy.wait(WAITING_TIME) 7 | }) 8 | 9 | beforeEach(() => { 10 | cy.visit('/') 11 | }) 12 | 13 | it('Should visit the dashboard', () => { 14 | cy.url().should('match', /\//) 15 | }) 16 | 17 | it('Should display the help cards view', () => { 18 | cy.contains( 19 | 'This dashboard will help you check the search results with ease.' 20 | ) 21 | cy.contains('Set your API key (optional)') 22 | cy.contains('Select an index') 23 | }) 24 | 25 | it('Should display a message telling that no api key is required', () => { 26 | cy.get('span').contains('Api Key').parent().click() 27 | cy.get('div[aria-label=settings-api-key]').within(() => { 28 | cy.contains('Enter your admin API key (optional)') 29 | cy.contains( 30 | 'You haven’t set an API key yet, if you want to set one you can read the documentation' 31 | ) 32 | }) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /cypress/e2e/test-no-meilisearch.cy.js: -------------------------------------------------------------------------------- 1 | describe(`Test without Meilisearch running`, () => { 2 | beforeEach(() => { 3 | cy.visit('/') 4 | }) 5 | 6 | it('Should visit the dashboard', () => { 7 | cy.url().should('match', /\//) 8 | }) 9 | 10 | it('Should invite to start Meilisearch', () => { 11 | cy.contains('It seems like Meilisearch isn’t running') 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /cypress/e2e/test-search.cy.js: -------------------------------------------------------------------------------- 1 | const WAITING_TIME = Cypress.env('waitingTime') 2 | 3 | describe(`Test search`, () => { 4 | before(() => { 5 | // Recreate the movies index with documents in it 6 | cy.deleteAllIndexes() 7 | cy.wait(WAITING_TIME) 8 | cy.createIndex('movies') 9 | cy.wait(WAITING_TIME) 10 | cy.fixture('movies.json').then((movies) => { 11 | cy.addDocuments('movies', movies) 12 | cy.wait(WAITING_TIME) 13 | }) 14 | }) 15 | 16 | beforeEach(() => { 17 | cy.visit('/') 18 | }) 19 | 20 | it('Should update the results according to the user’s search', () => { 21 | cy.get('input[type="search"]').type('Fifth Element') 22 | cy.get('ul') 23 | .children() 24 | .should(($p) => { 25 | expect($p).to.have.length(1) 26 | }) 27 | }) 28 | 29 | it('Should display a message if there are no result for the user’s search', () => { 30 | cy.get('input[type="search"]').type('zz') 31 | cy.contains('Sorry mate, no results matching your request') 32 | }) 33 | 34 | it('Should display a "Load more" button if there are more than 20 results', () => { 35 | cy.get('input[type="search"]') 36 | cy.get('ul') 37 | .children() 38 | .should(($p) => { 39 | expect($p).to.have.length(20) 40 | }) 41 | cy.contains('Load more') 42 | }) 43 | 44 | it('Should load the next documents on click on the "Load more" button', () => { 45 | cy.get('span').contains('Load more').parent().click() 46 | cy.get('ul') 47 | .children() 48 | .should(($p) => { 49 | expect($p).to.have.length(33) 50 | }) 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /cypress/e2e/test-select-indexes.cy.js: -------------------------------------------------------------------------------- 1 | const WAITING_TIME = Cypress.env('waitingTime') 2 | 3 | // TODO: refacto tests to get rid of the WAITING_TIME 4 | 5 | describe(`Test indexes`, () => { 6 | before(() => { 7 | cy.deleteAllIndexes() 8 | cy.wait(WAITING_TIME) 9 | 10 | cy.createIndex('empty_index') 11 | cy.wait(WAITING_TIME) 12 | cy.createIndex('movies') 13 | cy.wait(WAITING_TIME) 14 | cy.createIndex('pokemon') 15 | cy.wait(WAITING_TIME) 16 | 17 | cy.fixture('movies.json') 18 | .as('movies') 19 | .then((movies) => { 20 | cy.addDocuments('movies', movies) 21 | cy.wait(WAITING_TIME) 22 | }) 23 | cy.wait(WAITING_TIME) 24 | 25 | cy.fixture('pokemon.json') 26 | .as('pokemon') 27 | .then((pokemon) => { 28 | cy.addDocuments('pokemon', pokemon) 29 | cy.wait(WAITING_TIME) 30 | }) 31 | cy.wait(WAITING_TIME) 32 | }) 33 | 34 | after(() => { 35 | cy.deleteAllIndexes() 36 | cy.wait(WAITING_TIME) 37 | }) 38 | 39 | beforeEach(() => { 40 | cy.visit('/') 41 | }) 42 | 43 | it('Should display the first index based on localeCompare order on the uid', () => { 44 | cy.get('button[aria-haspopup=menu]').contains('empty_index 0') 45 | }) 46 | 47 | it('Should list all the indexes inside the select', () => { 48 | cy.get('button[aria-haspopup=menu]').click() 49 | cy.wait(WAITING_TIME) 50 | cy.get('div[role=menu]') 51 | .children() 52 | .should(($p) => { 53 | expect($p).to.have.length(3) 54 | expect($p).to.contain('empty_index 0') 55 | expect($p).to.contain('movies 33') 56 | expect($p).to.contain('pokemon 3') 57 | }) 58 | }) 59 | 60 | it('Should inform that the current index is empty', () => { 61 | cy.contains('There’s no document in the selected index') 62 | }) 63 | 64 | it('Should display an indexes documents', () => { 65 | cy.get('button[aria-haspopup=menu]').click() 66 | cy.wait(WAITING_TIME) 67 | cy.get('button[role=menuitem]').contains('movies').click() 68 | cy.get('ul') 69 | .children() 70 | .should(($p) => { 71 | expect($p).to.have.length(20) 72 | }) 73 | }) 74 | 75 | it('Should display the documents of an other index on click on it', () => { 76 | cy.get('button[aria-haspopup=menu]').click() 77 | cy.wait(WAITING_TIME) 78 | cy.get('button[role=menuitem]').contains('pokemon').click() 79 | cy.get('ul').children().should('have.length', 3) 80 | cy.get('ul') 81 | .children() 82 | .first() 83 | .within(() => { 84 | cy.contains('Bulbasaur') 85 | }) 86 | }) 87 | }) 88 | -------------------------------------------------------------------------------- /cypress/fixtures/pokemon.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "name": "Bulbasaur", 5 | "type": ["Grass", "Poison"], 6 | "sprite": "https://raw.githubusercontent.com/Purukitto/pokemon-data.json/master/images/pokedex/sprites/001.png" 7 | }, 8 | { 9 | "id": 2, 10 | "name": "Ivysaur", 11 | "type": ["Grass", "Poison"], 12 | "sprite": "https://raw.githubusercontent.com/Purukitto/pokemon-data.json/master/images/pokedex/sprites/002.png" 13 | }, 14 | { 15 | "id": 3, 16 | "name": "Venusaur", 17 | "type": ["Grass", "Poison"], 18 | "sprite": "https://raw.githubusercontent.com/Purukitto/pokemon-data.json/master/images/pokedex/sprites/003.png" 19 | } 20 | ] 21 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | const { MeiliSearch: Meilisearch } = require('meilisearch') 3 | 4 | const { apiKey, host } = Cypress.env() 5 | 6 | Cypress.Commands.add('deleteAllIndexes', async () => { 7 | try { 8 | const client = new Meilisearch({ 9 | host, 10 | apiKey, 11 | }) 12 | const { results: indexes } = await client.getIndexes() 13 | indexes.forEach(async (index) => { 14 | const task = await client.deleteIndex(index.uid) 15 | await client.waitForTask(task.taskUid) 16 | }) 17 | } catch (e) { 18 | console.log({ e }) 19 | } 20 | }) 21 | 22 | Cypress.Commands.add('createIndex', async (uid) => { 23 | try { 24 | const client = new Meilisearch({ 25 | host, 26 | apiKey, 27 | }) 28 | const task = await client.createIndex(uid) 29 | await client.waitForTask(task.taskUid) 30 | } catch (e) { 31 | console.log({ e }) 32 | } 33 | }) 34 | 35 | Cypress.Commands.add('addDocuments', async (uid, documents) => { 36 | try { 37 | const client = new Meilisearch({ 38 | host, 39 | apiKey, 40 | }) 41 | const index = await client.getIndex(uid) 42 | const task = await index.addDocuments(documents) 43 | await client.waitForTask(task.taskUid) 44 | } catch (e) { 45 | console.log({ e }) 46 | } 47 | }) 48 | 49 | Cypress.Commands.add('saveApiTokenCookie', () => { 50 | cy.window().then((win) => { 51 | win.localStorage.setItem('apiKey', JSON.stringify(apiKey)) 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | import './commands' 2 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src", 4 | "jsx": "react" 5 | }, 6 | "include": ["src"] 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mini-dashboard", 3 | "version": "0.2.19", 4 | "private": true, 5 | "dependencies": { 6 | "@meilisearch/instant-meilisearch": "0.25", 7 | "@styled-system/should-forward-prop": "^5.1.5", 8 | "babel-loader": "9.1.3", 9 | "color": "^5.0.0", 10 | "eslint-plugin-import": "^2.29.1", 11 | "meilisearch": "0.48.2", 12 | "prop-types": "^15.8.1", 13 | "react": "^18.3.1", 14 | "react-dom": "^18.3.1", 15 | "react-instantsearch-dom": "^6.40.4", 16 | "react-json-view": "^1.21.3", 17 | "react-lazy-load-image-component": "^1.6.3", 18 | "react-scripts": "5.0.1", 19 | "reakit": "^1.3.11", 20 | "styled-components": "^5.3.9", 21 | "styled-system": "^5.1.5" 22 | }, 23 | "scripts": { 24 | "version-script": "node version-script.js", 25 | "validate-env": "node validate-env.js", 26 | "prestart": "yarn version-script && yarn validate-env", 27 | "prebuild": "yarn version-script && yarn validate-env", 28 | "start": "react-scripts start", 29 | "start:ci": "REACT_APP_MEILI_SERVER_ADDRESS=http://meilisearch:7700 react-scripts start", 30 | "build": "react-scripts build", 31 | "eject": "react-scripts eject", 32 | "lint": "eslint .", 33 | "prettier-check": "prettier --check ./src", 34 | "format:fix": "prettier --write ./src", 35 | "storybook": "storybook dev -p 6006", 36 | "build-storybook": "storybook build", 37 | "cy:open": "cypress open", 38 | "cy:run:test-no-meilisearch": "cypress run --spec '**/*/test-no-meilisearch.cy.js'", 39 | "cy:run:test-api-key-required": "cypress run --spec '**/*/test-api-key-required.cy.js,**/*/test-api-key-query-param.cy.js'", 40 | "cy:run": "cypress run --config excludeSpecPattern=['**/*/test-no-meilisearch.cy.js','**/*/test-api-key-required.cy.js','**/*/test-api-key-query-param.cy.js']", 41 | "icons": "npx @svgr/cli --title-prop --no-dimensions --replace-attr-values \"#39486E=currentColor,#959DB3=currentColor\" -d src/components/icons src/components/icons/svg" 42 | }, 43 | "browserslist": [ 44 | "defaults" 45 | ], 46 | "devDependencies": { 47 | "@storybook/addon-actions": "^8.4.7", 48 | "@storybook/addon-essentials": "^8.4.7", 49 | "@storybook/addon-links": "^8.4.7", 50 | "@storybook/node-logger": "^8.4.7", 51 | "@storybook/preset-create-react-app": "^8.6.12", 52 | "@storybook/react": "^8.6.12", 53 | "@storybook/react-webpack5": "^8.4.7", 54 | "@svgr/cli": "^8.1.0", 55 | "cypress": "^14.1.0", 56 | "eslint": "8.57.0", 57 | "eslint-config-airbnb": "^19.0.4", 58 | "eslint-config-prettier": "^10.0.2", 59 | "eslint-plugin-cypress": "^4.2.0", 60 | "eslint-plugin-jsx-a11y": "^6.7.0", 61 | "eslint-plugin-prettier": "^5.1.3", 62 | "eslint-plugin-react": "^7.37.0", 63 | "prettier": "^3.3.3", 64 | "storybook": "^8.6.11" 65 | }, 66 | "packageManager": "yarn@4.6.0", 67 | "resolutions": { 68 | "css-select/nth-check": "^2.0.1", 69 | "css-minimizer-webpack-plugin/postcss": "^8.4.31", 70 | "react-scripts/postcss": "^8.4.31", 71 | "resolve-url-loader/postcss": "^8.4.31", 72 | "@storybook/core/esbuild": "^0.25.0" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meilisearch/mini-dashboard/986ec2fd237d6854a6c58e81c8ea024792939433/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/fonts/Barlow/regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meilisearch/mini-dashboard/986ec2fd237d6854a6c58e81c8ea024792939433/public/fonts/Barlow/regular.woff2 -------------------------------------------------------------------------------- /public/fonts/Work_Sans/bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meilisearch/mini-dashboard/986ec2fd237d6854a6c58e81c8ea024792939433/public/fonts/Work_Sans/bold.woff2 -------------------------------------------------------------------------------- /public/fonts/Work_Sans/light.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meilisearch/mini-dashboard/986ec2fd237d6854a6c58e81c8ea024792939433/public/fonts/Work_Sans/light.woff2 -------------------------------------------------------------------------------- /public/fonts/Work_Sans/medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meilisearch/mini-dashboard/986ec2fd237d6854a6c58e81c8ea024792939433/public/fonts/Work_Sans/medium.woff2 -------------------------------------------------------------------------------- /public/fonts/Work_Sans/regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meilisearch/mini-dashboard/986ec2fd237d6854a6c58e81c8ea024792939433/public/fonts/Work_Sans/regular.woff2 -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 16 | 17 | 26 | Mini-dashboard | Meilisearch 27 | 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon-32x32.png", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/jsx-no-constructed-context-values */ 2 | /* eslint-disable no-console */ 3 | import React, { useState, useEffect, useCallback } from 'react' 4 | import styled from 'styled-components' 5 | import { instantMeiliSearch as instantMeilisearch } from '@meilisearch/instant-meilisearch' 6 | import { useDialogState } from 'reakit/Dialog' 7 | import { MeiliSearch as Meilisearch } from 'meilisearch' 8 | 9 | import ApiKeyContext from 'context/ApiKeyContext' 10 | import { useMeilisearchClientContext } from 'context/MeilisearchClientContext' 11 | import useLocalStorage from 'hooks/useLocalStorage' 12 | import ApiKeyModalContent from 'components/ApiKeyModalContent' 13 | import Body from 'components/Body' 14 | import Modal from 'components/Modal' 15 | import NoMeilisearchRunning from 'components/NoMeilisearchRunning' 16 | import ApiKeyAwarenessBanner from 'components/ApiKeyAwarenessBanner' 17 | import getIndexesListWithStats from 'utils/getIndexesListWithStats' 18 | import shouldDisplayApiKeyModal from 'utils/shouldDisplayApiKeyModal' 19 | import hasAnApiKeySet from 'utils/hasAnApiKeySet' 20 | import clientAgents from './version/client-agents' 21 | 22 | export const baseUrl = 23 | process.env.REACT_APP_MEILI_SERVER_ADDRESS || 24 | (process.env.NODE_ENV === 'development' 25 | ? 'http://0.0.0.0:7700' 26 | : window.location.origin) 27 | 28 | const Wrapper = styled.div` 29 | background-color: ${(p) => p.theme.colors.gray[11]}; 30 | min-height: 100vh; 31 | ` 32 | 33 | const App = () => { 34 | const [apiKey, setApiKey] = useLocalStorage('apiKey') 35 | const [indexes, setIndexes] = useState() 36 | const [isMeilisearchRunning, setIsMeilisearchRunning] = useState(false) 37 | const [requireApiKeyToWork, setRequireApiKeyToWork] = useState(false) 38 | const [currentIndex, setCurrentIndex] = useLocalStorage('currentIndex') 39 | const [isApiKeyBannerVisible, setIsApiKeyBannerVisible] = useState(false) 40 | const dialog = useDialogState({ animated: true, visible: false }) 41 | 42 | const { 43 | meilisearchJsClient, 44 | setMeilisearchJsClient, 45 | setInstantMeilisearchClient, 46 | } = useMeilisearchClientContext() 47 | 48 | const getIndexesList = useCallback(async () => { 49 | try { 50 | const indexesList = await getIndexesListWithStats(meilisearchJsClient) 51 | setIndexes(indexesList) 52 | if (indexesList && indexesList?.length > 0) { 53 | setCurrentIndex( 54 | currentIndex 55 | ? indexesList.find((option) => option.uid === currentIndex.uid) 56 | : indexesList[0] 57 | ) 58 | } else { 59 | setCurrentIndex(null) 60 | } 61 | } catch (error) { 62 | setCurrentIndex(null) 63 | console.log(error) 64 | } 65 | }, [meilisearchJsClient, currentIndex]) 66 | 67 | // Check if the API key is present on the url then put it in the local storage 68 | const getApiKeyFromUrl = useCallback(() => { 69 | const urlParams = new URLSearchParams(window.location.search) 70 | const apiKeyParam = urlParams.get('api_key') 71 | if (apiKeyParam) { 72 | setApiKey(apiKeyParam) 73 | setIsApiKeyBannerVisible(true) 74 | } 75 | }, []) 76 | 77 | useEffect(() => { 78 | getApiKeyFromUrl() 79 | }, []) 80 | 81 | useEffect(() => { 82 | setInstantMeilisearchClient( 83 | instantMeilisearch(baseUrl, apiKey, { 84 | primaryKey: 'id', 85 | clientAgents, 86 | }).searchClient 87 | ) 88 | 89 | setMeilisearchJsClient( 90 | new Meilisearch({ 91 | host: baseUrl, 92 | apiKey, 93 | clientAgents, 94 | }) 95 | ) 96 | }, [apiKey]) 97 | 98 | useEffect(() => { 99 | const onClientUpdate = async () => { 100 | const isInstanceRunning = await meilisearchJsClient.isHealthy() 101 | setIsMeilisearchRunning(isInstanceRunning) 102 | if (isInstanceRunning) { 103 | setRequireApiKeyToWork(await hasAnApiKeySet()) 104 | dialog.setVisible(await shouldDisplayApiKeyModal(meilisearchJsClient)) 105 | getIndexesList() 106 | } 107 | } 108 | onClientUpdate() 109 | }, [meilisearchJsClient]) 110 | 111 | return ( 112 | 113 | 114 | {isApiKeyBannerVisible && ( 115 | setIsApiKeyBannerVisible(false)} 117 | /> 118 | )} 119 | {isMeilisearchRunning ? ( 120 | 128 | ) : ( 129 | 130 | )} 131 | 138 | dialog.hide()} /> 139 | 140 | 141 | 142 | ) 143 | } 144 | 145 | export default App 146 | -------------------------------------------------------------------------------- /src/GlobalStyle.js: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from 'styled-components' 2 | 3 | const GlobalStyle = createGlobalStyle` 4 | html, body { 5 | margin: 0; 6 | } 7 | * { 8 | font-family: 'Work Sans'; 9 | box-sizing: border-box; 10 | } 11 | 12 | mark, ::selection { 13 | background-color: ${(p) => p.theme.colors.main.light}; 14 | } 15 | 16 | @font-face { 17 | font-family: 'Work Sans'; 18 | src: url("fonts/Work_Sans/light.woff2") format("woff2"); 19 | font-weight: 300; 20 | font-style: normal; 21 | } 22 | 23 | @font-face { 24 | font-family: 'Work Sans'; 25 | src: url("fonts/Work_Sans/regular.woff2") format("woff2"); 26 | font-weight: 400; 27 | font-style: normal; 28 | } 29 | 30 | @font-face { 31 | font-family: 'Work Sans'; 32 | src: url("fonts/Work_Sans/medium.woff2") format("woff2"); 33 | font-weight: 500; 34 | font-style: normal; 35 | } 36 | 37 | @font-face { 38 | font-family: 'Work Sans'; 39 | src: url("fonts/Work_Sans/bold.woff2") format("woff2"); 40 | font-weight: 700; 41 | font-style: normal; 42 | } 43 | 44 | @font-face { 45 | font-family: 'Barlow'; 46 | src: url("fonts/Barlow/regular.woff2") format("woff2"); 47 | font-weight: 400; 48 | font-style: normal; 49 | } 50 | 51 | ` 52 | 53 | export default GlobalStyle 54 | -------------------------------------------------------------------------------- /src/components/ApiKeyAwarenessBanner.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import Color from 'color' 4 | import Typography from 'components/Typography' 5 | import IconButton from 'components/IconButton' 6 | import { AlertCircle, Cross } from 'components/icons' 7 | import Container from './Container' 8 | 9 | const Button = styled(IconButton)` 10 | position: absolute; 11 | right: 16px; 12 | &:hover { 13 | pointer-events: initial; 14 | } 15 | ` 16 | 17 | const ApiKeyBannerWrapper = styled.div` 18 | background: #e41359; 19 | display: flex; 20 | position: sticky; 21 | top: 0; 22 | height: 55px; 23 | box-shadow: 0px 0px 30px ${(p) => Color(p.theme.colors.gray[0]).alpha(0.15)}; 24 | z-index: 4; 25 | padding: 4px; 26 | overflow: hidden; 27 | ` 28 | 29 | const CloudBanner = ({ onClose }) => ( 30 | 31 | 32 | 33 | 34 | Please be aware that you are using api_key in the params. Do not share 35 | the url with api_key to any unknown source. 36 | 37 | 40 | 41 | 42 | ) 43 | 44 | export default CloudBanner 45 | -------------------------------------------------------------------------------- /src/components/ApiKeyModalContent.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import { MeiliSearch as Meilisearch } from 'meilisearch' 4 | 5 | import { baseUrl } from 'App' 6 | import Box from 'components/Box' 7 | import Button from 'components/Button' 8 | import Input from 'components/Input' 9 | import Link from 'components/Link' 10 | import Typography from 'components/Typography' 11 | 12 | import ApiKeyContext from 'context/ApiKeyContext' 13 | 14 | import clientAgents from '../version/client-agents' 15 | 16 | const ErrorMessage = styled(Typography)` 17 | position: absolute; 18 | left: 0; 19 | top: 32px; 20 | ` 21 | 22 | const ApiKeyModalContent = ({ closeModal }) => { 23 | const { apiKey, setApiKey } = React.useContext(ApiKeyContext) 24 | const [value, setValue] = React.useState(apiKey || '') 25 | const [error, setError] = React.useState() 26 | 27 | const updateClient = async () => { 28 | const clientToTry = new Meilisearch({ 29 | host: baseUrl, 30 | apiKey: value, 31 | clientAgents, 32 | }) 33 | try { 34 | await clientToTry.getIndexes() 35 | setApiKey(value) 36 | closeModal() 37 | setError() 38 | } catch (err) { 39 | setError(err.message) 40 | } 41 | } 42 | 43 | React.useEffect(() => { 44 | setValue(apiKey) 45 | }, [apiKey]) 46 | 47 | return ( 48 | <> 49 | 50 | setValue(e.target.value)} 56 | onKeyPress={(e) => { 57 | if (e.key === 'Enter') { 58 | updateClient() 59 | } 60 | }} 61 | /> 62 | 70 | 71 | 72 | 73 | An API key that has at least{' '} 74 | 78 | permission 79 | {' '} 80 | to get the indexes, search and get the version of Meilisearch. 81 | 82 | {error && ( 83 | 84 | {error} 85 | 86 | )} 87 | 88 | 89 | ) 90 | } 91 | 92 | export default ApiKeyModalContent 93 | -------------------------------------------------------------------------------- /src/components/Badge.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | const Badge = styled.span` 4 | background-color: ${(p) => p.theme.colors.main.lighter}; 5 | color: ${(p) => p.theme.colors.main.dark}; 6 | height: 16px; 7 | border-radius: 5px; 8 | padding: 0 4px; 9 | display: inline-flex; 10 | align-items: center; 11 | justify-content: center; 12 | font-size: 12px; 13 | line-height: 22px; 14 | font-weight: 400; 15 | letter-spacing: 0.03em; 16 | ` 17 | 18 | export default Badge 19 | -------------------------------------------------------------------------------- /src/components/Body.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { InstantSearch } from 'react-instantsearch-dom' 3 | import styled from 'styled-components' 4 | import { useMeilisearchClientContext } from 'context/MeilisearchClientContext' 5 | import useLocalStorage from 'hooks/useLocalStorage' 6 | import Box from 'components/Box' 7 | import Header from 'components/Header/index' 8 | import RightPanel from 'components/RightPanel' 9 | import BodyWrapper from 'components/BodyWrapper' 10 | import EmptyView from 'components/EmptyView' 11 | import OnBoarding from 'components/OnBoarding' 12 | import Results from 'components/Results' 13 | import Typography from 'components/Typography' 14 | 15 | const ContentWrapper = styled.div` 16 | width: ${({ isRightPanelOpen, theme }) => 17 | isRightPanelOpen ? `calc(100% - ${theme.sizes.rightPanel})` : '100%'}; 18 | transition: width 0.3s ease-in-out; 19 | ` 20 | 21 | const IndexContent = ({ currentIndex }) => { 22 | if (!currentIndex) return 23 | if (currentIndex?.stats?.numberOfDocuments > 0) return 24 | return ( 25 | 26 | 32 | There’s no document in the selected index 33 | 34 | 35 | ) 36 | } 37 | 38 | const Body = ({ 39 | currentIndex, 40 | indexes, 41 | getIndexesList, 42 | setCurrentIndex, 43 | requireApiKeyToWork, 44 | isApiKeyBannerVisible, 45 | }) => { 46 | const { meilisearchJsClient, instantMeilisearchClient } = 47 | useMeilisearchClientContext() 48 | const [storedIsPanelOpen, setStoredIsPanelOpen] = useLocalStorage( 49 | 'meilisearch-panel-open', 50 | true 51 | ) 52 | 53 | // Right-side panel 54 | const [isRightPanelOpen, setIsRightPanelOpen] = 55 | React.useState(storedIsPanelOpen) 56 | const handleTogglePanel = React.useCallback(() => { 57 | setIsRightPanelOpen((isOpen) => !isOpen) 58 | setStoredIsPanelOpen((isOpen) => !isOpen) 59 | }, []) 60 | 61 | return ( 62 | 66 | 67 |
78 | 79 | {/* */} 80 | 87 | 88 | 89 | 90 | 91 | { 94 | setIsRightPanelOpen(false) 95 | setStoredIsPanelOpen(false) 96 | }} 97 | /> 98 | 99 | ) 100 | } 101 | 102 | export default Body 103 | -------------------------------------------------------------------------------- /src/components/BodyWrapper.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | const BodyWrapper = styled.div` 4 | display: flex; 5 | flex: 1; 6 | width: 100%; 7 | min-height: calc(100vh - 120px); 8 | ` 9 | 10 | export default BodyWrapper 11 | -------------------------------------------------------------------------------- /src/components/Box.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { space, layout, color, compose, flexbox, position } from 'styled-system' 3 | import { props as stprops } from '@styled-system/should-forward-prop' 4 | 5 | const regex = new RegExp(`^(${stprops.join('|')})$`) 6 | 7 | const shouldForwardProp = (prop) => !regex.test(prop) 8 | 9 | const Box = styled('div').withConfig({ shouldForwardProp })` 10 | box-sizing: border-box; 11 | min-width: 0; 12 | 13 | && { 14 | ${compose(space, layout, color, flexbox, position)} 15 | } 16 | ` 17 | export default Box 18 | -------------------------------------------------------------------------------- /src/components/Button.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Color from 'color' 3 | import styled, { css } from 'styled-components' 4 | import PropTypes from 'prop-types' 5 | import { space, color } from 'styled-system' 6 | import { Button as ReakitButton } from 'reakit/Button' 7 | 8 | import Typography from 'components/Typography' 9 | import { ArrowDown } from 'components/icons' 10 | 11 | const Arrow = styled(ArrowDown)` 12 | margin-left: 6px; 13 | ` 14 | 15 | const variants = { 16 | default: css` 17 | padding: 0 24px; 18 | min-width: 128px; 19 | background-color: transparent; 20 | border-width: 1px; 21 | border-style: solid; 22 | border-color: ${(p) => p.theme.colors.gray[10]}; 23 | box-shadow: 0px 4px 6px ${Color('#000').alpha(0.04)}; 24 | color: ${(p) => p.theme.colors.gray[0]}; 25 | svg { 26 | color: ${(p) => p.theme.colors.main.default}; 27 | } 28 | 29 | &:hover, 30 | &:focus, 31 | &:active, 32 | &:active, 33 | &[aria-expanded='true'] { 34 | box-shadow: none; 35 | border-color: ${(p) => p.theme.colors.main.default}; 36 | } 37 | `, 38 | filled: css` 39 | padding: 0 24px; 40 | min-width: 128px; 41 | background-color: ${(p) => p.theme.colors.main.default}; 42 | border: 1px solid ${(p) => p.theme.colors.main.default}; 43 | color: white; 44 | svg { 45 | color: white; 46 | } 47 | 48 | &:hover, 49 | &:focus, 50 | &:active { 51 | background-color: ${(p) => p.theme.colors.main.hover}; 52 | } 53 | 54 | &[aria-disabled='true'] { 55 | background-color: ${(p) => Color(p.theme.colors.gray[2]).alpha(0.4)}; 56 | border-color: transparent; 57 | } 58 | `, 59 | bordered: css` 60 | padding: 0 24px; 61 | min-width: 128px; 62 | background-color: transparent; 63 | border: 2px solid ${(p) => p.theme.colors.main.default}; 64 | color: ${(p) => p.theme.colors.main.default}; 65 | svg { 66 | color: ${(p) => p.theme.colors.main.default}; 67 | } 68 | 69 | &:hover, 70 | &:focus, 71 | &:active { 72 | border-color: ${(p) => p.theme.colors.main.hover}; 73 | color: ${(p) => p.theme.colors.main.hover}; 74 | } 75 | `, 76 | link: css` 77 | border: none; 78 | height: auto !important; 79 | background-color: transparent; 80 | color: ${(p) => p.theme.colors.main.default}; 81 | padding: 0 !important; 82 | span { 83 | text-decoration: underline; 84 | text-underline-offset: 3px; 85 | } 86 | 87 | &:hover, 88 | &:focus, 89 | &:active { 90 | color: ${(p) => p.theme.colors.main.hover}; 91 | } 92 | `, 93 | grayscale: css` 94 | padding: 8px; 95 | background-color: transparent; 96 | border: none; 97 | color: ${(p) => p.theme.colors.gray[2]}; 98 | 99 | svg { 100 | color: ${(p) => p.theme.colors.gray[4]}; 101 | } 102 | 103 | &:hover, 104 | &:focus, 105 | &:active { 106 | background-color: ${(p) => p.theme.colors.gray[10]}; 107 | } 108 | `, 109 | } 110 | 111 | const sizes = { 112 | medium: css` 113 | height: 48px; 114 | `, 115 | small: css` 116 | height: 34px; 117 | span { 118 | font-size: 14px; 119 | } 120 | `, 121 | } 122 | 123 | const StyledButton = styled(ReakitButton)` 124 | ${(p) => p.$variant}; 125 | ${(p) => p.$size}; 126 | ${(p) => p.$shape}; 127 | ${space}; 128 | ${color}; 129 | 130 | border-radius: 8px; 131 | display: flex; 132 | justify-content: center; 133 | align-items: center; 134 | outline: none; 135 | transition: 136 | background-color 300ms, 137 | color 200ms, 138 | box-shadow 300ms, 139 | border-color 300ms; 140 | 141 | &:hover { 142 | cursor: pointer; 143 | } 144 | svg { 145 | transition: 146 | color 200ms, 147 | transform 300ms; 148 | margin-right: 8px; 149 | } 150 | 151 | &[aria-expanded='true'] { 152 | ${Arrow} { 153 | transform: rotate(180deg); 154 | } 155 | } 156 | ` 157 | 158 | const Button = React.forwardRef( 159 | ( 160 | { 161 | as, 162 | variant = 'default', 163 | size = 'medium', 164 | icon, 165 | toggable = false, 166 | children, 167 | ...props 168 | }, 169 | ref 170 | ) => { 171 | const safeVariant = variants[variant] || variants.default 172 | const safeSize = sizes[size] || sizes.medium 173 | return ( 174 | 181 | {icon} 182 | {children} 183 | {toggable && } 184 | 185 | ) 186 | } 187 | ) 188 | 189 | Button.propTypes = { 190 | /** 191 | * Custom tag if we don't want a "button" to appear in the DOM 192 | */ 193 | as: PropTypes.string, 194 | /** 195 | * Buttons's variant 196 | */ 197 | variant: PropTypes.oneOf([ 198 | 'default', 199 | 'filled', 200 | 'bordered', 201 | 'clean', 202 | 'link', 203 | 'grayscale', 204 | ]), 205 | /** 206 | * Buttons's size 207 | */ 208 | size: PropTypes.oneOf(['medium', 'small']), 209 | /** 210 | * The icon provided to appear on the left 211 | */ 212 | icon: PropTypes.node, 213 | /** 214 | * Whether the button is toggable or not 215 | */ 216 | toggable: PropTypes.bool, 217 | /** 218 | * Text to be displayed 219 | */ 220 | children: PropTypes.node, 221 | } 222 | 223 | export default Button 224 | -------------------------------------------------------------------------------- /src/components/Card.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import PropTypes from 'prop-types' 4 | 5 | const Wrapper = styled.li` 6 | background-color: white; 7 | list-style-type: none; 8 | box-shadow: 0px 0px 30px rgba(0, 0, 0, 0.05); 9 | border-radius: 20px; 10 | padding: ${(p) => p.theme.space[4]}px; 11 | ` 12 | 13 | const Card = ({ children, ...props }) => ( 14 | {children} 15 | ) 16 | 17 | Card.propTypes = { 18 | /** 19 | * The content to appear inside the Card 20 | */ 21 | children: PropTypes.node, 22 | } 23 | 24 | export default Card 25 | -------------------------------------------------------------------------------- /src/components/Checkbox.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import { Checkbox as ReakitCheckbox } from 'reakit/Checkbox' 4 | 5 | const StyledCheckbox = styled(ReakitCheckbox)` 6 | appearance: none; 7 | position: relative; 8 | border-radius: 4px; 9 | width: 16px; 10 | height: 16px; 11 | margin-right: 8px; 12 | border-width: 2px; 13 | border-style: solid; 14 | border-color: ${(p) => p.theme.colors.gray[7]}; 15 | background-color: white; 16 | outline: none; 17 | cursor: pointer; 18 | transition: 19 | background-color 300ms, 20 | border-color 300ms; 21 | 22 | &[aria-checked='true'] { 23 | color: white; 24 | border-color: ${(p) => p.theme.colors.main.default}; 25 | background-color: ${(p) => p.theme.colors.main.default}; 26 | &:before { 27 | content: '✔'; 28 | position: absolute; 29 | top: 50%; 30 | left: 50%; 31 | transform: translate(-50%, -50%); 32 | font-size: 10px; 33 | } 34 | } 35 | ` 36 | 37 | const Label = styled.label` 38 | transition: color 300ms; 39 | cursor: pointer; 40 | outline: none; 41 | ` 42 | const Container = styled.div` 43 | color: ${(p) => p.theme.colors.gray[3]}; 44 | 45 | display: flex; 46 | align-items: center; 47 | &:hover { 48 | color: ${(p) => p.theme.colors.gray[0]}; 49 | } 50 | 51 | ${StyledCheckbox}:focus + ${Label} { 52 | color: ${(p) => p.theme.colors.gray[0]}; 53 | } 54 | ` 55 | 56 | const Checkbox = ({ 57 | children, 58 | checked, 59 | onChange, 60 | label = 'checkbox', 61 | ...props 62 | }) => ( 63 | 64 | 65 | 66 | 67 | ) 68 | 69 | export default Checkbox 70 | -------------------------------------------------------------------------------- /src/components/Container.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import styled from 'styled-components' 3 | import Box from 'components/Box' 4 | 5 | const Container = styled(Box)` 6 | max-width: ${(p) => p.theme.breakpoints.large}px; 7 | width: 100%; 8 | margin: auto; 9 | ` 10 | 11 | Container.propTypes = { 12 | /** 13 | * Container contents 14 | */ 15 | children: PropTypes.node, 16 | } 17 | 18 | export default Container 19 | -------------------------------------------------------------------------------- /src/components/EmptyView.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import Button from 'components/Button' 5 | import Box from 'components/Box' 6 | 7 | const EmptyView = ({ buttonLink, children, ...props }) => ( 8 | 18 | {children} 19 | 29 | 30 | ) 31 | 32 | EmptyView.propTypes = { 33 | /** 34 | * External link 35 | */ 36 | buttonLink: PropTypes.string, 37 | /** 38 | * Children to be displayed 39 | */ 40 | children: PropTypes.node, 41 | } 42 | 43 | export default EmptyView 44 | -------------------------------------------------------------------------------- /src/components/Header/HelpCenter.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Color from 'color' 3 | import styled from 'styled-components' 4 | import { DialogDisclosure, useDialogState } from 'reakit/Dialog' 5 | 6 | import { 7 | MeilisearchLogo, 8 | DiscordLogo, 9 | GithubLogo, 10 | InterrogationMark, 11 | } from 'components/icons' 12 | import Card from 'components/Card' 13 | import IconButton from 'components/IconButton' 14 | import Link from 'components/Link' 15 | import Modal from 'components/Modal' 16 | import Typography from 'components/Typography' 17 | 18 | const StyledCard = styled(Card)` 19 | padding: 20px 12px; 20 | width: 200px; 21 | display: flex; 22 | flex-direction: column; 23 | align-items: center; 24 | justify-content: center; 25 | ` 26 | 27 | const StyledLink = styled(Link)` 28 | border-radius: 20px; 29 | box-shadow: none; 30 | transition: box-shadow 300ms; 31 | 32 | &:hover, 33 | &:focus { 34 | outline: none; 35 | box-shadow: 0px 0px 30px ${(p) => Color(p.theme.colors.gray[0]).alpha(0.1)}; 36 | } 37 | ` 38 | 39 | const Logo = styled.div` 40 | height: 62px; 41 | display: flex; 42 | justify-content: center; 43 | align-items: center; 44 | ` 45 | 46 | const HelpCard = ({ description, title, logo, href, ...props }) => ( 47 | 48 | 49 | {logo} 50 | 51 | {title} 52 | 53 | 58 | {description} 59 | 60 | 61 | 62 | ) 63 | 64 | const CardsContainer = styled.div` 65 | margin-top: 48px; 66 | display: flex; 67 | flex-direction: row; 68 | align-items: center; 69 | justify-content: center; 70 | 71 | > a + a { 72 | margin-left: 20px; 73 | } 74 | ` 75 | 76 | const HelpCenter = () => { 77 | const dialog = useDialogState() 78 | return ( 79 | <> 80 | 81 | {(props) => ( 82 | 89 | 90 | 91 | )} 92 | 93 | 99 | 100 | If you need help with anything, here are a few links that can be 101 | useful. 102 | 103 | 104 | } 106 | title="Github" 107 | description="Explore our repositories on Github" 108 | href="https://github.com/meilisearch" 109 | /> 110 | } 112 | title="Discord" 113 | description="Join our Discord and find the help you need" 114 | href="https://discord.meilisearch.com" 115 | /> 116 | } 118 | title="Documentation" 119 | description="Learn how to tune your Meilisearch" 120 | href="https://docs.meilisearch.com/?utm_campaign=oss&utm_source=integration&utm_medium=minidashboard" 121 | /> 122 | 123 | 124 | 125 | ) 126 | } 127 | 128 | export default HelpCenter 129 | -------------------------------------------------------------------------------- /src/components/IconButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Color from 'color' 3 | import styled, { css } from 'styled-components' 4 | import PropTypes from 'prop-types' 5 | import { space, color } from 'styled-system' 6 | import theme from 'theme' 7 | 8 | const variants = { 9 | default: css` 10 | border: none; 11 | padding: 4px 6px; 12 | &:focus { 13 | svg { 14 | filter: drop-shadow( 15 | 0px 0px 3px ${(p) => Color(p.theme.colors[p.color]).alpha(0.2)} 16 | ); 17 | } 18 | } 19 | `, 20 | bordered: css` 21 | border-width: 1px; 22 | border-style: solid; 23 | border-color: inherit; 24 | border-radius: 50%; 25 | padding: 4px; 26 | display: flex; 27 | justify-content: center; 28 | align-items: center; 29 | transition: background-color 300ms; 30 | svg { 31 | transition: color 300ms; 32 | } 33 | &:hover, 34 | &:focus { 35 | background-color: currentColor; 36 | svg { 37 | color: white; 38 | } 39 | } 40 | `, 41 | } 42 | 43 | const StyledButton = styled.button` 44 | ${(p) => p.$variant}; 45 | ${space}; 46 | ${color}; 47 | outline: none; 48 | background-color: transparent; 49 | cursor: pointer; 50 | svg { 51 | display: block; 52 | } 53 | ` 54 | 55 | const IconButton = React.forwardRef( 56 | ({ color: iconColor, variant = 'default', children, ...props }, ref) => { 57 | const safeVariant = variants[variant] || variants.default 58 | 59 | return ( 60 | 66 | {children} 67 | 68 | ) 69 | } 70 | ) 71 | 72 | IconButton.propTypes = { 73 | /** 74 | * Color of the icon 75 | */ 76 | color: PropTypes.node, 77 | /** 78 | * variant of the button 79 | */ 80 | variant: PropTypes.oneOf(['default', 'bordered']), 81 | /** 82 | * Text to be displayed 83 | */ 84 | children: PropTypes.node, 85 | } 86 | 87 | export default IconButton 88 | -------------------------------------------------------------------------------- /src/components/Input.js: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react' 2 | import styled from 'styled-components' 3 | import Color from 'color' 4 | import PropTypes from 'prop-types' 5 | 6 | import IconButton from 'components/IconButton' 7 | import Cross from 'components/icons/Cross' 8 | 9 | const InputField = styled.input` 10 | height: 48px; 11 | width: 100%; 12 | padding-left: ${(p) => (p.$hasIcon ? '48px' : '8px')}; 13 | padding-right: 8px; 14 | background-position: top 50% left 16px; 15 | border-color: ${(p) => p.theme.colors.gray[10]}; 16 | border-width: 1px; 17 | border-style: solid; 18 | border-radius: 8px; 19 | box-shadow: 0px 4px 6px ${Color('#000').alpha(0.04)}; 20 | transition: border-color 300ms; 21 | outline: none; 22 | color: ${(p) => p.theme.colors.gray[0]}; 23 | font-weight: 500; 24 | font-size: 18px; 25 | line-height: 22px; 26 | 27 | ::placeholder { 28 | color: ${(p) => p.theme.colors.gray[7]}; 29 | } 30 | 31 | &:hover { 32 | border-color: ${(p) => p.theme.colors.gray[8]}; 33 | } 34 | 35 | &:focus { 36 | border-color: ${(p) => p.theme.colors.main.default}; 37 | svg { 38 | fill: ${(p) => p.theme.colors.main.default}; 39 | } 40 | } 41 | 42 | ::-webkit-search-cancel-button { 43 | -webkit-appearance: none; 44 | } 45 | ` 46 | 47 | const ClearButton = styled(IconButton)` 48 | svg { 49 | width: 11px; 50 | height: 11px; 51 | } 52 | position: absolute; 53 | right: 8px; 54 | top: 50%; 55 | transform: translateY(-50%); 56 | color: ${(p) => p.theme.colors.gray[5]}; 57 | ` 58 | 59 | const InputContainer = styled.div` 60 | position: relative; 61 | display: inline-block; 62 | 63 | > svg { 64 | position: absolute; 65 | top: 50%; 66 | left: 16px; 67 | transform: translateY(-50%); 68 | } 69 | ` 70 | 71 | const Input = ({ icon, ref, clear, type, value, ...props }) => { 72 | const input = useRef(null) 73 | return ( 74 | 75 | {icon} 76 | 83 | {type === 'search' && ( 84 | { 87 | clear() 88 | input.current.focus() 89 | }} 90 | style={{ display: value ? 'block' : 'none' }} 91 | > 92 | 93 | 94 | )} 95 | 96 | ) 97 | } 98 | 99 | export default Input 100 | 101 | Input.propTypes = { 102 | /** 103 | * Icon you want to appear inside the input, on the left 104 | */ 105 | icon: PropTypes.node, 106 | } 107 | -------------------------------------------------------------------------------- /src/components/Link.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import PropTypes from 'prop-types' 4 | 5 | const A = styled.a` 6 | color: ${(p) => p.color || p.theme.colors.main.default}; 7 | text-decoration: underline; 8 | transition: color 300ms; 9 | outline: none; 10 | &:hover, 11 | &:focus { 12 | color: ${(p) => p.theme.colors.main.hover}; 13 | } 14 | ` 15 | 16 | const Link = ({ href, target = '_blank', children, ...props }) => ( 17 | 18 | {children} 19 | 20 | ) 21 | 22 | Link.propTypes = { 23 | /** 24 | * The link where the user should be redirected 25 | */ 26 | href: PropTypes.string, 27 | /** 28 | * How the user should be redirected 29 | */ 30 | target: PropTypes.string, 31 | /** 32 | * The text that should be cliquable 33 | */ 34 | children: PropTypes.node, 35 | } 36 | 37 | export default Link 38 | -------------------------------------------------------------------------------- /src/components/Modal.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Color from 'color' 3 | import PropTypes from 'prop-types' 4 | import styled from 'styled-components' 5 | import { 6 | Dialog as ReakitDialog, 7 | DialogBackdrop as ReakitDialogBackdrop, 8 | } from 'reakit/Dialog' 9 | 10 | import IconButton from 'components/IconButton' 11 | import Typography from 'components/Typography' 12 | import { Cross } from 'components/icons' 13 | 14 | const DialogBackdrop = styled(ReakitDialogBackdrop)` 15 | &[data-leave] { 16 | opacity: 0; 17 | } 18 | &[data-enter] { 19 | opacity: 1; 20 | } 21 | transition: opacity 250ms ease-in-out; 22 | position: fixed; 23 | top: 0; 24 | right: 0; 25 | bottom: 0; 26 | left: 0; 27 | z-index: 997; 28 | background-color: ${(p) => Color(p.theme.colors.gray[3]).alpha(0.6)}; 29 | ` 30 | 31 | const Dialog = styled(ReakitDialog)` 32 | &[data-leave] { 33 | opacity: 0; 34 | } 35 | &[data-enter] { 36 | opacity: 1; 37 | } 38 | transition: opacity 250ms ease-in-out; 39 | max-width: 992px; 40 | position: relative; 41 | width: 70%; 42 | top: 50%; 43 | left: 50%; 44 | transform: translate(-50%, calc(-50% - 48px)); 45 | border-radius: 0.25rem; 46 | outline: 0px; 47 | padding: 24px 32px 24px 40px; 48 | box-shadow: 0px 0px 30px ${(p) => Color(p.theme.colors.gray[0]).alpha(0.15)}; 49 | background-color: ${(p) => p.theme.colors.gray[11]}; 50 | z-index: 999; 51 | ` 52 | 53 | const Button = styled(IconButton)` 54 | position: absolute; 55 | top: 16px; 56 | right: 16px; 57 | &:hover { 58 | pointer-events: initial; 59 | } 60 | ` 61 | 62 | const Modal = ({ 63 | title, 64 | dialog, 65 | children, 66 | ariaLabel = 'Welcome', 67 | ...props 68 | }) => ( 69 | 70 | 71 | {title && ( 72 | 73 | {title} 74 | 75 | )} 76 | {children} 77 | 80 | 81 | 82 | ) 83 | 84 | Modal.propTypes = { 85 | /** 86 | * Title of the Modal 87 | */ 88 | title: PropTypes.string, 89 | /** 90 | * Modal contents 91 | */ 92 | children: PropTypes.node, 93 | } 94 | 95 | export default Modal 96 | -------------------------------------------------------------------------------- /src/components/NewsletterForm.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled, { keyframes } from 'styled-components' 3 | import useNewsletter from 'hooks/useNewsletter' 4 | import ArrowPathIcon from './icons/heroicons/ArrowPathIcon' 5 | import CheckIcon from './icons/heroicons/CheckIcon' 6 | 7 | const spin = keyframes` 8 | from { 9 | transform: rotate(0deg); 10 | } 11 | to { 12 | transform: rotate(360deg); 13 | } 14 | ` 15 | 16 | const SpinningIcon = styled.div` 17 | display: inline-flex; 18 | animation: ${spin} 1s linear infinite; 19 | svg { 20 | width: 1.25rem; 21 | height: 1.25rem; 22 | } 23 | ` 24 | 25 | const Input = styled.input` 26 | width: 100%; 27 | padding: 0.75rem 1rem; 28 | border: 1px solid ${(p) => p.theme.colors.gray[8]}; 29 | border-radius: 8px; 30 | font-size: 0.875rem; 31 | color: ${(p) => p.theme.colors.gray[0]}; 32 | background: ${(p) => p.theme.colors.white}; 33 | margin: 1rem 0; 34 | &:focus { 35 | outline: none; 36 | border-color: ${(p) => p.theme.colors.main.default}; 37 | } 38 | &::placeholder { 39 | color: ${(p) => p.theme.colors.gray[6]}; 40 | } 41 | ` 42 | 43 | const Button = styled.button` 44 | width: 100%; 45 | padding: 0.75rem 1rem; 46 | background: ${(p) => p.theme.colors.main.default}; 47 | color: white; 48 | border: none; 49 | border-radius: 8px; 50 | font-size: 0.875rem; 51 | font-weight: 600; 52 | cursor: pointer; 53 | transition: background-color 0.2s; 54 | display: flex; 55 | align-items: center; 56 | justify-content: center; 57 | gap: 0.5rem; 58 | &:hover { 59 | background: ${(p) => p.theme.colors.main.dark}; 60 | } 61 | &:disabled { 62 | opacity: 0.7; 63 | cursor: not-allowed; 64 | } 65 | ` 66 | 67 | const ErrorMessage = styled.p` 68 | color: ${(p) => p.theme.colors.error.text}; 69 | font-size: 0.875rem; 70 | margin-top: 0.5rem; 71 | ` 72 | 73 | const SuccessMessage = styled.div` 74 | display: flex; 75 | text-align: center; 76 | align-items: center; 77 | justify-content: center; 78 | gap: 0.5rem; 79 | background-color: ${(p) => p.theme.colors.success.background}; 80 | border: 1px solid ${(p) => p.theme.colors.success.border}; 81 | border-radius: 8px; 82 | padding: 0rem; 83 | color: ${(p) => p.theme.colors.success.text}; 84 | svg { 85 | width: 1.25rem; 86 | height: 1.25rem; 87 | } 88 | p { 89 | font-size: 0.875rem; 90 | } 91 | ` 92 | 93 | const NewsletterForm = () => { 94 | const [email, setEmail] = React.useState('') 95 | const { subscribe } = useNewsletter() 96 | const [status, setStatus] = React.useState('idle') 97 | const [error, setError] = React.useState(null) 98 | 99 | const handleSubmit = (e) => { 100 | e.preventDefault() 101 | setStatus('loading') 102 | subscribe(email) 103 | .then(() => setStatus('success')) 104 | .catch((err) => { 105 | setStatus('error') 106 | setError(err.message) 107 | }) 108 | } 109 | 110 | if (status === 'success') { 111 | return ( 112 | 113 | 114 |

Thanks for subscribing!

115 |
116 | ) 117 | } 118 | 119 | return ( 120 |
121 | setEmail(e.target.value)} 126 | disabled={status === 'loading'} 127 | required 128 | style={{ marginTop: '0rem' }} 129 | /> 130 | 146 | {status === 'error' && {error}} 147 |
148 | ) 149 | } 150 | 151 | export default NewsletterForm 152 | -------------------------------------------------------------------------------- /src/components/NoMeilisearchRunning.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Typography from 'components/Typography' 3 | import EmptyView from 'components/EmptyView' 4 | import BodyWrapper from 'components/BodyWrapper' 5 | import Box from 'components/Box' 6 | 7 | const NoMeilisearchRunning = () => ( 8 | 9 | 10 | 11 | 17 | It seems like Meilisearch isn’t running, did you forget to start it? 18 | 19 | 25 | (Don’t forget to set an API Key if you want one) 26 | 27 | 32 | 33 | 🧐 34 | 35 | 36 | 37 | 38 | 39 | ) 40 | 41 | export default NoMeilisearchRunning 42 | -------------------------------------------------------------------------------- /src/components/NoSelectOption.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import Box from 'components/Box' 4 | import Typography from 'components/Typography' 5 | import Button from 'components/Button' 6 | 7 | const NoSelectOption = () => ( 8 | 17 | 18 | no index found 19 | 20 | 30 | 31 | ) 32 | 33 | export default NoSelectOption 34 | -------------------------------------------------------------------------------- /src/components/OnBoarding.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | 4 | import Box from 'components/Box' 5 | import Button from 'components/Button' 6 | import Card from 'components/Card' 7 | import Typography from 'components/Typography' 8 | import { LogoText, KeyBig, DocumentBig } from 'components/icons' 9 | 10 | const OnBoardingCard = ({ number, title, icon, href, ...props }) => ( 11 | 23 | {icon} 24 | 25 | 26 | {number} 27 | 28 | 29 | {title} 30 | 31 | 32 | 42 | 43 | ) 44 | 45 | const CardsContainer = styled.div` 46 | display: flex; 47 | margin-top: 48px; 48 | section + section { 49 | margin-left: 72px; 50 | } 51 | svg { 52 | color: ${(p) => p.theme.colors.gray[8]}; 53 | height: 36px; 54 | } 55 | ` 56 | 57 | const OnBoarding = () => ( 58 | 65 | 66 | Welcome to 67 | 68 | 69 | 70 | Mini Dashboard 71 | 72 | 78 | This dashboard will help you check the search results with ease. 79 | 80 | 81 | } 86 | /> 87 | } 92 | /> 93 | {/* TODO: Enable it once facet search is available */} 94 | {/* } 99 | /> */} 100 | 101 | 102 | ) 103 | 104 | export default OnBoarding 105 | -------------------------------------------------------------------------------- /src/components/Results/Highlight.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connectHighlight } from 'react-instantsearch-dom' 3 | import Typography from 'components/Typography' 4 | 5 | const Highlight = connectHighlight( 6 | ({ highlight, attribute, hit, indexContextValue, ...props }) => { 7 | const parsedHit = highlight({ 8 | highlightProperty: '_highlightResult', 9 | attribute, 10 | hit, 11 | }) 12 | 13 | return ( 14 | 15 | {parsedHit.map((part, index) => 16 | part.isHighlighted ? ( 17 | {part.value} 18 | ) : ( 19 | {part.value} 20 | ) 21 | )} 22 | 23 | ) 24 | } 25 | ) 26 | 27 | export default Highlight 28 | -------------------------------------------------------------------------------- /src/components/Results/InfiniteHits.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import { connectInfiniteHits } from 'react-instantsearch-dom' 4 | // import ReactJson from 'react-json-view' 5 | 6 | // import { jsonTheme } from 'theme' 7 | import Button from 'components/Button' 8 | // import Card from 'components/Card' 9 | import ScrollToTop from 'components/ScrollToTop' 10 | 11 | import Hit from './Hit' 12 | 13 | const HitsList = styled.ul` 14 | padding: 0; 15 | margin: 0; 16 | > li + li { 17 | margin-top: 16px; 18 | } 19 | ` 20 | 21 | const isAnImage = async (elem) => { 22 | // Test the standard way with regex and image extensions 23 | if ( 24 | typeof elem === 'string' && 25 | elem.match(/^(https|http):\/\/.*(jpe?g|png|gif|webp)(\?.*)?$/gi) 26 | ) 27 | return true 28 | 29 | if (typeof elem === 'string' && elem.match(/^https?:\/\//)) { 30 | // Tries to load an image that is a valid URL but doesn't have a correct extension 31 | return new Promise((resolve) => { 32 | const img = new Image() 33 | img.src = elem 34 | img.onload = () => resolve(true) 35 | img.onerror = () => resolve(false) 36 | }) 37 | } 38 | return false 39 | } 40 | 41 | const findImageKey = async (array) => { 42 | const promises = array.map(async (elem) => isAnImage(elem[1])) 43 | const results = await Promise.all(promises) 44 | const index = results.findIndex((result) => result) 45 | const imageField = array[index] 46 | return imageField?.[0] 47 | } 48 | 49 | const InfiniteHits = connectInfiniteHits(({ hits, hasMore, refineNext }) => { 50 | const [imageKey, setImageKey] = React.useState(false) 51 | 52 | React.useEffect(() => { 53 | const getImageKey = async () => { 54 | setImageKey(hits[0] ? await findImageKey(Object.entries(hits[0])) : null) 55 | } 56 | getImageKey() 57 | }, [hits[0]]) 58 | // ({ hits, hasMore, refineNext, mode }) => { 59 | return ( 60 |
61 | {/* {mode === 'fancy' ? ( */} 62 | 63 | {hits.map((hit, index) => ( 64 | 65 | ))} 66 | 67 | {/* ) : ( 68 | 69 | 78 | 79 | )} */} 80 | {hasMore && ( 81 | 89 | )} 90 | 91 |
92 | ) 93 | }) 94 | 95 | export default InfiniteHits 96 | -------------------------------------------------------------------------------- /src/components/Results/NoResultForRequest.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import EmptyView from 'components/EmptyView' 3 | import Typography from 'components/Typography' 4 | 5 | const NoResultForRequest = () => ( 6 | 7 | 13 | Sorry mate, no results matching your request 14 | 15 | 16 | 17 | ☹️ 18 | 19 | 20 | 21 | ) 22 | 23 | export default NoResultForRequest 24 | -------------------------------------------------------------------------------- /src/components/Results/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | import React from 'react' 3 | import { connectStateResults, connectStats } from 'react-instantsearch-dom' 4 | 5 | // import { DocumentMedium, Picture } from 'components/icons' 6 | import Box from 'components/Box' 7 | import Stats from 'components/Stats' 8 | // import Toggle from 'components/Toggle' 9 | // import useLocalStorage from 'hooks/useLocalStorage' 10 | import InfiniteHits from './InfiniteHits' 11 | import NoResultForRequest from './NoResultForRequest' 12 | 13 | // const Label1 = () => ( 14 | // <> 15 | // 16 | // Fancy 17 | // 18 | // ) 19 | 20 | // const Label2 = () => ( 21 | // <> 22 | // 23 | // Json 24 | // 25 | // ) 26 | 27 | const ConnectedStats = connectStats((props) => ) 28 | 29 | const Results = connectStateResults(({ searchResults }) => { 30 | const hasResults = !!searchResults && searchResults?.nbHits !== 0 31 | 32 | return ( 33 | <> 34 | 40 |
41 | 42 |
43 | {/* } 45 | offLabel={} 46 | ariaLabel="toggleMode" 47 | initialValue={mode === 'fancy'} 48 | onChange={(e) => setMode(e.target.checked ? 'fancy' : 'json')} 49 | /> */} 50 |
51 | {/* {hasResults ? : } */} 52 | {hasResults ? : } 53 | 54 | ) 55 | }) 56 | 57 | export default Results 58 | -------------------------------------------------------------------------------- /src/components/ScrollToTop.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Color from 'color' 3 | import styled from 'styled-components' 4 | 5 | import IconButton from 'components/IconButton' 6 | import ArrowDownIcon from 'components/icons/ArrowDown' 7 | 8 | const ArrowDown = styled(ArrowDownIcon)` 9 | transform: rotate(180deg); 10 | color: white; 11 | ` 12 | 13 | const ScrollButton = styled(IconButton)` 14 | position: fixed; 15 | bottom: 40px; 16 | right: 40px; 17 | background-color: ${(p) => Color(p.theme.colors.gray[2]).alpha(0.4)}; 18 | width: 40px; 19 | height: 40px; 20 | border-radius: 50%; 21 | display: flex; 22 | align-items: center; 23 | justify-content: center; 24 | transition: background-color 300ms; 25 | 26 | &:hover, 27 | &:focus { 28 | background-color: ${(p) => Color(p.theme.colors.gray[4])}; 29 | } 30 | ` 31 | 32 | const ScrollToTop = () => { 33 | const scrollToTop = () => { 34 | window.scroll({ top: 0, behavior: 'smooth' }) 35 | } 36 | 37 | return ( 38 | scrollToTop()} aria-label="scroll to top"> 39 | 40 | 41 | ) 42 | } 43 | 44 | export default ScrollToTop 45 | -------------------------------------------------------------------------------- /src/components/SearchBox.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import { connectSearchBox } from 'react-instantsearch-dom' 4 | 5 | import Input from 'components/Input' 6 | import { SearchMedium } from 'components/icons' 7 | 8 | const SearchWrapper = styled.div` 9 | width: 100%; 10 | display: flex; 11 | flex: 1; 12 | 13 | > div { 14 | width: 100%; 15 | } 16 | ` 17 | 18 | const SearchIcon = styled(SearchMedium)` 19 | max-width: 20px; 20 | color: ${(p) => p.theme.colors.gray[2]}; 21 | ` 22 | 23 | const SearchBox = connectSearchBox( 24 | ({ currentRefinement, refine, refreshIndexes, currentIndex }) => { 25 | const [value, setValue] = React.useState(currentRefinement) 26 | 27 | React.useEffect(() => { 28 | if (currentIndex?.stats?.numberOfDocuments === 0) { 29 | refreshIndexes() 30 | } 31 | refine(value) 32 | }, [value]) 33 | 34 | return ( 35 | 36 | setValue(e.target.value)} 40 | clear={() => setValue('')} 41 | placeholder="Search something" 42 | icon={} 43 | /> 44 | 45 | ) 46 | } 47 | ) 48 | 49 | export default SearchBox 50 | -------------------------------------------------------------------------------- /src/components/Select.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled, { css } from 'styled-components' 3 | import Color from 'color' 4 | import PropTypes from 'prop-types' 5 | import { useMenuState, Menu, MenuItem, MenuButton } from 'reakit/Menu' 6 | 7 | import { ArrowDown } from 'components/icons' 8 | import Typography from 'components/Typography' 9 | 10 | const Arrow = styled(ArrowDown)` 11 | position: absolute; 12 | right: 0; 13 | top: calc(50% - 3px); 14 | transition: transform 300ms; 15 | width: 9px; 16 | ` 17 | 18 | const SelectIndexesButton = styled(MenuButton)` 19 | position: relative; 20 | padding: 12px 32px 12px 12px; 21 | height: 48px; 22 | background-color: white; 23 | display: flex; 24 | align-items: center; 25 | min-width: 260px; 26 | border-color: ${(p) => p.theme.colors.gray[10]}; 27 | border-width: 1px; 28 | border-style: solid; 29 | border-radius: 8px; 30 | transition: border-color 300ms; 31 | outline: none; 32 | color: ${(p) => p.theme.colors.gray[0]}; 33 | font-weight: 500; 34 | font-size: 16px; 35 | line-height: 22px; 36 | cursor: pointer; 37 | 38 | ${(p) => 39 | p.visible && 40 | css` 41 | ${Arrow} { 42 | transform: rotate(180deg); 43 | } 44 | `}; 45 | 46 | &:hover, 47 | &:focus, 48 | &[aria-expanded='true'] { 49 | border-color: ${(p) => p.theme.colors.main.default}; 50 | } 51 | 52 | svg { 53 | margin-right: 16px; 54 | color: ${(p) => p.theme.colors.main.default}; 55 | flex-shrink: 0; 56 | } 57 | ` 58 | 59 | const IndexesListContainer = styled(Menu)` 60 | min-width: 218px; 61 | display: flex; 62 | flex-direction: column; 63 | outline: none; 64 | border-width: 1px; 65 | border-style: solid; 66 | border-color: ${(p) => p.theme.colors.gray[10]}; 67 | border-radius: 8px; 68 | box-shadow: 0px 4px 6px ${Color('#000').alpha(0.04)}; 69 | overflow: hidden; 70 | max-height: 180px; 71 | overflow: auto; 72 | ` 73 | 74 | const IndexItem = styled(MenuItem)` 75 | background-color: white; 76 | height: 40px; 77 | border: 0; 78 | outline: none; 79 | transition: background-color 300ms; 80 | padding: 6px 18px; 81 | text-align: left; 82 | color: ${(p) => p.theme.colors.gray[2]}; 83 | 84 | &:hover, 85 | &:focus { 86 | cursor: pointer; 87 | background-color: ${(p) => p.theme.colors.gray[10]}; 88 | } 89 | 90 | ${(p) => 91 | p.$selected && 92 | css` 93 | span:first-child { 94 | font-weight: 600; 95 | } 96 | span:nth-child(2) { 97 | color: ${p.theme.colors.gray[5]}; 98 | } 99 | `} 100 | ` 101 | 102 | const IndexId = styled(Typography)` 103 | text-overflow: ellipsis; 104 | white-space: nowrap; 105 | overflow: hidden; 106 | ` 107 | 108 | const TextToDisplay = ({ option, currentOption }) => ( 109 | <> 110 | 111 | {option ? option.uid : 'Select an index'} 112 | {' '} 113 | {option?.stats && ( 114 | 115 | {option.stats.numberOfDocuments.toLocaleString()} 116 | 117 | )} 118 | 119 | ) 120 | 121 | const Select = ({ 122 | options, 123 | icon, 124 | currentOption, 125 | onChange, 126 | noOptionComponent, 127 | ...props 128 | }) => { 129 | const menu = useMenuState() 130 | return ( 131 | <> 132 | 133 | {icon || null} 134 | 135 | 136 | 137 | 138 | {options?.length 139 | ? options.map((data, index) => ( 140 | { 146 | onChange(data) 147 | menu.hide() 148 | }} 149 | $selected={currentOption?.uid === data.uid} 150 | > 151 | 152 | 153 | )) 154 | : noOptionComponent} 155 | 156 | 157 | ) 158 | } 159 | 160 | Select.propTypes = { 161 | /** 162 | * List of options to appear 163 | */ 164 | options: PropTypes.arrayOf( 165 | PropTypes.shape({ 166 | uid: PropTypes.string, 167 | stats: PropTypes.shape({ 168 | numberOfDocuments: PropTypes.number, 169 | }), 170 | }) 171 | ), 172 | /** 173 | * Icon you want to appear inside the select button, on the left 174 | */ 175 | icon: PropTypes.node, 176 | /** 177 | * The current option to be displayed 178 | */ 179 | currentOption: PropTypes.shape({ 180 | uid: PropTypes.string, 181 | stats: PropTypes.shape({ 182 | numberOfDocuments: PropTypes.number, 183 | }), 184 | }), 185 | /** 186 | * Function used to change the current option, triggered on click on an option 187 | */ 188 | onChange: PropTypes.func, 189 | /** 190 | * Component to display if select has no options 191 | */ 192 | noOptionComponent: PropTypes.node, 193 | } 194 | 195 | export default Select 196 | -------------------------------------------------------------------------------- /src/components/Sidebar.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Color from 'color' 3 | import styled from 'styled-components' 4 | import PropTypes from 'prop-types' 5 | import { ArrowDown } from 'components/icons' 6 | import { 7 | useDisclosureState, 8 | Disclosure as ReakitDisclosure, 9 | DisclosureContent as ReakitDisclosureContent, 10 | } from 'reakit/Disclosure' 11 | 12 | const SidebarWrapper = styled.div` 13 | background-color: white; 14 | flex-shrink: 0; 15 | width: 300px; 16 | display: flex; 17 | overflow: auto; 18 | box-shadow: 0px 0px 30px ${(p) => Color(p.theme.colors.gray[0]).alpha(0.07)}; 19 | position: relative; 20 | margin-left: -246px; 21 | transition: margin-left 300ms; 22 | position: sticky; 23 | top: 0; 24 | height: 100%; 25 | 26 | &[aria-expanded='true'] { 27 | margin-left: 0px; 28 | } 29 | ` 30 | 31 | const Arrow = styled(ArrowDown)` 32 | width: 18px; 33 | height: 9px; 34 | transition: color 300ms; 35 | ` 36 | 37 | const OpeningIcon = styled.div` 38 | width: 16px; 39 | height: 16px; 40 | display: flex; 41 | align-items: center; 42 | justify-content: center; 43 | transition: opacity 300ms; 44 | ` 45 | 46 | const Disclosure = styled(ReakitDisclosure)` 47 | position: absolute; 48 | top: 24px; 49 | right: 12px; 50 | z-index: 1; 51 | cursor: pointer; 52 | padding: 6px; 53 | background-color: transparent; 54 | border: 0; 55 | 56 | svg { 57 | color: ${(p) => p.theme.colors.gray[7]}; 58 | transition: color 300ms; 59 | } 60 | &:hover, 61 | &:focus { 62 | outline: none; 63 | 64 | svg { 65 | color: ${(p) => p.theme.colors.main.default}; 66 | } 67 | } 68 | ` 69 | 70 | const DisclosureContent = styled(ReakitDisclosureContent)` 71 | transition: 72 | opacity 300ms ease-in-out, 73 | transform 300ms ease-in-out; 74 | opacity: 0; 75 | height: 100%; 76 | width: 100%; 77 | transform: translateX(-100%); 78 | &[data-enter] { 79 | opacity: 1; 80 | transform: translateX(0); 81 | } 82 | ` 83 | 84 | const Sidebar = ({ 85 | sidebarIcon = , 86 | visible = true, 87 | onChange = () => {}, 88 | children, 89 | ...props 90 | }) => { 91 | const disclosure = useDisclosureState({ animated: true, visible }) 92 | const [toggled, setToggled] = React.useState(false) 93 | 94 | const openingIcon = sidebarIcon || ( 95 | 96 | ) 97 | const openIconRef = React.useRef(null) 98 | const closeIconRef = React.useRef(null) 99 | 100 | React.useEffect(() => { 101 | if (toggled) { 102 | if (!disclosure.visible) { 103 | openIconRef?.current?.focus() 104 | } else { 105 | closeIconRef?.current?.focus() 106 | } 107 | onChange(disclosure.visible) 108 | } 109 | }, [openIconRef, disclosure.visible]) 110 | 111 | return ( 112 | 113 | {!disclosure.visible && ( 114 | setToggled(true)} 117 | {...disclosure} 118 | > 119 | {openingIcon} 120 | 121 | )} 122 | 123 | {disclosure.visible && ( 124 | { 127 | disclosure.hide() 128 | setToggled(true) 129 | }} 130 | > 131 | 132 | 133 | )} 134 | {children} 135 | 136 | 137 | ) 138 | } 139 | 140 | Sidebar.propTypes = { 141 | /** 142 | * The icon you want to display to open the sidebar. Arrow by default 143 | */ 144 | sidebarIcon: PropTypes.node, 145 | /** 146 | * The initial state of the sidebar (open or closed) 147 | */ 148 | visible: PropTypes.bool, 149 | /** 150 | * Action to trigger on toggle change 151 | */ 152 | onChange: PropTypes.func, 153 | /** 154 | * Content of the sidebar 155 | */ 156 | children: PropTypes.node, 157 | } 158 | 159 | export default Sidebar 160 | -------------------------------------------------------------------------------- /src/components/Stats.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import PropTypes from 'prop-types' 4 | 5 | import { SearchSmall, Speed } from 'components/icons' 6 | import Typography from 'components/Typography' 7 | 8 | const Legend = styled(Typography)` 9 | display: flex; 10 | align-items: center; 11 | margin-bottom: 6px; 12 | svg { 13 | margin-right: 6px; 14 | } 15 | ` 16 | 17 | const Stat = ({ icon, legend, value, ...props }) => ( 18 |
19 | 20 | {icon} 21 | {legend} 22 | 23 | 24 | {value} 25 | 26 |
27 | ) 28 | 29 | const StatsContainer = styled.div` 30 | display: flex; 31 | ` 32 | 33 | const Stats = ({ nbHits, processingTimeMS, nbResults, ...props }) => { 34 | const localeNbHits = `${ 35 | nbHits !== nbResults ? '~' : '' 36 | } ${nbHits.toLocaleString()}` 37 | 38 | return ( 39 | 40 | } 42 | legend="Hits" 43 | value={localeNbHits} 44 | /> 45 | } 47 | legend="Time spent" 48 | value={`${processingTimeMS} ms`} 49 | /> 50 | 51 | ) 52 | } 53 | 54 | Stats.propTypes = { 55 | /** 56 | * Number of hits provided by connectStats 57 | */ 58 | nbHits: PropTypes.number, 59 | /** 60 | * Time in ms needed to execute the request 61 | */ 62 | processingTimeMS: PropTypes.number, 63 | /** 64 | * Number of results provided by connectStateResults 65 | */ 66 | nbResults: PropTypes.number, 67 | } 68 | 69 | export default Stats 70 | -------------------------------------------------------------------------------- /src/components/Toggle.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import PropTypes from 'prop-types' 4 | import Color from 'color' 5 | import { Checkbox } from 'reakit/Checkbox' 6 | 7 | const Label = styled.label` 8 | width: 200px; 9 | height: 40px; 10 | background-color: ${(p) => p.theme.colors.gray[10]}; 11 | border-radius: 60px; 12 | display: flex; 13 | align-items: center; 14 | position: relative; 15 | ` 16 | 17 | const Input = styled(Checkbox)` 18 | width: 98px; 19 | height: 32px; 20 | margin: 0; 21 | background-color: white; 22 | border-radius: 60px; 23 | position: absolute; 24 | top: 4px; 25 | transform: translate(4px); 26 | transition: transform 300ms; 27 | &[aria-checked='false'] { 28 | transform: translate(98px); 29 | } 30 | &:before { 31 | content: ''; 32 | position: absolute; 33 | top: 0; 34 | left: 0; 35 | right: 0; 36 | bottom: 0; 37 | background-color: white; 38 | border-radius: 60px; 39 | box-shadow: 0px 4px 6px ${(p) => Color(p.theme.colors.gray[0]).alpha(0.11)}; 40 | } 41 | &:focus { 42 | outline: none; 43 | &:before { 44 | box-shadow: 0px 0px 12px 45 | ${(p) => Color(p.theme.colors.gray[0]).alpha(0.2)}; 46 | } 47 | } 48 | -moz-appearance: initial; 49 | ` 50 | 51 | const Span = styled.span` 52 | height: 100%; 53 | 54 | width: 50%; 55 | display: inline-flex; 56 | align-items: center; 57 | justify-content: center; 58 | z-index: 2; 59 | &: hover { 60 | cursor: pointer; 61 | } 62 | transition: color 300ms; 63 | &:hover { 64 | color: ${(p) => p.theme.colors.gray[0]}; 65 | } 66 | color: ${(p) => 67 | p.checked ? p.theme.colors.gray[0] : p.theme.colors.gray[5]}; 68 | ` 69 | 70 | const Toggle = ({ 71 | onLabel = 'On', 72 | offLabel = 'Off', 73 | ariaLabel, 74 | onChange, 75 | initialValue = true, 76 | ...props 77 | }) => { 78 | const [checked, setChecked] = React.useState(initialValue) 79 | const toggle = () => setChecked(!checked) 80 | 81 | return ( 82 | 94 | ) 95 | } 96 | 97 | Toggle.propTypes = { 98 | /** 99 | * Text displayed when toggle is on 100 | */ 101 | onLabel: PropTypes.element, 102 | /** 103 | * Text displayed when toggle is off 104 | */ 105 | offLabel: PropTypes.element, 106 | /** 107 | * Aria-label for toggler 108 | */ 109 | ariaLabel: PropTypes.string, 110 | /** 111 | * Function to run when a change occurs 112 | */ 113 | onChange: PropTypes.func, 114 | /** 115 | * The initial state of the Toggle 116 | */ 117 | initialValue: PropTypes.bool, 118 | } 119 | 120 | export default Toggle 121 | -------------------------------------------------------------------------------- /src/components/Typography.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled, { css } from 'styled-components' 3 | import PropTypes from 'prop-types' 4 | import { space, color } from 'styled-system' 5 | 6 | const variants = { 7 | default: { 8 | tag: 'span', 9 | style: css``, 10 | }, 11 | typo1: { 12 | tag: 'h2', 13 | style: css` 14 | font-size: 22px; 15 | line-height: 22px; 16 | font-weight: 500; 17 | margin: 0; 18 | `, 19 | }, 20 | typo2: { 21 | tag: 'span', 22 | style: css` 23 | font-family: Barlow; 24 | font-size: 15px; 25 | line-height: 22px; 26 | font-weight: 500; 27 | `, 28 | }, 29 | typo3: { 30 | tag: 'span', 31 | style: css` 32 | font-size: 13px; 33 | line-height: 19px; 34 | font-weight: 500; 35 | `, 36 | }, 37 | typo4: { 38 | tag: 'span', 39 | style: css` 40 | font-size: 16px; 41 | line-height: 22px; 42 | font-weight: 500; 43 | `, 44 | }, 45 | typo5: { 46 | tag: 'span', 47 | style: css` 48 | font-size: 14px; 49 | line-height: 22px; 50 | font-weight: 500; 51 | `, 52 | }, 53 | typo6: { 54 | tag: 'span', 55 | style: css` 56 | font-size: 12px; 57 | line-height: 22px; 58 | font-weight: 400; 59 | letter-spacing: 0.03em; 60 | `, 61 | }, 62 | typo7: { 63 | tag: 'span', 64 | style: css` 65 | font-size: 16px; 66 | line-height: 22px; 67 | font-weight: 500; 68 | letter-spacing: 1px; 69 | `, 70 | }, 71 | typo8: { 72 | tag: 'span', 73 | style: css` 74 | font-size: 18px; 75 | line-height: 25px; 76 | font-weight: 400; 77 | `, 78 | }, 79 | typo9: { 80 | tag: 'span', 81 | style: css` 82 | font-size: 25px; 83 | line-height: 22px; 84 | font-weight: 400; 85 | `, 86 | }, 87 | typo10: { 88 | tag: 'span', 89 | style: css` 90 | font-size: 12px; 91 | font-weight: 500; 92 | line-height: 22px; 93 | `, 94 | }, 95 | typo11: { 96 | tag: 'span', 97 | style: css` 98 | font-size: 15px; 99 | font-weight: 400; 100 | line-height: 22px; 101 | display: inline-block; 102 | `, 103 | }, 104 | typo12: { 105 | tag: 'span', 106 | style: css` 107 | font-size: 46px; 108 | line-height: 54px; 109 | font-weight: 300; 110 | `, 111 | }, 112 | typo13: { 113 | tag: 'span', 114 | style: css` 115 | font-size: 26px; 116 | line-height: 30px; 117 | font-weight: 500; 118 | `, 119 | }, 120 | // Used in Banner (Cloud banner & api key warning banner) 121 | typo14: { 122 | tag: 'span', 123 | style: css` 124 | font-size: 15px; 125 | line-height: 25px; 126 | font-weight: 500; 127 | `, 128 | }, 129 | // Used in Banner 130 | typo15: { 131 | tag: 'span', 132 | style: css` 133 | font-size: 15px; 134 | font-weight: 300; 135 | line-height: 25px; 136 | display: inline-block; 137 | `, 138 | }, 139 | } 140 | 141 | const StyledTypography = styled.span` 142 | ${(p) => p.$variant.style}; 143 | ${space}; 144 | ${color}; 145 | ` 146 | 147 | const Typography = ({ variant = 'default', children, ...props }) => { 148 | const safeVariant = variants[variant] || variants.default 149 | const { tag = 'span' } = safeVariant 150 | return ( 151 | 152 | {children} 153 | 154 | ) 155 | } 156 | 157 | Typography.propTypes = { 158 | /** 159 | * Text's variant 160 | */ 161 | variant: PropTypes.oneOf([ 162 | 'default', 163 | 'typo1', 164 | 'typo2', 165 | 'typo3', 166 | 'typo4', 167 | 'typo5', 168 | 'typo6', 169 | 'typo7', 170 | 'typo8', 171 | 'typo9', 172 | 'typo10', 173 | 'typo11', 174 | 'typo12', 175 | 'typo13', 176 | 'typo14', 177 | 'typo15', 178 | ]), 179 | /** 180 | * Text to be displayed 181 | */ 182 | children: PropTypes.node, 183 | } 184 | 185 | export default Typography 186 | -------------------------------------------------------------------------------- /src/components/icons/AlertCircle.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | const SvgAlertCircle = ({ title, titleId, ...props }) => ( 3 | 15 | {title ? {title} : null} 16 | 17 | 18 | 19 | ) 20 | export default SvgAlertCircle 21 | -------------------------------------------------------------------------------- /src/components/icons/ArrowDown.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | const SvgArrowDown = ({ title, titleId, ...props }) => ( 3 | 10 | {title ? {title} : null} 11 | 18 | 19 | ) 20 | export default SvgArrowDown 21 | -------------------------------------------------------------------------------- /src/components/icons/Cross.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | const SvgCross = ({ title, titleId, ...props }) => ( 3 | 10 | {title ? {title} : null} 11 | 18 | 25 | 26 | ) 27 | export default SvgCross 28 | -------------------------------------------------------------------------------- /src/components/icons/DiscordLogo.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | const SvgDiscordLogo = ({ title, titleId, ...props }) => ( 3 | 10 | {title ? {title} : null} 11 | 17 | 23 | 24 | ) 25 | export default SvgDiscordLogo 26 | -------------------------------------------------------------------------------- /src/components/icons/DocumentBig.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | const SvgDocumentBig = ({ title, titleId, ...props }) => ( 3 | 10 | {title ? {title} : null} 11 | 15 | 21 | 22 | ) 23 | export default SvgDocumentBig 24 | -------------------------------------------------------------------------------- /src/components/icons/DocumentMedium.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | const SvgDocumentMedium = ({ title, titleId, ...props }) => ( 3 | 10 | {title ? {title} : null} 11 | 15 | 21 | 22 | ) 23 | export default SvgDocumentMedium 24 | -------------------------------------------------------------------------------- /src/components/icons/GithubLogo.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | const SvgGithubLogo = ({ title, titleId, ...props }) => ( 3 | 10 | {title ? {title} : null} 11 | 15 | 16 | ) 17 | export default SvgGithubLogo 18 | -------------------------------------------------------------------------------- /src/components/icons/Indexes.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | const SvgIndexes = ({ title, titleId, ...props }) => ( 3 | 10 | {title ? {title} : null} 11 | 17 | 18 | ) 19 | export default SvgIndexes 20 | -------------------------------------------------------------------------------- /src/components/icons/InterrogationMark.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | const SvgInterrogationMark = ({ title, titleId, ...props }) => ( 3 | 10 | {title ? {title} : null} 11 | 15 | 16 | ) 17 | export default SvgInterrogationMark 18 | -------------------------------------------------------------------------------- /src/components/icons/Key.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | const SvgKey = ({ title, titleId, ...props }) => ( 3 | 10 | {title ? {title} : null} 11 | 17 | 23 | 24 | ) 25 | export default SvgKey 26 | -------------------------------------------------------------------------------- /src/components/icons/KeyBig.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | const SvgKeyBig = ({ title, titleId, ...props }) => ( 3 | 10 | {title ? {title} : null} 11 | 17 | 23 | 24 | ) 25 | export default SvgKeyBig 26 | -------------------------------------------------------------------------------- /src/components/icons/LogoText.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | const SvgLogoText = ({ title, titleId, ...props }) => ( 3 | 10 | {title ? {title} : null} 11 | 15 | 16 | ) 17 | export default SvgLogoText 18 | -------------------------------------------------------------------------------- /src/components/icons/MeilisearchLogo.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | const SvgMeilisearchLogo = ({ title, titleId, ...props }) => ( 3 | 10 | {title ? {title} : null} 11 | 15 | 19 | 23 | 24 | 32 | 33 | 34 | 35 | 43 | 44 | 45 | 46 | 54 | 55 | 56 | 57 | 58 | 59 | ) 60 | export default SvgMeilisearchLogo 61 | -------------------------------------------------------------------------------- /src/components/icons/Picture.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | const SvgPicture = ({ title, titleId, ...props }) => ( 3 | 10 | {title ? {title} : null} 11 | 15 | 16 | ) 17 | export default SvgPicture 18 | -------------------------------------------------------------------------------- /src/components/icons/SearchMedium.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | const SvgSearchMedium = ({ title, titleId, ...props }) => ( 3 | 10 | {title ? {title} : null} 11 | 17 | 18 | ) 19 | export default SvgSearchMedium 20 | -------------------------------------------------------------------------------- /src/components/icons/SearchSmall.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | const SvgSearchSmall = ({ title, titleId, ...props }) => ( 3 | 10 | {title ? {title} : null} 11 | 17 | 18 | ) 19 | export default SvgSearchSmall 20 | -------------------------------------------------------------------------------- /src/components/icons/SettingsBig.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | const SvgSettingsBig = ({ title, titleId, ...props }) => ( 3 | 10 | {title ? {title} : null} 11 | 17 | 18 | ) 19 | export default SvgSettingsBig 20 | -------------------------------------------------------------------------------- /src/components/icons/SettingsMedium.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | const SvgSettingsMedium = ({ title, titleId, ...props }) => ( 3 | 10 | {title ? {title} : null} 11 | 17 | 18 | ) 19 | export default SvgSettingsMedium 20 | -------------------------------------------------------------------------------- /src/components/icons/Speed.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | const SvgSpeed = ({ title, titleId, ...props }) => ( 3 | 10 | {title ? {title} : null} 11 | 15 | 19 | 20 | ) 21 | export default SvgSpeed 22 | -------------------------------------------------------------------------------- /src/components/icons/heroicons/AcademicHatIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const AcademicHatIcon = () => ( 4 | 12 | 17 | 18 | ) 19 | 20 | export default AcademicHatIcon 21 | -------------------------------------------------------------------------------- /src/components/icons/heroicons/ArrowPathIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default function ArrowPathIcon() { 4 | return ( 5 | 13 | 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/components/icons/heroicons/ChatBubbleIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const ChatBubbleIcon = () => ( 4 | 12 | 17 | 18 | ) 19 | 20 | export default ChatBubbleIcon 21 | -------------------------------------------------------------------------------- /src/components/icons/heroicons/CheckIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default function CheckIcon() { 4 | return ( 5 | 13 | 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/components/icons/heroicons/CloseIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default function CloseIcon() { 4 | return ( 5 | 13 | 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /src/components/icons/heroicons/LifebuoyIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const LifebuoyIcon = () => ( 4 | 12 | 17 | 18 | ) 19 | 20 | export default LifebuoyIcon 21 | -------------------------------------------------------------------------------- /src/components/icons/heroicons/MenuBarsIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const MenuBarsIcon = () => ( 4 | 12 | 17 | 18 | ) 19 | 20 | export default MenuBarsIcon 21 | -------------------------------------------------------------------------------- /src/components/icons/index.js: -------------------------------------------------------------------------------- 1 | export { default as AlertCircle } from './AlertCircle' 2 | export { default as ArrowDown } from './ArrowDown' 3 | export { default as Cross } from './Cross' 4 | export { default as DiscordLogo } from './DiscordLogo' 5 | export { default as DocumentBig } from './DocumentBig' 6 | export { default as DocumentMedium } from './DocumentMedium' 7 | export { default as GithubLogo } from './GithubLogo' 8 | export { default as Indexes } from './Indexes' 9 | export { default as InterrogationMark } from './InterrogationMark' 10 | export { default as Key } from './Key' 11 | export { default as KeyBig } from './KeyBig' 12 | export { default as LogoText } from './LogoText' 13 | export { default as MeilisearchLogo } from './MeilisearchLogo' 14 | export { default as Picture } from './Picture' 15 | export { default as SearchMedium } from './SearchMedium' 16 | export { default as SearchSmall } from './SearchSmall' 17 | export { default as SettingsBig } from './SettingsBig' 18 | export { default as SettingsMedium } from './SettingsMedium' 19 | export { default as Speed } from './Speed' 20 | -------------------------------------------------------------------------------- /src/components/icons/svg/alert-circle.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/icons/svg/arrow_down.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/components/icons/svg/cross.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/components/icons/svg/discord-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/components/icons/svg/document_big.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/components/icons/svg/document_medium.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/components/icons/svg/github_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/components/icons/svg/indexes.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/components/icons/svg/interrogation_mark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/components/icons/svg/key.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/components/icons/svg/key_big.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/components/icons/svg/logo_text.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/components/icons/svg/meilisearch_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/components/icons/svg/picture.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/components/icons/svg/search_medium.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/components/icons/svg/search_small.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/components/icons/svg/settings_big.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/components/icons/svg/settings_medium.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/components/icons/svg/speed.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/context/ApiKeyContext.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const ApiKeyContext = React.createContext({ 4 | apiKey: '', 5 | setApiKey: () => {}, 6 | }) 7 | 8 | export const ApiKeyProvider = ApiKeyContext.Provider 9 | export const ApiKeyConsumer = ApiKeyContext.Consumer 10 | 11 | export default ApiKeyContext 12 | -------------------------------------------------------------------------------- /src/context/MeilisearchClientContext.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useMemo, createContext, useContext } from 'react' 2 | import { instantMeiliSearch as instantMeilisearch } from '@meilisearch/instant-meilisearch' 3 | import { MeiliSearch as Meilisearch } from 'meilisearch' 4 | import useLocalStorage from 'hooks/useLocalStorage' 5 | import { baseUrl } from 'App' 6 | import clientAgents from 'version/client-agents' 7 | 8 | export const MeilisearchClientContext = createContext({ 9 | meilisearchJsClient: '', 10 | setMeilisearchJsClient: () => {}, 11 | instantMeilisearchClient: '', 12 | setInstantMeilisearchClient: () => {}, 13 | }) 14 | 15 | export const MeiliSearchClientProvider = ({ children }) => { 16 | const [apiKey] = useLocalStorage('apiKey') 17 | 18 | const [meilisearchJsClient, setMeilisearchJsClient] = useState( 19 | new Meilisearch({ 20 | host: baseUrl, 21 | apiKey, 22 | clientAgents, 23 | }) 24 | ) 25 | const [instantMeilisearchClient, setInstantMeilisearchClient] = useState( 26 | instantMeilisearch(baseUrl, apiKey, { 27 | primaryKey: 'id', 28 | clientAgents, 29 | }).searchClient 30 | ) 31 | 32 | const contextValue = useMemo( 33 | () => ({ 34 | meilisearchJsClient, 35 | setMeilisearchJsClient, 36 | instantMeilisearchClient, 37 | setInstantMeilisearchClient, 38 | }), 39 | [ 40 | meilisearchJsClient, 41 | setMeilisearchJsClient, 42 | instantMeilisearchClient, 43 | setInstantMeilisearchClient, 44 | ] 45 | ) 46 | 47 | return ( 48 | 49 | {children} 50 | 51 | ) 52 | } 53 | 54 | export const useMeilisearchClientContext = () => { 55 | const context = useContext(MeilisearchClientContext) 56 | if (context === undefined) { 57 | throw new Error( 58 | 'useMeilisearchClientContext must be used within a MeilisearchClientProvider' 59 | ) 60 | } 61 | return context 62 | } 63 | -------------------------------------------------------------------------------- /src/hooks/useLocalStorage.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { useState } from 'react' 3 | 4 | function useLocalStorage(key, initialValue) { 5 | const [storedValue, setStoredValue] = useState(() => { 6 | try { 7 | const item = window.localStorage.getItem(key) 8 | return item ? JSON.parse(item) : initialValue 9 | } catch (error) { 10 | console.log(error) 11 | return initialValue 12 | } 13 | }) 14 | 15 | const setValue = (value) => { 16 | try { 17 | const valueToStore = 18 | value instanceof Function ? value(storedValue) : value 19 | setStoredValue(valueToStore) 20 | window.localStorage.setItem(key, JSON.stringify(valueToStore)) 21 | } catch (error) { 22 | console.log(error) 23 | } 24 | } 25 | 26 | return [storedValue, setValue] 27 | } 28 | 29 | export default useLocalStorage 30 | -------------------------------------------------------------------------------- /src/hooks/useNewsletter.js: -------------------------------------------------------------------------------- 1 | import version from '../version/version' 2 | 3 | const PORTAL_ID = process.env.REACT_APP_HUBSPOT_PORTAL_ID 4 | const FORM_GUID = process.env.REACT_APP_HUBSPOT_FORM_GUID 5 | 6 | const PAGE_NAME = 7 | process.env.NODE_ENV === 'development' 8 | ? `Mini-dashboard (dev)` 9 | : `Mini-dashboard v${version}` 10 | 11 | function getBody({ email, pageName }) { 12 | return { 13 | fields: [ 14 | { 15 | objectTypeId: '0-1', 16 | name: 'email', 17 | value: email, 18 | }, 19 | ], 20 | context: { 21 | pageName, 22 | }, 23 | legalConsentOptions: { 24 | consent: { 25 | consentToProcess: true, 26 | text: 'I agree to allow Meilisearch to store and process my personal data.', 27 | communications: [ 28 | { 29 | value: true, 30 | subscriptionTypeId: 999, 31 | text: 'I agree to receive marketing communications from Meilisearch.', 32 | }, 33 | ], 34 | }, 35 | }, 36 | } 37 | } 38 | 39 | export default function useNewsletter() { 40 | const endpoint = `https://api.hsforms.com/submissions/v3/integration/submit/${PORTAL_ID}/${FORM_GUID}` 41 | 42 | const subscribe = (email) => 43 | fetch(endpoint, { 44 | method: 'POST', 45 | headers: { 46 | 'Content-Type': 'application/json', 47 | }, 48 | body: JSON.stringify( 49 | getBody({ 50 | email, 51 | pageName: PAGE_NAME, 52 | }) 53 | ), 54 | }) 55 | 56 | return { subscribe } 57 | } 58 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import { ThemeProvider } from 'styled-components' 4 | 5 | import theme from 'theme' 6 | import App from 'App' 7 | import GlobalStyle from 'GlobalStyle' 8 | import { MeiliSearchClientProvider } from 'context/MeilisearchClientContext' 9 | 10 | const container = document.getElementById('root') 11 | const root = createRoot(container) 12 | root.render( 13 | 14 | 15 | 16 | 17 | 18 | 19 | ) 20 | -------------------------------------------------------------------------------- /src/stories/Badge.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import Badge from 'components/Badge' 4 | 5 | export default { 6 | title: 'Components/Badge', 7 | component: Badge, 8 | } 9 | 10 | const Template = (args) => 11 | 12 | export const Default = Template.bind({}) 13 | Default.args = { 14 | children: '4762', 15 | } 16 | -------------------------------------------------------------------------------- /src/stories/Button.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import Button from 'components/Button' 4 | import { DocumentBig, Key } from 'components/icons' 5 | 6 | export default { 7 | title: 'Components/Button', 8 | component: Button, 9 | argTypes: { 10 | variant: { 11 | control: { 12 | type: 'select', 13 | options: ['default', 'filled', 'bordered', 'link', 'grayscale'], 14 | }, 15 | }, 16 | size: { 17 | control: { 18 | type: 'select', 19 | options: ['medium', 'small'], 20 | }, 21 | }, 22 | }, 23 | } 24 | 25 | const Template = (args) => { 26 | const [toggled, setToggled] = React.useState(false) 27 | return ( 28 |