Compare commits
No commits in common. "master" and "v1.26.0" have entirely different histories.
|
|
@ -4,4 +4,3 @@ Dockerfile
|
|||
.gitignore
|
||||
.DS_Store
|
||||
node_modules
|
||||
.idea
|
||||
|
|
@ -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", "next"],
|
||||
"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"
|
||||
|
|
|
|||
36
.github/ISSUE_TEMPLATE/1.bug_report.yml
vendored
|
|
@ -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'
|
||||
9
.github/ISSUE_TEMPLATE/2.feature_request.yml
vendored
|
|
@ -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
|
||||
6
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -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
|
|
@ -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
|
||||
43
.github/workflows/cd-manual.yml
vendored
|
|
@ -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 }}
|
||||
44
.github/workflows/cd.yml
vendored
|
|
@ -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 }}
|
||||
36
.github/workflows/ci.yml
vendored
|
|
@ -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
|
|
@ -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
|
||||
run: >-
|
||||
echo "${{ secrets.GITHUB_TOKEN }}"
|
||||
| docker login -u "${{ github.actor }}" --password-stdin 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
|
||||
25
.github/workflows/stale-issues.yml
vendored
|
|
@ -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
|
||||
23
.gitignore
vendored
|
|
@ -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,19 @@ node_modules
|
|||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
/prisma/
|
||||
/prisma/schema.prisma
|
||||
|
||||
# production
|
||||
/build
|
||||
/public/script.js
|
||||
/geo
|
||||
/dist
|
||||
/public/umami.js
|
||||
/public/geo
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.idea
|
||||
.yarn
|
||||
*.iml
|
||||
*.log
|
||||
.vscode
|
||||
.tool-versions
|
||||
/.vscode/
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
|
|
@ -35,8 +32,6 @@ yarn-error.log*
|
|||
|
||||
# local env files
|
||||
.env
|
||||
.env.*
|
||||
*.env.*
|
||||
|
||||
*.dev.yml
|
||||
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
/public/script.js
|
||||
/public/
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
84
Dockerfile
|
|
@ -1,61 +1,43 @@
|
|||
# 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.22-alpine AS build
|
||||
ARG BASE_PATH
|
||||
ARG DATABASE_TYPE
|
||||
ENV BASE_PATH=$BASE_PATH
|
||||
ENV DATABASE_URL "postgresql://umami:umami@db:5432/umami" \
|
||||
DATABASE_TYPE=$DATABASE_TYPE
|
||||
WORKDIR /build
|
||||
|
||||
RUN yarn config set --home enableTelemetry 0
|
||||
COPY package.json yarn.lock /build/
|
||||
|
||||
# Install only the production dependencies
|
||||
RUN yarn install --production --frozen-lockfile
|
||||
|
||||
# Cache these modules for production
|
||||
RUN cp -R node_modules/ prod_node_modules/
|
||||
|
||||
# Install development dependencies
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
# 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.22-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"]
|
||||
|
|
|
|||
2
LICENSE
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
web: npm run start-env
|
||||
162
README.md
|
|
@ -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 12 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
|
||||
|
|
|
|||
38
app.json
|
|
@ -1,16 +1,26 @@
|
|||
{
|
||||
"name": "Umami",
|
||||
"description": "Umami is a simple, fast, website analytics alternative to Google Analytics.",
|
||||
"keywords": ["analytics", "charts", "statistics", "web-analytics"],
|
||||
"website": "https://umami.is",
|
||||
"repository": "https://github.com/umami-software/umami",
|
||||
"addons": ["heroku-postgresql"],
|
||||
"env": {
|
||||
"APP_SECRET": {
|
||||
"description": "Used to generate unique values for your installation",
|
||||
"required": true,
|
||||
"generator": "secret"
|
||||
}
|
||||
},
|
||||
"success_url": "/"
|
||||
"name": "Umami",
|
||||
"description": "Umami is a simple, fast, website analytics alternative to Google Analytics.",
|
||||
"keywords": [
|
||||
"analytics",
|
||||
"charts",
|
||||
"statistics",
|
||||
"web-analytics"
|
||||
],
|
||||
"website": "https://umami.is",
|
||||
"repository": "https://github.com/mikecao/umami",
|
||||
"addons": [
|
||||
"heroku-postgresql"
|
||||
],
|
||||
"env": {
|
||||
"HASH_SALT": {
|
||||
"description": "Used to generate unique values for your installation",
|
||||
"required": true,
|
||||
"generator": "secret"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"postdeploy": "psql $DATABASE_URL -f sql/schema.postgresql.sql"
|
||||
},
|
||||
"success_url": "/"
|
||||
}
|
||||
|
|
|
|||
1
assets/arrow-right.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M216.464 36.465l-7.071 7.07c-4.686 4.686-4.686 12.284 0 16.971L387.887 239H12c-6.627 0-12 5.373-12 12v10c0 6.627 5.373 12 12 12h375.887L209.393 451.494c-4.686 4.686-4.686 12.284 0 16.971l7.071 7.07c4.686 4.686 12.284 4.686 16.97 0l211.051-211.05c4.686-4.686 4.686-12.284 0-16.971L233.434 36.465c-4.686-4.687-12.284-4.687-16.97 0z"/></svg>
|
||||
|
After Width: | Height: | Size: 409 B |
1
assets/arrow-up-right-from-square.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!-- Font Awesome Pro 6.0.0-alpha2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M392 320C378.75 320 368 330.75 368 344V456C368 460.406 364.406 464 360 464H56C51.594 464 48 460.406 48 456V152C48 147.594 51.594 144 56 144H168C181.25 144 192 133.25 192 120S181.25 96 168 96H56C25.125 96 0 121.125 0 152V456C0 486.875 25.125 512 56 512H360C390.875 512 416 486.875 416 456V344C416 330.75 405.25 320 392 320ZM488 0H320C306.75 0 296 10.75 296 24S306.75 48 320 48H430.062L183.031 295.031C173.656 304.406 173.656 319.594 183.031 328.969C187.719 333.656 193.844 336 200 336S212.281 333.656 216.969 328.969L464 81.938V192C464 205.25 474.75 216 488 216S512 205.25 512 192V24C512 10.75 501.25 0 488 0Z"/></svg>
|
||||
|
After Width: | Height: | Size: 831 B |
1
assets/bars.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!-- Font Awesome Pro 6.0.0-alpha1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M424 392H24C11 392 0 403 0 416V416C0 429 11 440 24 440H424C437 440 448 429 448 416V416C448 403 437 392 424 392ZM424 72H24C11 72 0 83 0 96V96C0 109 11 120 24 120H424C437 120 448 109 448 96V96C448 83 437 72 424 72ZM424 232H24C11 232 0 243 0 256V256C0 269 11 280 24 280H424C437 280 448 269 448 256V256C448 243 437 232 424 232Z"/></svg>
|
||||
|
After Width: | Height: | Size: 546 B |
|
Before Width: | Height: | Size: 289 B After Width: | Height: | Size: 289 B |
|
Before Width: | Height: | Size: 1,002 B After Width: | Height: | Size: 1,002 B |
1
assets/chart-bar.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!-- Font Awesome Pro 5.15.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M396.8 352h22.4c6.4 0 12.8-6.4 12.8-12.8V108.8c0-6.4-6.4-12.8-12.8-12.8h-22.4c-6.4 0-12.8 6.4-12.8 12.8v230.4c0 6.4 6.4 12.8 12.8 12.8zm-192 0h22.4c6.4 0 12.8-6.4 12.8-12.8V140.8c0-6.4-6.4-12.8-12.8-12.8h-22.4c-6.4 0-12.8 6.4-12.8 12.8v198.4c0 6.4 6.4 12.8 12.8 12.8zm96 0h22.4c6.4 0 12.8-6.4 12.8-12.8V204.8c0-6.4-6.4-12.8-12.8-12.8h-22.4c-6.4 0-12.8 6.4-12.8 12.8v134.4c0 6.4 6.4 12.8 12.8 12.8zM496 400H48V80c0-8.84-7.16-16-16-16H16C7.16 64 0 71.16 0 80v336c0 17.67 14.33 32 32 32h464c8.84 0 16-7.16 16-16v-16c0-8.84-7.16-16-16-16zm-387.2-48h22.4c6.4 0 12.8-6.4 12.8-12.8v-70.4c0-6.4-6.4-12.8-12.8-12.8h-22.4c-6.4 0-12.8 6.4-12.8 12.8v70.4c0 6.4 6.4 12.8 12.8 12.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 885 B |
1
assets/check.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M435.848 83.466L172.804 346.51l-96.652-96.652c-4.686-4.686-12.284-4.686-16.971 0l-28.284 28.284c-4.686 4.686-4.686 12.284 0 16.971l133.421 133.421c4.686 4.686 12.284 4.686 16.971 0l299.813-299.813c4.686-4.686 4.686-12.284 0-16.971l-28.284-28.284c-4.686-4.686-12.284-4.686-16.97 0z"/></svg>
|
||||
|
After Width: | Height: | Size: 360 B |
1
assets/chevron-down.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M441.9 167.3l-19.8-19.8c-4.7-4.7-12.3-4.7-17 0L224 328.2 42.9 147.5c-4.7-4.7-12.3-4.7-17 0L6.1 167.3c-4.7 4.7-4.7 12.3 0 17l209.4 209.4c4.7 4.7 12.3 4.7 17 0l209.4-209.4c4.7-4.7 4.7-12.3 0-17z"/></svg>
|
||||
|
After Width: | Height: | Size: 272 B |
1
assets/code.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M234.8 511.7L196 500.4c-4.2-1.2-6.7-5.7-5.5-9.9L331.3 5.8c1.2-4.2 5.7-6.7 9.9-5.5L380 11.6c4.2 1.2 6.7 5.7 5.5 9.9L244.7 506.2c-1.2 4.3-5.6 6.7-9.9 5.5zm-83.2-121.1l27.2-29c3.1-3.3 2.8-8.5-.5-11.5L72.2 256l106.1-94.1c3.4-3 3.6-8.2.5-11.5l-27.2-29c-3-3.2-8.1-3.4-11.3-.4L2.5 250.2c-3.4 3.2-3.4 8.5 0 11.7L140.3 391c3.2 3 8.2 2.8 11.3-.4zm284.1.4l137.7-129.1c3.4-3.2 3.4-8.5 0-11.7L435.7 121c-3.2-3-8.3-2.9-11.3.4l-27.2 29c-3.1 3.3-2.8 8.5.5 11.5L503.8 256l-106.1 94.1c-3.4 3-3.6 8.2-.5 11.5l27.2 29c3.1 3.2 8.1 3.4 11.3.4z"/></svg>
|
||||
|
After Width: | Height: | Size: 601 B |
1
assets/ellipsis-h.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M304 256c0 26.5-21.5 48-48 48s-48-21.5-48-48 21.5-48 48-48 48 21.5 48 48zm120-48c-26.5 0-48 21.5-48 48s21.5 48 48 48 48-21.5 48-48-21.5-48-48-48zm-336 0c-26.5 0-48 21.5-48 48s21.5 48 48 48 48-21.5 48-48-21.5-48-48-48z"/></svg>
|
||||
|
After Width: | Height: | Size: 297 B |
1
assets/exclamation-triangle.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M270.2 160h35.5c3.4 0 6.1 2.8 6 6.2l-7.5 196c-.1 3.2-2.8 5.8-6 5.8h-20.5c-3.2 0-5.9-2.5-6-5.8l-7.5-196c-.1-3.4 2.6-6.2 6-6.2zM288 388c-15.5 0-28 12.5-28 28s12.5 28 28 28 28-12.5 28-28-12.5-28-28-28zm281.5 52L329.6 24c-18.4-32-64.7-32-83.2 0L6.5 440c-18.4 31.9 4.6 72 41.6 72H528c36.8 0 60-40 41.5-72zM528 480H48c-12.3 0-20-13.3-13.9-24l240-416c6.1-10.6 21.6-10.7 27.7 0l240 416c6.2 10.6-1.5 24-13.8 24z"/></svg>
|
||||
|
After Width: | Height: | Size: 482 B |
1
assets/external-link.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M497.6,0,334.4.17A14.4,14.4,0,0,0,320,14.57V47.88a14.4,14.4,0,0,0,14.69,14.4l73.63-2.72,2.06,2.06L131.52,340.49a12,12,0,0,0,0,17l23,23a12,12,0,0,0,17,0L450.38,101.62l2.06,2.06-2.72,73.63A14.4,14.4,0,0,0,464.12,192h33.31a14.4,14.4,0,0,0,14.4-14.4L512,14.4A14.4,14.4,0,0,0,497.6,0ZM432,288H416a16,16,0,0,0-16,16V458a6,6,0,0,1-6,6H54a6,6,0,0,1-6-6V118a6,6,0,0,1,6-6H208a16,16,0,0,0,16-16V80a16,16,0,0,0-16-16H48A48,48,0,0,0,0,112V464a48,48,0,0,0,48,48H400a48,48,0,0,0,48-48V304A16,16,0,0,0,432,288Z"/></svg>
|
||||
|
After Width: | Height: | Size: 575 B |
|
Before Width: | Height: | Size: 509 B After Width: | Height: | Size: 509 B |
|
Before Width: | Height: | Size: 874 B After Width: | Height: | Size: 874 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
1
assets/list-ul.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M48 368a48 48 0 1 0 48 48 48 48 0 0 0-48-48zm0-160a48 48 0 1 0 48 48 48 48 0 0 0-48-48zm0-160a48 48 0 1 0 48 48 48 48 0 0 0-48-48zm448 24H176a16 16 0 0 0-16 16v16a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16V88a16 16 0 0 0-16-16zm0 160H176a16 16 0 0 0-16 16v16a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16v-16a16 16 0 0 0-16-16zm0 160H176a16 16 0 0 0-16 16v16a16 16 0 0 0 16 16h320a16 16 0 0 0 16-16v-16a16 16 0 0 0-16-16z"/></svg>
|
||||
|
After Width: | Height: | Size: 492 B |
2
assets/logo.svg
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 428 389.11">
|
||||
<circle cx="214.15" cy="181" r="171" fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="20"/><path d="M413,134.11H15.29a15,15,0,0,0-15,15v15.3C.12,168,0,171.52,0,175.11c0,118.19,95.81,214,214,214,116.4,0,211.1-92.94,213.93-208.67,0-.44.07-.88.07-1.33v-30A15,15,0,0,0,413,134.11Z"/></svg>
|
||||
|
After Width: | Height: | Size: 377 B |
1
assets/moon.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1399.98 1400"><path d="M562.44,837.55C335.89,611,288.08,273.54,418.71,0A734.31,734.31,0,0,0,215.54,143.73c-287.39,287.39-287.39,753.33,0,1040.72s753.33,287.4,1040.74,0A733.8,733.8,0,0,0,1400,981.29C1126.45,1111.92,789,1064.09,562.44,837.55Z"/></svg>
|
||||
|
After Width: | Height: | Size: 302 B |
1
assets/pen.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M493.26 56.26l-37.51-37.51C443.25 6.25 426.87 0 410.49 0s-32.76 6.25-45.25 18.74l-74.49 74.49L256 127.98 12.85 371.12.15 485.34C-1.45 499.72 9.88 512 23.95 512c.89 0 1.79-.05 2.69-.15l114.14-12.61L384.02 256l34.74-34.74 74.49-74.49c25-25 25-65.52.01-90.51zM118.75 453.39l-67.58 7.46 7.53-67.69 231.24-231.24 31.02-31.02 60.14 60.14-31.02 31.02-231.33 231.33zm340.56-340.57l-44.28 44.28-60.13-60.14 44.28-44.28c4.08-4.08 8.84-4.69 11.31-4.69s7.24.61 11.31 4.69l37.51 37.51c6.24 6.25 6.24 16.4 0 22.63z"/></svg>
|
||||
|
After Width: | Height: | Size: 580 B |
1
assets/plus.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="M368 224H224V80c0-8.84-7.16-16-16-16h-32c-8.84 0-16 7.16-16 16v144H16c-8.84 0-16 7.16-16 16v32c0 8.84 7.16 16 16 16h144v144c0 8.84 7.16 16 16 16h32c8.84 0 16-7.16 16-16V288h144c8.84 0 16-7.16 16-16v-32c0-8.84-7.16-16-16-16z"/></svg>
|
||||
|
After Width: | Height: | Size: 303 B |
|
Before Width: | Height: | Size: 653 B After Width: | Height: | Size: 653 B |
1
assets/sun.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400"><path d="M367.43,422.13a54.44,54.44,0,0,1-38.66-16L205,282.35A54.69,54.69,0,0,1,282.37,205L406.11,328.79a54.68,54.68,0,0,1-38.68,93.34Z"/><path d="M1156.3,1211a54.51,54.51,0,0,1-38.67-16L993.89,1071.21a54.68,54.68,0,1,1,77.34-77.33L1195,1117.65A54.7,54.7,0,0,1,1156.3,1211Z"/><path d="M243.7,1211A54.7,54.7,0,0,1,205,1117.65L328.74,993.89a54.69,54.69,0,0,1,77.36,77.32L282.37,1195A54.51,54.51,0,0,1,243.7,1211Z"/><path d="M1032.57,422.13a54.68,54.68,0,0,1-38.68-93.34L1117.61,205A54.69,54.69,0,0,1,1195,282.35L1071.23,406.11A54.44,54.44,0,0,1,1032.57,422.13Z"/><path d="M229.69,754.69h-175a54.69,54.69,0,0,1,0-109.38h175a54.69,54.69,0,0,1,0,109.38Z"/><path d="M1345.31,754.69h-175a54.69,54.69,0,0,1,0-109.38h175a54.69,54.69,0,0,1,0,109.38Z"/><path d="M700,1400a54.68,54.68,0,0,1-54.69-54.69v-175a54.69,54.69,0,0,1,109.38,0v175A54.68,54.68,0,0,1,700,1400Z"/><path d="M700,284.38a54.7,54.7,0,0,1-54.69-54.69v-175a54.69,54.69,0,0,1,109.38,0v175A54.7,54.7,0,0,1,700,284.38Z"/><circle cx="700" cy="700" r="306.25"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
1
assets/times.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path d="M207.6 256l107.72-107.72c6.23-6.23 6.23-16.34 0-22.58l-25.03-25.03c-6.23-6.23-16.34-6.23-22.58 0L160 208.4 52.28 100.68c-6.23-6.23-16.34-6.23-22.58 0L4.68 125.7c-6.23 6.23-6.23 16.34 0 22.58L112.4 256 4.68 363.72c-6.23 6.23-6.23 16.34 0 22.58l25.03 25.03c6.23 6.23 16.34 6.23 22.58 0L160 303.6l107.72 107.72c6.23 6.23 16.34 6.23 22.58 0l25.03-25.03c6.23-6.23 6.23-16.34 0-22.58L207.6 256z"/></svg>
|
||||
|
After Width: | Height: | Size: 468 B |
1
assets/trash.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M432 80h-82.4l-34-56.7A48 48 0 0 0 274.4 0H173.6a48 48 0 0 0-41.2 23.3L98.4 80H16A16 16 0 0 0 0 96v16a16 16 0 0 0 16 16h16l21.2 339a48 48 0 0 0 47.9 45h245.8a48 48 0 0 0 47.9-45L416 128h16a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16zM173.6 48h100.8l19.2 32H154.4zm173.3 416H101.11l-21-336h287.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 370 B |
1
assets/user.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1652 1652"><title>Asset 1</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path d="M1587.07,504.47A828.56,828.56,0,1,0,1652,826,823.13,823.13,0,0,0,1587.07,504.47ZM826,1577a747.29,747.29,0,0,1-464.48-161.26,39.94,39.94,0,0,0,2.8-11.35,458.82,458.82,0,0,1,34.29-135.74,464.15,464.15,0,0,1,854.78,0,458.82,458.82,0,0,1,34.29,135.74,39.94,39.94,0,0,0,2.8,11.35A747.29,747.29,0,0,1,826,1577ZM719.81,866.57A274,274,0,1,1,826,888,272.1,272.1,0,0,1,719.81,866.57Zm641.28,485.87c-36.11-201.1-182.78-363.82-374.86-423,114.28-58.37,192.53-177.22,192.53-314.35,0-194.83-157.94-352.76-352.76-352.76S473.24,420.29,473.24,615.12c0,137.13,78.25,256,192.53,314.35-192.08,59.15-338.75,221.87-374.86,423C157.46,1216.81,75,1030.86,75,826,75,411.9,411.9,75,826,75s751,336.9,751,751C1577,1030.86,1494.54,1216.81,1361.09,1352.44Z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 910 B |
1
assets/visitor.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M224 256c70.7 0 128-57.3 128-128S294.7 0 224 0 96 57.3 96 128s57.3 128 128 128zm89.6 32h-16.7c-22.2 10.2-46.9 16-72.9 16s-50.6-5.8-72.9-16h-16.7C60.2 288 0 348.2 0 422.4V464c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48v-41.6c0-74.2-60.2-134.4-134.4-134.4z"/></svg>
|
||||
|
After Width: | Height: | Size: 336 B |
1
assets/xmark.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!-- Font Awesome Pro 6.0.0-alpha1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M345 375C354 384 354 400 345 409S320 418 311 409L192 290L73 409C64 418 48 418 39 409S30 384 39 375L158 256L39 137C30 128 30 112 39 103S64 94 73 103L192 222L311 103C320 94 336 94 345 103S354 128 345 137L226 256L345 375Z"/></svg>
|
||||
|
After Width: | Height: | Size: 441 B |
63
components/common/Button.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ReactTooltip from 'react-tooltip';
|
||||
import classNames from 'classnames';
|
||||
import Icon from './Icon';
|
||||
import styles from './Button.module.css';
|
||||
|
||||
function Button({
|
||||
type = 'button',
|
||||
icon,
|
||||
size,
|
||||
variant,
|
||||
children,
|
||||
className,
|
||||
tooltip,
|
||||
tooltipId,
|
||||
disabled,
|
||||
iconRight,
|
||||
onClick = () => {},
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
data-tip={tooltip}
|
||||
data-effect="solid"
|
||||
data-for={tooltipId}
|
||||
data-offset={JSON.stringify({ left: 10 })}
|
||||
type={type}
|
||||
className={classNames(styles.button, className, {
|
||||
[styles.large]: size === 'large',
|
||||
[styles.small]: size === 'small',
|
||||
[styles.xsmall]: size === 'xsmall',
|
||||
[styles.action]: variant === 'action',
|
||||
[styles.danger]: variant === 'danger',
|
||||
[styles.light]: variant === 'light',
|
||||
[styles.iconRight]: iconRight,
|
||||
})}
|
||||
disabled={disabled}
|
||||
onClick={!disabled ? onClick : null}
|
||||
{...props}
|
||||
>
|
||||
{icon && <Icon className={styles.icon} icon={icon} size={size} />}
|
||||
{children && <div className={styles.label}>{children}</div>}
|
||||
{tooltip && <ReactTooltip id={tooltipId}>{tooltip}</ReactTooltip>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
Button.propTypes = {
|
||||
type: PropTypes.oneOf(['button', 'submit', 'reset']),
|
||||
icon: PropTypes.node,
|
||||
size: PropTypes.oneOf(['xlarge', 'large', 'medium', 'small', 'xsmall']),
|
||||
variant: PropTypes.oneOf(['action', 'danger', 'light']),
|
||||
children: PropTypes.node,
|
||||
className: PropTypes.string,
|
||||
tooltip: PropTypes.node,
|
||||
tooltipId: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
iconRight: PropTypes.bool,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
export default Button;
|
||||
102
components/common/Button.module.css
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
.button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: var(--font-size-normal);
|
||||
color: var(--gray900);
|
||||
background: var(--gray100);
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
border: 0;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: var(--gray200);
|
||||
}
|
||||
|
||||
.button:active {
|
||||
color: var(--gray900);
|
||||
}
|
||||
|
||||
.label {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.large {
|
||||
font-size: var(--font-size-large);
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.xsmall {
|
||||
font-size: var(--font-size-xsmall);
|
||||
}
|
||||
|
||||
.action,
|
||||
.action:active {
|
||||
color: var(--gray50);
|
||||
background: var(--gray900);
|
||||
}
|
||||
|
||||
.action:hover {
|
||||
background: var(--gray800);
|
||||
}
|
||||
|
||||
.danger,
|
||||
.danger:active {
|
||||
color: var(--gray50);
|
||||
background: var(--red500);
|
||||
}
|
||||
|
||||
.danger:hover {
|
||||
background: var(--red400);
|
||||
}
|
||||
|
||||
.light,
|
||||
.light:active {
|
||||
color: var(--gray900);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.light:hover {
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
.button .icon + * {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.button.iconRight .icon {
|
||||
order: 1;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.button.iconRight .icon + * {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: default;
|
||||
color: var(--gray500);
|
||||
background: var(--gray75);
|
||||
}
|
||||
|
||||
.button:disabled:active {
|
||||
color: var(--gray500);
|
||||
}
|
||||
|
||||
.button:disabled:hover {
|
||||
background: var(--gray75);
|
||||
}
|
||||
|
||||
.button.light:disabled {
|
||||
background: var(--gray50);
|
||||
}
|
||||
42
components/common/ButtonGroup.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import Button from './Button';
|
||||
import styles from './ButtonGroup.module.css';
|
||||
|
||||
function ButtonGroup({ items = [], selectedItem, className, size, icon, onClick = () => {} }) {
|
||||
return (
|
||||
<div className={classNames(styles.group, className)}>
|
||||
{items.map(item => {
|
||||
const { label, value } = item;
|
||||
return (
|
||||
<Button
|
||||
key={value}
|
||||
className={classNames(styles.button, { [styles.selected]: selectedItem === value })}
|
||||
size={size}
|
||||
icon={icon}
|
||||
onClick={() => onClick(value)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ButtonGroup.propTypes = {
|
||||
items: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
label: PropTypes.node,
|
||||
value: PropTypes.any.isRequired,
|
||||
}),
|
||||
),
|
||||
selectedItem: PropTypes.any,
|
||||
className: PropTypes.string,
|
||||
size: PropTypes.oneOf(['xlarge', 'large', 'medium', 'small', 'xsmall']),
|
||||
icon: PropTypes.node,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
export default ButtonGroup;
|
||||
31
components/common/ButtonGroup.module.css
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
.group {
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--gray500);
|
||||
}
|
||||
|
||||
.group .button {
|
||||
border-radius: 0;
|
||||
color: var(--gray800);
|
||||
background: var(--gray50);
|
||||
border-left: 1px solid var(--gray500);
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.group .button:first-child {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.group .button:hover {
|
||||
background: var(--gray100);
|
||||
}
|
||||
|
||||
.group .button + .button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.group .button.selected {
|
||||
color: var(--gray900);
|
||||
font-weight: 600;
|
||||
}
|
||||
273
components/common/Calendar.js
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
import React, { useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import {
|
||||
startOfWeek,
|
||||
startOfMonth,
|
||||
startOfYear,
|
||||
endOfMonth,
|
||||
addDays,
|
||||
subDays,
|
||||
addYears,
|
||||
subYears,
|
||||
addMonths,
|
||||
setMonth,
|
||||
setYear,
|
||||
isSameDay,
|
||||
isBefore,
|
||||
isAfter,
|
||||
} from 'date-fns';
|
||||
import Button from './Button';
|
||||
import useLocale from 'hooks/useLocale';
|
||||
import { dateFormat } from 'lib/date';
|
||||
import { chunk } from 'lib/array';
|
||||
import { getDateLocale } from 'lib/lang';
|
||||
import Chevron from 'assets/chevron-down.svg';
|
||||
import Cross from 'assets/times.svg';
|
||||
import styles from './Calendar.module.css';
|
||||
import Icon from './Icon';
|
||||
|
||||
export default function Calendar({ date, minDate, maxDate, onChange }) {
|
||||
const { locale } = useLocale();
|
||||
const [selectMonth, setSelectMonth] = useState(false);
|
||||
const [selectYear, setSelectYear] = useState(false);
|
||||
|
||||
const month = dateFormat(date, 'MMMM', locale);
|
||||
const year = date.getFullYear();
|
||||
|
||||
function toggleMonthSelect() {
|
||||
setSelectYear(false);
|
||||
setSelectMonth(state => !state);
|
||||
}
|
||||
|
||||
function toggleYearSelect() {
|
||||
setSelectMonth(false);
|
||||
setSelectYear(state => !state);
|
||||
}
|
||||
|
||||
function handleChange(value) {
|
||||
setSelectMonth(false);
|
||||
setSelectYear(false);
|
||||
if (value) {
|
||||
onChange(value);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.calendar}>
|
||||
<div className={styles.header}>
|
||||
<div>{date.getDate()}</div>
|
||||
<div
|
||||
className={classNames(styles.selector, { [styles.open]: selectMonth })}
|
||||
onClick={toggleMonthSelect}
|
||||
>
|
||||
{month}
|
||||
<Icon className={styles.icon} icon={selectMonth ? <Cross /> : <Chevron />} size="small" />
|
||||
</div>
|
||||
<div
|
||||
className={classNames(styles.selector, { [styles.open]: selectYear })}
|
||||
onClick={toggleYearSelect}
|
||||
>
|
||||
{year}
|
||||
<Icon className={styles.icon} icon={selectYear ? <Cross /> : <Chevron />} size="small" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
{!selectMonth && !selectYear && (
|
||||
<DaySelector
|
||||
date={date}
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
locale={locale}
|
||||
onSelect={handleChange}
|
||||
/>
|
||||
)}
|
||||
{selectMonth && (
|
||||
<MonthSelector
|
||||
date={date}
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
locale={locale}
|
||||
onSelect={handleChange}
|
||||
onClose={toggleMonthSelect}
|
||||
/>
|
||||
)}
|
||||
{selectYear && (
|
||||
<YearSelector
|
||||
date={date}
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
onSelect={handleChange}
|
||||
onClose={toggleYearSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DaySelector = ({ date, minDate, maxDate, locale, onSelect }) => {
|
||||
const 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>
|
||||
{chunk(days, 7).map((week, i) => (
|
||||
<tr key={i}>
|
||||
{week.map((day, j) => {
|
||||
const disabled = isBefore(day, minDate) || isAfter(day, maxDate);
|
||||
return (
|
||||
<td
|
||||
key={j}
|
||||
className={classNames({
|
||||
[styles.selected]: isSameDay(date, day),
|
||||
[styles.faded]: day.getMonth() !== month || day.getFullYear() !== year,
|
||||
[styles.disabled]: disabled,
|
||||
})}
|
||||
onClick={!disabled ? () => onSelect(day) : null}
|
||||
>
|
||||
{day.getDate()}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
const MonthSelector = ({ date, minDate, maxDate, locale, onSelect }) => {
|
||||
const start = startOfYear(date);
|
||||
const months = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
months.push(addMonths(start, i));
|
||||
}
|
||||
|
||||
function handleSelect(value) {
|
||||
onSelect(setMonth(date, value));
|
||||
}
|
||||
|
||||
return (
|
||||
<table>
|
||||
<tbody>
|
||||
{chunk(months, 3).map((row, i) => (
|
||||
<tr key={i}>
|
||||
{row.map((month, j) => {
|
||||
const disabled =
|
||||
isBefore(endOfMonth(month), minDate) || isAfter(startOfMonth(month), maxDate);
|
||||
return (
|
||||
<td
|
||||
key={j}
|
||||
className={classNames(locale, {
|
||||
[styles.selected]: month.getMonth() === date.getMonth(),
|
||||
[styles.disabled]: disabled,
|
||||
})}
|
||||
onClick={!disabled ? () => handleSelect(month.getMonth()) : null}
|
||||
>
|
||||
{dateFormat(month, 'MMMM', locale)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
};
|
||||
|
||||
const YearSelector = ({ date, minDate, maxDate, onSelect }) => {
|
||||
const [currentDate, setCurrentDate] = useState(date);
|
||||
const year = date.getFullYear();
|
||||
const currentYear = currentDate.getFullYear();
|
||||
const minYear = minDate.getFullYear();
|
||||
const maxYear = maxDate.getFullYear();
|
||||
const years = [];
|
||||
for (let i = 0; i < 15; i++) {
|
||||
years.push(currentYear - 7 + i);
|
||||
}
|
||||
|
||||
function handleSelect(value) {
|
||||
onSelect(setYear(date, value));
|
||||
}
|
||||
|
||||
function handlePrevClick() {
|
||||
setCurrentDate(state => subYears(state, 15));
|
||||
}
|
||||
|
||||
function handleNextClick() {
|
||||
setCurrentDate(state => addYears(state, 15));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.pager}>
|
||||
<div className={styles.left}>
|
||||
<Button
|
||||
icon={<Chevron />}
|
||||
size="small"
|
||||
onClick={handlePrevClick}
|
||||
disabled={years[0] <= minYear}
|
||||
variant="light"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.middle}>
|
||||
<table>
|
||||
<tbody>
|
||||
{chunk(years, 5).map((row, i) => (
|
||||
<tr key={i}>
|
||||
{row.map((n, j) => (
|
||||
<td
|
||||
key={j}
|
||||
className={classNames({
|
||||
[styles.selected]: n === year,
|
||||
[styles.disabled]: n < minYear || n > maxYear,
|
||||
})}
|
||||
onClick={() => (n < minYear || n > maxYear ? null : handleSelect(n))}
|
||||
>
|
||||
{n}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className={styles.right}>
|
||||
<Button
|
||||
icon={<Chevron />}
|
||||
size="small"
|
||||
onClick={handleNextClick}
|
||||
disabled={years[years.length - 1] > maxYear}
|
||||
variant="light"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
111
components/common/Calendar.module.css
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
.calendar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: var(--font-size-small);
|
||||
flex: 1;
|
||||
min-height: 306px;
|
||||
}
|
||||
|
||||
.calendar table {
|
||||
width: 100%;
|
||||
border-spacing: 5px;
|
||||
}
|
||||
|
||||
.calendar td {
|
||||
color: var(--gray800);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
vertical-align: center;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.calendar td:hover {
|
||||
border: 1px solid var(--gray300);
|
||||
background: var(--gray75);
|
||||
}
|
||||
|
||||
.calendar td.faded {
|
||||
color: var(--gray500);
|
||||
}
|
||||
|
||||
.calendar td.selected {
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--gray600);
|
||||
}
|
||||
|
||||
.calendar td.selected:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.calendar td.disabled {
|
||||
color: var(--gray400);
|
||||
background: var(--gray75);
|
||||
}
|
||||
|
||||
.calendar td.disabled:hover {
|
||||
cursor: default;
|
||||
background: var(--gray75);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.calendar td.faded.disabled {
|
||||
background: var(--gray100);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
align-items: center;
|
||||
font-weight: 700;
|
||||
line-height: 40px;
|
||||
font-size: var(--font-size-normal);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.selector {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pager button {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.middle {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.left,
|
||||
.right {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.left svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.right svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 992px) {
|
||||
.calendar table {
|
||||
max-width: calc(100vw - 30px);
|
||||
}
|
||||
}
|
||||
39
components/common/Checkbox.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import React, { useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Icon from 'components/common/Icon';
|
||||
import Check from 'assets/check.svg';
|
||||
import styles from './Checkbox.module.css';
|
||||
|
||||
function Checkbox({ name, value, label, onChange }) {
|
||||
const ref = useRef();
|
||||
|
||||
const onClick = () => ref.current.click();
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.checkbox} onClick={onClick}>
|
||||
{value && <Icon icon={<Check />} size="small" />}
|
||||
</div>
|
||||
<label className={styles.label} htmlFor={name} onClick={onClick}>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
ref={ref}
|
||||
className={styles.input}
|
||||
type="checkbox"
|
||||
name={name}
|
||||
defaultChecked={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Checkbox.propTypes = {
|
||||
name: PropTypes.string,
|
||||
value: PropTypes.any,
|
||||
label: PropTypes.node,
|
||||
onChange: PropTypes.func,
|
||||
};
|
||||
|
||||
export default Checkbox;
|
||||
30
components/common/Checkbox.module.css
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1px solid var(--gray500);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-left: 10px;
|
||||
user-select: none; /* disable text selection when clicking to toggle the checkbox */
|
||||
}
|
||||
|
||||
.input {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
height: 0;
|
||||
width: 0;
|
||||
bottom: 100%;
|
||||
right: 100%;
|
||||
}
|
||||
37
components/common/CopyButton.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Button from './Button';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
const defaultText = (
|
||||
<FormattedMessage id="label.copy-to-clipboard" defaultMessage="Copy to clipboard" />
|
||||
);
|
||||
|
||||
function CopyButton({ element, ...props }) {
|
||||
const [text, setText] = useState(defaultText);
|
||||
|
||||
function handleClick() {
|
||||
if (element?.current) {
|
||||
element.current.select();
|
||||
document.execCommand('copy');
|
||||
setText(<FormattedMessage id="message.copied" defaultMessage="Copied!" />);
|
||||
window.getSelection().removeAllRanges();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button {...props} onClick={handleClick}>
|
||||
{text}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
CopyButton.propTypes = {
|
||||
element: PropTypes.shape({
|
||||
current: PropTypes.shape({
|
||||
select: PropTypes.func.isRequired,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
export default CopyButton;
|
||||
134
components/common/DateFilter.js
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { endOfYear, isSameDay } from 'date-fns';
|
||||
import Modal from './Modal';
|
||||
import DropDown from './DropDown';
|
||||
import DatePickerForm from 'components/forms/DatePickerForm';
|
||||
import useLocale from 'hooks/useLocale';
|
||||
import { dateFormat } from 'lib/date';
|
||||
import Calendar from 'assets/calendar-alt.svg';
|
||||
import Icon from './Icon';
|
||||
|
||||
export 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.all-time" defaultMessage="All time" />,
|
||||
value: 'all',
|
||||
divider: true,
|
||||
},
|
||||
{
|
||||
label: <FormattedMessage id="label.custom-range" defaultMessage="Custom range" />,
|
||||
value: 'custom',
|
||||
divider: true,
|
||||
},
|
||||
];
|
||||
|
||||
function DateFilter({ value, startDate, endDate, onChange, className, options }) {
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
const displayValue =
|
||||
value === 'custom' ? (
|
||||
<CustomRange startDate={startDate} endDate={endDate} onClick={() => handleChange('custom')} />
|
||||
) : (
|
||||
value
|
||||
);
|
||||
|
||||
async function handleChange(value) {
|
||||
if (value === 'custom') {
|
||||
setShowPicker(true);
|
||||
return;
|
||||
}
|
||||
onChange(value);
|
||||
}
|
||||
|
||||
function handlePickerChange(value) {
|
||||
setShowPicker(false);
|
||||
onChange(value);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropDown
|
||||
className={className}
|
||||
value={displayValue}
|
||||
options={options || filterOptions}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
{showPicker && (
|
||||
<Modal>
|
||||
<DatePickerForm
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
minDate={new Date(2000, 0, 1)}
|
||||
maxDate={endOfYear(new Date())}
|
||||
onChange={handlePickerChange}
|
||||
onClose={() => setShowPicker(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const CustomRange = ({ startDate, endDate, onClick }) => {
|
||||
const { locale } = useLocale();
|
||||
|
||||
function handleClick(e) {
|
||||
e.stopPropagation();
|
||||
|
||||
onClick();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Icon icon={<Calendar />} className="mr-2" onClick={handleClick} />
|
||||
{dateFormat(startDate, 'd LLL y', locale)}
|
||||
{!isSameDay(startDate, endDate) && ` — ${dateFormat(endDate, 'd LLL y', locale)}`}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
DateFilter.propTypes = {
|
||||
value: PropTypes.string,
|
||||
startDate: PropTypes.instanceOf(Date),
|
||||
endDate: PropTypes.instanceOf(Date),
|
||||
onChange: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
export default DateFilter;
|
||||
26
components/common/Dot.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import styles from './Dot.module.css';
|
||||
|
||||
function Dot({ color, size, className }) {
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div
|
||||
style={{ background: color }}
|
||||
className={classNames(styles.dot, className, {
|
||||
[styles.small]: size === 'small',
|
||||
[styles.large]: size === 'large',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Dot.propTypes = {
|
||||
color: PropTypes.string,
|
||||
size: PropTypes.oneOf(['small', 'large']),
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
export default Dot;
|
||||
22
components/common/Dot.module.css
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
.wrapper {
|
||||
background: var(--gray50);
|
||||
margin-right: 10px;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.dot {
|
||||
background: var(--green400);
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.dot.small {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.dot.large {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
64
components/common/DropDown.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import React, { useState, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import Menu from './Menu';
|
||||
import useDocumentClick from 'hooks/useDocumentClick';
|
||||
import Chevron from 'assets/chevron-down.svg';
|
||||
import styles from './Dropdown.module.css';
|
||||
import Icon from './Icon';
|
||||
|
||||
function DropDown({ value, className, menuClassName, options = [], onChange = () => {} }) {
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const ref = useRef();
|
||||
const selectedOption = options.find(e => e.value === value);
|
||||
|
||||
function handleShowMenu() {
|
||||
setShowMenu(state => !state);
|
||||
}
|
||||
|
||||
function handleSelect(selected, e) {
|
||||
e.stopPropagation();
|
||||
setShowMenu(false);
|
||||
|
||||
onChange(selected);
|
||||
}
|
||||
|
||||
useDocumentClick(e => {
|
||||
if (!ref.current?.contains(e.target)) {
|
||||
setShowMenu(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={ref} className={classNames(styles.dropdown, className)} onClick={handleShowMenu}>
|
||||
<div className={styles.value}>
|
||||
<div className={styles.text}>{options.find(e => e.value === value)?.label || value}</div>
|
||||
<Icon icon={<Chevron />} className={styles.icon} size="small" />
|
||||
</div>
|
||||
{showMenu && (
|
||||
<Menu
|
||||
className={menuClassName}
|
||||
options={options}
|
||||
selectedOption={selectedOption}
|
||||
onSelect={handleSelect}
|
||||
float="bottom"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DropDown.propTypes = {
|
||||
value: PropTypes.any,
|
||||
className: PropTypes.string,
|
||||
menuClassName: PropTypes.string,
|
||||
options: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
value: PropTypes.any.isRequired,
|
||||
label: PropTypes.node,
|
||||
}),
|
||||
),
|
||||
onChange: PropTypes.func,
|
||||
};
|
||||
|
||||
export default DropDown;
|
||||
28
components/common/Dropdown.module.css
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
.dropdown {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: 1px solid var(--gray500);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.value {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--font-size-small);
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
padding: 4px 16px;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.icon {
|
||||
padding-left: 20px;
|
||||
}
|
||||
22
components/common/EmptyPlaceholder.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Icon from 'components/common/Icon';
|
||||
import Logo from 'assets/logo.svg';
|
||||
import styles from './EmptyPlaceholder.module.css';
|
||||
|
||||
function EmptyPlaceholder({ msg, children }) {
|
||||
return (
|
||||
<div className={styles.placeholder}>
|
||||
<Icon className={styles.icon} icon={<Logo />} size="xlarge" />
|
||||
<h2 className={styles.msg}>{msg}</h2>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
EmptyPlaceholder.propTypes = {
|
||||
msg: PropTypes.node,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export default EmptyPlaceholder;
|
||||
15
components/common/EmptyPlaceholder.module.css
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
.placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.msg {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
14
components/common/ErrorMessage.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import Icon from './Icon';
|
||||
import Exclamation from 'assets/exclamation-triangle.svg';
|
||||
import styles from './ErrorMessage.module.css';
|
||||
|
||||
export default function ErrorMessage() {
|
||||
return (
|
||||
<div className={styles.error}>
|
||||
<Icon icon={<Exclamation />} className={styles.icon} size="large" />
|
||||
<FormattedMessage id="message.failure" defaultMessage="Something went wrong." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,11 +5,11 @@
|
|||
transform: translate(-50%, -50%);
|
||||
margin: auto;
|
||||
display: flex;
|
||||
background-color: var(--base50);
|
||||
padding: 10px;
|
||||
z-index: 1;
|
||||
background-color: var(--gray50);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-inline-end: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
|
@ -1,24 +1,28 @@
|
|||
function getHostName(url: string) {
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styles from './Favicon.module.css';
|
||||
|
||||
function getHostName(url) {
|
||||
const match = url.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?(?:www\.)?([^:/\n?=]+)/im);
|
||||
return match && match.length > 1 ? match[1] : null;
|
||||
}
|
||||
|
||||
export function Favicon({ domain, ...props }) {
|
||||
if (process.env.privateMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function Favicon({ domain, ...props }) {
|
||||
const hostName = domain ? getHostName(domain) : null;
|
||||
|
||||
return hostName ? (
|
||||
<img
|
||||
className={styles.favicon}
|
||||
src={`https://icons.duckduckgo.com/ip3/${hostName}.ico`}
|
||||
width={16}
|
||||
height={16}
|
||||
height="16"
|
||||
alt=""
|
||||
{...props}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
Favicon.propTypes = {
|
||||
domain: PropTypes.string,
|
||||
};
|
||||
|
||||
export default Favicon;
|
||||
3
components/common/Favicon.module.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.favicon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
25
components/common/FilterButtons.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ButtonLayout from 'components/layout/ButtonLayout';
|
||||
import ButtonGroup from './ButtonGroup';
|
||||
|
||||
function FilterButtons({ buttons, selected, onClick }) {
|
||||
return (
|
||||
<ButtonLayout>
|
||||
<ButtonGroup size="xsmall" items={buttons} selectedItem={selected} onClick={onClick} />
|
||||
</ButtonLayout>
|
||||
);
|
||||
}
|
||||
|
||||
FilterButtons.propTypes = {
|
||||
buttons: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
label: PropTypes.node,
|
||||
value: PropTypes.any.isRequired,
|
||||
}),
|
||||
),
|
||||
selected: PropTypes.any,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
export default FilterButtons;
|
||||
29
components/common/Icon.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import styles from './Icon.module.css';
|
||||
|
||||
function Icon({ icon, className, size = 'medium', ...props }) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.icon, className, {
|
||||
[styles.xlarge]: size === 'xlarge',
|
||||
[styles.large]: size === 'large',
|
||||
[styles.medium]: size === 'medium',
|
||||
[styles.small]: size === 'small',
|
||||
[styles.xsmall]: size === 'xsmall',
|
||||
})}
|
||||
{...props}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Icon.propTypes = {
|
||||
className: PropTypes.string,
|
||||
icon: PropTypes.node.isRequired,
|
||||
size: PropTypes.oneOf(['xlarge', 'large', 'medium', 'small', 'xsmall']),
|
||||
};
|
||||
|
||||
export default Icon;
|
||||
35
components/common/Icon.module.css
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
.icon {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.icon svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.xlarge > svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.large > svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.medium > svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.small > svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.xsmall > svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
34
components/common/Link.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import NextLink from 'next/link';
|
||||
import Icon from './Icon';
|
||||
import styles from './Link.module.css';
|
||||
|
||||
function Link({ className, icon, children, size, iconRight, ...props }) {
|
||||
return (
|
||||
<NextLink {...props}>
|
||||
<a
|
||||
className={classNames(styles.link, className, {
|
||||
[styles.large]: size === 'large',
|
||||
[styles.small]: size === 'small',
|
||||
[styles.xsmall]: size === 'xsmall',
|
||||
[styles.iconRight]: iconRight,
|
||||
})}
|
||||
>
|
||||
{icon && <Icon className={styles.icon} icon={icon} size={size} />}
|
||||
{children}
|
||||
</a>
|
||||
</NextLink>
|
||||
);
|
||||
}
|
||||
|
||||
Link.propTypes = {
|
||||
className: PropTypes.string,
|
||||
icon: PropTypes.node,
|
||||
children: PropTypes.node,
|
||||
size: PropTypes.oneOf(['large', 'small', 'xsmall']),
|
||||
iconRight: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default Link;
|
||||
50
components/common/Link.module.css
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
a.link,
|
||||
a.link:active,
|
||||
a.link:visited {
|
||||
position: relative;
|
||||
color: var(--gray900);
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
a.link:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background: var(--primary400);
|
||||
opacity: 0.5;
|
||||
transition: width 100ms;
|
||||
}
|
||||
|
||||
a.link:hover:before {
|
||||
width: 100%;
|
||||
transition: width 100ms;
|
||||
}
|
||||
|
||||
a.link.large {
|
||||
font-size: var(--font-size-large);
|
||||
}
|
||||
|
||||
a.link.small {
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
a.link.xsmall {
|
||||
font-size: var(--font-size-xsmall);
|
||||
}
|
||||
|
||||
a.link .icon + * {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
a.link.iconRight .icon {
|
||||
order: 1;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
a.link.iconRight .icon + * {
|
||||
margin: 0;
|
||||
}
|
||||
20
components/common/Loading.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import styles from './Loading.module.css';
|
||||
|
||||
function Loading({ className }) {
|
||||
return (
|
||||
<div className={classNames(styles.loading, className)}>
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Loading.propTypes = {
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
export default Loading;
|
||||
43
components/common/Loading.module.css
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
@keyframes blink {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.loading div {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 100%;
|
||||
background: var(--gray400);
|
||||
animation: blink 1.4s infinite;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
.loading div + div {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.loading div:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.loading div:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
70
components/common/Menu.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import styles from './Menu.module.css';
|
||||
|
||||
function Menu({
|
||||
options = [],
|
||||
selectedOption,
|
||||
className,
|
||||
float,
|
||||
align = 'left',
|
||||
optionClassName,
|
||||
selectedClassName,
|
||||
onSelect = () => {},
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.menu, className, {
|
||||
[styles.float]: float,
|
||||
[styles.top]: float === 'top',
|
||||
[styles.bottom]: float === 'bottom',
|
||||
[styles.left]: align === 'left',
|
||||
[styles.right]: align === 'right',
|
||||
})}
|
||||
>
|
||||
{options
|
||||
.filter(({ hidden }) => !hidden)
|
||||
.map(option => {
|
||||
const { label, value, className: customClassName, render, divider } = option;
|
||||
|
||||
return render ? (
|
||||
render(option)
|
||||
) : (
|
||||
<div
|
||||
key={value}
|
||||
className={classNames(styles.option, optionClassName, customClassName, {
|
||||
[selectedClassName]: selectedOption === option,
|
||||
[styles.selected]: selectedOption === option,
|
||||
[styles.divider]: divider,
|
||||
})}
|
||||
onClick={e => onSelect(value, e)}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Menu.propTypes = {
|
||||
options: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
label: PropTypes.node,
|
||||
value: PropTypes.any,
|
||||
className: PropTypes.string,
|
||||
render: PropTypes.func,
|
||||
divider: PropTypes.bool,
|
||||
}),
|
||||
),
|
||||
selectedOption: PropTypes.any,
|
||||
className: PropTypes.string,
|
||||
float: PropTypes.oneOf(['top', 'bottom']),
|
||||
align: PropTypes.oneOf(['left', 'right']),
|
||||
optionClassName: PropTypes.string,
|
||||
selectedClassName: PropTypes.string,
|
||||
onSelect: PropTypes.func,
|
||||
};
|
||||
|
||||
export default Menu;
|
||||
51
components/common/Menu.module.css
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
.menu {
|
||||
background: var(--gray50);
|
||||
border: 1px solid var(--gray500);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.option {
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: normal;
|
||||
background: var(--gray50);
|
||||
padding: 4px 16px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.option:hover {
|
||||
background: var(--gray100);
|
||||
}
|
||||
|
||||
.float {
|
||||
position: absolute;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.top {
|
||||
bottom: 100%;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
top: 100%;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.left {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.right {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.divider {
|
||||
border-top: 1px solid var(--gray300);
|
||||
}
|
||||
|
||||
.selected {
|
||||
font-weight: 600;
|
||||
}
|
||||
86
components/common/MenuButton.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import React, { useState, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import Menu from 'components/common/Menu';
|
||||
import Button from 'components/common/Button';
|
||||
import useDocumentClick from 'hooks/useDocumentClick';
|
||||
import styles from './MenuButton.module.css';
|
||||
|
||||
function MenuButton({
|
||||
icon,
|
||||
value,
|
||||
options,
|
||||
buttonClassName,
|
||||
menuClassName,
|
||||
menuPosition = 'bottom',
|
||||
menuAlign = 'right',
|
||||
onSelect,
|
||||
renderValue,
|
||||
hideLabel,
|
||||
}) {
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const ref = useRef();
|
||||
const selectedOption = options.find(e => e.value === value);
|
||||
|
||||
function handleSelect(value) {
|
||||
onSelect(value);
|
||||
setShowMenu(false);
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
setShowMenu(state => !state);
|
||||
}
|
||||
|
||||
useDocumentClick(e => {
|
||||
if (!ref.current?.contains(e.target)) {
|
||||
setShowMenu(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.container} ref={ref}>
|
||||
<Button
|
||||
icon={icon}
|
||||
className={classNames(styles.button, buttonClassName, { [styles.open]: showMenu })}
|
||||
onClick={toggleMenu}
|
||||
variant="light"
|
||||
>
|
||||
{!hideLabel && (
|
||||
<div className={styles.text}>{renderValue ? renderValue(selectedOption) : value}</div>
|
||||
)}
|
||||
</Button>
|
||||
{showMenu && (
|
||||
<Menu
|
||||
className={menuClassName}
|
||||
options={options}
|
||||
selectedOption={selectedOption}
|
||||
onSelect={handleSelect}
|
||||
float={menuPosition}
|
||||
align={menuAlign}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
MenuButton.propTypes = {
|
||||
icon: PropTypes.node,
|
||||
value: PropTypes.any,
|
||||
options: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
label: PropTypes.node,
|
||||
value: PropTypes.any,
|
||||
className: PropTypes.string,
|
||||
render: PropTypes.func,
|
||||
divider: PropTypes.bool,
|
||||
}),
|
||||
),
|
||||
buttonClassName: PropTypes.string,
|
||||
menuClassName: PropTypes.string,
|
||||
menuPosition: PropTypes.oneOf(['top', 'bottom']),
|
||||
menuAlign: PropTypes.oneOf(['left', 'right']),
|
||||
onSelect: PropTypes.func,
|
||||
renderValue: PropTypes.func,
|
||||
};
|
||||
|
||||
export default MenuButton;
|
||||
20
components/common/MenuButton.module.css
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
.container {
|
||||
display: flex;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.open,
|
||||
.open:hover {
|
||||
background: var(--gray50);
|
||||
border: 1px solid var(--gray500);
|
||||
}
|
||||
26
components/common/Modal.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useSpring, animated } from 'react-spring';
|
||||
import styles from './Modal.module.css';
|
||||
|
||||
function Modal({ title, children }) {
|
||||
const props = useSpring({ opacity: 1, from: { opacity: 0 } });
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<animated.div className={styles.modal} style={props}>
|
||||
<div className={styles.content}>
|
||||
{title && <div className={styles.header}>{title}</div>}
|
||||
<div className={styles.body}>{children}</div>
|
||||
</div>
|
||||
</animated.div>,
|
||||
document.getElementById('__modals'),
|
||||
);
|
||||
}
|
||||
|
||||
Modal.propTypes = {
|
||||
title: PropTypes.node,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
46
components/common/Modal.module.css
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.modal:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
background: #000;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--gray50);
|
||||
min-width: 400px;
|
||||
min-height: 100px;
|
||||
max-width: 100vw;
|
||||
z-index: 1;
|
||||
border: 1px solid var(--gray300);
|
||||
padding: 30px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.header {
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
47
components/common/NavMenu.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useRouter } from 'next/router';
|
||||
import classNames from 'classnames';
|
||||
import styles from './NavMenu.module.css';
|
||||
|
||||
function NavMenu({ options = [], className, onSelect = () => {} }) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.menu, className)}>
|
||||
{options
|
||||
.filter(({ hidden }) => !hidden)
|
||||
.map(option => {
|
||||
const { label, value, className: customClassName, render } = option;
|
||||
|
||||
return render ? (
|
||||
render(option)
|
||||
) : (
|
||||
<div
|
||||
key={value}
|
||||
className={classNames(styles.option, customClassName, {
|
||||
[styles.selected]: router.asPath === value,
|
||||
})}
|
||||
onClick={e => onSelect(value, e)}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
NavMenu.propTypes = {
|
||||
options: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
label: PropTypes.node,
|
||||
value: PropTypes.any,
|
||||
className: PropTypes.string,
|
||||
render: PropTypes.func,
|
||||
}),
|
||||
),
|
||||
className: PropTypes.string,
|
||||
onSelect: PropTypes.func,
|
||||
};
|
||||
export default NavMenu;
|
||||
22
components/common/NavMenu.module.css
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
.menu {
|
||||
color: var(--gray800);
|
||||
border: 1px solid var(--gray500);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.option {
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.option:hover {
|
||||
background: var(--gray75);
|
||||
}
|
||||
|
||||
.selected {
|
||||
color: var(--gray900);
|
||||
font-weight: 600;
|
||||
}
|
||||
19
components/common/NoData.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import styles from './NoData.module.css';
|
||||
|
||||
function NoData({ className }) {
|
||||
return (
|
||||
<div className={classNames(styles.container, className)}>
|
||||
<FormattedMessage id="message.no-data-available" defaultMessage="No data available." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
NoData.propTypes = {
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
export default NoData;
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
.container {
|
||||
color: var(--base500);
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--gray500);
|
||||
font-size: var(--font-size-normal);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -8,5 +8,4 @@
|
|||
text-align: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 70px;
|
||||
}
|
||||
43
components/common/RefreshButton.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { setDateRange } from 'redux/actions/websites';
|
||||
import Button from './Button';
|
||||
import Refresh from 'assets/redo.svg';
|
||||
import Dots from 'assets/ellipsis-h.svg';
|
||||
import useDateRange from 'hooks/useDateRange';
|
||||
|
||||
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, dateRange));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(false);
|
||||
}, [completed]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
icon={loading ? <Dots /> : <Refresh />}
|
||||
tooltip={<FormattedMessage id="label.refresh" defaultMessage="Refresh" />}
|
||||
tooltipId="button-refresh"
|
||||
size="small"
|
||||
onClick={handleClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
RefreshButton.propTypes = {
|
||||
websiteId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
};
|
||||
|
||||
export default RefreshButton;
|
||||
89
components/common/Table.js
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import NoData from 'components/common/NoData';
|
||||
import styles from './Table.module.css';
|
||||
|
||||
function Table({
|
||||
columns,
|
||||
rows,
|
||||
empty,
|
||||
className,
|
||||
bodyClassName,
|
||||
rowKey,
|
||||
showHeader = true,
|
||||
children,
|
||||
}) {
|
||||
if (empty && rows.length === 0) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.table, className)}>
|
||||
{showHeader && (
|
||||
<div className={classNames(styles.header, 'row')}>
|
||||
{columns.map(({ key, label, className, style, header }) => (
|
||||
<div
|
||||
key={key}
|
||||
className={classNames(styles.head, className, header?.className)}
|
||||
style={{ ...style, ...header?.style }}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className={classNames(styles.body, bodyClassName)}>
|
||||
{rows.length === 0 && <NoData />}
|
||||
{!children &&
|
||||
rows.map((row, index) => {
|
||||
const id = rowKey ? rowKey(row) : index;
|
||||
return <TableRow key={id} columns={columns} row={row} />;
|
||||
})}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styledObject = PropTypes.shape({
|
||||
className: PropTypes.string,
|
||||
style: PropTypes.object,
|
||||
});
|
||||
|
||||
Table.propTypes = {
|
||||
columns: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
cell: styledObject,
|
||||
className: PropTypes.string,
|
||||
header: styledObject,
|
||||
key: PropTypes.string,
|
||||
label: PropTypes.node,
|
||||
render: PropTypes.func,
|
||||
style: PropTypes.object,
|
||||
}),
|
||||
),
|
||||
rows: PropTypes.arrayOf(PropTypes.object),
|
||||
empty: PropTypes.node,
|
||||
className: PropTypes.string,
|
||||
bodyClassName: PropTypes.string,
|
||||
rowKey: PropTypes.func,
|
||||
showHeader: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export default Table;
|
||||
|
||||
export const TableRow = ({ columns, row }) => (
|
||||
<div className={classNames(styles.row, 'row')}>
|
||||
{columns.map(({ key, render, className, style, cell }, index) => (
|
||||
<div
|
||||
key={`${key}-${index}`}
|
||||
className={classNames(styles.cell, className, cell?.className)}
|
||||
style={{ ...style, ...cell?.style }}
|
||||
>
|
||||
{render ? render(row) : row[key]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
30
components/common/Table.module.css
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
.table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
border-bottom: 1px solid var(--gray300);
|
||||
}
|
||||
|
||||
.head {
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
.body {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.row {
|
||||
border-bottom: 1px solid var(--gray300);
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.cell {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
15
components/common/Tag.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import styles from './Tag.module.css';
|
||||
|
||||
function Tag({ className, children }) {
|
||||
return <span className={classNames(styles.tag, className)}>{children}</span>;
|
||||
}
|
||||
|
||||
Tag.propTypes = {
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export default Tag;
|
||||
6
components/common/Tag.module.css
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
.tag {
|
||||
padding: 2px 4px;
|
||||
border: 1px solid var(--gray300);
|
||||
border-radius: 4px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
35
components/common/Toast.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useSpring, animated } from 'react-spring';
|
||||
import styles from './Toast.module.css';
|
||||
import Icon from 'components/common/Icon';
|
||||
import Close from 'assets/times.svg';
|
||||
|
||||
function Toast({ message, timeout = 3000, onClose }) {
|
||||
const props = useSpring({
|
||||
opacity: 1,
|
||||
transform: 'translate3d(0,0px,0)',
|
||||
from: { opacity: 0, transform: 'translate3d(0,-40px,0)' },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(onClose, timeout);
|
||||
}, []);
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<animated.div className={styles.toast} style={props} onClick={onClose}>
|
||||
<div className={styles.message}>{message}</div>
|
||||
<Icon className={styles.close} icon={<Close />} size="small" />
|
||||
</animated.div>,
|
||||
document.getElementById('__modals'),
|
||||
);
|
||||
}
|
||||
|
||||
Toast.propTypes = {
|
||||
message: PropTypes.node,
|
||||
timeout: PropTypes.number,
|
||||
onClose: PropTypes.func,
|
||||
};
|
||||
|
||||
export default Toast;
|
||||
25
components/common/Toast.module.css
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
.toast {
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 300px;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
color: var(--msgColor);
|
||||
background: var(--green400);
|
||||
margin: auto;
|
||||
z-index: 2;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: var(--font-size-normal);
|
||||
}
|
||||
|
||||
.close {
|
||||
margin-left: 20px;
|
||||
}
|
||||
47
components/common/UpdateNotice.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import useVersion from 'hooks/useVersion';
|
||||
import styles from './UpdateNotice.module.css';
|
||||
import ButtonLayout from '../layout/ButtonLayout';
|
||||
import Button from './Button';
|
||||
import useForceUpdate from '../../hooks/useForceUpdate';
|
||||
|
||||
export default function UpdateNotice() {
|
||||
const forceUpdate = useForceUpdate();
|
||||
const { hasUpdate, checked, latest, updateCheck } = useVersion(true);
|
||||
|
||||
function handleViewClick() {
|
||||
location.href = 'https://github.com/mikecao/umami/releases';
|
||||
updateCheck();
|
||||
forceUpdate();
|
||||
}
|
||||
|
||||
function handleDismissClick() {
|
||||
updateCheck();
|
||||
forceUpdate();
|
||||
}
|
||||
|
||||
if (!hasUpdate || checked) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.notice}>
|
||||
<div className={styles.message}>
|
||||
<FormattedMessage
|
||||
id="message.new-version-available"
|
||||
defaultMessage="A new version of umami {version} is available!"
|
||||
values={{ version: `v${latest}` }}
|
||||
/>
|
||||
</div>
|
||||
<ButtonLayout>
|
||||
<Button size="xsmall" variant="action" onClick={handleViewClick}>
|
||||
<FormattedMessage id="label.view-details" defaultMessage="View details" />
|
||||
</Button>
|
||||
<Button size="xsmall" onClick={handleDismissClick}>
|
||||
<FormattedMessage id="label.dismiss" defaultMessage="Dismiss" />
|
||||
</Button>
|
||||
</ButtonLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
components/common/UpdateNotice.module.css
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
.notice {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding-top: 10px;
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.message {
|
||||
text-align: center;
|
||||
margin-right: 20px;
|
||||
}
|
||||
103
components/common/WorldMap.js
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import PropTypes from 'prop-types';
|
||||
import ReactTooltip from 'react-tooltip';
|
||||
import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps';
|
||||
import classNames from 'classnames';
|
||||
import { colord } from 'colord';
|
||||
import useTheme from 'hooks/useTheme';
|
||||
import { ISO_COUNTRIES, THEME_COLORS, MAP_FILE } from 'lib/constants';
|
||||
import styles from './WorldMap.module.css';
|
||||
import useCountryNames from 'hooks/useCountryNames';
|
||||
import useLocale from 'hooks/useLocale';
|
||||
|
||||
function WorldMap({ data, className }) {
|
||||
const { basePath } = useRouter();
|
||||
const [tooltip, setTooltip] = useState();
|
||||
const [theme] = useTheme();
|
||||
const colors = useMemo(
|
||||
() => ({
|
||||
baseColor: THEME_COLORS[theme].primary,
|
||||
fillColor: THEME_COLORS[theme].gray100,
|
||||
strokeColor: THEME_COLORS[theme].primary,
|
||||
hoverColor: THEME_COLORS[theme].primary,
|
||||
}),
|
||||
[theme],
|
||||
);
|
||||
const { locale } = useLocale();
|
||||
const countryNames = useCountryNames(locale);
|
||||
|
||||
function getFillColor(code) {
|
||||
if (code === 'AQ') return;
|
||||
const country = data?.find(({ x }) => x === code);
|
||||
|
||||
if (!country) {
|
||||
return colors.fillColor;
|
||||
}
|
||||
|
||||
return colord(colors.baseColor)
|
||||
[theme === 'light' ? 'lighten' : 'darken'](0.4 * (1.0 - country.z / 100))
|
||||
.toHex();
|
||||
}
|
||||
|
||||
function getOpacity(code) {
|
||||
return code === 'AQ' ? 0 : 1;
|
||||
}
|
||||
|
||||
function handleHover(code) {
|
||||
if (code === 'AQ') return;
|
||||
const country = data?.find(({ x }) => x === code);
|
||||
setTooltip(`${countryNames[code]}: ${country?.y || 0} visitors`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.container, className)}
|
||||
data-tip=""
|
||||
data-for="world-map-tooltip"
|
||||
>
|
||||
<ComposableMap projection="geoMercator">
|
||||
<ZoomableGroup zoom={0.8} minZoom={0.7} center={[0, 40]}>
|
||||
<Geographies geography={`${basePath}${MAP_FILE}`}>
|
||||
{({ geographies }) => {
|
||||
return geographies.map(geo => {
|
||||
const code = ISO_COUNTRIES[geo.id];
|
||||
|
||||
return (
|
||||
<Geography
|
||||
key={geo.rsmKey}
|
||||
geography={geo}
|
||||
fill={getFillColor(code)}
|
||||
stroke={colors.strokeColor}
|
||||
opacity={getOpacity(code)}
|
||||
style={{
|
||||
default: { outline: 'none' },
|
||||
hover: { outline: 'none', fill: colors.hoverColor },
|
||||
pressed: { outline: 'none' },
|
||||
}}
|
||||
onMouseOver={() => handleHover(code)}
|
||||
onMouseOut={() => setTooltip(null)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}}
|
||||
</Geographies>
|
||||
</ZoomableGroup>
|
||||
</ComposableMap>
|
||||
<ReactTooltip id="world-map-tooltip">{tooltip}</ReactTooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
WorldMap.propTypes = {
|
||||
data: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
x: PropTypes.string,
|
||||
y: PropTypes.number,
|
||||
z: PropTypes.number,
|
||||
}),
|
||||
),
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
export default WorldMap;
|
||||
88
components/forms/AccountEditForm.js
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Formik, Form, Field } from 'formik';
|
||||
import Button from 'components/common/Button';
|
||||
import FormLayout, {
|
||||
FormButtons,
|
||||
FormError,
|
||||
FormMessage,
|
||||
FormRow,
|
||||
} from 'components/layout/FormLayout';
|
||||
import usePost from 'hooks/usePost';
|
||||
|
||||
const initialValues = {
|
||||
username: '',
|
||||
password: '',
|
||||
};
|
||||
|
||||
const validate = ({ user_id, username, password }) => {
|
||||
const errors = {};
|
||||
|
||||
if (!username) {
|
||||
errors.username = <FormattedMessage id="label.required" defaultMessage="Required" />;
|
||||
}
|
||||
if (!user_id && !password) {
|
||||
errors.password = <FormattedMessage id="label.required" defaultMessage="Required" />;
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
export default function AccountEditForm({ values, onSave, onClose }) {
|
||||
const post = usePost();
|
||||
const [message, setMessage] = useState();
|
||||
|
||||
const handleSubmit = async values => {
|
||||
const { ok, data } = await post('/api/account', values);
|
||||
|
||||
if (ok) {
|
||||
onSave();
|
||||
} else {
|
||||
setMessage(
|
||||
data || <FormattedMessage id="message.failure" defaultMessage="Something went wrong." />,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormLayout>
|
||||
<Formik
|
||||
initialValues={{ ...initialValues, ...values }}
|
||||
validate={validate}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{() => (
|
||||
<Form>
|
||||
<FormRow>
|
||||
<label htmlFor="username">
|
||||
<FormattedMessage id="label.username" defaultMessage="Username" />
|
||||
</label>
|
||||
<div>
|
||||
<Field name="username" type="text" />
|
||||
<FormError name="username" />
|
||||
</div>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<label htmlFor="password">
|
||||
<FormattedMessage id="label.password" defaultMessage="Password" />
|
||||
</label>
|
||||
<div>
|
||||
<Field name="password" type="password" />
|
||||
<FormError name="password" />
|
||||
</div>
|
||||
</FormRow>
|
||||
<FormButtons>
|
||||
<Button type="submit" variant="action">
|
||||
<FormattedMessage id="label.save" defaultMessage="Save" />
|
||||
</Button>
|
||||
<Button onClick={onClose}>
|
||||
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
|
||||
</Button>
|
||||
</FormButtons>
|
||||
<FormMessage>{message}</FormMessage>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</FormLayout>
|
||||
);
|
||||
}
|
||||
105
components/forms/ChangePasswordForm.js
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Formik, Form, Field } from 'formik';
|
||||
import Button from 'components/common/Button';
|
||||
import FormLayout, {
|
||||
FormButtons,
|
||||
FormError,
|
||||
FormMessage,
|
||||
FormRow,
|
||||
} from 'components/layout/FormLayout';
|
||||
import usePost from 'hooks/usePost';
|
||||
|
||||
const initialValues = {
|
||||
current_password: '',
|
||||
new_password: '',
|
||||
confirm_password: '',
|
||||
};
|
||||
|
||||
const validate = ({ current_password, new_password, confirm_password }) => {
|
||||
const errors = {};
|
||||
|
||||
if (!current_password) {
|
||||
errors.current_password = <FormattedMessage id="label.required" defaultMessage="Required" />;
|
||||
}
|
||||
if (!new_password) {
|
||||
errors.new_password = <FormattedMessage id="label.required" defaultMessage="Required" />;
|
||||
}
|
||||
if (!confirm_password) {
|
||||
errors.confirm_password = <FormattedMessage id="label.required" defaultMessage="Required" />;
|
||||
} else if (new_password !== confirm_password) {
|
||||
errors.confirm_password = (
|
||||
<FormattedMessage id="label.passwords-dont-match" defaultMessage="Passwords don't match" />
|
||||
);
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
export default function ChangePasswordForm({ values, onSave, onClose }) {
|
||||
const post = usePost();
|
||||
const [message, setMessage] = useState();
|
||||
|
||||
const handleSubmit = async values => {
|
||||
const { ok, data } = await post('/api/account/password', values);
|
||||
|
||||
if (ok) {
|
||||
onSave();
|
||||
} else {
|
||||
setMessage(
|
||||
data || <FormattedMessage id="message.failure" defaultMessage="Something went wrong." />,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormLayout>
|
||||
<Formik
|
||||
initialValues={{ ...initialValues, ...values }}
|
||||
validate={validate}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{() => (
|
||||
<Form>
|
||||
<FormRow>
|
||||
<label htmlFor="current_password">
|
||||
<FormattedMessage id="label.current-password" defaultMessage="Current password" />
|
||||
</label>
|
||||
<div>
|
||||
<Field name="current_password" type="password" />
|
||||
<FormError name="current_password" />
|
||||
</div>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<label htmlFor="new_password">
|
||||
<FormattedMessage id="label.new-password" defaultMessage="New password" />
|
||||
</label>
|
||||
<div>
|
||||
<Field name="new_password" type="password" />
|
||||
<FormError name="new_password" />
|
||||
</div>
|
||||
</FormRow>
|
||||
<FormRow>
|
||||
<label htmlFor="confirm_password">
|
||||
<FormattedMessage id="label.confirm-password" defaultMessage="Confirm password" />
|
||||
</label>
|
||||
<div>
|
||||
<Field name="confirm_password" type="password" />
|
||||
<FormError name="confirm_password" />
|
||||
</div>
|
||||
</FormRow>
|
||||
<FormButtons>
|
||||
<Button type="submit" variant="action">
|
||||
<FormattedMessage id="label.save" defaultMessage="Save" />
|
||||
</Button>
|
||||
<Button onClick={onClose}>
|
||||
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
|
||||
</Button>
|
||||
</FormButtons>
|
||||
<FormMessage>{message}</FormMessage>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</FormLayout>
|
||||
);
|
||||
}
|
||||
83
components/forms/DatePickerForm.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { isAfter, isBefore, isSameDay } from 'date-fns';
|
||||
import Calendar from 'components/common/Calendar';
|
||||
import Button from 'components/common/Button';
|
||||
import { FormButtons } from 'components/layout/FormLayout';
|
||||
import { getDateRangeValues } from 'lib/date';
|
||||
import styles from './DatePickerForm.module.css';
|
||||
import ButtonGroup from 'components/common/ButtonGroup';
|
||||
|
||||
const FILTER_DAY = 0;
|
||||
const FILTER_RANGE = 1;
|
||||
|
||||
export default function DatePickerForm({
|
||||
startDate: defaultStartDate,
|
||||
endDate: defaultEndDate,
|
||||
minDate,
|
||||
maxDate,
|
||||
onChange,
|
||||
onClose,
|
||||
}) {
|
||||
const [selected, setSelected] = useState(
|
||||
isSameDay(defaultStartDate, defaultEndDate) ? FILTER_DAY : FILTER_RANGE,
|
||||
);
|
||||
const [date, setDate] = useState(defaultStartDate);
|
||||
const [startDate, setStartDate] = useState(defaultStartDate);
|
||||
const [endDate, setEndDate] = useState(defaultEndDate);
|
||||
|
||||
const disabled =
|
||||
selected === FILTER_DAY
|
||||
? isAfter(minDate, date) && isBefore(maxDate, date)
|
||||
: isAfter(startDate, endDate);
|
||||
|
||||
const buttons = [
|
||||
{
|
||||
label: <FormattedMessage id="label.single-day" defaultMessage="Single day" />,
|
||||
value: FILTER_DAY,
|
||||
},
|
||||
{
|
||||
label: <FormattedMessage id="label.date-range" defaultMessage="Date range" />,
|
||||
value: FILTER_RANGE,
|
||||
},
|
||||
];
|
||||
|
||||
function handleSave() {
|
||||
if (selected === FILTER_DAY) {
|
||||
onChange({ ...getDateRangeValues(date, date), value: 'custom' });
|
||||
} else {
|
||||
onChange({ ...getDateRangeValues(startDate, endDate), value: 'custom' });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.filter}>
|
||||
<ButtonGroup size="small" items={buttons} selectedItem={selected} onClick={setSelected} />
|
||||
</div>
|
||||
<div className={styles.calendars}>
|
||||
{selected === FILTER_DAY ? (
|
||||
<Calendar date={date} minDate={minDate} maxDate={maxDate} onChange={setDate} />
|
||||
) : (
|
||||
<>
|
||||
<Calendar
|
||||
date={startDate}
|
||||
minDate={minDate}
|
||||
maxDate={endDate}
|
||||
onChange={setStartDate}
|
||||
/>
|
||||
<Calendar date={endDate} minDate={startDate} maxDate={maxDate} onChange={setEndDate} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<FormButtons>
|
||||
<Button variant="action" onClick={handleSave} disabled={disabled}>
|
||||
<FormattedMessage id="label.save" defaultMessage="Save" />
|
||||
</Button>
|
||||
<Button onClick={onClose}>
|
||||
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
|
||||
</Button>
|
||||
</FormButtons>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
98
components/forms/DeleteForm.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { Formik, Form, Field } from 'formik';
|
||||
import Button from 'components/common/Button';
|
||||
import FormLayout, {
|
||||
FormButtons,
|
||||
FormError,
|
||||
FormMessage,
|
||||
FormRow,
|
||||
} from 'components/layout/FormLayout';
|
||||
import useDelete from 'hooks/useDelete';
|
||||
|
||||
const CONFIRMATION_WORD = 'DELETE';
|
||||
|
||||
const validate = ({ confirmation }) => {
|
||||
const errors = {};
|
||||
|
||||
if (confirmation !== CONFIRMATION_WORD) {
|
||||
errors.confirmation = !confirmation ? (
|
||||
<FormattedMessage id="label.required" defaultMessage="Required" />
|
||||
) : (
|
||||
<FormattedMessage id="label.invalid" defaultMessage="Invalid" />
|
||||
);
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
export default function DeleteForm({ values, onSave, onClose }) {
|
||||
const del = useDelete();
|
||||
const [message, setMessage] = useState();
|
||||
|
||||
const handleSubmit = async ({ type, id }) => {
|
||||
const { ok, data } = await del(`/api/${type}/${id}`);
|
||||
|
||||
if (ok) {
|
||||
onSave();
|
||||
} else {
|
||||
setMessage(
|
||||
data || <FormattedMessage id="message.failure" defaultMessage="Something went wrong." />,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormLayout>
|
||||
<Formik
|
||||
initialValues={{ confirmation: '', ...values }}
|
||||
validate={validate}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{props => (
|
||||
<Form>
|
||||
<div>
|
||||
<FormattedMessage
|
||||
id="message.confirm-delete"
|
||||
defaultMessage="Are your sure you want to delete {target}?"
|
||||
values={{ target: <b>{values.name}</b> }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormattedMessage
|
||||
id="message.delete-warning"
|
||||
defaultMessage="All associated data will be deleted as well."
|
||||
/>
|
||||
</div>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id="message.type-delete"
|
||||
defaultMessage="Type {delete} in the box below to confirm."
|
||||
values={{ delete: <b>{CONFIRMATION_WORD}</b> }}
|
||||
/>
|
||||
</p>
|
||||
<FormRow>
|
||||
<div>
|
||||
<Field name="confirmation" type="text" />
|
||||
<FormError name="confirmation" />
|
||||
</div>
|
||||
</FormRow>
|
||||
<FormButtons>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="danger"
|
||||
disabled={props.values.confirmation !== CONFIRMATION_WORD}
|
||||
>
|
||||
<FormattedMessage id="label.delete" defaultMessage="Delete" />
|
||||
</Button>
|
||||
<Button onClick={onClose}>
|
||||
<FormattedMessage id="label.cancel" defaultMessage="Cancel" />
|
||||
</Button>
|
||||
</FormButtons>
|
||||
<FormMessage>{message}</FormMessage>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</FormLayout>
|
||||
);
|
||||
}
|
||||
106
components/forms/LoginForm.js
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
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';
|
||||
import { setItem } from 'lib/web';
|
||||
import { AUTH_TOKEN } from '../../lib/constants';
|
||||
|
||||
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) {
|
||||
setItem(AUTH_TOKEN, data.token);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||