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 {
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.props.site.domain}
+ {this.renderArrow()}
+
+
+
+ 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