commit 4047a7ec23222ad6d62a71a6dabe1e5c1d1fd56f Author: WaylonWalker Date: Wed Dec 8 18:25:42 2021 +0000 Initial commit Created from https://vercel.com/new diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..40d5f5b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +docker-compose.yml +Dockerfile +.gitignore +.DS_Store +node_modules diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..168665f --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,24 @@ +{ + "env": { + "browser": true, + "es2020": true, + "node": true + }, + "extends": ["eslint:recommended", "plugin:react/recommended", "prettier", "next"], + "parserOptions": { + "ecmaFeatures": { + "jsx": true + }, + "ecmaVersion": 11, + "sourceType": "module" + }, + "plugins": ["react"], + "rules": { + "react/display-name": "off", + "react/react-in-jsx-scope": "off", + "react/prop-types": "off" + }, + "globals": { + "React": "writable" + } +} diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000..2dc5b67 --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,19 @@ +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 60 +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 7 +# Issues with these labels will never be considered stale +exemptLabels: + - pinned + - security + - enhancement + - bug +# Label to use when marking an issue as stale +staleLabel: wontfix +# Comment to post when marking an issue as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aaff7c7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ +/prisma/schema.prisma + +# production +/build +/public/umami.js +/public/geo +/public/lang +/lang-compiled + +# misc +.DS_Store +.idea +*.iml +*.log +/.vscode/ + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env +.env.development.local +.env.test.local +.env.production.local diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..36af219 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npx lint-staged diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..15ce475 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +/public/ \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..1193784 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "arrowParens": "avoid", + "endOfLine": "lf", + "printWidth": 100, + "singleQuote": true, + "trailingComma": "all" +} diff --git a/.stylelintrc.json b/.stylelintrc.json new file mode 100644 index 0000000..117fac2 --- /dev/null +++ b/.stylelintrc.json @@ -0,0 +1,17 @@ +{ + "extends": [ + "stylelint-config-recommended", + "stylelint-config-css-modules", + "stylelint-config-prettier" + ], + "rules": { + "no-descending-specificity": null, + "selector-pseudo-class-no-unknown": [ + true, + { + "ignorePseudoClasses": ["global", "horizontal", "vertical"] + } + ] + }, + "ignoreFiles": ["**/*.js"] +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e66ec5d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +# Build image +FROM node:12.22-alpine AS build +ARG BASE_PATH +ARG DATABASE_TYPE +ENV BASE_PATH=$BASE_PATH +ENV DATABASE_URL "postgresql://umami:umami@db:5432/umami" \ + DATABASE_TYPE=$DATABASE_TYPE +WORKDIR /build + +RUN yarn config set --home enableTelemetry 0 +COPY package.json yarn.lock /build/ + +# Install only the production dependencies +RUN yarn install --production --frozen-lockfile + +# Cache these modules for production +RUN cp -R node_modules/ prod_node_modules/ + +# Install development dependencies +RUN yarn install --frozen-lockfile + +COPY . /build +RUN yarn next telemetry disable +RUN yarn build + +# Production image +FROM node:12.22-alpine AS production +WORKDIR /app + +# Copy cached dependencies +COPY --from=build /build/prod_node_modules ./node_modules + +# Copy generated Prisma client +COPY --from=build /build/node_modules/.prisma/ ./node_modules/.prisma/ + +COPY --from=build /build/yarn.lock /build/package.json ./ +COPY --from=build /build/.next ./.next +COPY --from=build /build/public ./public + +USER node + +EXPOSE 3000 +CMD ["yarn", "start"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5127765 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Mike Cao + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..edc6c9a --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: npm run start-env diff --git a/README.md b/README.md new file mode 100644 index 0000000..98fe215 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# umami + +Umami is a simple, fast, website analytics alternative to Google Analytics. + +## Getting started + +A detailed getting started guide can be found at [https://umami.is/docs/](https://umami.is/docs/) + +## Installing from source + +### Requirements + +- A server with Node.js 12 or newer +- A database (MySQL or Postgresql) + +### Get the source code and install packages + +``` +git clone https://github.com/mikecao/umami.git +cd umami +npm 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) +``` + +The connection url is in the following format: +``` +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 +``` + +### Start the application + +```bash +npm start +``` + +By default this will launch the application on `http://localhost:3000`. You will need to either +[proxy](https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/) requests from your web server +or change the [port](https://nextjs.org/docs/api-reference/cli#production) to serve the application directly. + +## Installing with Docker + +To build the umami container and start up a Postgres database, run: + +```bash +docker-compose up +``` + +Alternatively, to pull just the Umami Docker image with PostgreSQL support: +```bash +docker pull ghcr.io/mikecao/umami:postgresql-latest +``` + +Or with MySQL support: +```bash +docker pull ghcr.io/mikecao/umami:mysql-latest +``` + +## Getting updates + +To get the latest features, simply do a pull, install any new dependencies, and rebuild: + +```bash +git pull +npm install +npm run build +``` + +## License + +MIT diff --git a/app.json b/app.json new file mode 100644 index 0000000..a27dc6f --- /dev/null +++ b/app.json @@ -0,0 +1,26 @@ +{ + "name": "Umami", + "description": "Umami is a simple, fast, website analytics alternative to Google Analytics.", + "keywords": [ + "analytics", + "charts", + "statistics", + "web-analytics" + ], + "website": "https://umami.is", + "repository": "https://github.com/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": "/" +} diff --git a/assets/arrow-right.svg b/assets/arrow-right.svg new file mode 100644 index 0000000..6fc9390 --- /dev/null +++ b/assets/arrow-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/arrow-up-right-from-square.svg b/assets/arrow-up-right-from-square.svg new file mode 100644 index 0000000..90ad457 --- /dev/null +++ b/assets/arrow-up-right-from-square.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/bars.svg b/assets/bars.svg new file mode 100644 index 0000000..91c83c4 --- /dev/null +++ b/assets/bars.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/bolt.svg b/assets/bolt.svg new file mode 100644 index 0000000..4654a1e --- /dev/null +++ b/assets/bolt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/calendar-alt.svg b/assets/calendar-alt.svg new file mode 100644 index 0000000..230c4e6 --- /dev/null +++ b/assets/calendar-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/chart-bar.svg b/assets/chart-bar.svg new file mode 100644 index 0000000..d1d72fd --- /dev/null +++ b/assets/chart-bar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/check.svg b/assets/check.svg new file mode 100644 index 0000000..1a7abdc --- /dev/null +++ b/assets/check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/chevron-down.svg b/assets/chevron-down.svg new file mode 100644 index 0000000..cb9d8fe --- /dev/null +++ b/assets/chevron-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/code.svg b/assets/code.svg new file mode 100644 index 0000000..cd29765 --- /dev/null +++ b/assets/code.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ellipsis-h.svg b/assets/ellipsis-h.svg new file mode 100644 index 0000000..5bb0835 --- /dev/null +++ b/assets/ellipsis-h.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/exclamation-triangle.svg b/assets/exclamation-triangle.svg new file mode 100644 index 0000000..46bef5b --- /dev/null +++ b/assets/exclamation-triangle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/external-link.svg b/assets/external-link.svg new file mode 100644 index 0000000..ed09306 --- /dev/null +++ b/assets/external-link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/eye.svg b/assets/eye.svg new file mode 100644 index 0000000..09c9345 --- /dev/null +++ b/assets/eye.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/globe.svg b/assets/globe.svg new file mode 100644 index 0000000..509eaba --- /dev/null +++ b/assets/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/link.svg b/assets/link.svg new file mode 100644 index 0000000..c53d1ad --- /dev/null +++ b/assets/link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/list-ul.svg b/assets/list-ul.svg new file mode 100644 index 0000000..5e63212 --- /dev/null +++ b/assets/list-ul.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/logo.svg b/assets/logo.svg new file mode 100644 index 0000000..c80f166 --- /dev/null +++ b/assets/logo.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/assets/moon.svg b/assets/moon.svg new file mode 100644 index 0000000..6c8955a --- /dev/null +++ b/assets/moon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/pen.svg b/assets/pen.svg new file mode 100644 index 0000000..426c520 --- /dev/null +++ b/assets/pen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/plus.svg b/assets/plus.svg new file mode 100644 index 0000000..e4774d8 --- /dev/null +++ b/assets/plus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/redo.svg b/assets/redo.svg new file mode 100644 index 0000000..4544eb1 --- /dev/null +++ b/assets/redo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/sun.svg b/assets/sun.svg new file mode 100644 index 0000000..ebc20eb --- /dev/null +++ b/assets/sun.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/times.svg b/assets/times.svg new file mode 100644 index 0000000..c528bcd --- /dev/null +++ b/assets/times.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/trash.svg b/assets/trash.svg new file mode 100644 index 0000000..2f525c8 --- /dev/null +++ b/assets/trash.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/user.svg b/assets/user.svg new file mode 100644 index 0000000..c009466 --- /dev/null +++ b/assets/user.svg @@ -0,0 +1 @@ +Asset 1 \ No newline at end of file diff --git a/assets/visitor.svg b/assets/visitor.svg new file mode 100644 index 0000000..591873a --- /dev/null +++ b/assets/visitor.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/xmark.svg b/assets/xmark.svg new file mode 100644 index 0000000..6d72bf6 --- /dev/null +++ b/assets/xmark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/common/Button.js b/components/common/Button.js new file mode 100644 index 0000000..0cdb5fb --- /dev/null +++ b/components/common/Button.js @@ -0,0 +1,62 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ReactTooltip from 'react-tooltip'; +import classNames from 'classnames'; +import Icon from './Icon'; +import styles from './Button.module.css'; + +function Button({ + type = 'button', + icon, + size, + variant, + children, + className, + tooltip, + tooltipId, + disabled, + iconRight, + onClick = () => {}, + ...props +}) { + return ( + + ); +} + +Button.propTypes = { + type: PropTypes.oneOf(['button', 'submit', 'reset']), + icon: PropTypes.node, + size: PropTypes.oneOf(['xlarge', 'large', 'medium', 'small', 'xsmall']), + variant: PropTypes.oneOf(['action', 'danger', 'light']), + children: PropTypes.node, + className: PropTypes.string, + tooltip: PropTypes.node, + tooltipId: PropTypes.string, + disabled: PropTypes.bool, + iconRight: PropTypes.bool, + onClick: PropTypes.func, +}; + +export default Button; diff --git a/components/common/Button.module.css b/components/common/Button.module.css new file mode 100644 index 0000000..b911095 --- /dev/null +++ b/components/common/Button.module.css @@ -0,0 +1,102 @@ +.button { + display: flex; + justify-content: center; + align-items: center; + font-size: var(--font-size-normal); + color: var(--gray900); + background: var(--gray100); + padding: 8px 16px; + border-radius: 4px; + border: 0; + outline: none; + cursor: pointer; + position: relative; +} + +.button:hover { + background: var(--gray200); +} + +.button:active { + color: var(--gray900); +} + +.label { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + max-width: 300px; +} + +.large { + font-size: var(--font-size-large); +} + +.small { + font-size: var(--font-size-small); +} + +.xsmall { + font-size: var(--font-size-xsmall); +} + +.action, +.action:active { + color: var(--gray50); + background: var(--gray900); +} + +.action:hover { + background: var(--gray800); +} + +.danger, +.danger:active { + color: var(--gray50); + background: var(--red500); +} + +.danger:hover { + background: var(--red400); +} + +.light, +.light:active { + color: var(--gray900); + background: transparent; +} + +.light:hover { + background: inherit; +} + +.button .icon + * { + margin-left: 10px; +} + +.button.iconRight .icon { + order: 1; + margin-left: 10px; +} + +.button.iconRight .icon + * { + margin: 0; +} + +.button:disabled { + cursor: default; + color: var(--gray500); + background: var(--gray75); +} + +.button:disabled:active { + color: var(--gray500); +} + +.button:disabled:hover { + background: var(--gray75); +} + +.button.light:disabled { + background: var(--gray50); +} diff --git a/components/common/ButtonGroup.js b/components/common/ButtonGroup.js new file mode 100644 index 0000000..353ce69 --- /dev/null +++ b/components/common/ButtonGroup.js @@ -0,0 +1,42 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import Button from './Button'; +import styles from './ButtonGroup.module.css'; + +function ButtonGroup({ items = [], selectedItem, className, size, icon, onClick = () => {} }) { + return ( +
+ {items.map(item => { + const { label, value } = item; + return ( + + ); + })} +
+ ); +} + +ButtonGroup.propTypes = { + items: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.node, + value: PropTypes.any.isRequired, + }), + ), + selectedItem: PropTypes.any, + className: PropTypes.string, + size: PropTypes.oneOf(['xlarge', 'large', 'medium', 'small', 'xsmall']), + icon: PropTypes.node, + onClick: PropTypes.func, +}; + +export default ButtonGroup; diff --git a/components/common/ButtonGroup.module.css b/components/common/ButtonGroup.module.css new file mode 100644 index 0000000..bc60f8d --- /dev/null +++ b/components/common/ButtonGroup.module.css @@ -0,0 +1,31 @@ +.group { + display: inline-flex; + border-radius: 4px; + overflow: hidden; + border: 1px solid var(--gray500); +} + +.group .button { + border-radius: 0; + color: var(--gray800); + background: var(--gray50); + border-left: 1px solid var(--gray500); + padding: 4px 8px; +} + +.group .button:first-child { + border: 0; +} + +.group .button:hover { + background: var(--gray100); +} + +.group .button + .button { + margin: 0; +} + +.group .button.selected { + color: var(--gray900); + font-weight: 600; +} diff --git a/components/common/Calendar.js b/components/common/Calendar.js new file mode 100644 index 0000000..7aea34c --- /dev/null +++ b/components/common/Calendar.js @@ -0,0 +1,268 @@ +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { + startOfWeek, + startOfMonth, + startOfYear, + endOfMonth, + addDays, + subDays, + addYears, + subYears, + addMonths, + setMonth, + setYear, + isSameDay, + isBefore, + isAfter, +} from 'date-fns'; +import Button from './Button'; +import useLocale from 'hooks/useLocale'; +import { dateFormat } from 'lib/date'; +import { chunk } from 'lib/array'; +import { getDateLocale } from 'lib/lang'; +import Chevron from 'assets/chevron-down.svg'; +import Cross from 'assets/times.svg'; +import styles from './Calendar.module.css'; +import Icon from './Icon'; + +export default function Calendar({ date, minDate, maxDate, onChange }) { + const { locale } = useLocale(); + const [selectMonth, setSelectMonth] = useState(false); + const [selectYear, setSelectYear] = useState(false); + + const month = dateFormat(date, 'MMMM', locale); + const year = date.getFullYear(); + + function toggleMonthSelect() { + setSelectYear(false); + setSelectMonth(state => !state); + } + + function toggleYearSelect() { + setSelectMonth(false); + setSelectYear(state => !state); + } + + function handleChange(value) { + setSelectMonth(false); + setSelectYear(false); + if (value) { + onChange(value); + } + } + + return ( +
+
+
{date.getDate()}
+
+ {month} + : } size="small" /> +
+
+ {year} + : } size="small" /> +
+
+
+ {!selectMonth && !selectYear && ( + + )} + {selectMonth && ( + + )} + {selectYear && ( + + )} +
+
+ ); +} + +const DaySelector = ({ date, minDate, maxDate, locale, onSelect }) => { + const startWeek = startOfWeek(date, { locale: getDateLocale(locale) }); + const startMonth = startOfMonth(date, { locale: getDateLocale(locale) }); + const startDay = subDays(startMonth, startMonth.getDay()); + const month = date.getMonth(); + const year = date.getFullYear(); + + const daysOfWeek = []; + for (let i = 0; i < 7; i++) { + daysOfWeek.push(addDays(startWeek, i)); + } + + const days = []; + for (let i = 0; i < 35; i++) { + days.push(addDays(startDay, i)); + } + + return ( + + + + {daysOfWeek.map((day, i) => ( + + ))} + + + + {chunk(days, 7).map((week, i) => ( + + {week.map((day, j) => { + const disabled = isBefore(day, minDate) || isAfter(day, maxDate); + return ( + + ); + })} + + ))} + +
+ {dateFormat(day, 'EEE', locale)} +
onSelect(day) : null} + > + {day.getDate()} +
+ ); +}; + +const MonthSelector = ({ date, minDate, maxDate, locale, onSelect }) => { + const start = startOfYear(date); + const months = []; + for (let i = 0; i < 12; i++) { + months.push(addMonths(start, i)); + } + + function handleSelect(value) { + onSelect(setMonth(date, value)); + } + + return ( + + + {chunk(months, 3).map((row, i) => ( + + {row.map((month, j) => { + const disabled = + isBefore(endOfMonth(month), minDate) || isAfter(startOfMonth(month), maxDate); + return ( + + ); + })} + + ))} + +
handleSelect(month.getMonth()) : null} + > + {dateFormat(month, 'MMMM', locale)} +
+ ); +}; + +const YearSelector = ({ date, minDate, maxDate, onSelect }) => { + const [currentDate, setCurrentDate] = useState(date); + const year = date.getFullYear(); + const currentYear = currentDate.getFullYear(); + const minYear = minDate.getFullYear(); + const maxYear = maxDate.getFullYear(); + const years = []; + for (let i = 0; i < 15; i++) { + years.push(currentYear - 7 + i); + } + + function handleSelect(value) { + onSelect(setYear(date, value)); + } + + function handlePrevClick() { + setCurrentDate(state => subYears(state, 15)); + } + + function handleNextClick() { + setCurrentDate(state => addYears(state, 15)); + } + + return ( +
+
+
+
+ + + {chunk(years, 5).map((row, i) => ( + + {row.map((n, j) => ( + + ))} + + ))} + +
maxYear, + })} + onClick={() => (n < minYear || n > maxYear ? null : handleSelect(n))} + > + {n} +
+
+
+
+
+ ); +}; diff --git a/components/common/Calendar.module.css b/components/common/Calendar.module.css new file mode 100644 index 0000000..9751cf2 --- /dev/null +++ b/components/common/Calendar.module.css @@ -0,0 +1,111 @@ +.calendar { + display: flex; + flex-direction: column; + font-size: var(--font-size-small); + flex: 1; + min-height: 306px; +} + +.calendar table { + width: 100%; + border-spacing: 5px; +} + +.calendar td { + color: var(--gray800); + cursor: pointer; + text-align: center; + vertical-align: center; + height: 40px; + width: 40px; + border-radius: 5px; + border: 1px solid transparent; +} + +.calendar td:hover { + border: 1px solid var(--gray300); + background: var(--gray75); +} + +.calendar td.faded { + color: var(--gray500); +} + +.calendar td.selected { + font-weight: 600; + border: 1px solid var(--gray600); +} + +.calendar td.selected:hover { + background: transparent; +} + +.calendar td.disabled { + color: var(--gray400); + background: var(--gray75); +} + +.calendar td.disabled:hover { + cursor: default; + background: var(--gray75); + border-color: transparent; +} + +.calendar td.faded.disabled { + background: var(--gray100); +} + +.header { + display: flex; + justify-content: space-evenly; + align-items: center; + font-weight: 700; + line-height: 40px; + font-size: var(--font-size-normal); +} + +.body { + display: flex; +} + +.selector { + cursor: pointer; +} + +.pager { + display: flex; + flex: 1; +} + +.pager button { + align-self: center; +} + +.middle { + flex: 1; +} + +.left, +.right { + display: flex; + justify-content: center; + align-items: center; +} + +.left svg { + transform: rotate(90deg); +} + +.right svg { + transform: rotate(-90deg); +} + +.icon { + margin-left: 10px; +} + +@media only screen and (max-width: 992px) { + .calendar table { + max-width: calc(100vw - 30px); + } +} diff --git a/components/common/Checkbox.js b/components/common/Checkbox.js new file mode 100644 index 0000000..0cd0dca --- /dev/null +++ b/components/common/Checkbox.js @@ -0,0 +1,39 @@ +import React, { useRef } from 'react'; +import PropTypes from 'prop-types'; +import Icon from 'components/common/Icon'; +import Check from 'assets/check.svg'; +import styles from './Checkbox.module.css'; + +function Checkbox({ name, value, label, onChange }) { + const ref = useRef(); + + const onClick = () => ref.current.click(); + + return ( +
+
+ {value && } size="small" />} +
+ + +
+ ); +} + +Checkbox.propTypes = { + name: PropTypes.string, + value: PropTypes.any, + label: PropTypes.node, + onChange: PropTypes.func, +}; + +export default Checkbox; diff --git a/components/common/Checkbox.module.css b/components/common/Checkbox.module.css new file mode 100644 index 0000000..c9a01ea --- /dev/null +++ b/components/common/Checkbox.module.css @@ -0,0 +1,30 @@ +.container { + display: flex; + align-items: center; + position: relative; + overflow: hidden; +} + +.checkbox { + display: flex; + justify-content: center; + align-items: center; + width: 20px; + height: 20px; + border: 1px solid var(--gray500); + border-radius: 4px; +} + +.label { + margin-left: 10px; + user-select: none; /* disable text selection when clicking to toggle the checkbox */ +} + +.input { + position: absolute; + visibility: hidden; + height: 0; + width: 0; + bottom: 100%; + right: 100%; +} diff --git a/components/common/CopyButton.js b/components/common/CopyButton.js new file mode 100644 index 0000000..b300ef3 --- /dev/null +++ b/components/common/CopyButton.js @@ -0,0 +1,37 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import Button from './Button'; +import { FormattedMessage } from 'react-intl'; + +const defaultText = ( + +); + +function CopyButton({ element, ...props }) { + const [text, setText] = useState(defaultText); + + function handleClick() { + if (element?.current) { + element.current.select(); + document.execCommand('copy'); + setText(); + window.getSelection().removeAllRanges(); + } + } + + return ( + + ); +} + +CopyButton.propTypes = { + element: PropTypes.shape({ + current: PropTypes.shape({ + select: PropTypes.func.isRequired, + }), + }), +}; + +export default CopyButton; diff --git a/components/common/DateFilter.js b/components/common/DateFilter.js new file mode 100644 index 0000000..ba3417d --- /dev/null +++ b/components/common/DateFilter.js @@ -0,0 +1,130 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { endOfYear, isSameDay } from 'date-fns'; +import Modal from './Modal'; +import DropDown from './DropDown'; +import DatePickerForm from 'components/forms/DatePickerForm'; +import useLocale from 'hooks/useLocale'; +import { getDateRange, dateFormat } from 'lib/date'; +import Calendar from 'assets/calendar-alt.svg'; +import Icon from './Icon'; + +const filterOptions = [ + { label: , value: '1day' }, + { + label: ( + + ), + value: '24hour', + }, + { + label: , + value: '1week', + divider: true, + }, + { + label: ( + + ), + value: '7day', + }, + { + label: , + value: '1month', + divider: true, + }, + { + label: ( + + ), + value: '30day', + }, + { + label: ( + + ), + value: '90day', + }, + { label: , value: '1year' }, + { + label: , + value: 'custom', + divider: true, + }, +]; + +function DateFilter({ value, startDate, endDate, onChange, className }) { + const { locale } = useLocale(); + const [showPicker, setShowPicker] = useState(false); + const displayValue = + value === 'custom' ? ( + handleChange('custom')} /> + ) : ( + value + ); + + function handleChange(value) { + if (value === 'custom') { + setShowPicker(true); + return; + } + onChange(getDateRange(value, locale)); + } + + function handlePickerChange(value) { + setShowPicker(false); + onChange(value); + } + + return ( + <> + + {showPicker && ( + + setShowPicker(false)} + /> + + )} + + ); +} + +const CustomRange = ({ startDate, endDate, onClick }) => { + const { locale } = useLocale(); + + function handleClick(e) { + e.stopPropagation(); + + onClick(); + } + + return ( + <> + } className="mr-2" onClick={handleClick} /> + {dateFormat(startDate, 'd LLL y', locale)} + {!isSameDay(startDate, endDate) && ` — ${dateFormat(endDate, 'd LLL y', locale)}`} + + ); +}; + +DateFilter.propTypes = { + value: PropTypes.string, + startDate: PropTypes.instanceOf(Date), + endDate: PropTypes.instanceOf(Date), + onChange: PropTypes.func, + className: PropTypes.string, +}; + +export default DateFilter; diff --git a/components/common/Dot.js b/components/common/Dot.js new file mode 100644 index 0000000..81454c4 --- /dev/null +++ b/components/common/Dot.js @@ -0,0 +1,26 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import styles from './Dot.module.css'; + +function Dot({ color, size, className }) { + return ( +
+
+
+ ); +} + +Dot.propTypes = { + color: PropTypes.string, + size: PropTypes.oneOf(['small', 'large']), + className: PropTypes.string, +}; + +export default Dot; diff --git a/components/common/Dot.module.css b/components/common/Dot.module.css new file mode 100644 index 0000000..258d6e8 --- /dev/null +++ b/components/common/Dot.module.css @@ -0,0 +1,22 @@ +.wrapper { + background: var(--gray50); + margin-right: 10px; + border-radius: 100%; +} + +.dot { + background: var(--green400); + width: 10px; + height: 10px; + border-radius: 100%; +} + +.dot.small { + width: 8px; + height: 8px; +} + +.dot.large { + width: 16px; + height: 16px; +} diff --git a/components/common/DropDown.js b/components/common/DropDown.js new file mode 100644 index 0000000..00d20e3 --- /dev/null +++ b/components/common/DropDown.js @@ -0,0 +1,64 @@ +import React, { useState, useRef } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import Menu from './Menu'; +import useDocumentClick from 'hooks/useDocumentClick'; +import Chevron from 'assets/chevron-down.svg'; +import styles from './Dropdown.module.css'; +import Icon from './Icon'; + +function DropDown({ value, className, menuClassName, options = [], onChange = () => {} }) { + const [showMenu, setShowMenu] = useState(false); + const ref = useRef(); + const selectedOption = options.find(e => e.value === value); + + function handleShowMenu() { + setShowMenu(state => !state); + } + + function handleSelect(selected, e) { + e.stopPropagation(); + setShowMenu(false); + + onChange(selected); + } + + useDocumentClick(e => { + if (!ref.current?.contains(e.target)) { + setShowMenu(false); + } + }); + + return ( +
+
+
{options.find(e => e.value === value)?.label || value}
+ } className={styles.icon} size="small" /> +
+ {showMenu && ( + + )} +
+ ); +} + +DropDown.propTypes = { + value: PropTypes.any, + className: PropTypes.string, + menuClassName: PropTypes.string, + options: PropTypes.arrayOf( + PropTypes.shape({ + value: PropTypes.any.isRequired, + label: PropTypes.node, + }), + ), + onChange: PropTypes.func, +}; + +export default DropDown; diff --git a/components/common/Dropdown.module.css b/components/common/Dropdown.module.css new file mode 100644 index 0000000..9738b00 --- /dev/null +++ b/components/common/Dropdown.module.css @@ -0,0 +1,28 @@ +.dropdown { + position: relative; + display: flex; + justify-content: space-between; + align-items: center; + border: 1px solid var(--gray500); + border-radius: 4px; + cursor: pointer; +} + +.value { + flex: 1; + display: flex; + justify-content: space-between; + font-size: var(--font-size-small); + flex-wrap: nowrap; + white-space: nowrap; + padding: 4px 16px; + min-width: 160px; +} + +.text { + flex: 1; +} + +.icon { + padding-left: 20px; +} diff --git a/components/common/EmptyPlaceholder.js b/components/common/EmptyPlaceholder.js new file mode 100644 index 0000000..b5394e8 --- /dev/null +++ b/components/common/EmptyPlaceholder.js @@ -0,0 +1,22 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Icon from 'components/common/Icon'; +import Logo from 'assets/logo.svg'; +import styles from './EmptyPlaceholder.module.css'; + +function EmptyPlaceholder({ msg, children }) { + return ( +
+ } size="xlarge" /> +

{msg}

+ {children} +
+ ); +} + +EmptyPlaceholder.propTypes = { + msg: PropTypes.node, + children: PropTypes.node, +}; + +export default EmptyPlaceholder; diff --git a/components/common/EmptyPlaceholder.module.css b/components/common/EmptyPlaceholder.module.css new file mode 100644 index 0000000..a923183 --- /dev/null +++ b/components/common/EmptyPlaceholder.module.css @@ -0,0 +1,15 @@ +.placeholder { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + min-height: 600px; +} + +.icon { + margin-bottom: 30px; +} + +.msg { + margin-bottom: 15px; +} diff --git a/components/common/ErrorMessage.js b/components/common/ErrorMessage.js new file mode 100644 index 0000000..5747f22 --- /dev/null +++ b/components/common/ErrorMessage.js @@ -0,0 +1,14 @@ +import React from 'react'; +import { FormattedMessage } from 'react-intl'; +import Icon from './Icon'; +import Exclamation from 'assets/exclamation-triangle.svg'; +import styles from './ErrorMessage.module.css'; + +export default function ErrorMessage() { + return ( +
+ } className={styles.icon} size="large" /> + +
+ ); +} diff --git a/components/common/ErrorMessage.module.css b/components/common/ErrorMessage.module.css new file mode 100644 index 0000000..88769cf --- /dev/null +++ b/components/common/ErrorMessage.module.css @@ -0,0 +1,15 @@ +.error { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + margin: auto; + display: flex; + z-index: 1; + background-color: var(--gray50); + padding: 10px; +} + +.icon { + margin-right: 10px; +} diff --git a/components/common/Favicon.js b/components/common/Favicon.js new file mode 100644 index 0000000..d72cd3c --- /dev/null +++ b/components/common/Favicon.js @@ -0,0 +1,28 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styles from './Favicon.module.css'; + +function getHostName(url) { + const match = url.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?(?:www\.)?([^:/\n?=]+)/im); + return match && match.length > 1 ? match[1] : null; +} + +function Favicon({ domain, ...props }) { + const hostName = domain ? getHostName(domain) : null; + + return hostName ? ( + + ) : null; +} + +Favicon.propTypes = { + domain: PropTypes.string, +}; + +export default Favicon; diff --git a/components/common/Favicon.module.css b/components/common/Favicon.module.css new file mode 100644 index 0000000..82c85c4 --- /dev/null +++ b/components/common/Favicon.module.css @@ -0,0 +1,3 @@ +.favicon { + margin-right: 8px; +} diff --git a/components/common/FilterButtons.js b/components/common/FilterButtons.js new file mode 100644 index 0000000..ea81121 --- /dev/null +++ b/components/common/FilterButtons.js @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ButtonLayout from 'components/layout/ButtonLayout'; +import ButtonGroup from './ButtonGroup'; + +function FilterButtons({ buttons, selected, onClick }) { + return ( + + + + ); +} + +FilterButtons.propTypes = { + buttons: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.node, + value: PropTypes.any.isRequired, + }), + ), + selected: PropTypes.any, + onClick: PropTypes.func, +}; + +export default FilterButtons; diff --git a/components/common/Icon.js b/components/common/Icon.js new file mode 100644 index 0000000..e9d96eb --- /dev/null +++ b/components/common/Icon.js @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import styles from './Icon.module.css'; + +function Icon({ icon, className, size = 'medium', ...props }) { + return ( +
+ {icon} +
+ ); +} + +Icon.propTypes = { + className: PropTypes.string, + icon: PropTypes.node.isRequired, + size: PropTypes.oneOf(['xlarge', 'large', 'medium', 'small', 'xsmall']), +}; + +export default Icon; diff --git a/components/common/Icon.module.css b/components/common/Icon.module.css new file mode 100644 index 0000000..5b43166 --- /dev/null +++ b/components/common/Icon.module.css @@ -0,0 +1,35 @@ +.icon { + display: inline-flex; + justify-content: center; + align-items: center; + vertical-align: middle; +} + +.icon svg { + fill: currentColor; +} + +.xlarge > svg { + width: 48px; + height: 48px; +} + +.large > svg { + width: 24px; + height: 24px; +} + +.medium > svg { + width: 16px; + height: 16px; +} + +.small > svg { + width: 12px; + height: 12px; +} + +.xsmall > svg { + width: 10px; + height: 10px; +} diff --git a/components/common/Link.js b/components/common/Link.js new file mode 100644 index 0000000..f0fad73 --- /dev/null +++ b/components/common/Link.js @@ -0,0 +1,34 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import NextLink from 'next/link'; +import Icon from './Icon'; +import styles from './Link.module.css'; + +function Link({ className, icon, children, size, iconRight, ...props }) { + return ( + + + {icon && } + {children} + + + ); +} + +Link.propTypes = { + className: PropTypes.string, + icon: PropTypes.node, + children: PropTypes.node, + size: PropTypes.oneOf(['large', 'small', 'xsmall']), + iconRight: PropTypes.bool, +}; + +export default Link; diff --git a/components/common/Link.module.css b/components/common/Link.module.css new file mode 100644 index 0000000..ea6d281 --- /dev/null +++ b/components/common/Link.module.css @@ -0,0 +1,50 @@ +a.link, +a.link:active, +a.link:visited { + position: relative; + color: var(--gray900); + text-decoration: none; + display: inline-flex; + align-items: center; +} + +a.link:before { + content: ''; + position: absolute; + bottom: -2px; + width: 0; + height: 2px; + background: var(--primary400); + opacity: 0.5; + transition: width 100ms; +} + +a.link:hover:before { + width: 100%; + transition: width 100ms; +} + +a.link.large { + font-size: var(--font-size-large); +} + +a.link.small { + font-size: var(--font-size-small); +} + +a.link.xsmall { + font-size: var(--font-size-xsmall); +} + +a.link .icon + * { + margin-left: 10px; +} + +a.link.iconRight .icon { + order: 1; + margin-left: 10px; +} + +a.link.iconRight .icon + * { + margin: 0; +} diff --git a/components/common/Loading.js b/components/common/Loading.js new file mode 100644 index 0000000..16d8bb8 --- /dev/null +++ b/components/common/Loading.js @@ -0,0 +1,20 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import styles from './Loading.module.css'; + +function Loading({ className }) { + return ( +
+
+
+
+
+ ); +} + +Loading.propTypes = { + className: PropTypes.string, +}; + +export default Loading; diff --git a/components/common/Loading.module.css b/components/common/Loading.module.css new file mode 100644 index 0000000..4e56dd8 --- /dev/null +++ b/components/common/Loading.module.css @@ -0,0 +1,43 @@ +@keyframes blink { + 0% { + opacity: 0.2; + } + 20% { + opacity: 1; + } + 100% { + opacity: 0.2; + } +} + +.loading { + display: flex; + justify-content: center; + align-items: center; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + margin: 0; +} + +.loading div { + width: 10px; + height: 10px; + border-radius: 100%; + background: var(--gray400); + animation: blink 1.4s infinite; + animation-fill-mode: both; +} + +.loading div + div { + margin-left: 10px; +} + +.loading div:nth-child(2) { + animation-delay: 0.2s; +} + +.loading div:nth-child(3) { + animation-delay: 0.4s; +} diff --git a/components/common/Menu.js b/components/common/Menu.js new file mode 100644 index 0000000..91eeee9 --- /dev/null +++ b/components/common/Menu.js @@ -0,0 +1,70 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import styles from './Menu.module.css'; + +function Menu({ + options = [], + selectedOption, + className, + float, + align = 'left', + optionClassName, + selectedClassName, + onSelect = () => {}, +}) { + return ( +
+ {options + .filter(({ hidden }) => !hidden) + .map(option => { + const { label, value, className: customClassName, render, divider } = option; + + return render ? ( + render(option) + ) : ( +
onSelect(value, e)} + > + {label} +
+ ); + })} +
+ ); +} + +Menu.propTypes = { + options: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.node, + value: PropTypes.any, + className: PropTypes.string, + render: PropTypes.func, + divider: PropTypes.bool, + }), + ), + selectedOption: PropTypes.any, + className: PropTypes.string, + float: PropTypes.oneOf(['top', 'bottom']), + align: PropTypes.oneOf(['left', 'right']), + optionClassName: PropTypes.string, + selectedClassName: PropTypes.string, + onSelect: PropTypes.func, +}; + +export default Menu; diff --git a/components/common/Menu.module.css b/components/common/Menu.module.css new file mode 100644 index 0000000..d2ad2cc --- /dev/null +++ b/components/common/Menu.module.css @@ -0,0 +1,51 @@ +.menu { + background: var(--gray50); + border: 1px solid var(--gray500); + border-radius: 4px; + overflow: hidden; + z-index: 100; +} + +.option { + font-size: var(--font-size-small); + font-weight: normal; + background: var(--gray50); + padding: 4px 16px; + cursor: pointer; + white-space: nowrap; +} + +.option:hover { + background: var(--gray100); +} + +.float { + position: absolute; + min-width: 100px; +} + +.top { + bottom: 100%; + margin-bottom: 5px; +} + +.bottom { + top: 100%; + margin-top: 5px; +} + +.left { + left: 0; +} + +.right { + right: 0; +} + +.divider { + border-top: 1px solid var(--gray300); +} + +.selected { + font-weight: 600; +} diff --git a/components/common/MenuButton.js b/components/common/MenuButton.js new file mode 100644 index 0000000..eb8d62f --- /dev/null +++ b/components/common/MenuButton.js @@ -0,0 +1,86 @@ +import React, { useState, useRef } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import Menu from 'components/common/Menu'; +import Button from 'components/common/Button'; +import useDocumentClick from 'hooks/useDocumentClick'; +import styles from './MenuButton.module.css'; + +function MenuButton({ + icon, + value, + options, + buttonClassName, + menuClassName, + menuPosition = 'bottom', + menuAlign = 'right', + onSelect, + renderValue, + hideLabel, +}) { + const [showMenu, setShowMenu] = useState(false); + const ref = useRef(); + const selectedOption = options.find(e => e.value === value); + + function handleSelect(value) { + onSelect(value); + setShowMenu(false); + } + + function toggleMenu() { + setShowMenu(state => !state); + } + + useDocumentClick(e => { + if (!ref.current?.contains(e.target)) { + setShowMenu(false); + } + }); + + return ( +
+ + {showMenu && ( + + )} +
+ ); +} + +MenuButton.propTypes = { + icon: PropTypes.node, + value: PropTypes.any, + options: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.node, + value: PropTypes.any, + className: PropTypes.string, + render: PropTypes.func, + divider: PropTypes.bool, + }), + ), + buttonClassName: PropTypes.string, + menuClassName: PropTypes.string, + menuPosition: PropTypes.oneOf(['top', 'bottom']), + menuAlign: PropTypes.oneOf(['left', 'right']), + onSelect: PropTypes.func, + renderValue: PropTypes.func, +}; + +export default MenuButton; diff --git a/components/common/MenuButton.module.css b/components/common/MenuButton.module.css new file mode 100644 index 0000000..7e9dd7e --- /dev/null +++ b/components/common/MenuButton.module.css @@ -0,0 +1,20 @@ +.container { + display: flex; + position: relative; + cursor: pointer; +} + +.button { + border: 1px solid transparent; + border-radius: 4px; +} + +.text { + font-size: var(--font-size-small); +} + +.open, +.open:hover { + background: var(--gray50); + border: 1px solid var(--gray500); +} diff --git a/components/common/Modal.js b/components/common/Modal.js new file mode 100644 index 0000000..694fba6 --- /dev/null +++ b/components/common/Modal.js @@ -0,0 +1,26 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ReactDOM from 'react-dom'; +import { useSpring, animated } from 'react-spring'; +import styles from './Modal.module.css'; + +function Modal({ title, children }) { + const props = useSpring({ opacity: 1, from: { opacity: 0 } }); + + return ReactDOM.createPortal( + +
+ {title &&
{title}
} +
{children}
+
+
, + document.getElementById('__modals'), + ); +} + +Modal.propTypes = { + title: PropTypes.node, + children: PropTypes.node, +}; + +export default Modal; diff --git a/components/common/Modal.module.css b/components/common/Modal.module.css new file mode 100644 index 0000000..bf2491c --- /dev/null +++ b/components/common/Modal.module.css @@ -0,0 +1,46 @@ +.modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + z-index: 2; +} + +.modal:before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + background: #000; + opacity: 0.5; +} + +.content { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: var(--gray50); + min-width: 400px; + min-height: 100px; + max-width: 100vw; + z-index: 1; + border: 1px solid var(--gray300); + padding: 30px; + border-radius: 4px; +} + +.header { + font-weight: 600; + margin-bottom: 20px; +} + +.body { + display: flex; + flex-direction: column; +} diff --git a/components/common/NavMenu.js b/components/common/NavMenu.js new file mode 100644 index 0000000..82d97ff --- /dev/null +++ b/components/common/NavMenu.js @@ -0,0 +1,47 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useRouter } from 'next/router'; +import classNames from 'classnames'; +import styles from './NavMenu.module.css'; + +function NavMenu({ options = [], className, onSelect = () => {} }) { + const router = useRouter(); + + return ( +
+ {options + .filter(({ hidden }) => !hidden) + .map(option => { + const { label, value, className: customClassName, render } = option; + + return render ? ( + render(option) + ) : ( +
onSelect(value, e)} + > + {label} +
+ ); + })} +
+ ); +} + +NavMenu.propTypes = { + options: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.node, + value: PropTypes.any, + className: PropTypes.string, + render: PropTypes.func, + }), + ), + className: PropTypes.string, + onSelect: PropTypes.func, +}; +export default NavMenu; diff --git a/components/common/NavMenu.module.css b/components/common/NavMenu.module.css new file mode 100644 index 0000000..7be7397 --- /dev/null +++ b/components/common/NavMenu.module.css @@ -0,0 +1,22 @@ +.menu { + color: var(--gray800); + border: 1px solid var(--gray500); + border-radius: 4px; + overflow: hidden; + z-index: 2; +} + +.option { + padding: 8px 16px; + cursor: pointer; + border-radius: 4px; +} + +.option:hover { + background: var(--gray75); +} + +.selected { + color: var(--gray900); + font-weight: 600; +} diff --git a/components/common/NoData.js b/components/common/NoData.js new file mode 100644 index 0000000..9d52343 --- /dev/null +++ b/components/common/NoData.js @@ -0,0 +1,19 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { FormattedMessage } from 'react-intl'; +import styles from './NoData.module.css'; + +function NoData({ className }) { + return ( +
+ +
+ ); +} + +NoData.propTypes = { + className: PropTypes.string, +}; + +export default NoData; diff --git a/components/common/NoData.module.css b/components/common/NoData.module.css new file mode 100644 index 0000000..518fa48 --- /dev/null +++ b/components/common/NoData.module.css @@ -0,0 +1,11 @@ +.container { + color: var(--gray500); + font-size: var(--font-size-normal); + position: relative; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + width: 100%; + height: 100%; +} diff --git a/components/common/RefreshButton.js b/components/common/RefreshButton.js new file mode 100644 index 0000000..91a43ab --- /dev/null +++ b/components/common/RefreshButton.js @@ -0,0 +1,46 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import { FormattedMessage } from 'react-intl'; +import { setDateRange } from 'redux/actions/websites'; +import Button from './Button'; +import Refresh from 'assets/redo.svg'; +import Dots from 'assets/ellipsis-h.svg'; +import useDateRange from 'hooks/useDateRange'; +import { getDateRange } from '../../lib/date'; +import useLocale from 'hooks/useLocale'; + +function RefreshButton({ websiteId }) { + const dispatch = useDispatch(); + const { locale } = useLocale(); + const [dateRange] = useDateRange(websiteId); + const [loading, setLoading] = useState(false); + const completed = useSelector(state => state.queries[`/api/website/${websiteId}/stats`]); + + function handleClick() { + if (dateRange) { + setLoading(true); + dispatch(setDateRange(websiteId, getDateRange(dateRange.value, locale))); + } + } + + useEffect(() => { + setLoading(false); + }, [completed]); + + return ( + + + +
+ ); +} diff --git a/components/common/UpdateNotice.module.css b/components/common/UpdateNotice.module.css new file mode 100644 index 0000000..52a97c3 --- /dev/null +++ b/components/common/UpdateNotice.module.css @@ -0,0 +1,13 @@ +.notice { + display: flex; + justify-content: center; + align-items: center; + padding-top: 10px; + font-size: var(--font-size-small); + font-weight: 600; +} + +.message { + text-align: center; + margin-right: 20px; +} diff --git a/components/common/WorldMap.js b/components/common/WorldMap.js new file mode 100644 index 0000000..6dc8a35 --- /dev/null +++ b/components/common/WorldMap.js @@ -0,0 +1,103 @@ +import React, { useState, useMemo } from 'react'; +import { useRouter } from 'next/router'; +import PropTypes from 'prop-types'; +import ReactTooltip from 'react-tooltip'; +import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps'; +import classNames from 'classnames'; +import tinycolor from 'tinycolor2'; +import useTheme from 'hooks/useTheme'; +import { ISO_COUNTRIES, THEME_COLORS, MAP_FILE } from 'lib/constants'; +import styles from './WorldMap.module.css'; +import useCountryNames from 'hooks/useCountryNames'; +import useLocale from 'hooks/useLocale'; + +function WorldMap({ data, className }) { + const { basePath } = useRouter(); + const [tooltip, setTooltip] = useState(); + const [theme] = useTheme(); + const colors = useMemo( + () => ({ + baseColor: THEME_COLORS[theme].primary, + fillColor: THEME_COLORS[theme].gray100, + strokeColor: THEME_COLORS[theme].primary, + hoverColor: THEME_COLORS[theme].primary, + }), + [theme], + ); + const { locale } = useLocale(); + const countryNames = useCountryNames(locale); + + function getFillColor(code) { + if (code === 'AQ') return; + const country = data?.find(({ x }) => x === code); + + if (!country) { + return colors.fillColor; + } + + return tinycolor(colors.baseColor)[theme === 'light' ? 'lighten' : 'darken']( + 40 * (1.0 - country.z / 100), + ); + } + + function getOpacity(code) { + return code === 'AQ' ? 0 : 1; + } + + function handleHover(code) { + if (code === 'AQ') return; + const country = data?.find(({ x }) => x === code); + setTooltip(`${countryNames[code]}: ${country?.y || 0} visitors`); + } + + return ( +
+ + + + {({ geographies }) => { + return geographies.map(geo => { + const code = ISO_COUNTRIES[geo.id]; + + return ( + handleHover(code)} + onMouseOut={() => setTooltip(null)} + /> + ); + }); + }} + + + + {tooltip} +
+ ); +} + +WorldMap.propTypes = { + data: PropTypes.arrayOf( + PropTypes.shape({ + x: PropTypes.string, + y: PropTypes.number, + z: PropTypes.number, + }), + ), + className: PropTypes.string, +}; + +export default WorldMap; diff --git a/components/common/WorldMap.module.css b/components/common/WorldMap.module.css new file mode 100644 index 0000000..c252803 --- /dev/null +++ b/components/common/WorldMap.module.css @@ -0,0 +1,4 @@ +.container { + overflow: hidden; + position: relative; +} diff --git a/components/forms/AccountEditForm.js b/components/forms/AccountEditForm.js new file mode 100644 index 0000000..4b185f1 --- /dev/null +++ b/components/forms/AccountEditForm.js @@ -0,0 +1,88 @@ +import React, { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { Formik, Form, Field } from 'formik'; +import Button from 'components/common/Button'; +import FormLayout, { + FormButtons, + FormError, + FormMessage, + FormRow, +} from 'components/layout/FormLayout'; +import usePost from 'hooks/usePost'; + +const initialValues = { + username: '', + password: '', +}; + +const validate = ({ user_id, username, password }) => { + const errors = {}; + + if (!username) { + errors.username = ; + } + if (!user_id && !password) { + errors.password = ; + } + + return errors; +}; + +export default function AccountEditForm({ values, onSave, onClose }) { + const post = usePost(); + const [message, setMessage] = useState(); + + const handleSubmit = async values => { + const { ok, data } = await post('/api/account', values); + + if (ok) { + onSave(); + } else { + setMessage( + data || , + ); + } + }; + + return ( + + + {() => ( +
+ + +
+ + +
+
+ + +
+ + +
+
+ + + + + {message} +
+ )} +
+
+ ); +} diff --git a/components/forms/ChangePasswordForm.js b/components/forms/ChangePasswordForm.js new file mode 100644 index 0000000..d62b553 --- /dev/null +++ b/components/forms/ChangePasswordForm.js @@ -0,0 +1,105 @@ +import React, { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { Formik, Form, Field } from 'formik'; +import Button from 'components/common/Button'; +import FormLayout, { + FormButtons, + FormError, + FormMessage, + FormRow, +} from 'components/layout/FormLayout'; +import usePost from 'hooks/usePost'; + +const initialValues = { + current_password: '', + new_password: '', + confirm_password: '', +}; + +const validate = ({ current_password, new_password, confirm_password }) => { + const errors = {}; + + if (!current_password) { + errors.current_password = ; + } + if (!new_password) { + errors.new_password = ; + } + if (!confirm_password) { + errors.confirm_password = ; + } else if (new_password !== confirm_password) { + errors.confirm_password = ( + + ); + } + + return errors; +}; + +export default function ChangePasswordForm({ values, onSave, onClose }) { + const post = usePost(); + const [message, setMessage] = useState(); + + const handleSubmit = async values => { + const { ok, data } = await post('/api/account/password', values); + + if (ok) { + onSave(); + } else { + setMessage( + data || , + ); + } + }; + + return ( + + + {() => ( +
+ + +
+ + +
+
+ + +
+ + +
+
+ + +
+ + +
+
+ + + + + {message} +
+ )} +
+
+ ); +} diff --git a/components/forms/DatePickerForm.js b/components/forms/DatePickerForm.js new file mode 100644 index 0000000..9669f74 --- /dev/null +++ b/components/forms/DatePickerForm.js @@ -0,0 +1,83 @@ +import React, { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { isAfter, isBefore, isSameDay } from 'date-fns'; +import Calendar from 'components/common/Calendar'; +import Button from 'components/common/Button'; +import { FormButtons } from 'components/layout/FormLayout'; +import { getDateRangeValues } from 'lib/date'; +import styles from './DatePickerForm.module.css'; +import ButtonGroup from 'components/common/ButtonGroup'; + +const FILTER_DAY = 0; +const FILTER_RANGE = 1; + +export default function DatePickerForm({ + startDate: defaultStartDate, + endDate: defaultEndDate, + minDate, + maxDate, + onChange, + onClose, +}) { + const [selected, setSelected] = useState( + isSameDay(defaultStartDate, defaultEndDate) ? FILTER_DAY : FILTER_RANGE, + ); + const [date, setDate] = useState(defaultStartDate); + const [startDate, setStartDate] = useState(defaultStartDate); + const [endDate, setEndDate] = useState(defaultEndDate); + + const disabled = + selected === FILTER_DAY + ? isAfter(minDate, date) && isBefore(maxDate, date) + : isAfter(startDate, endDate); + + const buttons = [ + { + label: , + value: FILTER_DAY, + }, + { + label: , + value: FILTER_RANGE, + }, + ]; + + function handleSave() { + if (selected === FILTER_DAY) { + onChange({ ...getDateRangeValues(date, date), value: 'custom' }); + } else { + onChange({ ...getDateRangeValues(startDate, endDate), value: 'custom' }); + } + } + + return ( +
+
+ +
+
+ {selected === FILTER_DAY ? ( + + ) : ( + <> + + + + )} +
+ + + + +
+ ); +} diff --git a/components/forms/DatePickerForm.module.css b/components/forms/DatePickerForm.module.css new file mode 100644 index 0000000..96e2d2e --- /dev/null +++ b/components/forms/DatePickerForm.module.css @@ -0,0 +1,40 @@ +.container { + display: flex; + flex-direction: column; + max-width: 100vw; +} + +.calendars { + display: flex; + justify-content: center; +} + +.calendars > div { + width: 380px; +} + +.calendars > div + div { + margin-left: 20px; + padding-left: 20px; + border-left: 1px solid var(--gray300); +} + +.filter { + display: flex; + justify-content: center; + align-items: center; + margin-bottom: 20px; +} + +@media only screen and (max-width: 768px) { + .calendars { + flex-direction: column; + } + + .calendars > div + div { + padding: 0; + margin-left: 0; + margin-top: 20px; + border: 0; + } +} diff --git a/components/forms/DeleteForm.js b/components/forms/DeleteForm.js new file mode 100644 index 0000000..7860030 --- /dev/null +++ b/components/forms/DeleteForm.js @@ -0,0 +1,98 @@ +import React, { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { Formik, Form, Field } from 'formik'; +import Button from 'components/common/Button'; +import FormLayout, { + FormButtons, + FormError, + FormMessage, + FormRow, +} from 'components/layout/FormLayout'; +import useDelete from 'hooks/useDelete'; + +const CONFIRMATION_WORD = 'DELETE'; + +const validate = ({ confirmation }) => { + const errors = {}; + + if (confirmation !== CONFIRMATION_WORD) { + errors.confirmation = !confirmation ? ( + + ) : ( + + ); + } + + return errors; +}; + +export default function DeleteForm({ values, onSave, onClose }) { + const del = useDelete(); + const [message, setMessage] = useState(); + + const handleSubmit = async ({ type, id }) => { + const { ok, data } = await del(`/api/${type}/${id}`); + + if (ok) { + onSave(); + } else { + setMessage( + data || , + ); + } + }; + + return ( + + + {props => ( +
+
+ {values.name} }} + /> +
+
+ +
+

+ {CONFIRMATION_WORD} }} + /> +

+ +
+ + +
+
+ + + + + {message} +
+ )} +
+
+ ); +} diff --git a/components/forms/LoginForm.js b/components/forms/LoginForm.js new file mode 100644 index 0000000..8ac3a55 --- /dev/null +++ b/components/forms/LoginForm.js @@ -0,0 +1,102 @@ +import React, { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { Formik, Form, Field } from 'formik'; +import { useRouter } from 'next/router'; +import Button from 'components/common/Button'; +import FormLayout, { + FormButtons, + FormError, + FormMessage, + FormRow, +} from 'components/layout/FormLayout'; +import Icon from 'components/common/Icon'; +import Logo from 'assets/logo.svg'; +import styles from './LoginForm.module.css'; +import usePost from 'hooks/usePost'; + +const validate = ({ username, password }) => { + const errors = {}; + + if (!username) { + errors.username = ; + } + if (!password) { + errors.password = ; + } + + return errors; +}; + +export default function LoginForm() { + const post = usePost(); + const router = useRouter(); + const [message, setMessage] = useState(); + + const handleSubmit = async ({ username, password }) => { + const { ok, status, data } = await post('/api/auth/login', { + username, + password, + }); + + if (ok) { + return router.push('/'); + } else { + setMessage( + status === 401 ? ( + + ) : ( + data + ), + ); + } + }; + + return ( + + + {() => ( +
+
+ } size="xlarge" className={styles.icon} /> +

umami

+
+ + +
+ + +
+
+ + +
+ + +
+
+ + + + {message} +
+ )} +
+
+ ); +} diff --git a/components/forms/LoginForm.module.css b/components/forms/LoginForm.module.css new file mode 100644 index 0000000..dfd5456 --- /dev/null +++ b/components/forms/LoginForm.module.css @@ -0,0 +1,23 @@ +.login { + display: flex; + flex-direction: column; + margin-top: 80px; +} + +.login form { + margin: 0 auto; +} + +.icon { + display: flex; + justify-content: center; + margin: 0 auto; +} + +.header { + margin-bottom: 30px; +} + +.header h1 { + margin: 12px 0; +} diff --git a/components/forms/ResetForm.js b/components/forms/ResetForm.js new file mode 100644 index 0000000..791039a --- /dev/null +++ b/components/forms/ResetForm.js @@ -0,0 +1,98 @@ +import React, { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { Formik, Form, Field } from 'formik'; +import Button from 'components/common/Button'; +import FormLayout, { + FormButtons, + FormError, + FormMessage, + FormRow, +} from 'components/layout/FormLayout'; +import usePost from 'hooks/usePost'; + +const CONFIRMATION_WORD = 'RESET'; + +const validate = ({ confirmation }) => { + const errors = {}; + + if (confirmation !== CONFIRMATION_WORD) { + errors.confirmation = !confirmation ? ( + + ) : ( + + ); + } + + return errors; +}; + +export default function ResetForm({ values, onSave, onClose }) { + const post = usePost(); + const [message, setMessage] = useState(); + + const handleSubmit = async ({ type, id }) => { + const { ok, data } = await post(`/api/${type}/${id}/reset`); + + if (ok) { + onSave(); + } else { + setMessage( + data || , + ); + } + }; + + return ( + + + {props => ( +
+
+ {values.name} }} + /> +
+
+ +
+

+ {CONFIRMATION_WORD} }} + /> +

+ +
+ + +
+
+ + + + + {message} +
+ )} +
+
+ ); +} diff --git a/components/forms/ShareUrlForm.js b/components/forms/ShareUrlForm.js new file mode 100644 index 0000000..a77d3fb --- /dev/null +++ b/components/forms/ShareUrlForm.js @@ -0,0 +1,42 @@ +import React, { useRef } from 'react'; +import { FormattedMessage } from 'react-intl'; +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'; + +export default function TrackingCodeForm({ values, onClose }) { + const ref = useRef(); + const { basePath } = useRouter(); + const { name, share_id } = values; + + return ( + +

+ {values.name} }} + /> +

+ +