6
.dockerignore
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
.git
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
.gitignore
|
||||
.DS_Store
|
||||
node_modules
|
||||
24
.eslintrc.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2020": true,
|
||||
"node": true
|
||||
},
|
||||
"extends": ["eslint:recommended", "plugin:react/recommended", "prettier", "next"],
|
||||
"parserOptions": {
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
},
|
||||
"ecmaVersion": 11,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": ["react"],
|
||||
"rules": {
|
||||
"react/display-name": "off",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/prop-types": "off"
|
||||
},
|
||||
"globals": {
|
||||
"React": "writable"
|
||||
}
|
||||
}
|
||||
19
.github/stale.yml
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 60
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 7
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- pinned
|
||||
- security
|
||||
- enhancement
|
||||
- bug
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: wontfix
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.
|
||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||
closeComment: false
|
||||
39
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
/prisma/schema.prisma
|
||||
|
||||
# production
|
||||
/build
|
||||
/public/umami.js
|
||||
/public/geo
|
||||
/public/lang
|
||||
/lang-compiled
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.idea
|
||||
*.iml
|
||||
*.log
|
||||
/.vscode/
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
4
.husky/pre-commit
Executable file
|
|
@ -0,0 +1,4 @@
|
|||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
1
.prettierignore
Normal file
|
|
@ -0,0 +1 @@
|
|||
/public/
|
||||
7
.prettierrc.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf",
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
17
.stylelintrc.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"extends": [
|
||||
"stylelint-config-recommended",
|
||||
"stylelint-config-css-modules",
|
||||
"stylelint-config-prettier"
|
||||
],
|
||||
"rules": {
|
||||
"no-descending-specificity": null,
|
||||
"selector-pseudo-class-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignorePseudoClasses": ["global", "horizontal", "vertical"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"ignoreFiles": ["**/*.js"]
|
||||
}
|
||||
43
Dockerfile
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# Build image
|
||||
FROM node:12.22-alpine AS build
|
||||
ARG BASE_PATH
|
||||
ARG DATABASE_TYPE
|
||||
ENV BASE_PATH=$BASE_PATH
|
||||
ENV DATABASE_URL "postgresql://umami:umami@db:5432/umami" \
|
||||
DATABASE_TYPE=$DATABASE_TYPE
|
||||
WORKDIR /build
|
||||
|
||||
RUN yarn config set --home enableTelemetry 0
|
||||
COPY package.json yarn.lock /build/
|
||||
|
||||
# Install only the production dependencies
|
||||
RUN yarn install --production --frozen-lockfile
|
||||
|
||||
# Cache these modules for production
|
||||
RUN cp -R node_modules/ prod_node_modules/
|
||||
|
||||
# Install development dependencies
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
COPY . /build
|
||||
RUN yarn next telemetry disable
|
||||
RUN yarn build
|
||||
|
||||
# Production image
|
||||
FROM node:12.22-alpine AS production
|
||||
WORKDIR /app
|
||||
|
||||
# Copy cached dependencies
|
||||
COPY --from=build /build/prod_node_modules ./node_modules
|
||||
|
||||
# Copy generated Prisma client
|
||||
COPY --from=build /build/node_modules/.prisma/ ./node_modules/.prisma/
|
||||
|
||||
COPY --from=build /build/yarn.lock /build/package.json ./
|
||||
COPY --from=build /build/.next ./.next
|
||||
COPY --from=build /build/public ./public
|
||||
|
||||
USER node
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["yarn", "start"]
|
||||
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2020 Mike Cao <mike@mikecao.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
1
Procfile
Normal file
|
|
@ -0,0 +1 @@
|
|||
web: npm run start-env
|
||||
107
README.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
# umami
|
||||
|
||||
Umami is a simple, fast, website analytics alternative to Google Analytics.
|
||||
|
||||
## Getting started
|
||||
|
||||
A detailed getting started guide can be found at [https://umami.is/docs/](https://umami.is/docs/)
|
||||
|
||||
## Installing from source
|
||||
|
||||
### Requirements
|
||||
|
||||
- A server with Node.js 12 or newer
|
||||
- A database (MySQL or Postgresql)
|
||||
|
||||
### Get the source code and install packages
|
||||
|
||||
```
|
||||
git clone https://github.com/mikecao/umami.git
|
||||
cd umami
|
||||
npm install
|
||||
```
|
||||
|
||||
### Create database tables
|
||||
|
||||
Umami supports [MySQL](https://www.mysql.com/) and [Postgresql](https://www.postgresql.org/).
|
||||
Create a database for your Umami installation and install the tables with the included scripts.
|
||||
|
||||
For MySQL:
|
||||
|
||||
```
|
||||
mysql -u username -p databasename < sql/schema.mysql.sql
|
||||
```
|
||||
|
||||
For Postgresql:
|
||||
|
||||
```
|
||||
psql -h hostname -U username -d databasename -f sql/schema.postgresql.sql
|
||||
```
|
||||
|
||||
This will also create a login account with username **admin** and password **umami**.
|
||||
|
||||
### Configure umami
|
||||
|
||||
Create an `.env` file with the following
|
||||
|
||||
```
|
||||
DATABASE_URL=(connection url)
|
||||
HASH_SALT=(any random string)
|
||||
```
|
||||
|
||||
The connection url is in the following format:
|
||||
```
|
||||
postgresql://username:mypassword@localhost:5432/mydb
|
||||
|
||||
mysql://username:mypassword@localhost:3306/mydb
|
||||
```
|
||||
|
||||
The `HASH_SALT` is used to generate unique values for your installation.
|
||||
|
||||
### Build the application
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Start the application
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
By default this will launch the application on `http://localhost:3000`. You will need to either
|
||||
[proxy](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) requests from your web server
|
||||
or change the [port](https://nextjs.org/docs/api-reference/cli#production) to serve the application directly.
|
||||
|
||||
## Installing with Docker
|
||||
|
||||
To build the umami container and start up a Postgres database, run:
|
||||
|
||||
```bash
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
Alternatively, to pull just the Umami Docker image with PostgreSQL support:
|
||||
```bash
|
||||
docker pull ghcr.io/mikecao/umami:postgresql-latest
|
||||
```
|
||||
|
||||
Or with MySQL support:
|
||||
```bash
|
||||
docker pull ghcr.io/mikecao/umami:mysql-latest
|
||||
```
|
||||
|
||||
## Getting updates
|
||||
|
||||
To get the latest features, simply do a pull, install any new dependencies, and rebuild:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
26
app.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "Umami",
|
||||
"description": "Umami is a simple, fast, website analytics alternative to Google Analytics.",
|
||||
"keywords": [
|
||||
"analytics",
|
||||
"charts",
|
||||
"statistics",
|
||||
"web-analytics"
|
||||
],
|
||||
"website": "https://umami.is",
|
||||
"repository": "https://github.com/mikecao/umami",
|
||||
"addons": [
|
||||
"heroku-postgresql"
|
||||
],
|
||||
"env": {
|
||||
"HASH_SALT": {
|
||||
"description": "Used to generate unique values for your installation",
|
||||
"required": true,
|
||||
"generator": "secret"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"postdeploy": "psql $DATABASE_URL -f sql/schema.postgresql.sql"
|
||||
},
|
||||
"success_url": "/"
|
||||
}
|
||||
1
assets/arrow-right.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M216.464 36.465l-7.071 7.07c-4.686 4.686-4.686 12.284 0 16.971L387.887 239H12c-6.627 0-12 5.373-12 12v10c0 6.627 5.373 12 12 12h375.887L209.393 451.494c-4.686 4.686-4.686 12.284 0 16.971l7.071 7.07c4.686 4.686 12.284 4.686 16.97 0l211.051-211.05c4.686-4.686 4.686-12.284 0-16.971L233.434 36.465c-4.686-4.687-12.284-4.687-16.97 0z"/></svg>
|
||||
|
After Width: | Height: | Size: 409 B |
1
assets/arrow-up-right-from-square.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!-- Font Awesome Pro 6.0.0-alpha2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M392 320C378.75 320 368 330.75 368 344V456C368 460.406 364.406 464 360 464H56C51.594 464 48 460.406 48 456V152C48 147.594 51.594 144 56 144H168C181.25 144 192 133.25 192 120S181.25 96 168 96H56C25.125 96 0 121.125 0 152V456C0 486.875 25.125 512 56 512H360C390.875 512 416 486.875 416 456V344C416 330.75 405.25 320 392 320ZM488 0H320C306.75 0 296 10.75 296 24S306.75 48 320 48H430.062L183.031 295.031C173.656 304.406 173.656 319.594 183.031 328.969C187.719 333.656 193.844 336 200 336S212.281 333.656 216.969 328.969L464 81.938V192C464 205.25 474.75 216 488 216S512 205.25 512 192V24C512 10.75 501.25 0 488 0Z"/></svg>
|
||||
|
After Width: | Height: | Size: 831 B |
1
assets/bars.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!-- Font Awesome Pro 6.0.0-alpha1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M424 392H24C11 392 0 403 0 416V416C0 429 11 440 24 440H424C437 440 448 429 448 416V416C448 403 437 392 424 392ZM424 72H24C11 72 0 83 0 96V96C0 109 11 120 24 120H424C437 120 448 109 448 96V96C448 83 437 72 424 72ZM424 232H24C11 232 0 243 0 256V256C0 269 11 280 24 280H424C437 280 448 269 448 256V256C448 243 437 232 424 232Z"/></svg>
|
||||
|
After Width: | Height: | Size: 546 B |
1
assets/bolt.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path d="M296 160H180.6l42.6-129.8C227.2 15 215.7 0 200 0H56C44 0 33.8 8.9 32.2 20.8l-32 240C-1.7 275.2 9.5 288 24 288h118.7L96.6 482.5c-3.6 15.2 8 29.5 23.3 29.5 8.4 0 16.4-4.4 20.8-12l176-304c9.3-15.9-2.2-36-20.7-36z"/></svg>
|
||||
|
After Width: | Height: | Size: 289 B |
1
assets/calendar-alt.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M400 64h-48V12c0-6.6-5.4-12-12-12h-8c-6.6 0-12 5.4-12 12v52H128V12c0-6.6-5.4-12-12-12h-8c-6.6 0-12 5.4-12 12v52H48C21.5 64 0 85.5 0 112v352c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48zM48 96h352c8.8 0 16 7.2 16 16v48H32v-48c0-8.8 7.2-16 16-16zm352 384H48c-8.8 0-16-7.2-16-16V192h384v272c0 8.8-7.2 16-16 16zM148 320h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12zm96 0h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12zm96 0h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12zm-96 96h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12zm-96 0h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12zm192 0h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12z"/></svg>
|
||||
|
After Width: | Height: | Size: 1,002 B |
1
assets/chart-bar.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!-- Font Awesome Pro 5.15.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M396.8 352h22.4c6.4 0 12.8-6.4 12.8-12.8V108.8c0-6.4-6.4-12.8-12.8-12.8h-22.4c-6.4 0-12.8 6.4-12.8 12.8v230.4c0 6.4 6.4 12.8 12.8 12.8zm-192 0h22.4c6.4 0 12.8-6.4 12.8-12.8V140.8c0-6.4-6.4-12.8-12.8-12.8h-22.4c-6.4 0-12.8 6.4-12.8 12.8v198.4c0 6.4 6.4 12.8 12.8 12.8zm96 0h22.4c6.4 0 12.8-6.4 12.8-12.8V204.8c0-6.4-6.4-12.8-12.8-12.8h-22.4c-6.4 0-12.8 6.4-12.8 12.8v134.4c0 6.4 6.4 12.8 12.8 12.8zM496 400H48V80c0-8.84-7.16-16-16-16H16C7.16 64 0 71.16 0 80v336c0 17.67 14.33 32 32 32h464c8.84 0 16-7.16 16-16v-16c0-8.84-7.16-16-16-16zm-387.2-48h22.4c6.4 0 12.8-6.4 12.8-12.8v-70.4c0-6.4-6.4-12.8-12.8-12.8h-22.4c-6.4 0-12.8 6.4-12.8 12.8v70.4c0 6.4 6.4 12.8 12.8 12.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 885 B |
1
assets/check.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M435.848 83.466L172.804 346.51l-96.652-96.652c-4.686-4.686-12.284-4.686-16.971 0l-28.284 28.284c-4.686 4.686-4.686 12.284 0 16.971l133.421 133.421c4.686 4.686 12.284 4.686 16.971 0l299.813-299.813c4.686-4.686 4.686-12.284 0-16.971l-28.284-28.284c-4.686-4.686-12.284-4.686-16.97 0z"/></svg>
|
||||
|
After Width: | Height: | Size: 360 B |
1
assets/chevron-down.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M441.9 167.3l-19.8-19.8c-4.7-4.7-12.3-4.7-17 0L224 328.2 42.9 147.5c-4.7-4.7-12.3-4.7-17 0L6.1 167.3c-4.7 4.7-4.7 12.3 0 17l209.4 209.4c4.7 4.7 12.3 4.7 17 0l209.4-209.4c4.7-4.7 4.7-12.3 0-17z"/></svg>
|
||||
|
After Width: | Height: | Size: 272 B |
1
assets/code.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M234.8 511.7L196 500.4c-4.2-1.2-6.7-5.7-5.5-9.9L331.3 5.8c1.2-4.2 5.7-6.7 9.9-5.5L380 11.6c4.2 1.2 6.7 5.7 5.5 9.9L244.7 506.2c-1.2 4.3-5.6 6.7-9.9 5.5zm-83.2-121.1l27.2-29c3.1-3.3 2.8-8.5-.5-11.5L72.2 256l106.1-94.1c3.4-3 3.6-8.2.5-11.5l-27.2-29c-3-3.2-8.1-3.4-11.3-.4L2.5 250.2c-3.4 3.2-3.4 8.5 0 11.7L140.3 391c3.2 3 8.2 2.8 11.3-.4zm284.1.4l137.7-129.1c3.4-3.2 3.4-8.5 0-11.7L435.7 121c-3.2-3-8.3-2.9-11.3.4l-27.2 29c-3.1 3.3-2.8 8.5.5 11.5L503.8 256l-106.1 94.1c-3.4 3-3.6 8.2-.5 11.5l27.2 29c3.1 3.2 8.1 3.4 11.3.4z"/></svg>
|
||||
|
After Width: | Height: | Size: 601 B |
1
assets/ellipsis-h.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M304 256c0 26.5-21.5 48-48 48s-48-21.5-48-48 21.5-48 48-48 48 21.5 48 48zm120-48c-26.5 0-48 21.5-48 48s21.5 48 48 48 48-21.5 48-48-21.5-48-48-48zm-336 0c-26.5 0-48 21.5-48 48s21.5 48 48 48 48-21.5 48-48-21.5-48-48-48z"/></svg>
|
||||
|
After Width: | Height: | Size: 297 B |
1
assets/exclamation-triangle.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M270.2 160h35.5c3.4 0 6.1 2.8 6 6.2l-7.5 196c-.1 3.2-2.8 5.8-6 5.8h-20.5c-3.2 0-5.9-2.5-6-5.8l-7.5-196c-.1-3.4 2.6-6.2 6-6.2zM288 388c-15.5 0-28 12.5-28 28s12.5 28 28 28 28-12.5 28-28-12.5-28-28-28zm281.5 52L329.6 24c-18.4-32-64.7-32-83.2 0L6.5 440c-18.4 31.9 4.6 72 41.6 72H528c36.8 0 60-40 41.5-72zM528 480H48c-12.3 0-20-13.3-13.9-24l240-416c6.1-10.6 21.6-10.7 27.7 0l240 416c6.2 10.6-1.5 24-13.8 24z"/></svg>
|
||||
|
After Width: | Height: | Size: 482 B |
1
assets/external-link.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M497.6,0,334.4.17A14.4,14.4,0,0,0,320,14.57V47.88a14.4,14.4,0,0,0,14.69,14.4l73.63-2.72,2.06,2.06L131.52,340.49a12,12,0,0,0,0,17l23,23a12,12,0,0,0,17,0L450.38,101.62l2.06,2.06-2.72,73.63A14.4,14.4,0,0,0,464.12,192h33.31a14.4,14.4,0,0,0,14.4-14.4L512,14.4A14.4,14.4,0,0,0,497.6,0ZM432,288H416a16,16,0,0,0-16,16V458a6,6,0,0,1-6,6H54a6,6,0,0,1-6-6V118a6,6,0,0,1,6-6H208a16,16,0,0,0,16-16V80a16,16,0,0,0-16-16H48A48,48,0,0,0,0,112V464a48,48,0,0,0,48,48H400a48,48,0,0,0,48-48V304A16,16,0,0,0,432,288Z"/></svg>
|
||||
|
After Width: | Height: | Size: 575 B |
1
assets/eye.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M288 144a110.94 110.94 0 0 0-31.24 5 55.4 55.4 0 0 1 7.24 27 56 56 0 0 1-56 56 55.4 55.4 0 0 1-27-7.24A111.71 111.71 0 1 0 288 144zm284.52 97.4C518.29 135.59 410.93 64 288 64S57.68 135.64 3.48 241.41a32.35 32.35 0 0 0 0 29.19C57.71 376.41 165.07 448 288 448s230.32-71.64 284.52-177.41a32.35 32.35 0 0 0 0-29.19zM288 400c-98.65 0-189.09-55-237.93-144C98.91 167 189.34 112 288 112s189.09 55 237.93 144C477.1 345 386.66 400 288 400z"/></svg>
|
||||
|
After Width: | Height: | Size: 509 B |
1
assets/globe.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path d="M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm179.3 160h-67.2c-6.7-36.5-17.5-68.8-31.2-94.7 42.9 19 77.7 52.7 98.4 94.7zM248 56c18.6 0 48.6 41.2 63.2 112H184.8C199.4 97.2 229.4 56 248 56zM48 256c0-13.7 1.4-27.1 4-40h77.7c-1 13.1-1.7 26.3-1.7 40s.7 26.9 1.7 40H52c-2.6-12.9-4-26.3-4-40zm20.7 88h67.2c6.7 36.5 17.5 68.8 31.2 94.7-42.9-19-77.7-52.7-98.4-94.7zm67.2-176H68.7c20.7-42 55.5-75.7 98.4-94.7-13.7 25.9-24.5 58.2-31.2 94.7zM248 456c-18.6 0-48.6-41.2-63.2-112h126.5c-14.7 70.8-44.7 112-63.3 112zm70.1-160H177.9c-1.1-12.8-1.9-26-1.9-40s.8-27.2 1.9-40h140.3c1.1 12.8 1.9 26 1.9 40s-.9 27.2-2 40zm10.8 142.7c13.7-25.9 24.4-58.2 31.2-94.7h67.2c-20.7 42-55.5 75.7-98.4 94.7zM366.3 296c1-13.1 1.7-26.3 1.7-40s-.7-26.9-1.7-40H444c2.6 12.9 4 26.3 4 40s-1.4 27.1-4 40h-77.7z"/></svg>
|
||||
|
After Width: | Height: | Size: 874 B |
1
assets/link.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M314.222 197.78c51.091 51.091 54.377 132.287 9.75 187.16-6.242 7.73-2.784 3.865-84.94 86.02-54.696 54.696-143.266 54.745-197.99 0-54.711-54.69-54.734-143.255 0-197.99 32.773-32.773 51.835-51.899 63.409-63.457 7.463-7.452 20.331-2.354 20.486 8.192a173.31 173.31 0 0 0 4.746 37.828c.966 4.029-.272 8.269-3.202 11.198L80.632 312.57c-32.755 32.775-32.887 85.892 0 118.8 32.775 32.755 85.892 32.887 118.8 0l75.19-75.2c32.718-32.725 32.777-86.013 0-118.79a83.722 83.722 0 0 0-22.814-16.229c-4.623-2.233-7.182-7.25-6.561-12.346 1.356-11.122 6.296-21.885 14.815-30.405l4.375-4.375c3.625-3.626 9.177-4.594 13.76-2.294 12.999 6.524 25.187 15.211 36.025 26.049zM470.958 41.04c-54.724-54.745-143.294-54.696-197.99 0-82.156 82.156-78.698 78.29-84.94 86.02-44.627 54.873-41.341 136.069 9.75 187.16 10.838 10.838 23.026 19.525 36.025 26.049 4.582 2.3 10.134 1.331 13.76-2.294l4.375-4.375c8.52-8.519 13.459-19.283 14.815-30.405.621-5.096-1.938-10.113-6.561-12.346a83.706 83.706 0 0 1-22.814-16.229c-32.777-32.777-32.718-86.065 0-118.79l75.19-75.2c32.908-32.887 86.025-32.755 118.8 0 32.887 32.908 32.755 86.025 0 118.8l-45.848 45.84c-2.93 2.929-4.168 7.169-3.202 11.198a173.31 173.31 0 0 1 4.746 37.828c.155 10.546 13.023 15.644 20.486 8.192 11.574-11.558 30.636-30.684 63.409-63.457 54.733-54.735 54.71-143.3-.001-197.991z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
1
assets/list-ul.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M48 368a48 48 0 1 0 48 48 48 48 0 0 0-48-48zm0-160a48 48 0 1 0 48 48 48 48 0 0 0-48-48zm0-160a48 48 0 1 0 48 48 48 48 0 0 0-48-48zm448 24H176a16 16 0 0 0-16 16v16a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16V88a16 16 0 0 0-16-16zm0 160H176a16 16 0 0 0-16 16v16a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16v-16a16 16 0 0 0-16-16zm0 160H176a16 16 0 0 0-16 16v16a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16v-16a16 16 0 0 0-16-16z"/></svg>
|
||||
|
After Width: | Height: | Size: 492 B |
2
assets/logo.svg
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 428 389.11">
|
||||
<circle cx="214.15" cy="181" r="171" fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="20"/><path d="M413,134.11H15.29a15,15,0,0,0-15,15v15.3C.12,168,0,171.52,0,175.11c0,118.19,95.81,214,214,214,116.4,0,211.1-92.94,213.93-208.67,0-.44.07-.88.07-1.33v-30A15,15,0,0,0,413,134.11Z"/></svg>
|
||||
|
After Width: | Height: | Size: 377 B |
1
assets/moon.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1399.98 1400"><path d="M562.44,837.55C335.89,611,288.08,273.54,418.71,0A734.31,734.31,0,0,0,215.54,143.73c-287.39,287.39-287.39,753.33,0,1040.72s753.33,287.4,1040.74,0A733.8,733.8,0,0,0,1400,981.29C1126.45,1111.92,789,1064.09,562.44,837.55Z"/></svg>
|
||||
|
After Width: | Height: | Size: 302 B |
1
assets/pen.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M493.26 56.26l-37.51-37.51C443.25 6.25 426.87 0 410.49 0s-32.76 6.25-45.25 18.74l-74.49 74.49L256 127.98 12.85 371.12.15 485.34C-1.45 499.72 9.88 512 23.95 512c.89 0 1.79-.05 2.69-.15l114.14-12.61L384.02 256l34.74-34.74 74.49-74.49c25-25 25-65.52.01-90.51zM118.75 453.39l-67.58 7.46 7.53-67.69 231.24-231.24 31.02-31.02 60.14 60.14-31.02 31.02-231.33 231.33zm340.56-340.57l-44.28 44.28-60.13-60.14 44.28-44.28c4.08-4.08 8.84-4.69 11.31-4.69s7.24.61 11.31 4.69l37.51 37.51c6.24 6.25 6.24 16.4 0 22.63z"/></svg>
|
||||
|
After Width: | Height: | Size: 580 B |
1
assets/plus.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="M368 224H224V80c0-8.84-7.16-16-16-16h-32c-8.84 0-16 7.16-16 16v144H16c-8.84 0-16 7.16-16 16v32c0 8.84 7.16 16 16 16h144v144c0 8.84 7.16 16 16 16h32c8.84 0 16-7.16 16-16V288h144c8.84 0 16-7.16 16-16v-32c0-8.84-7.16-16-16-16z"/></svg>
|
||||
|
After Width: | Height: | Size: 303 B |
1
assets/redo.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M500 8h-27.711c-6.739 0-12.157 5.548-11.997 12.286l2.347 98.568C418.075 51.834 341.788 7.73 255.207 8.001 118.82 8.428 7.787 120.009 8 256.396 8.214 393.181 119.165 504 256 504c63.926 0 122.202-24.187 166.178-63.908 5.113-4.618 5.354-12.561.482-17.433l-19.738-19.738c-4.498-4.498-11.753-4.785-16.501-.552C351.787 433.246 306.105 452 256 452c-108.322 0-196-87.662-196-196 0-108.322 87.662-196 196-196 79.545 0 147.941 47.282 178.675 115.302l-126.389-3.009c-6.737-.16-12.286 5.257-12.286 11.997V212c0 6.627 5.373 12 12 12h192c6.627 0 12-5.373 12-12V20c0-6.627-5.373-12-12-12z"/></svg>
|
||||
|
After Width: | Height: | Size: 653 B |
1
assets/sun.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400"><path d="M367.43,422.13a54.44,54.44,0,0,1-38.66-16L205,282.35A54.69,54.69,0,0,1,282.37,205L406.11,328.79a54.68,54.68,0,0,1-38.68,93.34Z"/><path d="M1156.3,1211a54.51,54.51,0,0,1-38.67-16L993.89,1071.21a54.68,54.68,0,1,1,77.34-77.33L1195,1117.65A54.7,54.7,0,0,1,1156.3,1211Z"/><path d="M243.7,1211A54.7,54.7,0,0,1,205,1117.65L328.74,993.89a54.69,54.69,0,0,1,77.36,77.32L282.37,1195A54.51,54.51,0,0,1,243.7,1211Z"/><path d="M1032.57,422.13a54.68,54.68,0,0,1-38.68-93.34L1117.61,205A54.69,54.69,0,0,1,1195,282.35L1071.23,406.11A54.44,54.44,0,0,1,1032.57,422.13Z"/><path d="M229.69,754.69h-175a54.69,54.69,0,0,1,0-109.38h175a54.69,54.69,0,0,1,0,109.38Z"/><path d="M1345.31,754.69h-175a54.69,54.69,0,0,1,0-109.38h175a54.69,54.69,0,0,1,0,109.38Z"/><path d="M700,1400a54.68,54.68,0,0,1-54.69-54.69v-175a54.69,54.69,0,0,1,109.38,0v175A54.68,54.68,0,0,1,700,1400Z"/><path d="M700,284.38a54.7,54.7,0,0,1-54.69-54.69v-175a54.69,54.69,0,0,1,109.38,0v175A54.7,54.7,0,0,1,700,284.38Z"/><circle cx="700" cy="700" r="306.25"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
1
assets/times.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path d="M207.6 256l107.72-107.72c6.23-6.23 6.23-16.34 0-22.58l-25.03-25.03c-6.23-6.23-16.34-6.23-22.58 0L160 208.4 52.28 100.68c-6.23-6.23-16.34-6.23-22.58 0L4.68 125.7c-6.23 6.23-6.23 16.34 0 22.58L112.4 256 4.68 363.72c-6.23 6.23-6.23 16.34 0 22.58l25.03 25.03c6.23 6.23 16.34 6.23 22.58 0L160 303.6l107.72 107.72c6.23 6.23 16.34 6.23 22.58 0l25.03-25.03c6.23-6.23 6.23-16.34 0-22.58L207.6 256z"/></svg>
|
||||
|
After Width: | Height: | Size: 468 B |
1
assets/trash.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M432 80h-82.4l-34-56.7A48 48 0 0 0 274.4 0H173.6a48 48 0 0 0-41.2 23.3L98.4 80H16A16 16 0 0 0 0 96v16a16 16 0 0 0 16 16h16l21.2 339a48 48 0 0 0 47.9 45h245.8a48 48 0 0 0 47.9-45L416 128h16a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16zM173.6 48h100.8l19.2 32H154.4zm173.3 416H101.11l-21-336h287.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 370 B |
1
assets/user.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1652 1652"><title>Asset 1</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path d="M1587.07,504.47A828.56,828.56,0,1,0,1652,826,823.13,823.13,0,0,0,1587.07,504.47ZM826,1577a747.29,747.29,0,0,1-464.48-161.26,39.94,39.94,0,0,0,2.8-11.35,458.82,458.82,0,0,1,34.29-135.74,464.15,464.15,0,0,1,854.78,0,458.82,458.82,0,0,1,34.29,135.74,39.94,39.94,0,0,0,2.8,11.35A747.29,747.29,0,0,1,826,1577ZM719.81,866.57A274,274,0,1,1,826,888,272.1,272.1,0,0,1,719.81,866.57Zm641.28,485.87c-36.11-201.1-182.78-363.82-374.86-423,114.28-58.37,192.53-177.22,192.53-314.35,0-194.83-157.94-352.76-352.76-352.76S473.24,420.29,473.24,615.12c0,137.13,78.25,256,192.53,314.35-192.08,59.15-338.75,221.87-374.86,423C157.46,1216.81,75,1030.86,75,826,75,411.9,411.9,75,826,75s751,336.9,751,751C1577,1030.86,1494.54,1216.81,1361.09,1352.44Z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 910 B |
1
assets/visitor.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M224 256c70.7 0 128-57.3 128-128S294.7 0 224 0 96 57.3 96 128s57.3 128 128 128zm89.6 32h-16.7c-22.2 10.2-46.9 16-72.9 16s-50.6-5.8-72.9-16h-16.7C60.2 288 0 348.2 0 422.4V464c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48v-41.6c0-74.2-60.2-134.4-134.4-134.4z"/></svg>
|
||||
|
After Width: | Height: | Size: 336 B |
1
assets/xmark.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!-- Font Awesome Pro 6.0.0-alpha1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M345 375C354 384 354 400 345 409S320 418 311 409L192 290L73 409C64 418 48 418 39 409S30 384 39 375L158 256L39 137C30 128 30 112 39 103S64 94 73 103L192 222L311 103C320 94 336 94 345 103S354 128 345 137L226 256L345 375Z"/></svg>
|
||||
|
After Width: | Height: | Size: 441 B |
62
components/common/Button.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ReactTooltip from 'react-tooltip';
|
||||
import classNames from 'classnames';
|
||||
import Icon from './Icon';
|
||||
import styles from './Button.module.css';
|
||||
|
||||
function Button({
|
||||
type = 'button',
|
||||
icon,
|
||||
size,
|
||||
variant,
|
||||
children,
|
||||
className,
|
||||
tooltip,
|
||||
tooltipId,
|
||||
disabled,
|
||||
iconRight,
|
||||
onClick = () => {},
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
data-tip={tooltip}
|
||||
data-effect="solid"
|
||||
data-for={tooltipId}
|
||||
type={type}
|
||||
className={classNames(styles.button, className, {
|
||||
[styles.large]: size === 'large',
|
||||
[styles.small]: size === 'small',
|
||||
[styles.xsmall]: size === 'xsmall',
|
||||
[styles.action]: variant === 'action',
|
||||
[styles.danger]: variant === 'danger',
|
||||
[styles.light]: variant === 'light',
|
||||
[styles.iconRight]: iconRight,
|
||||
})}
|
||||
disabled={disabled}
|
||||
onClick={!disabled ? onClick : null}
|
||||
{...props}
|
||||
>
|
||||
{icon && <Icon className={styles.icon} icon={icon} size={size} />}
|
||||
{children && <div className={styles.label}>{children}</div>}
|
||||
{tooltip && <ReactTooltip id={tooltipId}>{tooltip}</ReactTooltip>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
Button.propTypes = {
|
||||
type: PropTypes.oneOf(['button', 'submit', 'reset']),
|
||||
icon: PropTypes.node,
|
||||
size: PropTypes.oneOf(['xlarge', 'large', 'medium', 'small', 'xsmall']),
|
||||
variant: PropTypes.oneOf(['action', 'danger', 'light']),
|
||||
children: PropTypes.node,
|
||||
className: PropTypes.string,
|
||||
tooltip: PropTypes.node,
|
||||
tooltipId: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
iconRight: PropTypes.bool,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
export default Button;
|
||||
102
components/common/Button.module.css
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
.button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: var(--font-size-normal);
|
||||
color: var(--gray900);
|
||||
background: var(--gray100);
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
border: 0;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: var(--gray200);
|
||||
}
|
||||
|
||||
.button:active {
|
||||
color: var(--gray900);
|
||||
}
|
||||
|
||||
.label {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.large {
|
||||
font-size: var(--font-size-large);
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.xsmall {
|
||||
font-size: var(--font-size-xsmall);
|
||||
}
|
||||
|
||||
.action,
|
||||
.action:active {
|
||||
color: var(--gray50);
|
||||
background: var(--gray900);
|
||||
}
|
||||
|
||||
.action:hover {
|
||||
background: var(--gray800);
|
||||
}
|
||||
|
||||
.danger,
|
||||
.danger:active {
|
||||
color: var(--gray50);
|
||||
background: var(--red500);
|
||||
}
|
||||
|
||||
.danger:hover {
|
||||
background: var(--red400);
|
||||
}
|
||||
|
||||
.light,
|
||||
.light:active {
|
||||
color: var(--gray900);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.light:hover {
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
.button .icon + * {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.button.iconRight .icon {
|
||||
order: 1;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.button.iconRight .icon + * {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: default;
|
||||
color: var(--gray500);
|
||||
background: var(--gray75);
|
||||
}
|
||||
|
||||
.button:disabled:active {
|
||||
color: var(--gray500);
|
||||
}
|
||||
|
||||
.button:disabled:hover {
|
||||
background: var(--gray75);
|
||||
}
|
||||
|
||||
.button.light:disabled {
|
||||
background: var(--gray50);
|
||||
}
|
||||
42
components/common/ButtonGroup.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import Button from './Button';
|
||||
import styles from './ButtonGroup.module.css';
|
||||
|
||||
function ButtonGroup({ items = [], selectedItem, className, size, icon, onClick = () => {} }) {
|
||||
return (
|
||||
<div className={classNames(styles.group, className)}>
|
||||
{items.map(item => {
|
||||
const { label, value } = item;
|
||||
return (
|
||||
<Button
|
||||
key={value}
|
||||
className={classNames(styles.button, { [styles.selected]: selectedItem === value })}
|
||||
size={size}
|
||||
icon={icon}
|
||||
onClick={() => onClick(value)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ButtonGroup.propTypes = {
|
||||
items: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
label: PropTypes.node,
|
||||
value: PropTypes.any.isRequired,
|
||||
}),
|
||||
),
|
||||
selectedItem: PropTypes.any,
|
||||
className: PropTypes.string,
|
||||
size: PropTypes.oneOf(['xlarge', 'large', 'medium', 'small', 'xsmall']),
|
||||
icon: PropTypes.node,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
export default ButtonGroup;
|
||||
31
components/common/ButtonGroup.module.css
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
.group {
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--gray500);
|
||||
}
|
||||
|
||||
.group .button {
|
||||
border-radius: 0;
|
||||
color: var(--gray800);
|
||||
background: var(--gray50);
|
||||
border-left: 1px solid var(--gray500);
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.group .button:first-child {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.group .button:hover {
|
||||
background: var(--gray100);
|
||||
}
|
||||
|
||||
.group .button + .button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.group .button.selected {
|
||||
color: var(--gray900);
|
||||
font-weight: 600;
|
||||
}
|
||||
268
components/common/Calendar.js
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
import React, { useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import {
|
||||
startOfWeek,
|
||||
startOfMonth,
|
||||
startOfYear,
|
||||
endOfMonth,
|
||||
addDays,
|
||||
subDays,
|
||||
addYears,
|
||||
subYears,
|
||||
addMonths,
|
||||
setMonth,
|
||||
setYear,
|
||||
isSameDay,
|
||||
isBefore,
|
||||
isAfter,
|
||||
} from 'date-fns';
|
||||
import Button from './Button';
|
||||
import useLocale from 'hooks/useLocale';
|
||||
import { dateFormat } from 'lib/date';
|
||||
import { chunk } from 'lib/array';
|
||||
import { getDateLocale } from 'lib/lang';
|
||||
import Chevron from 'assets/chevron-down.svg';
|
||||
import Cross from 'assets/times.svg';
|
||||
import styles from './Calendar.module.css';
|
||||
import Icon from './Icon';
|
||||
|
||||
export default function Calendar({ date, minDate, maxDate, onChange }) {
|
||||
const { locale } = useLocale();
|
||||
const [selectMonth, setSelectMonth] = useState(false);
|
||||
const [selectYear, setSelectYear] = useState(false);
|
||||
|
||||
const month = dateFormat(date, 'MMMM', locale);
|
||||
const year = date.getFullYear();
|
||||
|
||||
function toggleMonthSelect() {
|
||||
setSelectYear(false);
|
||||
setSelectMonth(state => !state);
|
||||
}
|
||||
|
||||
function toggleYearSelect() {
|
||||
setSelectMonth(false);
|
||||
setSelectYear(state => !state);
|
||||
}
|
||||
|
||||
function handleChange(value) {
|
||||
setSelectMonth(false);
|
||||
setSelectYear(false);
|
||||
if (value) {
|
||||
onChange(value);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.calendar}>
|
||||
<div className={styles.header}>
|
||||
<div>{date.getDate()}</div>
|
||||
<div
|
||||
className={classNames(styles.selector, { [styles.open]: selectMonth })}
|
||||
onClick={toggleMonthSelect}
|
||||
>
|
||||
{month}
|
||||
<Icon className={styles.icon} icon={selectMonth ? <Cross /> : <Chevron />} size="small" />
|
||||
</div>
|
||||
<div
|
||||
className={classNames(styles.selector, { [styles.open]: selectYear })}
|
||||
onClick={toggleYearSelect}
|
||||
>
|
||||
{year}
|
||||
<Icon className={styles.icon} icon={selectYear ? <Cross /> : <Chevron />} size="small" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
{!selectMonth && !selectYear && (
|
||||
<DaySelector
|
||||
date={date}
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
locale={locale}
|
||||
onSelect={handleChange}
|
||||
/>
|
||||
)}
|
||||
{selectMonth && (
|
||||
<MonthSelector
|
||||
date={date}
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
locale={locale}
|
||||
onSelect={handleChange}
|
||||
onClose={toggleMonthSelect}
|
||||
/>
|
||||
)}
|
||||
{selectYear && (
|
||||
<YearSelector
|
||||
date={date}
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
onSelect={handleChange}
|
||||
onClose={toggleYearSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DaySelector = ({ date, minDate, maxDate, locale, onSelect }) => {
|
||||
const startWeek = startOfWeek(date, { locale: getDateLocale(locale) });
|
||||
const startMonth = startOfMonth(date, { locale: getDateLocale(locale) });
|
||||
const startDay = subDays(startMonth, startMonth.getDay());
|
||||
const month = date.getMonth();
|
||||
const year = date.getFullYear();
|
||||
|
||||
const daysOfWeek = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
daysOfWeek.push(addDays(startWeek, i));
|
||||
}
|
||||
|
||||
const days = [];
|
||||
for (let i = 0; i < 35; i++) {
|
||||
days.push(addDays(startDay, i));
|
||||
}
|
||||
|
||||
return (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{daysOfWeek.map((day, i) => (
|
||||
<th key={i} className={locale}>
|
||||
{dateFormat(day, 'EEE', locale)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{chunk(days, 7).map((week, i) => (
|
||||
<tr key={i}>
|
||||
{week.map((day, j) => {
|
||||
const disabled = isBefore(day, minDate) || isAfter(day, maxDate);
|
||||
return (
|
||||
<td
|
||||
key={j}
|
||||
className={classNames({
|
||||
[styles.selected]: isSameDay(date, day),
|
||||
[styles.faded]: day.getMonth() !== month || day.getFullYear() !== year,
|
||||
[styles.disabled]: disabled,
|
||||
})}
|
||||
onClick={!disabled ? () => onSelect(day) : null}
|
||||
>
|
||||
{day.getDate()}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
const MonthSelector = ({ date, minDate, maxDate, locale, onSelect }) => {
|
||||
const start = startOfYear(date);
|
||||
const months = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
months.push(addMonths(start, i));
|
||||
}
|
||||
|
||||
function handleSelect(value) {
|
||||
onSelect(setMonth(date, value));
|
||||
}
|
||||
|
||||
return (
|
||||
<table>
|
||||
<tbody>
|
||||
{chunk(months, 3).map((row, i) => (
|
||||
<tr key={i}>
|
||||
{row.map((month, j) => {
|
||||
const disabled =
|
||||
isBefore(endOfMonth(month), minDate) || isAfter(startOfMonth(month), maxDate);
|
||||
return (
|
||||
<td
|
||||
key={j}
|
||||
className={classNames(locale, {
|
||||
[styles.selected]: month.getMonth() === date.getMonth(),
|
||||
[styles.disabled]: disabled,
|
||||
})}
|
||||
onClick={!disabled ? () => handleSelect(month.getMonth()) : null}
|
||||
>
|
||||
{dateFormat(month, 'MMMM', locale)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
const YearSelector = ({ date, minDate, maxDate, onSelect }) => {
|
||||
const [currentDate, setCurrentDate] = useState(date);
|
||||
const year = date.getFullYear();
|
||||
const currentYear = currentDate.getFullYear();
|
||||
const minYear = minDate.getFullYear();
|
||||
const maxYear = maxDate.getFullYear();
|
||||
const years = [];
|
||||
for (let i = 0; i < 15; i++) {
|
||||
years.push(currentYear - 7 + i);
|
||||
}
|
||||
|
||||
function handleSelect(value) {
|
||||
onSelect(setYear(date, value));
|
||||
}
|
||||
|
||||
function handlePrevClick() {
|
||||
setCurrentDate(state => subYears(state, 15));
|
||||
}
|
||||
|
||||
function handleNextClick() {
|
||||
setCurrentDate(state => addYears(state, 15));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.pager}>
|
||||
<div className={styles.left}>
|
||||
<Button
|
||||
icon={<Chevron />}
|
||||
size="small"
|
||||
onClick={handlePrevClick}
|
||||
disabled={years[0] <= minYear}
|
||||
variant="light"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.middle}>
|
||||
<table>
|
||||
<tbody>
|
||||
{chunk(years, 5).map((row, i) => (
|
||||
<tr key={i}>
|
||||
{row.map((n, j) => (
|
||||
<td
|
||||
key={j}
|
||||
className={classNames({
|
||||
[styles.selected]: n === year,
|
||||
[styles.disabled]: n < minYear || n > maxYear,
|
||||
})}
|
||||
onClick={() => (n < minYear || n > maxYear ? null : handleSelect(n))}
|
||||
>
|
||||
{n}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className={styles.right}>
|
||||
<Button
|
||||
icon={<Chevron />}
|
||||
size="small"
|
||||
onClick={handleNextClick}
|
||||
disabled={years[years.length - 1] > maxYear}
|
||||
variant="light"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
111
components/common/Calendar.module.css
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
.calendar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: var(--font-size-small);
|
||||
flex: 1;
|
||||
min-height: 306px;
|
||||
}
|
||||
|
||||
.calendar table {
|
||||
width: 100%;
|
||||
border-spacing: 5px;
|
||||
}
|
||||
|
||||
.calendar td {
|
||||
color: var(--gray800);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
vertical-align: center;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.calendar td:hover {
|
||||
border: 1px solid var(--gray300);
|
||||
background: var(--gray75);
|
||||
}
|
||||
|
||||
.calendar td.faded {
|
||||
color: var(--gray500);
|
||||
}
|
||||
|
||||
.calendar td.selected {
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--gray600);
|
||||
}
|
||||
|
||||
.calendar td.selected:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.calendar td.disabled {
|
||||
color: var(--gray400);
|
||||
background: var(--gray75);
|
||||
}
|
||||
|
||||
.calendar td.disabled:hover {
|
||||
cursor: default;
|
||||
background: var(--gray75);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.calendar td.faded.disabled {
|
||||
background: var(--gray100);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
align-items: center;
|
||||
font-weight: 700;
|
||||
line-height: 40px;
|
||||
font-size: var(--font-size-normal);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.selector {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pager button {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.middle {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.left,
|
||||
.right {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.left svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.right svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 992px) {
|
||||
.calendar table {
|
||||
max-width: calc(100vw - 30px);
|
||||
}
|
||||
}
|
||||
39
components/common/Checkbox.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import React, { useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Icon from 'components/common/Icon';
|
||||
import Check from 'assets/check.svg';
|
||||
import styles from './Checkbox.module.css';
|
||||
|
||||
function Checkbox({ name, value, label, onChange }) {
|
||||
const ref = useRef();
|
||||
|
||||
const onClick = () => ref.current.click();
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.checkbox} onClick={onClick}>
|
||||
{value && <Icon icon={<Check />} size="small" />}
|
||||
</div>
|
||||
<label className={styles.label} htmlFor={name} onClick={onClick}>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
ref={ref}
|
||||
className={styles.input}
|
||||
type="checkbox"
|
||||
name={name}
|
||||
defaultChecked={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Checkbox.propTypes = {
|
||||
name: PropTypes.string,
|
||||
value: PropTypes.any,
|
||||
label: PropTypes.node,
|
||||
onChange: PropTypes.func,
|
||||
};
|
||||
|
||||
export default Checkbox;
|
||||
30
components/common/Checkbox.module.css
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1px solid var(--gray500);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-left: 10px;
|
||||
user-select: none; /* disable text selection when clicking to toggle the checkbox */
|
||||
}
|
||||
|
||||
.input {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
height: 0;
|
||||
width: 0;
|
||||
bottom: 100%;
|
||||
right: 100%;
|
||||
}
|
||||
37
components/common/CopyButton.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Button from './Button';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
const defaultText = (
|
||||
<FormattedMessage id="label.copy-to-clipboard" defaultMessage="Copy to clipboard" />
|
||||
);
|
||||
|
||||
function CopyButton({ element, ...props }) {
|
||||
const [text, setText] = useState(defaultText);
|
||||
|
||||
function handleClick() {
|
||||
if (element?.current) {
|
||||
element.current.select();
|
||||
document.execCommand('copy');
|
||||
setText(<FormattedMessage id="message.copied" defaultMessage="Copied!" />);
|
||||
window.getSelection().removeAllRanges();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button {...props} onClick={handleClick}>
|
||||
{text}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
CopyButton.propTypes = {
|
||||
element: PropTypes.shape({
|
||||
current: PropTypes.shape({
|
||||
select: PropTypes.func.isRequired,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
export default CopyButton;
|
||||
130
components/common/DateFilter.js
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { endOfYear, isSameDay } from 'date-fns';
|
||||
import Modal from './Modal';
|
||||
import DropDown from './DropDown';
|
||||
import DatePickerForm from 'components/forms/DatePickerForm';
|
||||
import useLocale from 'hooks/useLocale';
|
||||
import { getDateRange, dateFormat } from 'lib/date';
|
||||
import Calendar from 'assets/calendar-alt.svg';
|
||||
import Icon from './Icon';
|
||||
|
||||
const filterOptions = [
|
||||
{ label: <FormattedMessage id="label.today" defaultMessage="Today" />, value: '1day' },
|
||||
{
|
||||
label: (
|
||||
<FormattedMessage id="label.last-hours" defaultMessage="Last {x} hours" values={{ x: 24 }} />
|
||||
),
|
||||
value: '24hour',
|
||||
},
|
||||
{
|
||||
label: <FormattedMessage id="label.this-week" defaultMessage="This week" />,
|
||||
value: '1week',
|
||||
divider: true,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<FormattedMessage id="label.last-days" defaultMessage="Last {x} days" values={{ x: 7 }} />
|
||||
),
|
||||
value: '7day',
|
||||
},
|
||||
{
|
||||
label: <FormattedMessage id="label.this-month" defaultMessage="This month" />,
|
||||
value: '1month',
|
||||
divider: true,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<FormattedMessage id="label.last-days" defaultMessage="Last {x} days" values={{ x: 30 }} />
|
||||
),
|
||||
value: '30day',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<FormattedMessage id="label.last-days" defaultMessage="Last {x} days" values={{ x: 90 }} />
|
||||
),
|
||||
value: '90day',
|
||||
},
|
||||
{ label: <FormattedMessage id="label.this-year" defaultMessage="This year" />, value: '1year' },
|
||||
{
|
||||
label: <FormattedMessage id="label.custom-range" defaultMessage="Custom range" />,
|
||||
value: 'custom',
|
||||
divider: true,
|
||||
},
|
||||
];
|
||||
|
||||
function DateFilter({ value, startDate, endDate, onChange, className }) {
|
||||
const { locale } = useLocale();
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
const displayValue =
|
||||
value === 'custom' ? (
|
||||
<CustomRange startDate={startDate} endDate={endDate} onClick={() => handleChange('custom')} />
|
||||
) : (
|
||||
value
|
||||
);
|
||||
|
||||
function handleChange(value) {
|
||||
if (value === 'custom') {
|
||||
setShowPicker(true);
|
||||
return;
|
||||
}
|
||||
onChange(getDateRange(value, locale));
|
||||
}
|
||||
|
||||
function handlePickerChange(value) {
|
||||
setShowPicker(false);
|
||||
onChange(value);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropDown
|
||||
className={className}
|
||||
value={displayValue}
|
||||
options={filterOptions}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
{showPicker && (
|
||||
<Modal>
|
||||
<DatePickerForm
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
minDate={new Date(2000, 0, 1)}
|
||||
maxDate={endOfYear(new Date())}
|
||||
onChange={handlePickerChange}
|
||||
onClose={() => setShowPicker(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const CustomRange = ({ startDate, endDate, onClick }) => {
|
||||
const { locale } = useLocale();
|
||||
|
||||
function handleClick(e) {
|
||||
e.stopPropagation();
|
||||
|
||||
onClick();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Icon icon={<Calendar />} className="mr-2" onClick={handleClick} />
|
||||
{dateFormat(startDate, 'd LLL y', locale)}
|
||||
{!isSameDay(startDate, endDate) && ` — ${dateFormat(endDate, 'd LLL y', locale)}`}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
DateFilter.propTypes = {
|
||||
value: PropTypes.string,
|
||||
startDate: PropTypes.instanceOf(Date),
|
||||
endDate: PropTypes.instanceOf(Date),
|
||||
onChange: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
export default DateFilter;
|
||||
26
components/common/Dot.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import styles from './Dot.module.css';
|
||||
|
||||
function Dot({ color, size, className }) {
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div
|
||||
style={{ background: color }}
|
||||
className={classNames(styles.dot, className, {
|
||||
[styles.small]: size === 'small',
|
||||
[styles.large]: size === 'large',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Dot.propTypes = {
|
||||
color: PropTypes.string,
|
||||
size: PropTypes.oneOf(['small', 'large']),
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
export default Dot;
|
||||
22
components/common/Dot.module.css
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
.wrapper {
|
||||
background: var(--gray50);
|
||||
margin-right: 10px;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.dot {
|
||||
background: var(--green400);
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.dot.small {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.dot.large {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
64
components/common/DropDown.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import React, { useState, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import Menu from './Menu';
|
||||
import useDocumentClick from 'hooks/useDocumentClick';
|
||||
import Chevron from 'assets/chevron-down.svg';
|
||||
import styles from './Dropdown.module.css';
|
||||
import Icon from './Icon';
|
||||
|
||||
function DropDown({ value, className, menuClassName, options = [], onChange = () => {} }) {
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const ref = useRef();
|
||||
const selectedOption = options.find(e => e.value === value);
|
||||
|
||||
function handleShowMenu() {
|
||||
setShowMenu(state => !state);
|
||||
}
|
||||
|
||||
function handleSelect(selected, e) {
|
||||
e.stopPropagation();
|
||||
setShowMenu(false);
|
||||
|
||||
onChange(selected);
|
||||
}
|
||||
|
||||
useDocumentClick(e => {
|
||||
if (!ref.current?.contains(e.target)) {
|
||||
setShowMenu(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={ref} className={classNames(styles.dropdown, className)} onClick={handleShowMenu}>
|
||||
<div className={styles.value}>
|
||||
<div className={styles.text}>{options.find(e => e.value === value)?.label || value}</div>
|
||||
<Icon icon={<Chevron />} className={styles.icon} size="small" />
|
||||
</div>
|
||||
{showMenu && (
|
||||
<Menu
|
||||
className={menuClassName}
|
||||
options={options}
|
||||
selectedOption={selectedOption}
|
||||
onSelect={handleSelect}
|
||||
float="bottom"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DropDown.propTypes = {
|
||||
value: PropTypes.any,
|
||||
className: PropTypes.string,
|
||||
menuClassName: PropTypes.string,
|
||||
options: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
value: PropTypes.any.isRequired,
|
||||
label: PropTypes.node,
|
||||
}),
|
||||
),
|
||||
onChange: PropTypes.func,
|
||||
};
|
||||
|
||||
export default DropDown;
|
||||
28
components/common/Dropdown.module.css
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
.dropdown {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: 1px solid var(--gray500);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.value {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--font-size-small);
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
padding: 4px 16px;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.icon {
|
||||
padding-left: 20px;
|
||||
}
|
||||
22
components/common/EmptyPlaceholder.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Icon from 'components/common/Icon';
|
||||
import Logo from 'assets/logo.svg';
|
||||
import styles from './EmptyPlaceholder.module.css';
|
||||
|
||||
function EmptyPlaceholder({ msg, children }) {
|
||||
return (
|
||||
<div className={styles.placeholder}>
|
||||
<Icon className={styles.icon} icon={<Logo />} size="xlarge" />
|
||||
<h2 className={styles.msg}>{msg}</h2>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
EmptyPlaceholder.propTypes = {
|
||||
msg: PropTypes.node,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export default EmptyPlaceholder;
|
||||
15
components/common/EmptyPlaceholder.module.css
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
.placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.msg {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
14
components/common/ErrorMessage.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import Icon from './Icon';
|
||||
import Exclamation from 'assets/exclamation-triangle.svg';
|
||||
import styles from './ErrorMessage.module.css';
|
||||
|
||||
export default function ErrorMessage() {
|
||||
return (
|
||||
<div className={styles.error}>
|
||||
<Icon icon={<Exclamation />} className={styles.icon} size="large" />
|
||||
<FormattedMessage id="message.failure" defaultMessage="Something went wrong." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
components/common/ErrorMessage.module.css
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
.error {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
margin: auto;
|
||||
display: flex;
|
||||
z-index: 1;
|
||||
background-color: var(--gray50);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-right: 10px;
|
||||
}
|
||||
28
components/common/Favicon.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styles from './Favicon.module.css';
|
||||
|
||||
function getHostName(url) {
|
||||
const match = url.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?(?:www\.)?([^:/\n?=]+)/im);
|
||||
return match && match.length > 1 ? match[1] : null;
|
||||
}
|
||||
|
||||
function Favicon({ domain, ...props }) {
|
||||
const hostName = domain ? getHostName(domain) : null;
|
||||
|
||||
return hostName ? (
|
||||
<img
|
||||
className={styles.favicon}
|
||||
src={`https://icons.duckduckgo.com/ip3/${hostName}.ico`}
|
||||
height="16"
|
||||
alt=""
|
||||
{...props}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
Favicon.propTypes = {
|
||||
domain: PropTypes.string,
|
||||
};
|
||||
|
||||
export default Favicon;
|
||||
3
components/common/Favicon.module.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.favicon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
25
components/common/FilterButtons.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ButtonLayout from 'components/layout/ButtonLayout';
|
||||
import ButtonGroup from './ButtonGroup';
|
||||
|
||||
function FilterButtons({ buttons, selected, onClick }) {
|
||||
return (
|
||||
<ButtonLayout>
|
||||
<ButtonGroup size="xsmall" items={buttons} selectedItem={selected} onClick={onClick} />
|
||||
</ButtonLayout>
|
||||
);
|
||||
}
|
||||
|
||||
FilterButtons.propTypes = {
|
||||
buttons: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
label: PropTypes.node,
|
||||
value: PropTypes.any.isRequired,
|
||||
}),
|
||||
),
|
||||
selected: PropTypes.any,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
export default FilterButtons;
|
||||
29
components/common/Icon.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import styles from './Icon.module.css';
|
||||
|
||||
function Icon({ icon, className, size = 'medium', ...props }) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.icon, className, {
|
||||
[styles.xlarge]: size === 'xlarge',
|
||||
[styles.large]: size === 'large',
|
||||
[styles.medium]: size === 'medium',
|
||||
[styles.small]: size === 'small',
|
||||
[styles.xsmall]: size === 'xsmall',
|
||||
})}
|
||||
{...props}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Icon.propTypes = {
|
||||
className: PropTypes.string,
|
||||
icon: PropTypes.node.isRequired,
|
||||
size: PropTypes.oneOf(['xlarge', 'large', 'medium', 'small', 'xsmall']),
|
||||
};
|
||||
|
||||
export default Icon;
|
||||
35
components/common/Icon.module.css
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
.icon {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.icon svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.xlarge > svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.large > svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.medium > svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.small > svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.xsmall > svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
34
components/common/Link.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import NextLink from 'next/link';
|
||||
import Icon from './Icon';
|
||||
import styles from './Link.module.css';
|
||||
|
||||
function Link({ className, icon, children, size, iconRight, ...props }) {
|
||||
return (
|
||||
<NextLink {...props}>
|
||||
<a
|
||||
className={classNames(styles.link, className, {
|
||||
[styles.large]: size === 'large',
|
||||
[styles.small]: size === 'small',
|
||||
[styles.xsmall]: size === 'xsmall',
|
||||
[styles.iconRight]: iconRight,
|
||||
})}
|
||||
>
|
||||
{icon && <Icon className={styles.icon} icon={icon} size={size} />}
|
||||
{children}
|
||||
</a>
|
||||
</NextLink>
|
||||
);
|
||||
}
|
||||
|
||||
Link.propTypes = {
|
||||
className: PropTypes.string,
|
||||
icon: PropTypes.node,
|
||||
children: PropTypes.node,
|
||||
size: PropTypes.oneOf(['large', 'small', 'xsmall']),
|
||||
iconRight: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default Link;
|
||||
50
components/common/Link.module.css
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
a.link,
|
||||
a.link:active,
|
||||
a.link:visited {
|
||||
position: relative;
|
||||
color: var(--gray900);
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
a.link:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background: var(--primary400);
|
||||
opacity: 0.5;
|
||||
transition: width 100ms;
|
||||
}
|
||||
|
||||
a.link:hover:before {
|
||||
width: 100%;
|
||||
transition: width 100ms;
|
||||
}
|
||||
|
||||
a.link.large {
|
||||
font-size: var(--font-size-large);
|
||||
}
|
||||
|
||||
a.link.small {
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
a.link.xsmall {
|
||||
font-size: var(--font-size-xsmall);
|
||||
}
|
||||
|
||||
a.link .icon + * {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
a.link.iconRight .icon {
|
||||
order: 1;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
a.link.iconRight .icon + * {
|
||||
margin: 0;
|
||||
}
|
||||
20
components/common/Loading.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import styles from './Loading.module.css';
|
||||
|
||||
function Loading({ className }) {
|
||||
return (
|
||||
<div className={classNames(styles.loading, className)}>
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Loading.propTypes = {
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
export default Loading;
|
||||
43
components/common/Loading.module.css
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
@keyframes blink {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.loading div {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 100%;
|
||||
background: var(--gray400);
|
||||
animation: blink 1.4s infinite;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
.loading div + div {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.loading div:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.loading div:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
70
components/common/Menu.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import styles from './Menu.module.css';
|
||||
|
||||
function Menu({
|
||||
options = [],
|
||||
selectedOption,
|
||||
className,
|
||||
float,
|
||||
align = 'left',
|
||||
optionClassName,
|
||||
selectedClassName,
|
||||
onSelect = () => {},
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.menu, className, {
|
||||
[styles.float]: float,
|
||||
[styles.top]: float === 'top',
|
||||
[styles.bottom]: float === 'bottom',
|
||||
[styles.left]: align === 'left',
|
||||
[styles.right]: align === 'right',
|
||||
})}
|
||||
>
|
||||
{options
|
||||
.filter(({ hidden }) => !hidden)
|
||||
.map(option => {
|
||||
const { label, value, className: customClassName, render, divider } = option;
|
||||
|
||||
return render ? (
|
||||
render(option)
|
||||
) : (
|
||||
<div
|
||||
key={value}
|
||||
className={classNames(styles.option, optionClassName, customClassName, {
|
||||
[selectedClassName]: selectedOption === option,
|
||||
[styles.selected]: selectedOption === option,
|
||||
[styles.divider]: divider,
|
||||
})}
|
||||
onClick={e => onSelect(value, e)}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Menu.propTypes = {
|
||||
options: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
label: PropTypes.node,
|
||||
value: PropTypes.any,
|
||||
className: PropTypes.string,
|
||||
render: PropTypes.func,
|
||||
divider: PropTypes.bool,
|
||||
}),
|
||||
),
|
||||
selectedOption: PropTypes.any,
|
||||
className: PropTypes.string,
|
||||
float: PropTypes.oneOf(['top', 'bottom']),
|
||||
align: PropTypes.oneOf(['left', 'right']),
|
||||
optionClassName: PropTypes.string,
|
||||
selectedClassName: PropTypes.string,
|
||||
onSelect: PropTypes.func,
|
||||
};
|
||||
|
||||
export default Menu;
|
||||
51
components/common/Menu.module.css
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
.menu {
|
||||
background: var(--gray50);
|
||||
border: 1px solid var(--gray500);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.option {
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: normal;
|
||||
background: var(--gray50);
|
||||
padding: 4px 16px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.option:hover {
|
||||
background: var(--gray100);
|
||||
}
|
||||
|
||||
.float {
|
||||
position: absolute;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.top {
|
||||
bottom: 100%;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
top: 100%;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.left {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.right {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.divider {
|
||||
border-top: 1px solid var(--gray300);
|
||||
}
|
||||
|
||||
.selected {
|
||||
font-weight: 600;
|
||||
}
|
||||
86
components/common/MenuButton.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import React, { useState, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import Menu from 'components/common/Menu';
|
||||
import Button from 'components/common/Button';
|
||||
import useDocumentClick from 'hooks/useDocumentClick';
|
||||
import styles from './MenuButton.module.css';
|
||||
|
||||
function MenuButton({
|
||||
icon,
|
||||
value,
|
||||
options,
|
||||
buttonClassName,
|
||||
menuClassName,
|
||||
menuPosition = 'bottom',
|
||||
menuAlign = 'right',
|
||||
onSelect,
|
||||
renderValue,
|
||||
hideLabel,
|
||||
}) {
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const ref = useRef();
|
||||
const selectedOption = options.find(e => e.value === value);
|
||||
|
||||
function handleSelect(value) {
|
||||
onSelect(value);
|
||||
setShowMenu(false);
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
setShowMenu(state => !state);
|
||||
}
|
||||
|
||||
useDocumentClick(e => {
|
||||
if (!ref.current?.contains(e.target)) {
|
||||
setShowMenu(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.container} ref={ref}>
|
||||
<Button
|
||||
icon={icon}
|
||||
className={classNames(styles.button, buttonClassName, { [styles.open]: showMenu })}
|
||||
onClick={toggleMenu}
|
||||
variant="light"
|
||||
>
|
||||
{!hideLabel && (
|
||||
<div className={styles.text}>{renderValue ? renderValue(selectedOption) : value}</div>
|
||||
)}
|
||||
</Button>
|
||||
{showMenu && (
|
||||
<Menu
|
||||
className={menuClassName}
|
||||
options={options}
|
||||
selectedOption={selectedOption}
|
||||
onSelect={handleSelect}
|
||||
float={menuPosition}
|
||||
align={menuAlign}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
MenuButton.propTypes = {
|
||||
icon: PropTypes.node,
|
||||
value: PropTypes.any,
|
||||
options: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
label: PropTypes.node,
|
||||
value: PropTypes.any,
|
||||
className: PropTypes.string,
|
||||
render: PropTypes.func,
|
||||
divider: PropTypes.bool,
|
||||
}),
|
||||
),
|
||||
buttonClassName: PropTypes.string,
|
||||
menuClassName: PropTypes.string,
|
||||
menuPosition: PropTypes.oneOf(['top', 'bottom']),
|
||||
menuAlign: PropTypes.oneOf(['left', 'right']),
|
||||
onSelect: PropTypes.func,
|
||||
renderValue: PropTypes.func,
|
||||
};
|
||||
|
||||
export default MenuButton;
|
||||
20
components/common/MenuButton.module.css
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
.container {
|
||||
display: flex;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.open,
|
||||
.open:hover {
|
||||
background: var(--gray50);
|
||||
border: 1px solid var(--gray500);
|
||||
}
|
||||
26
components/common/Modal.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useSpring, animated } from 'react-spring';
|
||||
import styles from './Modal.module.css';
|
||||
|
||||
function Modal({ title, children }) {
|
||||
const props = useSpring({ opacity: 1, from: { opacity: 0 } });
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<animated.div className={styles.modal} style={props}>
|
||||
<div className={styles.content}>
|
||||
{title && <div className={styles.header}>{title}</div>}
|
||||
<div className={styles.body}>{children}</div>
|
||||
</div>
|
||||
</animated.div>,
|
||||
document.getElementById('__modals'),
|
||||
);
|
||||
}
|
||||
|
||||
Modal.propTypes = {
|
||||
title: PropTypes.node,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
46
components/common/Modal.module.css
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.modal:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
background: #000;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--gray50);
|
||||
min-width: 400px;
|
||||
min-height: 100px;
|
||||
max-width: 100vw;
|
||||
z-index: 1;
|
||||
border: 1px solid var(--gray300);
|
||||
padding: 30px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.header {
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
47
components/common/NavMenu.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useRouter } from 'next/router';
|
||||
import classNames from 'classnames';
|
||||
import styles from './NavMenu.module.css';
|
||||
|
||||
function NavMenu({ options = [], className, onSelect = () => {} }) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.menu, className)}>
|
||||
{options
|
||||
.filter(({ hidden }) => !hidden)
|
||||
.map(option => {
|
||||
const { label, value, className: customClassName, render } = option;
|
||||
|
||||
return render ? (
|
||||
render(option)
|
||||
) : (
|
||||
<div
|
||||
key={value}
|
||||
className={classNames(styles.option, customClassName, {
|
||||
[styles.selected]: router.asPath === value,
|
||||
})}
|
||||
onClick={e => onSelect(value, e)}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
NavMenu.propTypes = {
|
||||
options: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
label: PropTypes.node,
|
||||
value: PropTypes.any,
|
||||
className: PropTypes.string,
|
||||
render: PropTypes.func,
|
||||
}),
|
||||
),
|
||||
className: PropTypes.string,
|
||||
onSelect: PropTypes.func,
|
||||
};
|
||||
export default NavMenu;
|
||||
22
components/common/NavMenu.module.css
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
.menu {
|
||||
color: var(--gray800);
|
||||
border: 1px solid var(--gray500);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.option {
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.option:hover {
|
||||
background: var(--gray75);
|
||||
}
|
||||
|
||||
.selected {
|
||||
color: var(--gray900);
|
||||
font-weight: 600;
|
||||
}
|
||||
19
components/common/NoData.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import styles from './NoData.module.css';
|
||||
|
||||
function NoData({ className }) {
|
||||
return (
|
||||
<div className={classNames(styles.container, className)}>
|
||||
<FormattedMessage id="message.no-data-available" defaultMessage="No data available." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
NoData.propTypes = {
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
export default NoData;
|
||||
11
components/common/NoData.module.css
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
.container {
|
||||
color: var(--gray500);
|
||||
font-size: var(--font-size-normal);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
46
components/common/RefreshButton.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { setDateRange } from 'redux/actions/websites';
|
||||
import Button from './Button';
|
||||
import Refresh from 'assets/redo.svg';
|
||||
import Dots from 'assets/ellipsis-h.svg';
|
||||
import useDateRange from 'hooks/useDateRange';
|
||||
import { getDateRange } from '../../lib/date';
|
||||
import useLocale from 'hooks/useLocale';
|
||||
|
||||
function RefreshButton({ websiteId }) {
|
||||
const dispatch = useDispatch();
|
||||
const { locale } = useLocale();
|
||||
const [dateRange] = useDateRange(websiteId);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const completed = useSelector(state => state.queries[`/api/website/${websiteId}/stats`]);
|
||||
|
||||
function handleClick() {
|
||||
if (dateRange) {
|
||||
setLoading(true);
|
||||
dispatch(setDateRange(websiteId, getDateRange(dateRange.value, locale)));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
}, [completed]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
icon={loading ? <Dots /> : <Refresh />}
|
||||
tooltip={<FormattedMessage id="label.refresh" defaultMessage="Refresh" />}
|
||||
tooltipId="button-refresh"
|
||||
size="small"
|
||||
onClick={handleClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
RefreshButton.propTypes = {
|
||||
websiteId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
};
|
||||
|
||||
export default RefreshButton;
|
||||
89
components/common/Table.js
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import NoData from 'components/common/NoData';
|
||||
import styles from './Table.module.css';
|
||||
|
||||
function Table({
|
||||
columns,
|
||||
rows,
|
||||
empty,
|
||||
className,
|
||||
bodyClassName,
|
||||
rowKey,
|
||||
showHeader = true,
|
||||
children,
|
||||
}) {
|
||||
if (empty && rows.length === 0) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.table, className)}>
|
||||
{showHeader && (
|
||||
<div className={classNames(styles.header, 'row')}>
|
||||
{columns.map(({ key, label, className, style, header }) => (
|
||||
<div
|
||||
key={key}
|
||||
className={classNames(styles.head, className, header?.className)}
|
||||
style={{ ...style, ...header?.style }}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className={classNames(styles.body, bodyClassName)}>
|
||||
{rows.length === 0 && <NoData />}
|
||||
{!children &&
|
||||
rows.map((row, index) => {
|
||||
const id = rowKey ? rowKey(row) : index;
|
||||
return <TableRow key={id} columns={columns} row={row} />;
|
||||
})}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styledObject = PropTypes.shape({
|
||||
className: PropTypes.string,
|
||||
style: PropTypes.object,
|
||||
});
|
||||
|
||||
Table.propTypes = {
|
||||
columns: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
cell: styledObject,
|
||||
className: PropTypes.string,
|
||||
header: styledObject,
|
||||
key: PropTypes.string,
|
||||
label: PropTypes.node,
|
||||
render: PropTypes.func,
|
||||
style: PropTypes.object,
|
||||
}),
|
||||
),
|
||||
rows: PropTypes.arrayOf(PropTypes.object),
|
||||
empty: PropTypes.node,
|
||||
className: PropTypes.string,
|
||||
bodyClassName: PropTypes.string,
|
||||
rowKey: PropTypes.func,
|
||||
showHeader: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export default Table;
|
||||
|
||||
export const TableRow = ({ columns, row }) => (
|
||||
<div className={classNames(styles.row, 'row')}>
|
||||
{columns.map(({ key, render, className, style, cell }, index) => (
|
||||
<div
|
||||
key={`${key}-${index}`}
|
||||
className={classNames(styles.cell, className, cell?.className)}
|
||||
style={{ ...style, ...cell?.style }}
|
||||
>
|
||||
{render ? render(row) : row[key]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
30
components/common/Table.module.css
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
.table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
border-bottom: 1px solid var(--gray300);
|
||||
}
|
||||
|
||||
.head {
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
.body {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.row {
|
||||
border-bottom: 1px solid var(--gray300);
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.cell {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
15
components/common/Tag.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import styles from './Tag.module.css';
|
||||
|
||||
function Tag({ className, children }) {
|
||||
return <span className={classNames(styles.tag, className)}>{children}</span>;
|
||||
}
|
||||
|
||||
Tag.propTypes = {
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export default Tag;
|
||||
6
components/common/Tag.module.css
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
.tag {
|
||||
padding: 2px 4px;
|
||||
border: 1px solid var(--gray300);
|
||||
border-radius: 4px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
35
components/common/Toast.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useSpring, animated } from 'react-spring';
|
||||
import styles from './Toast.module.css';
|
||||
import Icon from 'components/common/Icon';
|
||||
import Close from 'assets/times.svg';
|
||||
|
||||
function Toast({ message, timeout = 3000, onClose }) {
|
||||
const props = useSpring({
|
||||
opacity: 1,
|
||||
transform: 'translate3d(0,0px,0)',
|
||||
from: { opacity: 0, transform: 'translate3d(0,-40px,0)' },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(onClose, timeout);
|
||||
}, []);
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<animated.div className={styles.toast} style={props} onClick={onClose}>
|
||||
<div className={styles.message}>{message}</div>
|
||||
<Icon className={styles.close} icon={<Close />} size="small" />
|
||||
</animated.div>,
|
||||
document.getElementById('__modals'),
|
||||
);
|
||||
}
|
||||
|
||||
Toast.propTypes = {
|
||||
message: PropTypes.node,
|
||||
timeout: PropTypes.number,
|
||||
onClose: PropTypes.func,
|
||||
};
|
||||
|
||||
export default Toast;
|
||||
25
components/common/Toast.module.css
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
.toast {
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 300px;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
color: var(--msgColor);
|
||||
background: var(--green400);
|
||||
margin: auto;
|
||||
z-index: 2;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: var(--font-size-normal);
|
||||
}
|
||||
|
||||
.close {
|
||||
margin-left: 20px;
|
||||
}
|
||||
47
components/common/UpdateNotice.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import useVersion from 'hooks/useVersion';
|
||||
import styles from './UpdateNotice.module.css';
|
||||
import ButtonLayout from '../layout/ButtonLayout';
|
||||
import Button from './Button';
|
||||
import useForceUpdate from '../../hooks/useForceUpdate';
|
||||
|
||||
export default function UpdateNotice() {
|
||||
const forceUpdate = useForceUpdate();
|
||||
const { hasUpdate, checked, latest, updateCheck } = useVersion(true);
|
||||
|
||||
function handleViewClick() {
|
||||
location.href = 'https://github.com/mikecao/umami/releases';
|
||||
updateCheck();
|
||||
forceUpdate();
|
||||
}
|
||||
|
||||
function handleDismissClick() {
|
||||
updateCheck();
|
||||
forceUpdate();
|
||||
}
|
||||
|
||||
if (!hasUpdate || checked) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.notice}>
|
||||
<div className={styles.message}>
|
||||
<FormattedMessage
|
||||
id="message.new-version-available"
|
||||
defaultMessage="A new version of umami {version} is available!"
|
||||
values={{ version: `v${latest}` }}
|
||||
/>
|
||||
</div>
|
||||
<ButtonLayout>
|
||||
<Button size="xsmall" variant="action" onClick={handleViewClick}>
|
||||
<FormattedMessage id="label.view-details" defaultMessage="View details" />
|
||||
</Button>
|
||||
<Button size="xsmall" onClick={handleDismissClick}>
|
||||
<FormattedMessage id="label.dismiss" defaultMessage="Dismiss" />
|
||||
</Button>
|
||||
</ButtonLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
components/common/UpdateNotice.module.css
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
.notice {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding-top: 10px;
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.message {
|
||||
text-align: center;
|
||||
margin-right: 20px;
|
||||
}
|
||||
103
components/common/WorldMap.js
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import PropTypes from 'prop-types';
|
||||
import ReactTooltip from 'react-tooltip';
|
||||
import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps';
|
||||
import classNames from 'classnames';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import useTheme from 'hooks/useTheme';
|
||||
import { ISO_COUNTRIES, THEME_COLORS, MAP_FILE } from 'lib/constants';
|
||||
import styles from './WorldMap.module.css';
|
||||
import useCountryNames from 'hooks/useCountryNames';
|
||||
import useLocale from 'hooks/useLocale';
|
||||
|
||||
function WorldMap({ data, className }) {
|
||||
const { basePath } = useRouter();
|
||||
const [tooltip, setTooltip] = useState();
|
||||
const [theme] = useTheme();
|
||||
const colors = useMemo(
|
||||
() => ({
|
||||
baseColor: THEME_COLORS[theme].primary,
|
||||
fillColor: THEME_COLORS[theme].gray100,
|
||||
strokeColor: THEME_COLORS[theme].primary,
|
||||
hoverColor: THEME_COLORS[theme].primary,
|
||||
}),
|
||||
[theme],
|
||||
);
|
||||
const { locale } = useLocale();
|
||||
const countryNames = useCountryNames(locale);
|
||||
|
||||
function getFillColor(code) {
|
||||
if (code === 'AQ') return;
|
||||
const country = data?.find(({ x }) => x === code);
|
||||
|
||||
if (!country) {
|
||||
return colors.fillColor;
|
||||
}
|
||||
|
||||
return tinycolor(colors.baseColor)[theme === 'light' ? 'lighten' : 'darken'](
|
||||
40 * (1.0 - country.z / 100),
|
||||
);
|
||||
}
|
||||
|
||||
function getOpacity(code) {
|
||||
return code === 'AQ' ? 0 : 1;
|
||||
}
|
||||
|
||||
function handleHover(code) {
|
||||
if (code === 'AQ') return;
|
||||
const country = data?.find(({ x }) => x === code);
|
||||
setTooltip(`${countryNames[code]}: ${country?.y || 0} visitors`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.container, className)}
|
||||
data-tip=""
|
||||
data-for="world-map-tooltip"
|
||||
>
|
||||
<ComposableMap projection="geoMercator">
|
||||
<ZoomableGroup zoom={0.8} minZoom={0.7} center={[0, 40]}>
|
||||
<Geographies geography={`${basePath}${MAP_FILE}`}>
|
||||
{({ geographies }) => {
|
||||
return geographies.map(geo => {
|
||||
const code = ISO_COUNTRIES[geo.id];
|
||||
|
||||
return (
|
||||
<Geography
|
||||
key={geo.rsmKey}
|
||||
geography={geo}
|
||||
fill={getFillColor(code)}
|
||||
stroke={colors.strokeColor}
|
||||
opacity={getOpacity(code)}
|
||||
style={{
|
||||
default: { outline: 'none' },
|
||||
hover: { outline: 'none', fill: colors.hoverColor },
|
||||
pressed: { outline: 'none' },
|
||||
}}
|
||||
onMouseOver={() => handleHover(code)}
|
||||
onMouseOut={() => setTooltip(null)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}}
|
||||
</Geographies>
|
||||
</ZoomableGroup>
|
||||
</ComposableMap>
|
||||
<ReactTooltip id="world-map-tooltip">{tooltip}</ReactTooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
WorldMap.propTypes = {
|
||||
data: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
x: PropTypes.string,
|
||||
y: PropTypes.number,
|
||||
z: PropTypes.number,
|
||||
}),
|
||||
),
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
export default WorldMap;
|
||||
4
components/common/WorldMap.module.css
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
.container {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
88
components/forms/AccountEditForm.js
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Formik, Form, Field } from 'formik';
|
||||
import Button from 'components/common/Button';
|
||||
import FormLayout, {
|
||||
FormButtons,
|
||||
FormError,
|
||||
FormMessage,
|
||||
FormRow,
|
||||
} from 'components/layout/FormLayout';
|
||||
import usePost from 'hooks/usePost';
|
||||
|
||||
const initialValues = {
|
||||
username: '',
|
||||
password: '',
|
||||
};
|
||||
|
||||
const validate = ({ user_id, username, password }) => {
|
||||
const errors = {};
|
||||
|
||||
if (!username) {
|
||||
errors.username = <FormattedMessage id="label.required" defaultMessage="Required" />;
|
||||
}
|
||||
if (!user_id && !password) {
|
||||
errors.password = <FormattedMessage id="label.required" defaultMessage="Required" />;
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
export default function AccountEditForm({ values, onSave, onClose }) {
|
||||
const post = usePost();
|
||||
const [message, setMessage] = useState();
|
||||
|
||||
const handleSubmit = async values => {
|
||||
const { ok, data } = await post('/api/account', values);
|
||||
|
||||
if (ok) {
|
||||
onSave();
|
||||
} else {
|
||||
setMessage(
|
||||
data || <FormattedMessage id="message.failure" defaultMessage="Something went wrong." />,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormLayout>
|
||||
<Formik
|
||||
initialValues={{ ...initialValues, ...values }}
|
||||
validate={validate}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{() => (
|
||||
<Form>
|
||||
<FormRow>
|
||||
<label htmlFor="username">
|
||||
<FormattedMessage id="label.username" defaultMessage="Username" />
|
||||
</label>
|
||||
<div>
|
||||
<Field name="username" type="text" />
|
||||
<FormError name="username" />
|
||||
</div>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<label htmlFor="password">
|
||||
<FormattedMessage id="label.password" defaultMessage="Password" />
|
||||
</label>
|
||||
<div>
|
||||
<Field name="password" type="password" />
|
||||
<FormError name="password" />
|
||||
</div>
|
||||
</FormRow>
|
||||
<FormButtons>
|
||||
<Button type="submit" variant="action">
|
||||
<FormattedMessage id="label.save" defaultMessage="Save" />
|
||||
</Button>
|
||||
<Button onClick={onClose}>
|
||||
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
|
||||
</Button>
|
||||
</FormButtons>
|
||||
<FormMessage>{message}</FormMessage>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</FormLayout>
|
||||
);
|
||||
}
|
||||
105
components/forms/ChangePasswordForm.js
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Formik, Form, Field } from 'formik';
|
||||
import Button from 'components/common/Button';
|
||||
import FormLayout, {
|
||||
FormButtons,
|
||||
FormError,
|
||||
FormMessage,
|
||||
FormRow,
|
||||
} from 'components/layout/FormLayout';
|
||||
import usePost from 'hooks/usePost';
|
||||
|
||||
const initialValues = {
|
||||
current_password: '',
|
||||
new_password: '',
|
||||
confirm_password: '',
|
||||
};
|
||||
|
||||
const validate = ({ current_password, new_password, confirm_password }) => {
|
||||
const errors = {};
|
||||
|
||||
if (!current_password) {
|
||||
errors.current_password = <FormattedMessage id="label.required" defaultMessage="Required" />;
|
||||
}
|
||||
if (!new_password) {
|
||||
errors.new_password = <FormattedMessage id="label.required" defaultMessage="Required" />;
|
||||
}
|
||||
if (!confirm_password) {
|
||||
errors.confirm_password = <FormattedMessage id="label.required" defaultMessage="Required" />;
|
||||
} else if (new_password !== confirm_password) {
|
||||
errors.confirm_password = (
|
||||
<FormattedMessage id="label.passwords-dont-match" defaultMessage="Passwords don't match" />
|
||||
);
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
export default function ChangePasswordForm({ values, onSave, onClose }) {
|
||||
const post = usePost();
|
||||
const [message, setMessage] = useState();
|
||||
|
||||
const handleSubmit = async values => {
|
||||
const { ok, data } = await post('/api/account/password', values);
|
||||
|
||||
if (ok) {
|
||||
onSave();
|
||||
} else {
|
||||
setMessage(
|
||||
data || <FormattedMessage id="message.failure" defaultMessage="Something went wrong." />,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormLayout>
|
||||
<Formik
|
||||
initialValues={{ ...initialValues, ...values }}
|
||||
validate={validate}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{() => (
|
||||
<Form>
|
||||
<FormRow>
|
||||
<label htmlFor="current_password">
|
||||
<FormattedMessage id="label.current-password" defaultMessage="Current password" />
|
||||
</label>
|
||||
<div>
|
||||
<Field name="current_password" type="password" />
|
||||
<FormError name="current_password" />
|
||||
</div>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<label htmlFor="new_password">
|
||||
<FormattedMessage id="label.new-password" defaultMessage="New password" />
|
||||
</label>
|
||||
<div>
|
||||
<Field name="new_password" type="password" />
|
||||
<FormError name="new_password" />
|
||||
</div>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<label htmlFor="confirm_password">
|
||||
<FormattedMessage id="label.confirm-password" defaultMessage="Confirm password" />
|
||||
</label>
|
||||
<div>
|
||||
<Field name="confirm_password" type="password" />
|
||||
<FormError name="confirm_password" />
|
||||
</div>
|
||||
</FormRow>
|
||||
<FormButtons>
|
||||
<Button type="submit" variant="action">
|
||||
<FormattedMessage id="label.save" defaultMessage="Save" />
|
||||
</Button>
|
||||
<Button onClick={onClose}>
|
||||
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
|
||||
</Button>
|
||||
</FormButtons>
|
||||
<FormMessage>{message}</FormMessage>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</FormLayout>
|
||||
);
|
||||
}
|
||||
83
components/forms/DatePickerForm.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { isAfter, isBefore, isSameDay } from 'date-fns';
|
||||
import Calendar from 'components/common/Calendar';
|
||||
import Button from 'components/common/Button';
|
||||
import { FormButtons } from 'components/layout/FormLayout';
|
||||
import { getDateRangeValues } from 'lib/date';
|
||||
import styles from './DatePickerForm.module.css';
|
||||
import ButtonGroup from 'components/common/ButtonGroup';
|
||||
|
||||
const FILTER_DAY = 0;
|
||||
const FILTER_RANGE = 1;
|
||||
|
||||
export default function DatePickerForm({
|
||||
startDate: defaultStartDate,
|
||||
endDate: defaultEndDate,
|
||||
minDate,
|
||||
maxDate,
|
||||
onChange,
|
||||
onClose,
|
||||
}) {
|
||||
const [selected, setSelected] = useState(
|
||||
isSameDay(defaultStartDate, defaultEndDate) ? FILTER_DAY : FILTER_RANGE,
|
||||
);
|
||||
const [date, setDate] = useState(defaultStartDate);
|
||||
const [startDate, setStartDate] = useState(defaultStartDate);
|
||||
const [endDate, setEndDate] = useState(defaultEndDate);
|
||||
|
||||
const disabled =
|
||||
selected === FILTER_DAY
|
||||
? isAfter(minDate, date) && isBefore(maxDate, date)
|
||||
: isAfter(startDate, endDate);
|
||||
|
||||
const buttons = [
|
||||
{
|
||||
label: <FormattedMessage id="label.single-day" defaultMessage="Single day" />,
|
||||
value: FILTER_DAY,
|
||||
},
|
||||
{
|
||||
label: <FormattedMessage id="label.date-range" defaultMessage="Date range" />,
|
||||
value: FILTER_RANGE,
|
||||
},
|
||||
];
|
||||
|
||||
function handleSave() {
|
||||
if (selected === FILTER_DAY) {
|
||||
onChange({ ...getDateRangeValues(date, date), value: 'custom' });
|
||||
} else {
|
||||
onChange({ ...getDateRangeValues(startDate, endDate), value: 'custom' });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.filter}>
|
||||
<ButtonGroup size="small" items={buttons} selectedItem={selected} onClick={setSelected} />
|
||||
</div>
|
||||
<div className={styles.calendars}>
|
||||
{selected === FILTER_DAY ? (
|
||||
<Calendar date={date} minDate={minDate} maxDate={maxDate} onChange={setDate} />
|
||||
) : (
|
||||
<>
|
||||
<Calendar
|
||||
date={startDate}
|
||||
minDate={minDate}
|
||||
maxDate={endDate}
|
||||
onChange={setStartDate}
|
||||
/>
|
||||
<Calendar date={endDate} minDate={startDate} maxDate={maxDate} onChange={setEndDate} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<FormButtons>
|
||||
<Button variant="action" onClick={handleSave} disabled={disabled}>
|
||||
<FormattedMessage id="label.save" defaultMessage="Save" />
|
||||
</Button>
|
||||
<Button onClick={onClose}>
|
||||
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
|
||||
</Button>
|
||||
</FormButtons>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
components/forms/DatePickerForm.module.css
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.calendars {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.calendars > div {
|
||||
width: 380px;
|
||||
}
|
||||
|
||||
.calendars > div + div {
|
||||
margin-left: 20px;
|
||||
padding-left: 20px;
|
||||
border-left: 1px solid var(--gray300);
|
||||
}
|
||||
|
||||
.filter {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.calendars {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.calendars > div + div {
|
||||
padding: 0;
|
||||
margin-left: 0;
|
||||
margin-top: 20px;
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
98
components/forms/DeleteForm.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Formik, Form, Field } from 'formik';
|
||||
import Button from 'components/common/Button';
|
||||
import FormLayout, {
|
||||
FormButtons,
|
||||
FormError,
|
||||
FormMessage,
|
||||
FormRow,
|
||||
} from 'components/layout/FormLayout';
|
||||
import useDelete from 'hooks/useDelete';
|
||||
|
||||
const CONFIRMATION_WORD = 'DELETE';
|
||||
|
||||
const validate = ({ confirmation }) => {
|
||||
const errors = {};
|
||||
|
||||
if (confirmation !== CONFIRMATION_WORD) {
|
||||
errors.confirmation = !confirmation ? (
|
||||
<FormattedMessage id="label.required" defaultMessage="Required" />
|
||||
) : (
|
||||
<FormattedMessage id="label.invalid" defaultMessage="Invalid" />
|
||||
);
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
export default function DeleteForm({ values, onSave, onClose }) {
|
||||
const del = useDelete();
|
||||
const [message, setMessage] = useState();
|
||||
|
||||
const handleSubmit = async ({ type, id }) => {
|
||||
const { ok, data } = await del(`/api/${type}/${id}`);
|
||||
|
||||
if (ok) {
|
||||
onSave();
|
||||
} else {
|
||||
setMessage(
|
||||
data || <FormattedMessage id="message.failure" defaultMessage="Something went wrong." />,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormLayout>
|
||||
<Formik
|
||||
initialValues={{ confirmation: '', ...values }}
|
||||
validate={validate}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{props => (
|
||||
<Form>
|
||||
<div>
|
||||
<FormattedMessage
|
||||
id="message.confirm-delete"
|
||||
defaultMessage="Are your sure you want to delete {target}?"
|
||||
values={{ target: <b>{values.name}</b> }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormattedMessage
|
||||
id="message.delete-warning"
|
||||
defaultMessage="All associated data will be deleted as well."
|
||||
/>
|
||||
</div>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="message.type-delete"
|
||||
defaultMessage="Type {delete} in the box below to confirm."
|
||||
values={{ delete: <b>{CONFIRMATION_WORD}</b> }}
|
||||
/>
|
||||
</p>
|
||||
<FormRow>
|
||||
<div>
|
||||
<Field name="confirmation" type="text" />
|
||||
<FormError name="confirmation" />
|
||||
</div>
|
||||
</FormRow>
|
||||
<FormButtons>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="danger"
|
||||
disabled={props.values.confirmation !== CONFIRMATION_WORD}
|
||||
>
|
||||
<FormattedMessage id="label.delete" defaultMessage="Delete" />
|
||||
</Button>
|
||||
<Button onClick={onClose}>
|
||||
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
|
||||
</Button>
|
||||
</FormButtons>
|
||||
<FormMessage>{message}</FormMessage>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</FormLayout>
|
||||
);
|
||||
}
|
||||
102
components/forms/LoginForm.js
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Formik, Form, Field } from 'formik';
|
||||
import { useRouter } from 'next/router';
|
||||
import Button from 'components/common/Button';
|
||||
import FormLayout, {
|
||||
FormButtons,
|
||||
FormError,
|
||||
FormMessage,
|
||||
FormRow,
|
||||
} from 'components/layout/FormLayout';
|
||||
import Icon from 'components/common/Icon';
|
||||
import Logo from 'assets/logo.svg';
|
||||
import styles from './LoginForm.module.css';
|
||||
import usePost from 'hooks/usePost';
|
||||
|
||||
const validate = ({ username, password }) => {
|
||||
const errors = {};
|
||||
|
||||
if (!username) {
|
||||
errors.username = <FormattedMessage id="label.required" defaultMessage="Required" />;
|
||||
}
|
||||
if (!password) {
|
||||
errors.password = <FormattedMessage id="label.required" defaultMessage="Required" />;
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
export default function LoginForm() {
|
||||
const post = usePost();
|
||||
const router = useRouter();
|
||||
const [message, setMessage] = useState();
|
||||
|
||||
const handleSubmit = async ({ username, password }) => {
|
||||
const { ok, status, data } = await post('/api/auth/login', {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
|
||||
if (ok) {
|
||||
return router.push('/');
|
||||
} else {
|
||||
setMessage(
|
||||
status === 401 ? (
|
||||
<FormattedMessage
|
||||
id="message.incorrect-username-password"
|
||||
defaultMessage="Incorrect username/password."
|
||||
/>
|
||||
) : (
|
||||
data
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormLayout className={styles.login}>
|
||||
<Formik
|
||||
initialValues={{
|
||||
username: '',
|
||||
password: '',
|
||||
}}
|
||||
validate={validate}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{() => (
|
||||
<Form>
|
||||
<div className={styles.header}>
|
||||
<Icon icon={<Logo />} size="xlarge" className={styles.icon} />
|
||||
<h1 className="center">umami</h1>
|
||||
</div>
|
||||
<FormRow>
|
||||
<label htmlFor="username">
|
||||
<FormattedMessage id="label.username" defaultMessage="Username" />
|
||||
</label>
|
||||
<div>
|
||||
<Field name="username" type="text" />
|
||||
<FormError name="username" />
|
||||
</div>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<label htmlFor="password">
|
||||
<FormattedMessage id="label.password" defaultMessage="Password" />
|
||||
</label>
|
||||
<div>
|
||||
<Field name="password" type="password" />
|
||||
<FormError name="password" />
|
||||
</div>
|
||||
</FormRow>
|
||||
<FormButtons>
|
||||
<Button type="submit" variant="action">
|
||||
<FormattedMessage id="label.login" defaultMessage="Login" />
|
||||
</Button>
|
||||
</FormButtons>
|
||||
<FormMessage>{message}</FormMessage>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</FormLayout>
|
||||
);
|
||||
}
|
||||
23
components/forms/LoginForm.module.css
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
.login {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 80px;
|
||||
}
|
||||
|
||||
.login form {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 12px 0;
|
||||
}
|
||||
98
components/forms/ResetForm.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Formik, Form, Field } from 'formik';
|
||||
import Button from 'components/common/Button';
|
||||
import FormLayout, {
|
||||
FormButtons,
|
||||
FormError,
|
||||
FormMessage,
|
||||
FormRow,
|
||||
} from 'components/layout/FormLayout';
|
||||
import usePost from 'hooks/usePost';
|
||||
|
||||
const CONFIRMATION_WORD = 'RESET';
|
||||
|
||||
const validate = ({ confirmation }) => {
|
||||
const errors = {};
|
||||
|
||||
if (confirmation !== CONFIRMATION_WORD) {
|
||||
errors.confirmation = !confirmation ? (
|
||||
<FormattedMessage id="label.required" defaultMessage="Required" />
|
||||
) : (
|
||||
<FormattedMessage id="label.invalid" defaultMessage="Invalid" />
|
||||
);
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
export default function ResetForm({ values, onSave, onClose }) {
|
||||
const post = usePost();
|
||||
const [message, setMessage] = useState();
|
||||
|
||||
const handleSubmit = async ({ type, id }) => {
|
||||
const { ok, data } = await post(`/api/${type}/${id}/reset`);
|
||||
|
||||
if (ok) {
|
||||
onSave();
|
||||
} else {
|
||||
setMessage(
|
||||
data || <FormattedMessage id="message.failure" defaultMessage="Something went wrong." />,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormLayout>
|
||||
<Formik
|
||||
initialValues={{ confirmation: '', ...values }}
|
||||
validate={validate}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{props => (
|
||||
<Form>
|
||||
<div>
|
||||
<FormattedMessage
|
||||
id="message.confirm-reset"
|
||||
defaultMessage="Are your sure you want to reset {target}'s statistics?"
|
||||
values={{ target: <b>{values.name}</b> }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormattedMessage
|
||||
id="message.reset-warning"
|
||||
defaultMessage="All statistics for this website will be deleted, but your tracking code will remain intact."
|
||||
/>
|
||||
</div>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="message.type-reset"
|
||||
defaultMessage="Type {reset} in the box below to confirm."
|
||||
values={{ reset: <b>{CONFIRMATION_WORD}</b> }}
|
||||
/>
|
||||
</p>
|
||||
<FormRow>
|
||||
<div>
|
||||
<Field name="confirmation" type="text" />
|
||||
<FormError name="confirmation" />
|
||||
</div>
|
||||
</FormRow>
|
||||
<FormButtons>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="danger"
|
||||
disabled={props.values.confirmation !== CONFIRMATION_WORD}
|
||||
>
|
||||
<FormattedMessage id="label.reset" defaultMessage="Reset" />
|
||||
</Button>
|
||||
<Button onClick={onClose}>
|
||||
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
|
||||
</Button>
|
||||
</FormButtons>
|
||||
<FormMessage>{message}</FormMessage>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</FormLayout>
|
||||
);
|
||||
}
|
||||
42
components/forms/ShareUrlForm.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import React, { useRef } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useRouter } from 'next/router';
|
||||
import Button from 'components/common/Button';
|
||||
import FormLayout, { FormButtons, FormRow } from 'components/layout/FormLayout';
|
||||
import CopyButton from 'components/common/CopyButton';
|
||||
|
||||
export default function TrackingCodeForm({ values, onClose }) {
|
||||
const ref = useRef();
|
||||
const { basePath } = useRouter();
|
||||
const { name, share_id } = values;
|
||||
|
||||
return (
|
||||
<FormLayout>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="message.share-url"
|
||||
defaultMessage="This is the publicly shared URL for {target}."
|
||||
values={{ target: <b>{values.name}</b> }}
|
||||
/>
|
||||
</p>
|
||||
<FormRow>
|
||||
<textarea
|
||||
ref={ref}
|
||||
rows={3}
|
||||
cols={60}
|
||||
spellCheck={false}
|
||||
defaultValue={`${
|
||||
document.location.origin
|
||||
}${basePath}/share/${share_id}/${encodeURIComponent(name)}`}
|
||||
readOnly
|
||||
/>
|
||||
</FormRow>
|
||||
<FormButtons>
|
||||
<CopyButton type="submit" variant="action" element={ref} />
|
||||
<Button onClick={onClose}>
|
||||
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
|
||||
</Button>
|
||||
</FormButtons>
|
||||
</FormLayout>
|
||||
);
|
||||
}
|
||||
39
components/forms/TrackingCodeForm.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import React, { useRef } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useRouter } from 'next/router';
|
||||
import Button from 'components/common/Button';
|
||||
import FormLayout, { FormButtons, FormRow } from 'components/layout/FormLayout';
|
||||
import CopyButton from 'components/common/CopyButton';
|
||||
|
||||
export default function TrackingCodeForm({ values, onClose }) {
|
||||
const ref = useRef();
|
||||
const { basePath } = useRouter();
|
||||
|
||||
return (
|
||||
<FormLayout>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="message.track-stats"
|
||||
defaultMessage="To track stats for {target}, place the following code in the {head} section of your website."
|
||||
values={{ head: '<head>', target: <b>{values.name}</b> }}
|
||||
/>
|
||||
</p>
|
||||
<FormRow>
|
||||
<textarea
|
||||
ref={ref}
|
||||
rows={3}
|
||||
cols={60}
|
||||
spellCheck={false}
|
||||
defaultValue={`<script async defer data-website-id="${values.website_uuid}" src="${document.location.origin}${basePath}/umami.js"></script>`}
|
||||
readOnly
|
||||
/>
|
||||
</FormRow>
|
||||
<FormButtons>
|
||||
<CopyButton type="submit" variant="action" element={ref} />
|
||||
<Button onClick={onClose}>
|
||||
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
|
||||
</Button>
|
||||
</FormButtons>
|
||||
</FormLayout>
|
||||
);
|
||||
}
|
||||
109
components/forms/WebsiteEditForm.js
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Formik, Form, Field } from 'formik';
|
||||
import Button from 'components/common/Button';
|
||||
import FormLayout, {
|
||||
FormButtons,
|
||||
FormError,
|
||||
FormMessage,
|
||||
FormRow,
|
||||
} from 'components/layout/FormLayout';
|
||||
import Checkbox from 'components/common/Checkbox';
|
||||
import { DOMAIN_REGEX } from 'lib/constants';
|
||||
import usePost from 'hooks/usePost';
|
||||
|
||||
const initialValues = {
|
||||
name: '',
|
||||
domain: '',
|
||||
public: false,
|
||||
};
|
||||
|
||||
const validate = ({ name, domain }) => {
|
||||
const errors = {};
|
||||
|
||||
if (!name) {
|
||||
errors.name = <FormattedMessage id="label.required" defaultMessage="Required" />;
|
||||
}
|
||||
if (!domain) {
|
||||
errors.domain = <FormattedMessage id="label.required" defaultMessage="Required" />;
|
||||
} else if (!DOMAIN_REGEX.test(domain)) {
|
||||
errors.domain = <FormattedMessage id="label.invalid-domain" defaultMessage="Invalid domain" />;
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
export default function WebsiteEditForm({ values, onSave, onClose }) {
|
||||
const post = usePost();
|
||||
const [message, setMessage] = useState();
|
||||
|
||||
const handleSubmit = async values => {
|
||||
const { ok, data } = await post('/api/website', values);
|
||||
|
||||
if (ok) {
|
||||
onSave();
|
||||
} else {
|
||||
setMessage(
|
||||
data || <FormattedMessage id="message.failure" defaultMessage="Something went wrong." />,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormLayout>
|
||||
<Formik
|
||||
initialValues={{ ...initialValues, ...values, enable_share_url: !!values?.share_id }}
|
||||
validate={validate}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{() => (
|
||||
<Form>
|
||||
<FormRow>
|
||||
<label htmlFor="name">
|
||||
<FormattedMessage id="label.name" defaultMessage="Name" />
|
||||
</label>
|
||||
<div>
|
||||
<Field name="name" type="text" />
|
||||
<FormError name="name" />
|
||||
</div>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<label htmlFor="domain">
|
||||
<FormattedMessage id="label.domain" defaultMessage="Domain" />
|
||||
</label>
|
||||
<div>
|
||||
<Field name="domain" type="text" placeholder="example.com" />
|
||||
<FormError name="domain" />
|
||||
</div>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<label />
|
||||
<Field name="enable_share_url">
|
||||
{({ field }) => (
|
||||
<Checkbox
|
||||
{...field}
|
||||
label={
|
||||
<FormattedMessage
|
||||
id="label.enable-share-url"
|
||||
defaultMessage="Enable share URL"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</FormRow>
|
||||
<FormButtons>
|
||||
<Button type="submit" variant="action">
|
||||
<FormattedMessage id="label.save" defaultMessage="Save" />
|
||||
</Button>
|
||||
<Button onClick={onClose}>
|
||||
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
|
||||
</Button>
|
||||
</FormButtons>
|
||||
<FormMessage>{message}</FormMessage>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</FormLayout>
|
||||
);
|
||||
}
|
||||
41
components/helpers/CheckVisible.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
|
||||
function isInViewport(element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return !(
|
||||
rect.bottom < 0 ||
|
||||
rect.right < 0 ||
|
||||
rect.left > window.innerWidth ||
|
||||
rect.top > window.innerHeight
|
||||
);
|
||||
}
|
||||
|
||||
export default function CheckVisible({ className, children }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const ref = useRef();
|
||||
|
||||
useEffect(() => {
|
||||
const checkPosition = () => {
|
||||
if (ref.current) {
|
||||
const state = isInViewport(ref.current);
|
||||
if (state !== visible) {
|
||||
setVisible(state);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkPosition();
|
||||
|
||||
window.addEventListener('scroll', checkPosition);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', checkPosition);
|
||||
};
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={className} data-visible={visible}>
|
||||
{typeof children === 'function' ? children(visible) : children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||