Show per-user stats in profile settings
This commit is contained in:
parent
630f8ca10c
commit
54d0a56b9a
|
@ -5,6 +5,7 @@ const { DateTime } = require('luxon');
|
||||||
const Track = mongoose.model('Track');
|
const Track = mongoose.model('Track');
|
||||||
const User = mongoose.model('User');
|
const User = mongoose.model('User');
|
||||||
const wrapRoute = require('../../_helpers/wrapRoute');
|
const wrapRoute = require('../../_helpers/wrapRoute');
|
||||||
|
const auth = require('../../passport');
|
||||||
|
|
||||||
// round to this number of meters for privacy reasons
|
// round to this number of meters for privacy reasons
|
||||||
const TRACK_LENGTH_ROUNDING = 1000;
|
const TRACK_LENGTH_ROUNDING = 1000;
|
||||||
|
@ -14,6 +15,7 @@ const TRACK_DURATION_ROUNDING = 120;
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/',
|
'/',
|
||||||
|
auth.optional,
|
||||||
wrapRoute(async (req, res) => {
|
wrapRoute(async (req, res) => {
|
||||||
const start = DateTime.fromISO(req.query.start);
|
const start = DateTime.fromISO(req.query.start);
|
||||||
const end = DateTime.fromISO(req.query.end);
|
const end = DateTime.fromISO(req.query.end);
|
||||||
|
@ -24,25 +26,40 @@ router.get(
|
||||||
...(end.isValid ? { $lt: end.toJSDate() } : {}),
|
...(end.isValid ? { $lt: end.toJSDate() } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const trackCount = await Track.find({
|
let userFilter;
|
||||||
|
if (req.query.user) {
|
||||||
|
const user = await User.findOne({ username: req.query.user });
|
||||||
|
|
||||||
|
// Only the user can look for their own stats, for now
|
||||||
|
if (req.user && req.user._id.equals(user._id)) {
|
||||||
|
userFilter = user._id;
|
||||||
|
} else {
|
||||||
|
userFilter = { $ne: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackFilter = {
|
||||||
'statistics.recordedAt': dateFilter,
|
'statistics.recordedAt': dateFilter,
|
||||||
}).count();
|
...(userFilter ? { author: userFilter } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const trackCount = await Track.find(trackFilter).count();
|
||||||
|
|
||||||
const publicTrackCount = await Track.find({
|
const publicTrackCount = await Track.find({
|
||||||
'statistics.recordedAt': dateFilter,
|
...trackFilter,
|
||||||
public: true,
|
public: true,
|
||||||
}).count();
|
}).count();
|
||||||
|
|
||||||
const userCount = await User.find({
|
const userCount = await User.find({
|
||||||
|
...(userFilter
|
||||||
|
? { _id: userFilter }
|
||||||
|
: {
|
||||||
createdAt: dateFilter,
|
createdAt: dateFilter,
|
||||||
|
}),
|
||||||
}).count();
|
}).count();
|
||||||
|
|
||||||
const trackStats = await Track.aggregate([
|
const trackStats = await Track.aggregate([
|
||||||
{
|
{ $match: trackFilter },
|
||||||
$match: {
|
|
||||||
'statistics.recordedAt': dateFilter,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
$addFields: {
|
$addFields: {
|
||||||
trackLength: {
|
trackLength: {
|
||||||
|
|
114
frontend/src/components/Stats/index.tsx
Normal file
114
frontend/src/components/Stats/index.tsx
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
import React, {useState, useCallback} from 'react'
|
||||||
|
import {pickBy} from 'lodash'
|
||||||
|
import {Loader, Statistic, Segment, Header, Menu} from 'semantic-ui-react'
|
||||||
|
import {useObservable} from 'rxjs-hooks'
|
||||||
|
import {of, from, concat, combineLatest} from 'rxjs'
|
||||||
|
import {map, switchMap, distinctUntilChanged} from 'rxjs/operators'
|
||||||
|
import {Duration, DateTime} from 'luxon'
|
||||||
|
|
||||||
|
import api from 'api'
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
return (
|
||||||
|
Duration.fromMillis((seconds ?? 0) * 1000)
|
||||||
|
.as('hours')
|
||||||
|
.toFixed(1) + ' h'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Stats({user = null}: {user?: null | string}) {
|
||||||
|
const [timeframe, setTimeframe] = useState('all_time')
|
||||||
|
const onClick = useCallback((_e, {name}) => setTimeframe(name), [setTimeframe])
|
||||||
|
|
||||||
|
const stats = useObservable(
|
||||||
|
(_$, inputs$) => {
|
||||||
|
const timeframe$ = inputs$.pipe(
|
||||||
|
map((inputs) => inputs[0]),
|
||||||
|
distinctUntilChanged()
|
||||||
|
)
|
||||||
|
|
||||||
|
const user$ = inputs$.pipe(
|
||||||
|
map((inputs) => inputs[1]),
|
||||||
|
distinctUntilChanged()
|
||||||
|
)
|
||||||
|
|
||||||
|
return combineLatest(timeframe$, user$).pipe(
|
||||||
|
map(([timeframe_, user_]) => {
|
||||||
|
const now = DateTime.now()
|
||||||
|
|
||||||
|
let start, end
|
||||||
|
|
||||||
|
switch (timeframe_) {
|
||||||
|
case 'this_month':
|
||||||
|
start = now.startOf('month')
|
||||||
|
end = now.endOf('month')
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'this_year':
|
||||||
|
start = now.startOf('year')
|
||||||
|
end = now.endOf('year')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return pickBy({
|
||||||
|
start: start?.toISODate(),
|
||||||
|
end: end?.toISODate(),
|
||||||
|
user: user_,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
switchMap((query) => concat(of(null), from(api.get('/stats', {query}))))
|
||||||
|
)
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
[timeframe, user]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header as="h2">Statistics</Header>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Segment attached="top">
|
||||||
|
<Loader active={stats == null} />
|
||||||
|
<Statistic.Group widths={2} size="tiny">
|
||||||
|
<Statistic>
|
||||||
|
<Statistic.Value>{stats ? `${Number(stats?.trackLength / 1000).toFixed(1)} km` : '...'}</Statistic.Value>
|
||||||
|
<Statistic.Label>Total track length</Statistic.Label>
|
||||||
|
</Statistic>
|
||||||
|
<Statistic>
|
||||||
|
<Statistic.Value>{stats ? formatDuration(stats?.trackDuration) : '...'}</Statistic.Value>
|
||||||
|
<Statistic.Label>Time recorded</Statistic.Label>
|
||||||
|
</Statistic>
|
||||||
|
<Statistic>
|
||||||
|
<Statistic.Value>{stats?.numEvents ?? '...'}</Statistic.Value>
|
||||||
|
<Statistic.Label>Events confirmed</Statistic.Label>
|
||||||
|
</Statistic>
|
||||||
|
{user ? (
|
||||||
|
<Statistic>
|
||||||
|
<Statistic.Value>{stats?.trackCount ?? '...'}</Statistic.Value>
|
||||||
|
<Statistic.Label>Tracks recorded</Statistic.Label>
|
||||||
|
</Statistic>
|
||||||
|
) : (
|
||||||
|
<Statistic>
|
||||||
|
<Statistic.Value>{stats?.userCount ?? '...'}</Statistic.Value>
|
||||||
|
<Statistic.Label>Members joined</Statistic.Label>
|
||||||
|
</Statistic>
|
||||||
|
)}
|
||||||
|
</Statistic.Group>
|
||||||
|
</Segment>
|
||||||
|
|
||||||
|
<Menu widths={3} attached="bottom" size="small">
|
||||||
|
<Menu.Item name="this_month" active={timeframe === 'this_month'} onClick={onClick}>
|
||||||
|
This month
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item name="this_year" active={timeframe === 'this_year'} onClick={onClick}>
|
||||||
|
This year
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item name="all_time" active={timeframe === 'all_time'} onClick={onClick}>
|
||||||
|
All time
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -6,4 +6,5 @@ export {default as LoginButton} from './LoginButton'
|
||||||
export {default as Map} from './Map'
|
export {default as Map} from './Map'
|
||||||
export {default as Page} from './Page'
|
export {default as Page} from './Page'
|
||||||
export {default as RoadsLayer} from './RoadsLayer'
|
export {default as RoadsLayer} from './RoadsLayer'
|
||||||
|
export {default as Stats} from './Stats'
|
||||||
export {default as StripMarkdown} from './StripMarkdown'
|
export {default as StripMarkdown} from './StripMarkdown'
|
||||||
|
|
|
@ -1,159 +0,0 @@
|
||||||
import React, {useState, useCallback} from 'react'
|
|
||||||
import {Link} from 'react-router-dom'
|
|
||||||
import {Message, Grid, Loader, Statistic, Segment, Header, Item, Menu} from 'semantic-ui-react'
|
|
||||||
import {useObservable} from 'rxjs-hooks'
|
|
||||||
import {of, from, concat} from 'rxjs'
|
|
||||||
import {map, switchMap, distinctUntilChanged} from 'rxjs/operators'
|
|
||||||
import {fromLonLat} from 'ol/proj'
|
|
||||||
import {Duration, DateTime} from 'luxon'
|
|
||||||
|
|
||||||
import api from '../api'
|
|
||||||
import {Map, Page, RoadsLayer} from '../components'
|
|
||||||
|
|
||||||
import {TrackListItem} from './TracksPage'
|
|
||||||
import styles from './HomePage.module.scss'
|
|
||||||
|
|
||||||
function formatDuration(seconds) {
|
|
||||||
return Duration.fromMillis((seconds ?? 0) * 1000)
|
|
||||||
.as('hours')
|
|
||||||
.toFixed(1) + ' h'
|
|
||||||
}
|
|
||||||
|
|
||||||
function WelcomeMap() {
|
|
||||||
return (
|
|
||||||
<Map className={styles.welcomeMap}>
|
|
||||||
<RoadsLayer />
|
|
||||||
<Map.TileLayer
|
|
||||||
osm={{
|
|
||||||
url: 'https://tiles.wmflabs.org/bw-mapnik/{z}/{x}/{y}.png',
|
|
||||||
crossOrigin: null,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* <Map.View maxZoom={22} zoom={6} center={fromLonLat([10, 51])} /> */}
|
|
||||||
<Map.View maxZoom={22} zoom={13} center={fromLonLat([9.1798, 48.7759])} />
|
|
||||||
</Map>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Stats() {
|
|
||||||
const [timeframe, setTimeframe] = useState('all_time')
|
|
||||||
const onClick = useCallback((_e, {name}) => setTimeframe(name), [setTimeframe])
|
|
||||||
|
|
||||||
const stats = useObservable(
|
|
||||||
(_$, inputs$) =>
|
|
||||||
inputs$.pipe(
|
|
||||||
map((inputs) => inputs[0]),
|
|
||||||
distinctUntilChanged(),
|
|
||||||
map((timeframe_) => {
|
|
||||||
const now = DateTime.now()
|
|
||||||
|
|
||||||
switch (timeframe_) {
|
|
||||||
case 'this_month':
|
|
||||||
return {
|
|
||||||
start: now.startOf('month').toISODate(),
|
|
||||||
end: now.endOf('month').toISODate(),
|
|
||||||
}
|
|
||||||
case 'this_year':
|
|
||||||
return {
|
|
||||||
start: now.startOf('year').toISODate(),
|
|
||||||
end: now.endOf('year').toISODate(),
|
|
||||||
}
|
|
||||||
case 'all_time':
|
|
||||||
default:
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
switchMap((query) => concat(of(null), from(api.get('/stats', {query})))),
|
|
||||||
),
|
|
||||||
null,
|
|
||||||
[timeframe]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Header as="h2">Statistics</Header>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Segment attached="top">
|
|
||||||
<Loader active={stats == null} />
|
|
||||||
<Statistic.Group widths={2} size="tiny">
|
|
||||||
<Statistic>
|
|
||||||
<Statistic.Value>{stats ? `${Number(stats?.trackLength / 1000).toFixed(1)} km` : '...'}</Statistic.Value>
|
|
||||||
<Statistic.Label>Total track length</Statistic.Label>
|
|
||||||
</Statistic>
|
|
||||||
<Statistic>
|
|
||||||
<Statistic.Value>{stats ? formatDuration(stats?.trackDuration) : '...'}</Statistic.Value>
|
|
||||||
<Statistic.Label>Time recorded</Statistic.Label>
|
|
||||||
</Statistic>
|
|
||||||
<Statistic>
|
|
||||||
<Statistic.Value>{stats?.numEvents ?? '...'}</Statistic.Value>
|
|
||||||
<Statistic.Label>Events confirmed</Statistic.Label>
|
|
||||||
</Statistic>
|
|
||||||
<Statistic>
|
|
||||||
<Statistic.Value>{stats?.userCount ?? '...'}</Statistic.Value>
|
|
||||||
<Statistic.Label>Members joined</Statistic.Label>
|
|
||||||
</Statistic>
|
|
||||||
</Statistic.Group>
|
|
||||||
</Segment>
|
|
||||||
|
|
||||||
<Menu widths={3} attached="bottom" size="small">
|
|
||||||
<Menu.Item name="this_month" active={timeframe === 'this_month'} onClick={onClick}>
|
|
||||||
This month
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item name="this_year" active={timeframe === 'this_year'} onClick={onClick}>
|
|
||||||
This year
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item name="all_time" active={timeframe === 'all_time'} onClick={onClick}>
|
|
||||||
All time
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MostRecentTrack() {
|
|
||||||
const track: Track | null = useObservable(
|
|
||||||
() =>
|
|
||||||
of(null).pipe(
|
|
||||||
switchMap(() => from(api.fetch('/tracks?limit=1'))),
|
|
||||||
map((response) => response?.tracks?.[0])
|
|
||||||
),
|
|
||||||
null,
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Header as="h2">Most recent track</Header>
|
|
||||||
<Loader active={track === null} />
|
|
||||||
{track === undefined ? (
|
|
||||||
<Message>
|
|
||||||
No public tracks yet. <Link to="/upload">Upload the first!</Link>
|
|
||||||
</Message>
|
|
||||||
) : track ? (
|
|
||||||
<Item.Group>
|
|
||||||
<TrackListItem track={track} />
|
|
||||||
</Item.Group>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HomePage() {
|
|
||||||
return (
|
|
||||||
<Page>
|
|
||||||
<Grid stackable>
|
|
||||||
<Grid.Row>
|
|
||||||
<Grid.Column width={10}>
|
|
||||||
<WelcomeMap />
|
|
||||||
</Grid.Column>
|
|
||||||
<Grid.Column width={6}>
|
|
||||||
<Stats />
|
|
||||||
<MostRecentTrack />
|
|
||||||
</Grid.Column>
|
|
||||||
</Grid.Row>
|
|
||||||
</Grid>
|
|
||||||
</Page>
|
|
||||||
)
|
|
||||||
}
|
|
76
frontend/src/pages/HomePage.tsx
Normal file
76
frontend/src/pages/HomePage.tsx
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {Link} from 'react-router-dom'
|
||||||
|
import {Message, Grid, Loader, Header, Item} from 'semantic-ui-react'
|
||||||
|
import {useObservable} from 'rxjs-hooks'
|
||||||
|
import {of, from} from 'rxjs'
|
||||||
|
import {map, switchMap} from 'rxjs/operators'
|
||||||
|
import {fromLonLat} from 'ol/proj'
|
||||||
|
|
||||||
|
import api from 'api'
|
||||||
|
import {Stats, Map, Page, RoadsLayer} from 'components'
|
||||||
|
|
||||||
|
import {TrackListItem} from './TracksPage'
|
||||||
|
import styles from './HomePage.module.scss'
|
||||||
|
|
||||||
|
function WelcomeMap() {
|
||||||
|
return (
|
||||||
|
<Map className={styles.welcomeMap}>
|
||||||
|
<RoadsLayer />
|
||||||
|
<Map.TileLayer
|
||||||
|
osm={{
|
||||||
|
url: 'https://tiles.wmflabs.org/bw-mapnik/{z}/{x}/{y}.png',
|
||||||
|
crossOrigin: null,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* <Map.View maxZoom={22} zoom={6} center={fromLonLat([10, 51])} /> */}
|
||||||
|
<Map.View maxZoom={22} zoom={13} center={fromLonLat([9.1798, 48.7759])} />
|
||||||
|
</Map>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function MostRecentTrack() {
|
||||||
|
const track: Track | null = useObservable(
|
||||||
|
() =>
|
||||||
|
of(null).pipe(
|
||||||
|
switchMap(() => from(api.fetch('/tracks?limit=1'))),
|
||||||
|
map((response) => response?.tracks?.[0])
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header as="h2">Most recent track</Header>
|
||||||
|
<Loader active={track === null} />
|
||||||
|
{track === undefined ? (
|
||||||
|
<Message>
|
||||||
|
No public tracks yet. <Link to="/upload">Upload the first!</Link>
|
||||||
|
</Message>
|
||||||
|
) : track ? (
|
||||||
|
<Item.Group>
|
||||||
|
<TrackListItem track={track} />
|
||||||
|
</Item.Group>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<Grid stackable>
|
||||||
|
<Grid.Row>
|
||||||
|
<Grid.Column width={10}>
|
||||||
|
<WelcomeMap />
|
||||||
|
</Grid.Column>
|
||||||
|
<Grid.Column width={6}>
|
||||||
|
<Stats />
|
||||||
|
<MostRecentTrack />
|
||||||
|
</Grid.Column>
|
||||||
|
</Grid.Row>
|
||||||
|
</Grid>
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,14 +1,13 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {connect} from 'react-redux'
|
import {connect} from 'react-redux'
|
||||||
import {Message, Icon, Grid, Form, Button, TextArea, Ref, Input, Header} from 'semantic-ui-react'
|
import {Message, Icon, Grid, Form, Button, TextArea, Ref, Input, Header, Divider} from 'semantic-ui-react'
|
||||||
import {useForm} from 'react-hook-form'
|
import {useForm} from 'react-hook-form'
|
||||||
|
|
||||||
import {setLogin} from 'reducers/login'
|
import {setLogin} from 'reducers/login'
|
||||||
import {Page} from 'components'
|
import {Page, Stats} from 'components'
|
||||||
import api from 'api'
|
import api from 'api'
|
||||||
import {findInput} from 'utils'
|
import {findInput} from 'utils'
|
||||||
|
|
||||||
|
|
||||||
const SettingsPage = connect((state) => ({login: state.login}), {setLogin})(function SettingsPage({login, setLogin}) {
|
const SettingsPage = connect((state) => ({login: state.login}), {setLogin})(function SettingsPage({login, setLogin}) {
|
||||||
const {register, handleSubmit} = useForm()
|
const {register, handleSubmit} = useForm()
|
||||||
const [loading, setLoading] = React.useState(false)
|
const [loading, setLoading] = React.useState(false)
|
||||||
|
@ -35,7 +34,6 @@ const SettingsPage = connect((state) => ({login: state.login}), {setLogin})(func
|
||||||
<Grid centered relaxed divided>
|
<Grid centered relaxed divided>
|
||||||
<Grid.Row>
|
<Grid.Row>
|
||||||
<Grid.Column width={8}>
|
<Grid.Column width={8}>
|
||||||
|
|
||||||
<Header as="h2">Your profile</Header>
|
<Header as="h2">Your profile</Header>
|
||||||
|
|
||||||
<Message info>All of this information is public.</Message>
|
<Message info>All of this information is public.</Message>
|
||||||
|
@ -64,6 +62,10 @@ const SettingsPage = connect((state) => ({login: state.login}), {setLogin})(func
|
||||||
</Grid.Column>
|
</Grid.Column>
|
||||||
<Grid.Column width={6}>
|
<Grid.Column width={6}>
|
||||||
<ApiKeyDialog {...{login}} />
|
<ApiKeyDialog {...{login}} />
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<Stats user={login.username} />
|
||||||
</Grid.Column>
|
</Grid.Column>
|
||||||
</Grid.Row>
|
</Grid.Row>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
@ -87,9 +89,8 @@ function ApiKeyDialog({login}) {
|
||||||
<>
|
<>
|
||||||
<Header as="h2">Your API Key</Header>
|
<Header as="h2">Your API Key</Header>
|
||||||
<p>
|
<p>
|
||||||
Here you find your API Key, for use in the OpenBikeSensor. You can
|
Here you find your API Key, for use in the OpenBikeSensor. You can to copy and paste it into your sensor's
|
||||||
to copy and paste it into your sensor's configuration interface to
|
configuration interface to allow direct upload from the device.
|
||||||
allow direct upload from the device.
|
|
||||||
</p>
|
</p>
|
||||||
<p>Please protect your API Key carefully as it allows full control over your account.</p>
|
<p>Please protect your API Key carefully as it allows full control over your account.</p>
|
||||||
{show ? (
|
{show ? (
|
||||||
|
@ -97,7 +98,9 @@ function ApiKeyDialog({login}) {
|
||||||
<Input value={login.apiKey} fluid />
|
<Input value={login.apiKey} fluid />
|
||||||
</Ref>
|
</Ref>
|
||||||
) : (
|
) : (
|
||||||
<Button onClick={onClick}><Icon name='lock' /> Show API Key</Button>
|
<Button onClick={onClick}>
|
||||||
|
<Icon name="lock" /> Show API Key
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
Loading…
Reference in a new issue