diff --git a/.gitignore b/.gitignore index ca0f3c4f..32d3cbce 100644 --- a/.gitignore +++ b/.gitignore @@ -16,13 +16,15 @@ # production /build /public/umami.js +/public/geo /lang-compiled -/lang-formatted # misc .DS_Store .idea *.iml +*.log +.vscode/* # debug npm-debug.log* diff --git a/Dockerfile b/Dockerfile index 871b8a87..31ea0054 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,41 @@ -FROM node:12.18-alpine - +# Build image +FROM node:12.18-alpine AS build ARG DATABASE_TYPE - ENV DATABASE_URL "postgresql://umami:umami@db:5432/umami" \ DATABASE_TYPE=$DATABASE_TYPE +WORKDIR /build -COPY . /app +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.18-alpine AS production WORKDIR /app -RUN npm install && npm run build +# 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 ["npm", "start"] +CMD ["yarn", "start"] diff --git a/Procfile b/Procfile new file mode 100644 index 00000000..edc6c9a0 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: npm run start-env diff --git a/assets/bolt.svg b/assets/bolt.svg new file mode 100644 index 00000000..4654a1eb --- /dev/null +++ b/assets/bolt.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 00000000..46bef5bc --- /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 00000000..ed09306f --- /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 00000000..09c93453 --- /dev/null +++ b/assets/eye.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/logo.svg b/assets/logo.svg index eca6048b..c80f1668 100644 --- a/assets/logo.svg +++ b/assets/logo.svg @@ -1 +1,2 @@ - \ No newline at end of file + + \ No newline at end of file diff --git a/assets/moon.svg b/assets/moon.svg new file mode 100644 index 00000000..6c8955ae --- /dev/null +++ b/assets/moon.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 00000000..ebc20eb2 --- /dev/null +++ b/assets/sun.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/visitor.svg b/assets/visitor.svg new file mode 100644 index 00000000..591873a5 --- /dev/null +++ b/assets/visitor.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/WebsiteDetails.module.css b/components/WebsiteDetails.module.css deleted file mode 100644 index 4f117ba1..00000000 --- a/components/WebsiteDetails.module.css +++ /dev/null @@ -1,59 +0,0 @@ -.chart { - margin-bottom: 30px; -} - -.view { - border-top: 1px solid var(--gray300); -} - -.menu { - font-size: var(--font-size-small); -} - -.content { - min-height: 600px; -} - -.backButton { - align-self: flex-start; - margin-bottom: 16px; -} - -.backButton svg { - transform: rotate(180deg); -} - -.row { - border-top: 1px solid var(--gray300); - min-height: 430px; -} - -.row > [class*='col-'] { - border-left: 1px solid var(--gray300); - padding: 0 20px; -} - -.row > [class*='col-']:first-child { - border-left: 0; - padding-left: 0; -} - -.row > [class*='col-']:last-child { - padding-right: 0; -} - -.hidden { - display: none; -} - -@media only screen and (max-width: 992px) { - .row { - border: 0; - } - - .row > [class*='col-'] { - border-top: 1px solid var(--gray300); - border-left: 0; - padding: 0; - } -} diff --git a/components/common/Button.js b/components/common/Button.js index b973b36e..5e92d0d8 100644 --- a/components/common/Button.js +++ b/components/common/Button.js @@ -13,7 +13,8 @@ export default function Button({ className, tooltip, tooltipId, - disabled = false, + disabled, + iconRight, onClick = () => {}, ...props }) { @@ -30,14 +31,14 @@ export default function Button({ [styles.action]: variant === 'action', [styles.danger]: variant === 'danger', [styles.light]: variant === 'light', - [styles.disabled]: disabled, + [styles.iconRight]: iconRight, })} disabled={disabled} onClick={!disabled ? onClick : null} {...props} > - {icon && } - {children} + {icon && } + {children &&
{children}
} {tooltip && {tooltip}} ); diff --git a/components/common/Button.module.css b/components/common/Button.module.css index faae656b..b911095f 100644 --- a/components/common/Button.module.css +++ b/components/common/Button.module.css @@ -3,13 +3,13 @@ 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; - white-space: nowrap; position: relative; } @@ -18,17 +18,20 @@ } .button:active { - color: initial; + color: var(--gray900); +} + +.label { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + max-width: 300px; } .large { font-size: var(--font-size-large); } -.medium { - font-size: var(--font-size-normal); -} - .small { font-size: var(--font-size-small); } @@ -37,7 +40,8 @@ font-size: var(--font-size-xsmall); } -.action { +.action, +.action:active { color: var(--gray50); background: var(--gray900); } @@ -46,7 +50,8 @@ background: var(--gray800); } -.danger { +.danger, +.danger:active { color: var(--gray50); background: var(--red500); } @@ -55,12 +60,27 @@ background: var(--red400); } -.light { - background: var(--gray50); +.light, +.light:active { + color: var(--gray900); + background: transparent; } .light:hover { - background: var(--gray75); + background: inherit; +} + +.button .icon + * { + margin-left: 10px; +} + +.button.iconRight .icon { + order: 1; + margin-left: 10px; +} + +.button.iconRight .icon + * { + margin: 0; } .button:disabled { diff --git a/components/common/ButtonGroup.module.css b/components/common/ButtonGroup.module.css index d18a8e9c..bc60f8d3 100644 --- a/components/common/ButtonGroup.module.css +++ b/components/common/ButtonGroup.module.css @@ -7,6 +7,7 @@ .group .button { border-radius: 0; + color: var(--gray800); background: var(--gray50); border-left: 1px solid var(--gray500); padding: 4px 8px; @@ -24,6 +25,7 @@ margin: 0; } -.selected { +.group .button.selected { + color: var(--gray900); font-weight: 600; } diff --git a/components/common/Calendar.module.css b/components/common/Calendar.module.css index eb07431b..9751cf25 100644 --- a/components/common/Calendar.module.css +++ b/components/common/Calendar.module.css @@ -17,7 +17,7 @@ text-align: center; vertical-align: center; height: 40px; - min-width: 40px; + width: 40px; border-radius: 5px; border: 1px solid transparent; } @@ -103,3 +103,9 @@ .icon { margin-left: 10px; } + +@media only screen and (max-width: 992px) { + .calendar table { + max-width: calc(100vw - 30px); + } +} diff --git a/components/common/CopyButton.js b/components/common/CopyButton.js index 399da90d..460c68ac 100644 --- a/components/common/CopyButton.js +++ b/components/common/CopyButton.js @@ -3,7 +3,7 @@ import Button from './Button'; import { FormattedMessage } from 'react-intl'; const defaultText = ( - + ); export default function CopyButton({ element, ...props }) { diff --git a/components/common/Dot.js b/components/common/Dot.js new file mode 100644 index 00000000..d5dcf914 --- /dev/null +++ b/components/common/Dot.js @@ -0,0 +1,17 @@ +import React from 'react'; +import classNames from 'classnames'; +import styles from './Dot.module.css'; + +export default function Dot({ color, size, className }) { + return ( +
+
+
+ ); +} diff --git a/components/common/Dot.module.css b/components/common/Dot.module.css new file mode 100644 index 00000000..258d6e87 --- /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 index df559ef9..d240fbd6 100644 --- a/components/common/DropDown.js +++ b/components/common/DropDown.js @@ -15,6 +15,7 @@ export default function DropDown({ }) { const [showMenu, setShowMenu] = useState(false); const ref = useRef(); + const selectedOption = options.find(e => e.value === value); function handleShowMenu() { setShowMenu(state => !state); @@ -36,11 +37,17 @@ export default function DropDown({ return (
- {options.find(e => e.value === value)?.label || value} +
{options.find(e => e.value === value)?.label || value}
} className={styles.icon} size="small" />
{showMenu && ( - + )}
); diff --git a/components/common/Dropdown.module.css b/components/common/Dropdown.module.css index 250e6c29..9738b007 100644 --- a/components/common/Dropdown.module.css +++ b/components/common/Dropdown.module.css @@ -19,6 +19,10 @@ min-width: 160px; } -.icon { - padding-left: 10px; +.text { + flex: 1; +} + +.icon { + padding-left: 20px; } diff --git a/components/common/EmptyPlaceholder.js b/components/common/EmptyPlaceholder.js index a7b0720c..26a9fcbf 100644 --- a/components/common/EmptyPlaceholder.js +++ b/components/common/EmptyPlaceholder.js @@ -6,7 +6,7 @@ import styles from './EmptyPlaceholder.module.css'; export default function EmptyPlaceholder({ msg, children }) { return (
- } size="xlarge" /> + } size="xlarge" />

{msg}

{children}
diff --git a/components/common/EmptyPlaceholder.module.css b/components/common/EmptyPlaceholder.module.css index 7d2a93a5..58332566 100644 --- a/components/common/EmptyPlaceholder.module.css +++ b/components/common/EmptyPlaceholder.module.css @@ -5,3 +5,7 @@ align-items: center; min-height: 600px; } + +.icon { + margin-bottom: 30px; +} diff --git a/components/common/ErrorMessage.js b/components/common/ErrorMessage.js new file mode 100644 index 00000000..5747f226 --- /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 00000000..232b5f84 --- /dev/null +++ b/components/common/ErrorMessage.module.css @@ -0,0 +1,13 @@ +.error { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + margin: auto; + display: flex; + z-index: 1; +} + +.icon { + margin-right: 10px; +} diff --git a/components/common/Favicon.js b/components/common/Favicon.js new file mode 100644 index 00000000..07ec696c --- /dev/null +++ b/components/common/Favicon.js @@ -0,0 +1,21 @@ +import React from 'react'; +import styles from './Favicon.module.css'; + +function getHostName(url) { + const match = url.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?(?:www\.)?([^:/\n?=]+)/im); + return match && match.length > 1 ? match[1] : null; +} + +export default function Favicon({ domain, ...props }) { + const hostName = domain ? getHostName(domain) : null; + + return hostName ? ( + + ) : null; +} diff --git a/components/common/Favicon.module.css b/components/common/Favicon.module.css new file mode 100644 index 00000000..82c85c42 --- /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 00000000..5b898bf4 --- /dev/null +++ b/components/common/FilterButtons.js @@ -0,0 +1,11 @@ +import React from 'react'; +import ButtonLayout from 'components/layout/ButtonLayout'; +import ButtonGroup from './ButtonGroup'; + +export default function FilterButtons({ buttons, selected, onClick }) { + return ( + + + + ); +} diff --git a/components/common/Icon.module.css b/components/common/Icon.module.css index 47d0ab0d..5b431668 100644 --- a/components/common/Icon.module.css +++ b/components/common/Icon.module.css @@ -5,10 +5,6 @@ vertical-align: middle; } -.icon + * { - margin-left: 10px; -} - .icon svg { fill: currentColor; } diff --git a/components/common/LanguageButton.js b/components/common/LanguageButton.js deleted file mode 100644 index 6fe7eb83..00000000 --- a/components/common/LanguageButton.js +++ /dev/null @@ -1,66 +0,0 @@ -import React, { useState, useRef } from 'react'; -import Head from 'next/head'; -import Menu from './Menu'; -import Button from './Button'; -import { menuOptions } from 'lib/lang'; -import { setItem } from 'lib/web'; -import useLocale from 'hooks/useLocale'; -import useDocumentClick from 'hooks/useDocumentClick'; -import Globe from 'assets/globe.svg'; -import styles from './LanguageButton.module.css'; - -export default function LanguageButton({ menuPosition = 'bottom', menuAlign = 'left' }) { - const [showMenu, setShowMenu] = useState(false); - const [locale, setLocale] = useLocale(); - const ref = useRef(); - const selectedLocale = menuOptions.find(e => e.value === locale)?.display; - - function handleSelect(value) { - setLocale(value); - setItem('umami.locale', value); - setShowMenu(false); - } - - function toggleMenu() { - setShowMenu(state => !state); - } - - useDocumentClick(e => { - if (!ref.current.contains(e.target)) { - setShowMenu(false); - } - }); - - return ( - <> - - {locale === 'zh-CN' && ( - - )} - {locale === 'ja-JP' && ( - - )} - -
- - {showMenu && ( - - )} -
- - ); -} diff --git a/components/common/LanguageButton.module.css b/components/common/LanguageButton.module.css deleted file mode 100644 index 55464c4d..00000000 --- a/components/common/LanguageButton.module.css +++ /dev/null @@ -1,9 +0,0 @@ -.container { - display: flex; - position: relative; - cursor: pointer; -} - -.menu { - z-index: 100; -} diff --git a/components/common/Link.js b/components/common/Link.js index c3a5fa7e..466e018c 100644 --- a/components/common/Link.js +++ b/components/common/Link.js @@ -1,12 +1,23 @@ import React from 'react'; import classNames from 'classnames'; import NextLink from 'next/link'; +import Icon from './Icon'; import styles from './Link.module.css'; -export default function Link({ className, children, ...props }) { +export default function Link({ className, icon, children, size, iconRight, ...props }) { return ( - {children} + + {icon && } + {children} + ); } diff --git a/components/common/Link.module.css b/components/common/Link.module.css index d6dc0536..ea6d281d 100644 --- a/components/common/Link.module.css +++ b/components/common/Link.module.css @@ -2,8 +2,10 @@ a.link, a.link:active, a.link:visited { position: relative; - color: #2c2c2c; + color: var(--gray900); text-decoration: none; + display: inline-flex; + align-items: center; } a.link:before { @@ -12,7 +14,7 @@ a.link:before { bottom: -2px; width: 0; height: 2px; - background: #2680eb; + background: var(--primary400); opacity: 0.5; transition: width 100ms; } @@ -21,3 +23,28 @@ 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.module.css b/components/common/Loading.module.css index 2a210078..4e56dd8e 100644 --- a/components/common/Loading.module.css +++ b/components/common/Loading.module.css @@ -12,6 +12,8 @@ .loading { display: flex; + justify-content: center; + align-items: center; position: absolute; top: 50%; left: 50%; diff --git a/components/common/Menu.js b/components/common/Menu.js index 283ee1fb..6421ba55 100644 --- a/components/common/Menu.js +++ b/components/common/Menu.js @@ -33,7 +33,8 @@ export default function Menu({
onSelect(value, e)} diff --git a/components/common/Menu.module.css b/components/common/Menu.module.css index 9bcd642f..d2ad2cc4 100644 --- a/components/common/Menu.module.css +++ b/components/common/Menu.module.css @@ -1,21 +1,22 @@ .menu { + background: var(--gray50); border: 1px solid var(--gray500); border-radius: 4px; overflow: hidden; - z-index: 2; + z-index: 100; } .option { font-size: var(--font-size-small); font-weight: normal; - background: #fff; + background: var(--gray50); padding: 4px 16px; cursor: pointer; white-space: nowrap; } .option:hover { - background: #f5f5f5; + background: var(--gray100); } .float { @@ -44,3 +45,7 @@ .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 00000000..f3de66d0 --- /dev/null +++ b/components/common/MenuButton.js @@ -0,0 +1,60 @@ +import React, { useState, useRef } from 'react'; +import classNames from 'classnames'; +import Menu from 'components/common/Menu'; +import Button from 'components/common/Button'; +import useDocumentClick from 'hooks/useDocumentClick'; +import styles from './MenuButton.module.css'; + +export default function MenuButton({ + icon, + value, + options, + buttonClassName, + menuClassName, + menuPosition = 'bottom', + menuAlign = 'right', + onSelect, + renderValue, +}) { + const [showMenu, setShowMenu] = useState(false); + const ref = useRef(); + const selectedOption = options.find(e => e.value === value); + + function handleSelect(value) { + onSelect(value); + setShowMenu(false); + } + + function toggleMenu() { + setShowMenu(state => !state); + } + + useDocumentClick(e => { + if (!ref.current.contains(e.target)) { + setShowMenu(false); + } + }); + + return ( +
+ + {showMenu && ( + + )} +
+ ); +} diff --git a/components/common/MenuButton.module.css b/components/common/MenuButton.module.css new file mode 100644 index 00000000..7e9dd7e1 --- /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.module.css b/components/common/Modal.module.css index 3702e774..bf2491c7 100644 --- a/components/common/Modal.module.css +++ b/components/common/Modal.module.css @@ -16,8 +16,8 @@ right: 0; bottom: 0; margin: auto; - background: var(--gray900); - opacity: 0.1; + background: #000; + opacity: 0.5; } .content { diff --git a/components/common/NavMenu.module.css b/components/common/NavMenu.module.css index c5d6c9db..7be73973 100644 --- a/components/common/NavMenu.module.css +++ b/components/common/NavMenu.module.css @@ -1,4 +1,5 @@ .menu { + color: var(--gray800); border: 1px solid var(--gray500); border-radius: 4px; overflow: hidden; @@ -16,5 +17,6 @@ } .selected { + color: var(--gray900); font-weight: 600; } diff --git a/components/common/NoData.module.css b/components/common/NoData.module.css index d1c712eb..82f9c3ee 100644 --- a/components/common/NoData.module.css +++ b/components/common/NoData.module.css @@ -1,5 +1,6 @@ .container { color: var(--gray500); + font-size: var(--font-size-normal); position: absolute; top: 50%; left: 50%; diff --git a/components/common/RefreshButton.js b/components/common/RefreshButton.js index 1fa02f48..b1b80a83 100644 --- a/components/common/RefreshButton.js +++ b/components/common/RefreshButton.js @@ -10,9 +10,9 @@ import { getDateRange } from '../../lib/date'; export default function RefreshButton({ websiteId }) { const dispatch = useDispatch(); - const dateRange = useDateRange(websiteId); + const [dateRange] = useDateRange(websiteId); const [loading, setLoading] = useState(false); - const completed = useSelector(state => state.queries[`/api/website/${websiteId}/metrics`]); + const completed = useSelector(state => state.queries[`/api/website/${websiteId}/stats`]); function handleClick() { if (dateRange) { @@ -28,7 +28,7 @@ export default function RefreshButton({ websiteId }) { return ( + + +
+ ); +} diff --git a/components/common/UpdateNotice.module.css b/components/common/UpdateNotice.module.css new file mode 100644 index 00000000..52a97c3b --- /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/UserButton.module.css b/components/common/UserButton.module.css deleted file mode 100644 index d848c8e3..00000000 --- a/components/common/UserButton.module.css +++ /dev/null @@ -1,22 +0,0 @@ -.container { - display: flex; - position: relative; - cursor: pointer; -} - -.button { - display: flex; - flex-wrap: nowrap; -} - -.username { - border-bottom: 1px solid var(--gray500); -} - -.username:hover { - background: var(--gray50); -} - -.menu { - z-index: 100; -} diff --git a/components/common/WorldMap.js b/components/common/WorldMap.js index f10dd542..a24f7400 100644 --- a/components/common/WorldMap.js +++ b/components/common/WorldMap.js @@ -1,44 +1,61 @@ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import ReactTooltip from 'react-tooltip'; +import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps'; import classNames from 'classnames'; import tinycolor from 'tinycolor2'; -import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps'; +import useTheme from 'hooks/useTheme'; +import { THEME_COLORS } from 'lib/constants'; import styles from './WorldMap.module.css'; +import useCountryNames from 'hooks/useCountryNames'; +import useLocale from 'hooks/useLocale'; const geoUrl = '/world-110m.json'; -export default function WorldMap({ - data, - className, - baseColor = '#e9f3fd', - fillColor = '#f5f5f5', - strokeColor = '#2680eb', - hoverColor = '#2680eb', -}) { +export default function WorldMap({ data, className }) { 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 '#ffffff'; + if (code === 'AQ') return; const country = data?.find(({ x }) => x === code); - return country ? tinycolor(baseColor).darken(country.z) : fillColor; + + if (!country) { + return colors.fillColor; + } + + return tinycolor(colors.baseColor)[theme === 'light' ? 'lighten' : 'darken']( + 40 * (1.0 - country.z / 100), + ); } - function getStrokeColor(code) { - return code === 'AQ' ? '#ffffff' : strokeColor; + function getOpacity(code) { + return code === 'AQ' ? 0 : 1; } - function getHoverColor(code) { - return code === 'AQ' ? '#ffffff' : hoverColor; - } - - function handleHover({ ISO_A2: code, NAME: name }) { + function handleHover(code) { + if (code === 'AQ') return; const country = data?.find(({ x }) => x === code); - setTooltip(`${name}: ${country?.y || 0} visitors`); + setTooltip(`${countryNames[code]}: ${country?.y || 0} visitors`); } return ( -
- +
+ {({ geographies }) => { @@ -50,13 +67,14 @@ export default function WorldMap({ key={geo.rsmKey} geography={geo} fill={getFillColor(code)} - stroke={getStrokeColor(code)} + stroke={colors.strokeColor} + opacity={getOpacity(code)} style={{ default: { outline: 'none' }, - hover: { outline: 'none', fill: getHoverColor(code) }, + hover: { outline: 'none', fill: colors.hoverColor }, pressed: { outline: 'none' }, }} - onMouseOver={() => handleHover(geo.properties)} + onMouseOver={() => handleHover(code)} onMouseOut={() => setTooltip(null)} /> ); @@ -65,7 +83,7 @@ export default function WorldMap({ - {tooltip} + {tooltip}
); } diff --git a/components/common/WorldMap.module.css b/components/common/WorldMap.module.css index bf84d697..c2528038 100644 --- a/components/common/WorldMap.module.css +++ b/components/common/WorldMap.module.css @@ -1,5 +1,4 @@ .container { overflow: hidden; position: relative; - background: #fff; } diff --git a/components/forms/AccountEditForm.js b/components/forms/AccountEditForm.js index 16c6fd3f..cc000cf7 100644 --- a/components/forms/AccountEditForm.js +++ b/components/forms/AccountEditForm.js @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import { FormattedMessage } from 'react-intl'; import { Formik, Form, Field } from 'formik'; +import { useRouter } from 'next/router'; import { post } from 'lib/web'; import Button from 'components/common/Button'; import FormLayout, { @@ -29,18 +30,17 @@ const validate = ({ user_id, username, password }) => { }; export default function AccountEditForm({ values, onSave, onClose }) { + const { basePath } = useRouter(); const [message, setMessage] = useState(); const handleSubmit = async values => { - const response = await post(`/api/account`, values); + const { ok, data } = await post(`${basePath}/api/account`, values); - if (typeof response !== 'string') { + if (ok) { onSave(); } else { setMessage( - response || ( - - ), + data || , ); } }; @@ -58,22 +58,26 @@ export default function AccountEditForm({ values, onSave, onClose }) { - - +
+ + +
- - +
+ + +
{message} diff --git a/components/forms/ChangePasswordForm.js b/components/forms/ChangePasswordForm.js index e2f225b7..9c41bd55 100644 --- a/components/forms/ChangePasswordForm.js +++ b/components/forms/ChangePasswordForm.js @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import { FormattedMessage } from 'react-intl'; +import { useRouter } from 'next/router'; import { Formik, Form, Field } from 'formik'; import { post } from 'lib/web'; import Button from 'components/common/Button'; @@ -37,18 +38,17 @@ const validate = ({ current_password, new_password, confirm_password }) => { }; export default function ChangePasswordForm({ values, onSave, onClose }) { + const { basePath } = useRouter(); const [message, setMessage] = useState(); const handleSubmit = async values => { - const response = await post(`/api/account/password`, values); + const { ok, data } = await post(`${basePath}/api/account/password`, values); - if (typeof response !== 'string') { + if (ok) { onSave(); } else { setMessage( - response || ( - - ), + data || , ); } }; @@ -66,29 +66,35 @@ export default function ChangePasswordForm({ values, onSave, onClose }) { - - +
+ + +
- - +
+ + +
- - +
+ + +
{message} diff --git a/components/forms/DatePickerForm.js b/components/forms/DatePickerForm.js index f8b6416e..9669f741 100644 --- a/components/forms/DatePickerForm.js +++ b/components/forms/DatePickerForm.js @@ -6,7 +6,7 @@ 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 '../common/ButtonGroup'; +import ButtonGroup from 'components/common/ButtonGroup'; const FILTER_DAY = 0; const FILTER_RANGE = 1; @@ -33,11 +33,11 @@ export default function DatePickerForm({ const buttons = [ { - label: , + label: , value: FILTER_DAY, }, { - label: , + label: , value: FILTER_RANGE, }, ]; @@ -72,10 +72,10 @@ export default function DatePickerForm({
diff --git a/components/forms/DeleteForm.js b/components/forms/DeleteForm.js index f53b286f..145a342a 100644 --- a/components/forms/DeleteForm.js +++ b/components/forms/DeleteForm.js @@ -1,4 +1,6 @@ import React, { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { useRouter } from 'next/router'; import { Formik, Form, Field } from 'formik'; import { del } from 'lib/web'; import Button from 'components/common/Button'; @@ -8,7 +10,6 @@ import FormLayout, { FormMessage, FormRow, } from 'components/layout/FormLayout'; -import { FormattedMessage } from 'react-intl'; const CONFIRMATION_WORD = 'DELETE'; @@ -27,15 +28,18 @@ const validate = ({ confirmation }) => { }; export default function DeleteForm({ values, onSave, onClose }) { + const { basePath } = useRouter(); const [message, setMessage] = useState(); const handleSubmit = async ({ type, id }) => { - const response = await del(`/api/${type}/${id}`); + const { ok, data } = await del(`${basePath}/api/${type}/${id}`); - if (typeof response !== 'string') { + if (ok) { onSave(); } else { - setMessage(); + setMessage( + data || , + ); } }; @@ -69,8 +73,10 @@ export default function DeleteForm({ values, onSave, onClose }) { />

- - +
+ + +
{message} diff --git a/components/forms/LoginForm.js b/components/forms/LoginForm.js index a34a4474..38b85125 100644 --- a/components/forms/LoginForm.js +++ b/components/forms/LoginForm.js @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { FormattedMessage } from 'react-intl'; import { Formik, Form, Field } from 'formik'; -import Router from 'next/router'; +import { useRouter } from 'next/router'; import { post } from 'lib/web'; import Button from 'components/common/Button'; import FormLayout, { @@ -28,22 +28,26 @@ const validate = ({ username, password }) => { }; export default function LoginForm() { + const router = useRouter(); const [message, setMessage] = useState(); const handleSubmit = async ({ username, password }) => { - const response = await post('/api/auth/login', { username, password }); + const { ok, status, data } = await post(`${router.basePath}/api/auth/login`, { + username, + password, + }); - if (typeof response !== 'string') { - await Router.push('/'); + if (ok) { + return router.push('/'); } else { setMessage( - response.startsWith('401') ? ( + status === 401 ? ( ) : ( - response + data ), ); } @@ -67,19 +71,23 @@ export default function LoginForm() { - - +
+ + +
- - +
+ + +
{message} diff --git a/components/forms/ShareUrlForm.js b/components/forms/ShareUrlForm.js index ea162f67..dbb1b656 100644 --- a/components/forms/ShareUrlForm.js +++ b/components/forms/ShareUrlForm.js @@ -30,7 +30,7 @@ export default function TrackingCodeForm({ values, onClose }) { diff --git a/components/forms/TrackingCodeForm.js b/components/forms/TrackingCodeForm.js index 1f44f835..a8d5a344 100644 --- a/components/forms/TrackingCodeForm.js +++ b/components/forms/TrackingCodeForm.js @@ -29,7 +29,7 @@ export default function TrackingCodeForm({ values, onClose }) { diff --git a/components/forms/WebsiteEditForm.js b/components/forms/WebsiteEditForm.js index 7d405e52..7be89f79 100644 --- a/components/forms/WebsiteEditForm.js +++ b/components/forms/WebsiteEditForm.js @@ -11,6 +11,7 @@ import FormLayout, { } from 'components/layout/FormLayout'; import Checkbox from 'components/common/Checkbox'; import { DOMAIN_REGEX } from 'lib/constants'; +import { useRouter } from 'next/router'; const initialValues = { name: '', @@ -34,15 +35,18 @@ const validate = ({ name, domain }) => { }; export default function WebsiteEditForm({ values, onSave, onClose }) { + const { basePath } = useRouter(); const [message, setMessage] = useState(); const handleSubmit = async values => { - const response = await post(`/api/website`, values); + const { ok, data } = await post(`${basePath}/api/website`, values); - if (typeof response !== 'string') { + if (ok) { onSave(); } else { - setMessage(); + setMessage( + data || , + ); } }; @@ -59,15 +63,19 @@ export default function WebsiteEditForm({ values, onSave, onClose }) { - - +
+ + +
- - +
+ + +
@@ -87,10 +95,10 @@ export default function WebsiteEditForm({ values, onSave, onClose }) { {message} diff --git a/components/layout/ButtonLayout.js b/components/layout/ButtonLayout.js index 7a9ae8cb..40be399f 100644 --- a/components/layout/ButtonLayout.js +++ b/components/layout/ButtonLayout.js @@ -2,6 +2,16 @@ import React from 'react'; import classNames from 'classnames'; import styles from './ButtonLayout.module.css'; -export default function ButtonLayout({ className, children }) { - return
{children}
; +export default function ButtonLayout({ className, children, align = 'center' }) { + return ( +
+ {children} +
+ ); } diff --git a/components/layout/ButtonLayout.module.css b/components/layout/ButtonLayout.module.css index f153ba54..ef7707e4 100644 --- a/components/layout/ButtonLayout.module.css +++ b/components/layout/ButtonLayout.module.css @@ -6,3 +6,15 @@ .buttons button + * { margin-left: 10px; } + +.center { + justify-content: center; +} + +.left { + justify-content: flex-start; +} + +.right { + justify-content: flex-end; +} diff --git a/components/layout/Footer.js b/components/layout/Footer.js index 7bd1ebd3..73e010bc 100644 --- a/components/layout/Footer.js +++ b/components/layout/Footer.js @@ -1,15 +1,17 @@ import React from 'react'; +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'; export default function Footer() { - const version = process.env.VERSION; + const { current } = useVersion(); return (
-
-
-
+
+
+
-
{`v${version}`}
+
+ {`v${current}`} +
); diff --git a/components/layout/Footer.module.css b/components/layout/Footer.module.css index 7c671d7e..a83c8c3c 100644 --- a/components/layout/Footer.module.css +++ b/components/layout/Footer.module.css @@ -4,4 +4,15 @@ align-items: center; font-size: var(--font-size-small); min-height: 100px; + text-align: center; +} + +.version { + text-align: right; +} + +@media only screen and (max-width: 768px) { + .version { + text-align: center; + } } diff --git a/components/layout/FormLayout.module.css b/components/layout/FormLayout.module.css index 0b82ee7c..1ae393bb 100644 --- a/components/layout/FormLayout.module.css +++ b/components/layout/FormLayout.module.css @@ -17,6 +17,10 @@ line-height: 1.8; } +.row > div { + position: relative; +} + .buttons { display: flex; justify-content: center; @@ -33,13 +37,13 @@ justify-content: center; align-items: center; top: 0; - left: 100%; + left: calc(100% + 16px); bottom: 0; - margin-left: 16px; + z-index: 1; } .msg { - color: var(--gray50); + color: var(--msgColor); background: var(--red400); font-size: var(--font-size-small); padding: 4px 8px; @@ -68,3 +72,15 @@ color: var(--gray50); background: var(--gray800); } + +@media only screen and (max-width: 576px) { + .error { + align-items: flex-start; + top: calc(100% + 7px); + left: 0; + } + + .error:after { + left: 10px; + } +} diff --git a/components/layout/GridLayout.js b/components/layout/GridLayout.js new file mode 100644 index 00000000..1d3170d6 --- /dev/null +++ b/components/layout/GridLayout.js @@ -0,0 +1,31 @@ +import React from 'react'; +import classNames from 'classnames'; +import styles from './GridLayout.module.css'; + +export default function GridLayout({ className, children }) { + return
{children}
; +} + +export const GridRow = ({ className, children }) => { + return
{children}
; +}; + +export const GridColumn = ({ xs, sm, md, lg, xl, className, children }) => { + const classes = []; + + classes.push(xs ? `col-${xs}` : 'col-12'); + + if (sm) { + classes.push(`col-sm-${sm}`); + } + if (md) { + classes.push(`col-md-${md}`); + } + if (lg) { + classes.push(`col-lg-${lg}`); + } + if (xl) { + classes.push(`col-lg-${xl}`); + } + return
{children}
; +}; diff --git a/components/layout/GridLayout.module.css b/components/layout/GridLayout.module.css new file mode 100644 index 00000000..f17c195e --- /dev/null +++ b/components/layout/GridLayout.module.css @@ -0,0 +1,40 @@ +.grid { + display: flex; + flex-direction: column; +} + +.col { + display: flex; + flex-direction: column; +} + +.row { + border-top: 1px solid var(--gray300); + min-height: 430px; +} + +.row > .col { + border-left: 1px solid var(--gray300); + padding: 20px; +} + +.row > .col:first-child { + border-left: 0; + padding-left: 0; +} + +.row > .col:last-child { + padding-right: 0; +} + +@media only screen and (max-width: 992px) { + .row { + border: 0; + } + + .row > .col { + border-top: 1px solid var(--gray300); + border-left: 0; + padding: 0; + } +} diff --git a/components/layout/Header.js b/components/layout/Header.js index f275bda3..cc8baae3 100644 --- a/components/layout/Header.js +++ b/components/layout/Header.js @@ -3,40 +3,47 @@ import { FormattedMessage } from 'react-intl'; import { useSelector } from 'react-redux'; import classNames from 'classnames'; import Link from 'components/common/Link'; -import UserButton from '../common/UserButton'; -import Icon from '../common/Icon'; +import Icon from 'components/common/Icon'; +import LanguageButton from 'components/settings/LanguageButton'; +import ThemeButton from 'components/settings/ThemeButton'; +import UpdateNotice from 'components/common/UpdateNotice'; +import UserButton from 'components/settings/UserButton'; import Logo from 'assets/logo.svg'; import styles from './Header.module.css'; -import LanguageButton from '../common/LanguageButton'; export default function Header() { const user = useSelector(state => state.user); return (
+ {user?.is_admin && }
-
+
} size="large" className={styles.logo} /> umami
-
-
- {user ? ( - <> - - - - - - - - - - ) : ( - - )} +
+ {user && ( +
+ + + + + + + + + +
+ )} +
+
+
+ + + {user && }
diff --git a/components/layout/Header.module.css b/components/layout/Header.module.css index 6853eeda..b7fdc62c 100644 --- a/components/layout/Header.module.css +++ b/components/layout/Header.module.css @@ -5,6 +5,9 @@ .title { font-size: var(--font-size-large); + display: flex; + align-items: center; + line-height: 1.4; } .logo { @@ -13,18 +16,34 @@ .nav { display: flex; - justify-content: flex-end; + justify-content: center; align-items: center; font-size: var(--font-size-normal); font-weight: 600; } -.nav > * { +.nav a + a { margin-left: 40px; } -@media only screen and (max-width: 768px) { +.buttons { + display: flex; + justify-content: flex-end; + align-items: center; +} + +@media only screen and (max-width: 992px) { .title { - text-align: center; + justify-content: center; + } + + .nav { + font-size: var(--font-size-large); + justify-content: center; + padding: 20px 0; + } + + .buttons { + justify-content: center; } } diff --git a/components/layout/Layout.js b/components/layout/Layout.js index 021745cc..b16a0717 100644 --- a/components/layout/Layout.js +++ b/components/layout/Layout.js @@ -16,8 +16,8 @@ export default function Layout({ title, children, header = true, footer = true } {header &&
}
{children}
-
{footer &&