diff --git a/assets/css/loader.css b/assets/css/loader.css index ad6eb9c66644..38af0f968718 100644 --- a/assets/css/loader.css +++ b/assets/css/loader.css @@ -4,6 +4,11 @@ animation: loaderFadein .2s ease-in; } +.loading.sm { + width: 25px; + height: 25px; +} + .loading div { display: inline-block; width: 50px; @@ -15,6 +20,12 @@ -webkit-animation: spin 1s ease-in-out infinite; } +.loading.sm div { + width: 25px; + height: 25px; +} + + @keyframes spin { to { -webkit-transform: rotate(360deg); } } diff --git a/assets/js/dashboard/historical.js b/assets/js/dashboard/historical.js index b0a25dd9729b..55124fbbbb2d 100644 --- a/assets/js/dashboard/historical.js +++ b/assets/js/dashboard/historical.js @@ -1,6 +1,7 @@ import React from 'react'; import Datepicker from './datepicker' +import SiteSwitcher from './site-switcher' import Filters from './filters' import CurrentVisitors from './stats/current-visitors' import VisitorGraph from './stats/visitor-graph' @@ -26,7 +27,7 @@ export default class Historical extends React.Component {
-

Analytics for {this.props.site.domain}

+
diff --git a/assets/js/dashboard/index.js b/assets/js/dashboard/index.js index 5cdcea91d29e..5b0e37e86e80 100644 --- a/assets/js/dashboard/index.js +++ b/assets/js/dashboard/index.js @@ -44,9 +44,9 @@ class Dashboard extends React.Component { render() { if (this.state.query.period === 'realtime') { - return + return } else { - return + return } } } diff --git a/assets/js/dashboard/mount.js b/assets/js/dashboard/mount.js index 5799b4e180a7..8357e707f45b 100644 --- a/assets/js/dashboard/mount.js +++ b/assets/js/dashboard/mount.js @@ -14,9 +14,11 @@ if (container) { hasGoals: container.dataset.hasGoals === 'true' } + const loggedIn = container.dataset.loggedIn === 'true' + const app = ( - + ) diff --git a/assets/js/dashboard/realtime.js b/assets/js/dashboard/realtime.js index 927e0c8f0154..5f3a3e912f49 100644 --- a/assets/js/dashboard/realtime.js +++ b/assets/js/dashboard/realtime.js @@ -1,6 +1,7 @@ import React from 'react'; import Datepicker from './datepicker' +import SiteSwitcher from './site-switcher' import Filters from './filters' import CurrentVisitors from './stats/current-visitors' import VisitorGraph from './stats/visitor-graph' @@ -26,7 +27,7 @@ export default class Stats extends React.Component {
-

Analytics for {this.props.site.domain}

+
diff --git a/assets/js/dashboard/router.js b/assets/js/dashboard/router.js index 56cb32e8acc4..be328b89400a 100644 --- a/assets/js/dashboard/router.js +++ b/assets/js/dashboard/router.js @@ -21,12 +21,12 @@ function ScrollToTop() { return null; } -export default function Router({site}) { +export default function Router({site, loggedIn}) { return ( - + diff --git a/assets/js/dashboard/site-switcher.js b/assets/js/dashboard/site-switcher.js new file mode 100644 index 000000000000..5a1b9bfe9e02 --- /dev/null +++ b/assets/js/dashboard/site-switcher.js @@ -0,0 +1,120 @@ +import React from 'react'; +import Transition from "../transition.js"; + +export default class SiteSwitcher extends React.Component { + constructor() { + super() + this.handleClick = this.handleClick.bind(this) + this.state = { + open: false, + sites: null, + error: null, + loading: true + } + } + + componentDidMount() { + document.addEventListener('mousedown', this.handleClick, false); + } + + componentWillUnmount() { + document.removeEventListener('mousedown', this.handleClick, false); + } + + handleClick(e) { + if (this.dropDownNode && this.dropDownNode.contains(e.target)) return; + if (!this.state.open) return; + + this.setState({open: false}) + } + + toggle() { + if (!this.props.loggedIn) return; + + this.setState({open: !this.state.open}) + + if (!this.state.sites) { + fetch('/api/sites') + .then( response => { + if (!response.ok) { throw response } + return response.json() + }) + .then((sites) => this.setState({loading: false, sites: sites})) + .catch((e) => this.setState({loading: false, error: e})) + } + } + + renderSiteLink(domain) { + const extraClass = domain === this.props.site.domain ? 'font-medium text-gray-900' : 'hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:bg-gray-100 focus:text-gray-900' + return ( + + + {domain} + + ) + } + + renderDropdown() { + if (this.state.loading) { + return
+ } else if (this.state.error) { + return
Something went wrong, try again
+ } else { + return ( + + +
+
+ { this.state.sites.map(this.renderSiteLink.bind(this)) } +
+
+ ) + } + } + + renderArrow() { + if (this.props.loggedIn) { + return ( + + + + ) + } + } + + render() { + const hoverClass = this.props.loggedIn ? 'hover:text-gray-500 focus:border-blue-300 focus:shadow-outline-blue ' : 'cursor-default' + + return ( +
+ + + +
this.dropDownNode = node} > +
+ { this.renderDropdown() } +
+
+
+
+ ) + } +} diff --git a/assets/js/dashboard/stats/current-visitors.js b/assets/js/dashboard/stats/current-visitors.js index 5543398a761c..ed70b3698b85 100644 --- a/assets/js/dashboard/stats/current-visitors.js +++ b/assets/js/dashboard/stats/current-visitors.js @@ -25,7 +25,7 @@ export default class CurrentVisitors extends React.Component { const { currentVisitors } = this.state; if (currentVisitors !== null) { return ( - + diff --git a/assets/js/transition.js b/assets/js/transition.js new file mode 100644 index 000000000000..963f761b44a1 --- /dev/null +++ b/assets/js/transition.js @@ -0,0 +1,107 @@ +// https://gist.github.com/adamwathan/3b9f3ad1a285a2d1b482769aeb862467 +import { CSSTransition as ReactCSSTransition } from 'react-transition-group' +import React, { useRef, useEffect, useContext } from 'react' + +const TransitionContext = React.createContext({ + parent: {}, +}) + +function useIsInitialRender() { + const isInitialRender = useRef(true) + useEffect(() => { + isInitialRender.current = false + }, []) + return isInitialRender.current +} + +function CSSTransition({ + show, + enter = '', + enterFrom = '', + enterTo = '', + leave = '', + leaveFrom = '', + leaveTo = '', + appear, + children, +}) { + const enterClasses = enter.split(' ').filter((s) => s.length) + const enterFromClasses = enterFrom.split(' ').filter((s) => s.length) + const enterToClasses = enterTo.split(' ').filter((s) => s.length) + const leaveClasses = leave.split(' ').filter((s) => s.length) + const leaveFromClasses = leaveFrom.split(' ').filter((s) => s.length) + const leaveToClasses = leaveTo.split(' ').filter((s) => s.length) + + function addClasses(node, classes) { + classes.length && node.classList.add(...classes) + } + + function removeClasses(node, classes) { + classes.length && node.classList.remove(...classes) + } + + return ( + { + node.addEventListener('transitionend', done, false) + }} + onEnter={(node) => { + addClasses(node, [...enterClasses, ...enterFromClasses]) + }} + onEntering={(node) => { + removeClasses(node, enterFromClasses) + addClasses(node, enterToClasses) + }} + onEntered={(node) => { + removeClasses(node, [...enterToClasses, ...enterClasses]) + }} + onExit={(node) => { + addClasses(node, [...leaveClasses, ...leaveFromClasses]) + }} + onExiting={(node) => { + removeClasses(node, leaveFromClasses) + addClasses(node, leaveToClasses) + }} + onExited={(node) => { + removeClasses(node, [...leaveToClasses, ...leaveClasses]) + }} + > + {children} + + ) +} + +function Transition({ show, appear, ...rest }) { + const { parent } = useContext(TransitionContext) + const isInitialRender = useIsInitialRender() + const isChild = show === undefined + + if (isChild) { + return ( + + ) + } + + return ( + + + + ) +} + +export default Transition diff --git a/assets/package-lock.json b/assets/package-lock.json index 0765296a43e9..773b3016e87f 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -1356,7 +1356,7 @@ }, "util": { "version": "0.10.3", - "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz", + "resolved": "http://registry.npmjs.org/util/-/util-0.10.3.tgz", "integrity": "sha1-evsa/lCAUkZInj23/g7TeTNqwPk=", "requires": { "inherits": "2.0.1" @@ -2817,6 +2817,11 @@ } } }, + "csstype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.2.tgz", + "integrity": "sha512-ofovWglpqoqbfLNOTBNZLSbMuGrblAf1efvvArGKOZMBrIoJeu5UsAipQolkijtyQx5MtAzT/J9IHj/CEY1mJw==" + }, "cyclist": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-0.2.2.tgz", @@ -2970,6 +2975,25 @@ "path-type": "^3.0.0" } }, + "dom-helpers": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.0.tgz", + "integrity": "sha512-Ru5o9+V8CpunKnz5LGgWXkmrH/20cGKwcHwS4m73zIvs54CN9epEmT/HLqFJW3kXpakAFkEdzgy1hzlJe3E4OQ==", + "requires": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.11.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.11.2.tgz", + "integrity": "sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw==", + "requires": { + "regenerator-runtime": "^0.13.4" + } + } + } + }, "dom-serializer": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz", @@ -4255,7 +4279,7 @@ "dependencies": { "json5": { "version": "0.5.1", - "resolved": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz", + "resolved": "http://registry.npmjs.org/json5/-/json5-0.5.1.tgz", "integrity": "sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE=" } } @@ -6779,7 +6803,7 @@ }, "pretty-hrtime": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz", + "resolved": "http://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz", "integrity": "sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=" }, "private": { @@ -7005,6 +7029,17 @@ "tiny-warning": "^1.0.0" } }, + "react-transition-group": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz", + "integrity": "sha512-Djqr7OQ2aPUiYurhPalTrVy9ddmFCCzwhqQmtN+J3+3DzLO209Fdr70QrN8Z3DsglWql6iY1lDWAfpFiBtuKGw==", + "requires": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + } + }, "readable-stream": { "version": "2.3.6", "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", @@ -7111,7 +7146,7 @@ "dependencies": { "jsesc": { "version": "0.5.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "resolved": "http://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=" } } @@ -7752,7 +7787,7 @@ }, "strip-eof": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "resolved": "http://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=" }, "stylehacks": { diff --git a/assets/package.json b/assets/package.json index b2608ff935a9..001e41ee9953 100644 --- a/assets/package.json +++ b/assets/package.json @@ -28,6 +28,7 @@ "react-flatpickr": "^3.10.0", "react-flip-move": "^3.0.4", "react-router-dom": "^5.1.2", + "react-transition-group": "^4.4.1", "tailwindcss": "^1.3.1", "uglifyjs-webpack-plugin": "^2.2.0", "url-search-params-polyfill": "^7.0.1", diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js index d9f49d7bc89a..94030cdba182 100644 --- a/assets/tailwind.config.js +++ b/assets/tailwind.config.js @@ -14,7 +14,8 @@ module.exports = { }, }, variants: { - textColor: ['responsive', 'hover', 'focus', 'group-hover'] + textColor: ['responsive', 'hover', 'focus', 'group-hover'], + display: ['responsive', 'hover', 'focus', 'group-hover'] }, corePlugins: {}, plugins: [ diff --git a/lib/plausible_web/controllers/api/internal_controller.ex b/lib/plausible_web/controllers/api/internal_controller.ex index 6cefc340c164..b5b5285eaa8f 100644 --- a/lib/plausible_web/controllers/api/internal_controller.ex +++ b/lib/plausible_web/controllers/api/internal_controller.ex @@ -10,4 +10,9 @@ defmodule PlausibleWeb.Api.InternalController do json(conn, "WAITING") end end + + def sites(conn, _) do + user = Repo.preload(conn.assigns[:current_user], :sites) + json(conn, Enum.map(user.sites, &(&1.domain))) + end end diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex index e7738fe999e5..8f532e552dfd 100644 --- a/lib/plausible_web/router.ex +++ b/lib/plausible_web/router.ex @@ -68,6 +68,7 @@ defmodule PlausibleWeb.Router do post "/paddle/webhook", Api.PaddleController, :webhook get "/:domain/status", Api.InternalController, :domain_status + get "/sites", Api.InternalController, :sites end scope "/", PlausibleWeb do diff --git a/lib/plausible_web/templates/stats/stats.html.eex b/lib/plausible_web/templates/stats/stats.html.eex index cdf7c6805ccf..7e1301829886 100644 --- a/lib/plausible_web/templates/stats/stats.html.eex +++ b/lib/plausible_web/templates/stats/stats.html.eex @@ -5,7 +5,7 @@
<% end %>
-
+
<%= if !@conn.assigns[:current_user] && @conn.assigns[:demo] do %>
diff --git a/test/plausible_web/controllers/api/internal_controller_test.exs b/test/plausible_web/controllers/api/internal_controller_test.exs index 9c335191dd73..428128c0d6b2 100644 --- a/test/plausible_web/controllers/api/internal_controller_test.exs +++ b/test/plausible_web/controllers/api/internal_controller_test.exs @@ -3,7 +3,7 @@ defmodule PlausibleWeb.Api.InternalControllerTest do use Plausible.Repo import Plausible.TestUtils - describe "GET /:domain/status" do + describe "GET /api/:domain/status" do setup [:create_user, :log_in] test "is WAITING when site has no pageviews", %{conn: conn, user: user} do @@ -22,4 +22,16 @@ defmodule PlausibleWeb.Api.InternalControllerTest do assert json_response(conn, 200) == "READY" end end + + describe "GET /api/status" do + setup [:create_user, :log_in] + + test "returns a list of site domains for the current user", %{conn: conn, user: user} do + site = insert(:site, members: [user]) + site2 = insert(:site, members: [user]) + conn = get(conn, "/api/sites") + + assert json_response(conn, 200) == [site.domain, site2.domain] + end + end end