|
@ -4,7 +4,7 @@
|
|||
"es2020": true,
|
||||
"node": true
|
||||
},
|
||||
"extends": ["eslint:recommended", "plugin:react/recommended", "prettier", "next"],
|
||||
"extends": ["eslint:recommended", "plugin:prettier/recommended", "next"],
|
||||
"parserOptions": {
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
|
@ -12,11 +12,12 @@
|
|||
"ecmaVersion": 11,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": ["react"],
|
||||
"rules": {
|
||||
"react/display-name": "off",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/prop-types": "off"
|
||||
"react/prop-types": "off",
|
||||
"import/no-anonymous-default-export": "off",
|
||||
"@next/next/no-img-element": "off"
|
||||
},
|
||||
"globals": {
|
||||
"React": "writable"
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
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@v2
|
||||
|
||||
- uses: mr-smithers-excellent/docker-build-push@v5
|
||||
name: Build & push Docker image 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
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
@ -0,0 +1,30 @@
|
|||
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@v2
|
||||
|
||||
- name: Set env
|
||||
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||
|
||||
- uses: mr-smithers-excellent/docker-build-push@v5
|
||||
name: Build & push Docker image 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
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
@ -0,0 +1,38 @@
|
|||
# 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
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- node-version: 14.x
|
||||
db-type: postgresql
|
||||
- node-version: 14.x
|
||||
db-type: mysql
|
||||
- node-version: 16.x
|
||||
db-type: postgresql
|
||||
- node-version: 16.x
|
||||
db-type: mysql
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
env:
|
||||
DATABASE_TYPE: ${{ matrix.db-type }}
|
||||
- run: npm install --global yarn
|
||||
- run: yarn install --frozen-lockfile
|
||||
- run: yarn build
|
|
@ -1,42 +0,0 @@
|
|||
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
|
|
@ -11,7 +11,7 @@
|
|||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
/prisma/schema.prisma
|
||||
/prisma/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
|
|
@ -13,5 +13,5 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"ignoreFiles": ["**/*.js"]
|
||||
"ignoreFiles": ["**/*.js", "**/*.md"]
|
||||
}
|
||||
|
|
76
Dockerfile
|
@ -1,43 +1,55 @@
|
|||
# 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
|
||||
# Install dependencies only when needed
|
||||
FROM node:16-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 ./
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
COPY . /build
|
||||
RUN yarn next telemetry disable
|
||||
# Rebuild the source code only when needed
|
||||
FROM node:16-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
ARG DATABASE_TYPE
|
||||
ARG BASE_PATH
|
||||
|
||||
ENV DATABASE_TYPE $DATABASE_TYPE
|
||||
ENV BASE_PATH $BASE_PATH
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN yarn build
|
||||
|
||||
# Production image
|
||||
FROM node:12.22-alpine AS production
|
||||
# Production image, copy all the files and run next
|
||||
FROM node:16-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Copy cached dependencies
|
||||
COPY --from=build /build/prod_node_modules ./node_modules
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
# Copy generated Prisma client
|
||||
COPY --from=build /build/node_modules/.prisma/ ./node_modules/.prisma/
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=build /build/yarn.lock /build/package.json ./
|
||||
COPY --from=build /build/.next ./.next
|
||||
COPY --from=build /build/public ./public
|
||||
RUN yarn add npm-run-all dotenv prisma
|
||||
|
||||
USER node
|
||||
# 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
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["yarn", "start"]
|
||||
|
||||
ENV PORT 3000
|
||||
|
||||
CMD ["yarn", "start-docker"]
|
||||
|
|
68
README.md
|
@ -1,6 +1,6 @@
|
|||
# umami
|
||||
|
||||
Umami is a simple, fast, website analytics alternative to Google Analytics.
|
||||
Umami is a simple, fast, privacy-focused alternative to Google Analytics.
|
||||
|
||||
## Getting started
|
||||
|
||||
|
@ -10,43 +10,29 @@ A detailed getting started guide can be found at [https://umami.is/docs/](https:
|
|||
|
||||
### Requirements
|
||||
|
||||
- A server with Node.js 12 or newer
|
||||
- A database (MySQL or Postgresql)
|
||||
- A server with Node.js version 12 or newer
|
||||
- A database. Umami supports [MySQL](https://www.mysql.com/) and [Postgresql](https://www.postgresql.org/) databases.
|
||||
|
||||
### Install Yarn
|
||||
|
||||
```
|
||||
npm install -g yarn
|
||||
```
|
||||
|
||||
### Get the source code and install packages
|
||||
|
||||
```
|
||||
git clone https://github.com/mikecao/umami.git
|
||||
git clone https://github.com/umami-software/umami.git
|
||||
cd umami
|
||||
npm install
|
||||
yarn install
|
||||
```
|
||||
|
||||
### Create database tables
|
||||
|
||||
Umami supports [MySQL](https://www.mysql.com/) and [Postgresql](https://www.postgresql.org/).
|
||||
Create a database for your Umami installation and install the tables with the included scripts.
|
||||
|
||||
For MySQL:
|
||||
|
||||
```
|
||||
mysql -u username -p databasename < sql/schema.mysql.sql
|
||||
```
|
||||
|
||||
For Postgresql:
|
||||
|
||||
```
|
||||
psql -h hostname -U username -d databasename -f sql/schema.postgresql.sql
|
||||
```
|
||||
|
||||
This will also create a login account with username **admin** and password **umami**.
|
||||
|
||||
### Configure umami
|
||||
|
||||
Create an `.env` file with the following
|
||||
|
||||
```
|
||||
DATABASE_URL=(connection url)
|
||||
HASH_SALT=(any random string)
|
||||
DATABASE_URL=connection-url
|
||||
```
|
||||
|
||||
The connection url is in the following format:
|
||||
|
@ -56,18 +42,24 @@ postgresql://username:mypassword@localhost:5432/mydb
|
|||
mysql://username:mypassword@localhost:3306/mydb
|
||||
```
|
||||
|
||||
The `HASH_SALT` is used to generate unique values for your installation.
|
||||
|
||||
### Build the application
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
yarn build
|
||||
```
|
||||
|
||||
### Create database tables
|
||||
|
||||
```bash
|
||||
yarn update-db
|
||||
```
|
||||
|
||||
This will also create a login account with username **admin** and password **umami**.
|
||||
|
||||
### Start the application
|
||||
|
||||
```bash
|
||||
npm start
|
||||
yarn start
|
||||
```
|
||||
|
||||
By default this will launch the application on `http://localhost:3000`. You will need to either
|
||||
|
@ -84,12 +76,12 @@ docker-compose up
|
|||
|
||||
Alternatively, to pull just the Umami Docker image with PostgreSQL support:
|
||||
```bash
|
||||
docker pull ghcr.io/mikecao/umami:postgresql-latest
|
||||
docker pull docker.umami.is/umami-software/umami:postgresql-latest
|
||||
```
|
||||
|
||||
Or with MySQL support:
|
||||
```bash
|
||||
docker pull ghcr.io/mikecao/umami:mysql-latest
|
||||
docker pull docker.umami.is/umami-software/umami:mysql-latest
|
||||
```
|
||||
|
||||
## Getting updates
|
||||
|
@ -98,8 +90,16 @@ To get the latest features, simply do a pull, install any new dependencies, and
|
|||
|
||||
```bash
|
||||
git pull
|
||||
npm install
|
||||
npm run build
|
||||
yarn install
|
||||
yarn build
|
||||
yarn update-db
|
||||
```
|
||||
|
||||
To update the Docker image, simply pull the new images and rebuild:
|
||||
|
||||
```bash
|
||||
docker-compose pull
|
||||
docker-compose up --force-recreate
|
||||
```
|
||||
|
||||
## License
|
||||
|
|
38
app.json
|
@ -1,26 +1,16 @@
|
|||
{
|
||||
"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": "/"
|
||||
"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": {
|
||||
"HASH_SALT": {
|
||||
"description": "Used to generate unique values for your installation",
|
||||
"required": true,
|
||||
"generator": "secret"
|
||||
}
|
||||
},
|
||||
"success_url": "/"
|
||||
}
|
||||
|
|
|
@ -1 +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>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!-- Font Awesome Pro 6.0.0-alpha2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M424 392H24C10.8 392 0 402.8 0 416V416C0 429.2 10.8 440 24 440H424C437.2 440 448 429.2 448 416V416C448 402.8 437.2 392 424 392ZM424 72H24C10.8 72 0 82.8 0 96V96C0 109.2 10.8 120 24 120H424C437.2 120 448 109.2 448 96V96C448 82.8 437.2 72 424 72ZM424 232H24C10.8 232 0 242.8 0 256V256C0 269.2 10.8 280 24 280H424C437.2 280 448 269.2 448 256V256C448 242.8 437.2 232 424 232Z"/></svg>
|
Before Width: | Height: | Size: 546 B After Width: | Height: | Size: 594 B |
|
@ -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="M504.265 315.978C504.265 307.326 499.658 299.134 491.906 294.586L458.998 275.615C459.643 269.099 459.966 262.549 459.966 256S459.643 242.901 458.998 236.385L491.906 217.414C499.658 212.866 504.265 204.674 504.265 196.022C504.265 174.755 454.947 67.846 419.746 67.846C415.502 67.846 411.236 68.939 407.379 71.203L374.599 90.172C363.888 82.43 352.533 75.848 340.531 70.428V32.488C340.531 21.262 333.047 11.453 322.205 8.613C300.654 2.871 278.425 0 256.181 0C233.935 0 211.675 2.871 190.06 8.613C179.218 11.453 171.734 21.262 171.734 32.488V70.428C159.732 75.848 148.377 82.43 137.666 90.172L104.886 71.203C101.031 68.939 96.763 67.846 92.519 67.846C92.517 67.846 92.514 67.846 92.512 67.846C60.048 67.846 8 169.591 8 196.022C8 204.674 12.607 212.866 20.359 217.414L53.267 236.385C52.622 242.901 52.299 249.451 52.299 256S52.622 269.099 53.267 275.615L20.359 294.586C12.607 299.134 8 307.326 8 315.978C8 337.245 57.318 444.154 92.519 444.154C96.763 444.154 101.029 443.061 104.886 440.797L137.666 421.828C148.377 429.57 159.732 436.152 171.734 441.572V479.512C171.734 490.738 179.218 500.547 190.06 503.387C211.611 509.129 233.84 512 256.084 512C278.33 512 300.59 509.129 322.205 503.387C333.047 500.547 340.531 490.738 340.531 479.512V441.572C352.533 436.152 363.888 429.57 374.599 421.828L407.379 440.797C411.234 443.061 415.502 444.154 419.746 444.154C452.209 444.154 504.265 342.423 504.265 315.978ZM415.361 389.959C391.561 376.186 404.101 383.444 371.705 364.695C329.649 395.09 339.375 389.426 292.531 410.582V460.82C279.236 463.161 266.948 464 256.093 464C240.669 464 228.14 462.306 219.734 460.824V410.582C172.779 389.376 182.552 395.044 140.56 364.695C108.748 383.105 117.896 377.811 96.924 389.949C81.181 371.256 68.849 349.895 60.517 326.84C81.643 314.663 72.361 320.014 104.088 301.723C101.549 276.083 100.277 266.079 100.277 256.04C100.277 246.018 101.545 235.96 104.088 210.277C72.198 191.892 81.571 197.295 60.504 185.152C68.818 162.109 81.187 140.686 96.904 122.041C120.704 135.814 108.164 128.556 140.56 147.305C182.616 116.91 172.89 122.574 219.734 101.418V51.18C233.029 48.839 245.318 48 256.172 48C271.597 48 284.126 49.694 292.531 51.176V101.418C339.486 122.624 329.713 116.956 371.705 147.305C405.655 127.657 394.228 134.27 415.343 122.051C431.084 140.744 443.416 162.105 451.748 185.16C430.622 197.337 439.904 191.986 408.177 210.277C410.716 235.917 411.988 245.921 411.988 255.96C411.988 265.982 410.72 276.04 408.177 301.723C440.067 320.108 430.694 314.705 451.761 326.848C443.447 349.891 431.078 371.314 415.361 389.959ZM256.133 160C203.258 160 160.133 203.125 160.133 256S203.258 352 256.133 352S352.133 308.875 352.133 256S309.008 160 256.133 160ZM256.133 304C229.666 304 208.133 282.467 208.133 256S229.666 208 256.133 208S304.133 229.533 304.133 256S282.599 304 256.133 304Z "></path></svg>
|
After Width: | Height: | Size: 3.0 KiB |
|
@ -1,2 +1,2 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 428 389.11">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 428 389.11" width="20" height="20">
|
||||
<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>
|
Before Width: | Height: | Size: 377 B After Width: | Height: | Size: 401 B |
|
@ -1 +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>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><!-- Font Awesome Pro 6.0.0-alpha2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M312.973 375.032C322.342 384.401 322.342 399.604 312.973 408.973S288.401 418.342 279.032 408.973L160 289.941L40.968 408.973C31.599 418.342 16.396 418.342 7.027 408.973S-2.342 384.401 7.027 375.032L126.059 256L7.027 136.968C-2.342 127.599 -2.342 112.396 7.027 103.027S31.599 93.658 40.968 103.027L160 222.059L279.032 103.027C288.401 93.658 303.604 93.658 312.973 103.027S322.342 127.599 312.973 136.968L193.941 256L312.973 375.032Z"/></svg>
|
Before Width: | Height: | Size: 441 B After Width: | Height: | Size: 653 B |
|
@ -0,0 +1,34 @@
|
|||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import classNames from 'classnames';
|
||||
import usePageQuery from 'hooks/usePageQuery';
|
||||
import { safeDecodeURI } from 'lib/url';
|
||||
import Icon from './Icon';
|
||||
import External from 'assets/arrow-up-right-from-square.svg';
|
||||
import styles from './FilterLink.module.css';
|
||||
|
||||
export default function FilterLink({ id, value, label, externalUrl }) {
|
||||
const { resolve, query } = usePageQuery();
|
||||
const active = query[id] !== undefined;
|
||||
const selected = query[id] === value;
|
||||
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
<Link href={resolve({ [id]: value })} replace>
|
||||
<a
|
||||
className={classNames(styles.label, {
|
||||
[styles.inactive]: active && !selected,
|
||||
[styles.active]: active && selected,
|
||||
})}
|
||||
>
|
||||
{safeDecodeURI(label || value)}
|
||||
</a>
|
||||
</Link>
|
||||
{externalUrl && (
|
||||
<a href={externalUrl} target="_blank" rel="noreferrer noopener" className={styles.link}>
|
||||
<Icon icon={<External />} className={styles.icon} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,19 +1,20 @@
|
|||
body .inactive {
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.row .inactive {
|
||||
color: var(--gray500);
|
||||
}
|
||||
|
||||
body .active {
|
||||
.row .active {
|
||||
color: var(--gray900);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.row .link {
|
||||
display: none;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.row .label {
|
|
@ -0,0 +1,44 @@
|
|||
import Button from 'components/common/Button';
|
||||
import XMark from 'assets/xmark.svg';
|
||||
import Bars from 'assets/bars.svg';
|
||||
import { useState } from 'react';
|
||||
import styles from './HamburgerButton.module.css';
|
||||
import MobileMenu from './MobileMenu';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
label: <FormattedMessage id="label.dashboard" defaultMessage="Dashboard" />,
|
||||
value: '/dashboard',
|
||||
},
|
||||
{ label: <FormattedMessage id="label.realtime" defaultMessage="Realtime" />, value: '/realtime' },
|
||||
{ label: <FormattedMessage id="label.settings" defaultMessage="Settings" />, value: '/settings' },
|
||||
{
|
||||
label: <FormattedMessage id="label.profile" defaultMessage="Profile" />,
|
||||
value: '/settings/profile',
|
||||
},
|
||||
{ label: <FormattedMessage id="label.logout" defaultMessage="Logout" />, value: '/logout' },
|
||||
];
|
||||
|
||||
export default function HamburgerButton() {
|
||||
const [active, setActive] = useState(false);
|
||||
|
||||
function handleClick() {
|
||||
setActive(state => !state);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
setActive(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
className={styles.button}
|
||||
icon={active ? <XMark /> : <Bars />}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
{active && <MobileMenu items={menuItems} onClose={handleClose} />}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
.button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.button {
|
||||
display: flex;
|
||||
}
|
||||
}
|
|
@ -5,7 +5,7 @@ import NextLink from 'next/link';
|
|||
import Icon from './Icon';
|
||||
import styles from './Link.module.css';
|
||||
|
||||
function Link({ className, icon, children, size, iconRight, ...props }) {
|
||||
function Link({ className, icon, children, size, iconRight, onClick, ...props }) {
|
||||
return (
|
||||
<NextLink {...props}>
|
||||
<a
|
||||
|
@ -15,6 +15,7 @@ function Link({ className, icon, children, size, iconRight, ...props }) {
|
|||
[styles.xsmall]: size === 'xsmall',
|
||||
[styles.iconRight]: iconRight,
|
||||
})}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon && <Icon className={styles.icon} icon={icon} size={size} />}
|
||||
{children}
|
||||
|
|
|
@ -8,20 +8,12 @@ a.link:visited {
|
|||
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 span {
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
a.link:hover:before {
|
||||
width: 100%;
|
||||
transition: width 100ms;
|
||||
a.link:hover span {
|
||||
border-bottom: 2px solid var(--primary400);
|
||||
}
|
||||
|
||||
a.link.large {
|
||||
|
|
|
@ -11,6 +11,7 @@ function MenuButton({
|
|||
value,
|
||||
options,
|
||||
buttonClassName,
|
||||
buttonVariant,
|
||||
menuClassName,
|
||||
menuPosition = 'bottom',
|
||||
menuAlign = 'right',
|
||||
|
@ -43,7 +44,7 @@ function MenuButton({
|
|||
icon={icon}
|
||||
className={classNames(styles.button, buttonClassName, { [styles.open]: showMenu })}
|
||||
onClick={toggleMenu}
|
||||
variant="light"
|
||||
variant={buttonVariant}
|
||||
>
|
||||
{!hideLabel && (
|
||||
<div className={styles.text}>{renderValue ? renderValue(selectedOption) : value}</div>
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
import classNames from 'classnames';
|
||||
import Link from './Link';
|
||||
import Button from './Button';
|
||||
import XMark from 'assets/xmark.svg';
|
||||
import styles from './MobileMenu.module.css';
|
||||
|
||||
export default function MobileMenu({ items = [], onClose }) {
|
||||
return (
|
||||
<div className={classNames(styles.menu, 'container')}>
|
||||
<div className={styles.header}>
|
||||
<Button icon={<XMark />} onClick={onClose} />
|
||||
</div>
|
||||
<div className={styles.items}>
|
||||
{items.map(({ label, value }) => (
|
||||
<Link key={value} href={value} className={styles.item} onClick={onClose}>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
.menu {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--gray50);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.items {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.item {
|
||||
font-size: var(--font-size-large);
|
||||
}
|
||||
|
||||
.item + .item {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.item:last-child {
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
height: 100px;
|
||||
padding: 0 30px;
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import ReactTooltip from 'react-tooltip';
|
||||
|
||||
import styles from './OverflowText.module.css';
|
||||
|
||||
const OverflowText = ({ children, tooltipId }) => {
|
||||
const measureEl = useRef();
|
||||
const [isOverflown, setIsOverflown] = useState(false);
|
||||
|
||||
const measure = useCallback(
|
||||
el => {
|
||||
if (!el) return;
|
||||
setIsOverflown(el.scrollWidth > el.clientWidth);
|
||||
},
|
||||
[setIsOverflown],
|
||||
);
|
||||
|
||||
// Do one measure on mount
|
||||
useEffect(() => {
|
||||
measure(measureEl.current);
|
||||
}, [measure]);
|
||||
|
||||
// Set up resize listener for subsequent measures
|
||||
useEffect(() => {
|
||||
if (!measureEl.current) return;
|
||||
|
||||
// Destructure ref in case it changes out from under us
|
||||
const el = measureEl.current;
|
||||
|
||||
if ('ResizeObserver' in global) {
|
||||
// Ideally, we have access to ResizeObservers
|
||||
const observer = new ResizeObserver(() => {
|
||||
measure(el);
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.unobserve(el);
|
||||
} else {
|
||||
// Otherwise, fall back to measuring on window resizes
|
||||
const handler = () => measure(el);
|
||||
|
||||
window.addEventListener('resize', handler, { passive: true });
|
||||
return () => window.removeEventListener('resize', handler, { passive: true });
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={measureEl}
|
||||
data-tip={children.toString()}
|
||||
data-effect="solid"
|
||||
data-for={tooltipId}
|
||||
className={styles.root}
|
||||
>
|
||||
{children}
|
||||
{isOverflown && <ReactTooltip id={tooltipId}>{children}</ReactTooltip>}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
OverflowText.propTypes = {
|
||||
children: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
|
||||
tooltipId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default OverflowText;
|
|
@ -0,0 +1,6 @@
|
|||
.root {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
|
@ -1,23 +1,27 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } 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 useStore from 'store/queries';
|
||||
import { setDateRange } from 'store/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`]);
|
||||
const selector = useCallback(state => state[`/website/${websiteId}/stats`], [websiteId]);
|
||||
const completed = useStore(selector);
|
||||
|
||||
function handleClick() {
|
||||
if (dateRange) {
|
||||
if (!loading && dateRange) {
|
||||
setLoading(true);
|
||||
dispatch(setDateRange(websiteId, dateRange));
|
||||
if (/^[\d]+/.test(dateRange.value)) {
|
||||
setDateRange(websiteId, dateRange.value);
|
||||
} else {
|
||||
setDateRange(websiteId, dateRange);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -76,12 +76,13 @@ export default Table;
|
|||
|
||||
export const TableRow = ({ columns, row }) => (
|
||||
<div className={classNames(styles.row, 'row')}>
|
||||
{columns.map(({ key, render, className, style, cell }, index) => (
|
||||
{columns.map(({ key, label, render, className, style, cell }, index) => (
|
||||
<div
|
||||
key={`${key}-${index}`}
|
||||
className={classNames(styles.cell, className, cell?.className)}
|
||||
style={{ ...style, ...cell?.style }}
|
||||
>
|
||||
{label && <label>{label}</label>}
|
||||
{render ? render(row) : row[key]}
|
||||
</div>
|
||||
))}
|
||||
|
|
|
@ -3,6 +3,12 @@
|
|||
flex-direction: column;
|
||||
}
|
||||
|
||||
.table label {
|
||||
display: none;
|
||||
font-size: var(--font-size-xsmall);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.header {
|
||||
border-bottom: 1px solid var(--gray300);
|
||||
}
|
||||
|
@ -26,5 +32,24 @@
|
|||
|
||||
.cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 992px) {
|
||||
.table label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cell {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,9 +2,9 @@ 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';
|
||||
import styles from './Toast.module.css';
|
||||
|
||||
function Toast({ message, timeout = 3000, onClose }) {
|
||||
const props = useSpring({
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
.toast {
|
||||
position: absolute;
|
||||
position: fixed;
|
||||
top: 30px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
|
|
@ -1,27 +1,38 @@
|
|||
import React from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import useVersion from 'hooks/useVersion';
|
||||
import styles from './UpdateNotice.module.css';
|
||||
import ButtonLayout from '../layout/ButtonLayout';
|
||||
import ButtonLayout from 'components/layout/ButtonLayout';
|
||||
import useStore, { checkVersion } from 'store/version';
|
||||
import { setItem } from 'lib/web';
|
||||
import { REPO_URL, VERSION_CHECK } from 'lib/constants';
|
||||
import Button from './Button';
|
||||
import useForceUpdate from '../../hooks/useForceUpdate';
|
||||
import styles from './UpdateNotice.module.css';
|
||||
|
||||
export default function UpdateNotice() {
|
||||
const forceUpdate = useForceUpdate();
|
||||
const { hasUpdate, checked, latest, updateCheck } = useVersion(true);
|
||||
const { latest, checked, hasUpdate, releaseUrl } = useStore();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
const updateCheck = useCallback(() => {
|
||||
setItem(VERSION_CHECK, { version: latest, time: Date.now() });
|
||||
}, [latest]);
|
||||
|
||||
function handleViewClick() {
|
||||
location.href = 'https://github.com/mikecao/umami/releases';
|
||||
updateCheck();
|
||||
forceUpdate();
|
||||
setDismissed(true);
|
||||
location.href = releaseUrl || REPO_URL;
|
||||
}
|
||||
|
||||
function handleDismissClick() {
|
||||
updateCheck();
|
||||
forceUpdate();
|
||||
setDismissed(true);
|
||||
}
|
||||
|
||||
if (!hasUpdate || checked) {
|
||||
useEffect(() => {
|
||||
if (!checked) {
|
||||
checkVersion();
|
||||
}
|
||||
}, [checked]);
|
||||
|
||||
if (!hasUpdate || dismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -34,7 +45,7 @@ export default function UpdateNotice() {
|
|||
values={{ version: `v${latest}` }}
|
||||
/>
|
||||
</div>
|
||||
<ButtonLayout>
|
||||
<ButtonLayout className={styles.buttons}>
|
||||
<Button size="xsmall" variant="action" onClick={handleViewClick}>
|
||||
<FormattedMessage id="label.view-details" defaultMessage="View details" />
|
||||
</Button>
|
||||
|
|
|
@ -2,12 +2,17 @@
|
|||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding-top: 10px;
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
flex: 0;
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import FormLayout, {
|
|||
FormMessage,
|
||||
FormRow,
|
||||
} from 'components/layout/FormLayout';
|
||||
import usePost from 'hooks/usePost';
|
||||
import useApi from 'hooks/useApi';
|
||||
|
||||
const initialValues = {
|
||||
username: '',
|
||||
|
@ -29,11 +29,11 @@ const validate = ({ user_id, username, password }) => {
|
|||
};
|
||||
|
||||
export default function AccountEditForm({ values, onSave, onClose }) {
|
||||
const post = usePost();
|
||||
const { post } = useApi();
|
||||
const [message, setMessage] = useState();
|
||||
|
||||
const handleSubmit = async values => {
|
||||
const { ok, data } = await post('/api/account', values);
|
||||
const { ok, data } = await post('/account', values);
|
||||
|
||||
if (ok) {
|
||||
onSave();
|
||||
|
|
|
@ -8,7 +8,7 @@ import FormLayout, {
|
|||
FormMessage,
|
||||
FormRow,
|
||||
} from 'components/layout/FormLayout';
|
||||
import usePost from 'hooks/usePost';
|
||||
import useApi from 'hooks/useApi';
|
||||
|
||||
const initialValues = {
|
||||
current_password: '',
|
||||
|
@ -37,11 +37,11 @@ const validate = ({ current_password, new_password, confirm_password }) => {
|
|||
};
|
||||
|
||||
export default function ChangePasswordForm({ values, onSave, onClose }) {
|
||||
const post = usePost();
|
||||
const { post } = useApi();
|
||||
const [message, setMessage] = useState();
|
||||
|
||||
const handleSubmit = async values => {
|
||||
const { ok, data } = await post('/api/account/password', values);
|
||||
const { ok, data } = await post('/account/password', values);
|
||||
|
||||
if (ok) {
|
||||
onSave();
|
||||
|
|
|
@ -8,7 +8,7 @@ import FormLayout, {
|
|||
FormMessage,
|
||||
FormRow,
|
||||
} from 'components/layout/FormLayout';
|
||||
import useDelete from 'hooks/useDelete';
|
||||
import useApi from 'hooks/useApi';
|
||||
|
||||
const CONFIRMATION_WORD = 'DELETE';
|
||||
|
||||
|
@ -27,11 +27,11 @@ const validate = ({ confirmation }) => {
|
|||
};
|
||||
|
||||
export default function DeleteForm({ values, onSave, onClose }) {
|
||||
const del = useDelete();
|
||||
const { del } = useApi();
|
||||
const [message, setMessage] = useState();
|
||||
|
||||
const handleSubmit = async ({ type, id }) => {
|
||||
const { ok, data } = await del(`/api/${type}/${id}`);
|
||||
const { ok, data } = await del(`/${type}/${id}`);
|
||||
|
||||
if (ok) {
|
||||
onSave();
|
||||
|
|
|
@ -10,11 +10,12 @@ import FormLayout, {
|
|||
FormRow,
|
||||
} from 'components/layout/FormLayout';
|
||||
import Icon from 'components/common/Icon';
|
||||
import useApi from 'hooks/useApi';
|
||||
import { setItem } from 'lib/web';
|
||||
import { AUTH_TOKEN } from 'lib/constants';
|
||||
import { setUser } from 'store/app';
|
||||
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 = {};
|
||||
|
@ -30,12 +31,12 @@ const validate = ({ username, password }) => {
|
|||
};
|
||||
|
||||
export default function LoginForm() {
|
||||
const post = usePost();
|
||||
const { post } = useApi();
|
||||
const router = useRouter();
|
||||
const [message, setMessage] = useState();
|
||||
|
||||
const handleSubmit = async ({ username, password }) => {
|
||||
const { ok, status, data } = await post('/api/auth/login', {
|
||||
const { ok, status, data } = await post('/auth/login', {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
|
@ -43,7 +44,11 @@ export default function LoginForm() {
|
|||
if (ok) {
|
||||
setItem(AUTH_TOKEN, data.token);
|
||||
|
||||
return router.push('/');
|
||||
setUser(data.user);
|
||||
|
||||
await router.push('/');
|
||||
|
||||
return null;
|
||||
} else {
|
||||
setMessage(
|
||||
status === 401 ? (
|
||||
|
|
|
@ -8,7 +8,7 @@ import FormLayout, {
|
|||
FormMessage,
|
||||
FormRow,
|
||||
} from 'components/layout/FormLayout';
|
||||
import usePost from 'hooks/usePost';
|
||||
import useApi from 'hooks/useApi';
|
||||
|
||||
const CONFIRMATION_WORD = 'RESET';
|
||||
|
||||
|
@ -27,11 +27,11 @@ const validate = ({ confirmation }) => {
|
|||
};
|
||||
|
||||
export default function ResetForm({ values, onSave, onClose }) {
|
||||
const post = usePost();
|
||||
const { post } = useApi();
|
||||
const [message, setMessage] = useState();
|
||||
|
||||
const handleSubmit = async ({ type, id }) => {
|
||||
const { ok, data } = await post(`/api/${type}/${id}/reset`);
|
||||
const { ok, data } = await post(`/${type}/${id}/reset`);
|
||||
|
||||
if (ok) {
|
||||
onSave();
|
||||
|
|
|
@ -4,12 +4,12 @@ import { useRouter } from 'next/router';
|
|||
import Button from 'components/common/Button';
|
||||
import FormLayout, { FormButtons, FormRow } from 'components/layout/FormLayout';
|
||||
import CopyButton from 'components/common/CopyButton';
|
||||
|
||||
const scriptName = process.env.TRACKER_SCRIPT_NAME || 'umami';
|
||||
import useConfig from 'hooks/useConfig';
|
||||
|
||||
export default function TrackingCodeForm({ values, onClose }) {
|
||||
const ref = useRef();
|
||||
const { basePath } = useRouter();
|
||||
const { trackerScriptName } = useConfig();
|
||||
|
||||
return (
|
||||
<FormLayout>
|
||||
|
@ -26,7 +26,9 @@ export default function TrackingCodeForm({ values, onClose }) {
|
|||
rows={3}
|
||||
cols={60}
|
||||
spellCheck={false}
|
||||
defaultValue={`<script async defer data-website-id="${values.website_uuid}" src="${document.location.origin}${basePath}/${scriptName}.js"></script>`}
|
||||
defaultValue={`<script async defer data-website-id="${values.website_uuid}" src="${
|
||||
document.location.origin
|
||||
}${basePath}/${trackerScriptName ? `${trackerScriptName}.js` : 'umami.js'}"></script>`}
|
||||
readOnly
|
||||
/>
|
||||
</FormRow>
|
||||
|
|
|
@ -10,7 +10,7 @@ import FormLayout, {
|
|||
} from 'components/layout/FormLayout';
|
||||
import Checkbox from 'components/common/Checkbox';
|
||||
import { DOMAIN_REGEX } from 'lib/constants';
|
||||
import usePost from 'hooks/usePost';
|
||||
import useApi from 'hooks/useApi';
|
||||
|
||||
const initialValues = {
|
||||
name: '',
|
||||
|
@ -34,11 +34,11 @@ const validate = ({ name, domain }) => {
|
|||
};
|
||||
|
||||
export default function WebsiteEditForm({ values, onSave, onClose }) {
|
||||
const post = usePost();
|
||||
const { post } = useApi();
|
||||
const [message, setMessage] = useState();
|
||||
|
||||
const handleSubmit = async values => {
|
||||
const { ok, data } = await post('/api/website', values);
|
||||
const { ok, data } = await post('/website', values);
|
||||
|
||||
if (ok) {
|
||||
onSave();
|
||||
|
|
|
@ -25,9 +25,8 @@ export default function StickyHeader({
|
|||
}
|
||||
};
|
||||
|
||||
checkPosition();
|
||||
|
||||
if (enabled) {
|
||||
checkPosition();
|
||||
window.addEventListener('scroll', checkPosition);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
.buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.buttons button + * {
|
||||
|
|
|
@ -1,36 +1,34 @@
|
|||
import React from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Script from 'next/script';
|
||||
import classNames from 'classnames';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import Link from 'components/common/Link';
|
||||
import styles from './Footer.module.css';
|
||||
import useVersion from 'hooks/useVersion';
|
||||
import useLocale from 'hooks/useLocale';
|
||||
import { CURRENT_VERSION, HOMEPAGE_URL, REPO_URL } from 'lib/constants';
|
||||
|
||||
export default function Footer() {
|
||||
const { current } = useVersion();
|
||||
const { dir } = useLocale();
|
||||
const { pathname } = useRouter();
|
||||
|
||||
return (
|
||||
<footer className="container" dir={dir}>
|
||||
<div className={classNames(styles.footer, 'row')}>
|
||||
<div className="col-12 col-md-4" />
|
||||
<div className="col-12 col-md-4">
|
||||
<FormattedMessage
|
||||
id="message.powered-by"
|
||||
defaultMessage="Powered by {name}"
|
||||
values={{
|
||||
name: (
|
||||
<Link href="https://umami.is">
|
||||
<b>umami</b>
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={classNames(styles.version, 'col-12 col-md-4')}>
|
||||
<Link href={`https://github.com/mikecao/umami/releases`}>{`v${current}`}</Link>
|
||||
</div>
|
||||
<footer className={classNames(styles.footer, 'row')}>
|
||||
<div className="col-12 col-md-4" />
|
||||
<div className="col-12 col-md-4">
|
||||
<FormattedMessage
|
||||
id="message.powered-by"
|
||||
defaultMessage="Powered by {name}"
|
||||
values={{
|
||||
name: (
|
||||
<Link href={HOMEPAGE_URL}>
|
||||
<b>umami</b>
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={classNames(styles.version, 'col-12 col-md-4')}>
|
||||
<Link href={REPO_URL}>{`v${CURRENT_VERSION}`}</Link>
|
||||
</div>
|
||||
{!pathname.includes('/share/') && <Script src={`/telemetry.js`} />}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -3,16 +3,17 @@
|
|||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: var(--font-size-small);
|
||||
min-height: 100px;
|
||||
text-align: center;
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
.version {
|
||||
text-align: right;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.version {
|
||||
.footer .version {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,71 +1,54 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useSelector } from 'react-redux';
|
||||
import classNames from 'classnames';
|
||||
import Link from 'components/common/Link';
|
||||
import Icon from 'components/common/Icon';
|
||||
import LanguageButton from 'components/settings/LanguageButton';
|
||||
import ThemeButton from 'components/settings/ThemeButton';
|
||||
import HamburgerButton from 'components/common/HamburgerButton';
|
||||
import UpdateNotice from 'components/common/UpdateNotice';
|
||||
import UserButton from 'components/settings/UserButton';
|
||||
import Button from 'components/common/Button';
|
||||
import { HOMEPAGE_URL } from 'lib/constants';
|
||||
import useConfig from '/hooks/useConfig';
|
||||
import useUser from 'hooks/useUser';
|
||||
import Logo from 'assets/logo.svg';
|
||||
import styles from './Header.module.css';
|
||||
import useLocale from 'hooks/useLocale';
|
||||
import XMark from 'assets/xmark.svg';
|
||||
import Bars from 'assets/bars.svg';
|
||||
|
||||
export default function Header() {
|
||||
const user = useSelector(state => state.user);
|
||||
const [active, setActive] = useState(false);
|
||||
const { dir } = useLocale();
|
||||
|
||||
function handleClick() {
|
||||
setActive(state => !state);
|
||||
}
|
||||
const { user } = useUser();
|
||||
const { pathname } = useRouter();
|
||||
const { updatesDisabled } = useConfig();
|
||||
const isSharePage = pathname.includes('/share/');
|
||||
const allowUpdate = user?.is_admin && !updatesDisabled && !isSharePage;
|
||||
|
||||
return (
|
||||
<nav className="container" dir={dir}>
|
||||
{user?.is_admin && <UpdateNotice />}
|
||||
<div className={classNames(styles.header, 'row align-items-center')}>
|
||||
<div className={styles.nav}>
|
||||
<div className="">
|
||||
<div className={styles.title}>
|
||||
<Icon icon={<Logo />} size="large" className={styles.logo} />
|
||||
<Link href={user ? '/' : 'https://umami.is'}>umami</Link>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className={styles.burger}
|
||||
icon={active ? <XMark /> : <Bars />}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
{user && (
|
||||
<div className={styles.items}>
|
||||
<div className={active ? classNames(styles.active) : ''}>
|
||||
<Link href="/dashboard">
|
||||
<FormattedMessage id="label.dashboard" defaultMessage="Dashboard" />
|
||||
</Link>
|
||||
<Link href="/realtime">
|
||||
<FormattedMessage id="label.realtime" defaultMessage="Realtime" />
|
||||
</Link>
|
||||
<Link href="/settings">
|
||||
<FormattedMessage id="label.settings" defaultMessage="Settings" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.items}>
|
||||
<div className={active ? classNames(styles.active) : ''}>
|
||||
<div className={styles.buttons}>
|
||||
<ThemeButton />
|
||||
<LanguageButton menuAlign="right" />
|
||||
{user && <UserButton />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
{allowUpdate && <UpdateNotice />}
|
||||
<header className={classNames(styles.header, 'row')}>
|
||||
<div className={styles.title}>
|
||||
<Icon icon={<Logo />} size="large" className={styles.logo} />
|
||||
<Link href={isSharePage ? HOMEPAGE_URL : '/'}>umami</Link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<HamburgerButton />
|
||||
{user && (
|
||||
<div className={styles.links}>
|
||||
<Link href="/dashboard">
|
||||
<FormattedMessage id="label.dashboard" defaultMessage="Dashboard" />
|
||||
</Link>
|
||||
<Link href="/realtime">
|
||||
<FormattedMessage id="label.realtime" defaultMessage="Realtime" />
|
||||
</Link>
|
||||
<Link href="/settings">
|
||||
<FormattedMessage id="label.settings" defaultMessage="Settings" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.buttons}>
|
||||
<ThemeButton />
|
||||
<LanguageButton menuAlign="right" />
|
||||
{user && <UserButton />}
|
||||
</div>
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,22 +1,12 @@
|
|||
.navbar {
|
||||
align-items: stretch;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.burger {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 100px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
font-size: var(--font-size-large);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
@ -27,16 +17,8 @@
|
|||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: var(--font-size-normal);
|
||||
font-weight: 600;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.items {
|
||||
.links {
|
||||
flex: 2;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
@ -44,73 +26,42 @@
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav a + a {
|
||||
.links a + a {
|
||||
margin-left: 40px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 992px) {
|
||||
.nav {
|
||||
font-size: var(--font-size-large);
|
||||
justify-content: space-between;
|
||||
margin: 20px 0;
|
||||
.header .buttons {
|
||||
flex: 1;
|
||||
}
|
||||
.items {
|
||||
flex-wrap: wrap;
|
||||
|
||||
.links {
|
||||
order: 2;
|
||||
margin: 20px 0;
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
.header {
|
||||
padding: 0 15px;
|
||||
padding: 0 30px;
|
||||
}
|
||||
|
||||
.title {
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.nav {
|
||||
font-size: var(--font-size-normal);
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.items {
|
||||
display: flex;
|
||||
justify-content: unset;
|
||||
font-size: var(--font-size-normal);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.items > div {
|
||||
.buttons,
|
||||
.links {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header .active {
|
||||
display: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.items a {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.burger {
|
||||
display: block;
|
||||
background: none;
|
||||
border: 1px solid var(--gray900);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
.title {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,9 +14,7 @@ export default function Layout({ title, children, header = true, footer = true }
|
|||
</Head>
|
||||
|
||||
{header && <Header />}
|
||||
<main className="container" dir={dir}>
|
||||
{children}
|
||||
</main>
|
||||
<main>{children}</main>
|
||||
{footer && <Footer />}
|
||||
<div id="__modals" dir={dir} />
|
||||
</>
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import React from 'react';
|
||||
import { defineMessages, FormattedMessage } from 'react-intl';
|
||||
import { defineMessages } from 'react-intl';
|
||||
|
||||
export const labels = defineMessages({
|
||||
unknown: { id: 'label.unknown', defaultMessage: 'Unknown' },
|
||||
|
@ -13,5 +12,5 @@ export const devices = defineMessages({
|
|||
});
|
||||
|
||||
export function getDeviceMessage(device) {
|
||||
return <FormattedMessage {...(devices[device] || labels.unknown)} />;
|
||||
return devices[device] || labels.unknown;
|
||||
}
|
||||
|
|
|
@ -3,19 +3,20 @@ import { FormattedMessage } from 'react-intl';
|
|||
import classNames from 'classnames';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import Dot from 'components/common/Dot';
|
||||
import { TOKEN_HEADER } from 'lib/constants';
|
||||
import useShareToken from 'hooks/useShareToken';
|
||||
import styles from './ActiveUsers.module.css';
|
||||
|
||||
export default function ActiveUsers({ websiteId, className, value, interval = 60000 }) {
|
||||
const shareToken = useShareToken();
|
||||
const { data } = useFetch(!value && `/api/website/${websiteId}/active`, {
|
||||
const url = websiteId ? `/website/${websiteId}/active` : null;
|
||||
const { data } = useFetch(url, {
|
||||
interval,
|
||||
headers: { [TOKEN_HEADER]: shareToken?.token },
|
||||
});
|
||||
const count = useMemo(() => {
|
||||
return value || data?.[0]?.x || 0;
|
||||
}, [data, value]);
|
||||
if (websiteId) {
|
||||
return data?.[0]?.x || 0;
|
||||
}
|
||||
|
||||
return value !== undefined ? value : 0;
|
||||
}, [data, value, websiteId]);
|
||||
|
||||
if (count === 0) {
|
||||
return null;
|
||||
|
|
|
@ -7,7 +7,7 @@ import { dateFormat } from 'lib/date';
|
|||
import useLocale from 'hooks/useLocale';
|
||||
import useTheme from 'hooks/useTheme';
|
||||
import useForceUpdate from 'hooks/useForceUpdate';
|
||||
import { DEFAUL_CHART_HEIGHT, DEFAULT_ANIMATION_DURATION, THEME_COLORS } from 'lib/constants';
|
||||
import { DEFAULT_ANIMATION_DURATION, THEME_COLORS } from 'lib/constants';
|
||||
import styles from './BarChart.module.css';
|
||||
import ChartTooltip from './ChartTooltip';
|
||||
|
||||
|
@ -16,7 +16,6 @@ export default function BarChart({
|
|||
datasets,
|
||||
unit,
|
||||
records,
|
||||
height = DEFAUL_CHART_HEIGHT,
|
||||
animationDuration = DEFAULT_ANIMATION_DURATION,
|
||||
className,
|
||||
stacked = false,
|
||||
|
@ -215,7 +214,6 @@ export default function BarChart({
|
|||
data-tip=""
|
||||
data-for={`${chartId}-tooltip`}
|
||||
className={classNames(styles.chart, className)}
|
||||
style={{ height }}
|
||||
>
|
||||
<canvas ref={canvas} />
|
||||
</div>
|
||||
|
|
|
@ -1,3 +1,10 @@
|
|||
.chart {
|
||||
position: relative;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 992px) {
|
||||
.chart {
|
||||
height: 200px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,14 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import MetricsTable from './MetricsTable';
|
||||
import { browserFilter } from 'lib/filters';
|
||||
import FilterLink from 'components/common/FilterLink';
|
||||
import MetricsTable from 'components/metrics/MetricsTable';
|
||||
import { BROWSERS } from 'lib/constants';
|
||||
|
||||
export default function BrowsersTable({ websiteId, ...props }) {
|
||||
function renderLink({ x: browser }) {
|
||||
return <FilterLink id="browser" value={browser} label={BROWSERS[browser] || browser} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MetricsTable
|
||||
{...props}
|
||||
|
@ -11,7 +16,7 @@ export default function BrowsersTable({ websiteId, ...props }) {
|
|||
type="browser"
|
||||
metric={<FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />}
|
||||
websiteId={websiteId}
|
||||
dataFilter={browserFilter}
|
||||
renderLabel={renderLink}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,18 +1,30 @@
|
|||
import React from 'react';
|
||||
import MetricsTable from './MetricsTable';
|
||||
import { percentFilter } from 'lib/filters';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
import FilterLink from 'components/common/FilterLink';
|
||||
import useCountryNames from 'hooks/useCountryNames';
|
||||
import useLocale from 'hooks/useLocale';
|
||||
|
||||
const messages = defineMessages({
|
||||
unknown: { id: 'label.unknown', defaultMessage: 'Unknown' },
|
||||
countries: { id: 'metrics.countries', defaultMessage: 'Countries' },
|
||||
visitors: { id: 'metrics.visitors', defaultMessage: 'Visitors' },
|
||||
});
|
||||
|
||||
export default function CountriesTable({ websiteId, onDataLoad, ...props }) {
|
||||
const { locale } = useLocale();
|
||||
const countryNames = useCountryNames(locale);
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
function renderLabel({ x }) {
|
||||
function renderLink({ x: code }) {
|
||||
return (
|
||||
<div className={locale}>
|
||||
{countryNames[x] ?? <FormattedMessage id="label.unknown" defaultMessage="Unknown" />}
|
||||
<FilterLink
|
||||
id="country"
|
||||
value={code}
|
||||
label={countryNames[code] ?? formatMessage(messages.unknown)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -20,12 +32,12 @@ export default function CountriesTable({ websiteId, onDataLoad, ...props }) {
|
|||
return (
|
||||
<MetricsTable
|
||||
{...props}
|
||||
title={<FormattedMessage id="metrics.countries" defaultMessage="Countries" />}
|
||||
title={formatMessage(messages.countries)}
|
||||
type="country"
|
||||
metric={<FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />}
|
||||
metric={formatMessage(messages.visitors)}
|
||||
websiteId={websiteId}
|
||||
onDataLoad={data => onDataLoad?.(percentFilter(data))}
|
||||
renderLabel={renderLabel}
|
||||
renderLabel={renderLink}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -95,3 +95,9 @@
|
|||
background: var(--primary400);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 992px) {
|
||||
.body {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,18 @@
|
|||
import React from 'react';
|
||||
import MetricsTable from './MetricsTable';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useIntl, FormattedMessage } from 'react-intl';
|
||||
import { getDeviceMessage } from 'components/messages';
|
||||
import FilterLink from 'components/common/FilterLink';
|
||||
|
||||
export default function DevicesTable({ websiteId, ...props }) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
function renderLink({ x: device }) {
|
||||
return (
|
||||
<FilterLink id="device" value={device} label={formatMessage(getDeviceMessage(device))} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MetricsTable
|
||||
{...props}
|
||||
|
@ -11,7 +20,7 @@ export default function DevicesTable({ websiteId, ...props }) {
|
|||
type="device"
|
||||
metric={<FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />}
|
||||
websiteId={websiteId}
|
||||
renderLabel={({ x }) => getDeviceMessage(x)}
|
||||
renderLabel={renderLink}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -6,30 +6,29 @@ import useFetch from 'hooks/useFetch';
|
|||
import useDateRange from 'hooks/useDateRange';
|
||||
import useTimezone from 'hooks/useTimezone';
|
||||
import usePageQuery from 'hooks/usePageQuery';
|
||||
import useShareToken from 'hooks/useShareToken';
|
||||
import { EVENT_COLORS, TOKEN_HEADER } from 'lib/constants';
|
||||
import { EVENT_COLORS } from 'lib/constants';
|
||||
|
||||
export default function EventsChart({ websiteId, className, token }) {
|
||||
const [dateRange] = useDateRange(websiteId);
|
||||
const { startDate, endDate, unit, modified } = dateRange;
|
||||
const [{ startDate, endDate, unit, modified }] = useDateRange(websiteId);
|
||||
const [timezone] = useTimezone();
|
||||
const { query } = usePageQuery();
|
||||
const shareToken = useShareToken();
|
||||
const {
|
||||
query: { url, eventName },
|
||||
} = usePageQuery();
|
||||
|
||||
const { data, loading } = useFetch(
|
||||
`/api/website/${websiteId}/events`,
|
||||
`/website/${websiteId}/events`,
|
||||
{
|
||||
params: {
|
||||
start_at: +startDate,
|
||||
end_at: +endDate,
|
||||
unit,
|
||||
tz: timezone,
|
||||
url: query.url,
|
||||
url,
|
||||
event_name: eventName,
|
||||
token,
|
||||
},
|
||||
headers: { [TOKEN_HEADER]: shareToken?.token },
|
||||
},
|
||||
[modified],
|
||||
[modified, eventName],
|
||||
);
|
||||
|
||||
const datasets = useMemo(() => {
|
||||
|
|
|
@ -1,55 +1,26 @@
|
|||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import MetricsTable from './MetricsTable';
|
||||
import Tag from 'components/common/Tag';
|
||||
import DropDown from 'components/common/DropDown';
|
||||
import { eventTypeFilter } from 'lib/filters';
|
||||
import styles from './EventsTable.module.css';
|
||||
|
||||
const EVENT_FILTER_DEFAULT = {
|
||||
value: 'EVENT_FILTER_DEFAULT',
|
||||
label: <FormattedMessage id="label.all-events" defaultMessage="All events" />,
|
||||
};
|
||||
const messages = defineMessages({
|
||||
events: { id: 'metrics.events', defaultMessage: 'Events' },
|
||||
actions: { id: 'metrics.actions', defaultMessage: 'Actions' },
|
||||
});
|
||||
|
||||
export default function EventsTable({ websiteId, ...props }) {
|
||||
const [eventType, setEventType] = useState(EVENT_FILTER_DEFAULT.value);
|
||||
const [eventTypes, setEventTypes] = useState([]);
|
||||
|
||||
const dropDownOptions = [EVENT_FILTER_DEFAULT, ...eventTypes.map(t => ({ value: t, label: t }))];
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
function handleDataLoad(data) {
|
||||
setEventTypes([...new Set(data.map(({ x }) => x.split('\t')[0]))]);
|
||||
props.onDataLoad?.(data);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{eventTypes?.length > 1 && (
|
||||
<div className={styles.filter}>
|
||||
<DropDown value={eventType} options={dropDownOptions} onChange={setEventType} />
|
||||
</div>
|
||||
)}
|
||||
<MetricsTable
|
||||
{...props}
|
||||
title={<FormattedMessage id="metrics.events" defaultMessage="Events" />}
|
||||
type="event"
|
||||
metric={<FormattedMessage id="metrics.actions" defaultMessage="Actions" />}
|
||||
websiteId={websiteId}
|
||||
dataFilter={eventTypeFilter}
|
||||
filterOptions={eventType === EVENT_FILTER_DEFAULT.value ? [] : [eventType]}
|
||||
renderLabel={({ x }) => <Label value={x} />}
|
||||
onDataLoad={handleDataLoad}
|
||||
/>
|
||||
</>
|
||||
<MetricsTable
|
||||
{...props}
|
||||
title={formatMessage(messages.events)}
|
||||
type="event"
|
||||
metric={formatMessage(messages.actions)}
|
||||
websiteId={websiteId}
|
||||
onDataLoad={handleDataLoad}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const Label = ({ value }) => {
|
||||
const [event, label] = value.split('\t');
|
||||
return (
|
||||
<>
|
||||
<Tag>{event}</Tag>
|
||||
{label}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
.filter {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
|||
import classNames from 'classnames';
|
||||
import Button from 'components/common/Button';
|
||||
import Times from 'assets/times.svg';
|
||||
import { safeDecodeURI } from 'lib/url';
|
||||
import styles from './FilterTags.module.css';
|
||||
|
||||
export default function FilterTags({ params, onClick }) {
|
||||
|
@ -17,7 +18,7 @@ export default function FilterTags({ params, onClick }) {
|
|||
return (
|
||||
<div key={key} className={styles.tag}>
|
||||
<Button icon={<Times />} onClick={() => onClick(key)} variant="action" iconRight>
|
||||
{`${key}: ${params[key]}`}
|
||||
{`${key}: ${safeDecodeURI(params[key])}`}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -19,7 +19,6 @@ const MetricCard = ({
|
|||
<animated.div className={styles.value}>{props.x.interpolate(x => format(x))}</animated.div>
|
||||
<div className={styles.label}>
|
||||
{label}
|
||||
{~~change === 0 && !hideComparison && <span className={styles.change}>{format(0)}</span>}
|
||||
{~~change !== 0 && !hideComparison && (
|
||||
<animated.span
|
||||
className={`${styles.change} ${
|
||||
|
|
|
@ -6,33 +6,33 @@ import ErrorMessage from 'components/common/ErrorMessage';
|
|||
import useFetch from 'hooks/useFetch';
|
||||
import useDateRange from 'hooks/useDateRange';
|
||||
import usePageQuery from 'hooks/usePageQuery';
|
||||
import useShareToken from 'hooks/useShareToken';
|
||||
import { formatShortTime, formatNumber, formatLongNumber } from 'lib/format';
|
||||
import { TOKEN_HEADER } from 'lib/constants';
|
||||
import MetricCard from './MetricCard';
|
||||
import styles from './MetricsBar.module.css';
|
||||
|
||||
export default function MetricsBar({ websiteId, className }) {
|
||||
const shareToken = useShareToken();
|
||||
const [dateRange] = useDateRange(websiteId);
|
||||
const { startDate, endDate, modified } = dateRange;
|
||||
const [format, setFormat] = useState(true);
|
||||
const {
|
||||
query: { url, ref },
|
||||
query: { url, referrer, os, browser, device, country },
|
||||
} = usePageQuery();
|
||||
|
||||
const { data, error, loading } = useFetch(
|
||||
`/api/website/${websiteId}/stats`,
|
||||
`/website/${websiteId}/stats`,
|
||||
{
|
||||
params: {
|
||||
start_at: +startDate,
|
||||
end_at: +endDate,
|
||||
url,
|
||||
ref,
|
||||
referrer,
|
||||
os,
|
||||
browser,
|
||||
device,
|
||||
country,
|
||||
},
|
||||
headers: { [TOKEN_HEADER]: shareToken?.token },
|
||||
},
|
||||
[modified, url, ref],
|
||||
[modified, url, referrer, os, browser, device, country],
|
||||
);
|
||||
|
||||
const formatFunc = format
|
||||
|
|
|
@ -9,10 +9,9 @@ import Arrow from 'assets/arrow-right.svg';
|
|||
import { percentFilter } from 'lib/filters';
|
||||
import useDateRange from 'hooks/useDateRange';
|
||||
import usePageQuery from 'hooks/usePageQuery';
|
||||
import useShareToken from 'hooks/useShareToken';
|
||||
import ErrorMessage from 'components/common/ErrorMessage';
|
||||
import DataTable from './DataTable';
|
||||
import { DEFAULT_ANIMATION_DURATION, TOKEN_HEADER } from 'lib/constants';
|
||||
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
|
||||
import styles from './MetricsTable.module.css';
|
||||
|
||||
export default function MetricsTable({
|
||||
|
@ -23,38 +22,44 @@ export default function MetricsTable({
|
|||
filterOptions,
|
||||
limit,
|
||||
onDataLoad,
|
||||
delay = null,
|
||||
...props
|
||||
}) {
|
||||
const shareToken = useShareToken();
|
||||
const [dateRange] = useDateRange(websiteId);
|
||||
const { startDate, endDate, modified } = dateRange;
|
||||
const [{ startDate, endDate, modified }] = useDateRange(websiteId);
|
||||
const {
|
||||
resolve,
|
||||
router,
|
||||
query: { url },
|
||||
query: { url, referrer, os, browser, device, country },
|
||||
} = usePageQuery();
|
||||
|
||||
const { data, loading, error } = useFetch(
|
||||
`/api/website/${websiteId}/metrics`,
|
||||
`/website/${websiteId}/metrics`,
|
||||
{
|
||||
params: {
|
||||
type,
|
||||
start_at: +startDate,
|
||||
end_at: +endDate,
|
||||
url,
|
||||
referrer,
|
||||
os,
|
||||
browser,
|
||||
device,
|
||||
country,
|
||||
},
|
||||
onDataLoad,
|
||||
delay: DEFAULT_ANIMATION_DURATION,
|
||||
headers: { [TOKEN_HEADER]: shareToken?.token },
|
||||
delay: delay || DEFAULT_ANIMATION_DURATION,
|
||||
},
|
||||
[modified],
|
||||
[type, modified, url, referrer, os, browser, device, country],
|
||||
);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (data) {
|
||||
const items = percentFilter(dataFilter ? dataFilter(data, filterOptions) : data);
|
||||
let items = percentFilter(dataFilter ? dataFilter(data, filterOptions) : data);
|
||||
if (limit) {
|
||||
return items.filter((e, i) => i < limit).sort(firstBy('y', -1).thenBy('x'));
|
||||
items = items.filter((e, i) => i < limit);
|
||||
}
|
||||
if (filterOptions?.sort === false) {
|
||||
return items;
|
||||
}
|
||||
return items.sort(firstBy('y', -1).thenBy('x'));
|
||||
}
|
||||
|
|
|
@ -1,14 +1,20 @@
|
|||
import React from 'react';
|
||||
import MetricsTable from './MetricsTable';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import FilterLink from 'components/common/FilterLink';
|
||||
|
||||
export default function OSTable({ websiteId, ...props }) {
|
||||
function renderLink({ x: os }) {
|
||||
return <FilterLink id="os" value={os} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MetricsTable
|
||||
{...props}
|
||||
title={<FormattedMessage id="metrics.operating-systems" defaultMessage="Operating system" />}
|
||||
type="os"
|
||||
metric={<FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />}
|
||||
renderLabel={renderLink}
|
||||
websiteId={websiteId}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -1,57 +1,48 @@
|
|||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
import Link from 'next/link';
|
||||
import { useIntl, defineMessage } from 'react-intl';
|
||||
import FilterLink from 'components/common/FilterLink';
|
||||
import FilterButtons from 'components/common/FilterButtons';
|
||||
import { urlFilter } from 'lib/filters';
|
||||
import { safeDecodeURI } from 'lib/url';
|
||||
import usePageQuery from 'hooks/usePageQuery';
|
||||
import MetricsTable from './MetricsTable';
|
||||
import styles from './PagesTable.module.css';
|
||||
|
||||
export const FILTER_COMBINED = 0;
|
||||
export const FILTER_RAW = 1;
|
||||
|
||||
export default function PagesTable({ websiteId, websiteDomain, showFilters, ...props }) {
|
||||
const messages = defineMessage({
|
||||
combined: { id: 'metrics.filter.combined', defaultMessage: 'Combined' },
|
||||
raw: { id: 'metrics.filter.raw', defaultMessage: 'Raw' },
|
||||
pages: { id: 'metrics.pages', defaultMessage: 'Pages' },
|
||||
views: { id: 'metrics.views', defaultMessage: 'View' },
|
||||
});
|
||||
|
||||
export default function PagesTable({ websiteId, showFilters, ...props }) {
|
||||
const [filter, setFilter] = useState(FILTER_COMBINED);
|
||||
const {
|
||||
resolve,
|
||||
query: { url: currentUrl },
|
||||
} = usePageQuery();
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const buttons = [
|
||||
{
|
||||
label: <FormattedMessage id="metrics.filter.combined" defaultMessage="Combined" />,
|
||||
label: formatMessage(messages.combined),
|
||||
value: FILTER_COMBINED,
|
||||
},
|
||||
{ label: <FormattedMessage id="metrics.filter.raw" defaultMessage="Raw" />, value: FILTER_RAW },
|
||||
{
|
||||
label: formatMessage(messages.raw),
|
||||
value: FILTER_RAW,
|
||||
},
|
||||
];
|
||||
|
||||
const renderLink = ({ x: url }) => {
|
||||
return (
|
||||
<Link href={resolve({ url })} replace={true}>
|
||||
<a
|
||||
className={classNames({
|
||||
[styles.inactive]: currentUrl && url !== currentUrl,
|
||||
[styles.active]: url === currentUrl,
|
||||
})}
|
||||
>
|
||||
{safeDecodeURI(url)}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
return <FilterLink id="url" value={url} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{showFilters && <FilterButtons buttons={buttons} selected={filter} onClick={setFilter} />}
|
||||
<MetricsTable
|
||||
title={<FormattedMessage id="metrics.pages" defaultMessage="Pages" />}
|
||||
title={formatMessage(messages.pages)}
|
||||
type="url"
|
||||
metric={<FormattedMessage id="metrics.views" defaultMessage="Views" />}
|
||||
metric={formatMessage(messages.views)}
|
||||
websiteId={websiteId}
|
||||
dataFilter={urlFilter}
|
||||
filterOptions={{ domain: websiteDomain, raw: filter === FILTER_RAW }}
|
||||
dataFilter={filter !== FILTER_RAW ? urlFilter : null}
|
||||
renderLabel={renderLink}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
body .inactive {
|
||||
color: var(--gray500);
|
||||
}
|
||||
|
||||
body .active {
|
||||
color: var(--gray900);
|
||||
font-weight: 600;
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
import { useState } from 'react';
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
import MetricsTable from './MetricsTable';
|
||||
import Tag from 'components/common/Tag';
|
||||
import { paramFilter } from 'lib/filters';
|
||||
import { safeDecodeURI } from 'lib/url';
|
||||
import FilterButtons from '../common/FilterButtons';
|
||||
|
||||
const FILTER_COMBINED = 0;
|
||||
const FILTER_RAW = 1;
|
||||
|
||||
const messages = defineMessages({
|
||||
combined: { id: 'metrics.filter.combined', defaultMessage: 'Combined' },
|
||||
raw: { id: 'metrics.filter.raw', defaultMessage: 'Raw' },
|
||||
views: { id: 'metrics.views', defaultMessage: 'Views' },
|
||||
none: { id: 'label.none', defaultMessage: 'None' },
|
||||
query: { id: 'metrics.query-parameters', defaultMessage: 'Query parameters' },
|
||||
});
|
||||
|
||||
export default function QueryParametersTable({ websiteId, showFilters, ...props }) {
|
||||
const [filter, setFilter] = useState(FILTER_COMBINED);
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const buttons = [
|
||||
{
|
||||
label: formatMessage(messages.combined),
|
||||
value: FILTER_COMBINED,
|
||||
},
|
||||
{ label: formatMessage(messages.raw), value: FILTER_RAW },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{showFilters && <FilterButtons buttons={buttons} selected={filter} onClick={setFilter} />}
|
||||
<MetricsTable
|
||||
{...props}
|
||||
title={formatMessage(messages.query)}
|
||||
type="query"
|
||||
metric={formatMessage(messages.views)}
|
||||
websiteId={websiteId}
|
||||
dataFilter={filter !== FILTER_RAW ? paramFilter : null}
|
||||
renderLabel={({ x, p, v }) =>
|
||||
filter === FILTER_RAW ? (
|
||||
x
|
||||
) : (
|
||||
<>
|
||||
<Tag>{safeDecodeURI(p)}</Tag>
|
||||
{safeDecodeURI(v)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
delay={0}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -24,7 +24,7 @@ export default function RealtimeHeader({ websites, data, websiteId, onSelect })
|
|||
return sessions.filter(
|
||||
({ created_at }) => differenceInMinutes(new Date(), new Date(created_at)) <= 5,
|
||||
).length;
|
||||
}, [sessions]);
|
||||
}, [sessions, websiteId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
.metrics {
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 576px) {
|
||||
|
|
|
@ -3,11 +3,10 @@ import { FormattedMessage, useIntl } from 'react-intl';
|
|||
import { FixedSizeList } from 'react-window';
|
||||
import firstBy from 'thenby';
|
||||
import Icon from 'components/common/Icon';
|
||||
import Tag from 'components/common/Tag';
|
||||
import Dot from 'components/common/Dot';
|
||||
import FilterButtons from 'components/common/FilterButtons';
|
||||
import NoData from 'components/common/NoData';
|
||||
import { getDeviceMessage } from 'components/messages';
|
||||
import { getDeviceMessage, labels } from 'components/messages';
|
||||
import useLocale from 'hooks/useLocale';
|
||||
import useCountryNames from 'hooks/useCountryNames';
|
||||
import { BROWSERS } from 'lib/constants';
|
||||
|
@ -92,8 +91,7 @@ export default function RealtimeLog({ data, websites, websiteId }) {
|
|||
}
|
||||
|
||||
function getDetail({
|
||||
event_type,
|
||||
event_value,
|
||||
event_name,
|
||||
view_id,
|
||||
session_id,
|
||||
url,
|
||||
|
@ -103,12 +101,8 @@ export default function RealtimeLog({ data, websites, websiteId }) {
|
|||
device,
|
||||
website_id,
|
||||
}) {
|
||||
if (event_type) {
|
||||
return (
|
||||
<div>
|
||||
<Tag>{event_type}</Tag> {event_value}
|
||||
</div>
|
||||
);
|
||||
if (event_name) {
|
||||
return <div>{event_name}</div>;
|
||||
}
|
||||
if (view_id) {
|
||||
const domain = getWebsite({ website_id })?.domain;
|
||||
|
@ -129,15 +123,10 @@ export default function RealtimeLog({ data, websites, websiteId }) {
|
|||
id="message.log.visitor"
|
||||
defaultMessage="Visitor from {country} using {browser} on {os} {device}"
|
||||
values={{
|
||||
country: (
|
||||
<b>
|
||||
{countryNames[country] ||
|
||||
intl.formatMessage({ id: 'label.unknown', defaultMessage: 'Unknown' })}
|
||||
</b>
|
||||
),
|
||||
country: <b>{countryNames[country] || intl.formatMessage(labels.unknown)}</b>,
|
||||
browser: <b>{BROWSERS[browser]}</b>,
|
||||
os: <b>{os}</b>,
|
||||
device: <b>{getDeviceMessage(device)}</b>,
|
||||
device: <b>{intl.formatMessage(getDeviceMessage(device))}</b>,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -95,8 +95,8 @@ export default function RealtimeViews({ websiteId, data, websites }) {
|
|||
<DataTable
|
||||
title={<FormattedMessage id="metrics.referrers" defaultMessage="Referrers" />}
|
||||
metric={<FormattedMessage id="metrics.views" defaultMessage="Views" />}
|
||||
renderLabel={renderLink}
|
||||
data={referrers}
|
||||
height={400}
|
||||
/>
|
||||
)}
|
||||
{filter === FILTER_PAGES && (
|
||||
|
@ -105,7 +105,6 @@ export default function RealtimeViews({ websiteId, data, websites }) {
|
|||
metric={<FormattedMessage id="metrics.views" defaultMessage="Views" />}
|
||||
renderLabel={renderLink}
|
||||
data={pages}
|
||||
height={400}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
@ -1,56 +1,39 @@
|
|||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
import MetricsTable from './MetricsTable';
|
||||
import FilterButtons from 'components/common/FilterButtons';
|
||||
import FilterLink from 'components/common/FilterLink';
|
||||
import { refFilter } from 'lib/filters';
|
||||
import { safeDecodeURI } from 'lib/url';
|
||||
import Link from 'next/link';
|
||||
import classNames from 'classnames';
|
||||
import usePageQuery from 'hooks/usePageQuery';
|
||||
import External from 'assets/arrow-up-right-from-square.svg';
|
||||
import Icon from '../common/Icon';
|
||||
import styles from './ReferrersTable.module.css';
|
||||
|
||||
export const FILTER_DOMAIN_ONLY = 0;
|
||||
export const FILTER_COMBINED = 1;
|
||||
export const FILTER_RAW = 2;
|
||||
const FILTER_COMBINED = 0;
|
||||
const FILTER_RAW = 1;
|
||||
|
||||
export default function ReferrersTable({ websiteId, websiteDomain, showFilters, ...props }) {
|
||||
const messages = defineMessages({
|
||||
combined: { id: 'metrics.filter.combined', defaultMessage: 'Combined' },
|
||||
raw: { id: 'metrics.filter.raw', defaultMessage: 'Raw' },
|
||||
referrers: { id: 'metrics.referrers', defaultMessage: 'Referrers' },
|
||||
views: { id: 'metrics.views', defaultMessage: 'Views' },
|
||||
none: { id: 'label.none', defaultMessage: 'None' },
|
||||
});
|
||||
|
||||
export default function ReferrersTable({ websiteId, showFilters, ...props }) {
|
||||
const [filter, setFilter] = useState(FILTER_COMBINED);
|
||||
const {
|
||||
resolve,
|
||||
query: { ref: currentRef },
|
||||
} = usePageQuery();
|
||||
const { formatMessage } = useIntl();
|
||||
const none = formatMessage(messages.none);
|
||||
|
||||
const buttons = [
|
||||
{
|
||||
label: <FormattedMessage id="metrics.filter.domain-only" defaultMessage="Domain only" />,
|
||||
value: FILTER_DOMAIN_ONLY,
|
||||
},
|
||||
{
|
||||
label: <FormattedMessage id="metrics.filter.combined" defaultMessage="Combined" />,
|
||||
label: formatMessage(messages.combined),
|
||||
value: FILTER_COMBINED,
|
||||
},
|
||||
{ label: <FormattedMessage id="metrics.filter.raw" defaultMessage="Raw" />, value: FILTER_RAW },
|
||||
{ label: formatMessage(messages.raw), value: FILTER_RAW },
|
||||
];
|
||||
|
||||
const renderLink = ({ w: link, x: label }) => {
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
<Link href={resolve({ ref: label })} replace={true}>
|
||||
<a
|
||||
className={classNames(styles.label, {
|
||||
[styles.inactive]: currentRef && label !== currentRef,
|
||||
[styles.active]: label === currentRef,
|
||||
})}
|
||||
>
|
||||
{safeDecodeURI(label)}
|
||||
</a>
|
||||
</Link>
|
||||
<a href={link || label} target="_blank" rel="noreferrer noopener" className={styles.link}>
|
||||
<Icon icon={<External />} className={styles.icon} />
|
||||
</a>
|
||||
</div>
|
||||
const renderLink = ({ w: link, x: referrer }) => {
|
||||
return referrer ? (
|
||||
<FilterLink id="referrer" value={referrer} externalUrl={link} />
|
||||
) : (
|
||||
`(${none})`
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -59,16 +42,11 @@ export default function ReferrersTable({ websiteId, websiteDomain, showFilters,
|
|||
{showFilters && <FilterButtons buttons={buttons} selected={filter} onClick={setFilter} />}
|
||||
<MetricsTable
|
||||
{...props}
|
||||
title={<FormattedMessage id="metrics.referrers" defaultMessage="Referrers" />}
|
||||
title={formatMessage(messages.referrers)}
|
||||
type="referrer"
|
||||
metric={<FormattedMessage id="metrics.views" defaultMessage="Views" />}
|
||||
metric={formatMessage(messages.views)}
|
||||
websiteId={websiteId}
|
||||
dataFilter={refFilter}
|
||||
filterOptions={{
|
||||
domain: websiteDomain,
|
||||
domainOnly: filter === FILTER_DOMAIN_ONLY,
|
||||
raw: filter === FILTER_RAW,
|
||||
}}
|
||||
dataFilter={filter !== FILTER_RAW ? refFilter : null}
|
||||
renderLabel={renderLink}
|
||||
/>
|
||||
</>
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
import React from 'react';
|
||||
import MetricsTable from './MetricsTable';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export default function ScreenTable({ websiteId, ...props }) {
|
||||
return (
|
||||
<MetricsTable
|
||||
{...props}
|
||||
title={<FormattedMessage id="metrics.screens" defaultMessage="Screens" />}
|
||||
type="screen"
|
||||
metric={<FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />}
|
||||
websiteId={websiteId}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { useRouter } from 'next/router';
|
||||
import PageviewsChart from './PageviewsChart';
|
||||
import MetricsBar from './MetricsBar';
|
||||
import WebsiteHeader from './WebsiteHeader';
|
||||
|
@ -12,11 +11,8 @@ import useFetch from 'hooks/useFetch';
|
|||
import useDateRange from 'hooks/useDateRange';
|
||||
import useTimezone from 'hooks/useTimezone';
|
||||
import usePageQuery from 'hooks/usePageQuery';
|
||||
import useLocale from 'hooks/useLocale';
|
||||
import { getDateArray, getDateLength, getDateRange, getDateRangeValues } from 'lib/date';
|
||||
import useShareToken from 'hooks/useShareToken';
|
||||
import { TOKEN_HEADER } from 'lib/constants';
|
||||
import { get } from 'lib/web';
|
||||
import { getDateArray, getDateLength, getDateRangeValues } from 'lib/date';
|
||||
import useApi from 'hooks/useApi';
|
||||
import styles from './WebsiteChart.module.css';
|
||||
|
||||
export default function WebsiteChart({
|
||||
|
@ -28,20 +24,18 @@ export default function WebsiteChart({
|
|||
showChart = true,
|
||||
onDataLoad = () => {},
|
||||
}) {
|
||||
const shareToken = useShareToken();
|
||||
const [dateRange, setDateRange] = useDateRange(websiteId);
|
||||
const { startDate, endDate, unit, value, modified } = dateRange;
|
||||
const { locale } = useLocale();
|
||||
const [timezone] = useTimezone();
|
||||
const { basePath } = useRouter();
|
||||
const {
|
||||
router,
|
||||
resolve,
|
||||
query: { url, ref },
|
||||
query: { url, referrer, os, browser, device, country },
|
||||
} = usePageQuery();
|
||||
const { get } = useApi();
|
||||
|
||||
const { data, loading, error } = useFetch(
|
||||
`/api/website/${websiteId}/pageviews`,
|
||||
`/website/${websiteId}/pageviews`,
|
||||
{
|
||||
params: {
|
||||
start_at: +startDate,
|
||||
|
@ -49,12 +43,15 @@ export default function WebsiteChart({
|
|||
unit,
|
||||
tz: timezone,
|
||||
url,
|
||||
ref,
|
||||
referrer,
|
||||
os,
|
||||
browser,
|
||||
device,
|
||||
country,
|
||||
},
|
||||
onDataLoad,
|
||||
headers: { [TOKEN_HEADER]: shareToken?.token },
|
||||
},
|
||||
[modified, url, ref],
|
||||
[modified, url, referrer, os, browser, device, country],
|
||||
);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
|
@ -73,12 +70,10 @@ export default function WebsiteChart({
|
|||
|
||||
async function handleDateChange(value) {
|
||||
if (value === 'all') {
|
||||
const { data, ok } = await get(`${basePath}/api/website/${websiteId}`);
|
||||
const { data, ok } = await get(`/website/${websiteId}`);
|
||||
if (ok) {
|
||||
setDateRange({ value, ...getDateRangeValues(new Date(data.created_at), Date.now()) });
|
||||
}
|
||||
} else if (typeof value === 'string') {
|
||||
setDateRange(getDateRange(value, locale));
|
||||
} else {
|
||||
setDateRange(value);
|
||||
}
|
||||
|
@ -93,7 +88,10 @@ export default function WebsiteChart({
|
|||
stickyClassName={styles.sticky}
|
||||
enabled={stickyHeader}
|
||||
>
|
||||
<FilterTags params={{ url, ref }} onClick={handleCloseFilter} />
|
||||
<FilterTags
|
||||
params={{ url, referrer, os, browser, device, country }}
|
||||
onClick={handleCloseFilter}
|
||||
/>
|
||||
<div className="col-12 col-lg-9">
|
||||
<MetricsBar websiteId={websiteId} />
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import Link from 'components/common/Link';
|
||||
import OverflowText from 'components/common/OverflowText';
|
||||
import PageHeader from 'components/layout/PageHeader';
|
||||
import RefreshButton from 'components/common/RefreshButton';
|
||||
import ButtonLayout from 'components/layout/ButtonLayout';
|
||||
|
@ -13,36 +15,44 @@ export default function WebsiteHeader({ websiteId, title, domain, showLink = fal
|
|||
const header = showLink ? (
|
||||
<>
|
||||
<Favicon domain={domain} />
|
||||
<Link href="/website/[...id]" as={`/website/${websiteId}/${title}`}>
|
||||
{title}
|
||||
<Link
|
||||
className={styles.titleLink}
|
||||
href="/website/[...id]"
|
||||
as={`/website/${websiteId}/${title}`}
|
||||
>
|
||||
<OverflowText tooltipId={`${websiteId}-title`}>{title}</OverflowText>
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<>
|
||||
<Favicon domain={domain} />
|
||||
{title}
|
||||
</div>
|
||||
<OverflowText tooltipId={`${websiteId}-title`}>{title}</OverflowText>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageHeader>
|
||||
<div className={styles.title}>{header}</div>
|
||||
<ActiveUsers className={styles.active} websiteId={websiteId} />
|
||||
<ButtonLayout align="right">
|
||||
<RefreshButton websiteId={websiteId} />
|
||||
{showLink && (
|
||||
<Link
|
||||
href="/website/[...id]"
|
||||
as={`/website/${websiteId}/${title}`}
|
||||
className={styles.link}
|
||||
icon={<Arrow />}
|
||||
size="small"
|
||||
iconRight
|
||||
>
|
||||
<FormattedMessage id="label.view-details" defaultMessage="View details" />
|
||||
</Link>
|
||||
)}
|
||||
</ButtonLayout>
|
||||
<PageHeader className="row">
|
||||
<div className={classNames(styles.title, 'col-10 col-lg-4 order-1 order-lg-1')}>{header}</div>
|
||||
<div className={classNames(styles.active, 'col-12 col-lg-4 order-3 order-lg-2')}>
|
||||
<ActiveUsers websiteId={websiteId} />
|
||||
</div>
|
||||
<div className="col-2 col-lg-4 order-2 order-lg-3">
|
||||
<ButtonLayout align="right">
|
||||
<RefreshButton websiteId={websiteId} />
|
||||
{showLink && (
|
||||
<Link
|
||||
href="/website/[...id]"
|
||||
as={`/website/${websiteId}/${title}`}
|
||||
className={styles.link}
|
||||
icon={<Arrow />}
|
||||
size="small"
|
||||
iconRight
|
||||
>
|
||||
<FormattedMessage id="label.view-details" defaultMessage="View details" />
|
||||
</Link>
|
||||
)}
|
||||
</ButtonLayout>
|
||||
</div>
|
||||
</PageHeader>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,15 +1,32 @@
|
|||
.title {
|
||||
color: var(--gray900);
|
||||
font-size: var(--font-size-large);
|
||||
line-height: var(--font-size-large);
|
||||
line-height: var(--font-size-xlarge);
|
||||
align-items: center;
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.titleLink {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.link {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 576px) {
|
||||
.active {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 992px) {
|
||||
.active {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
a.link {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
import { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useRouter } from 'next/router';
|
||||
import Page from 'components/layout/Page';
|
||||
import PageHeader from 'components/layout/PageHeader';
|
||||
import WebsiteList from 'components/pages/WebsiteList';
|
||||
import Button from 'components/common/Button';
|
||||
import DashboardSettingsButton from 'components/settings/DashboardSettingsButton';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import useDashboard from 'store/dashboard';
|
||||
import DashboardEdit from './DashboardEdit';
|
||||
import styles from './WebsiteList.module.css';
|
||||
|
||||
const messages = defineMessages({
|
||||
dashboard: { id: 'label.dashboard', defaultMessage: 'Dashboard' },
|
||||
more: { id: 'label.more', defaultMessage: 'More' },
|
||||
});
|
||||
|
||||
export default function Dashboard() {
|
||||
const router = useRouter();
|
||||
const { id } = router.query;
|
||||
const userId = id?.[0];
|
||||
const dashboard = useDashboard();
|
||||
const { showCharts, limit, editing } = dashboard;
|
||||
const [max, setMax] = useState(limit);
|
||||
const { data } = useFetch('/websites', { params: { user_id: userId } });
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
function handleMore() {
|
||||
setMax(max + limit);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<div>{formatMessage(messages.dashboard)}</div>
|
||||
{!editing && <DashboardSettingsButton />}
|
||||
</PageHeader>
|
||||
{editing && <DashboardEdit websites={data} />}
|
||||
{!editing && <WebsiteList websites={data} showCharts={showCharts} limit={max} />}
|
||||
{max < data.length && (
|
||||
<Button className={styles.button} onClick={handleMore}>
|
||||
{formatMessage(messages.more)}
|
||||
</Button>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,102 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd';
|
||||
import classNames from 'classnames';
|
||||
import Button from 'components/common/Button';
|
||||
import { sortArrayByMap } from 'lib/array';
|
||||
import useDashboard, { saveDashboard } from 'store/dashboard';
|
||||
import styles from './DashboardEdit.module.css';
|
||||
|
||||
const messages = defineMessages({
|
||||
save: { id: 'label.save', defaultMessage: 'Save' },
|
||||
reset: { id: 'label.reset', defaultMessage: 'Reset' },
|
||||
cancel: { id: 'label.cancel', defaultMessage: 'Cancel' },
|
||||
});
|
||||
|
||||
const dragId = 'dashboard-website-ordering';
|
||||
|
||||
export default function DashboardEdit({ websites }) {
|
||||
const settings = useDashboard();
|
||||
const { websiteOrder } = settings;
|
||||
const { formatMessage } = useIntl();
|
||||
const [order, setOrder] = useState(websiteOrder || []);
|
||||
|
||||
const ordered = useMemo(() => sortArrayByMap(websites, order, 'website_id'), [websites, order]);
|
||||
|
||||
console.log({ order, ordered });
|
||||
|
||||
function handleWebsiteDrag({ destination, source }) {
|
||||
if (!destination || destination.index === source.index) return;
|
||||
|
||||
const orderedWebsites = [...ordered];
|
||||
const [removed] = orderedWebsites.splice(source.index, 1);
|
||||
orderedWebsites.splice(destination.index, 0, removed);
|
||||
|
||||
setOrder(orderedWebsites.map(({ website_id }) => website_id));
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
saveDashboard({
|
||||
editing: false,
|
||||
websiteOrder: order,
|
||||
});
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
saveDashboard({ editing: false, websiteOrder });
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
setOrder([]);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.buttons}>
|
||||
<Button onClick={handleSave} variant="action" size="small">
|
||||
{formatMessage(messages.save)}
|
||||
</Button>
|
||||
<Button onClick={handleCancel} size="small">
|
||||
{formatMessage(messages.cancel)}
|
||||
</Button>
|
||||
<Button onClick={handleReset} size="small">
|
||||
{formatMessage(messages.reset)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.dragActive}>
|
||||
<DragDropContext onDragEnd={handleWebsiteDrag}>
|
||||
<Droppable droppableId={dragId}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
style={{ marginBottom: snapshot.isDraggingOver ? 260 : null }}
|
||||
>
|
||||
{ordered.map(({ website_id, name, domain }, index) => (
|
||||
<Draggable key={website_id} draggableId={`${dragId}-${website_id}`} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
className={classNames(styles.item, {
|
||||
[styles.active]: snapshot.isDragging,
|
||||
})}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
>
|
||||
<div className={styles.text}>
|
||||
<h1>{name}</h1>
|
||||
<h2>{domain}</h2>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.item {
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.item h1 {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.item h2 {
|
||||
font-size: 14px;
|
||||
color: var(--gray700);
|
||||
}
|
||||
|
||||
.text {
|
||||
padding: 20px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--gray400);
|
||||
background: var(--gray50);
|
||||
}
|
||||
|
||||
.active .text {
|
||||
border-color: var(--gray600);
|
||||
box-shadow: 4px 4px 4px var(--gray100);
|
||||
}
|
||||
|
||||
.dragActive {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.dragActive:active {
|
||||
cursor: grabbing;
|
||||
}
|
|
@ -14,7 +14,7 @@ import useFetch from 'hooks/useFetch';
|
|||
import useLocale from 'hooks/useLocale';
|
||||
import useCountryNames from 'hooks/useCountryNames';
|
||||
import { percentFilter } from 'lib/filters';
|
||||
import { TOKEN_HEADER, REALTIME_RANGE, REALTIME_INTERVAL } from 'lib/constants';
|
||||
import { SHARE_TOKEN_HEADER, REALTIME_RANGE, REALTIME_INTERVAL } from 'lib/constants';
|
||||
import styles from './RealtimeDashboard.module.css';
|
||||
|
||||
function mergeData(state, data, time) {
|
||||
|
@ -33,12 +33,12 @@ export default function RealtimeDashboard() {
|
|||
const countryNames = useCountryNames(locale);
|
||||
const [data, setData] = useState();
|
||||
const [websiteId, setWebsiteId] = useState(0);
|
||||
const { data: init, loading } = useFetch('/api/realtime/init');
|
||||
const { data: updates } = useFetch('/api/realtime/update', {
|
||||
const { data: init, loading } = useFetch('/realtime/init');
|
||||
const { data: updates } = useFetch('/realtime/update', {
|
||||
params: { start_at: data?.timestamp },
|
||||
disabled: !init?.websites?.length || !data,
|
||||
interval: REALTIME_INTERVAL,
|
||||
headers: { [TOKEN_HEADER]: init?.token },
|
||||
headers: { [SHARE_TOKEN_HEADER]: init?.token },
|
||||
});
|
||||
|
||||
const renderCountryName = useCallback(
|
||||
|
@ -145,7 +145,6 @@ export default function RealtimeDashboard() {
|
|||
metric={<FormattedMessage id="metrics.visitors" defaultMessage="Visitors" />}
|
||||
data={countries}
|
||||
renderLabel={renderCountryName}
|
||||
height={500}
|
||||
/>
|
||||
</GridColumn>
|
||||
<GridColumn xs={12} lg={8}>
|
||||
|
|
|
@ -1,23 +1,27 @@
|
|||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useRouter } from 'next/router';
|
||||
import Page from 'components/layout/Page';
|
||||
import MenuLayout from 'components/layout/MenuLayout';
|
||||
import WebsiteSettings from '../settings/WebsiteSettings';
|
||||
import AccountSettings from '../settings/AccountSettings';
|
||||
import ProfileSettings from '../settings/ProfileSettings';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import WebsiteSettings from 'components/settings/WebsiteSettings';
|
||||
import AccountSettings from 'components/settings/AccountSettings';
|
||||
import ProfileSettings from 'components/settings/ProfileSettings';
|
||||
import useUser from 'hooks/useUser';
|
||||
|
||||
const WEBSITES = '/settings';
|
||||
const ACCOUNTS = '/settings/accounts';
|
||||
const PROFILE = '/settings/profile';
|
||||
|
||||
export default function Settings() {
|
||||
const user = useSelector(state => state.user);
|
||||
const { user } = useUser();
|
||||
const [option, setOption] = useState(WEBSITES);
|
||||
const router = useRouter();
|
||||
const { pathname } = router;
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const menuOptions = [
|
||||
{
|
||||
label: <FormattedMessage id="label.websites" defaultMessage="Websites" />,
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import classNames from 'classnames';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
|
@ -10,41 +8,50 @@ import DropDown from 'components/common/DropDown';
|
|||
import WebsiteChart from 'components/metrics/WebsiteChart';
|
||||
import EventsChart from 'components/metrics/EventsChart';
|
||||
import Button from 'components/common/Button';
|
||||
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
|
||||
import Icon from 'components/common/Icon';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import ChevronDown from 'assets/chevron-down.svg';
|
||||
import styles from './TestConsole.module.css';
|
||||
|
||||
export default function TestConsole() {
|
||||
const user = useSelector(state => state.user);
|
||||
const [website, setWebsite] = useState();
|
||||
const [show, setShow] = useState(true);
|
||||
const { basePath } = useRouter();
|
||||
const { data } = useFetch('/api/websites');
|
||||
const { data } = useFetch('/websites');
|
||||
const router = useRouter();
|
||||
const {
|
||||
basePath,
|
||||
query: { id },
|
||||
} = router;
|
||||
const websiteId = id?.[0];
|
||||
|
||||
if (!data || !user?.is_admin) {
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const options = data.map(({ name, website_id }) => ({ label: name, value: website_id }));
|
||||
const website = data.find(({ website_id }) => website_id === +websiteId);
|
||||
const selectedValue = options.find(({ value }) => value === website?.website_id)?.value;
|
||||
|
||||
console.log({ websiteId, data, options, website });
|
||||
|
||||
function handleSelect(value) {
|
||||
setWebsite(data.find(({ website_id }) => website_id === value));
|
||||
router.push(`/console/${value}`);
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
window.umami('event (default)');
|
||||
window.umami('umami-default');
|
||||
window.umami.trackView('/page-view', 'https://www.google.com');
|
||||
window.umami.trackEvent('event (custom)', 'custom-type');
|
||||
window.umami.trackEvent('track-event-no-data');
|
||||
window.umami.trackEvent('track-event-with-data', { test: 'test-data', time: Date.now() });
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Head>
|
||||
{typeof window !== 'undefined' && website && (
|
||||
<script async defer data-website-id={website.website_uuid} src={`${basePath}/umami.js`} />
|
||||
<script
|
||||
async
|
||||
defer
|
||||
data-website-id={website.website_uuid}
|
||||
src={`${basePath}/umami.js`}
|
||||
data-cache="true"
|
||||
/>
|
||||
)}
|
||||
</Head>
|
||||
<PageHeader>
|
||||
|
@ -55,49 +62,47 @@ export default function TestConsole() {
|
|||
onChange={handleSelect}
|
||||
/>
|
||||
</PageHeader>
|
||||
{!selectedValue && <EmptyPlaceholder msg="I hope you know what you're doing here" />}
|
||||
{selectedValue && (
|
||||
{website && (
|
||||
<>
|
||||
<div>
|
||||
<Icon
|
||||
icon={<ChevronDown />}
|
||||
className={classNames({ [styles.hidden]: !show })}
|
||||
onClick={() => setShow(!show)}
|
||||
/>
|
||||
</div>
|
||||
{show && (
|
||||
<div className={classNames(styles.test, 'row')}>
|
||||
<div className="col-4">
|
||||
<PageHeader>Page links</PageHeader>
|
||||
<div>
|
||||
<Link href={`?page=1`}>
|
||||
<a>page one</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<Link href={`?page=2`}>
|
||||
<a>page two</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div className={classNames(styles.test, 'row')}>
|
||||
<div className="col-4">
|
||||
<PageHeader>Page links</PageHeader>
|
||||
<div>
|
||||
<Link href={`/console/${websiteId}?page=1`}>
|
||||
<a>page one</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="col-4">
|
||||
<PageHeader>CSS events</PageHeader>
|
||||
<Button
|
||||
id="primary-button"
|
||||
className="umami--click--primary-button"
|
||||
variant="action"
|
||||
>
|
||||
Send event
|
||||
</Button>
|
||||
<div>
|
||||
<Link href={`/console/${websiteId}?page=2`}>
|
||||
<a>page two</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="col-4">
|
||||
<PageHeader>Javascript events</PageHeader>
|
||||
<Button id="manual-button" variant="action" onClick={handleClick}>
|
||||
Run script
|
||||
</Button>
|
||||
<div>
|
||||
<Link href={`https://www.google.com`}>
|
||||
<a className="umami--click--external-link-direct">external link (direct)</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<Link href={`https://www.google.com`}>
|
||||
<a className="umami--click--external-link-tab" target="_blank">
|
||||
external link (tab)
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="col-4">
|
||||
<PageHeader>CSS events</PageHeader>
|
||||
<Button id="primary-button" className="umami--click--button-click" variant="action">
|
||||
Send event
|
||||
</Button>
|
||||
</div>
|
||||
<div className="col-4">
|
||||
<PageHeader>Javascript events</PageHeader>
|
||||
<Button id="manual-button" variant="action" onClick={handleClick}>
|
||||
Run script
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-12">
|
||||
<WebsiteChart
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
import WebsiteChart from 'components/metrics/WebsiteChart';
|
||||
import WorldMap from 'components/common/WorldMap';
|
||||
|
@ -9,20 +9,34 @@ import MenuLayout from 'components/layout/MenuLayout';
|
|||
import Link from 'components/common/Link';
|
||||
import Loading from 'components/common/Loading';
|
||||
import Arrow from 'assets/arrow-right.svg';
|
||||
import styles from './WebsiteDetails.module.css';
|
||||
import PagesTable from '../metrics/PagesTable';
|
||||
import ReferrersTable from '../metrics/ReferrersTable';
|
||||
import BrowsersTable from '../metrics/BrowsersTable';
|
||||
import OSTable from '../metrics/OSTable';
|
||||
import DevicesTable from '../metrics/DevicesTable';
|
||||
import CountriesTable from '../metrics/CountriesTable';
|
||||
import LanguagesTable from '../metrics/LanguagesTable';
|
||||
import EventsTable from '../metrics/EventsTable';
|
||||
import EventsChart from '../metrics/EventsChart';
|
||||
import PagesTable from 'components/metrics/PagesTable';
|
||||
import ReferrersTable from 'components/metrics/ReferrersTable';
|
||||
import BrowsersTable from 'components/metrics/BrowsersTable';
|
||||
import OSTable from 'components/metrics/OSTable';
|
||||
import DevicesTable from 'components/metrics/DevicesTable';
|
||||
import CountriesTable from 'components/metrics/CountriesTable';
|
||||
import LanguagesTable from 'components/metrics/LanguagesTable';
|
||||
import EventsTable from 'components/metrics/EventsTable';
|
||||
import EventsChart from 'components/metrics/EventsChart';
|
||||
import ScreenTable from 'components/metrics/ScreenTable';
|
||||
import QueryParametersTable from 'components/metrics/QueryParametersTable';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import usePageQuery from 'hooks/usePageQuery';
|
||||
import useShareToken from 'hooks/useShareToken';
|
||||
import { DEFAULT_ANIMATION_DURATION, TOKEN_HEADER } from 'lib/constants';
|
||||
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
|
||||
import styles from './WebsiteDetails.module.css';
|
||||
|
||||
const messages = defineMessages({
|
||||
pages: { id: 'metrics.pages', defaultMessage: 'Pages' },
|
||||
referrers: { id: 'metrics.referrers', defaultMessage: 'Referrers' },
|
||||
screens: { id: 'metrics.screens', defaultMessage: 'Screens' },
|
||||
browsers: { id: 'metrics.browsers', defaultMessage: 'Browsers' },
|
||||
os: { id: 'metrics.operating-systems', defaultMessage: 'Operating system' },
|
||||
devices: { id: 'metrics.devices', defaultMessage: 'Devices' },
|
||||
countries: { id: 'metrics.countries', defaultMessage: 'Countries' },
|
||||
languages: { id: 'metrics.languages', defaultMessage: 'Languages' },
|
||||
events: { id: 'metrics.events', defaultMessage: 'Events' },
|
||||
query: { id: 'metrics.query-parameters', defaultMessage: 'Query parameters' },
|
||||
});
|
||||
|
||||
const views = {
|
||||
url: PagesTable,
|
||||
|
@ -30,16 +44,15 @@ const views = {
|
|||
browser: BrowsersTable,
|
||||
os: OSTable,
|
||||
device: DevicesTable,
|
||||
screen: ScreenTable,
|
||||
country: CountriesTable,
|
||||
language: LanguagesTable,
|
||||
event: EventsTable,
|
||||
query: QueryParametersTable,
|
||||
};
|
||||
|
||||
export default function WebsiteDetails({ websiteId }) {
|
||||
const shareToken = useShareToken();
|
||||
const { data } = useFetch(`/api/website/${websiteId}`, {
|
||||
headers: { [TOKEN_HEADER]: shareToken?.token },
|
||||
});
|
||||
const { data } = useFetch(`/website/${websiteId}`);
|
||||
const [chartLoaded, setChartLoaded] = useState(false);
|
||||
const [countryData, setCountryData] = useState();
|
||||
const [eventsData, setEventsData] = useState();
|
||||
|
@ -47,6 +60,7 @@ export default function WebsiteDetails({ websiteId }) {
|
|||
resolve,
|
||||
query: { view },
|
||||
} = usePageQuery();
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const BackButton = () => (
|
||||
<div key="back-button" className={classNames(styles.backButton, 'col-12')}>
|
||||
|
@ -61,37 +75,45 @@ export default function WebsiteDetails({ websiteId }) {
|
|||
render: BackButton,
|
||||
},
|
||||
{
|
||||
label: <FormattedMessage id="metrics.pages" defaultMessage="Pages" />,
|
||||
label: formatMessage(messages.pages),
|
||||
value: resolve({ view: 'url' }),
|
||||
},
|
||||
{
|
||||
label: <FormattedMessage id="metrics.referrers" defaultMessage="Referrers" />,
|
||||
label: formatMessage(messages.referrers),
|
||||
value: resolve({ view: 'referrer' }),
|
||||
},
|
||||
{
|
||||
label: <FormattedMessage id="metrics.browsers" defaultMessage="Browsers" />,
|
||||
label: formatMessage(messages.browsers),
|
||||
value: resolve({ view: 'browser' }),
|
||||
},
|
||||
{
|
||||
label: <FormattedMessage id="metrics.operating-systems" defaultMessage="Operating system" />,
|
||||
label: formatMessage(messages.os),
|
||||
value: resolve({ view: 'os' }),
|
||||
},
|
||||
{
|
||||
label: <FormattedMessage id="metrics.devices" defaultMessage="Devices" />,
|
||||
label: formatMessage(messages.devices),
|
||||
value: resolve({ view: 'device' }),
|
||||
},
|
||||
{
|
||||
label: <FormattedMessage id="metrics.countries" defaultMessage="Countries" />,
|
||||
label: formatMessage(messages.countries),
|
||||
value: resolve({ view: 'country' }),
|
||||
},
|
||||
{
|
||||
label: <FormattedMessage id="metrics.languages" defaultMessage="Languages" />,
|
||||
label: formatMessage(messages.languages),
|
||||
value: resolve({ view: 'language' }),
|
||||
},
|
||||
{
|
||||
label: <FormattedMessage id="metrics.events" defaultMessage="Events" />,
|
||||
label: formatMessage(messages.screens),
|
||||
value: resolve({ view: 'screen' }),
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.events),
|
||||
value: resolve({ view: 'event' }),
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.query),
|
||||
value: resolve({ view: 'query' }),
|
||||
},
|
||||
];
|
||||
|
||||
const tableProps = {
|
||||
|
|
|
@ -1,36 +1,42 @@
|
|||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Link from 'components/common/Link';
|
||||
import WebsiteChart from 'components/metrics/WebsiteChart';
|
||||
import Page from 'components/layout/Page';
|
||||
import EmptyPlaceholder from 'components/common/EmptyPlaceholder';
|
||||
import Button from 'components/common/Button';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import Arrow from 'assets/arrow-right.svg';
|
||||
import Chart from 'assets/chart-bar.svg';
|
||||
import styles from './WebsiteList.module.css';
|
||||
import useDashboard from 'store/dashboard';
|
||||
import { sortArrayByMap } from 'lib/array';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export default function WebsiteList({ userId }) {
|
||||
const { data } = useFetch('/api/websites', { params: { user_id: userId } });
|
||||
const [showCharts, setShowCharts] = useState(true);
|
||||
const messages = defineMessages({
|
||||
noWebsites: {
|
||||
id: 'message.no-websites-configured',
|
||||
defaultMessage: "You don't have any websites configured.",
|
||||
},
|
||||
goToSettngs: {
|
||||
id: 'message.go-to-settings',
|
||||
defaultMessage: 'Go to settings',
|
||||
},
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
export default function WebsiteList({ websites, showCharts, limit }) {
|
||||
const { websiteOrder } = useDashboard();
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
if (data.length === 0) {
|
||||
console.log({ websiteOrder });
|
||||
|
||||
const ordered = useMemo(
|
||||
() => sortArrayByMap(websites, websiteOrder, 'website_id'),
|
||||
[websites, websiteOrder],
|
||||
);
|
||||
|
||||
if (websites.length === 0) {
|
||||
return (
|
||||
<Page>
|
||||
<EmptyPlaceholder
|
||||
msg={
|
||||
<FormattedMessage
|
||||
id="message.no-websites-configured"
|
||||
defaultMessage="You don't have any websites configured."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EmptyPlaceholder msg={formatMessage(messages.noWebsites)}>
|
||||
<Link href="/settings" icon={<Arrow />} iconRight>
|
||||
<FormattedMessage id="message.go-to-settings" defaultMessage="Go to settings" />
|
||||
{formatMessage(messages.goToSettngs)}
|
||||
</Link>
|
||||
</EmptyPlaceholder>
|
||||
</Page>
|
||||
|
@ -38,25 +44,20 @@ export default function WebsiteList({ userId }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className={styles.menubar}>
|
||||
<Button
|
||||
tooltip={<FormattedMessage id="message.toggle-charts" defaultMessage="Toggle charts" />}
|
||||
icon={<Chart />}
|
||||
onClick={() => setShowCharts(!showCharts)}
|
||||
/>
|
||||
</div>
|
||||
{data.map(({ website_id, name, domain }) => (
|
||||
<div key={website_id} className={styles.website}>
|
||||
<WebsiteChart
|
||||
websiteId={website_id}
|
||||
title={name}
|
||||
domain={domain}
|
||||
showChart={showCharts}
|
||||
showLink
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Page>
|
||||
<div>
|
||||
{ordered.map(({ website_id, name, domain }, index) =>
|
||||
index < limit ? (
|
||||
<div key={website_id} className={styles.website}>
|
||||
<WebsiteChart
|
||||
websiteId={website_id}
|
||||
title={name}
|
||||
domain={domain}
|
||||
showChart={showCharts}
|
||||
showLink
|
||||
/>
|
||||
</div>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -7,12 +7,5 @@
|
|||
|
||||
.website:last-child {
|
||||
border-bottom: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.menubar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding-top: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@ export default function AccountSettings() {
|
|||
const [deleteAccount, setDeleteAccount] = useState();
|
||||
const [saved, setSaved] = useState(0);
|
||||
const [message, setMessage] = useState();
|
||||
const { data } = useFetch(`/api/accounts`, {}, [saved]);
|
||||
const { data } = useFetch(`/accounts`, {}, [saved]);
|
||||
|
||||
const Checkmark = ({ is_admin }) => (is_admin ? <Icon icon={<Check />} size="medium" /> : null);
|
||||
|
||||
|
@ -37,39 +37,40 @@ export default function AccountSettings() {
|
|||
</Link>
|
||||
);
|
||||
|
||||
const Buttons = row =>
|
||||
row.username !== 'admin' ? (
|
||||
<ButtonLayout align="right">
|
||||
<Button icon={<Pen />} size="small" onClick={() => setEditAccount(row)}>
|
||||
<FormattedMessage id="label.edit" defaultMessage="Edit" />
|
||||
</Button>
|
||||
const Buttons = row => (
|
||||
<ButtonLayout align="right">
|
||||
<Button icon={<Pen />} size="small" onClick={() => setEditAccount(row)}>
|
||||
<FormattedMessage id="label.edit" defaultMessage="Edit" />
|
||||
</Button>
|
||||
{!row.is_admin && (
|
||||
<Button icon={<Trash />} size="small" onClick={() => setDeleteAccount(row)}>
|
||||
<FormattedMessage id="label.delete" defaultMessage="Delete" />
|
||||
</Button>
|
||||
</ButtonLayout>
|
||||
) : null;
|
||||
)}
|
||||
</ButtonLayout>
|
||||
);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'username',
|
||||
label: <FormattedMessage id="label.username" defaultMessage="Username" />,
|
||||
className: 'col-4 col-md-3',
|
||||
className: 'col-12 col-lg-4',
|
||||
},
|
||||
{
|
||||
key: 'is_admin',
|
||||
label: <FormattedMessage id="label.administrator" defaultMessage="Administrator" />,
|
||||
className: 'col-4 col-md-3',
|
||||
className: 'col-12 col-lg-3',
|
||||
render: Checkmark,
|
||||
},
|
||||
{
|
||||
key: 'dashboard',
|
||||
label: <FormattedMessage id="label.dashboard" defaultMessage="Dashboard" />,
|
||||
className: 'col-4 col-md-3',
|
||||
className: 'col-12 col-lg-3',
|
||||
render: DashboardLink,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
className: classNames(styles.buttons, 'col-12 col-md-3 pt-2 pt-md-0'),
|
||||
className: classNames(styles.buttons, 'col-12 col-lg-2 pt-2 pt-md-0'),
|
||||
render: Buttons,
|
||||
},
|
||||
];
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import MenuButton from 'components/common/MenuButton';
|
||||
import Gear from 'assets/gear.svg';
|
||||
import { saveDashboard } from 'store/dashboard';
|
||||
|
||||
const messages = defineMessages({
|
||||
toggleCharts: { id: 'message.toggle-charts', defaultMessage: 'Toggle charts' },
|
||||
editDashboard: { id: 'message.edit-dashboard', defaultMessage: 'Edit dashboard' },
|
||||
});
|
||||
|
||||
export default function DashboardSettingsButton() {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const menuOptions = [
|
||||
{
|
||||
label: formatMessage(messages.toggleCharts),
|
||||
value: 'charts',
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.editDashboard),
|
||||
value: 'order',
|
||||
},
|
||||
];
|
||||
|
||||
function handleSelect(value) {
|
||||
if (value === 'charts') {
|
||||
saveDashboard(state => ({ showCharts: !state.showCharts }));
|
||||
}
|
||||
if (value === 'order') {
|
||||
saveDashboard({ editing: true });
|
||||
}
|
||||
}
|
||||
|
||||
return <MenuButton icon={<Gear />} options={menuOptions} onSelect={handleSelect} hideLabel />;
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
.buttonGroup {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
gap: 10px;
|
||||
}
|
|
@ -4,18 +4,19 @@ import DateFilter, { filterOptions } from 'components/common/DateFilter';
|
|||
import Button from 'components/common/Button';
|
||||
import useDateRange from 'hooks/useDateRange';
|
||||
import { DEFAULT_DATE_RANGE } from 'lib/constants';
|
||||
import { getDateRange } from 'lib/date';
|
||||
import styles from './DateRangeSetting.module.css';
|
||||
import useLocale from 'hooks/useLocale';
|
||||
|
||||
export default function DateRangeSetting() {
|
||||
const { locale } = useLocale();
|
||||
const [dateRange, setDateRange] = useDateRange();
|
||||
const { startDate, endDate, value } = dateRange;
|
||||
const options = filterOptions.filter(e => e.value !== 'all');
|
||||
|
||||
function handleChange(value) {
|
||||
setDateRange(value);
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
setDateRange(getDateRange(DEFAULT_DATE_RANGE, locale));
|
||||
setDateRange(DEFAULT_DATE_RANGE);
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -25,7 +26,7 @@ export default function DateRangeSetting() {
|
|||
value={value}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onChange={setDateRange}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<Button className={styles.button} size="small" onClick={handleReset}>
|
||||
<FormattedMessage id="label.reset" defaultMessage="Reset" />
|
||||
|
|
|
@ -19,6 +19,7 @@ export default function LanguageButton() {
|
|||
options={menuOptions}
|
||||
value={locale}
|
||||
menuClassName={styles.menu}
|
||||
buttonVariant="light"
|
||||
onSelect={handleSelect}
|
||||
hideLabel
|
||||
/>
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import DropDown from 'components/common/DropDown';
|
||||
import Button from 'components/common/Button';
|
||||
import useLocale from 'hooks/useLocale';
|
||||
import { DEFAULT_LOCALE } from 'lib/constants';
|
||||
import styles from './TimezoneSetting.module.css';
|
||||
import { languages } from '../../lib/lang';
|
||||
|
||||
export default function LanguageSetting() {
|
||||
const { locale, saveLocale } = useLocale();
|
||||
const options = Object.keys(languages).map(key => ({ ...languages[key], value: key }));
|
||||
|
||||
function handleReset() {
|
||||
saveLocale(DEFAULT_LOCALE);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropDown
|
||||
menuClassName={styles.menu}
|
||||
value={locale}
|
||||
options={options}
|
||||
onChange={saveLocale}
|
||||
/>
|
||||
<Button className={styles.button} size="small" onClick={handleReset}>
|
||||
<FormattedMessage id="label.reset" defaultMessage="Reset" />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useSelector } from 'react-redux';
|
||||
import PageHeader from 'components/layout/PageHeader';
|
||||
import Button from 'components/common/Button';
|
||||
import Modal from 'components/common/Modal';
|
||||
|
@ -11,12 +10,14 @@ import Dots from 'assets/ellipsis-h.svg';
|
|||
import styles from './ProfileSettings.module.css';
|
||||
import DateRangeSetting from './DateRangeSetting';
|
||||
import useEscapeKey from 'hooks/useEscapeKey';
|
||||
import useUser from 'hooks/useUser';
|
||||
import LanguageSetting from './LanguageSetting';
|
||||
import ThemeSetting from './ThemeSetting';
|
||||
|
||||
export default function ProfileSettings() {
|
||||
const user = useSelector(state => state.user);
|
||||
const { user } = useUser();
|
||||
const [changePassword, setChangePassword] = useState(false);
|
||||
const [message, setMessage] = useState();
|
||||
const { user_id } = user;
|
||||
const [message, setMessage] = useState(null);
|
||||
|
||||
function handleSave() {
|
||||
setChangePassword(false);
|
||||
|
@ -27,6 +28,12 @@ export default function ProfileSettings() {
|
|||
setChangePassword(false);
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { user_id, username } = user;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader>
|
||||
|
@ -41,7 +48,7 @@ export default function ProfileSettings() {
|
|||
<dt>
|
||||
<FormattedMessage id="label.username" defaultMessage="Username" />
|
||||
</dt>
|
||||
<dd>{user.username}</dd>
|
||||
<dd>{username}</dd>
|
||||
<dt>
|
||||
<FormattedMessage id="label.timezone" defaultMessage="Timezone" />
|
||||
</dt>
|
||||
|
@ -54,6 +61,18 @@ export default function ProfileSettings() {
|
|||
<dd>
|
||||
<DateRangeSetting />
|
||||
</dd>
|
||||
<dt>
|
||||
<FormattedMessage id="label.language" defaultMessage="Language" />
|
||||
</dt>
|
||||
<dd>
|
||||
<LanguageSetting />
|
||||
</dd>
|
||||
<dt>
|
||||
<FormattedMessage id="label.theme" defaultMessage="Theme" />
|
||||
</dt>
|
||||
<dd>
|
||||
<ThemeSetting />
|
||||
</dd>
|
||||
</dl>
|
||||
{changePassword && (
|
||||
<Modal
|
||||
|
|
|
@ -9,7 +9,8 @@ import Icon from '../common/Icon';
|
|||
export default function ThemeButton() {
|
||||
const [theme, setTheme] = useTheme();
|
||||
|
||||
const transitions = useTransition(theme, theme => theme, {
|
||||
const transitions = useTransition(theme, {
|
||||
initial: { opacity: 1 },
|
||||
from: {
|
||||
opacity: 0,
|
||||
transform: `translateY(${theme === 'light' ? '20px' : '-20px'}) scale(0.5)`,
|
||||
|
@ -27,17 +28,11 @@ export default function ThemeButton() {
|
|||
|
||||
return (
|
||||
<div className={styles.button} onClick={handleClick}>
|
||||
{transitions.map(({ item, key, props }) =>
|
||||
item === 'light' ? (
|
||||
<animated.div key={key} style={props}>
|
||||
<Icon icon={<Sun />} />
|
||||
</animated.div>
|
||||
) : (
|
||||
<animated.div key={key} style={props}>
|
||||
<Icon icon={<Moon />} />
|
||||
</animated.div>
|
||||
),
|
||||
)}
|
||||
{transitions((styles, item) => (
|
||||
<animated.div key={item} style={styles}>
|
||||
<Icon icon={item === 'light' ? <Sun /> : <Moon />} />
|
||||
</animated.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
import classNames from 'classnames';
|
||||
import Button from 'components/common/Button';
|
||||
import useTheme from 'hooks/useTheme';
|
||||
import Sun from 'assets/sun.svg';
|
||||
import Moon from 'assets/moon.svg';
|
||||
import styles from './ThemeSetting.module.css';
|
||||
|
||||
export default function ThemeSetting() {
|
||||
const [theme, setTheme] = useTheme();
|
||||
|
||||
return (
|
||||
<div className={styles.buttons}>
|
||||
<Button
|
||||
className={classNames({ [styles.active]: theme === 'light' })}
|
||||
icon={<Sun />}
|
||||
onClick={() => setTheme('light')}
|
||||
/>
|
||||
<Button
|
||||
className={classNames({ [styles.active]: theme === 'dark' })}
|
||||
icon={<Moon />}
|
||||
onClick={() => setTheme('dark')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
.buttons {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.buttons button {
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.active {
|
||||
border: 1px solid var(--primary400);
|
||||
}
|
|
@ -1,17 +1,16 @@
|
|||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useRouter } from 'next/router';
|
||||
import MenuButton from 'components/common/MenuButton';
|
||||
import Icon from 'components/common/Icon';
|
||||
import User from 'assets/user.svg';
|
||||
import Chevron from 'assets/chevron-down.svg';
|
||||
import styles from './UserButton.module.css';
|
||||
import { removeItem } from 'lib/web';
|
||||
import { AUTH_TOKEN } from 'lib/constants';
|
||||
import useUser from 'hooks/useUser';
|
||||
|
||||
export default function UserButton() {
|
||||
const user = useSelector(state => state.user);
|
||||
const { user } = useUser();
|
||||
const router = useRouter();
|
||||
|
||||
const menuOptions = [
|
||||
|
@ -42,9 +41,10 @@ export default function UserButton() {
|
|||
return (
|
||||
<MenuButton
|
||||
icon={<Icon icon={<User />} size="large" />}
|
||||
value={<Icon icon={<Chevron />} size="small" />}
|
||||
buttonVariant="light"
|
||||
options={menuOptions}
|
||||
onSelect={handleSelect}
|
||||
hideLabel
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import React, { useState } from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { useSelector } from 'react-redux';
|
||||
import classNames from 'classnames';
|
||||
import Link from 'components/common/Link';
|
||||
import Table from 'components/common/Table';
|
||||
import Button from 'components/common/Button';
|
||||
import OverflowText from 'components/common/OverflowText';
|
||||
import PageHeader from 'components/layout/PageHeader';
|
||||
import Modal from 'components/common/Modal';
|
||||
import WebsiteEditForm from 'components/forms/WebsiteEditForm';
|
||||
|
@ -23,10 +23,11 @@ import Plus from 'assets/plus.svg';
|
|||
import Code from 'assets/code.svg';
|
||||
import LinkIcon from 'assets/link.svg';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import useUser from 'hooks/useUser';
|
||||
import styles from './WebsiteSettings.module.css';
|
||||
|
||||
export default function WebsiteSettings() {
|
||||
const user = useSelector(state => state.user);
|
||||
const { user } = useUser();
|
||||
const [editWebsite, setEditWebsite] = useState();
|
||||
const [resetWebsite, setResetWebsite] = useState();
|
||||
const [deleteWebsite, setDeleteWebsite] = useState();
|
||||
|
@ -35,9 +36,8 @@ export default function WebsiteSettings() {
|
|||
const [showUrl, setShowUrl] = useState();
|
||||
const [saved, setSaved] = useState(0);
|
||||
const [message, setMessage] = useState();
|
||||
const { data } = useFetch(`/api/websites` + (user.is_admin ? '?include_all=true' : ''), {}, [
|
||||
saved,
|
||||
]);
|
||||
|
||||
const { data } = useFetch('/websites', { params: { include_all: !!user?.is_admin } }, [saved]);
|
||||
|
||||
const Buttons = row => (
|
||||
<ButtonLayout align="right">
|
||||
|
@ -84,28 +84,37 @@ export default function WebsiteSettings() {
|
|||
);
|
||||
|
||||
const DetailsLink = ({ website_id, name, domain }) => (
|
||||
<Link href="/website/[...id]" as={`/website/${website_id}/${name}`}>
|
||||
<Link
|
||||
className={styles.detailLink}
|
||||
href="/website/[...id]"
|
||||
as={`/website/${website_id}/${name}`}
|
||||
>
|
||||
<Favicon domain={domain} />
|
||||
{name}
|
||||
<OverflowText tooltipId={`${website_id}-name`}>{name}</OverflowText>
|
||||
</Link>
|
||||
);
|
||||
|
||||
const Domain = ({ domain, website_id }) => (
|
||||
<OverflowText tooltipId={`${website_id}-domain`}>{domain}</OverflowText>
|
||||
);
|
||||
|
||||
const adminColumns = [
|
||||
{
|
||||
key: 'name',
|
||||
label: <FormattedMessage id="label.name" defaultMessage="Name" />,
|
||||
className: 'col-4 col-xl-3',
|
||||
className: 'col-12 col-lg-4 col-xl-3',
|
||||
render: DetailsLink,
|
||||
},
|
||||
{
|
||||
key: 'domain',
|
||||
label: <FormattedMessage id="label.domain" defaultMessage="Domain" />,
|
||||
className: 'col-4 col-xl-3',
|
||||
className: 'col-12 col-lg-4 col-xl-3',
|
||||
render: Domain,
|
||||
},
|
||||
{
|
||||
key: 'account',
|
||||
label: <FormattedMessage id="label.owner" defaultMessage="Owner" />,
|
||||
className: 'col-4 col-xl-1',
|
||||
className: 'col-12 col-lg-4 col-xl-1',
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
|
@ -118,13 +127,14 @@ export default function WebsiteSettings() {
|
|||
{
|
||||
key: 'name',
|
||||
label: <FormattedMessage id="label.name" defaultMessage="Name" />,
|
||||
className: 'col-6 col-xl-4',
|
||||
className: 'col-12 col-lg-6 col-xl-4',
|
||||
render: DetailsLink,
|
||||
},
|
||||
{
|
||||
key: 'domain',
|
||||
label: <FormattedMessage id="label.domain" defaultMessage="Domain" />,
|
||||
className: 'col-6 col-xl-4',
|
||||
className: 'col-12 col-lg-6 col-xl-4',
|
||||
render: Domain,
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
|
|
|
@ -3,5 +3,11 @@
|
|||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.detailLink {
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,102 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE `account` (
|
||||
`user_id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`username` VARCHAR(255) NOT NULL,
|
||||
`password` VARCHAR(60) NOT NULL,
|
||||
`is_admin` BOOLEAN NOT NULL DEFAULT false,
|
||||
`created_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`updated_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
|
||||
UNIQUE INDEX `username`(`username`),
|
||||
PRIMARY KEY (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `event` (
|
||||
`event_id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`website_id` INTEGER UNSIGNED NOT NULL,
|
||||
`session_id` INTEGER UNSIGNED NOT NULL,
|
||||
`created_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`url` VARCHAR(500) NOT NULL,
|
||||
`event_type` VARCHAR(50) NOT NULL,
|
||||
`event_value` VARCHAR(50) NOT NULL,
|
||||
|
||||
INDEX `event_created_at_idx`(`created_at`),
|
||||
INDEX `event_session_id_idx`(`session_id`),
|
||||
INDEX `event_website_id_idx`(`website_id`),
|
||||
PRIMARY KEY (`event_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `pageview` (
|
||||
`view_id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`website_id` INTEGER UNSIGNED NOT NULL,
|
||||
`session_id` INTEGER UNSIGNED NOT NULL,
|
||||
`created_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`url` VARCHAR(500) NOT NULL,
|
||||
`referrer` VARCHAR(500) NULL,
|
||||
|
||||
INDEX `pageview_created_at_idx`(`created_at`),
|
||||
INDEX `pageview_session_id_idx`(`session_id`),
|
||||
INDEX `pageview_website_id_created_at_idx`(`website_id`, `created_at`),
|
||||
INDEX `pageview_website_id_idx`(`website_id`),
|
||||
INDEX `pageview_website_id_session_id_created_at_idx`(`website_id`, `session_id`, `created_at`),
|
||||
PRIMARY KEY (`view_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `session` (
|
||||
`session_id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`session_uuid` VARCHAR(36) NOT NULL,
|
||||
`website_id` INTEGER UNSIGNED NOT NULL,
|
||||
`created_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`hostname` VARCHAR(100) NULL,
|
||||
`browser` VARCHAR(20) NULL,
|
||||
`os` VARCHAR(20) NULL,
|
||||
`device` VARCHAR(20) NULL,
|
||||
`screen` VARCHAR(11) NULL,
|
||||
`language` VARCHAR(35) NULL,
|
||||
`country` CHAR(2) NULL,
|
||||
|
||||
UNIQUE INDEX `session_uuid`(`session_uuid`),
|
||||
INDEX `session_created_at_idx`(`created_at`),
|
||||
INDEX `session_website_id_idx`(`website_id`),
|
||||
PRIMARY KEY (`session_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `website` (
|
||||
`website_id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`website_uuid` VARCHAR(36) NOT NULL,
|
||||
`user_id` INTEGER UNSIGNED NOT NULL,
|
||||
`name` VARCHAR(100) NOT NULL,
|
||||
`domain` VARCHAR(500) NULL,
|
||||
`share_id` VARCHAR(64) NULL,
|
||||
`created_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
|
||||
UNIQUE INDEX `website_uuid`(`website_uuid`),
|
||||
UNIQUE INDEX `share_id`(`share_id`),
|
||||
INDEX `website_user_id_idx`(`user_id`),
|
||||
PRIMARY KEY (`website_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `event` ADD CONSTRAINT `event_ibfk_2` FOREIGN KEY (`session_id`) REFERENCES `session`(`session_id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `event` ADD CONSTRAINT `event_ibfk_1` FOREIGN KEY (`website_id`) REFERENCES `website`(`website_id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `pageview` ADD CONSTRAINT `pageview_ibfk_2` FOREIGN KEY (`session_id`) REFERENCES `session`(`session_id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `pageview` ADD CONSTRAINT `pageview_ibfk_1` FOREIGN KEY (`website_id`) REFERENCES `website`(`website_id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `session` ADD CONSTRAINT `session_ibfk_1` FOREIGN KEY (`website_id`) REFERENCES `website`(`website_id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `website` ADD CONSTRAINT `website_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `account`(`user_id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- CreateAdminUser
|
||||
INSERT INTO account (username, password, is_admin) values ('admin', '$2b$10$BUli0c.muyCW1ErNJc3jL.vFRFtFJWrT8/GcR4A.sUdCznaXiqFXa', true);
|
|
@ -0,0 +1,62 @@
|
|||
-- DropForeignKey
|
||||
ALTER TABLE `event` DROP FOREIGN KEY `event_ibfk_1`;
|
||||
ALTER TABLE `event` DROP FOREIGN KEY `event_ibfk_2`;
|
||||
|
||||
DROP INDEX `event_created_at_idx` ON `event`;
|
||||
DROP INDEX `event_session_id_idx` ON `event`;
|
||||
DROP INDEX `event_website_id_idx` ON `event`;
|
||||
|
||||
CREATE INDEX `event_old_created_at_idx` ON `event` (created_at);
|
||||
CREATE INDEX `event_old_session_id_idx` ON `event` (session_id);
|
||||
CREATE INDEX `event_old_website_id_idx` ON `event` (website_id);
|
||||
|
||||
-- RenameTable
|
||||
RENAME TABLE `event` TO `_event_old`;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `event`
|
||||
(
|
||||
`event_id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`website_id` INTEGER UNSIGNED NOT NULL,
|
||||
`session_id` INTEGER UNSIGNED NOT NULL,
|
||||
`created_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0),
|
||||
`url` VARCHAR(500) NOT NULL,
|
||||
`event_name` VARCHAR(50) NOT NULL,
|
||||
|
||||
INDEX `event_created_at_idx`(`created_at`),
|
||||
INDEX `event_session_id_idx`(`session_id`),
|
||||
INDEX `event_website_id_idx`(`website_id`),
|
||||
PRIMARY KEY (`event_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `event` ADD CONSTRAINT `event_ibfk_2` FOREIGN KEY (`session_id`) REFERENCES `session`(`session_id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `event` ADD CONSTRAINT `event_ibfk_1` FOREIGN KEY (`website_id`) REFERENCES `website`(`website_id`) ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `event_data` (
|
||||
`event_data_id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`event_id` INTEGER UNSIGNED NOT NULL,
|
||||
`event_data` JSON NOT NULL,
|
||||
|
||||
UNIQUE INDEX `event_data_event_id_key`(`event_id`),
|
||||
PRIMARY KEY (`event_data_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `event_data` ADD CONSTRAINT `event_data_event_id_fkey` FOREIGN KEY (`event_id`) REFERENCES `event`(`event_id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- RenameIndex
|
||||
ALTER TABLE `account` RENAME INDEX `username` TO `account_username_key`;
|
||||
|
||||
-- RenameIndex
|
||||
ALTER TABLE `session` RENAME INDEX `session_uuid` TO `session_session_uuid_key`;
|
||||
|
||||
-- RenameIndex
|
||||
ALTER TABLE `website` RENAME INDEX `share_id` TO `website_share_id_key`;
|
||||
|
||||
-- RenameIndex
|
||||
ALTER TABLE `website` RENAME INDEX `website_uuid` TO `website_website_uuid_key`;
|