mirror of
https://github.com/umami-software/umami.git
synced 2025-12-06 01:18:00 +01:00
View all rankings in details.
This commit is contained in:
parent
f535dca7b9
commit
cd76cc895f
16 changed files with 472 additions and 283 deletions
|
|
@ -8,16 +8,20 @@ import { useSelector } from 'react-redux';
|
|||
|
||||
export default function Settings() {
|
||||
const user = useSelector(state => state.user);
|
||||
const [option, setOption] = useState('Websites');
|
||||
const [option, setOption] = useState(1);
|
||||
|
||||
const menuOptions = ['Websites', user.is_admin && 'Accounts', 'Profile'];
|
||||
const menuOptions = [
|
||||
{ label: 'Websites', value: 1 },
|
||||
{ label: 'Accounts', value: 2, hidden: !user.is_admin },
|
||||
{ label: 'Profile', value: 3 },
|
||||
];
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<MenuLayout menu={menuOptions} selectedOption={option} onMenuSelect={setOption}>
|
||||
{option === 'Websites' && <WebsiteSettings />}
|
||||
{option === 'Accounts' && <AccountSettings />}
|
||||
{option === 'Profile' && <ProfileSettings />}
|
||||
{option === 1 && <WebsiteSettings />}
|
||||
{option === 2 && <AccountSettings />}
|
||||
{option === 3 && <ProfileSettings />}
|
||||
</MenuLayout>
|
||||
</Page>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,16 +4,17 @@ import WebsiteChart from './charts/WebsiteChart';
|
|||
import RankingsChart from './charts/RankingsChart';
|
||||
import WorldMap from './common/WorldMap';
|
||||
import Page from './layout/Page';
|
||||
import PageHeader from './layout/PageHeader';
|
||||
import MenuLayout from './layout/MenuLayout';
|
||||
import Button from './common/Button';
|
||||
import { getDateRange } from 'lib/date';
|
||||
import { get } from 'lib/web';
|
||||
import { browserFilter, urlFilter, refFilter, deviceFilter, countryFilter } from 'lib/filters';
|
||||
import Arrow from 'assets/arrow-right.svg';
|
||||
import styles from './WebsiteDetails.module.css';
|
||||
import PageHeader from './layout/PageHeader';
|
||||
import MenuLayout from './layout/MenuLayout';
|
||||
|
||||
const pageviewClasses = 'col-md-12 col-lg-6';
|
||||
const sessionClasses = 'col-12 col-lg-4';
|
||||
const menuOptions = ['Pages', 'Referrers', 'Browsers', 'Operating system', 'Devices', 'Countries'];
|
||||
const sessionClasses = 'col-md-12 col-lg-4';
|
||||
|
||||
export default function WebsiteDetails({ websiteId, defaultDateRange = '7day' }) {
|
||||
const [data, setData] = useState();
|
||||
|
|
@ -23,6 +24,32 @@ export default function WebsiteDetails({ websiteId, defaultDateRange = '7day' })
|
|||
const [expand, setExpand] = useState();
|
||||
const { startDate, endDate } = dateRange;
|
||||
|
||||
const menuOptions = [
|
||||
{
|
||||
render: () => (
|
||||
<Button
|
||||
className={styles.backButton}
|
||||
icon={<Arrow />}
|
||||
size="xsmall"
|
||||
onClick={() => setExpand(null)}
|
||||
>
|
||||
<div>Back</div>
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{ label: 'Pages', value: 'url', filter: urlFilter },
|
||||
{ label: 'Referrers', value: 'referrer', filter: refFilter(data?.domain) },
|
||||
{ label: 'Browsers', value: 'browser', filter: browserFilter },
|
||||
{ label: 'Operating system', value: 'os' },
|
||||
{ label: 'Devices', value: 'device', filter: deviceFilter },
|
||||
{
|
||||
label: 'Countries',
|
||||
value: 'country',
|
||||
filter: countryFilter,
|
||||
onDataLoad: data => setCountryData(data),
|
||||
},
|
||||
];
|
||||
|
||||
async function loadData() {
|
||||
setData(await get(`/api/website/${websiteId}`));
|
||||
}
|
||||
|
|
@ -35,12 +62,16 @@ export default function WebsiteDetails({ websiteId, defaultDateRange = '7day' })
|
|||
setTimeout(() => setDateRange(values), 300);
|
||||
}
|
||||
|
||||
function handleExpand(title) {
|
||||
setExpand(title);
|
||||
function handleExpand(value) {
|
||||
setExpand(menuOptions.find(e => e.value === value));
|
||||
}
|
||||
|
||||
function handleMenuSelect(title) {
|
||||
setExpand(title);
|
||||
function handleMenuSelect(value) {
|
||||
setExpand(menuOptions.find(e => e.value === value));
|
||||
}
|
||||
|
||||
function getHeading(type) {
|
||||
return type === 'url' || type === 'referrer' ? 'Views' : 'Visitors';
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -77,6 +108,7 @@ export default function WebsiteDetails({ websiteId, defaultDateRange = '7day' })
|
|||
websiteId={websiteId}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
limit={10}
|
||||
dataFilter={urlFilter}
|
||||
onExpand={handleExpand}
|
||||
/>
|
||||
|
|
@ -89,6 +121,7 @@ export default function WebsiteDetails({ websiteId, defaultDateRange = '7day' })
|
|||
websiteId={websiteId}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
limit={10}
|
||||
dataFilter={refFilter(data.domain)}
|
||||
onExpand={handleExpand}
|
||||
/>
|
||||
|
|
@ -103,6 +136,7 @@ export default function WebsiteDetails({ websiteId, defaultDateRange = '7day' })
|
|||
websiteId={websiteId}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
limit={10}
|
||||
dataFilter={browserFilter}
|
||||
onExpand={handleExpand}
|
||||
/>
|
||||
|
|
@ -115,6 +149,7 @@ export default function WebsiteDetails({ websiteId, defaultDateRange = '7day' })
|
|||
websiteId={websiteId}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
limit={10}
|
||||
onExpand={handleExpand}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -126,6 +161,7 @@ export default function WebsiteDetails({ websiteId, defaultDateRange = '7day' })
|
|||
websiteId={websiteId}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
limit={10}
|
||||
dataFilter={deviceFilter}
|
||||
onExpand={handleExpand}
|
||||
/>
|
||||
|
|
@ -143,6 +179,7 @@ export default function WebsiteDetails({ websiteId, defaultDateRange = '7day' })
|
|||
websiteId={websiteId}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
limit={10}
|
||||
dataFilter={countryFilter}
|
||||
onDataLoad={data => setCountryData(data)}
|
||||
onExpand={handleExpand}
|
||||
|
|
@ -155,10 +192,22 @@ export default function WebsiteDetails({ websiteId, defaultDateRange = '7day' })
|
|||
<MenuLayout
|
||||
className={styles.expand}
|
||||
menuClassName={styles.menu}
|
||||
optionClassName={styles.option}
|
||||
menu={menuOptions}
|
||||
selectedOption={expand}
|
||||
selectedOption={expand.value}
|
||||
onMenuSelect={handleMenuSelect}
|
||||
/>
|
||||
>
|
||||
<RankingsChart
|
||||
title={expand.label}
|
||||
type={expand.value}
|
||||
heading={getHeading(expand.value)}
|
||||
websiteId={websiteId}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
dataFilter={expand.filter}
|
||||
onDataLoad={expand.onDataLoad}
|
||||
/>
|
||||
</MenuLayout>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,19 @@
|
|||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.menu .option {
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.backButton {
|
||||
align-self: flex-start;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.backButton svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.row {
|
||||
border-top: 1px solid var(--gray300);
|
||||
min-height: 430px;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { FixedSizeList } from 'react-window';
|
||||
import { useSpring, animated, config } from 'react-spring';
|
||||
import classNames from 'classnames';
|
||||
import CheckVisible from 'components/helpers/CheckVisible';
|
||||
|
|
@ -17,6 +18,7 @@ export default function RankingsChart({
|
|||
heading,
|
||||
className,
|
||||
dataFilter,
|
||||
limit,
|
||||
onDataLoad = () => {},
|
||||
onExpand = () => {},
|
||||
}) {
|
||||
|
|
@ -24,7 +26,11 @@ export default function RankingsChart({
|
|||
|
||||
const rankings = useMemo(() => {
|
||||
if (data) {
|
||||
return (dataFilter ? dataFilter(data) : data).filter((e, i) => i < 10);
|
||||
const items = dataFilter ? dataFilter(data) : data;
|
||||
if (limit) {
|
||||
return items.filter((e, i) => i < limit);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
return [];
|
||||
}, [data]);
|
||||
|
|
@ -52,31 +58,44 @@ export default function RankingsChart({
|
|||
return null;
|
||||
}
|
||||
|
||||
const Row = ({ index, style }) => {
|
||||
const { x, y, z } = rankings[index];
|
||||
return (
|
||||
<div style={style}>
|
||||
<AnimatedRow key={x} label={x} value={y} percent={z} animate={limit} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<CheckVisible>
|
||||
{visible => (
|
||||
<div className={classNames(styles.container, className)}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>{title}</div>
|
||||
<div className={styles.heading}>{heading}</div>
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
{rankings.map(({ x, y, z }) => (
|
||||
<Row key={x} label={x} value={y} percent={z} animate={visible} />
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
<Button icon={<Arrow />} size="xsmall" onClick={() => onExpand(title)}>
|
||||
<div>More</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CheckVisible>
|
||||
<div className={classNames(styles.container, className)}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.title}>{title}</div>
|
||||
<div className={styles.heading}>{heading}</div>
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
{limit ? (
|
||||
rankings.map(({ x, y, z }) => (
|
||||
<AnimatedRow key={x} label={x} value={y} percent={z} animate={limit} />
|
||||
))
|
||||
) : (
|
||||
<FixedSizeList height={600} itemCount={rankings.length} itemSize={30}>
|
||||
{Row}
|
||||
</FixedSizeList>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
{limit && data.length > limit && (
|
||||
<Button icon={<Arrow />} size="xsmall" onClick={() => onExpand(type)}>
|
||||
<div>More</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Row = ({ label, value, percent, animate }) => {
|
||||
const AnimatedRow = ({ label, value, percent, animate }) => {
|
||||
const props = useSpring({
|
||||
width: percent,
|
||||
y: value,
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@
|
|||
.body {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.body:empty:before {
|
||||
|
|
|
|||
|
|
@ -54,23 +54,25 @@ export default function WebsiteChart({
|
|||
|
||||
return (
|
||||
<>
|
||||
<StickyHeader
|
||||
className={classNames(styles.header, 'row')}
|
||||
stickyClassName={styles.sticky}
|
||||
enabled={stickyHeader}
|
||||
>
|
||||
<MetricsBar
|
||||
className="col-12 col-md-9 col-lg-10"
|
||||
websiteId={websiteId}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
<DateFilter
|
||||
className="col-12 col-md-3 col-lg-2"
|
||||
value={value}
|
||||
onChange={handleDateChange}
|
||||
/>
|
||||
</StickyHeader>
|
||||
<div className={classNames(styles.header, 'row')}>
|
||||
<StickyHeader
|
||||
className={classNames(styles.metrics, 'col row')}
|
||||
stickyClassName={styles.sticky}
|
||||
enabled={stickyHeader}
|
||||
>
|
||||
<MetricsBar
|
||||
className="col-12 col-md-9 col-lg-10"
|
||||
websiteId={websiteId}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
/>
|
||||
<DateFilter
|
||||
className="col-12 col-md-3 col-lg-2"
|
||||
value={value}
|
||||
onChange={handleDateChange}
|
||||
/>
|
||||
</StickyHeader>
|
||||
</div>
|
||||
<div className="row">
|
||||
<CheckVisible className="col">
|
||||
{visible => (
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@
|
|||
}
|
||||
|
||||
.header {
|
||||
min-height: 90px;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
|
@ -22,5 +27,5 @@
|
|||
margin: auto;
|
||||
background: var(--gray50);
|
||||
border-bottom: 1px solid var(--gray300);
|
||||
z-index: 2;
|
||||
z-index: 3;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,9 @@ export default function DropDown({
|
|||
{options.find(e => e.value === value)?.label}
|
||||
<Icon icon={<Chevron />} size="small" />
|
||||
</div>
|
||||
{showMenu && <Menu className={menuClassName} options={options} onSelect={handleSelect} />}
|
||||
{showMenu && (
|
||||
<Menu className={menuClassName} options={options} onSelect={handleSelect} float="bottom" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,23 +2,43 @@ import React from 'react';
|
|||
import classNames from 'classnames';
|
||||
import styles from './Menu.module.css';
|
||||
|
||||
export default function Menu({ options = [], className, align = 'left', onSelect = () => {} }) {
|
||||
export default function Menu({
|
||||
options = [],
|
||||
selectedOption,
|
||||
className,
|
||||
float,
|
||||
align = 'left',
|
||||
optionClassName,
|
||||
selectedClassName,
|
||||
onSelect = () => {},
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.menu, className, {
|
||||
[styles.float]: float,
|
||||
[styles.top]: float === 'top',
|
||||
[styles.bottom]: float === 'bottom',
|
||||
[styles.left]: align === 'left',
|
||||
[styles.right]: align === 'right',
|
||||
})}
|
||||
>
|
||||
{options.map(({ label, value, className: optionClassName }) => (
|
||||
<div
|
||||
key={value}
|
||||
className={classNames(styles.option, optionClassName)}
|
||||
onClick={e => onSelect(value, e)}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
{options.map(option => {
|
||||
const { label, value, className: customClassName, render } = option;
|
||||
|
||||
return render ? (
|
||||
render(option)
|
||||
) : (
|
||||
<div
|
||||
key={value}
|
||||
className={classNames(styles.option, optionClassName, customClassName, {
|
||||
[selectedClassName]: selectedOption === value,
|
||||
})}
|
||||
onClick={e => onSelect(value, e)}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
.menu {
|
||||
position: absolute;
|
||||
min-width: 100px;
|
||||
top: 100%;
|
||||
margin-top: 4px;
|
||||
border: 1px solid var(--gray500);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
|
|
@ -22,6 +18,21 @@
|
|||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.float {
|
||||
position: absolute;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.top {
|
||||
bottom: 100%;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
top: 100%;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.left {
|
||||
left: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
right: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.modal:before {
|
||||
|
|
|
|||
|
|
@ -47,7 +47,9 @@ export default function UserButton() {
|
|||
<Icon icon={<User />} size="large" />
|
||||
<Icon icon={<Chevron />} size="small" />
|
||||
</div>
|
||||
{showMenu && <Menu options={menuOptions} onSelect={handleSelect} align="right" />}
|
||||
{showMenu && (
|
||||
<Menu options={menuOptions} onSelect={handleSelect} float="bottom" align="right" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import Menu from 'components/common/Menu';
|
||||
import styles from './MenuLayout.module.css';
|
||||
|
||||
export default function MenuLayout({
|
||||
|
|
@ -9,26 +10,19 @@ export default function MenuLayout({
|
|||
className,
|
||||
menuClassName,
|
||||
contentClassName,
|
||||
optionClassName,
|
||||
children,
|
||||
}) {
|
||||
function handleMenuSelect(option) {
|
||||
onMenuSelect(option);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.container, className)}>
|
||||
<div className={classNames(styles.menu, menuClassName)}>
|
||||
{menu.map(option =>
|
||||
option ? (
|
||||
<div
|
||||
className={classNames(styles.option, { [styles.active]: option === selectedOption })}
|
||||
onClick={() => handleMenuSelect(option)}
|
||||
>
|
||||
{option}
|
||||
</div>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
<Menu
|
||||
options={menu}
|
||||
selectedOption={selectedOption}
|
||||
className={classNames(styles.menu, menuClassName)}
|
||||
selectedClassName={styles.selected}
|
||||
optionClassName={classNames(styles.option, optionClassName)}
|
||||
onSelect={onMenuSelect}
|
||||
/>
|
||||
<div className={classNames(styles.content, contentClassName)}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@
|
|||
flex: 1;
|
||||
}
|
||||
|
||||
.container .menu {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -18,6 +22,7 @@
|
|||
}
|
||||
|
||||
.option {
|
||||
font-size: var(--font-size-normal);
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
min-width: 160px;
|
||||
|
|
@ -29,6 +34,6 @@
|
|||
background: var(--gray75);
|
||||
}
|
||||
|
||||
.active {
|
||||
.selected {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue