diff --git a/assets/redo.svg b/assets/redo.svg new file mode 100644 index 00000000..4544eb1d --- /dev/null +++ b/assets/redo.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 00000000..c528bcdd --- /dev/null +++ b/assets/times.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/components/WebsiteDetails.js b/components/WebsiteDetails.js index 60367050..bd8025ee 100644 --- a/components/WebsiteDetails.js +++ b/components/WebsiteDetails.js @@ -1,13 +1,10 @@ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import classNames from 'classnames'; import WebsiteChart from 'components/metrics/WebsiteChart'; import WorldMap from 'components/common/WorldMap'; import Page from 'components/layout/Page'; -import WebsiteHeader from 'components/metrics/WebsiteHeader'; import MenuLayout from 'components/layout/MenuLayout'; import Button from 'components/common/Button'; -import { getDateRange } from 'lib/date'; -import { get } from 'lib/web'; import Arrow from 'assets/arrow-right.svg'; import styles from './WebsiteDetails.module.css'; import PagesTable from './metrics/PagesTable'; @@ -18,15 +15,15 @@ import DevicesTable from './metrics/DevicesTable'; import CountriesTable from './metrics/CountriesTable'; import EventsTable from './metrics/EventsTable'; import EventsChart from './metrics/EventsChart'; +import useFetch from 'hooks/useFetch'; +import Loading from 'components/common/Loading'; -export default function WebsiteDetails({ websiteId, defaultDateRange = '7day' }) { - const [data, setData] = useState(); +export default function WebsiteDetails({ websiteId }) { + const { data } = useFetch(`/api/website/${websiteId}`); const [chartLoaded, setChartLoaded] = useState(false); const [countryData, setCountryData] = useState(); const [eventsData, setEventsData] = useState(); - const [dateRange, setDateRange] = useState(getDateRange(defaultDateRange)); const [expand, setExpand] = useState(); - const { startDate, endDate, unit } = dateRange; const BackButton = () => ( - )} + + + {showLink && ( + + )} + ); } diff --git a/components/settings/AccountSettings.js b/components/settings/AccountSettings.js index e0b70698..cdf55988 100644 --- a/components/settings/AccountSettings.js +++ b/components/settings/AccountSettings.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import classNames from 'classnames'; import PageHeader from 'components/layout/PageHeader'; import Button from 'components/common/Button'; @@ -7,20 +7,22 @@ import Table from 'components/common/Table'; import Modal from 'components/common/Modal'; import AccountEditForm from 'components/forms/AccountEditForm'; import ButtonLayout from 'components/layout/ButtonLayout'; +import DeleteForm from 'components/forms/DeleteForm'; +import useFetch from 'hooks/useFetch'; import Pen from 'assets/pen.svg'; import Plus from 'assets/plus.svg'; import Trash from 'assets/trash.svg'; import Check from 'assets/check.svg'; -import { get } from 'lib/web'; import styles from './AccountSettings.module.css'; -import DeleteForm from '../forms/DeleteForm'; +import Toast from '../common/Toast'; export default function AccountSettings() { - const [data, setData] = useState(); const [addAccount, setAddAccount] = useState(); const [editAccount, setEditAccount] = useState(); const [deleteAccount, setDeleteAccount] = useState(); const [saved, setSaved] = useState(0); + const [message, setMessage] = useState(); + const { data } = useFetch(`/api/accounts`, {}, { update: [saved] }); const Checkmark = ({ is_admin }) => (is_admin ? } size="medium" /> : null); @@ -52,6 +54,7 @@ export default function AccountSettings() { function handleSave() { setSaved(state => state + 1); + setMessage('Saved successfully.'); handleClose(); } @@ -61,14 +64,6 @@ export default function AccountSettings() { setDeleteAccount(null); } - async function loadData() { - setData(await get(`/api/accounts`)); - } - - useEffect(() => { - loadData(); - }, [saved]); - if (!data) { return null; } @@ -105,6 +100,7 @@ export default function AccountSettings() { /> )} + {message && setMessage(null)} />} ); } diff --git a/components/settings/ProfileSettings.js b/components/settings/ProfileSettings.js index 1aac00af..3c873d73 100644 --- a/components/settings/ProfileSettings.js +++ b/components/settings/ProfileSettings.js @@ -5,12 +5,19 @@ import Button from 'components/common/Button'; import ChangePasswordForm from '../forms/ChangePasswordForm'; import Modal from 'components/common/Modal'; import Dots from 'assets/ellipsis-h.svg'; +import Toast from '../common/Toast'; export default function ProfileSettings() { const user = useSelector(state => state.user); const [changePassword, setChangePassword] = useState(false); + const [message, setMessage] = useState(); const { user_id } = user; + function handleSave() { + setChangePassword(false); + setMessage('Saved successfully.'); + } + return ( <> @@ -27,11 +34,12 @@ export default function ProfileSettings() { setChangePassword(false)} + onSave={handleSave} onClose={() => setChangePassword(false)} /> )} + {message && setMessage(null)} />} ); } diff --git a/components/settings/WebsiteSettings.js b/components/settings/WebsiteSettings.js index f759c664..0b6d9a88 100644 --- a/components/settings/WebsiteSettings.js +++ b/components/settings/WebsiteSettings.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import classNames from 'classnames'; import Table from 'components/common/Table'; import Button from 'components/common/Button'; @@ -15,17 +15,19 @@ import Trash from 'assets/trash.svg'; import Plus from 'assets/plus.svg'; import Code from 'assets/code.svg'; import Link from 'assets/link.svg'; -import { get } from 'lib/web'; import styles from './WebsiteSettings.module.css'; +import useFetch from '../../hooks/useFetch'; +import Toast from '../common/Toast'; export default function WebsiteSettings() { - const [data, setData] = useState(); const [editWebsite, setEditWebsite] = useState(); const [deleteWebsite, setDeleteWebsite] = useState(); const [addWebsite, setAddWebsite] = useState(); const [showCode, setShowCode] = useState(); const [showUrl, setShowUrl] = useState(); const [saved, setSaved] = useState(0); + const [message, setMessage] = useState(); + const { data } = useFetch(`/api/websites`, {}, { update: [saved] }); const Buttons = row => ( @@ -66,6 +68,7 @@ export default function WebsiteSettings() { function handleSave() { setSaved(state => state + 1); + setMessage('Saved successfully.'); handleClose(); } @@ -77,14 +80,6 @@ export default function WebsiteSettings() { setShowUrl(null); } - async function loadData() { - setData(await get(`/api/websites`)); - } - - useEffect(() => { - loadData(); - }, [saved]); - if (!data) { return null; } @@ -135,6 +130,7 @@ export default function WebsiteSettings() { )} + {message && setMessage(null)} />} ); } diff --git a/hooks/useDateRange.js b/hooks/useDateRange.js new file mode 100644 index 00000000..99e49dfd --- /dev/null +++ b/hooks/useDateRange.js @@ -0,0 +1,8 @@ +import { useSelector } from 'react-redux'; +import { getDateRange } from 'lib/date'; + +export function useDateRange(websiteId, defaultDateRange = '7day') { + return useSelector( + state => state.websites[websiteId]?.dateRange || getDateRange(defaultDateRange), + ); +} diff --git a/hooks/useFetch.js b/hooks/useFetch.js new file mode 100644 index 00000000..2e1ad247 --- /dev/null +++ b/hooks/useFetch.js @@ -0,0 +1,39 @@ +import { useState, useEffect } from 'react'; +import { get } from 'lib/web'; + +export default function useFetch(url, params = {}, options = {}) { + const [data, setData] = useState(); + const [error, setError] = useState(); + const keys = Object.keys(params) + .sort() + .map(key => params[key]); + const { update = [], onDataLoad = () => {} } = options; + + async function loadData() { + try { + setError(null); + const data = await get(url, params); + setData(data); + onDataLoad(data); + } catch (e) { + console.error(e); + setError(e); + } + } + + useEffect(() => { + if (url) { + const { interval, delay = 0 } = options; + + setTimeout(() => loadData(), delay); + + const id = interval ? setInterval(() => loadData(), interval) : null; + + return () => { + clearInterval(id); + }; + } + }, [url, ...keys, ...update]); + + return { data, error, loadData }; +} diff --git a/lib/constants.js b/lib/constants.js index 1c63ae18..47592a6c 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -1,39 +1,30 @@ export const AUTH_COOKIE_NAME = 'umami.auth'; +export const POSTGRESQL = 'postgresql'; +export const MYSQL = 'mysql'; + +export const MYSQL_DATE_FORMATS = { + minute: '%Y-%m-%d %H:%i:00', + hour: '%Y-%m-%d %H:00:00', + day: '%Y-%m-%d', + month: '%Y-%m-01', + year: '%Y-01-01', +}; + +export const POSTGRESQL_DATE_FORMATS = { + minute: 'YYYY-MM-DD HH24:MI:00', + hour: 'YYYY-MM-DD HH24:00:00', + day: 'YYYY-MM-DD', + month: 'YYYY-MM-01', + year: 'YYYY-01-01', +}; + export const DOMAIN_REGEX = /((?=[a-z0-9-]{1,63}\.)(xn--)?[a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,63}/; export const DESKTOP_SCREEN_WIDTH = 1920; export const LAPTOP_SCREEN_WIDTH = 1024; export const MOBILE_SCREEN_WIDTH = 479; -export const OPERATING_SYSTEMS = [ - 'iOS', - 'Android OS', - 'BlackBerry OS', - 'Windows Mobile', - 'Amazon OS', - 'Windows 3.11', - 'Windows 95', - 'Windows 98', - 'Windows 2000', - 'Windows XP', - 'Windows Server 2003', - 'Windows Vista', - 'Windows 7', - 'Windows 8', - 'Windows 8.1', - 'Windows 10', - 'Windows ME', - 'Open BSD', - 'Sun OS', - 'Linux', - 'Mac OS', - 'QNX', - 'BeOS', - 'OS/2', - 'Chrome OS', -]; - export const DESKTOP_OS = [ 'Windows 3.11', 'Windows 95', diff --git a/lib/queries.js b/lib/queries.js index 4fe46ee2..204b545b 100644 --- a/lib/queries.js +++ b/lib/queries.js @@ -1,25 +1,7 @@ import moment from 'moment-timezone'; import prisma, { runQuery } from 'lib/db'; import { subMinutes } from 'date-fns'; - -const POSTGRESQL = 'postgresql'; -const MYSQL = 'mysql'; - -const MYSQL_DATE_FORMATS = { - minute: '%Y-%m-%d %H:%i:00', - hour: '%Y-%m-%d %H:00:00', - day: '%Y-%m-%d', - month: '%Y-%m-01', - year: '%Y-01-01', -}; - -const POSTGRESQL_DATE_FORMATS = { - minute: 'YYYY-MM-DD HH24:MI:00', - hour: 'YYYY-MM-DD HH24:00:00', - day: 'YYYY-MM-DD', - month: 'YYYY-MM-01', - year: 'YYYY-01-01', -}; +import { MYSQL, POSTGRESQL, MYSQL_DATE_FORMATS, POSTGRESQL_DATE_FORMATS } from 'lib/constants'; export function getDatabase() { return ( @@ -317,7 +299,7 @@ export function getMetrics(website_id, start_at, end_at) { ); } - return Promise.resolve({}); + return Promise.reject(new Error('Unknown database.')); } export function getPageviews( @@ -364,7 +346,7 @@ export function getPageviews( ); } - return Promise.resolve([]); + return Promise.reject(new Error('Unknown database.')); } export function getRankings(website_id, start_at, end_at, type, table, domain) { @@ -406,7 +388,7 @@ export function getRankings(website_id, start_at, end_at, type, table, domain) { ); } - return Promise.resolve([]); + return Promise.reject(new Error('Unknown database.')); } export function getActiveVisitors(website_id) { @@ -439,7 +421,7 @@ export function getActiveVisitors(website_id) { ); } - return Promise.resolve([]); + return Promise.reject(new Error('Unknown database.')); } export function getEvents(website_id, start_at, end_at, timezone = 'utc', unit = 'day') { @@ -483,5 +465,5 @@ export function getEvents(website_id, start_at, end_at, timezone = 'utc', unit = ); } - return Promise.resolve([]); + return Promise.reject(new Error('Unknown database.')); } diff --git a/lib/url.js b/lib/url.js index e29243fb..0eb4a04a 100644 --- a/lib/url.js +++ b/lib/url.js @@ -1,11 +1,11 @@ export function removeTrailingSlash(url) { - return url.length > 1 && url.endsWith('/') ? url.slice(0, -1) : url; + return url && url.length > 1 && url.endsWith('/') ? url.slice(0, -1) : url; } export function getDomainName(str) { try { return new URL(str).hostname; - } catch { + } catch (e) { return str; } } diff --git a/package.json b/package.json index 17b79794..a6cca6bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "umami", - "version": "0.19.0", + "version": "0.20.0", "description": "A simple, fast, website analytics alternative to Google Analytics. ", "author": "Mike Cao ", "license": "MIT", @@ -30,8 +30,7 @@ ], "**/*.css": [ "stylelint --fix", - "prettier --write", - "eslint" + "prettier --write" ] }, "husky": { @@ -53,6 +52,7 @@ "dotenv": "^8.2.0", "formik": "^2.1.5", "geolite2-redist": "^1.0.7", + "immer": "^7.0.8", "is-localhost-ip": "^1.4.0", "jose": "^1.28.0", "maxmind": "^4.1.4", diff --git a/pages/api/website/[id]/metrics.js b/pages/api/website/[id]/metrics.js index 82ab393e..4b0d71e1 100644 --- a/pages/api/website/[id]/metrics.js +++ b/pages/api/website/[id]/metrics.js @@ -3,11 +3,14 @@ import { ok } from 'lib/response'; export default async (req, res) => { const { id, start_at, end_at } = req.query; + const websiteId = +id; + const startDate = new Date(+start_at); + const endDate = new Date(+end_at); - const metrics = await getMetrics(+id, new Date(+start_at), new Date(+end_at)); + const metrics = await getMetrics(websiteId, startDate, endDate); const stats = Object.keys(metrics[0]).reduce((obj, key) => { - obj[key] = +metrics[0][key]; + obj[key] = Number(metrics[0][key]) || 0; return obj; }, {}); diff --git a/pages/share/[...id].js b/pages/share/[...id].js index 21005a53..1e0897a9 100644 --- a/pages/share/[...id].js +++ b/pages/share/[...id].js @@ -1,46 +1,22 @@ -import React, { useState, useEffect } from 'react'; +import React from 'react'; import { useRouter } from 'next/router'; import Layout from 'components/layout/Layout'; import WebsiteDetails from 'components/WebsiteDetails'; -import NotFound from 'pages/404'; -import { get } from 'lib/web'; +import useFetch from 'hooks/useFetch'; export default function SharePage() { - const [loading, setLoading] = useState(true); - const [websiteId, setWebsiteId] = useState(); - const [notFound, setNotFound] = useState(false); const router = useRouter(); const { id } = router.query; + const shareId = id?.[0]; + const { data } = useFetch(shareId ? `/api/share/${shareId}` : null); - async function loadData() { - const website = await get(`/api/share/${id?.[0]}`); - - if (website) { - setWebsiteId(website.website_id); - } else if (typeof window !== 'undefined') { - setNotFound(true); - } - } - - useEffect(() => { - if (id) { - loadData().finally(() => { - setLoading(false); - }); - } else { - setLoading(false); - } - }, [id]); - - if (loading) return null; - - if (!id || notFound) { - return ; + if (!data) { + return null; } return ( - + ); } diff --git a/redux/actions/websites.js b/redux/actions/websites.js new file mode 100644 index 00000000..d619ddb7 --- /dev/null +++ b/redux/actions/websites.js @@ -0,0 +1,33 @@ +import { createSlice } from '@reduxjs/toolkit'; +import produce from 'immer'; + +const websites = createSlice({ + name: 'user', + initialState: {}, + reducers: { + updateWebsites(state, action) { + state = action.payload; + return state; + }, + }, +}); + +export const { updateWebsites } = websites.actions; + +export default websites.reducer; + +export function setDateRange(websiteId, dateRange) { + return (dispatch, getState) => { + const state = getState(); + let { websites = {} } = state; + + websites = produce(websites, draft => { + if (!draft[websiteId]) { + draft[websiteId] = {}; + } + draft[websiteId].dateRange = { ...dateRange, modified: Date.now() }; + }); + + return dispatch(updateWebsites(websites)); + }; +} diff --git a/redux/reducers.js b/redux/reducers.js index f751726f..68a080f7 100644 --- a/redux/reducers.js +++ b/redux/reducers.js @@ -1,4 +1,5 @@ import { combineReducers } from 'redux'; import user from './actions/user'; +import websites from './actions/websites'; -export default combineReducers({ user }); +export default combineReducers({ user, websites }); diff --git a/scripts/copy-db-schema.js b/scripts/copy-db-schema.js index f28c621e..2d480545 100644 --- a/scripts/copy-db-schema.js +++ b/scripts/copy-db-schema.js @@ -5,8 +5,8 @@ const path = require('path'); const databaseType = process.env.DATABASE_TYPE || (process.env.DATABASE_URL && process.env.DATABASE_URL.split(':')[0]); -if (!databaseType) { - throw new Error('Database schema not specified'); +if (!databaseType || !['mysql', 'postgresql'].includes(databaseType)) { + throw new Error('Missing or invalid database'); } console.log(`Database schema detected: ${databaseType}`); diff --git a/tracker/index.js b/tracker/index.js index a30a0220..f0ad78cd 100644 --- a/tracker/index.js +++ b/tracker/index.js @@ -1,6 +1,7 @@ import 'promise-polyfill/src/polyfill'; import 'unfetch/polyfill'; import { post, hook, doNotTrack } from '../lib/web'; +import { removeTrailingSlash } from '../lib/url'; (window => { const { @@ -17,7 +18,10 @@ import { post, hook, doNotTrack } from '../lib/web'; if (!script || (__DNT__ && doNotTrack())) return; const website = script.getAttribute('data-website-id'); - const hostUrl = new URL(script.src).href.split('/').slice(0, -1).join('/'); + const hostUrl = script.getAttribute('data-host-url'); + const root = hostUrl + ? removeTrailingSlash(hostUrl) + : new URL(script.src).href.split('/').slice(0, -1).join('/'); const screen = `${width}x${height}`; const listeners = []; @@ -42,7 +46,7 @@ import { post, hook, doNotTrack } from '../lib/web'; }); } - return post(`${hostUrl}/api/collect`, { + return post(`${root}/api/collect`, { type, payload, }); diff --git a/yarn.lock b/yarn.lock index 588dd177..73f0d4d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4456,7 +4456,7 @@ ignore@^5.1.4, ignore@^5.1.8: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57" integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw== -immer@^7.0.3: +immer@^7.0.3, immer@^7.0.8: version "7.0.8" resolved "https://registry.yarnpkg.com/immer/-/immer-7.0.8.tgz#41dcbc5669a76500d017bef3ad0d03ce0a1d7c1e" integrity sha512-XnpIN8PXBBaOD43U8Z17qg6RQiKQYGDGGCIbz1ixmLGwBkSWwmrmx5X7d+hTtXDM8ur7m5OdLE0PiO+y5RB3pw==