Merge branch 'dev' of https://github.com/umami-software/umami into feat/um-121-v2-migration-script

This commit is contained in:
Francis Cao 2023-02-02 10:00:53 -08:00
commit d6e17fe982
257 changed files with 4340 additions and 4079 deletions

View File

@ -72,13 +72,13 @@ docker compose up
Alternatively, to pull just the Umami Docker image with PostgreSQL support:
```bash
docker pull docker.umami.is/umami-software/umami:postgresql-latest
docker pull docker.umami.dev/umami-software/umami:postgresql-latest
```
Or with MySQL support:
```bash
docker pull docker.umami.is/umami-software/umami:mysql-latest
docker pull docker.umami.dev/umami-software/umami:mysql-latest
```
## Getting updates

1
assets/add-user.svg Normal file
View File

@ -0,0 +1 @@
<svg id="Layer_2" height="512" viewBox="0 0 30 30" width="512" xmlns="http://www.w3.org/2000/svg" data-name="Layer 2"><g fill="rgb(0,0,0)"><path d="m15 14a5.5 5.5 0 1 1 5.5-5.5 5.51 5.51 0 0 1 -5.5 5.5zm0-9a3.5 3.5 0 1 0 3.5 3.5 3.5 3.5 0 0 0 -3.5-3.5z"/><path d="m7.5 24.5a1 1 0 0 1 -1-1 8.5 8.5 0 0 1 13.6-6.8 1 1 0 1 1 -1.2 1.6 6.44 6.44 0 0 0 -3.9-1.3 6.51 6.51 0 0 0 -6.5 6.5 1 1 0 0 1 -1 1z"/><path d="m23 27a1 1 0 0 1 -1-1v-6a1 1 0 0 1 2 0v6a1 1 0 0 1 -1 1z"/><path d="m26 24h-6a1 1 0 0 1 0-2h6a1 1 0 0 1 0 2z"/></g></svg>

After

Width:  |  Height:  |  Size: 529 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="m216.464 36.465-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>

Before

Width:  |  Height:  |  Size: 408 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M392 320c-13.25 0-24 10.75-24 24v112c0 4.406-3.594 8-8 8H56c-4.406 0-8-3.594-8-8V152c0-4.406 3.594-8 8-8h112c13.25 0 24-10.75 24-24s-10.75-24-24-24H56c-30.875 0-56 25.125-56 56v304c0 30.875 25.125 56 56 56h304c30.875 0 56-25.125 56-56V344c0-13.25-10.75-24-24-24ZM488 0H320c-13.25 0-24 10.75-24 24s10.75 24 24 24h110.062L183.031 295.031c-9.375 9.375-9.375 24.563 0 33.938A23.9 23.9 0 0 0 200 336a23.9 23.9 0 0 0 16.969-7.031L464 81.938V192c0 13.25 10.75 24 24 24s24-10.75 24-24V24c0-13.25-10.75-24-24-24Z"/></svg>

Before

Width:  |  Height:  |  Size: 583 B

View File

@ -1 +0,0 @@
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path d="m457.65 54.783-.441-.441c-14.686-14.656-37.156-16.922-54.325-6.828C359.083 16.393 308.546 0 256 0c-52.545 0-103.082 16.393-146.884 47.515-17.168-10.094-39.635-7.83-54.314 6.817l-.46.46c-14.656 14.689-16.92 37.158-6.827 54.325C16.393 152.918 0 203.455 0 256s16.393 103.082 47.515 146.884c-10.093 17.167-7.828 39.637 6.83 54.328l.446.446c14.622 14.59 37.075 16.971 54.326 6.828C152.919 495.607 203.455 512 256 512c52.546 0 103.082-16.393 146.883-47.514 17.272 10.155 39.721 7.745 54.329-6.831l.445-.445c14.657-14.689 16.922-37.158 6.828-54.326C495.606 359.082 512 308.545 512 256s-16.394-103.081-47.515-146.884c10.094-17.168 7.828-39.638-6.835-54.333zm-42.556 20.915c5.798-5.796 15.184-5.849 20.919-.126l.408.409c5.73 5.743 5.678 15.13-.118 20.925l-74.876 74.875a136.212 136.212 0 0 0-21.209-21.209zM361 256c0 57.897-47.103 105-105 105s-105-47.103-105-105 47.103-105 105-105 105 47.103 105 105zM256 30c44.114 0 86.687 13.18 124.112 38.253l-65.939 65.939C296.548 125.74 276.818 121 256 121s-40.548 4.74-58.174 13.191l-65.938-65.939C169.313 43.18 211.886 30 256 30zM75.572 75.988l.409-.409c5.743-5.73 15.13-5.677 20.926.118l74.875 74.876a136.212 136.212 0 0 0-21.209 21.209L75.697 96.906c-5.795-5.796-5.848-15.183-.125-20.918zM30 256c0-44.113 13.18-86.687 38.253-124.112l65.938 65.939C125.74 215.452 121 235.182 121 256s4.74 40.548 13.191 58.174l-65.938 65.939C43.18 342.687 30 300.113 30 256zm66.906 180.302c-5.796 5.796-15.182 5.849-20.941.103l-.386-.385c-5.73-5.743-5.677-15.13.118-20.926l74.875-74.875a136.168 136.168 0 0 0 21.209 21.209zM256 482c-44.114 0-86.687-13.18-124.112-38.253l65.938-65.939C215.452 386.26 235.182 391 256 391s40.548-4.74 58.174-13.191l65.939 65.939C342.687 468.82 300.114 482 256 482zm180.423-45.983-.404.404c-5.742 5.73-15.128 5.677-20.925-.119l-74.875-74.875a136.168 136.168 0 0 0 21.209-21.209l74.876 74.876c5.795 5.796 5.847 15.183.119 20.923zM482 256c0 44.113-13.18 86.687-38.253 124.112l-65.938-65.938C386.26 296.548 391 276.818 391 256s-4.74-40.548-13.191-58.174l65.938-65.938C468.82 169.314 482 211.887 482 256z"/></svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 1002 B

After

Width:  |  Height:  |  Size: 1002 B

View File

@ -1 +0,0 @@
<svg height="512pt" viewBox="0 -76 512 512" width="512pt" xmlns="http://www.w3.org/2000/svg"><path d="M452 0H60C26.914 0 0 26.914 0 60v240c0 33.086 26.914 60 60 60h392c33.086 0 60-26.914 60-60V60c0-33.086-26.914-60-60-60zM60 40h392c11.027 0 20 8.973 20 20v20H40V60c0-11.027 8.973-20 20-20zm392 280H60c-11.027 0-20-8.973-20-20V120h432v180c0 11.027-8.973 20-20 20zm-10-55c0 13.809-11.191 25-25 25s-25-11.191-25-25 11.191-25 25-25 25 11.191 25 25zm-70 0c0 13.809-11.191 25-25 25s-25-11.191-25-25 11.191-25 25-25 25 11.191 25 25zm0 0"/></svg>

Before

Width:  |  Height:  |  Size: 538 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><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>

Before

Width:  |  Height:  |  Size: 748 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M435.848 83.466 172.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>

Before

Width:  |  Height:  |  Size: 360 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="m441.9 167.3-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>

Before

Width:  |  Height:  |  Size: 271 B

1
assets/clock.svg Normal file
View File

@ -0,0 +1 @@
<svg height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg"><g clip-rule="evenodd"><path d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16zM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12z"/><path d="M11.168 11.445a1 1 0 0 1 1.387-.277l3 2a1 1 0 0 1-1.11 1.664l-3-2a1 1 0 0 1-.277-1.387z"/><path d="M12 6a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V7a1 1 0 0 1 1-1z"/></g></svg>

After

Width:  |  Height:  |  Size: 400 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M234.8 511.7 196 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.1 27.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.4 137.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>

Before

Width:  |  Height:  |  Size: 601 B

1
assets/dashboard.svg Normal file
View File

@ -0,0 +1 @@
<svg height="512pt" viewBox="0 0 512 512" width="512pt" xmlns="http://www.w3.org/2000/svg"><path d="M197.332 170.668h-160C16.746 170.668 0 153.922 0 133.332v-96C0 16.746 16.746 0 37.332 0h160c20.59 0 37.336 16.746 37.336 37.332v96c0 20.59-16.746 37.336-37.336 37.336zM37.332 32A5.336 5.336 0 0 0 32 37.332v96a5.337 5.337 0 0 0 5.332 5.336h160a5.338 5.338 0 0 0 5.336-5.336v-96A5.337 5.337 0 0 0 197.332 32zM197.332 512h-160C16.746 512 0 495.254 0 474.668v-224c0-20.59 16.746-37.336 37.332-37.336h160c20.59 0 37.336 16.746 37.336 37.336v224c0 20.586-16.746 37.332-37.336 37.332zm-160-266.668A5.337 5.337 0 0 0 32 250.668v224A5.336 5.336 0 0 0 37.332 480h160a5.337 5.337 0 0 0 5.336-5.332v-224a5.338 5.338 0 0 0-5.336-5.336zM474.668 512h-160c-20.59 0-37.336-16.746-37.336-37.332v-96c0-20.59 16.746-37.336 37.336-37.336h160c20.586 0 37.332 16.746 37.332 37.336v96C512 495.254 495.254 512 474.668 512zm-160-138.668a5.338 5.338 0 0 0-5.336 5.336v96a5.337 5.337 0 0 0 5.336 5.332h160a5.336 5.336 0 0 0 5.332-5.332v-96a5.337 5.337 0 0 0-5.332-5.336zM474.668 298.668h-160c-20.59 0-37.336-16.746-37.336-37.336v-224C277.332 16.746 294.078 0 314.668 0h160C495.254 0 512 16.746 512 37.332v224c0 20.59-16.746 37.336-37.332 37.336zM314.668 32a5.337 5.337 0 0 0-5.336 5.332v224a5.338 5.338 0 0 0 5.336 5.336h160a5.337 5.337 0 0 0 5.332-5.336v-224A5.336 5.336 0 0 0 474.668 32zm0 0"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 297 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 482 B

View File

@ -1 +0,0 @@
<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-14.4 14.4v33.31a14.4 14.4 0 0 0 14.69 14.4l73.63-2.72 2.06 2.06-278.86 278.87a12 12 0 0 0 0 17l23 23a12 12 0 0 0 17 0l278.86-278.87 2.06 2.06-2.72 73.63a14.4 14.4 0 0 0 14.4 14.69h33.31a14.4 14.4 0 0 0 14.4-14.4L512 14.4A14.4 14.4 0 0 0 497.6 0ZM432 288h-16a16 16 0 0 0-16 16v154a6 6 0 0 1-6 6H54a6 6 0 0 1-6-6V118a6 6 0 0 1 6-6h154a16 16 0 0 0 16-16V80a16 16 0 0 0-16-16H48a48 48 0 0 0-48 48v352a48 48 0 0 0 48 48h352a48 48 0 0 0 48-48V304a16 16 0 0 0-16-16Z"/></svg>

Before

Width:  |  Height:  |  Size: 573 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 492 B

1
assets/lock.svg Normal file
View File

@ -0,0 +1 @@
<svg height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg"><path d="M18.75 9H18V6c0-3.309-2.691-6-6-6S6 2.691 6 6v3h-.75A2.253 2.253 0 0 0 3 11.25v10.5C3 22.991 4.01 24 5.25 24h13.5c1.24 0 2.25-1.009 2.25-2.25v-10.5C21 10.009 19.99 9 18.75 9zM8 6c0-2.206 1.794-4 4-4s4 1.794 4 4v3H8zm5 10.722V19a1 1 0 1 1-2 0v-2.278c-.595-.347-1-.985-1-1.722 0-1.103.897-2 2-2s2 .897 2 2c0 .737-.405 1.375-1 1.722z"/></svg>

After

Width:  |  Height:  |  Size: 433 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="m493.26 56.26-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.57-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>

Before

Width:  |  Height:  |  Size: 578 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 303 B

1
assets/profile.svg Normal file
View File

@ -0,0 +1 @@
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path d="M437.02 74.98C388.668 26.63 324.379 0 256 0S123.332 26.629 74.98 74.98C26.63 123.332 0 187.621 0 256s26.629 132.668 74.98 181.02C123.332 485.37 187.621 512 256 512s132.668-26.629 181.02-74.98C485.37 388.668 512 324.379 512 256s-26.629-132.668-74.98-181.02zM111.105 429.297c8.454-72.735 70.989-128.89 144.895-128.89 38.96 0 75.598 15.179 103.156 42.734 23.281 23.285 37.965 53.687 41.742 86.152C361.641 462.172 311.094 482 256 482s-105.637-19.824-144.895-52.703zM256 269.507c-42.871 0-77.754-34.882-77.754-77.753C178.246 148.879 213.13 114 256 114s77.754 34.879 77.754 77.754c0 42.871-34.883 77.754-77.754 77.754zm170.719 134.427a175.9 175.9 0 0 0-46.352-82.004c-18.437-18.438-40.25-32.27-64.039-40.938 28.598-19.394 47.426-52.16 47.426-89.238C363.754 132.34 315.414 84 256 84s-107.754 48.34-107.754 107.754c0 37.098 18.844 69.875 47.465 89.266-21.887 7.976-42.14 20.308-59.566 36.542-25.235 23.5-42.758 53.465-50.883 86.348C50.852 364.242 30 312.512 30 256 30 131.383 131.383 30 256 30s226 101.383 226 226c0 56.523-20.86 108.266-55.281 147.934zm0 0"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1 +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 205l123.74 123.79a54.68 54.68 0 0 1-38.68 93.34ZM1156.3 1211a54.51 54.51 0 0 1-38.67-16l-123.74-123.79a54.68 54.68 0 1 1 77.34-77.33L1195 1117.65a54.7 54.7 0 0 1-38.7 93.35ZM243.7 1211a54.7 54.7 0 0 1-38.7-93.35l123.74-123.76a54.69 54.69 0 0 1 77.36 77.32L282.37 1195a54.51 54.51 0 0 1-38.67 16ZM1032.57 422.13a54.68 54.68 0 0 1-38.68-93.34L1117.61 205a54.69 54.69 0 0 1 77.39 77.35l-123.77 123.76a54.44 54.44 0 0 1-38.66 16.02ZM229.69 754.69h-175a54.69 54.69 0 0 1 0-109.38h175a54.69 54.69 0 0 1 0 109.38ZM1345.31 754.69h-175a54.69 54.69 0 0 1 0-109.38h175a54.69 54.69 0 0 1 0 109.38ZM700 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 1400ZM700 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>
<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 205l123.74 123.79a54.68 54.68 0 0 1-38.68 93.34ZM1156.3 1211a54.51 54.51 0 0 1-38.67-16l-123.74-123.79a54.68 54.68 0 1 1 77.34-77.33L1195 1117.65a54.7 54.7 0 0 1-38.7 93.35Zm-912.6 0a54.7 54.7 0 0 1-38.7-93.35l123.74-123.76a54.69 54.69 0 0 1 77.36 77.32L282.37 1195a54.51 54.51 0 0 1-38.67 16Zm788.87-788.87a54.68 54.68 0 0 1-38.68-93.34L1117.61 205a54.69 54.69 0 0 1 77.39 77.35l-123.77 123.76a54.44 54.44 0 0 1-38.66 16.02ZM229.69 754.69h-175a54.69 54.69 0 0 1 0-109.38h175a54.69 54.69 0 0 1 0 109.38Zm1115.62 0h-175a54.69 54.69 0 0 1 0-109.38h175a54.69 54.69 0 0 1 0 109.38ZM700 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 1400Zm0-1115.62a54.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>

Before

Width:  |  Height:  |  Size: 989 B

After

Width:  |  Height:  |  Size: 980 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path d="m207.6 256 107.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>

Before

Width:  |  Height:  |  Size: 468 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 370 B

View File

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1652 1652"><g data-name="Layer 2"><path d="M1587.07 504.47A828.56 828.56 0 1 0 1652 826a823.13 823.13 0 0 0-64.93-321.53ZM826 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 888a272.1 272.1 0 0 1-106.19-21.43Zm641.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 751c0 204.86-82.46 390.81-215.91 526.44Z" data-name="Layer 1"/></g></svg>
<svg height="512pt" viewBox="-56 0 512 512" width="512pt" xmlns="http://www.w3.org/2000/svg"><path d="M267 236.375c36.254-22.582 60.434-62.797 60.434-108.563C327.434 57.337 270.098 0 199.62 0 129.145 0 71.81 57.336 71.81 127.813c0 45.765 24.18 85.976 60.43 108.558C55.222 264.071 0 337.84 0 424.273v72.243C0 505.066 6.934 512 15.484 512H383.75c8.55 0 15.48-6.934 15.48-15.484v-72.243c0-86.43-55.218-160.195-132.23-187.898zm101.266 244.656H30.969v-56.758c0-92.992 75.652-168.644 168.648-168.644 92.992 0 168.649 75.652 168.649 168.644zm-71.801-353.219c0 53.403-43.442 96.848-96.844 96.848s-96.844-43.445-96.844-96.847c0-53.399 43.442-96.844 96.844-96.844s96.844 43.445 96.844 96.844zm0 0"/></svg>

Before

Width:  |  Height:  |  Size: 841 B

After

Width:  |  Height:  |  Size: 695 B

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path d="M312.973 375.032c9.369 9.369 9.369 24.572 0 33.941s-24.572 9.369-33.941 0L160 289.941 40.968 408.973c-9.369 9.369-24.572 9.369-33.941 0s-9.369-24.572 0-33.941L126.059 256 7.027 136.968c-9.369-9.369-9.369-24.572 0-33.941s24.572-9.369 33.941 0L160 222.059l119.032-119.032c9.369-9.369 24.572-9.369 33.941 0s9.369 24.572 0 33.941L193.941 256l119.032 119.032Z"/></svg>

Before

Width:  |  Height:  |  Size: 434 B

View File

@ -0,0 +1,51 @@
import { Icon, Button, PopupTrigger, Popup, Tooltip, Text } from 'react-basics';
import { useIntl } from 'react-intl';
import classNames from 'classnames';
import { languages } from 'lib/lang';
import useLocale from 'hooks/useLocale';
import Icons from 'components/icons';
import { labels } from 'components/messages';
import styles from './LanguageButton.module.css';
export default function LanguageButton({ tooltipPosition = 'top' }) {
const { formatMessage } = useIntl();
const { locale, saveLocale } = useLocale();
const items = Object.keys(languages).map(key => ({ ...languages[key], value: key }));
function handleSelect(value) {
saveLocale(value);
}
return (
<PopupTrigger>
<PopupTrigger action="hover">
<Button variant="quiet">
<Icon>
<Icons.Globe />
</Icon>
</Button>
<Tooltip position={tooltipPosition}>{formatMessage(labels.language)}</Tooltip>
</PopupTrigger>
<Popup position="right" alignment="end">
<div className={styles.menu}>
{items.map(({ value, label }) => {
return (
<div
key={value}
className={classNames(styles.item, { [styles.selected]: value === locale })}
onClick={handleSelect.bind(null, value)}
>
<Text>{label}</Text>
{value === locale && (
<Icon className={styles.icon}>
<Icons.Check />
</Icon>
)}
</div>
);
})}
</div>
</Popup>
</PopupTrigger>
);
}

View File

@ -0,0 +1,35 @@
.menu {
display: flex;
flex-flow: row wrap;
min-width: 600px;
max-width: 100vw;
padding: 10px;
background: var(--base50);
z-index: var(--z-index100);
border-radius: 5px;
border: 1px solid var(--border-color);
margin-left: 10px;
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
min-width: calc(100% / 3);
border-radius: 5px;
padding: 5px 10px;
}
.item:hover {
background: var(--base75);
cursor: pointer;
}
.selected {
font-weight: 700;
background: var(--blue100);
}
.icon {
color: var(--primary400);
}

View File

@ -0,0 +1,22 @@
import { Button, Icon, Icons, PopupTrigger, Tooltip } from 'react-basics';
import Link from 'next/link';
import { labels } from 'components/messages';
import { useIntl } from 'react-intl';
export default function LogoutButton({ tooltipPosition = 'top' }) {
const { formatMessage } = useIntl();
return (
<Link href="/logout">
<a>
<PopupTrigger action="hover">
<Button variant="quiet">
<Icon>
<Icons.Logout />
</Icon>
</Button>
<Tooltip position={tooltipPosition}>{formatMessage(labels.logout)}</Tooltip>
</PopupTrigger>
</a>
</Link>
);
}

View File

@ -1,7 +1,7 @@
import { useRef, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import TimezoneSetting from './TimezoneSetting';
import DateRangeSetting from './DateRangeSetting';
import TimezoneSetting from '../pages/settings/profile/TimezoneSetting';
import DateRangeSetting from '../pages/settings/profile/DateRangeSetting';
import { Button, Icon } from 'react-basics';
import styles from './SettingsButton.module.css';
import Gear from 'assets/gear.svg';

View File

@ -0,0 +1,42 @@
import { useTransition, animated } from 'react-spring';
import { Button, Icon, PopupTrigger, Tooltip } from 'react-basics';
import { useIntl } from 'react-intl';
import useTheme from 'hooks/useTheme';
import Icons from 'components/icons';
import { labels } from 'components/messages';
import styles from './ThemeButton.module.css';
export default function ThemeButton({ tooltipPosition = 'top' }) {
const [theme, setTheme] = useTheme();
const { formatMessage } = useIntl();
const transitions = useTransition(theme, {
initial: { opacity: 1 },
from: {
opacity: 0,
transform: `translateY(${theme === 'light' ? '20px' : '-20px'}) scale(0.5)`,
},
enter: { opacity: 1, transform: 'translateY(0px) scale(1.0)' },
leave: {
opacity: 0,
transform: `translateY(${theme === 'light' ? '-20px' : '20px'}) scale(0.5)`,
},
});
function handleClick() {
setTheme(theme === 'light' ? 'dark' : 'light');
}
return (
<PopupTrigger action="hover">
<Button variant="quiet" className={styles.button} onClick={handleClick}>
{transitions((style, item) => (
<animated.div key={item} style={style}>
<Icon className={styles.icon}>{item === 'light' ? <Icons.Sun /> : <Icons.Moon />}</Icon>
</animated.div>
))}
</Button>
<Tooltip position={tooltipPosition}>{formatMessage(labels.theme)}</Tooltip>
</PopupTrigger>
);
}

View File

@ -5,9 +5,11 @@
justify-content: center;
align-items: center;
cursor: pointer;
padding-bottom: 3px;
}
.button svg {
.button > div {
display: flex;
justify-content: center;
align-items: center;
position: absolute;
}

View File

@ -1,4 +1,3 @@
import User from 'assets/user.svg';
import useConfig from 'hooks/useConfig';
import useUser from 'hooks/useUser';
import { AUTH_TOKEN } from 'lib/constants';
@ -7,13 +6,14 @@ import { useRouter } from 'next/router';
import { useRef, useState } from 'react';
import { Button, Icon, Item, Menu, Popup, Text } from 'react-basics';
import { FormattedMessage } from 'react-intl';
import useDocumentClick from 'hooks/useDocumentClick';
import Profile from 'assets/profile.svg';
import styles from './UserButton.module.css';
import useDocumentClick from '../../hooks/useDocumentClick';
export default function UserButton() {
const [show, setShow] = useState(false);
const ref = useRef();
const user = useUser();
const { user } = useUser();
const router = useRouter();
const { adminDisabled } = useConfig();
@ -61,7 +61,7 @@ export default function UserButton() {
<div className={styles.button} ref={ref}>
<Button variant="light" onClick={handleClick}>
<Icon className={styles.icon} size="large">
<User />
<Profile />
</Icon>
</Button>
{show && (

View File

@ -11,7 +11,6 @@
}
.icon svg {
font-size: 16px;
height: 16px;
width: 16px;
}

View File

@ -1,282 +0,0 @@
import { 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, Icon } from 'react-basics';
import { chunkArray } from 'next-basics';
import useLocale from 'hooks/useLocale';
import { dateFormat } from 'lib/date';
import { getDateLocale } from 'lib/lang';
import Chevron from 'assets/chevron-down.svg';
import Cross from 'assets/times.svg';
import styles from './Calendar.module.css';
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} size="small">
{selectMonth ? <Cross /> : <Chevron />}
</Icon>
</div>
<div
className={classNames(styles.selector, { [styles.open]: selectYear })}
onClick={toggleYearSelect}
>
{year}
<Icon className={styles.icon} size="small">
{selectMonth ? <Cross /> : <Chevron />}
</Icon>
</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 dateLocale = getDateLocale(locale);
const weekStartsOn = dateLocale?.options?.weekStartsOn || 0;
const startWeek = startOfWeek(date, {
locale: dateLocale,
weekStartsOn,
});
const startMonth = startOfMonth(date);
const startDay = subDays(startMonth, startMonth.getDay() - weekStartsOn);
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>
{chunkArray(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>
{chunkArray(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
size="small"
onClick={handlePrevClick}
disabled={years[0] <= minYear}
variant="light"
>
<Icon>
<Chevron />
</Icon>
</Button>
</div>
<div className={styles.middle}>
<table>
<tbody>
{chunkArray(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
size="small"
onClick={handleNextClick}
disabled={years[years.length - 1] > maxYear}
variant="light"
>
<Icon>
<Chevron />
</Icon>
</Button>
</div>
</div>
);
};

View File

@ -1,111 +0,0 @@
.calendar {
display: flex;
flex-direction: column;
font-size: var(--font-size-sm);
flex: 1;
min-height: 306px;
}
.calendar table {
width: 100%;
border-spacing: 5px;
}
.calendar td {
color: var(--base800);
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(--base300);
background: var(--base75);
}
.calendar td.faded {
color: var(--base500);
}
.calendar td.selected {
font-weight: 600;
border: 1px solid var(--base600);
}
.calendar td.selected:hover {
background: transparent;
}
.calendar td.disabled {
color: var(--base400);
background: var(--base75);
}
.calendar td.disabled:hover {
cursor: default;
background: var(--base75);
border-color: transparent;
}
.calendar td.faded.disabled {
background: var(--base100);
}
.header {
display: flex;
justify-content: space-evenly;
align-items: center;
font-weight: 700;
line-height: 40px;
font-size: var(--font-size-md);
}
.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);
}
}

View File

@ -1,37 +0,0 @@
import { useState } from 'react';
import PropTypes from 'prop-types';
import { Button } from 'react-basics';
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;

View File

@ -1,99 +1,109 @@
import Calendar from 'assets/calendar-alt.svg';
import DatePickerForm from 'components/forms/DatePickerForm';
import { endOfYear, isSameDay } from 'date-fns';
import { useState } from 'react';
import { Icon, Modal, Dropdown, Item } from 'react-basics';
import { useIntl, defineMessages } from 'react-intl';
import DatePickerForm from 'components/metrics/DatePickerForm';
import useLocale from 'hooks/useLocale';
import { dateFormat } from 'lib/date';
import PropTypes from 'prop-types';
import { useState } from 'react';
import { Icon, Modal } from 'react-basics';
import { FormattedMessage } from 'react-intl';
import DropDown from './DropDown';
import Calendar from 'assets/calendar.svg';
export const filterOptions = [
{ label: <FormattedMessage id="label.today" defaultMessage="Today" />, value: '1day' },
const messages = defineMessages({
today: { id: 'label.today', defaultMessage: 'Today' },
lastHours: { id: 'label.last-hours', defaultMessage: 'Last {x} hours' },
yesterday: { id: 'label.yesterday', defaultMessage: 'Yesterday' },
thisWeek: { id: 'label.this-week', defaultMessage: 'This week' },
lastDays: { id: 'label.last-days', defaultMessage: 'Last {x} days' },
thisMonth: { id: 'label.this-month', defaultMessage: 'This month' },
thisYear: { id: 'label.this-year', defaultMessage: 'This year' },
allTime: { id: 'label.all-time', defaultMessage: 'All time' },
customRange: { id: 'label.custom-range', defaultMessage: 'Custom-range' },
});
function DateFilter({ value, startDate, endDate, onChange, className }) {
const { formatMessage } = useIntl();
const [showPicker, setShowPicker] = useState(false);
const options = [
{ label: formatMessage(messages.today), value: '1day' },
{
label: (
<FormattedMessage id="label.last-hours" defaultMessage="Last {x} hours" values={{ x: 24 }} />
),
label: formatMessage(messages.lastHours, { x: 24 }),
value: '24hour',
},
{
label: <FormattedMessage id="label.yesterday" defaultMessage="Yesterday" />,
label: formatMessage(messages.yesterday),
value: '-1day',
},
{
label: <FormattedMessage id="label.this-week" defaultMessage="This week" />,
label: formatMessage(messages.thisWeek),
value: '1week',
divider: true,
},
{
label: (
<FormattedMessage id="label.last-days" defaultMessage="Last {x} days" values={{ x: 7 }} />
),
label: formatMessage(messages.lastDays, { x: 7 }),
value: '7day',
},
{
label: <FormattedMessage id="label.this-month" defaultMessage="This month" />,
label: formatMessage(messages.thisMonth),
value: '1month',
divider: true,
},
{
label: (
<FormattedMessage id="label.last-days" defaultMessage="Last {x} days" values={{ x: 30 }} />
),
label: formatMessage(messages.lastDays, { x: 30 }),
value: '30day',
},
{
label: (
<FormattedMessage id="label.last-days" defaultMessage="Last {x} days" values={{ x: 90 }} />
),
label: formatMessage(messages.lastDays, { x: 90 }),
value: '90day',
},
{ label: <FormattedMessage id="label.this-year" defaultMessage="This year" />, value: '1year' },
{ label: formatMessage(messages.thisYear), value: '1year' },
{
label: <FormattedMessage id="label.all-time" defaultMessage="All time" />,
label: formatMessage(messages.allTime),
value: 'all',
divider: true,
},
{
label: <FormattedMessage id="label.custom-range" defaultMessage="Custom range" />,
label: formatMessage(messages.customRange),
value: 'custom',
divider: true,
},
];
function DateFilter({ value, startDate, endDate, onChange, className, options }) {
const [showPicker, setShowPicker] = useState(false);
const displayValue =
value === 'custom' ? (
const renderValue = value => {
return value === 'custom' ? (
<CustomRange startDate={startDate} endDate={endDate} onClick={() => handleChange('custom')} />
) : (
value
options.find(e => e.value === value).label
);
};
async function handleChange(value) {
const handleChange = async value => {
if (value === 'custom') {
setShowPicker(true);
return;
}
onChange(value);
}
};
function handlePickerChange(value) {
const handlePickerChange = value => {
setShowPicker(false);
onChange(value);
}
};
const handleClose = () => setShowPicker(false);
return (
<>
<DropDown
<Dropdown
className={className}
value={displayValue}
options={options || filterOptions}
items={options}
renderValue={renderValue}
value={value}
onChange={handleChange}
/>
>
{({ label, value }) => <Item key={value}>{label}</Item>}
</Dropdown>
{showPicker && (
<Modal>
<Modal onClose={handleClose}>
<DatePickerForm
startDate={startDate}
endDate={endDate}
@ -128,12 +138,4 @@ const CustomRange = ({ startDate, endDate, onClick }) => {
);
};
DateFilter.propTypes = {
value: PropTypes.string,
startDate: PropTypes.instanceOf(Date),
endDate: PropTypes.instanceOf(Date),
onChange: PropTypes.func,
className: PropTypes.string,
};
export default DateFilter;

View File

@ -1,66 +0,0 @@
import { 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 'react-basics';
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 className={styles.icon} size="small">
<Chevron />
</Icon>
</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;

View File

@ -1,28 +0,0 @@
.dropdown {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
border: 1px solid var(--base500);
border-radius: 4px;
cursor: pointer;
}
.value {
flex: 1;
display: flex;
justify-content: space-between;
font-size: var(--font-size-sm);
flex-wrap: nowrap;
white-space: nowrap;
padding: 4px 16px;
min-width: 160px;
}
.text {
flex: 1;
}
.icon {
padding-left: 20px;
}

View File

@ -1,25 +1,16 @@
import PropTypes from 'prop-types';
import { Icon, Flexbox } from 'react-basics';
import { Icon, Text, Flexbox } from 'react-basics';
import Logo from 'assets/logo.svg';
import styles from './EmptyPlaceholder.module.css';
function EmptyPlaceholder({ msg, children }) {
function EmptyPlaceholder({ message, children }) {
return (
<div className={styles.placeholder}>
<Icon className={styles.icon} size="xl">
<Flexbox direction="column" alignItems="center" justifyContent="center" gap={60} height={600}>
<Icon size="xl">
<Logo />
</Icon>
<h2 className={styles.msg}>{msg}</h2>
<Flexbox justifyContent="center" alignItems="center">
{children}
<Text size="lg">{message}</Text>
<div>{children}</div>
</Flexbox>
</div>
);
}
EmptyPlaceholder.propTypes = {
msg: PropTypes.node,
children: PropTypes.node,
};
export default EmptyPlaceholder;

View File

@ -1,15 +0,0 @@
.placeholder {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 600px;
}
.icon {
margin-bottom: 30px;
}
.msg {
margin-bottom: 15px;
}

View File

@ -1,15 +1,17 @@
import Exclamation from 'assets/exclamation-triangle.svg';
import { FormattedMessage } from 'react-intl';
import { useIntl } from 'react-intl';
import { Icon, Icons, Text } from 'react-basics';
import { messages } from 'components/messages';
import styles from './ErrorMessage.module.css';
import { Icon } from 'react-basics';
export default function ErrorMessage() {
const { formatMessage } = useIntl();
return (
<div className={styles.error}>
<Icon className={styles.icon} size="large">
<Exclamation />
<Icons.Alert />
</Icon>
<FormattedMessage id="message.failure" defaultMessage="Something went wrong." />
<Text>{formatMessage(messages.error)}</Text>
</div>
);
}

View File

@ -1,8 +1,7 @@
import List from 'assets/list-ul.svg';
import EventDataForm from 'components/forms/EventDataForm';
import EventDataForm from 'components/metrics/EventDataForm';
import PropTypes from 'prop-types';
import { useState } from 'react';
import { Button, Icon, Modal } from 'react-basics';
import { Button, Icon, Modal, Icons } from 'react-basics';
import { FormattedMessage } from 'react-intl';
import styles from './EventDataButton.module.css';
@ -24,18 +23,21 @@ function EventDataButton({ websiteId }) {
<Button
tooltip={<FormattedMessage id="label.event-data" defaultMessage="Event" />}
tooltipId="button-event"
size="small"
size="sm"
onClick={handleClick}
className={styles.button}
>
<Icon>
<List />
<Icons.More />
</Icon>
Event Data
</Button>
{showEventData && (
<Modal title={<FormattedMessage id="label.event-data" defaultMessage="Query Event Data" />}>
<EventDataForm websiteId={websiteId} onClose={handleClose} />
<Modal
title={<FormattedMessage id="label.event-data" defaultMessage="Query Event Data" />}
onClose={handleClose}
>
{close => <EventDataForm websiteId={websiteId} onClose={close} />}
</Modal>
)}
</>

View File

@ -1,3 +1,4 @@
.button {
width: fit-content;
margin-bottom: 15px;
}

View File

@ -2,8 +2,7 @@ import classNames from 'classnames';
import Link from 'next/link';
import { safeDecodeURI } from 'next-basics';
import usePageQuery from 'hooks/usePageQuery';
import External from 'assets/arrow-up-right-from-square.svg';
import { Icon } from 'react-basics';
import { Icon, Icons } from 'react-basics';
import styles from './FilterLink.module.css';
export default function FilterLink({ id, value, label, externalUrl }) {
@ -26,7 +25,7 @@ export default function FilterLink({ id, value, label, externalUrl }) {
{externalUrl && (
<a className={styles.link} href={externalUrl} target="_blank" rel="noreferrer noopener">
<Icon className={styles.icon}>
<External />
<Icons.External />
</Icon>
</a>
)}

View File

@ -1,10 +1,9 @@
import { Button, Icon } from 'react-basics';
import XMark from 'assets/xmark.svg';
import Bars from 'assets/bars.svg';
import { useState } from 'react';
import styles from './HamburgerButton.module.css';
import MobileMenu from './MobileMenu';
import { FormattedMessage } from 'react-intl';
import MobileMenu from './MobileMenu';
import Icons from 'components/icons';
import styles from './HamburgerButton.module.css';
const menuItems = [
{
@ -12,10 +11,13 @@ const menuItems = [
value: '/dashboard',
},
{ label: <FormattedMessage id="label.realtime" defaultMessage="Realtime" />, value: '/realtime' },
{ label: <FormattedMessage id="label.settings" defaultMessage="Settings" />, value: '/settings' },
{
label: <FormattedMessage id="label.settings" defaultMessage="AppLayout" />,
value: '/buttons',
},
{
label: <FormattedMessage id="label.profile" defaultMessage="Profile" />,
value: '/settings/profile',
value: '/buttons/profile',
},
{ label: <FormattedMessage id="label.logout" defaultMessage="Logout" />, value: '/logout' },
];
@ -34,7 +36,7 @@ export default function HamburgerButton() {
return (
<>
<Button className={styles.button} onClick={handleClick}>
<Icon>{active ? <XMark /> : <Bars />}</Icon>
<Icon>{active ? <Icons.Close /> : <Icons.Menu />}</Icon>
</Button>
{active && <MobileMenu items={menuItems} onClose={handleClose} />}
</>

View File

@ -7,8 +7,6 @@
}
.option {
font-size: var(--font-size-sm);
font-weight: normal;
background: var(--base50);
padding: 4px 16px;
cursor: pointer;

View File

@ -1,19 +1,25 @@
import classNames from 'classnames';
import Link from './Link';
import { Button } from 'react-basics';
import XMark from 'assets/xmark.svg';
import Link from 'next/link';
import { Button, Icon } from 'react-basics';
import Icons from 'components/icons';
import styles from './MobileMenu.module.css';
export default function MobileMenu({ items = [], onClose }) {
return (
<div className={classNames(styles.menu, 'container')}>
<div className={classNames(styles.menu)}>
<div className={styles.header}>
<Button icon={<XMark />} onClick={onClose} />
<Button onClick={onClose}>
<Icon>
<Icons.Close />
</Icon>
</Button>
</div>
<div className={styles.items}>
{items.map(({ label, value }) => (
<Link key={value} href={value} className={styles.item} onClick={onClose}>
<Link key={value} href={value}>
<a className={styles.item} onClick={onClose}>
{label}
</a>
</Link>
))}
</div>

View File

@ -1,12 +1,10 @@
import { useState, useEffect, useCallback } from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import useStore from 'store/queries';
import { setDateRange } from 'store/websites';
import { Button, Icon } from 'react-basics';
import Refresh from 'assets/redo.svg';
import Dots from 'assets/ellipsis-h.svg';
import useDateRange from 'hooks/useDateRange';
import Icons from 'components/icons';
function RefreshButton({ websiteId }) {
const [dateRange] = useDateRange(websiteId);
@ -17,7 +15,7 @@ function RefreshButton({ websiteId }) {
function handleClick() {
if (!loading && dateRange) {
setLoading(true);
if (/^[\d]+/.test(dateRange.value)) {
if (/^\d+/.test(dateRange.value)) {
setDateRange(websiteId, dateRange.value);
} else {
setDateRange(websiteId, dateRange);
@ -36,13 +34,11 @@ function RefreshButton({ websiteId }) {
size="small"
onClick={handleClick}
>
<Icon>{loading ? <Dots /> : <Refresh />}</Icon>
<Icon>
<Icons.Refresh />
</Icon>
</Button>
);
}
RefreshButton.propTypes = {
websiteId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
};
export default RefreshButton;

View File

@ -6,7 +6,6 @@
}
.message {
font-size: var(--font-size-sm);
font-weight: 600;
flex: 1;
text-align: center;

View File

@ -1,262 +0,0 @@
import classNames from 'classnames';
import { Button } from 'react-basics';
import DateFilter from 'components/common/DateFilter';
import DropDown from 'components/common/DropDown';
import FormLayout, {
FormButtons,
FormError,
FormMessage,
FormRow,
} from 'components/layout/FormLayout';
import DataTable from 'components/metrics/DataTable';
import FilterTags from 'components/metrics/FilterTags';
import { Field, Form, Formik } from 'formik';
import useApi from 'hooks/useApi';
import useDateRange from 'hooks/useDateRange';
import { useState, useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import styles from './EventDataForm.module.css';
import useTimezone from 'hooks/useTimezone';
export const filterOptions = [
{ label: 'Count', value: 'count' },
{ label: 'Average', value: 'avg' },
{ label: 'Minimum', value: 'min' },
{ label: 'Maximum', value: 'max' },
{ label: 'Sum', value: 'sum' },
];
export const dateOptions = [
{ 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.yesterday" defaultMessage="Yesterday" />,
value: '-1day',
},
{
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,
},
];
export default function EventDataForm({ websiteId, onClose, className }) {
const { post } = useApi();
const [message, setMessage] = useState();
const [columns, setColumns] = useState({});
const [filters, setFilters] = useState({});
const [data, setData] = useState([]);
const [dateRange, setDateRange] = useDateRange('report');
const { startDate, endDate, value } = dateRange;
const [timezone] = useTimezone();
const [isValid, setIsValid] = useState(false);
useEffect(() => {
if (Object.keys(columns).length > 0) {
setIsValid(true);
} else {
setIsValid(false);
}
}, [columns]);
const handleAddTag = (value, list, setState, resetForm) => {
setState({ ...list, [`${value.field}`]: value.value });
resetForm();
};
const handleRemoveTag = (value, list, setState) => {
const newList = { ...list };
delete newList[`${value}`];
setState(newList);
};
const handleSubmit = async () => {
const params = {
website_id: websiteId,
startAt: +startDate,
endAt: +endDate,
timezone,
columns,
filters,
};
const { ok, data } = await post(`/websites/${websiteId}/eventdata`, params);
if (!ok) {
setMessage(<FormattedMessage id="message.failure" defaultMessage="Something went wrong." />);
setData([]);
} else {
setData(data);
setMessage(null);
}
};
return (
<>
<FormMessage>{message}</FormMessage>
<div className={classNames(styles.container, className)}>
<div className={styles.form}>
<FormLayout>
<div className={styles.filters}>
<FormRow>
<label htmlFor="date-range">
<FormattedMessage id="label.date-range" defaultMessage="Date Range" />
</label>
<DateFilter
value={value}
startDate={startDate}
endDate={endDate}
onChange={setDateRange}
options={dateOptions}
/>
</FormRow>
</div>
<div className={styles.filters}>
<Formik
initialValues={{ field: '', value: '' }}
onSubmit={(value, { resetForm }) =>
handleAddTag(value, columns, setColumns, resetForm)
}
>
{({ values, setFieldValue }) => (
<Form>
<FormRow>
<label htmlFor="field">
<FormattedMessage id="label.field-name" defaultMessage="Field Name" />
</label>
<div>
<Field name="field" type="text" />
<FormError name="field" />
</div>
</FormRow>
<FormRow>
<label htmlFor="value">
<FormattedMessage id="label.type" defaultMessage="Type" />
</label>
<div>
<DropDown
value={values.value}
onChange={value => setFieldValue('value', value)}
className={styles.dropdown}
name="value"
options={filterOptions}
/>
<FormError name="value" />
</div>
</FormRow>
<FormButtons className={styles.formButtons}>
<Button
variant="action"
type="submit"
disabled={!values.field || !values.value}
>
<FormattedMessage id="label.add-column" defaultMessage="Add Column" />
</Button>
</FormButtons>
</Form>
)}
</Formik>
<FilterTags
className={styles.filterTag}
params={columns}
onClick={value => handleRemoveTag(value, columns, setColumns)}
/>
</div>
<div className={styles.filters}>
<Formik
initialValues={{ field: '', value: '' }}
onSubmit={(value, { resetForm }) =>
handleAddTag(value, filters, setFilters, resetForm)
}
>
{({ values }) => (
<Form>
<FormRow>
<label htmlFor="field">
<FormattedMessage id="label.field-name" defaultMessage="Field Name" />
</label>
<div>
<Field name="field" type="text" />
<FormError name="field" />
</div>
</FormRow>
<FormRow>
<label htmlFor="value">
<FormattedMessage id="label.value" defaultMessage="Value" />
</label>
<div>
<Field name="value" type="text" />
<FormError name="value" />
</div>
</FormRow>
<FormButtons className={styles.formButtons}>
<Button
variant="action"
type="submit"
disabled={!values.field || !values.value}
>
<FormattedMessage id="label.add-filter" defaultMessage="Add Filter" />
</Button>
</FormButtons>
</Form>
)}
</Formik>
<FilterTags
className={styles.filterTag}
params={filters}
onClick={value => handleRemoveTag(value, filters, setFilters)}
/>
</div>
</FormLayout>
</div>
<div>
<DataTable className={styles.table} data={data} title="Results" showPercentage={false} />
</div>
</div>
<FormButtons>
<Button variant="action" onClick={handleSubmit} disabled={!isValid}>
<FormattedMessage id="label.search" defaultMessage="Search" />
</Button>
<Button onClick={onClose}>
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
</Button>
</FormButtons>
</>
);
}

View File

@ -1,63 +0,0 @@
.form {
display: flex;
flex-direction: column;
gap: 30px;
width: 300px;
margin: 0 auto;
}
.header {
font-size: 24px;
font-weight: 700;
text-align: center;
margin: 30px auto;
}
.info {
text-align: center;
padding: 30px 0;
}
.footer {
display: flex;
flex-direction: column;
gap: 20px;
font-size: 14px;
text-align: center;
margin: 30px auto;
}
.footer a {
font-weight: 600;
}
.buttons {
justify-content: center;
}
.button {
flex: 1;
justify-content: center;
}
.error {
width: 600px;
margin: 0 auto 30px;
background: var(--base50);
padding: 16px;
color: var(--red400);
border: 1px solid var(--red400);
border-radius: 5px;
text-align: center;
}
.success {
width: 600px;
margin: 60px auto;
background: var(--base50);
padding: 16px;
color: var(--green400);
border: 1px solid var(--green400);
border-radius: 5px;
text-align: center;
}

View File

@ -1,23 +0,0 @@
.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;
}

View File

@ -1,35 +0,0 @@
import { useRef } from 'react';
import { Form, FormInput, FormButtons, TextField, Button } from 'react-basics';
import useApi from 'hooks/useApi';
import styles from './Form.module.css';
import { useMutation } from '@tanstack/react-query';
export default function TeamAddForm({ onSave, onClose }) {
const { post } = useApi();
const { mutate, error, isLoading } = useMutation(data => post('/teams', data));
const ref = useRef(null);
const handleSubmit = async data => {
mutate(data, {
onSuccess: async () => {
onSave();
},
});
};
return (
<Form ref={ref} className={styles.form} onSubmit={handleSubmit} error={error}>
<FormInput name="name" label="Name" rules={{ required: 'Required' }}>
<TextField autoComplete="off" />
</FormInput>
<FormButtons flex>
<Button type="submit" variant="primary" disabled={isLoading}>
Save
</Button>
<Button disabled={isLoading} onClick={onClose}>
Cancel
</Button>
</FormButtons>
</Form>
);
}

View File

@ -1,33 +0,0 @@
import { SubmitButton, Form, FormInput, FormRow, FormButtons, TextField } from 'react-basics';
import { useMutation } from '@tanstack/react-query';
import { useRef } from 'react';
import useApi from 'hooks/useApi';
export default function TeamEditForm({ teamId, data, onSave }) {
const { post } = useApi();
const { mutate, error } = useMutation(data => post(`/teams/${teamId}`, data));
const ref = useRef(null);
const handleSubmit = async data => {
mutate(data, {
onSuccess: async () => {
ref.current.reset(data);
onSave(data);
},
});
};
return (
<Form ref={ref} onSubmit={handleSubmit} error={error} values={data}>
<FormRow label="Team ID">
<TextField value={teamId} readOnly allowCopy />
</FormRow>
<FormInput name="name" label="Name" rules={{ required: 'Required' }}>
<TextField />
</FormInput>
<FormButtons>
<SubmitButton variant="primary">Save</SubmitButton>
</FormButtons>
</Form>
);
}

View File

@ -1,23 +0,0 @@
import useConfig from 'hooks/useConfig';
import { useRef } from 'react';
import { Form, FormRow, TextArea } from 'react-basics';
export default function TrackingCodeForm({ websiteId }) {
const ref = useRef(null);
const { trackerScriptName } = useConfig();
const code = `<script async defer src="${trackerScriptName}" data-website-id="${websiteId}"></script>`;
return (
<>
<Form ref={ref}>
<FormRow>
<p>
To track stats for this website, place the following code in the{' '}
<code>&lt;head&gt;</code> section of your HTML.
</p>
<TextArea rows={4} value={code} readOnly allowCopy />
</FormRow>
</Form>
</>
);
}

View File

@ -1,42 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import useApi from 'hooks/useApi';
import { Button, Form, FormButtons, FormInput, SubmitButton, TextField } from 'react-basics';
import styles from './Form.module.css';
const CONFIRM_VALUE = 'DELETE';
export default function UserDeleteForm({ userId, onSave, onClose }) {
const { del } = useApi();
const { mutate, error, isLoading } = useMutation(data => del(`/users/${userId}`, data));
const handleSubmit = async data => {
mutate(data, {
onSuccess: async () => {
onSave();
},
});
};
return (
<Form className={styles.form} onSubmit={handleSubmit} error={error}>
<div>
To delete this user, type <b>{CONFIRM_VALUE}</b> in the box below to confirm.
</div>
<FormInput
name="confirmation"
label="Confirm"
rules={{ validate: value => value === CONFIRM_VALUE }}
>
<TextField autoComplete="off" />
</FormInput>
<FormButtons flex>
<SubmitButton variant="primary" className={styles.button} disabled={isLoading}>
Save
</SubmitButton>
<Button className={styles.button} disabled={isLoading} onClick={onClose}>
Cancel
</Button>
</FormButtons>
</Form>
);
}

View File

@ -1,64 +0,0 @@
import {
Dropdown,
Item,
Form,
FormButtons,
FormInput,
TextField,
SubmitButton,
} from 'react-basics';
import { useRef } from 'react';
import { useMutation } from '@tanstack/react-query';
import useApi from 'hooks/useApi';
import { ROLES } from 'lib/constants';
import styles from './UserForm.module.css';
const items = [
{
value: ROLES.user,
label: 'User',
},
{
value: ROLES.admin,
label: 'Admin',
},
];
export default function UserEditForm({ data, onSave }) {
const { id } = data;
const { post } = useApi();
const { mutate, error } = useMutation(({ username }) => post(`/user/${id}`, { username }));
const ref = useRef(null);
const handleSubmit = async data => {
mutate(data, {
onSuccess: async () => {
onSave(data);
ref.current.reset(data);
},
});
};
return (
<Form
key={id}
className={styles.form}
ref={ref}
onSubmit={handleSubmit}
error={error}
values={data}
>
<FormInput name="username" label="Username">
<TextField />
</FormInput>
<FormInput name="role" label="Role">
<Dropdown items={items} style={{ width: 200 }}>
{({ value, label }) => <Item key={value}>{label}</Item>}
</Dropdown>
</FormInput>
<FormButtons>
<SubmitButton variant="primary">Save</SubmitButton>
</FormButtons>
</Form>
);
}

View File

@ -1,6 +0,0 @@
.form {
display: flex;
flex-direction: column;
gap: 30px;
width: 300px;
}

View File

@ -1,74 +0,0 @@
import { useRef } from 'react';
import { Form, FormInput, FormButtons, PasswordField, Button } from 'react-basics';
import useApi from 'hooks/useApi';
import styles from './UserPasswordForm.module.css';
import useUser from 'hooks/useUser';
export default function UserPasswordForm({ onSave, onClose, userId }) {
const user = useUser();
const isCurrentUser = !userId || user?.id === userId;
const url = isCurrentUser ? `/users/${user?.id}/password` : `/users/${user?.id}`;
const { post, useMutation } = useApi();
const { mutate, error, isLoading } = useMutation(data => post(url, data));
const ref = useRef(null);
const handleSubmit = async data => {
const payload = isCurrentUser
? data
: {
password: data.newPassword,
};
mutate(payload, {
onSuccess: async () => {
onSave();
ref.current.reset();
},
});
};
const samePassword = value => {
if (value !== ref?.current?.getValues('newPassword')) {
return "Passwords don't match";
}
return true;
};
return (
<Form ref={ref} className={styles.form} onSubmit={handleSubmit} error={error}>
{isCurrentUser && (
<FormInput name="currentPassword" label="Current password" rules={{ required: 'Required' }}>
<PasswordField autoComplete="off" />
</FormInput>
)}
<FormInput
name="newPassword"
label="New password"
rules={{
required: 'Required',
minLength: { value: 8, message: 'Minimum length 8 characters' },
}}
>
<PasswordField autoComplete="off" />
</FormInput>
<FormInput
name="confirmPassword"
label="Confirm password"
rules={{
required: 'Required',
minLength: { value: 8, message: 'Minimum length 8 characters' },
validate: samePassword,
}}
>
<PasswordField autoComplete="off" />
</FormInput>
<FormButtons flex>
<Button type="submit" variant="primary" disabled={isLoading}>
Save
</Button>
<Button onClick={onClose}>Close</Button>
</FormButtons>
</Form>
);
}

View File

@ -1,6 +0,0 @@
.form {
display: flex;
flex-direction: column;
gap: 30px;
width: 300px;
}

View File

@ -1,46 +0,0 @@
import { useRef } from 'react';
import { Form, FormInput, FormButtons, TextField, Button, SubmitButton } from 'react-basics';
import useApi from 'hooks/useApi';
import styles from './Form.module.css';
import { useMutation } from '@tanstack/react-query';
import { DOMAIN_REGEX } from 'lib/constants';
export default function WebsiteAddForm({ onSave, onClose }) {
const { post } = useApi();
const { mutate, error, isLoading } = useMutation(data => post('/websites', data));
const ref = useRef(null);
const handleSubmit = async data => {
mutate(data, {
onSuccess: async () => {
onSave();
},
});
};
return (
<Form ref={ref} className={styles.form} onSubmit={handleSubmit} error={error}>
<FormInput name="name" label="Name" rules={{ required: 'Required' }}>
<TextField autoComplete="off" />
</FormInput>
<FormInput
name="domain"
label="Domain"
rules={{
required: 'Required',
pattern: { value: DOMAIN_REGEX, message: 'Invalid domain' },
}}
>
<TextField autoComplete="off" />
</FormInput>
<FormButtons flex>
<SubmitButton variant="primary" disabled={false}>
Save
</SubmitButton>
<Button disabled={isLoading} onClick={onClose}>
Cancel
</Button>
</FormButtons>
</Form>
);
}

View File

@ -1,42 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import useApi from 'hooks/useApi';
import { Button, Form, FormButtons, FormInput, SubmitButton, TextField } from 'react-basics';
import styles from './Form.module.css';
const CONFIRM_VALUE = 'DELETE';
export default function WebsiteDeleteForm({ websiteId, onSave, onClose }) {
const { del } = useApi();
const { mutate, error, isLoading } = useMutation(data => del(`/websites/${websiteId}`, data));
const handleSubmit = async data => {
mutate(data, {
onSuccess: async () => {
onSave();
},
});
};
return (
<Form className={styles.form} onSubmit={handleSubmit} error={error}>
<div>
To delete this website, type <b>{CONFIRM_VALUE}</b> in the box below to confirm.
</div>
<FormInput
name="confirmation"
label="Confirm"
rules={{ validate: value => value === CONFIRM_VALUE }}
>
<TextField autoComplete="off" />
</FormInput>
<FormButtons flex>
<SubmitButton variant="primary" className={styles.button} disabled={isLoading}>
Save
</SubmitButton>
<Button className={styles.button} disabled={isLoading} onClick={onClose}>
Cancel
</Button>
</FormButtons>
</Form>
);
}

View File

@ -1,47 +0,0 @@
import { SubmitButton, Form, FormInput, FormRow, FormButtons, TextField } from 'react-basics';
import { useMutation } from '@tanstack/react-query';
import { useRef } from 'react';
import useApi from 'hooks/useApi';
import { DOMAIN_REGEX } from 'lib/constants';
export default function WebsiteEditForm({ websiteId, data, onSave }) {
const { post } = useApi();
const { mutate, error } = useMutation(data => post(`/websites/${websiteId}`, data));
const ref = useRef(null);
const handleSubmit = async data => {
mutate(data, {
onSuccess: async () => {
ref.current.reset(data);
onSave(data);
},
});
};
return (
<Form ref={ref} onSubmit={handleSubmit} error={error} values={data}>
<FormRow label="Website ID">
<TextField value={websiteId} readOnly allowCopy />
</FormRow>
<FormInput name="name" label="Name" rules={{ required: 'Required' }}>
<TextField />
</FormInput>
<FormInput
name="domain"
label="Domain"
rules={{
required: 'Required',
pattern: {
value: DOMAIN_REGEX,
message: 'Invalid domain',
},
}}
>
<TextField />
</FormInput>
<FormButtons>
<SubmitButton variant="primary">Save</SubmitButton>
</FormButtons>
</Form>
);
}

View File

@ -1,49 +0,0 @@
import WebsiteDeleteForm from 'components/forms/WebsiteDeleteForm';
import WebsiteResetForm from 'components/forms/WebsiteResetForm';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { Button, Form, FormRow, Modal } from 'react-basics';
export default function WebsiteReset({ websiteId, onSave }) {
const [modal, setModal] = useState(null);
const router = useRouter();
const handleReset = async () => {
setModal(null);
onSave();
};
const handleDelete = async () => {
onSave();
await router.push('/websites');
};
const handleClose = () => setModal(null);
return (
<Form>
<FormRow label="Reset website">
<p>
All statistics for this website will be deleted, but your settings will remain intact.
</p>
<Button onClick={() => setModal('reset')}>Reset</Button>
</FormRow>
<FormRow label="Delete website">
<p>All website data will be deleted.</p>
<Button onClick={() => setModal('delete')}>Delete</Button>
</FormRow>
{modal === 'reset' && (
<Modal title="Reset website" onClose={handleClose}>
{close => <WebsiteResetForm websiteId={websiteId} onSave={handleReset} onClose={close} />}
</Modal>
)}
{modal === 'delete' && (
<Modal title="Delete website" onClose={handleClose}>
{close => (
<WebsiteDeleteForm websiteId={websiteId} onSave={handleDelete} onClose={close} />
)}
</Modal>
)}
</Form>
);
}

View File

@ -1,44 +0,0 @@
import { useMutation } from '@tanstack/react-query';
import useApi from 'hooks/useApi';
import { Button, Form, FormButtons, FormInput, SubmitButton, TextField } from 'react-basics';
import styles from './Form.module.css';
const CONFIRM_VALUE = 'RESET';
export default function WebsiteResetForm({ websiteId, onSave, onClose }) {
const { post } = useApi();
const { mutate, error, isLoading } = useMutation(data =>
post(`/websites/${websiteId}/reset`, data),
);
const handleSubmit = async data => {
mutate(data, {
onSuccess: async () => {
onSave();
},
});
};
return (
<Form className={styles.form} onSubmit={handleSubmit} error={error}>
<div>
To reset this website, type <b>{CONFIRM_VALUE}</b> in the box below to confirm.
</div>
<FormInput
name="confirm"
label="Confirmation"
rules={{ validate: value => value === CONFIRM_VALUE }}
>
<TextField autoComplete="off" />
</FormInput>
<FormButtons flex>
<SubmitButton variant="primary" className={styles.button} disabled={isLoading}>
Save
</SubmitButton>
<Button className={styles.button} disabled={isLoading} onClick={onClose}>
Cancel
</Button>
</FormButtons>
</Form>
);
}

35
components/icons.js Normal file
View File

@ -0,0 +1,35 @@
import { Icons } from 'react-basics';
import AddUser from 'assets/add-user.svg';
import Bolt from 'assets/bolt.svg';
import Calendar from 'assets/calendar.svg';
import Clock from 'assets/clock.svg';
import Dashboard from 'assets/dashboard.svg';
import Gear from 'assets/gear.svg';
import Globe from 'assets/globe.svg';
import Lock from 'assets/lock.svg';
import Logo from 'assets/logo.svg';
import Moon from 'assets/moon.svg';
import Profile from 'assets/profile.svg';
import Sun from 'assets/sun.svg';
import User from 'assets/user.svg';
import Users from 'assets/users.svg';
const icons = {
...Icons,
AddUser,
Bolt,
Calendar,
Clock,
Dashboard,
Gear,
Globe,
Lock,
Logo,
Moon,
Profile,
Sun,
User,
Users,
};
export default icons;

View File

@ -0,0 +1,29 @@
import { Container } from 'react-basics';
import Head from 'next/head';
import NavBar from 'components/layout/NavBar';
import useRequireLogin from 'hooks/useRequireLogin';
import styles from './AppLayout.module.css';
export default function AppLayout({ title, children }) {
const { user } = useRequireLogin();
if (!user) {
return null;
}
return (
<div className={styles.layout}>
<Head>
<title>{title ? `${title} | umami` : 'umami'}</title>
</Head>
<div className={styles.nav}>
<NavBar />
</div>
<div className={styles.body}>
<Container>
<main>{children}</main>
</Container>
</div>
</div>
);
}

View File

@ -0,0 +1,16 @@
.layout {
display: grid;
grid-template-rows: 1fr;
grid-template-columns: minmax(60px, 200px) 1fr;
height: 100vh;
overflow: hidden;
}
.nav {
grid-row: 1 / 3;
}
.body {
grid-area: 1 / 2;
overflow: auto;
}

View File

@ -1,33 +1,38 @@
import { Row, Column } from 'react-basics';
import { useRouter } from 'next/router';
import Script from 'next/script';
import classNames from 'classnames';
import { FormattedMessage } from 'react-intl';
import { useIntl, defineMessages } from 'react-intl';
import Link from 'components/common/Link';
import styles from './Footer.module.css';
import { CURRENT_VERSION, HOMEPAGE_URL, REPO_URL } from 'lib/constants';
import styles from './Footer.module.css';
export default function Footer() {
const messages = defineMessages({
poweredBy: { id: 'message.powered-by', defaultMessage: 'Powered by {name}' },
});
export default function Footer({ className }) {
const { pathname } = useRouter();
const { formatMessage } = useIntl();
return (
<footer className={classNames(styles.footer, 'row')}>
<div className="col-12 col-md-4" />
<div className="col-12 col-md-4">
<FormattedMessage
id="message.powered-by"
defaultMessage="Powered by {name}"
values={{
<footer className={classNames(styles.footer, className)}>
<Row>
<Column>
<div>
{formatMessage(messages.poweredBy, {
name: (
<Link href={HOMEPAGE_URL}>
<b>umami</b>
</Link>
),
}}
/>
})}
</div>
<div className={classNames(styles.version, 'col-12 col-md-4')}>
</Column>
<Column className={styles.version}>
<Link href={REPO_URL}>{`v${CURRENT_VERSION}`}</Link>
</div>
</Column>
</Row>
{!pathname.includes('/share/') && <Script src={`/telemetry.js`} />}
</footer>
);

View File

@ -54,7 +54,6 @@
.msg {
color: var(--msgColor);
background: var(--red400);
font-size: var(--font-size-sm);
padding: 4px 8px;
border-radius: 4px;
white-space: nowrap;

View File

@ -1,21 +1,18 @@
import Logo from 'assets/logo.svg';
import HamburgerButton from 'components/common/HamburgerButton';
import Link from 'components/common/Link';
import UpdateNotice from 'components/common/UpdateNotice';
import LanguageButton from 'components/settings/LanguageButton';
import ThemeButton from 'components/settings/ThemeButton';
import UserButton from 'components/settings/UserButton';
import LanguageButton from 'components/buttons/LanguageButton';
import ThemeButton from 'components/buttons/ThemeButton';
import UserButton from 'components/buttons/UserButton';
import useConfig from 'hooks/useConfig';
import useUser from 'hooks/useUser';
import { HOMEPAGE_URL } from 'lib/constants';
import { useRouter } from 'next/router';
import { Column, Icon, Row } from 'react-basics';
import { FormattedMessage } from 'react-intl';
import SettingsButton from '../settings/SettingsButton';
import { Column, Row } from 'react-basics';
import SettingsButton from '../buttons/SettingsButton';
import styles from './Header.module.css';
import classNames from 'classnames';
export default function Header() {
const user = useUser();
export default function Header({ className }) {
const { user } = useUser();
const { pathname } = useRouter();
const { updatesDisabled, adminDisabled } = useConfig();
const isSharePage = pathname.includes('/share/');
@ -24,28 +21,10 @@ export default function Header() {
return (
<>
{allowUpdate && <UpdateNotice />}
<header className={styles.header}>
<header className={classNames(styles.header, className)}>
<Row>
<Column className={styles.title}>
<Icon size="lg" className={styles.logo}>
<Logo />
</Icon>
<Link href={isSharePage ? HOMEPAGE_URL : '/'}>umami</Link>
</Column>
<Column className={styles.title}></Column>
<HamburgerButton />
{user && !adminDisabled && (
<div className={styles.links}>
<Link href="/dashboard">
<FormattedMessage id="label.dashboard" defaultMessage="Dashboard" />
</Link>
<Link href="/realtime">
<FormattedMessage id="label.realtime" defaultMessage="Realtime" />
</Link>
<Link href="/websites">
<FormattedMessage id="label.settings" defaultMessage="Settings" />
</Link>
</div>
)}
<Column className={styles.buttons}>
<ThemeButton />
<LanguageButton menuAlign="right" />

View File

@ -1,13 +1,15 @@
.header {
display: flex;
align-items: center;
min-height: 100px;
width: 100%;
height: 50px;
border-bottom: 1px solid var(--base300);
}
.title {
flex: 1;
font-size: var(--font-size-lg);
font-weight: 700;
display: flex;
align-items: center;
flex-direction: row;

View File

@ -1,32 +0,0 @@
import { Container } from 'react-basics';
import Head from 'next/head';
import Header from 'components/layout/Header';
import Footer from 'components/layout/Footer';
import useLocale from 'hooks/useLocale';
import { useRouter } from 'next/router';
export default function Layout({ title, children, header = true, footer = true }) {
const { dir } = useLocale();
const { basePath } = useRouter();
return (
<Container dir={dir} style={{ maxWidth: 1140 }}>
<Head>
<title>{title ? `${title} | umami` : 'umami'}</title>
<link rel="icon" href={`${basePath}/favicon.ico`} />
<link rel="apple-touch-icon" sizes="180x180" href={`${basePath}/apple-touch-icon.png`} />
<link rel="icon" type="image/png" sizes="32x32" href={`${basePath}/favicon-32x32.png`} />
<link rel="icon" type="image/png" sizes="16x16" href={`${basePath}/favicon-16x16.png`} />
<link rel="manifest" href={`${basePath}/site.webmanifest`} />
<link rel="mask-icon" href={`${basePath}/safari-pinned-tab.svg`} color="#5bbad5" />
<meta name="msapplication-TileColor" content="#da532c" />
<meta name="theme-color" content="#fafafa" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#2f2f2f" media="(prefers-color-scheme: dark)" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</Head>
{header && <Header />}
<main>{children}</main>
{footer && <Footer />}
</Container>
);
}

View File

@ -1,6 +0,0 @@
.layout {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}

View File

@ -0,0 +1,61 @@
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { Icon, Text } from 'react-basics';
import classNames from 'classnames';
import Icons from 'components/icons';
import ThemeButton from 'components/buttons/ThemeButton';
import LanguageButton from 'components/buttons/LanguageButton';
import LogoutButton from 'components/buttons/LogoutButton';
import { labels } from 'components/messages';
import useUser from 'hooks/useUser';
import NavGroup from './NavGroup';
import styles from './NavBar.module.css';
export default function NavBar() {
const { user } = useUser();
const { formatMessage } = useIntl();
const [minimized, setMinimized] = useState(false);
const tooltipPosition = minimized ? 'right' : 'top';
const analytics = [
{ label: formatMessage(labels.dashboard), url: '/dashboard', icon: <Icons.Dashboard /> },
{ label: formatMessage(labels.realtime), url: '/realtime', icon: <Icons.Clock /> },
{ label: formatMessage(labels.queries), url: '/queries', icon: <Icons.Search /> },
];
const settings = [
{ label: formatMessage(labels.websites), url: '/settings/websites', icon: <Icons.Globe /> },
user?.isAdmin && {
label: formatMessage(labels.users),
url: '/settings/users',
icon: <Icons.User />,
},
{ label: formatMessage(labels.teams), url: '/settings/teams', icon: <Icons.Users /> },
{ label: formatMessage(labels.profile), url: '/settings/profile', icon: <Icons.Profile /> },
].filter(n => n);
const handleMinimize = () => setMinimized(state => !state);
return (
<div className={classNames(styles.navbar, { [styles.minimized]: minimized })}>
<div className={styles.header} onClick={handleMinimize}>
<Icon size="lg">
<Icons.Logo />
</Icon>
<Text className={styles.text}>umami</Text>
<Icon size="sm" rotate={minimized ? -90 : 90} className={styles.icon}>
<Icons.ChevronDown />
</Icon>
</div>
<NavGroup title={formatMessage(labels.analytics)} items={analytics} minimized={minimized} />
<NavGroup title={formatMessage(labels.settings)} items={settings} minimized={minimized} />
<div className={styles.footer}>
<div className={styles.buttons}>
<ThemeButton tooltipPosition={tooltipPosition} />
<LanguageButton tooltipPosition={tooltipPosition} />
<LogoutButton tooltipPosition={tooltipPosition} />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,57 @@
.navbar {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
background: var(--base75);
height: 100%;
width: 200px;
border-right: 2px solid var(--base200);
}
.header {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-size: 16px;
font-weight: 700;
padding: 20px 0;
cursor: pointer;
}
.header:hover .icon {
visibility: visible;
}
.icon {
position: absolute;
right: 0;
visibility: hidden;
}
.minimized.navbar {
width: 60px;
}
.minimized .text {
display: none;
}
.footer {
flex: 1;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 20px;
}
.buttons {
display: flex;
align-items: center;
justify-content: center;
}
.minimized .buttons {
flex-direction: column;
}

View File

@ -0,0 +1,54 @@
import { useState } from 'react';
import { Icon, Text, Icons } from 'react-basics';
import classNames from 'classnames';
import { useRouter } from 'next/router';
import Link from 'next/link';
import styles from './NavGroup.module.css';
export default function NavGroup({
title,
items,
defaultExpanded = true,
allowExpand = true,
minimized = false,
}) {
const { pathname } = useRouter();
const [expanded, setExpanded] = useState(defaultExpanded);
const handleExpand = () => setExpanded(state => !state);
return (
<div
className={classNames(styles.group, {
[styles.expanded]: expanded,
[styles.minimized]: minimized,
})}
>
{title && (
<div className={styles.header} onClick={allowExpand ? handleExpand : undefined}>
<Text>{title}</Text>
<Icon size="sm" rotate={expanded ? 0 : -90}>
<Icons.ChevronDown />
</Icon>
</div>
)}
<div className={styles.body}>
{items.map(({ label, url, icon, divider }) => {
return (
<Link key={label} href={url}>
<a
className={classNames(styles.item, {
[styles.divider]: divider,
[styles.selected]: pathname.startsWith(url),
})}
>
<Icon>{icon}</Icon>
<Text className={styles.text}>{label}</Text>
</a>
</Link>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,93 @@
.group {
display: flex;
flex-direction: column;
width: 100%;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
color: var(--base600);
font-size: 11px;
font-weight: 600;
padding: 10px 20px;
text-transform: uppercase;
cursor: pointer;
}
.body {
display: none;
}
.expanded .body {
display: block;
}
.items {
position: relative;
display: flex;
flex-direction: column;
gap: 20px;
margin-right: -2px;
width: 200px;
}
.item {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
border-right: 2px solid var(--base200);
padding: 1rem 2rem;
gap: var(--size500);
font-weight: 600;
width: 200px;
}
a.item {
color: var(--base700);
}
.item.selected {
color: var(--base900);
border-right-color: var(--primary400);
background: var(--blue100);
}
.item:hover {
color: var(--base900);
}
.item.disabled {
color: var(--base500) !important;
pointer-events: none;
}
.minimized .text,
.minimized .header {
display: none;
}
.minimized .item {
width: 60px;
padding: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.divider:before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
margin: auto;
border-top: 1px solid var(--base300);
width: 160px;
}
.minimized .divider:before {
width: 60px;
}

View File

@ -8,7 +8,7 @@ export default function Page({ className, error, loading, children }) {
}
if (loading) {
return <Loading />;
return <Loading icon="spinner" size="xl" position="page" />;
}
return <div className={classNames(styles.page, className)}>{children}</div>;

View File

@ -1,23 +1,11 @@
import Link from 'next/link';
import React from 'react';
import classNames from 'classnames';
import { Button, Icon } from 'react-basics';
import styles from './PageHeader.module.css';
export default function PageHeader({ title, backUrl, children, className, style }) {
export default function PageHeader({ title, children, className, style }) {
return (
<div className={classNames(styles.header, className)} style={style}>
<div className={styles.title}>
{backUrl && (
<Link href={backUrl}>
<a>
<Button>
<Icon icon="arrow-left" /> Back
</Button>
</a>
</Link>
)}
{title}
</div>
<div className={styles.title}>{title}</div>
{children}
</div>
);

View File

@ -5,8 +5,6 @@
align-content: center;
align-self: stretch;
margin-bottom: 40px;
font-size: 18px;
font-weight: bold;
height: 50px;
}
@ -21,5 +19,7 @@
.title {
display: flex;
align-items: center;
font-size: 18px;
font-weight: bold;
gap: 20px;
}

View File

@ -2,6 +2,136 @@ import { defineMessages } from 'react-intl';
export const labels = defineMessages({
unknown: { id: 'label.unknown', defaultMessage: 'Unknown' },
required: { id: 'label.required', defaultMessage: 'Required' },
save: { id: 'label.save', defaultMessage: 'Save' },
cancel: { id: 'label.cancel', defaultMessage: 'Cancel' },
continue: { id: 'label.continue', defaultMessage: 'Continue' },
delete: { id: 'label.delete', defaultMessage: 'Delete' },
users: { id: 'label.users', defaultMessage: 'Users' },
createUser: { id: 'label.create-user', defaultMessage: 'Create user' },
username: { id: 'label.username', defaultMessage: 'Username' },
password: { id: 'label.password', defaultMessage: 'Password' },
role: { id: 'label.role', defaultMessage: 'Role' },
user: { id: 'label.user', defaultMessage: 'User' },
admin: { id: 'label.admin', defaultMessage: 'Admin' },
confirm: { id: 'label.confirm', defaultMessage: 'Confirm' },
details: { id: 'label.details', defaultMessage: 'Details' },
websites: { id: 'label.websites', defaultMessage: 'Websites' },
created: { id: 'label.created', defaultMessage: 'Created' },
edit: { id: 'label.edit', defaultMessage: 'Edit' },
name: { id: 'label.name', defaultMessage: 'Name' },
members: { id: 'label.members', defaultMessage: 'Members' },
accessCode: { id: 'label.access-code', defaultMessage: 'Access code' },
teamId: { id: 'label.team-id', defaultMessage: 'Team ID' },
team: { id: 'label.team', defaultMessage: 'Team' },
regenerate: { id: 'label.regenerate', defaultMessage: 'Regenerate' },
remove: { id: 'label.remove', defaultMessage: 'Remove' },
join: { id: 'label.join', defaultMessage: 'Join' },
createTeam: { id: 'label.create-team', defaultMessage: 'Create team' },
joinTeam: { id: 'label.join-team', defaultMessage: 'Join team' },
settings: { id: 'label.settings', defaultMessage: 'Settings' },
owner: { id: 'label.owner', defaultMessage: 'Owner' },
teamOwner: { id: 'label.team-owner', defaultMessage: 'Team owner' },
teamMember: { id: 'label.team-member', defaultMessage: 'Team member' },
teamGuest: { id: 'label.team-guest', defaultMessage: 'Team guest' },
enableShareUrl: { id: 'label.enable-share-url', defaultMessage: 'Enable share URL' },
data: { id: 'label.data', defaultMessage: 'Data' },
trackingCode: { id: 'label.tracking-code', defaultMessage: 'Tracking code' },
shareUrl: { id: 'label.share-url', defaultMessage: 'Share URL' },
actions: { id: 'label.actions', defaultMessage: 'Actions' },
view: { id: 'label.view', defaultMessage: 'View' },
domain: { id: 'label.domain', defaultMessage: 'Domain' },
websiteId: { id: 'label.website-id', defaultMessage: 'Website ID' },
resetWebsite: { id: 'label.reset-website', defaultMessage: 'Reset website' },
deleteWebsite: { id: 'label.delete-website', defaultMessage: 'Delete website' },
reset: { id: 'label.reset', defaultMessage: 'Reset' },
addWebsite: { id: 'label.add-website', defaultMessage: 'Add website' },
changePassword: { id: 'label.change-password', defaultMessage: 'Change password' },
currentPassword: { id: 'label.current-password', defaultMessage: 'Current password' },
newPassword: { id: 'label.new-password', defaultMessage: 'New password' },
confirmPassword: { id: 'label.confirm-password', defaultMessage: 'Confirm password' },
timezone: { id: 'label.timezone', defaultMessage: 'Timezone' },
defaultDateRange: { id: 'label.default-date-range', defaultMessage: 'Default date range' },
language: { id: 'label.language', defaultMessage: 'Language' },
theme: { id: 'label.theme', defaultMessage: 'Theme' },
profile: { id: 'label.profile', defaultMessage: 'Profile' },
dashboard: { id: 'label.dashboard', defaultMessage: 'Dashboard' },
more: { id: 'label.more', defaultMessage: 'More' },
realtime: { id: 'label.realtime', defaultMessage: 'Realtime' },
queries: { id: 'label.queries', defaultMessage: 'Queries' },
teams: { id: 'label.teams', defaultMessage: 'Teams' },
analytics: { id: 'label.analytics', defaultMessage: 'Analytics' },
logout: { id: 'label.logout', defaultMessage: 'Logout' },
singleDay: { id: 'label.single-day', defaultMessage: 'Single day' },
dateRange: { id: 'label.date-range', defaultMessage: 'Date range' },
viewDetails: { id: 'label.view-details', defaultMessage: 'View details' },
});
export const messages = defineMessages({
error: { id: 'message.error', defaultMessage: 'Something went wrong.' },
saved: { id: 'message.saved', defaultMessage: 'Saved.' },
noUsers: { id: 'message.no-users', defaultMessage: 'There are no users.' },
userDeleted: { id: 'message.user-deleted', defaultMessage: 'User deleted.' },
noData: { id: 'message.no-data', defaultMessage: 'No data available.' },
deleteUserWarning: {
id: 'message.delete-user-warning',
defaultMessage: 'Are you sure you want to delete the user {username}?',
},
minPasswordLength: {
id: 'message.min-password-length',
defaultMessage: 'Minimum length of 8 characters',
},
noTeams: {
id: 'message.no-teams',
defaultMessage: 'You have not created any teams.',
},
shareUrl: {
id: 'message.share-url',
defaultMessage: 'Your website stats are publically available at the following URL:',
},
trackingCode: {
id: 'message.tracking-code',
defaultMessage:
'To track stats for this website, place the following code in the <head>...</head> section of your HTML.',
},
deleteWebsite: {
id: 'message.delete-website',
defaultMessage: 'To delete this website, type {confirmation} in the box below to confirm.',
},
resetWebsite: {
id: 'message.reset-website',
defaultMessage: 'To reset this website, type {confirmation} in the box below to confirm.',
},
invalidDomain: {
id: 'message.invalid-domain',
defaultMessage: 'Invalid domain. Do not include http/https.',
},
resetWebsiteWarning: {
id: 'message.reset-website-warning',
defaultMessage:
'All statistics for this website will be deleted, but your settings will remain intact.',
},
deleteWebsiteWarning: {
id: 'message.delete-website-warning',
defaultMessage: 'All website data will be deleted.',
},
noWebsites: {
id: 'messages.no-websites',
defaultMessage: 'You do not have any websites configured.',
},
noMatchPassword: { id: 'message.no-match-password', defaultMessage: 'Passwords do not match.' },
goToSettings: {
id: 'message.go-to-settings',
defaultMessage: 'Go to settings',
},
activeUsers: {
id: 'message.active-users',
defaultMessage: '{x} current {x, plural, one {visitor} other {visitors}}',
},
teamNotFound: {
id: 'message.team-not-found',
defaultMessage: 'Team not found.',
},
});
export const devices = defineMessages({
@ -11,6 +141,12 @@ export const devices = defineMessages({
mobile: { id: 'metrics.device.mobile', defaultMessage: 'Mobile' },
});
export function getMessage(id, formatMessage) {
const message = Object.values(messages).find(value => value.id === id);
return message ? formatMessage(message) : id;
}
export function getDeviceMessage(device) {
return devices[device] || labels.unknown;
}

View File

@ -1,16 +1,20 @@
import { useMemo } from 'react';
import { StatusLight } from 'react-basics';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { useIntl } from 'react-intl';
import useApi from 'hooks/useApi';
import { messages } from 'components/messages';
import styles from './ActiveUsers.module.css';
export default function ActiveUsers({ websiteId, className, value, refetchInterval = 60000 }) {
const url = websiteId ? `/websites/${websiteId}/active` : null;
export default function ActiveUsers({ websiteId, value, refetchInterval = 60000 }) {
const { formatMessage } = useIntl();
const { get, useQuery } = useApi();
const { data } = useQuery(['websites:active', websiteId], () => get(url), {
const { data } = useQuery(
['websites:active', websiteId],
() => get(`/websites/${websiteId}/active`),
{
refetchInterval,
});
},
);
const count = useMemo(() => {
if (websiteId) {
@ -25,17 +29,8 @@ export default function ActiveUsers({ websiteId, className, value, refetchInterv
}
return (
<div className={classNames(styles.container, className)}>
<StatusLight variant="success" />
<div className={styles.text}>
<div>
<FormattedMessage
id="message.active-users"
defaultMessage="{x} current {x, plural, one {visitor} other {visitors}}"
values={{ x: count }}
/>
</div>
</div>
</div>
<StatusLight variant="success">
<div className={styles.text}>{formatMessage(messages.activeUsers, { x: count })}</div>
</StatusLight>
);
}

View File

@ -5,7 +5,6 @@
.text {
display: flex;
font-size: var(--font-size-md);
}
.value {

View File

@ -15,8 +15,9 @@ export default function ChartTooltip({ chartId, tooltip }) {
<div className={styles.content}>
<div className={styles.title}>{title}</div>
<div className={styles.metric}>
<StatusLight color={labelColor} />
<StatusLight color={labelColor}>
{value} {label}
</StatusLight>
</div>
</div>
</div>

View File

@ -1,7 +1,6 @@
.table {
position: relative;
height: 100%;
font-size: var(--font-size-sm);
display: grid;
grid-template-rows: fit-content(100%) auto;
overflow: hidden;
@ -23,11 +22,9 @@
.title {
display: flex;
font-weight: 600;
font-size: var(--font-size-md);
}
.metric {
font-size: var(--font-size-sm);
font-weight: 600;
text-align: center;
width: 100px;

View File

@ -1,14 +1,15 @@
import Calendar from 'components/common/Calendar';
import { FormButtons } from 'components/layout/FormLayout';
import { isAfter, isBefore, isSameDay } from 'date-fns';
import { getDateRangeValues } from 'lib/date';
import { useState } from 'react';
import { Button, ButtonGroup } from 'react-basics';
import { FormattedMessage } from 'react-intl';
import { Button, ButtonGroup, Calendar } from 'react-basics';
import { useIntl } from 'react-intl';
import { isAfter, isBefore, isSameDay } from 'date-fns';
import useLocale from 'hooks/useLocale';
import { getDateRangeValues } from 'lib/date';
import { getDateLocale } from 'lib/lang';
import { labels } from 'components/messages';
import styles from './DatePickerForm.module.css';
const FILTER_DAY = 0;
const FILTER_RANGE = 1;
const FILTER_DAY = 'day';
const FILTER_RANGE = 'range';
export default function DatePickerForm({
startDate: defaultStartDate,
@ -24,59 +25,59 @@ export default function DatePickerForm({
const [date, setDate] = useState(defaultStartDate);
const [startDate, setStartDate] = useState(defaultStartDate);
const [endDate, setEndDate] = useState(defaultEndDate);
const { locale } = useLocale();
const { formatMessage } = useIntl();
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() {
const 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} />
<ButtonGroup size="sm" selectedKey={selected} onSelect={setSelected}>
<Button key={FILTER_DAY}>{formatMessage(labels.singleDay)}</Button>
<Button key={FILTER_RANGE}>{formatMessage(labels.dateRange)}</Button>
</ButtonGroup>
</div>
<div className={styles.calendars}>
{selected === FILTER_DAY ? (
{selected === FILTER_DAY && (
<Calendar date={date} minDate={minDate} maxDate={maxDate} onChange={setDate} />
) : (
)}
{selected === FILTER_RANGE && (
<>
<Calendar
date={startDate}
minDate={minDate}
maxDate={endDate}
locale={getDateLocale(locale)}
onChange={setStartDate}
/>
<Calendar date={endDate} minDate={startDate} maxDate={maxDate} onChange={setEndDate} />
<Calendar
date={endDate}
minDate={startDate}
maxDate={maxDate}
locale={getDateLocale(locale)}
onChange={setEndDate}
/>
</>
)}
</div>
<FormButtons>
<Button variant="action" onClick={handleSave} disabled={disabled}>
<FormattedMessage id="label.save" defaultMessage="Save" />
<div className={styles.buttons}>
<Button variant="primary" onClick={handleSave} disabled={disabled}>
{formatMessage(labels.save)}
</Button>
<Button onClick={onClose}>
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
</Button>
</FormButtons>
<Button onClick={onClose}>{formatMessage(labels.cancel)}</Button>
</div>
</div>
);
}

View File

@ -26,6 +26,14 @@
margin-bottom: 20px;
}
.buttons {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-top: 20px;
}
@media only screen and (max-width: 768px) {
.calendars {
flex-direction: column;

View File

@ -0,0 +1,241 @@
import { useMutation } from '@tanstack/react-query';
import classNames from 'classnames';
import DateFilter from 'components/common/DateFilter';
import DataTable from 'components/metrics/DataTable';
import FilterTags from 'components/metrics/FilterTags';
import useApi from 'hooks/useApi';
import useDateRange from 'hooks/useDateRange';
import useTimezone from 'hooks/useTimezone';
import { useEffect, useState, useRef } from 'react';
import {
Button,
Dropdown,
Flexbox,
Form,
FormButtons,
FormInput,
FormRow,
Item,
TextField,
} from 'react-basics';
import { FormattedMessage } from 'react-intl';
import { FormMessage } from '../layout/FormLayout';
import styles from './EventDataForm.module.css';
export const filterOptions = [
{ label: 'Count', value: 'count' },
{ label: 'Average', value: 'avg' },
{ label: 'Minimum', value: 'min' },
{ label: 'Maximum', value: 'max' },
{ label: 'Sum', value: 'sum' },
];
export const dateOptions = [
{ 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.yesterday" defaultMessage="Yesterday" />,
value: '-1day',
},
{
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,
},
];
export default function EventDataForm({ websiteId, onClose, className }) {
const { post } = useApi();
const [message, setMessage] = useState();
const { mutate } = useMutation(data => post(`/websites/${websiteId}/eventdata`, data));
const [columns, setColumns] = useState({});
const [filters, setFilters] = useState({});
const [type, setType] = useState('');
const [data, setData] = useState([]);
const [dateRange, setDateRange] = useDateRange('report');
const { startDate, endDate, value } = dateRange;
const [timezone] = useTimezone();
const [isValid, setIsValid] = useState(false);
const columnRef = useRef(null);
const filterRef = useRef(null);
useEffect(() => {
if (Object.keys(columns).length > 0) {
setIsValid(true);
} else {
setIsValid(false);
}
}, [columns]);
const handleAddTag = (value, list, setState, ref, clearDropdown) => {
setState({ ...list, [`${value.field}`]: value.value });
ref.current.reset({ field: '', value: '' });
if (clearDropdown) {
setType('');
}
};
const handleRemoveTag = (value, list, setState) => {
const newList = { ...list };
delete newList[`${value}`];
setState(newList);
};
const handleSubmit = async () => {
const params = {
website_id: websiteId,
startAt: +startDate,
endAt: +endDate,
timezone,
columns,
filters,
};
mutate(params, {
onSuccess: async data => {
setData(data);
setMessage(null);
},
onError: async () => {
setMessage(
<FormattedMessage id="message.failure" defaultMessage="Something went wrong." />,
);
setData([]);
},
});
};
return (
<>
<Flexbox className={styles.message} alignItems="center" direction="column">
<FormMessage>{message}</FormMessage>
</Flexbox>
<div className={classNames(styles.container, className)}>
<div className={styles.form}>
<div className={styles.filters}>
<FormRow label={<FormattedMessage id="label.date-range" defaultMessage="Date Range" />}>
<DateFilter
value={value}
startDate={startDate}
endDate={endDate}
onChange={setDateRange}
options={dateOptions}
/>
</FormRow>
</div>
<div className={styles.filters}>
<Form
ref={columnRef}
onSubmit={value =>
handleAddTag({ ...value, value: type }, columns, setColumns, columnRef, true)
}
>
<FormRow
label={<FormattedMessage id="label.field-name" defaultMessage="Field Name" />}
>
<FormInput name="field" rules={{ required: 'Required' }}>
<TextField />
</FormInput>
</FormRow>
<FormRow label={<FormattedMessage id="label.type" defaultMessage="Type" />}>
<FormInput name="value">
<Dropdown items={filterOptions} value={type} onChange={setType}>
{({ value, label }) => <Item key={value}>{label}</Item>}
</Dropdown>
</FormInput>
</FormRow>
<FormButtons className={styles.formButtons}>
<Button variant="action" type="submit">
<FormattedMessage id="label.add-column" defaultMessage="Add Column" />
</Button>
</FormButtons>
</Form>
<FilterTags
className={styles.filterTag}
params={columns}
onClick={value => handleRemoveTag(value, columns, setColumns)}
/>
</div>
<div className={styles.filters}>
<Form
ref={filterRef}
onSubmit={value => handleAddTag(value, filters, setFilters, filterRef)}
>
<FormRow
label={<FormattedMessage id="label.field-name" defaultMessage="Field Name" />}
>
<FormInput name="field">
<TextField />
</FormInput>
</FormRow>
<FormRow label={<FormattedMessage id="label.value" defaultMessage="Value" />}>
<FormInput name="value">
<TextField />
</FormInput>
</FormRow>
<FormButtons className={styles.formButtons}>
<Button variant="action" type="submit">
<FormattedMessage id="label.add-filter" defaultMessage="Add Filter" />
</Button>
</FormButtons>
</Form>
<FilterTags
className={styles.filterTag}
params={filters}
onClick={value => handleRemoveTag(value, filters, setFilters)}
/>
</div>
</div>
<div>
<DataTable className={styles.table} data={data} title="Results" showPercentage={false} />
</div>
</div>
<FormButtons>
<Button variant="action" onClick={handleSubmit} disabled={!isValid}>
<FormattedMessage id="label.search" defaultMessage="Search" />
</Button>
<Button onClick={onClose}>
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
</Button>
</FormButtons>
</>
);
}

View File

@ -3,8 +3,9 @@
}
.form {
padding: 20px;
border-right: 1px solid var(--base300);
width: 420px;
width: 425px;
}
.filters {
@ -16,6 +17,10 @@
min-height: 250px;
}
.message {
width: 100%;
}
.table {
padding: 10px;
min-height: 430px;

View File

@ -1,7 +1,6 @@
import classNames from 'classnames';
import { safeDecodeURI } from 'next-basics';
import { Button } from 'react-basics';
import Times from 'assets/times.svg';
import { Button, Icon, Icons } from 'react-basics';
import styles from './FilterTags.module.css';
export default function FilterTags({ className, params, onClick }) {
@ -16,8 +15,11 @@ export default function FilterTags({ className, params, onClick }) {
}
return (
<div key={key} className={styles.tag}>
<Button icon={<Times />} onClick={() => onClick(key)} variant="action" iconRight>
<Button onClick={() => onClick(key)} variant="action" iconRight>
{`${key}: ${safeDecodeURI(params[key])}`}
<Icon>
<Icons.Close />
</Icon>
</Button>
</div>
);

View File

@ -34,8 +34,9 @@ export default function Legend({ chart }) {
className={classNames(styles.label, { [styles.hidden]: hidden })}
onClick={() => handleClick(datasetIndex)}
>
<StatusLight color={color.alpha(color.alpha() + 0.2).toHex()} />
<StatusLight color={color.alpha(color.alpha() + 0.2).toHex()}>
<span className={locale}>{text}</span>
</StatusLight>
</div>
);
})}

View File

@ -6,7 +6,7 @@
}
.value {
font-size: var(--font-size-xl);
font-size: var(--font-size-xxl);
line-height: 40px;
min-height: 40px;
font-weight: 600;
@ -14,7 +14,6 @@
}
.label {
font-size: var(--font-size-md);
white-space: nowrap;
display: flex;
align-items: center;

View File

@ -1,11 +1,10 @@
import { useMemo } from 'react';
import { Loading } from 'react-basics';
import { Loading, Icons } from 'react-basics';
import { defineMessages, useIntl } from 'react-intl';
import firstBy from 'thenby';
import classNames from 'classnames';
import Link from 'components/common/Link';
import useApi from 'hooks/useApi';
import Arrow from 'assets/arrow-right.svg';
import { percentFilter } from 'lib/filters';
import useDateRange from 'hooks/useDateRange';
import usePageQuery from 'hooks/usePageQuery';
@ -80,7 +79,7 @@ export default function MetricsTable({
<div className={styles.footer}>
{data && !error && limit && (
<Link
icon={<Arrow />}
icon={<Icons.ArrowRight />}
href={router.pathname}
as={resolve({ view: type })}
size="small"

View File

@ -2,7 +2,6 @@
position: relative;
min-height: 430px;
height: 100%;
font-size: var(--font-size-sm);
display: flex;
flex-direction: column;
}

View File

@ -2,25 +2,11 @@ import { useMemo } from 'react';
import { FormattedMessage } from 'react-intl';
import { differenceInMinutes } from 'date-fns';
import PageHeader from 'components/layout/PageHeader';
import DropDown from 'components/common/DropDown';
import ActiveUsers from './ActiveUsers';
import MetricCard from './MetricCard';
import styles from './RealtimeHeader.module.css';
export default function RealtimeHeader({ websites, data, websiteId, onSelect }) {
const options = [
{
label: <FormattedMessage id="label.all-websites" defaultMessage="All websites" />,
value: null,
},
].concat(
websites.map(({ name, id }, index) => ({
label: name,
value: id,
divider: index === 0,
})),
);
export default function RealtimeHeader({ data, websiteId }) {
const { pageviews, sessions, events, countries } = data;
const count = useMemo(() => {
@ -38,7 +24,6 @@ export default function RealtimeHeader({ websites, data, websiteId, onSelect })
<div>
<ActiveUsers className={styles.active} value={count} />
</div>
<DropDown value={websiteId} options={options} onChange={onSelect} />
</PageHeader>
<div className={styles.metrics}>
<MetricCard

View File

@ -1,5 +1,7 @@
import { useMemo } from 'react';
import { Row, Column, Loading } from 'react-basics';
import { useIntl } from 'react-intl';
import { Button, Icon, Text, Row, Column, Loading } from 'react-basics';
import Link from 'next/link';
import PageviewsChart from './PageviewsChart';
import MetricsBar from './MetricsBar';
import WebsiteHeader from './WebsiteHeader';
@ -12,6 +14,8 @@ import useDateRange from 'hooks/useDateRange';
import useTimezone from 'hooks/useTimezone';
import usePageQuery from 'hooks/usePageQuery';
import { getDateArray, getDateLength, getDateRangeValues } from 'lib/date';
import Icons from 'components/icons';
import { labels } from 'components/messages';
import styles from './WebsiteChart.module.css';
export default function WebsiteChart({
@ -20,8 +24,10 @@ export default function WebsiteChart({
domain,
stickyHeader = false,
showChart = true,
showDetailsButton = false,
onDataLoad = () => {},
}) {
const { formatMessage } = useIntl();
const [dateRange, setDateRange] = useDateRange(websiteId);
const { startDate, endDate, unit, value, modified } = dateRange;
const [timezone] = useTimezone();
@ -66,8 +72,9 @@ export default function WebsiteChart({
async function handleDateChange(value) {
if (value === 'all') {
const { data, ok } = await get(`/websites/${websiteId}`);
if (ok) {
const data = await get(`/websites/${websiteId}`);
if (data) {
setDateRange({ value, ...getDateRangeValues(new Date(data.createdAt), Date.now()) });
}
} else {
@ -81,7 +88,20 @@ export default function WebsiteChart({
return (
<>
<WebsiteHeader websiteId={websiteId} title={title} domain={domain} />
<WebsiteHeader websiteId={websiteId} title={title} domain={domain}>
{showDetailsButton && (
<Link href={`/websites/${websiteId}`}>
<a>
<Button>
<Text>{formatMessage(labels.viewDetails)}</Text>
<Icon>
<Icons.ArrowRight />
</Icon>
</Button>
</a>
</Link>
)}
</WebsiteHeader>
<StickyHeader
className={styles.metrics}
stickyClassName={styles.sticky}

Some files were not shown because too many files have changed in this diff Show More