Compare commits

..

No commits in common. "master" and "v1.7.0" have entirely different histories.

1487 changed files with 23776 additions and 161820 deletions

View file

@ -4,4 +4,3 @@ Dockerfile
.gitignore
.DS_Store
node_modules
.idea

View file

@ -2,10 +2,9 @@
"env": {
"browser": true,
"es2020": true,
"node": true,
"jest": true
"node": true
},
"parser": "@typescript-eslint/parser",
"extends": ["eslint:recommended", "plugin:react/recommended", "prettier", "prettier/react"],
"parserOptions": {
"ecmaFeatures": {
"jsx": true
@ -13,39 +12,11 @@
"ecmaVersion": 11,
"sourceType": "module"
},
"settings": {
"import/resolver": {
"node": {
"moduleDirectory": ["node_modules", "src/"]
}
}
},
"extends": [
"plugin:@typescript-eslint/recommended",
"eslint:recommended",
"plugin:prettier/recommended",
"plugin:import/errors",
"plugin:import/typescript",
"plugin:css-modules/recommended",
"plugin:cypress/recommended",
"prettier",
"next"
],
"plugins": ["@typescript-eslint", "prettier", "promise", "css-modules", "cypress"],
"plugins": ["react"],
"rules": {
"no-console": "error",
"react/display-name": "off",
"react-hooks/exhaustive-deps": "off",
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",
"import/no-anonymous-default-export": "off",
"import/no-named-as-default": "off",
"@next/next/no-img-element": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/no-empty-interface": "off",
"@typescript-eslint/no-unused-vars": ["error", { "ignoreRestSiblings": true }]
"react/prop-types": "off"
},
"globals": {
"React": "writable"

View file

@ -1,36 +0,0 @@
name: '🐛 Bug Report'
description: Create a bug report for Umami.
body:
- type: textarea
attributes:
label: Describe the Bug
description: A clear and concise description of what the bug is.
validations:
required: true
- type: dropdown
attributes:
label: Database
description: What database are you using?
options:
- PostgreSQL
- MySQL
- Umami Cloud
validations:
required: true
- type: textarea
attributes:
label: Relevant log output
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
render: shell
- type: input
attributes:
label: Which Umami version are you using? (if relevant)
description: 'For example: Chrome, Edge, Firefox, etc'
- type: input
attributes:
label: Which browser are you using? (if relevant)
description: 'For example: Chrome, Edge, Firefox, etc'
- type: input
attributes:
label: How are you deploying your application? (if relevant)
description: 'For example: Vercel, Railway, Docker, etc'

View file

@ -1,9 +0,0 @@
name: '✨ Feature Request'
description: Create a feature or enhancement request for Umami.
body:
- type: textarea
attributes:
label: Describe the feature or enhancement
description: A clear and concise description of what the feature or enhancement is.
validations:
required: true

View file

@ -1,6 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: "🤔 Ask a question"
url: https://github.com/umami-software/umami/discussions
about: Ask questions and discuss with other community members.

19
.github/stale.yml vendored Normal file
View file

@ -0,0 +1,19 @@
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 60
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 7
# Issues with these labels will never be considered stale
exemptLabels:
- pinned
- security
- enhancement
- bug
# Label to use when marking an issue as stale
staleLabel: wontfix
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false

View file

@ -1,43 +0,0 @@
name: Create docker images (manual)
on:
workflow_dispatch:
inputs:
version:
type: string
description: Version
required: true
jobs:
build:
name: Build, push, and deploy
runs-on: ubuntu-latest
strategy:
matrix:
db-type: [postgresql, mysql]
steps:
- uses: actions/checkout@v3
- uses: mr-smithers-excellent/docker-build-push@v6
name: Build & push Docker image to ghcr.io for ${{ matrix.db-type }}
with:
image: umami
tags: ${{ matrix.db-type }}-${{ inputs.version }}, ${{ matrix.db-type }}-latest
buildArgs: DATABASE_TYPE=${{ matrix.db-type }}
registry: ghcr.io
multiPlatform: true
platform: linux/amd64,linux/arm64
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: mr-smithers-excellent/docker-build-push@v6
name: Build & push Docker image to docker.io for ${{ matrix.db-type }}
with:
image: umamisoftware/umami
tags: ${{ matrix.db-type }}-${{ inputs.version }}, ${{ matrix.db-type }}-latest
buildArgs: DATABASE_TYPE=${{ matrix.db-type }}
registry: docker.io
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}

View file

@ -1,44 +0,0 @@
name: Create docker images
on: [create]
jobs:
build:
name: Build, push, and deploy
if: ${{ startsWith(github.ref, 'refs/tags/v') }}
runs-on: ubuntu-latest
strategy:
matrix:
db-type: [postgresql, mysql]
steps:
- uses: actions/checkout@v3
- name: Set env
run: |
echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
echo "NOW=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV
- uses: mr-smithers-excellent/docker-build-push@v6
name: Build & push Docker image to ghcr.io for ${{ matrix.db-type }}
with:
image: umami
tags: ${{ matrix.db-type }}-${{ env.RELEASE_VERSION }}, ${{ matrix.db-type }}-latest
buildArgs: DATABASE_TYPE=${{ matrix.db-type }}
registry: ghcr.io
multiPlatform: true
platform: linux/amd64,linux/arm64
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: mr-smithers-excellent/docker-build-push@v6
name: Build & push Docker image to docker.io for ${{ matrix.db-type }}
with:
image: umamisoftware/umami
tags: ${{ matrix.db-type }}-${{ env.RELEASE_VERSION }}, ${{ matrix.db-type }}-latest
buildArgs: DATABASE_TYPE=${{ matrix.db-type }}
registry: docker.io
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}

View file

@ -1,36 +0,0 @@
# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
name: Node.js CI
on: [push]
env:
DATABASE_TYPE: postgresql
SKIP_DB_CHECK: 1
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- node-version: 18.17
db-type: postgresql
- node-version: 18.17
db-type: mysql
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'yarn'
env:
DATABASE_TYPE: ${{ matrix.db-type }}
- run: npm install --global yarn
- run: yarn install
- run: yarn test
- run: yarn build

42
.github/workflows/main.yml vendored Normal file
View file

@ -0,0 +1,42 @@
on:
push:
branches:
- master
jobs:
build:
name: Build, push, and deploy
runs-on: ubuntu-latest
steps:
- name: Checkout master
uses: actions/checkout@v2
- name: Build PostgreSQL container image
run: |
docker build --build-arg DATABASE_TYPE=postgresql \
--tag ghcr.io/$GITHUB_ACTOR/umami:postgresql-$(echo $GITHUB_SHA | head -c7) \
--tag ghcr.io/$GITHUB_ACTOR/umami:postgresql-latest \
.
- name: Build MySQL container image
run: |
docker build --build-arg DATABASE_TYPE=mysql \
--tag ghcr.io/$GITHUB_ACTOR/umami:mysql-$(echo $GITHUB_SHA | head -c7) \
--tag ghcr.io/$GITHUB_ACTOR/umami:mysql-latest \
.
- name: Docker login
env:
CR_PAT: ${{ secrets.CR_PAT }}
run: docker login -u $GITHUB_ACTOR -p $CR_PAT ghcr.io
- name: Push image to GitHub
run: |
# Push each image individually, avoiding pushing to umami:latest
# as MySQL or PostgreSQL are required
docker push ghcr.io/$GITHUB_ACTOR/umami:postgresql-$(echo $GITHUB_SHA | head -c7)
docker push ghcr.io/$GITHUB_ACTOR/umami:postgresql-latest
docker push ghcr.io/$GITHUB_ACTOR/umami:mysql-$(echo $GITHUB_SHA | head -c7)
docker push ghcr.io/$GITHUB_ACTOR/umami:mysql-latest

View file

@ -1,25 +0,0 @@
name: Close stale issues
on:
schedule:
- cron: '30 1 * * *'
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v8
with:
days-before-issue-stale: 60
days-before-issue-close: 7
stale-issue-label: 'stale'
stale-issue-message: 'This issue is stale because it has been open for 60 days with no activity.'
close-issue-message: 'This issue was closed because it has been inactive for 7 days since being marked as stale.'
days-before-pr-stale: -1
days-before-pr-close: -1
operations-per-run: 200
ascending: true
repo-token: ${{ secrets.GITHUB_TOKEN }}
exempt-issue-labels: bug,enhancement

24
.gitignore vendored
View file

@ -1,8 +1,8 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
.pnp
/node_modules
/.pnp
.pnp.js
# testing
@ -11,22 +11,20 @@ node_modules
# next.js
/.next/
/out/
/prisma/
/prisma/schema.prisma
# production
/build
/public/script.js
/geo
/dist
/public/umami.js
/public/geo
/lang-compiled
# misc
.DS_Store
.idea
.yarn
*.iml
*.log
.vscode
.tool-versions
.vscode/*
# debug
npm-debug.log*
@ -35,8 +33,6 @@ yarn-error.log*
# local env files
.env
.env.*
*.env.*
*.dev.yml
.env.development.local
.env.test.local
.env.production.local

View file

@ -1,4 +0,0 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged

View file

@ -1 +1 @@
/public/script.js
/public/

View file

@ -5,7 +5,13 @@
"stylelint-config-prettier"
],
"rules": {
"no-descending-specificity": null
"no-descending-specificity": null,
"selector-pseudo-class-no-unknown": [
true,
{
"ignorePseudoClasses": ["global", "horizontal", "vertical"]
}
]
},
"ignoreFiles": ["**/*.js", "**/*.md"]
"ignoreFiles": ["**/*.js"]
}

View file

@ -1,61 +1,41 @@
# Install dependencies only when needed
FROM node:18-alpine AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json yarn.lock ./
# Add yarn timeout to handle slow CPU when Github Actions
RUN yarn config set network-timeout 300000
# Build image
FROM node:12.18-alpine AS build
ARG DATABASE_TYPE
ENV DATABASE_URL "postgresql://umami:umami@db:5432/umami" \
DATABASE_TYPE=$DATABASE_TYPE
WORKDIR /build
RUN yarn config set --home enableTelemetry 0
COPY package.json yarn.lock /build/
# Install only the production dependencies
RUN yarn install --production --frozen-lockfile
# Cache these modules for production
RUN cp -R node_modules/ prod_node_modules/
# Install development dependencies
RUN yarn install --frozen-lockfile
# Rebuild the source code only when needed
FROM node:18-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
COPY docker/middleware.js ./src
COPY . /build
RUN yarn next telemetry disable
RUN yarn build
ARG DATABASE_TYPE
ARG BASE_PATH
ENV DATABASE_TYPE $DATABASE_TYPE
ENV BASE_PATH $BASE_PATH
ENV NEXT_TELEMETRY_DISABLED 1
RUN yarn build-docker
# Production image, copy all the files and run next
FROM node:18-alpine AS runner
# Production image
FROM node:12.18-alpine AS production
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
# Copy cached dependencies
COPY --from=build /build/prod_node_modules ./node_modules
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy generated Prisma client
COPY --from=build /build/node_modules/.prisma/ ./node_modules/.prisma/
RUN set -x \
&& apk add --no-cache curl \
&& yarn add npm-run-all dotenv semver prisma@5.17.0
COPY --from=build /build/yarn.lock /build/package.json ./
COPY --from=build /build/.next ./.next
COPY --from=build /build/public ./public
# You only need to copy next.config.js if you are NOT using the default configuration
COPY --from=builder /app/next.config.js .
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/scripts ./scripts
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
USER node
EXPOSE 3000
ENV HOSTNAME 0.0.0.0
ENV PORT 3000
CMD ["yarn", "start-docker"]
CMD ["yarn", "start"]

View file

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2022 Umami Software, Inc. <hello@umami.is>
Copyright (c) 2020 Mike Cao <mike@mikecao.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

1
Procfile Normal file
View file

@ -0,0 +1 @@
web: npm run start-env

162
README.md
View file

@ -1,159 +1,107 @@
<p align="center">
<img src="https://content.umami.is/website/images/umami-logo.png" alt="Umami Logo" width="100">
</p>
# umami
<h1 align="center">Umami</h1>
Umami is a simple, fast, website analytics alternative to Google Analytics.
<p align="center">
<i>Umami is a simple, fast, privacy-focused alternative to Google Analytics.</i>
</p>
## Getting started
<p align="center">
<a href="https://github.com/umami-software/umami/releases">
<img src="https://img.shields.io/github/release/umami-software/umami.svg" alt="GitHub Release" />
</a>
<a href="https://github.com/umami-software/umami/blob/master/LICENSE">
<img src="https://img.shields.io/github/license/umami-software/umami.svg" alt="MIT License" />
</a>
<a href="https://github.com/umami-software/umami/actions">
<img src="https://img.shields.io/github/actions/workflow/status/umami-software/umami/ci.yml" alt="Build Status" />
</a>
<a href="https://analytics.umami.is/share/LGazGOecbDtaIwDr/umami.is" style="text-decoration: none;">
<img src="https://img.shields.io/badge/Try%20Demo%20Now-Click%20Here-brightgreen" alt="Umami Demo" />
</a>
</p>
A detailed getting started guide can be found at [https://umami.is/docs/](https://umami.is/docs/)
---
## 🚀 Getting Started
A detailed getting started guide can be found at [umami.is/docs](https://umami.is/docs/).
---
## 🛠 Installing from Source
## Installing from source
### Requirements
- A server with Node.js version 16.13 or newer
- A database. Umami supports [MySQL](https://www.mysql.com/) (minimum v8.0) and [PostgreSQL](https://www.postgresql.org/) (minimum v12.14) databases.
- A server with Node.js 10.13 or newer
- A database (MySQL or Postgresql)
### Install Yarn
### Get the source code and install packages
```bash
npm install -g yarn
```
### Get the Source Code and Install Packages
```bash
git clone https://github.com/umami-software/umami.git
git clone https://github.com/mikecao/umami.git
cd umami
yarn install
npm install
```
### Configure Umami
### Create database tables
Create an `.env` file with the following:
Umami supports [MySQL](https://www.mysql.com/) and [Postgresql](https://www.postgresql.org/).
Create a database for your Umami installation and install the tables with the included scripts.
```bash
DATABASE_URL=connection-url
For MySQL:
```
mysql -u username -p databasename < sql/schema.mysql.sql
```
The connection URL format:
For Postgresql:
```bash
```
psql -h hostname -U username -d databasename -f sql/schema.postgresql.sql
```
This will also create a login account with username **admin** and password **umami**.
### Configure umami
Create an `.env` file with the following
```
DATABASE_URL=(connection url)
HASH_SALT=(any random string)
```
The connection url is in the following format:
```
postgresql://username:mypassword@localhost:5432/mydb
mysql://username:mypassword@localhost:3306/mydb
```
### Build the Application
The `HASH_SALT` is used to generate unique values for your installation.
### Build the application
```bash
yarn build
npm run build
```
*The build step will create tables in your database if you are installing for the first time. It will also create a login user with username **admin** and password **umami**.*
### Start the Application
### Start the application
```bash
yarn start
npm start
```
*By default, this will launch the application on `http://localhost:3000`. You will need to either [proxy](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) requests from your web server or change the [port](https://nextjs.org/docs/api-reference/cli#production) to serve the application directly.*
By default this will launch the application on `http://localhost:3000`. You will need to either
[proxy](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) requests from your web server
or change the [port](https://nextjs.org/docs/api-reference/cli#production) to serve the application directly.
---
## Installing with Docker
## 🐳 Installing with Docker
To build the Umami container and start up a Postgres database, run:
To build the umami container and start up a Postgres database, run:
```bash
docker compose up -d
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 ghcr.io/mikecao/umami:postgresql-latest
```
Or with MySQL support:
```bash
docker pull docker.umami.is/umami-software/umami:mysql-latest
docker pull ghcr.io/mikecao/umami:mysql-latest
```
---
## 🔄 Getting Updates
## Getting updates
To get the latest features, simply do a pull, install any new dependencies, and rebuild:
```bash
git pull
yarn install
yarn build
npm install
npm run build
```
To update the Docker image, simply pull the new images and rebuild:
## License
```bash
docker compose pull
docker compose up --force-recreate
```
---
## 🛟 Support
<p align="center">
<a href="https://github.com/umami-software/umami">
<img src="https://img.shields.io/badge/GitHub--blue?style=social&logo=github" alt="GitHub" />
</a>
<a href="https://twitter.com/umami_software">
<img src="https://img.shields.io/badge/Twitter--blue?style=social&logo=twitter" alt="Twitter" />
</a>
<a href="https://linkedin.com/company/umami-software">
<img src="https://img.shields.io/badge/LinkedIn--blue?style=social&logo=linkedin" alt="LinkedIn" />
</a>
<a href="https://umami.is/discord">
<img src="https://img.shields.io/badge/Discord--blue?style=social&logo=discord" alt="Discord" />
</a>
</p>
[release-shield]: https://img.shields.io/github/release/umami-software/umami.svg
[releases-url]: https://github.com/umami-software/umami/releases
[license-shield]: https://img.shields.io/github/license/umami-software/umami.svg
[license-url]: https://github.com/umami-software/umami/blob/master/LICENSE
[build-shield]: https://img.shields.io/github/actions/workflow/status/umami-software/umami/ci.yml
[build-url]: https://github.com/umami-software/umami/actions
[github-shield]: https://img.shields.io/badge/GitHub--blue?style=social&logo=github
[github-url]: https://github.com/umami-software/umami
[twitter-shield]: https://img.shields.io/badge/Twitter--blue?style=social&logo=twitter
[twitter-url]: https://twitter.com/umami_software
[linkedin-shield]: https://img.shields.io/badge/LinkedIn--blue?style=social&logo=linkedin
[linkedin-url]: https://linkedin.com/company/umami-software
[discord-shield]: https://img.shields.io/badge/Discord--blue?style=social&logo=discord
[discord-url]: https://discord.com/invite/4dz4zcXYrQ
MIT

View file

@ -1,16 +0,0 @@
{
"name": "Umami",
"description": "Umami is a simple, fast, website analytics alternative to Google Analytics.",
"keywords": ["analytics", "charts", "statistics", "web-analytics"],
"website": "https://umami.is",
"repository": "https://github.com/umami-software/umami",
"addons": ["heroku-postgresql"],
"env": {
"APP_SECRET": {
"description": "Used to generate unique values for your installation",
"required": true,
"generator": "secret"
}
},
"success_url": "/"
}

1
assets/arrow-right.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M216.464 36.465l-7.071 7.07c-4.686 4.686-4.686 12.284 0 16.971L387.887 239H12c-6.627 0-12 5.373-12 12v10c0 6.627 5.373 12 12 12h375.887L209.393 451.494c-4.686 4.686-4.686 12.284 0 16.971l7.071 7.07c4.686 4.686 12.284 4.686 16.97 0l211.051-211.05c4.686-4.686 4.686-12.284 0-16.971L233.434 36.465c-4.686-4.687-12.284-4.687-16.97 0z"/></svg>

After

Width:  |  Height:  |  Size: 409 B

View file

Before

Width:  |  Height:  |  Size: 289 B

After

Width:  |  Height:  |  Size: 289 B

View file

Before

Width:  |  Height:  |  Size: 1,002 B

After

Width:  |  Height:  |  Size: 1,002 B

1
assets/check.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M435.848 83.466L172.804 346.51l-96.652-96.652c-4.686-4.686-12.284-4.686-16.971 0l-28.284 28.284c-4.686 4.686-4.686 12.284 0 16.971l133.421 133.421c4.686 4.686 12.284 4.686 16.971 0l299.813-299.813c4.686-4.686 4.686-12.284 0-16.971l-28.284-28.284c-4.686-4.686-12.284-4.686-16.97 0z"/></svg>

After

Width:  |  Height:  |  Size: 360 B

1
assets/chevron-down.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M441.9 167.3l-19.8-19.8c-4.7-4.7-12.3-4.7-17 0L224 328.2 42.9 147.5c-4.7-4.7-12.3-4.7-17 0L6.1 167.3c-4.7 4.7-4.7 12.3 0 17l209.4 209.4c4.7 4.7 12.3 4.7 17 0l209.4-209.4c4.7-4.7 4.7-12.3 0-17z"/></svg>

After

Width:  |  Height:  |  Size: 272 B

1
assets/code.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M234.8 511.7L196 500.4c-4.2-1.2-6.7-5.7-5.5-9.9L331.3 5.8c1.2-4.2 5.7-6.7 9.9-5.5L380 11.6c4.2 1.2 6.7 5.7 5.5 9.9L244.7 506.2c-1.2 4.3-5.6 6.7-9.9 5.5zm-83.2-121.1l27.2-29c3.1-3.3 2.8-8.5-.5-11.5L72.2 256l106.1-94.1c3.4-3 3.6-8.2.5-11.5l-27.2-29c-3-3.2-8.1-3.4-11.3-.4L2.5 250.2c-3.4 3.2-3.4 8.5 0 11.7L140.3 391c3.2 3 8.2 2.8 11.3-.4zm284.1.4l137.7-129.1c3.4-3.2 3.4-8.5 0-11.7L435.7 121c-3.2-3-8.3-2.9-11.3.4l-27.2 29c-3.1 3.3-2.8 8.5.5 11.5L503.8 256l-106.1 94.1c-3.4 3-3.6 8.2-.5 11.5l27.2 29c3.1 3.2 8.1 3.4 11.3.4z"/></svg>

After

Width:  |  Height:  |  Size: 601 B

1
assets/ellipsis-h.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M304 256c0 26.5-21.5 48-48 48s-48-21.5-48-48 21.5-48 48-48 48 21.5 48 48zm120-48c-26.5 0-48 21.5-48 48s21.5 48 48 48 48-21.5 48-48-21.5-48-48-48zm-336 0c-26.5 0-48 21.5-48 48s21.5 48 48 48 48-21.5 48-48-21.5-48-48-48z"/></svg>

After

Width:  |  Height:  |  Size: 297 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M270.2 160h35.5c3.4 0 6.1 2.8 6 6.2l-7.5 196c-.1 3.2-2.8 5.8-6 5.8h-20.5c-3.2 0-5.9-2.5-6-5.8l-7.5-196c-.1-3.4 2.6-6.2 6-6.2zM288 388c-15.5 0-28 12.5-28 28s12.5 28 28 28 28-12.5 28-28-12.5-28-28-28zm281.5 52L329.6 24c-18.4-32-64.7-32-83.2 0L6.5 440c-18.4 31.9 4.6 72 41.6 72H528c36.8 0 60-40 41.5-72zM528 480H48c-12.3 0-20-13.3-13.9-24l240-416c6.1-10.6 21.6-10.7 27.7 0l240 416c6.2 10.6-1.5 24-13.8 24z"/></svg>

After

Width:  |  Height:  |  Size: 482 B

1
assets/external-link.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M497.6,0,334.4.17A14.4,14.4,0,0,0,320,14.57V47.88a14.4,14.4,0,0,0,14.69,14.4l73.63-2.72,2.06,2.06L131.52,340.49a12,12,0,0,0,0,17l23,23a12,12,0,0,0,17,0L450.38,101.62l2.06,2.06-2.72,73.63A14.4,14.4,0,0,0,464.12,192h33.31a14.4,14.4,0,0,0,14.4-14.4L512,14.4A14.4,14.4,0,0,0,497.6,0ZM432,288H416a16,16,0,0,0-16,16V458a6,6,0,0,1-6,6H54a6,6,0,0,1-6-6V118a6,6,0,0,1,6-6H208a16,16,0,0,0,16-16V80a16,16,0,0,0-16-16H48A48,48,0,0,0,0,112V464a48,48,0,0,0,48,48H400a48,48,0,0,0,48-48V304A16,16,0,0,0,432,288Z"/></svg>

After

Width:  |  Height:  |  Size: 575 B

View file

Before

Width:  |  Height:  |  Size: 509 B

After

Width:  |  Height:  |  Size: 509 B

View file

Before

Width:  |  Height:  |  Size: 874 B

After

Width:  |  Height:  |  Size: 874 B

View file

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

1
assets/list-ul.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M48 368a48 48 0 1 0 48 48 48 48 0 0 0-48-48zm0-160a48 48 0 1 0 48 48 48 48 0 0 0-48-48zm0-160a48 48 0 1 0 48 48 48 48 0 0 0-48-48zm448 24H176a16 16 0 0 0-16 16v16a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16V88a16 16 0 0 0-16-16zm0 160H176a16 16 0 0 0-16 16v16a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16v-16a16 16 0 0 0-16-16zm0 160H176a16 16 0 0 0-16 16v16a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16v-16a16 16 0 0 0-16-16z"/></svg>

After

Width:  |  Height:  |  Size: 492 B

2
assets/logo.svg Normal file
View file

@ -0,0 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 428 389.11">
<circle cx="214.15" cy="181" r="171" fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="20"/><path d="M413,134.11H15.29a15,15,0,0,0-15,15v15.3C.12,168,0,171.52,0,175.11c0,118.19,95.81,214,214,214,116.4,0,211.1-92.94,213.93-208.67,0-.44.07-.88.07-1.33v-30A15,15,0,0,0,413,134.11Z"/></svg>

After

Width:  |  Height:  |  Size: 377 B

1
assets/moon.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1399.98 1400"><path d="M562.44,837.55C335.89,611,288.08,273.54,418.71,0A734.31,734.31,0,0,0,215.54,143.73c-287.39,287.39-287.39,753.33,0,1040.72s753.33,287.4,1040.74,0A733.8,733.8,0,0,0,1400,981.29C1126.45,1111.92,789,1064.09,562.44,837.55Z"/></svg>

After

Width:  |  Height:  |  Size: 302 B

1
assets/pen.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M493.26 56.26l-37.51-37.51C443.25 6.25 426.87 0 410.49 0s-32.76 6.25-45.25 18.74l-74.49 74.49L256 127.98 12.85 371.12.15 485.34C-1.45 499.72 9.88 512 23.95 512c.89 0 1.79-.05 2.69-.15l114.14-12.61L384.02 256l34.74-34.74 74.49-74.49c25-25 25-65.52.01-90.51zM118.75 453.39l-67.58 7.46 7.53-67.69 231.24-231.24 31.02-31.02 60.14 60.14-31.02 31.02-231.33 231.33zm340.56-340.57l-44.28 44.28-60.13-60.14 44.28-44.28c4.08-4.08 8.84-4.69 11.31-4.69s7.24.61 11.31 4.69l37.51 37.51c6.24 6.25 6.24 16.4 0 22.63z"/></svg>

After

Width:  |  Height:  |  Size: 580 B

1
assets/plus.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="M368 224H224V80c0-8.84-7.16-16-16-16h-32c-8.84 0-16 7.16-16 16v144H16c-8.84 0-16 7.16-16 16v32c0 8.84 7.16 16 16 16h144v144c0 8.84 7.16 16 16 16h32c8.84 0 16-7.16 16-16V288h144c8.84 0 16-7.16 16-16v-32c0-8.84-7.16-16-16-16z"/></svg>

After

Width:  |  Height:  |  Size: 303 B

View file

Before

Width:  |  Height:  |  Size: 653 B

After

Width:  |  Height:  |  Size: 653 B

1
assets/sun.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400"><path d="M367.43,422.13a54.44,54.44,0,0,1-38.66-16L205,282.35A54.69,54.69,0,0,1,282.37,205L406.11,328.79a54.68,54.68,0,0,1-38.68,93.34Z"/><path d="M1156.3,1211a54.51,54.51,0,0,1-38.67-16L993.89,1071.21a54.68,54.68,0,1,1,77.34-77.33L1195,1117.65A54.7,54.7,0,0,1,1156.3,1211Z"/><path d="M243.7,1211A54.7,54.7,0,0,1,205,1117.65L328.74,993.89a54.69,54.69,0,0,1,77.36,77.32L282.37,1195A54.51,54.51,0,0,1,243.7,1211Z"/><path d="M1032.57,422.13a54.68,54.68,0,0,1-38.68-93.34L1117.61,205A54.69,54.69,0,0,1,1195,282.35L1071.23,406.11A54.44,54.44,0,0,1,1032.57,422.13Z"/><path d="M229.69,754.69h-175a54.69,54.69,0,0,1,0-109.38h175a54.69,54.69,0,0,1,0,109.38Z"/><path d="M1345.31,754.69h-175a54.69,54.69,0,0,1,0-109.38h175a54.69,54.69,0,0,1,0,109.38Z"/><path d="M700,1400a54.68,54.68,0,0,1-54.69-54.69v-175a54.69,54.69,0,0,1,109.38,0v175A54.68,54.68,0,0,1,700,1400Z"/><path d="M700,284.38a54.7,54.7,0,0,1-54.69-54.69v-175a54.69,54.69,0,0,1,109.38,0v175A54.7,54.7,0,0,1,700,284.38Z"/><circle cx="700" cy="700" r="306.25"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

1
assets/times.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path d="M207.6 256l107.72-107.72c6.23-6.23 6.23-16.34 0-22.58l-25.03-25.03c-6.23-6.23-16.34-6.23-22.58 0L160 208.4 52.28 100.68c-6.23-6.23-16.34-6.23-22.58 0L4.68 125.7c-6.23 6.23-6.23 16.34 0 22.58L112.4 256 4.68 363.72c-6.23 6.23-6.23 16.34 0 22.58l25.03 25.03c6.23 6.23 16.34 6.23 22.58 0L160 303.6l107.72 107.72c6.23 6.23 16.34 6.23 22.58 0l25.03-25.03c6.23-6.23 6.23-16.34 0-22.58L207.6 256z"/></svg>

After

Width:  |  Height:  |  Size: 468 B

1
assets/trash.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M432 80h-82.4l-34-56.7A48 48 0 0 0 274.4 0H173.6a48 48 0 0 0-41.2 23.3L98.4 80H16A16 16 0 0 0 0 96v16a16 16 0 0 0 16 16h16l21.2 339a48 48 0 0 0 47.9 45h245.8a48 48 0 0 0 47.9-45L416 128h16a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16zM173.6 48h100.8l19.2 32H154.4zm173.3 416H101.11l-21-336h287.8z"/></svg>

After

Width:  |  Height:  |  Size: 370 B

1
assets/user.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1652 1652"><title>Asset 1</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path d="M1587.07,504.47A828.56,828.56,0,1,0,1652,826,823.13,823.13,0,0,0,1587.07,504.47ZM826,1577a747.29,747.29,0,0,1-464.48-161.26,39.94,39.94,0,0,0,2.8-11.35,458.82,458.82,0,0,1,34.29-135.74,464.15,464.15,0,0,1,854.78,0,458.82,458.82,0,0,1,34.29,135.74,39.94,39.94,0,0,0,2.8,11.35A747.29,747.29,0,0,1,826,1577ZM719.81,866.57A274,274,0,1,1,826,888,272.1,272.1,0,0,1,719.81,866.57Zm641.28,485.87c-36.11-201.1-182.78-363.82-374.86-423,114.28-58.37,192.53-177.22,192.53-314.35,0-194.83-157.94-352.76-352.76-352.76S473.24,420.29,473.24,615.12c0,137.13,78.25,256,192.53,314.35-192.08,59.15-338.75,221.87-374.86,423C157.46,1216.81,75,1030.86,75,826,75,411.9,411.9,75,826,75s751,336.9,751,751C1577,1030.86,1494.54,1216.81,1361.09,1352.44Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 910 B

1
assets/visitor.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M224 256c70.7 0 128-57.3 128-128S294.7 0 224 0 96 57.3 96 128s57.3 128 128 128zm89.6 32h-16.7c-22.2 10.2-46.9 16-72.9 16s-50.6-5.8-72.9-16h-16.7C60.2 288 0 348.2 0 422.4V464c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48v-41.6c0-74.2-60.2-134.4-134.4-134.4z"/></svg>

After

Width:  |  Height:  |  Size: 336 B

View file

@ -0,0 +1,45 @@
import React from 'react';
import ReactTooltip from 'react-tooltip';
import classNames from 'classnames';
import Icon from './Icon';
import styles from './Button.module.css';
export default function Button({
type = 'button',
icon,
size,
variant,
children,
className,
tooltip,
tooltipId,
disabled,
iconRight,
onClick = () => {},
...props
}) {
return (
<button
data-tip={tooltip}
data-effect="solid"
data-for={tooltipId}
type={type}
className={classNames(styles.button, className, {
[styles.large]: size === 'large',
[styles.small]: size === 'small',
[styles.xsmall]: size === 'xsmall',
[styles.action]: variant === 'action',
[styles.danger]: variant === 'danger',
[styles.light]: variant === 'light',
[styles.iconRight]: iconRight,
})}
disabled={disabled}
onClick={!disabled ? onClick : null}
{...props}
>
{icon && <Icon className={styles.icon} icon={icon} size={size} />}
{children && <div className={styles.label}>{children}</div>}
{tooltip && <ReactTooltip id={tooltipId}>{tooltip}</ReactTooltip>}
</button>
);
}

View file

@ -0,0 +1,102 @@
.button {
display: flex;
justify-content: center;
align-items: center;
font-size: var(--font-size-normal);
color: var(--gray900);
background: var(--gray100);
padding: 8px 16px;
border-radius: 4px;
border: 0;
outline: none;
cursor: pointer;
position: relative;
}
.button:hover {
background: var(--gray200);
}
.button:active {
color: var(--gray900);
}
.label {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
max-width: 300px;
}
.large {
font-size: var(--font-size-large);
}
.small {
font-size: var(--font-size-small);
}
.xsmall {
font-size: var(--font-size-xsmall);
}
.action,
.action:active {
color: var(--gray50);
background: var(--gray900);
}
.action:hover {
background: var(--gray800);
}
.danger,
.danger:active {
color: var(--gray50);
background: var(--red500);
}
.danger:hover {
background: var(--red400);
}
.light,
.light:active {
color: var(--gray900);
background: transparent;
}
.light:hover {
background: inherit;
}
.button .icon + * {
margin-left: 10px;
}
.button.iconRight .icon {
order: 1;
margin-left: 10px;
}
.button.iconRight .icon + * {
margin: 0;
}
.button:disabled {
cursor: default;
color: var(--gray500);
background: var(--gray75);
}
.button:disabled:active {
color: var(--gray500);
}
.button:disabled:hover {
background: var(--gray75);
}
.button.light:disabled {
background: var(--gray50);
}

View file

@ -0,0 +1,32 @@
import React from 'react';
import classNames from 'classnames';
import Button from './Button';
import styles from './ButtonGroup.module.css';
export default function ButtonGroup({
items = [],
selectedItem,
className,
size,
icon,
onClick = () => {},
}) {
return (
<div className={classNames(styles.group, className)}>
{items.map(item => {
const { label, value } = item;
return (
<Button
key={value}
className={classNames(styles.button, { [styles.selected]: selectedItem === value })}
size={size}
icon={icon}
onClick={() => onClick(value)}
>
{label}
</Button>
);
})}
</div>
);
}

View file

@ -0,0 +1,31 @@
.group {
display: inline-flex;
border-radius: 4px;
overflow: hidden;
border: 1px solid var(--gray500);
}
.group .button {
border-radius: 0;
color: var(--gray800);
background: var(--gray50);
border-left: 1px solid var(--gray500);
padding: 4px 8px;
}
.group .button:first-child {
border: 0;
}
.group .button:hover {
background: var(--gray100);
}
.group .button + .button {
margin: 0;
}
.group .button.selected {
color: var(--gray900);
font-weight: 600;
}

View file

@ -0,0 +1,267 @@
import React, { useState } from 'react';
import classNames from 'classnames';
import {
startOfWeek,
startOfMonth,
startOfYear,
endOfMonth,
addDays,
subDays,
addYears,
subYears,
addMonths,
setMonth,
setYear,
isSameDay,
isBefore,
isAfter,
} from 'date-fns';
import Button from './Button';
import useLocale from 'hooks/useLocale';
import { dateFormat } from 'lib/lang';
import { chunk } from 'lib/array';
import Chevron from 'assets/chevron-down.svg';
import Cross from 'assets/times.svg';
import styles from './Calendar.module.css';
import Icon from './Icon';
export default function Calendar({ date, minDate, maxDate, onChange }) {
const [locale] = useLocale();
const [selectMonth, setSelectMonth] = useState(false);
const [selectYear, setSelectYear] = useState(false);
const month = dateFormat(date, 'MMMM', locale);
const year = date.getFullYear();
function toggleMonthSelect() {
setSelectYear(false);
setSelectMonth(state => !state);
}
function toggleYearSelect() {
setSelectMonth(false);
setSelectYear(state => !state);
}
function handleChange(value) {
setSelectMonth(false);
setSelectYear(false);
if (value) {
onChange(value);
}
}
return (
<div className={styles.calendar}>
<div className={styles.header}>
<div>{date.getDate()}</div>
<div
className={classNames(styles.selector, { [styles.open]: selectMonth })}
onClick={toggleMonthSelect}
>
{month}
<Icon className={styles.icon} icon={selectMonth ? <Cross /> : <Chevron />} size="small" />
</div>
<div
className={classNames(styles.selector, { [styles.open]: selectYear })}
onClick={toggleYearSelect}
>
{year}
<Icon className={styles.icon} icon={selectYear ? <Cross /> : <Chevron />} size="small" />
</div>
</div>
<div className={styles.body}>
{!selectMonth && !selectYear && (
<DaySelector
date={date}
minDate={minDate}
maxDate={maxDate}
locale={locale}
onSelect={handleChange}
/>
)}
{selectMonth && (
<MonthSelector
date={date}
minDate={minDate}
maxDate={maxDate}
locale={locale}
onSelect={handleChange}
onClose={toggleMonthSelect}
/>
)}
{selectYear && (
<YearSelector
date={date}
minDate={minDate}
maxDate={maxDate}
onSelect={handleChange}
onClose={toggleYearSelect}
/>
)}
</div>
</div>
);
}
const DaySelector = ({ date, minDate, maxDate, locale, onSelect }) => {
const startWeek = startOfWeek(date);
const startMonth = startOfMonth(date);
const startDay = subDays(startMonth, startMonth.getDay());
const month = date.getMonth();
const year = date.getFullYear();
const daysOfWeek = [];
for (let i = 0; i < 7; i++) {
daysOfWeek.push(addDays(startWeek, i));
}
const days = [];
for (let i = 0; i < 35; i++) {
days.push(addDays(startDay, i));
}
return (
<table>
<thead>
<tr>
{daysOfWeek.map((day, i) => (
<th key={i} className={locale}>
{dateFormat(day, 'EEE', locale)}
</th>
))}
</tr>
</thead>
<tbody>
{chunk(days, 7).map((week, i) => (
<tr key={i}>
{week.map((day, j) => {
const disabled = isBefore(day, minDate) || isAfter(day, maxDate);
return (
<td
key={j}
className={classNames({
[styles.selected]: isSameDay(date, day),
[styles.faded]: day.getMonth() !== month || day.getFullYear() !== year,
[styles.disabled]: disabled,
})}
onClick={!disabled ? () => onSelect(day) : null}
>
{day.getDate()}
</td>
);
})}
</tr>
))}
</tbody>
</table>
);
};
const MonthSelector = ({ date, minDate, maxDate, locale, onSelect }) => {
const start = startOfYear(date);
const months = [];
for (let i = 0; i < 12; i++) {
months.push(addMonths(start, i));
}
function handleSelect(value) {
onSelect(setMonth(date, value));
}
return (
<table>
<tbody>
{chunk(months, 3).map((row, i) => (
<tr key={i}>
{row.map((month, j) => {
const disabled =
isBefore(endOfMonth(month), minDate) || isAfter(startOfMonth(month), maxDate);
return (
<td
key={j}
className={classNames(locale, {
[styles.selected]: month.getMonth() === date.getMonth(),
[styles.disabled]: disabled,
})}
onClick={!disabled ? () => handleSelect(month.getMonth()) : null}
>
{dateFormat(month, 'MMMM', locale)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
);
};
const YearSelector = ({ date, minDate, maxDate, onSelect }) => {
const [currentDate, setCurrentDate] = useState(date);
const year = date.getFullYear();
const currentYear = currentDate.getFullYear();
const minYear = minDate.getFullYear();
const maxYear = maxDate.getFullYear();
const years = [];
for (let i = 0; i < 15; i++) {
years.push(currentYear - 7 + i);
}
function handleSelect(value) {
onSelect(setYear(date, value));
}
function handlePrevClick() {
setCurrentDate(state => subYears(state, 15));
}
function handleNextClick() {
setCurrentDate(state => addYears(state, 15));
}
return (
<div className={styles.pager}>
<div className={styles.left}>
<Button
icon={<Chevron />}
size="small"
onClick={handlePrevClick}
disabled={years[0] <= minYear}
variant="light"
/>
</div>
<div className={styles.middle}>
<table>
<tbody>
{chunk(years, 5).map((row, i) => (
<tr key={i}>
{row.map((n, j) => (
<td
key={j}
className={classNames({
[styles.selected]: n === year,
[styles.disabled]: n < minYear || n > maxYear,
})}
onClick={() => (n < minYear || n > maxYear ? null : handleSelect(n))}
>
{n}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className={styles.right}>
<Button
icon={<Chevron />}
size="small"
onClick={handleNextClick}
disabled={years[years.length - 1] > maxYear}
variant="light"
/>
</div>
</div>
);
};

View file

@ -0,0 +1,111 @@
.calendar {
display: flex;
flex-direction: column;
font-size: var(--font-size-small);
flex: 1;
min-height: 306px;
}
.calendar table {
width: 100%;
border-spacing: 5px;
}
.calendar td {
color: var(--gray800);
cursor: pointer;
text-align: center;
vertical-align: center;
height: 40px;
width: 40px;
border-radius: 5px;
border: 1px solid transparent;
}
.calendar td:hover {
border: 1px solid var(--gray300);
background: var(--gray75);
}
.calendar td.faded {
color: var(--gray500);
}
.calendar td.selected {
font-weight: 600;
border: 1px solid var(--gray600);
}
.calendar td.selected:hover {
background: transparent;
}
.calendar td.disabled {
color: var(--gray400);
background: var(--gray75);
}
.calendar td.disabled:hover {
cursor: default;
background: var(--gray75);
border-color: transparent;
}
.calendar td.faded.disabled {
background: var(--gray100);
}
.header {
display: flex;
justify-content: space-evenly;
align-items: center;
font-weight: 700;
line-height: 40px;
font-size: var(--font-size-normal);
}
.body {
display: flex;
}
.selector {
cursor: pointer;
}
.pager {
display: flex;
flex: 1;
}
.pager button {
align-self: center;
}
.middle {
flex: 1;
}
.left,
.right {
display: flex;
justify-content: center;
align-items: center;
}
.left svg {
transform: rotate(90deg);
}
.right svg {
transform: rotate(-90deg);
}
.icon {
margin-left: 10px;
}
@media only screen and (max-width: 992px) {
.calendar table {
max-width: calc(100vw - 30px);
}
}

View file

@ -0,0 +1,27 @@
import React, { useRef } from 'react';
import Icon from 'components/common/Icon';
import Check from 'assets/check.svg';
import styles from './Checkbox.module.css';
export default function Checkbox({ name, value, label, onChange }) {
const ref = useRef();
return (
<div className={styles.container}>
<div className={styles.checkbox} onClick={() => ref.current.click()}>
{value && <Icon icon={<Check />} size="small" />}
</div>
<label className={styles.label} htmlFor={name}>
{label}
</label>
<input
ref={ref}
className={styles.input}
type="checkbox"
name={name}
value={value}
onChange={onChange}
/>
</div>
);
}

View file

@ -0,0 +1,29 @@
.container {
display: flex;
align-items: center;
position: relative;
overflow: hidden;
}
.checkbox {
display: flex;
justify-content: center;
align-items: center;
width: 20px;
height: 20px;
border: 1px solid var(--gray500);
border-radius: 4px;
}
.label {
margin-left: 10px;
}
.input {
position: absolute;
visibility: hidden;
height: 0;
width: 0;
bottom: 100%;
right: 100%;
}

View file

@ -0,0 +1,26 @@
import React, { useState } from 'react';
import Button from './Button';
import { FormattedMessage } from 'react-intl';
const defaultText = (
<FormattedMessage id="label.copy-to-clipboard" defaultMessage="Copy to clipboard" />
);
export default 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>
);
}

View file

@ -0,0 +1,119 @@
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { endOfYear, isSameDay } from 'date-fns';
import Modal from './Modal';
import DropDown from './DropDown';
import DatePickerForm from 'components/forms/DatePickerForm';
import useLocale from 'hooks/useLocale';
import { getDateRange } from 'lib/date';
import { dateFormat } from 'lib/lang';
import Calendar from 'assets/calendar-alt.svg';
import Icon from './Icon';
const filterOptions = [
{ label: <FormattedMessage id="label.today" defaultMessage="Today" />, value: '1day' },
{
label: (
<FormattedMessage id="label.last-hours" defaultMessage="Last {x} hours" values={{ x: 24 }} />
),
value: '24hour',
},
{
label: <FormattedMessage id="label.this-week" defaultMessage="This week" />,
value: '1week',
divider: true,
},
{
label: (
<FormattedMessage id="label.last-days" defaultMessage="Last {x} days" values={{ x: 7 }} />
),
value: '7day',
},
{
label: <FormattedMessage id="label.this-month" defaultMessage="This month" />,
value: '1month',
divider: true,
},
{
label: (
<FormattedMessage id="label.last-days" defaultMessage="Last {x} days" values={{ x: 30 }} />
),
value: '30day',
},
{
label: (
<FormattedMessage id="label.last-days" defaultMessage="Last {x} days" values={{ x: 90 }} />
),
value: '90day',
},
{ label: <FormattedMessage id="label.this-year" defaultMessage="This year" />, value: '1year' },
{
label: <FormattedMessage id="label.custom-range" defaultMessage="Custom range" />,
value: 'custom',
divider: true,
},
];
export default function DateFilter({ value, startDate, endDate, onChange, className }) {
const [showPicker, setShowPicker] = useState(false);
const displayValue =
value === 'custom' ? (
<CustomRange startDate={startDate} endDate={endDate} onClick={() => handleChange('custom')} />
) : (
value
);
function handleChange(value) {
if (value === 'custom') {
setShowPicker(true);
return;
}
onChange(getDateRange(value));
}
function handlePickerChange(value) {
setShowPicker(false);
onChange(value);
}
return (
<>
<DropDown
className={className}
value={displayValue}
options={filterOptions}
onChange={handleChange}
/>
{showPicker && (
<Modal>
<DatePickerForm
startDate={startDate}
endDate={endDate}
minDate={new Date(2000, 0, 1)}
maxDate={endOfYear(new Date())}
onChange={handlePickerChange}
onClose={() => setShowPicker(false)}
/>
</Modal>
)}
</>
);
}
const CustomRange = ({ startDate, endDate, onClick }) => {
const [locale] = useLocale();
function handleClick(e) {
e.stopPropagation();
onClick();
}
return (
<>
<Icon icon={<Calendar />} className="mr-2" onClick={handleClick} />
{dateFormat(startDate, 'd LLL y', locale)}
{!isSameDay(startDate, endDate) && `${dateFormat(endDate, 'd LLL y', locale)}`}
</>
);
};

17
components/common/Dot.js Normal file
View file

@ -0,0 +1,17 @@
import React from 'react';
import classNames from 'classnames';
import styles from './Dot.module.css';
export default function Dot({ color, size, className }) {
return (
<div className={styles.wrapper}>
<div
style={{ background: color }}
className={classNames(styles.dot, className, {
[styles.small]: size === 'small',
[styles.large]: size === 'large',
})}
/>
</div>
);
}

View file

@ -0,0 +1,22 @@
.wrapper {
background: var(--gray50);
margin-right: 10px;
border-radius: 100%;
}
.dot {
background: var(--green400);
width: 10px;
height: 10px;
border-radius: 100%;
}
.dot.small {
width: 8px;
height: 8px;
}
.dot.large {
width: 16px;
height: 16px;
}

View file

@ -0,0 +1,54 @@
import React, { useState, useRef } from 'react';
import classNames from 'classnames';
import Menu from './Menu';
import useDocumentClick from 'hooks/useDocumentClick';
import Chevron from 'assets/chevron-down.svg';
import styles from './Dropdown.module.css';
import Icon from './Icon';
export default function DropDown({
value,
className,
menuClassName,
options = [],
onChange = () => {},
}) {
const [showMenu, setShowMenu] = useState(false);
const ref = useRef();
const selectedOption = options.find(e => e.value === value);
function handleShowMenu() {
setShowMenu(state => !state);
}
function handleSelect(selected, e) {
e.stopPropagation();
setShowMenu(false);
onChange(selected);
}
useDocumentClick(e => {
if (!ref.current.contains(e.target)) {
setShowMenu(false);
}
});
return (
<div ref={ref} className={classNames(styles.dropdown, className)} onClick={handleShowMenu}>
<div className={styles.value}>
<div className={styles.text}>{options.find(e => e.value === value)?.label || value}</div>
<Icon icon={<Chevron />} className={styles.icon} size="small" />
</div>
{showMenu && (
<Menu
className={menuClassName}
options={options}
selectedOption={selectedOption}
onSelect={handleSelect}
float="bottom"
/>
)}
</div>
);
}

View file

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

View file

@ -0,0 +1,14 @@
import React from 'react';
import Icon from 'components/common/Icon';
import Logo from 'assets/logo.svg';
import styles from './EmptyPlaceholder.module.css';
export default function EmptyPlaceholder({ msg, children }) {
return (
<div className={styles.placeholder}>
<Icon className={styles.icon} icon={<Logo />} size="xlarge" />
<h2>{msg}</h2>
{children}
</div>
);
}

View file

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

View file

@ -0,0 +1,14 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import Icon from './Icon';
import Exclamation from 'assets/exclamation-triangle.svg';
import styles from './ErrorMessage.module.css';
export default function ErrorMessage() {
return (
<div className={styles.error}>
<Icon icon={<Exclamation />} className={styles.icon} size="large" />
<FormattedMessage id="message.failure" defaultMessage="Something went wrong." />
</div>
);
}

View file

@ -5,11 +5,9 @@
transform: translate(-50%, -50%);
margin: auto;
display: flex;
background-color: var(--base50);
padding: 10px;
z-index: 1;
}
.icon {
margin-inline-end: 10px;
margin-right: 10px;
}

View file

@ -1,24 +1,21 @@
function getHostName(url: string) {
import React from 'react';
import styles from './Favicon.module.css';
function getHostName(url) {
const match = url.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?(?:www\.)?([^:/\n?=]+)/im);
return match && match.length > 1 ? match[1] : null;
}
export function Favicon({ domain, ...props }) {
if (process.env.privateMode) {
return null;
}
export default function Favicon({ domain, ...props }) {
const hostName = domain ? getHostName(domain) : null;
return hostName ? (
<img
className={styles.favicon}
src={`https://icons.duckduckgo.com/ip3/${hostName}.ico`}
width={16}
height={16}
height="16"
alt=""
{...props}
/>
) : null;
}
export default Favicon;

View file

@ -0,0 +1,3 @@
.favicon {
margin-right: 8px;
}

View file

@ -0,0 +1,11 @@
import React from 'react';
import ButtonLayout from 'components/layout/ButtonLayout';
import ButtonGroup from './ButtonGroup';
export default function FilterButtons({ buttons, selected, onClick }) {
return (
<ButtonLayout>
<ButtonGroup size="xsmall" items={buttons} selectedItem={selected} onClick={onClick} />
</ButtonLayout>
);
}

20
components/common/Icon.js Normal file
View file

@ -0,0 +1,20 @@
import React from 'react';
import classNames from 'classnames';
import styles from './Icon.module.css';
export default function Icon({ icon, className, size = 'medium', ...props }) {
return (
<div
className={classNames(styles.icon, className, {
[styles.xlarge]: size === 'xlarge',
[styles.large]: size === 'large',
[styles.medium]: size === 'medium',
[styles.small]: size === 'small',
[styles.xsmall]: size === 'xsmall',
})}
{...props}
>
{icon}
</div>
);
}

View file

@ -0,0 +1,35 @@
.icon {
display: inline-flex;
justify-content: center;
align-items: center;
vertical-align: middle;
}
.icon svg {
fill: currentColor;
}
.xlarge > svg {
width: 48px;
height: 48px;
}
.large > svg {
width: 24px;
height: 24px;
}
.medium > svg {
width: 16px;
height: 16px;
}
.small > svg {
width: 12px;
height: 12px;
}
.xsmall > svg {
width: 10px;
height: 10px;
}

23
components/common/Link.js Normal file
View file

@ -0,0 +1,23 @@
import React from 'react';
import classNames from 'classnames';
import NextLink from 'next/link';
import Icon from './Icon';
import styles from './Link.module.css';
export default function Link({ className, icon, children, size, iconRight, ...props }) {
return (
<NextLink {...props}>
<a
className={classNames(styles.link, className, {
[styles.large]: size === 'large',
[styles.small]: size === 'small',
[styles.xsmall]: size === 'xsmall',
[styles.iconRight]: iconRight,
})}
>
{icon && <Icon className={styles.icon} icon={icon} size={size} />}
{children}
</a>
</NextLink>
);
}

View file

@ -0,0 +1,50 @@
a.link,
a.link:active,
a.link:visited {
position: relative;
color: var(--gray900);
text-decoration: none;
display: inline-flex;
align-items: center;
}
a.link:before {
content: '';
position: absolute;
bottom: -2px;
width: 0;
height: 2px;
background: var(--primary400);
opacity: 0.5;
transition: width 100ms;
}
a.link:hover:before {
width: 100%;
transition: width 100ms;
}
a.link.large {
font-size: var(--font-size-large);
}
a.link.small {
font-size: var(--font-size-small);
}
a.link.xsmall {
font-size: var(--font-size-xsmall);
}
a.link .icon + * {
margin-left: 10px;
}
a.link.iconRight .icon {
order: 1;
margin-left: 10px;
}
a.link.iconRight .icon + * {
margin: 0;
}

View file

@ -0,0 +1,13 @@
import React from 'react';
import classNames from 'classnames';
import styles from './Loading.module.css';
export default function Loading({ className }) {
return (
<div className={classNames(styles.loading, className)}>
<div />
<div />
<div />
</div>
);
}

View file

@ -0,0 +1,43 @@
@keyframes blink {
0% {
opacity: 0.2;
}
20% {
opacity: 1;
}
100% {
opacity: 0.2;
}
}
.loading {
display: flex;
justify-content: center;
align-items: center;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
margin: 0;
}
.loading div {
width: 10px;
height: 10px;
border-radius: 100%;
background: var(--gray400);
animation: blink 1.4s infinite;
animation-fill-mode: both;
}
.loading div + div {
margin-left: 10px;
}
.loading div:nth-child(2) {
animation-delay: 0.2s;
}
.loading div:nth-child(3) {
animation-delay: 0.4s;
}

48
components/common/Menu.js Normal file
View file

@ -0,0 +1,48 @@
import React from 'react';
import classNames from 'classnames';
import styles from './Menu.module.css';
export default function Menu({
options = [],
selectedOption,
className,
float,
align = 'left',
optionClassName,
selectedClassName,
onSelect = () => {},
}) {
return (
<div
className={classNames(styles.menu, className, {
[styles.float]: float,
[styles.top]: float === 'top',
[styles.bottom]: float === 'bottom',
[styles.left]: align === 'left',
[styles.right]: align === 'right',
})}
>
{options
.filter(({ hidden }) => !hidden)
.map(option => {
const { label, value, className: customClassName, render, divider } = option;
return render ? (
render(option)
) : (
<div
key={value}
className={classNames(styles.option, optionClassName, customClassName, {
[selectedClassName]: selectedOption === option,
[styles.selected]: selectedOption === option,
[styles.divider]: divider,
})}
onClick={e => onSelect(value, e)}
>
{label}
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,51 @@
.menu {
background: var(--gray50);
border: 1px solid var(--gray500);
border-radius: 4px;
overflow: hidden;
z-index: 100;
}
.option {
font-size: var(--font-size-small);
font-weight: normal;
background: var(--gray50);
padding: 4px 16px;
cursor: pointer;
white-space: nowrap;
}
.option:hover {
background: var(--gray100);
}
.float {
position: absolute;
min-width: 100px;
}
.top {
bottom: 100%;
margin-bottom: 5px;
}
.bottom {
top: 100%;
margin-top: 5px;
}
.left {
left: 0;
}
.right {
right: 0;
}
.divider {
border-top: 1px solid var(--gray300);
}
.selected {
font-weight: 600;
}

View file

@ -0,0 +1,60 @@
import React, { useState, useRef } from 'react';
import classNames from 'classnames';
import Menu from 'components/common/Menu';
import Button from 'components/common/Button';
import useDocumentClick from 'hooks/useDocumentClick';
import styles from './MenuButton.module.css';
export default function MenuButton({
icon,
value,
options,
buttonClassName,
menuClassName,
menuPosition = 'bottom',
menuAlign = 'right',
onSelect,
renderValue,
}) {
const [showMenu, setShowMenu] = useState(false);
const ref = useRef();
const selectedOption = options.find(e => e.value === value);
function handleSelect(value) {
onSelect(value);
setShowMenu(false);
}
function toggleMenu() {
setShowMenu(state => !state);
}
useDocumentClick(e => {
if (!ref.current.contains(e.target)) {
setShowMenu(false);
}
});
return (
<div className={styles.container} ref={ref}>
<Button
icon={icon}
className={classNames(styles.button, buttonClassName, { [styles.open]: showMenu })}
onClick={toggleMenu}
variant="light"
>
<div className={styles.text}>{renderValue ? renderValue(selectedOption) : value}</div>
</Button>
{showMenu && (
<Menu
className={menuClassName}
options={options}
selectedOption={selectedOption}
onSelect={handleSelect}
float={menuPosition}
align={menuAlign}
/>
)}
</div>
);
}

View file

@ -0,0 +1,20 @@
.container {
display: flex;
position: relative;
cursor: pointer;
}
.button {
border: 1px solid transparent;
border-radius: 4px;
}
.text {
font-size: var(--font-size-small);
}
.open,
.open:hover {
background: var(--gray50);
border: 1px solid var(--gray500);
}

View file

@ -0,0 +1,18 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { useSpring, animated } from 'react-spring';
import styles from './Modal.module.css';
export default function Modal({ title, children }) {
const props = useSpring({ opacity: 1, from: { opacity: 0 } });
return ReactDOM.createPortal(
<animated.div className={styles.modal} style={props}>
<div className={styles.content}>
{title && <div className={styles.header}>{title}</div>}
<div className={styles.body}>{children}</div>
</div>
</animated.div>,
document.getElementById('__modals'),
);
}

View file

@ -0,0 +1,46 @@
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: auto;
z-index: 2;
}
.modal:before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: auto;
background: #000;
opacity: 0.5;
}
.content {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--gray50);
min-width: 400px;
min-height: 100px;
max-width: 100vw;
z-index: 1;
border: 1px solid var(--gray300);
padding: 30px;
border-radius: 4px;
}
.header {
font-weight: 600;
margin-bottom: 20px;
}
.body {
display: flex;
flex-direction: column;
}

View file

@ -0,0 +1,32 @@
import React from 'react';
import { useRouter } from 'next/router';
import classNames from 'classnames';
import styles from './NavMenu.module.css';
export default function NavMenu({ options = [], className, onSelect = () => {} }) {
const router = useRouter();
return (
<div className={classNames(styles.menu, className)}>
{options
.filter(({ hidden }) => !hidden)
.map(option => {
const { label, value, className: customClassName, render } = option;
return render ? (
render(option)
) : (
<div
key={value}
className={classNames(styles.option, customClassName, {
[styles.selected]: router.asPath === value,
})}
onClick={e => onSelect(value, e)}
>
{label}
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,22 @@
.menu {
color: var(--gray800);
border: 1px solid var(--gray500);
border-radius: 4px;
overflow: hidden;
z-index: 2;
}
.option {
padding: 8px 16px;
cursor: pointer;
border-radius: 4px;
}
.option:hover {
background: var(--gray75);
}
.selected {
color: var(--gray900);
font-weight: 600;
}

View file

@ -0,0 +1,12 @@
import React from 'react';
import classNames from 'classnames';
import { FormattedMessage } from 'react-intl';
import styles from './NoData.module.css';
export default function NoData({ className }) {
return (
<div className={classNames(styles.container, className)}>
<FormattedMessage id="message.no-data-available" defaultMessage="No data available." />
</div>
);
}

View file

@ -0,0 +1,8 @@
.container {
color: var(--gray500);
font-size: var(--font-size-normal);
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}

View file

@ -0,0 +1,37 @@
import React, { useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { FormattedMessage } from 'react-intl';
import { setDateRange } from 'redux/actions/websites';
import Button from './Button';
import Refresh from 'assets/redo.svg';
import Dots from 'assets/ellipsis-h.svg';
import useDateRange from 'hooks/useDateRange';
import { getDateRange } from '../../lib/date';
export default function RefreshButton({ websiteId }) {
const dispatch = useDispatch();
const [dateRange] = useDateRange(websiteId);
const [loading, setLoading] = useState(false);
const completed = useSelector(state => state.queries[`/api/website/${websiteId}/stats`]);
function handleClick() {
if (dateRange) {
setLoading(true);
dispatch(setDateRange(websiteId, getDateRange(dateRange.value)));
}
}
useEffect(() => {
setLoading(false);
}, [completed]);
return (
<Button
icon={loading ? <Dots /> : <Refresh />}
tooltip={<FormattedMessage id="label.refresh" defaultMessage="Refresh" />}
tooltipId="button-refresh"
size="small"
onClick={handleClick}
/>
);
}

View file

@ -0,0 +1,60 @@
import React from 'react';
import classNames from 'classnames';
import NoData from 'components/common/NoData';
import styles from './Table.module.css';
export default function Table({
columns,
rows,
empty,
className,
bodyClassName,
rowKey,
showHeader = true,
children,
}) {
if (empty && rows.length === 0) {
return empty;
}
return (
<div className={classNames(styles.table, className)}>
{showHeader && (
<div className={classNames(styles.header, 'row')}>
{columns.map(({ key, label, className, style, header }) => (
<div
key={key}
className={classNames(styles.head, className, header?.className)}
style={{ ...style, ...header?.style }}
>
{label}
</div>
))}
</div>
)}
<div className={classNames(styles.body, bodyClassName)}>
{rows.length === 0 && <NoData />}
{!children &&
rows.map((row, index) => {
const id = rowKey ? rowKey(row) : index;
return <TableRow key={id} columns={columns} row={row} />;
})}
{children}
</div>
</div>
);
}
export const TableRow = ({ columns, row }) => (
<div className={classNames(styles.row, 'row')}>
{columns.map(({ key, render, className, style, cell }, index) => (
<div
key={`${key}-${index}`}
className={classNames(styles.cell, className, cell?.className)}
style={{ ...style, ...cell?.style }}
>
{render ? render(row) : row[key]}
</div>
))}
</div>
);

View file

@ -0,0 +1,30 @@
.table {
display: flex;
flex-direction: column;
}
.header {
border-bottom: 1px solid var(--gray300);
}
.head {
font-size: var(--font-size-small);
font-weight: 600;
line-height: 40px;
}
.body {
position: relative;
display: flex;
flex-direction: column;
}
.row {
border-bottom: 1px solid var(--gray300);
padding: 10px 0;
}
.cell {
display: flex;
align-items: flex-start;
}

7
components/common/Tag.js Normal file
View file

@ -0,0 +1,7 @@
import React from 'react';
import classNames from 'classnames';
import styles from './Tag.module.css';
export default function Tag({ className, children }) {
return <span className={classNames(styles.tag, className)}>{children}</span>;
}

View file

@ -0,0 +1,6 @@
.tag {
padding: 2px 4px;
border: 1px solid var(--gray300);
border-radius: 4px;
margin-right: 10px;
}

View file

@ -0,0 +1,26 @@
import React, { useEffect } from 'react';
import ReactDOM from 'react-dom';
import { useSpring, animated } from 'react-spring';
import styles from './Toast.module.css';
import Icon from 'components/common/Icon';
import Close from 'assets/times.svg';
export default function Toast({ message, timeout = 3000, onClose }) {
const props = useSpring({
opacity: 1,
transform: 'translate3d(0,0px,0)',
from: { opacity: 0, transform: 'translate3d(0,-40px,0)' },
});
useEffect(() => {
setTimeout(onClose, timeout);
}, []);
return ReactDOM.createPortal(
<animated.div className={styles.toast} style={props} onClick={onClose}>
<div className={styles.message}>{message}</div>
<Icon className={styles.close} icon={<Close />} size="small" />
</animated.div>,
document.getElementById('__modals'),
);
}

View file

@ -0,0 +1,25 @@
.toast {
position: absolute;
top: 30px;
left: 0;
right: 0;
width: 300px;
border-radius: 5px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
color: var(--msgColor);
background: var(--green400);
margin: auto;
z-index: 2;
cursor: pointer;
}
.message {
font-size: var(--font-size-normal);
}
.close {
margin-left: 20px;
}

View file

@ -0,0 +1,47 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import useVersion from 'hooks/useVersion';
import styles from './UpdateNotice.module.css';
import ButtonLayout from '../layout/ButtonLayout';
import Button from './Button';
import useForceUpdate from '../../hooks/useForceUpdate';
export default function UpdateNotice() {
const forceUpdate = useForceUpdate();
const { hasUpdate, checked, latest, updateCheck } = useVersion(true);
function handleViewClick() {
location.href = 'https://github.com/mikecao/umami/releases';
updateCheck();
forceUpdate();
}
function handleDismissClick() {
updateCheck();
forceUpdate();
}
if (!hasUpdate || checked) {
return null;
}
return (
<div className={styles.notice}>
<div className={styles.message}>
<FormattedMessage
id="message.new-version-available"
defaultMessage="A new version of umami {version} is available!"
values={{ version: `v${latest}` }}
/>
</div>
<ButtonLayout>
<Button size="xsmall" variant="action" onClick={handleViewClick}>
<FormattedMessage id="label.view-details" defaultMessage="View details" />
</Button>
<Button size="xsmall" onClick={handleDismissClick}>
<FormattedMessage id="label.dismiss" defaultMessage="Dismiss" />
</Button>
</ButtonLayout>
</div>
);
}

View file

@ -0,0 +1,13 @@
.notice {
display: flex;
justify-content: center;
align-items: center;
padding-top: 10px;
font-size: var(--font-size-small);
font-weight: 600;
}
.message {
text-align: center;
margin-right: 20px;
}

View file

@ -0,0 +1,91 @@
import React, { useState, useMemo } from 'react';
import ReactTooltip from 'react-tooltip';
import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps';
import classNames from 'classnames';
import tinycolor from 'tinycolor2';
import useTheme from 'hooks/useTheme';
import { THEME_COLORS } from 'lib/constants';
import styles from './WorldMap.module.css';
import useCountryNames from 'hooks/useCountryNames';
import useLocale from 'hooks/useLocale';
import { useRouter } from 'next/router';
const geoUrl = '/world-110m.json';
export default function WorldMap({ data, className }) {
const { basePath } = useRouter();
const [tooltip, setTooltip] = useState();
const [theme] = useTheme();
const colors = useMemo(
() => ({
baseColor: THEME_COLORS[theme].primary,
fillColor: THEME_COLORS[theme].gray100,
strokeColor: THEME_COLORS[theme].primary,
hoverColor: THEME_COLORS[theme].primary,
}),
[theme],
);
const [locale] = useLocale();
const countryNames = useCountryNames(locale);
function getFillColor(code) {
if (code === 'AQ') return;
const country = data?.find(({ x }) => x === code);
if (!country) {
return colors.fillColor;
}
return tinycolor(colors.baseColor)[theme === 'light' ? 'lighten' : 'darken'](
40 * (1.0 - country.z / 100),
);
}
function getOpacity(code) {
return code === 'AQ' ? 0 : 1;
}
function handleHover(code) {
if (code === 'AQ') return;
const country = data?.find(({ x }) => x === code);
setTooltip(`${countryNames[code]}: ${country?.y || 0} visitors`);
}
return (
<div
className={classNames(styles.container, className)}
data-tip=""
data-for="world-map-tooltip"
>
<ComposableMap projection="geoMercator">
<ZoomableGroup zoom={0.8} minZoom={0.7} center={[0, 40]}>
<Geographies geography={`${basePath}${geoUrl}`}>
{({ geographies }) => {
return geographies.map(geo => {
const code = geo.properties.ISO_A2;
return (
<Geography
key={geo.rsmKey}
geography={geo}
fill={getFillColor(code)}
stroke={colors.strokeColor}
opacity={getOpacity(code)}
style={{
default: { outline: 'none' },
hover: { outline: 'none', fill: colors.hoverColor },
pressed: { outline: 'none' },
}}
onMouseOver={() => handleHover(code)}
onMouseOut={() => setTooltip(null)}
/>
);
});
}}
</Geographies>
</ZoomableGroup>
</ComposableMap>
<ReactTooltip id="world-map-tooltip">{tooltip}</ReactTooltip>
</div>
);
}

View file

@ -0,0 +1,88 @@
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { Formik, Form, Field } from 'formik';
import Button from 'components/common/Button';
import FormLayout, {
FormButtons,
FormError,
FormMessage,
FormRow,
} from 'components/layout/FormLayout';
import usePost from 'hooks/usePost';
const initialValues = {
username: '',
password: '',
};
const validate = ({ user_id, username, password }) => {
const errors = {};
if (!username) {
errors.username = <FormattedMessage id="label.required" defaultMessage="Required" />;
}
if (!user_id && !password) {
errors.password = <FormattedMessage id="label.required" defaultMessage="Required" />;
}
return errors;
};
export default function AccountEditForm({ values, onSave, onClose }) {
const post = usePost();
const [message, setMessage] = useState();
const handleSubmit = async values => {
const { ok, data } = await post('/api/account', values);
if (ok) {
onSave();
} else {
setMessage(
data || <FormattedMessage id="message.failure" defaultMessage="Something went wrong." />,
);
}
};
return (
<FormLayout>
<Formik
initialValues={{ ...initialValues, ...values }}
validate={validate}
onSubmit={handleSubmit}
>
{() => (
<Form>
<FormRow>
<label htmlFor="username">
<FormattedMessage id="label.username" defaultMessage="Username" />
</label>
<div>
<Field name="username" type="text" />
<FormError name="username" />
</div>
</FormRow>
<FormRow>
<label htmlFor="password">
<FormattedMessage id="label.password" defaultMessage="Password" />
</label>
<div>
<Field name="password" type="password" />
<FormError name="password" />
</div>
</FormRow>
<FormButtons>
<Button type="submit" variant="action">
<FormattedMessage id="label.save" defaultMessage="Save" />
</Button>
<Button onClick={onClose}>
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
</Button>
</FormButtons>
<FormMessage>{message}</FormMessage>
</Form>
)}
</Formik>
</FormLayout>
);
}

View file

@ -0,0 +1,105 @@
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { Formik, Form, Field } from 'formik';
import Button from 'components/common/Button';
import FormLayout, {
FormButtons,
FormError,
FormMessage,
FormRow,
} from 'components/layout/FormLayout';
import usePost from 'hooks/usePost';
const initialValues = {
current_password: '',
new_password: '',
confirm_password: '',
};
const validate = ({ current_password, new_password, confirm_password }) => {
const errors = {};
if (!current_password) {
errors.current_password = <FormattedMessage id="label.required" defaultMessage="Required" />;
}
if (!new_password) {
errors.new_password = <FormattedMessage id="label.required" defaultMessage="Required" />;
}
if (!confirm_password) {
errors.confirm_password = <FormattedMessage id="label.required" defaultMessage="Required" />;
} else if (new_password !== confirm_password) {
errors.confirm_password = (
<FormattedMessage id="label.passwords-dont-match" defaultMessage="Passwords don't match" />
);
}
return errors;
};
export default function ChangePasswordForm({ values, onSave, onClose }) {
const post = usePost();
const [message, setMessage] = useState();
const handleSubmit = async values => {
const { ok, data } = await post('/api/account/password', values);
if (ok) {
onSave();
} else {
setMessage(
data || <FormattedMessage id="message.failure" defaultMessage="Something went wrong." />,
);
}
};
return (
<FormLayout>
<Formik
initialValues={{ ...initialValues, ...values }}
validate={validate}
onSubmit={handleSubmit}
>
{() => (
<Form>
<FormRow>
<label htmlFor="current_password">
<FormattedMessage id="label.current-password" defaultMessage="Current password" />
</label>
<div>
<Field name="current_password" type="password" />
<FormError name="current_password" />
</div>
</FormRow>
<FormRow>
<label htmlFor="new_password">
<FormattedMessage id="label.new-password" defaultMessage="New password" />
</label>
<div>
<Field name="new_password" type="password" />
<FormError name="new_password" />
</div>
</FormRow>
<FormRow>
<label htmlFor="confirm_password">
<FormattedMessage id="label.confirm-password" defaultMessage="Confirm password" />
</label>
<div>
<Field name="confirm_password" type="password" />
<FormError name="confirm_password" />
</div>
</FormRow>
<FormButtons>
<Button type="submit" variant="action">
<FormattedMessage id="label.save" defaultMessage="Save" />
</Button>
<Button onClick={onClose}>
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
</Button>
</FormButtons>
<FormMessage>{message}</FormMessage>
</Form>
)}
</Formik>
</FormLayout>
);
}

View file

@ -0,0 +1,83 @@
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { isAfter, isBefore, isSameDay } from 'date-fns';
import Calendar from 'components/common/Calendar';
import Button from 'components/common/Button';
import { FormButtons } from 'components/layout/FormLayout';
import { getDateRangeValues } from 'lib/date';
import styles from './DatePickerForm.module.css';
import ButtonGroup from 'components/common/ButtonGroup';
const FILTER_DAY = 0;
const FILTER_RANGE = 1;
export default function DatePickerForm({
startDate: defaultStartDate,
endDate: defaultEndDate,
minDate,
maxDate,
onChange,
onClose,
}) {
const [selected, setSelected] = useState(
isSameDay(defaultStartDate, defaultEndDate) ? FILTER_DAY : FILTER_RANGE,
);
const [date, setDate] = useState(defaultStartDate);
const [startDate, setStartDate] = useState(defaultStartDate);
const [endDate, setEndDate] = useState(defaultEndDate);
const disabled =
selected === FILTER_DAY
? isAfter(minDate, date) && isBefore(maxDate, date)
: isAfter(startDate, endDate);
const buttons = [
{
label: <FormattedMessage id="label.single-day" defaultMessage="Single day" />,
value: FILTER_DAY,
},
{
label: <FormattedMessage id="label.date-range" defaultMessage="Date range" />,
value: FILTER_RANGE,
},
];
function handleSave() {
if (selected === FILTER_DAY) {
onChange({ ...getDateRangeValues(date, date), value: 'custom' });
} else {
onChange({ ...getDateRangeValues(startDate, endDate), value: 'custom' });
}
}
return (
<div className={styles.container}>
<div className={styles.filter}>
<ButtonGroup size="small" items={buttons} selectedItem={selected} onClick={setSelected} />
</div>
<div className={styles.calendars}>
{selected === FILTER_DAY ? (
<Calendar date={date} minDate={minDate} maxDate={maxDate} onChange={setDate} />
) : (
<>
<Calendar
date={startDate}
minDate={minDate}
maxDate={endDate}
onChange={setStartDate}
/>
<Calendar date={endDate} minDate={startDate} maxDate={maxDate} onChange={setEndDate} />
</>
)}
</div>
<FormButtons>
<Button variant="action" onClick={handleSave} disabled={disabled}>
<FormattedMessage id="label.save" defaultMessage="Save" />
</Button>
<Button onClick={onClose}>
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
</Button>
</FormButtons>
</div>
);
}

View file

@ -9,10 +9,14 @@
justify-content: center;
}
.calendars > div {
width: 380px;
}
.calendars > div + div {
margin-inline-start: 20px;
padding-inline-start: 20px;
border-inline-start: 1px solid var(--base300);
margin-left: 20px;
padding-left: 20px;
border-left: 1px solid var(--gray300);
}
.filter {
@ -22,14 +26,6 @@
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;
@ -37,7 +33,7 @@
.calendars > div + div {
padding: 0;
margin-inline-start: 0;
margin-left: 0;
margin-top: 20px;
border: 0;
}

View file

@ -0,0 +1,98 @@
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { Formik, Form, Field } from 'formik';
import Button from 'components/common/Button';
import FormLayout, {
FormButtons,
FormError,
FormMessage,
FormRow,
} from 'components/layout/FormLayout';
import useDelete from 'hooks/useDelete';
const CONFIRMATION_WORD = 'DELETE';
const validate = ({ confirmation }) => {
const errors = {};
if (confirmation !== CONFIRMATION_WORD) {
errors.confirmation = !confirmation ? (
<FormattedMessage id="label.required" defaultMessage="Required" />
) : (
<FormattedMessage id="label.invalid" defaultMessage="Invalid" />
);
}
return errors;
};
export default function DeleteForm({ values, onSave, onClose }) {
const del = useDelete();
const [message, setMessage] = useState();
const handleSubmit = async ({ type, id }) => {
const { ok, data } = await del(`/api/${type}/${id}`);
if (ok) {
onSave();
} else {
setMessage(
data || <FormattedMessage id="message.failure" defaultMessage="Something went wrong." />,
);
}
};
return (
<FormLayout>
<Formik
initialValues={{ confirmation: '', ...values }}
validate={validate}
onSubmit={handleSubmit}
>
{props => (
<Form>
<div>
<FormattedMessage
id="message.confirm-delete"
defaultMessage="Are your sure you want to delete {target}?"
values={{ target: <b>{values.name}</b> }}
/>
</div>
<div>
<FormattedMessage
id="message.delete-warning"
defaultMessage="All associated data will be deleted as well."
/>
</div>
<p>
<FormattedMessage
id="message.type-delete"
defaultMessage="Type {delete} in the box below to confirm."
values={{ delete: <b>{CONFIRMATION_WORD}</b> }}
/>
</p>
<FormRow>
<div>
<Field name="confirmation" type="text" />
<FormError name="confirmation" />
</div>
</FormRow>
<FormButtons>
<Button
type="submit"
variant="danger"
disabled={props.values.confirmation !== CONFIRMATION_WORD}
>
<FormattedMessage id="label.delete" defaultMessage="Delete" />
</Button>
<Button onClick={onClose}>
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
</Button>
</FormButtons>
<FormMessage>{message}</FormMessage>
</Form>
)}
</Formik>
</FormLayout>
);
}

View file

@ -0,0 +1,102 @@
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { Formik, Form, Field } from 'formik';
import { useRouter } from 'next/router';
import Button from 'components/common/Button';
import FormLayout, {
FormButtons,
FormError,
FormMessage,
FormRow,
} from 'components/layout/FormLayout';
import Icon from 'components/common/Icon';
import Logo from 'assets/logo.svg';
import styles from './LoginForm.module.css';
import usePost from 'hooks/usePost';
const validate = ({ username, password }) => {
const errors = {};
if (!username) {
errors.username = <FormattedMessage id="label.required" defaultMessage="Required" />;
}
if (!password) {
errors.password = <FormattedMessage id="label.required" defaultMessage="Required" />;
}
return errors;
};
export default function LoginForm() {
const post = usePost();
const router = useRouter();
const [message, setMessage] = useState();
const handleSubmit = async ({ username, password }) => {
const { ok, status, data } = await post('/api/auth/login', {
username,
password,
});
if (ok) {
return router.push('/');
} else {
setMessage(
status === 401 ? (
<FormattedMessage
id="message.incorrect-username-password"
defaultMessage="Incorrect username/password."
/>
) : (
data
),
);
}
};
return (
<FormLayout className={styles.login}>
<Formik
initialValues={{
username: '',
password: '',
}}
validate={validate}
onSubmit={handleSubmit}
>
{() => (
<Form>
<div className={styles.header}>
<Icon icon={<Logo />} size="xlarge" className={styles.icon} />
<h1 className="center">umami</h1>
</div>
<FormRow>
<label htmlFor="username">
<FormattedMessage id="label.username" defaultMessage="Username" />
</label>
<div>
<Field name="username" type="text" />
<FormError name="username" />
</div>
</FormRow>
<FormRow>
<label htmlFor="password">
<FormattedMessage id="label.password" defaultMessage="Password" />
</label>
<div>
<Field name="password" type="password" />
<FormError name="password" />
</div>
</FormRow>
<FormButtons>
<Button type="submit" variant="action">
<FormattedMessage id="label.login" defaultMessage="Login" />
</Button>
</FormButtons>
<FormMessage>{message}</FormMessage>
</Form>
)}
</Formik>
</FormLayout>
);
}

View file

@ -0,0 +1,23 @@
.login {
display: flex;
flex-direction: column;
margin-top: 80px;
}
.login form {
margin: 0 auto;
}
.icon {
display: flex;
justify-content: center;
margin: 0 auto;
}
.header {
margin-bottom: 30px;
}
.header h1 {
margin: 12px 0;
}

View file

@ -0,0 +1,38 @@
import React, { useRef } from 'react';
import { FormattedMessage } from 'react-intl';
import Button from 'components/common/Button';
import FormLayout, { FormButtons, FormRow } from 'components/layout/FormLayout';
import CopyButton from 'components/common/CopyButton';
export default function TrackingCodeForm({ values, onClose }) {
const ref = useRef();
const { name, share_id } = values;
return (
<FormLayout>
<p>
<FormattedMessage
id="message.share-url"
defaultMessage="This is the publicly shared URL for {target}."
values={{ target: <b>{values.name}</b> }}
/>
</p>
<FormRow>
<textarea
ref={ref}
rows={3}
cols={60}
spellCheck={false}
defaultValue={`${document.location.origin}/share/${share_id}/${encodeURIComponent(name)}`}
readOnly
/>
</FormRow>
<FormButtons>
<CopyButton type="submit" variant="action" element={ref} />
<Button onClick={onClose}>
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
</Button>
</FormButtons>
</FormLayout>
);
}

View file

@ -0,0 +1,37 @@
import React, { useRef } from 'react';
import { FormattedMessage } from 'react-intl';
import Button from 'components/common/Button';
import FormLayout, { FormButtons, FormRow } from 'components/layout/FormLayout';
import CopyButton from 'components/common/CopyButton';
export default function TrackingCodeForm({ values, onClose }) {
const ref = useRef();
return (
<FormLayout>
<p>
<FormattedMessage
id="message.track-stats"
defaultMessage="To track stats for {target}, place the following code in the {head} section of your website."
values={{ head: '<head>', target: <b>{values.name}</b> }}
/>
</p>
<FormRow>
<textarea
ref={ref}
rows={3}
cols={60}
spellCheck={false}
defaultValue={`<script async defer data-website-id="${values.website_uuid}" src="${document.location.origin}/umami.js"></script>`}
readOnly
/>
</FormRow>
<FormButtons>
<CopyButton type="submit" variant="action" element={ref} />
<Button onClick={onClose}>
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
</Button>
</FormButtons>
</FormLayout>
);
}

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