Merge branch 'dev' of https://github.com/umami-software/umami into feat/um-121-v2-migration-script
@ -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
@ -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 |
@ -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 |
@ -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 |
@ -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 |
Before Width: | Height: | Size: 1002 B After Width: | Height: | Size: 1002 B |
@ -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 |
@ -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 |
@ -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 |
@ -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
@ -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 |
@ -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
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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
@ -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 |
@ -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 |
@ -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
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
51
components/buttons/LanguageButton.js
Normal 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>
|
||||
);
|
||||
}
|
35
components/buttons/LanguageButton.module.css
Normal 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);
|
||||
}
|
22
components/buttons/LogoutButton.js
Normal 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>
|
||||
);
|
||||
}
|
@ -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';
|
42
components/buttons/ThemeButton.js
Normal 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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
@ -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 && (
|
@ -11,7 +11,6 @@
|
||||
}
|
||||
|
||||
.icon svg {
|
||||
font-size: 16px;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
@ -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);
|
||||
}
|
||||
}
|
@ -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;
|
@ -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;
|
||||
|
@ -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;
|
@ -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;
|
||||
}
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
@ -1,3 +1,4 @@
|
||||
.button {
|
||||
width: fit-content;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -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} />}
|
||||
</>
|
||||
|
@ -7,8 +7,6 @@
|
||||
}
|
||||
|
||||
.option {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: normal;
|
||||
background: var(--base50);
|
||||
padding: 4px 16px;
|
||||
cursor: pointer;
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -6,7 +6,6 @@
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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><head></code> section of your HTML.
|
||||
</p>
|
||||
<TextArea rows={4} value={code} readOnly allowCopy />
|
||||
</FormRow>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
width: 300px;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
width: 300px;
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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
@ -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;
|
29
components/layout/AppLayout.js
Normal 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>
|
||||
);
|
||||
}
|
16
components/layout/AppLayout.module.css
Normal 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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
|
@ -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;
|
||||
|
@ -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" />
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
.layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
61
components/layout/NavBar.js
Normal 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>
|
||||
);
|
||||
}
|
57
components/layout/NavBar.module.css
Normal 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;
|
||||
}
|
54
components/layout/NavGroup.js
Normal 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>
|
||||
);
|
||||
}
|
93
components/layout/NavGroup.module.css
Normal 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;
|
||||
}
|
@ -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>;
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -5,7 +5,6 @@
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
font-size: var(--font-size-md);
|
||||
}
|
||||
|
||||
.value {
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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;
|
241
components/metrics/EventDataForm.js
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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;
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
@ -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;
|
||||
|
@ -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"
|
||||
|
@ -2,7 +2,6 @@
|
||||
position: relative;
|
||||
min-height: 430px;
|
||||
height: 100%;
|
||||
font-size: var(--font-size-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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}
|
||||
|