analytics/assets/js/dashboard/site-switcher.js
Uku Taht 0bc762bfdc
Fix svg favicons (#2295)
* Overrides content-type for SVG favicons

* Organize favicon rendering

Make sure the placeholder icon is always requested from
/favicon/sources/placeholder

* Run prettier on site-switcher.js

* Yak Shave: upgrade Heroicons to 2.0

* Use HeroIcons instead of custom svg

* Update lib/plausible_web/plugs/favicon.ex

Co-authored-by: Adam Rutkowski <hq@mtod.org>

Co-authored-by: Adam Rutkowski <hq@mtod.org>
2022-10-04 13:20:51 +03:00

272 lines
8.6 KiB
JavaScript

/**
* @prettier
*/
import React from 'react'
import { Transition } from '@headlessui/react'
import { Cog8ToothIcon, ChevronDownIcon } from '@heroicons/react/20/solid'
function Favicon({ domain, className }) {
return (
<img
src={`/favicon/sources/${encodeURIComponent(domain)}`}
onError={(e) => {
e.target.onerror = null
e.target.src = '/favicon/sources/placeholder'
}}
referrerPolicy="no-referrer"
className={className}
/>
)
}
export default class SiteSwitcher extends React.Component {
constructor() {
super()
this.handleClick = this.handleClick.bind(this)
this.handleKeydown = this.handleKeydown.bind(this)
this.populateSites = this.populateSites.bind(this)
this.toggle = this.toggle.bind(this)
this.siteSwitcherButton = React.createRef()
this.state = {
open: false,
sites: null,
error: null,
loading: true
}
}
componentDidMount() {
this.populateSites()
this.siteSwitcherButton.current.addEventListener('click', this.toggle)
document.addEventListener('keydown', this.handleKeydown)
document.addEventListener('click', this.handleClick, false)
}
componentWillUnmount() {
this.siteSwitcherButton.current.removeEventListener('click', this.toggle)
document.removeEventListener('keydown', this.handleKeydown)
document.removeEventListener('click', this.handleClick, false)
}
populateSites() {
if (!this.props.loggedIn) return
fetch('/api/sites')
.then((response) => {
if (!response.ok) {
throw response
}
return response.json()
})
.then((sites) =>
this.setState({
loading: false,
sites: sites.data.map((s) => s.domain)
})
)
.catch((e) => this.setState({ loading: false, error: e }))
}
handleClick(e) {
// If this is an interaction with the dropdown menu itself, do nothing.
if (this.dropDownNode && this.dropDownNode.contains(e.target)) return
// If the dropdown is not open, do nothing.
if (!this.state.open) return
// In any other case, close it.
this.setState({ open: false })
}
handleKeydown(e) {
if (!this.props.loggedIn) return
const { site } = this.props
const { sites } = this.state
if (e.target.tagName === 'INPUT') return true
if (
e.ctrlKey ||
e.metaKey ||
e.altKey ||
e.isComposing ||
e.keyCode === 229 ||
!sites
)
return
const siteNum = parseInt(e.key)
if (
1 <= siteNum &&
siteNum <= 9 &&
siteNum <= sites.length &&
sites[siteNum - 1] !== site.domain
) {
window.location = `/${encodeURIComponent(sites[siteNum - 1])}`
}
}
toggle(e) {
/**
* React doesn't seem to prioritise its own events when events are bubbling, and is unable to stop its events from propagating to the document's (root) event listeners which are attached on the DOM.
*
* A simple trick is to hook up our own click event listener via a ref node, which allows React to manage events in this situation better between the two.
*/
e.stopPropagation()
e.preventDefault()
if (!this.props.loggedIn) return
this.setState((prevState) => ({
open: !prevState.open
}))
if (this.props.loggedIn && !this.state.sites) {
this.populateSites()
}
}
renderSiteLink(domain, index) {
const extraClass =
domain === this.props.site.domain
? 'font-medium text-gray-900 dark:text-gray-100 cursor-default font-bold'
: 'hover:bg-gray-100 dark:hover:bg-gray-900 hover:text-gray-900 dark:hover:text-gray-100 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-900 focus:text-gray-900 dark:focus:text-gray-100'
const showHotkey = !this.props.loggedIn
return (
<a
href={
domain === this.props.site.domain
? null
: `/${encodeURIComponent(domain)}`
}
key={domain}
className={`flex items-center justify-between truncate px-4 py-2 md:text-sm leading-5 text-gray-700 dark:text-gray-300 ${extraClass}`}
>
<span>
<Favicon
domain={domain}
className="inline w-4 mr-2 align-middle"
></Favicon>
<span className="truncate inline-block align-middle max-w-3xs pr-2">
{domain}
</span>
</span>
{showHotkey ? index < 9 && <span>{index + 1}</span> : null}
</a>
)
}
renderSettingsLink() {
if (
['owner', 'admin', 'super_admin'].includes(this.props.currentUserRole)
) {
return (
<React.Fragment>
<div className="py-1">
<a
href={`/${encodeURIComponent(this.props.site.domain)}/settings`}
className="group flex items-center px-4 py-2 md:text-sm leading-5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-900 hover:text-gray-900 dark:hover:text-gray-100 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-900 focus:text-gray-900 dark:focus:text-gray-100"
role="menuitem"
>
<Cog8ToothIcon className="mr-2 h-4 w-4 text-gray-500 dark:text-gray-200 group-hover:text-gray-600 dark:group-hover:text-gray-400 group-focus:text-gray-500 dark:group-focus:text-gray-200" />
Site settings
</a>
</div>
<div className="border-t border-gray-200 dark:border-gray-500"></div>
</React.Fragment>
)
}
}
/**
* Render a dropdown regardless of whether the user is logged in or not. In case they are not logged in (such as in an embed), the dropdown merely contains the current domain name.
*/
renderDropdown() {
if (this.state.loading) {
return (
<div className="px-4 py-6">
<div className="loading sm mx-auto">
<div></div>
</div>
</div>
)
} else if (this.state.error) {
return (
<div className="mx-auto px-4 py-6 dark:text-gray-100">
Something went wrong, try again
</div>
)
} else if (!this.props.loggedIn) {
return (
<React.Fragment>
<div className="py-1">
{[this.props.site.domain].map(this.renderSiteLink.bind(this))}
</div>
</React.Fragment>
)
} else {
return (
<React.Fragment>
{this.renderSettingsLink()}
<div className="py-1">
{this.state.sites.map(this.renderSiteLink.bind(this))}
</div>
<div className="border-t border-gray-200 dark:border-gray-500"></div>
<a
href="/sites"
className="flex px-4 py-2 md:text-sm leading-5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-900 hover:text-gray-900 dark:hover:text-gray-100 focus:outline-none focus:bg-gray-100 dark:focus:bg-gray-900 focus:text-gray-900 dark:focus:text-gray-100"
role="menuitem"
>
View all
</a>
</React.Fragment>
)
}
}
render() {
const hoverClass = this.props.loggedIn
? 'hover:text-gray-500 dark:hover:text-gray-200 focus:border-blue-300 focus:ring '
: 'cursor-default'
return (
<div className="relative inline-block text-left mr-2 sm:mr-4">
<button
ref={this.siteSwitcherButton}
className={`inline-flex items-center md:text-lg w-full rounded-md py-2 leading-5 font-bold text-gray-700 dark:text-gray-300 focus:outline-none transition ease-in-out duration-150 ${hoverClass}`}
>
<Favicon
domain={this.props.site.domain}
className="w-4 mr-1 md:mr-2 align-middle w-4 mr-2 align-middle"
></Favicon>
<span className="hidden sm:inline-block">
{this.props.site.domain}
</span>
{this.props.loggedIn && (
<ChevronDownIcon className="-mr-1 ml-1 md:ml-2 h-5 w-5" />
)}
</button>
<Transition
show={this.state.open}
enter="transition ease-out duration-100 transform"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75 transform"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<div
className="origin-top-left absolute left-0 mt-2 w-64 rounded-md shadow-lg"
ref={(node) => (this.dropDownNode = node)}
>
<div className="rounded-md bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5">
{this.renderDropdown()}
</div>
</div>
</Transition>
</div>
)
}
}