Client - rename existing front
This commit is contained in:
@ -1,228 +0,0 @@
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import Message from '../Common/Message'
|
||||
import { getAppData, updateAppConfig } from '../../actions/application'
|
||||
import { history } from '../../index'
|
||||
import { getFileSizeInMB } from '../../utils'
|
||||
|
||||
class AdminApplication extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context)
|
||||
this.state = {
|
||||
formData: {},
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.initForm()
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.appConfig !== prevProps.appConfig) {
|
||||
this.initForm()
|
||||
}
|
||||
}
|
||||
|
||||
initForm() {
|
||||
const { appConfig } = this.props
|
||||
const formData = {}
|
||||
Object.keys(appConfig).map(k =>
|
||||
appConfig[k] === null
|
||||
? (formData[k] = '')
|
||||
: ['max_single_file_size', 'max_zip_file_size'].includes(k)
|
||||
? (formData[k] = getFileSizeInMB(appConfig[k]))
|
||||
: (formData[k] = appConfig[k])
|
||||
)
|
||||
this.setState({ formData })
|
||||
}
|
||||
|
||||
handleFormChange(e) {
|
||||
const { formData } = this.state
|
||||
formData[e.target.name] = +e.target.value
|
||||
this.setState(formData)
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
isInEdition,
|
||||
loadAppConfig,
|
||||
message,
|
||||
messages,
|
||||
onHandleConfigFormSubmit,
|
||||
t,
|
||||
} = this.props
|
||||
const { formData } = this.state
|
||||
return (
|
||||
<div>
|
||||
{(message || messages) && (
|
||||
<Message message={message} messages={messages} t={t} />
|
||||
)}
|
||||
{Object.keys(formData).length > 0 && (
|
||||
<div className="row">
|
||||
<div className="col-md-12">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<strong>
|
||||
{t('administration:Application configuration')}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<form
|
||||
className={`app-config-form ${
|
||||
isInEdition ? '' : 'form-disabled'
|
||||
}`}
|
||||
onSubmit={e => {
|
||||
e.preventDefault()
|
||||
onHandleConfigFormSubmit(formData)
|
||||
}}
|
||||
>
|
||||
<div className="form-group row">
|
||||
<label
|
||||
className="col-sm-6 col-form-label"
|
||||
htmlFor="max_users"
|
||||
>
|
||||
{t(
|
||||
// eslint-disable-next-line max-len
|
||||
'administration:Max. number of active users'
|
||||
)}
|
||||
<sup>
|
||||
<i
|
||||
className="fa fa-question-circle"
|
||||
aria-hidden="true"
|
||||
title={t('administration:if 0, no limitation')}
|
||||
/>
|
||||
</sup>
|
||||
:
|
||||
</label>
|
||||
<input
|
||||
className="col-sm-5"
|
||||
id="max_users"
|
||||
name="max_users"
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.max_users}
|
||||
onChange={e => this.handleFormChange(e)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group row">
|
||||
<label
|
||||
className="col-sm-6 col-form-label"
|
||||
htmlFor="max_single_file_size"
|
||||
>
|
||||
{t(
|
||||
'administration:Max. size of uploaded files (in Mb)'
|
||||
)}
|
||||
:
|
||||
</label>
|
||||
<input
|
||||
className="col-sm-5"
|
||||
id="max_single_file_size"
|
||||
name="max_single_file_size"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
value={formData.max_single_file_size}
|
||||
onChange={e => this.handleFormChange(e)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group row">
|
||||
<label
|
||||
className="col-sm-6 col-form-label"
|
||||
htmlFor="max_zip_file_size"
|
||||
>
|
||||
{t('administration:Max. size of zip archive (in Mb)')}:
|
||||
</label>
|
||||
<input
|
||||
className="col-sm-5"
|
||||
id="max_zip_file_size"
|
||||
name="max_zip_file_size"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
value={formData.max_zip_file_size}
|
||||
onChange={e => this.handleFormChange(e)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group row">
|
||||
<label
|
||||
className="col-sm-6 col-form-label"
|
||||
htmlFor="gpx_limit_import"
|
||||
>
|
||||
{t('administration:Max. files of zip archive')}
|
||||
</label>
|
||||
<input
|
||||
className="col-sm-5"
|
||||
id="gpx_limit_import"
|
||||
name="gpx_limit_import"
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.gpx_limit_import}
|
||||
onChange={e => this.handleFormChange(e)}
|
||||
/>
|
||||
</div>
|
||||
{isInEdition ? (
|
||||
<>
|
||||
<input
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
value={t('common:Submit')}
|
||||
/>
|
||||
<input
|
||||
type="submit"
|
||||
className="btn btn-secondary"
|
||||
onClick={e => {
|
||||
e.preventDefault()
|
||||
loadAppConfig()
|
||||
history.push('/admin/application')
|
||||
}}
|
||||
value={t('common:Cancel')}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
onClick={e => {
|
||||
e.preventDefault()
|
||||
history.push('/admin/application/edit')
|
||||
}}
|
||||
value={t('common:Edit')}
|
||||
/>
|
||||
<input
|
||||
type="submit"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => history.push('/admin')}
|
||||
value={t('common:Back')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
message: state.message,
|
||||
messages: state.messages,
|
||||
}),
|
||||
dispatch => ({
|
||||
loadAppConfig: () => {
|
||||
dispatch(getAppData('config'))
|
||||
},
|
||||
onHandleConfigFormSubmit: formData => {
|
||||
const data = Object.assign({}, formData)
|
||||
data.max_single_file_size *= 1048576
|
||||
data.max_zip_file_size *= 1048576
|
||||
dispatch(updateAppConfig(data))
|
||||
},
|
||||
})
|
||||
)(AdminApplication)
|
@ -1,71 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import AdminStats from './AdminStats'
|
||||
|
||||
export default function AdminDashboard(props) {
|
||||
const { appConfig, t } = props
|
||||
return (
|
||||
<div className="card workout-card">
|
||||
<div className="card-header">
|
||||
<strong>{t('administration:Administration')}</strong>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<AdminStats />
|
||||
<br />
|
||||
<dl className="admin-items">
|
||||
<dt>
|
||||
<Link
|
||||
to={{
|
||||
pathname: '/admin/application',
|
||||
}}
|
||||
>
|
||||
{t('administration:Application')}
|
||||
</Link>
|
||||
</dt>
|
||||
<dd>
|
||||
{t(
|
||||
'administration:Update application configuration ' +
|
||||
'(maximum number of registered users, maximum files size).'
|
||||
)}
|
||||
<br />
|
||||
<strong>
|
||||
{t(
|
||||
`administration:Registration is currently ${
|
||||
appConfig.is_registration_enabled ? 'enabled' : 'disabled'
|
||||
}.`
|
||||
)}
|
||||
</strong>
|
||||
</dd>
|
||||
<br />
|
||||
<dt>
|
||||
<Link
|
||||
to={{
|
||||
pathname: '/admin/sports',
|
||||
}}
|
||||
>
|
||||
{t('administration:Sports')}
|
||||
</Link>
|
||||
</dt>
|
||||
<dd>{t('administration:Enable/disable sports.')}</dd>
|
||||
<br />
|
||||
<dt>
|
||||
<Link
|
||||
to={{
|
||||
pathname: '/admin/users',
|
||||
}}
|
||||
>
|
||||
{t('administration:Users')}
|
||||
</Link>
|
||||
</dt>
|
||||
<dd>
|
||||
{t(
|
||||
'administration:Add/remove admin rights, ' +
|
||||
'delete user account.'
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,142 +0,0 @@
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import Message from '../Common/Message'
|
||||
import { getOrUpdateData } from '../../actions'
|
||||
import { history } from '../../index'
|
||||
|
||||
class AdminSports extends React.Component {
|
||||
componentDidMount() {
|
||||
this.props.loadSports()
|
||||
}
|
||||
|
||||
render() {
|
||||
const { message, sports, t, updateSport } = this.props
|
||||
return (
|
||||
<div>
|
||||
{message && <Message message={message} t={t} />}
|
||||
<div className="row">
|
||||
<div className="col">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<strong>{t('administration:Sports')}</strong>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{sports.length > 0 && (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('administration:id')}</th>
|
||||
<th>{t('administration:Image')}</th>
|
||||
<th>{t('administration:Label')}</th>
|
||||
<th>{t('administration:Active')}</th>
|
||||
<th>{t('administration:Actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sports.map(sport => (
|
||||
<tr key={sport.id}>
|
||||
<td>
|
||||
<span className="heading-span-absolute">
|
||||
{t('administration:id')}
|
||||
</span>
|
||||
{sport.id}
|
||||
</td>
|
||||
<td>
|
||||
<span className="heading-span-absolute">
|
||||
{t('administration:Image')}
|
||||
</span>
|
||||
<img
|
||||
className="admin-img"
|
||||
src={sport.img ? sport.img : '/img/photo.png'}
|
||||
alt="sport logo"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<span className="heading-span-absolute">
|
||||
{t('administration:Label')}
|
||||
</span>
|
||||
{t(`sports:${sport.label}`)}
|
||||
</td>
|
||||
<td>
|
||||
<span className="heading-span-absolute">
|
||||
{t('administration:Active')}
|
||||
</span>
|
||||
{sport.is_active ? (
|
||||
<i
|
||||
className="fa fa-check-square-o custom-fa"
|
||||
aria-hidden="true"
|
||||
data-toggle="tooltip"
|
||||
/>
|
||||
) : (
|
||||
<i
|
||||
className="fa fa-square-o custom-fa"
|
||||
aria-hidden="true"
|
||||
data-toggle="tooltip"
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span className="heading-span-absolute">
|
||||
{t('administration:Actions')}
|
||||
</span>
|
||||
<input
|
||||
type="submit"
|
||||
className={`btn btn-${
|
||||
sport.is_active ? 'dark' : 'primary'
|
||||
} btn-sm`}
|
||||
value={
|
||||
sport.is_active
|
||||
? t('administration:Disable')
|
||||
: t('administration:Enable')
|
||||
}
|
||||
onClick={() =>
|
||||
updateSport(sport.id, !sport.is_active)
|
||||
}
|
||||
/>
|
||||
{sport.has_workouts && (
|
||||
<span className="admin-message">
|
||||
<i
|
||||
className="fa fa-warning custom-fa"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t('administration:workouts exist')}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<input
|
||||
type="submit"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => history.push('/admin/')}
|
||||
value={t('common:Back')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
message: state.message,
|
||||
sports: state.sports.data,
|
||||
user: state.user,
|
||||
}),
|
||||
dispatch => ({
|
||||
loadSports: () => {
|
||||
dispatch(getOrUpdateData('getData', 'sports'))
|
||||
},
|
||||
updateSport: (sportId, isActive) => {
|
||||
const data = { id: sportId, is_active: isActive }
|
||||
dispatch(getOrUpdateData('updateData', 'sports', data, false))
|
||||
},
|
||||
})
|
||||
)(AdminSports)
|
@ -1,104 +0,0 @@
|
||||
import React from 'react'
|
||||
import { withTranslation } from 'react-i18next'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import { getAppData } from '../../actions/application'
|
||||
import { getFileSize } from '../../utils'
|
||||
|
||||
class AdminStats extends React.Component {
|
||||
componentDidMount() {
|
||||
this.props.loadAppStats()
|
||||
}
|
||||
|
||||
render() {
|
||||
const { appStats, t } = this.props
|
||||
const uploadDirSize = getFileSize(appStats.uploads_dir_size, false)
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-lg-3 col-md-6 col-sm-6">
|
||||
<div className="card workout-card">
|
||||
<div className="card-body row">
|
||||
<div className="col-3">
|
||||
<i className="fa fa-users fa-3x fa-color" />
|
||||
</div>
|
||||
<div className="col-9 text-right">
|
||||
<div className="huge">
|
||||
{appStats.users ? appStats.users : 0}
|
||||
</div>
|
||||
<div>{`${
|
||||
appStats.users === 1
|
||||
? t('administration:user')
|
||||
: t('administration:users')
|
||||
}`}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-lg-3 col-md-6 col-sm-6">
|
||||
<div className="card workout-card">
|
||||
<div className="card-body row">
|
||||
<div className="col-3">
|
||||
<i className="fa fa-tags fa-3x fa-color" />
|
||||
</div>
|
||||
<div className="col-9 text-right">
|
||||
<div className="huge">
|
||||
{appStats.sports ? appStats.sports : 0}
|
||||
</div>
|
||||
<div>{`${
|
||||
appStats.sports === 1 ? t('common:sport') : t('common:sports')
|
||||
}`}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-lg-3 col-md-6 col-sm-6">
|
||||
<div className="card workout-card">
|
||||
<div className="card-body row">
|
||||
<div className="col-3">
|
||||
<i className="fa fa-calendar fa-3x fa-color" />
|
||||
</div>
|
||||
<div className="col-9 text-right">
|
||||
<div className="huge">
|
||||
{appStats.workouts ? appStats.workouts : 0}
|
||||
</div>
|
||||
<div>{`${
|
||||
appStats.workouts === 1
|
||||
? t('common:workout')
|
||||
: t('common:workouts')
|
||||
}`}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-lg-3 col-md-6 col-sm-6">
|
||||
<div className="card workout-card">
|
||||
<div className="card-body row">
|
||||
<div className="col-3">
|
||||
<i className="fa fa-folder-open fa-3x fa-color" />
|
||||
</div>
|
||||
<div className="col-9 text-right">
|
||||
<div className="huge">{uploadDirSize.size}</div>
|
||||
<div>
|
||||
{uploadDirSize.suffix} ({t('administration:uploads')})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default withTranslation()(
|
||||
connect(
|
||||
state => ({
|
||||
appStats: state.application.statistics,
|
||||
}),
|
||||
dispatch => ({
|
||||
loadAppStats: () => {
|
||||
dispatch(getAppData('stats/all'))
|
||||
},
|
||||
})
|
||||
)(AdminStats)
|
||||
)
|
@ -1,257 +0,0 @@
|
||||
import { format } from 'date-fns'
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import Message from '../Common/Message'
|
||||
import Pagination from '../Common/Pagination'
|
||||
import { history } from '../../index'
|
||||
import { getOrUpdateData } from '../../actions'
|
||||
import {
|
||||
apiUrl,
|
||||
formatUrl,
|
||||
sortOrders,
|
||||
translateValues,
|
||||
userFilters,
|
||||
} from '../../utils'
|
||||
|
||||
class AdminUsers extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context)
|
||||
this.state = {
|
||||
page: null,
|
||||
per_page: null,
|
||||
order_by: 'created_at',
|
||||
order: 'asc',
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.loadUsers(this.initState())
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.location.query !== this.props.location.query) {
|
||||
this.props.loadUsers(this.props.location.query)
|
||||
}
|
||||
}
|
||||
|
||||
initState() {
|
||||
const { query } = this.props.location
|
||||
const newQuery = {
|
||||
page: query.page,
|
||||
per_page: query.per_page,
|
||||
order_by: query.order_by ? query.order_by : 'created_at',
|
||||
order: query.order ? query.order : 'asc',
|
||||
}
|
||||
this.setState(newQuery)
|
||||
return newQuery
|
||||
}
|
||||
|
||||
updatePage(key, value) {
|
||||
const query = Object.assign({}, this.state)
|
||||
query[key] = value
|
||||
this.setState(query)
|
||||
const url = formatUrl(this.props.location.pathname, query)
|
||||
history.push(url)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { authUser, location, message, t, pagination, updateUser, users } =
|
||||
this.props
|
||||
const translatedFilters = translateValues(t, userFilters)
|
||||
const translatedSortOrders = translateValues(t, sortOrders)
|
||||
return (
|
||||
<div>
|
||||
{message && <Message message={message} t={t} />}
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<strong>{t('administration:Users')}</strong>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="row user-filters">
|
||||
<div className="col-lg-4 col-md-6 col-sm-12">
|
||||
<label htmlFor="order_by">
|
||||
{t('common:Sort by')}:{' '}
|
||||
<select
|
||||
id="order_by"
|
||||
name="order_by"
|
||||
value={this.state.order_by}
|
||||
onChange={e =>
|
||||
this.updatePage('order_by', e.target.value)
|
||||
}
|
||||
>
|
||||
{translatedFilters.map(filter => (
|
||||
<option key={filter.key} value={filter.key}>
|
||||
{filter.label}
|
||||
</option>
|
||||
))}
|
||||
</select>{' '}
|
||||
</label>
|
||||
</div>
|
||||
<div className="col-lg-4 col-md-6 col-sm-12">
|
||||
<label htmlFor="sort">
|
||||
{t('common:Sort')}:{' '}
|
||||
<select
|
||||
id="sort"
|
||||
name="sort"
|
||||
value={this.state.order}
|
||||
onChange={e =>
|
||||
this.updatePage('order', e.target.value)
|
||||
}
|
||||
>
|
||||
{translatedSortOrders.map(sort => (
|
||||
<option key={sort.key} value={sort.key}>
|
||||
{sort.label}
|
||||
</option>
|
||||
))}
|
||||
</select>{' '}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>{t('user:Username')}</th>
|
||||
<th>{t('user:Email')}</th>
|
||||
<th>{t('user:Registration Date')}</th>
|
||||
<th>{t('workouts:Workouts')}</th>
|
||||
<th>{t('user:Admin')}</th>
|
||||
<th>{t('administration:Actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(user => (
|
||||
<tr key={user.username}>
|
||||
<td>
|
||||
<span className="heading-span-absolute">#</span>
|
||||
{user.picture === true ? (
|
||||
<img
|
||||
alt="Avatar"
|
||||
src={`${apiUrl}users/${
|
||||
user.username
|
||||
}/picture?${Date.now()}`}
|
||||
className="img-fluid App-nav-profile-img"
|
||||
/>
|
||||
) : (
|
||||
<i
|
||||
className="fa fa-user-circle-o fa-2x no-picture"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span className="heading-span-absolute">
|
||||
{t('user:Username')}
|
||||
</span>
|
||||
<Link to={`/users/${user.username}`}>
|
||||
{user.username}
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
<span className="heading-span-absolute">
|
||||
{t('user:Email')}
|
||||
</span>
|
||||
{user.email}
|
||||
</td>
|
||||
<td>
|
||||
<span className="heading-span-absolute">
|
||||
{t('user:Registration Date')}
|
||||
</span>
|
||||
{format(
|
||||
new Date(user.created_at),
|
||||
'dd/MM/yyyy HH:mm'
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span className="heading-span-absolute">
|
||||
{t('workouts:Workouts')}
|
||||
</span>
|
||||
{user.nb_workouts}
|
||||
</td>
|
||||
<td>
|
||||
<span className="heading-span-absolute">
|
||||
{t('user:Admin')}
|
||||
</span>
|
||||
{user.admin ? (
|
||||
<i
|
||||
className="fa fa-check-square-o custom-fa"
|
||||
aria-hidden="true"
|
||||
data-toggle="tooltip"
|
||||
/>
|
||||
) : (
|
||||
<i
|
||||
className="fa fa-square-o custom-fa"
|
||||
aria-hidden="true"
|
||||
data-toggle="tooltip"
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span className="heading-span-absolute">
|
||||
{t('administration:Actions')}
|
||||
</span>
|
||||
<input
|
||||
type="submit"
|
||||
className={`btn btn-${
|
||||
user.admin ? 'dark' : 'primary'
|
||||
} btn-sm`}
|
||||
disabled={user.username === authUser.username}
|
||||
value={
|
||||
user.admin
|
||||
? t('administration:Remove admin rights')
|
||||
: t('administration:Add admin rights')
|
||||
}
|
||||
onClick={() =>
|
||||
updateUser(user.username, !user.admin)
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<Pagination
|
||||
pagination={pagination}
|
||||
pathname={location.pathname}
|
||||
query={this.state}
|
||||
t={t}
|
||||
/>
|
||||
<input
|
||||
type="submit"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => history.push('/admin/')}
|
||||
value={t('common:Back')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
authUser: state.user,
|
||||
location: state.router.location,
|
||||
message: state.message,
|
||||
pagination: state.users.pagination,
|
||||
users: state.users.data,
|
||||
}),
|
||||
dispatch => ({
|
||||
loadUsers: query => {
|
||||
dispatch(getOrUpdateData('getData', 'users', query))
|
||||
},
|
||||
updateUser: (userName, isAdmin) => {
|
||||
const data = { username: userName, admin: isAdmin }
|
||||
dispatch(getOrUpdateData('updateData', 'users', data, false))
|
||||
},
|
||||
})
|
||||
)(AdminUsers)
|
@ -1,71 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import { withTranslation } from 'react-i18next'
|
||||
import { connect } from 'react-redux'
|
||||
import { Route, Switch } from 'react-router-dom'
|
||||
|
||||
import AdminApplication from './AdminApplication'
|
||||
import AdminDashboard from './AdminDashboard'
|
||||
import AdminSports from './AdminSports'
|
||||
import AdminUsers from './AdminUsers'
|
||||
import NotFound from './../Others/NotFound'
|
||||
|
||||
function Admin(props) {
|
||||
const { appConfig, t, user } = props
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>FitTrackee - {t('administration:Administration')}</title>
|
||||
</Helmet>
|
||||
<div className="container dashboard">
|
||||
{user.admin ? (
|
||||
<Switch>
|
||||
<Route
|
||||
exact
|
||||
path="/admin"
|
||||
render={() => <AdminDashboard appConfig={appConfig} t={t} />}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/admin/application"
|
||||
render={() => (
|
||||
<AdminApplication
|
||||
appConfig={appConfig}
|
||||
t={t}
|
||||
isInEdition={false}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/admin/application/edit"
|
||||
render={() => (
|
||||
<AdminApplication appConfig={appConfig} t={t} isInEdition />
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/admin/sports"
|
||||
render={() => <AdminSports t={t} />}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/admin/users"
|
||||
render={() => <AdminUsers t={t} />}
|
||||
/>
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
) : (
|
||||
<NotFound />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default withTranslation()(
|
||||
connect(state => ({
|
||||
appConfig: state.application.config,
|
||||
user: state.user,
|
||||
}))(Admin)
|
||||
)
|
@ -1,826 +0,0 @@
|
||||
html {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #eaeaea;
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 50px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.App {
|
||||
padding-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #222;
|
||||
height: 150px;
|
||||
padding: 20px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-title {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.App-intro {
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
.App-nav-profile-img {
|
||||
max-width: 32px;
|
||||
max-height: 32px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.App-profile-img-small {
|
||||
max-width: 150px;
|
||||
max-height: 150px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
a {
|
||||
color: #40578a;
|
||||
}
|
||||
|
||||
input[type="text"], textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
label {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.add-workout {
|
||||
margin-top: 50px;
|
||||
}
|
||||
|
||||
.add-workout-radio {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.admin-img {
|
||||
max-width: 35px;
|
||||
max-height: 35px;
|
||||
}
|
||||
|
||||
.admin-items {
|
||||
list-style-type: square;
|
||||
}
|
||||
|
||||
.admin-message {
|
||||
color: #7c7c7d;
|
||||
font-size: 0.9em;
|
||||
font-style: italic;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.app-config-form label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.btn {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.card {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.chart {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.chart-workouts {
|
||||
margin-left: 60px;
|
||||
}
|
||||
|
||||
.chart-arrows {
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
.chart-filters {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.chart-info {
|
||||
font-size: 0.8em;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.chart-radio {
|
||||
display: flex;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.chart-radio label {
|
||||
/* display: flex; */
|
||||
}
|
||||
|
||||
.chart-radio input {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.chart-stats {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 1.1em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.col-workout-logo{
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.custom-modal {
|
||||
background-color: #fff;
|
||||
border-radius: 5px;
|
||||
max-width: 500px;
|
||||
margin: 20% auto;
|
||||
z-index: 1250;
|
||||
}
|
||||
|
||||
.custom-modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: rgba(0,0,0,0.3);
|
||||
padding: 50px;
|
||||
z-index: 1240;
|
||||
}
|
||||
|
||||
.custom-fa {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.custom-fa-small {
|
||||
font-size: 0.8em;
|
||||
margin-left: -0.8em;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 992px) {
|
||||
.custom-fa-small {
|
||||
font-size: 0.6em;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-tooltip {
|
||||
background-color: #fff;
|
||||
border: 1px solid lightgrey;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.custom-tooltip p {
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
.custom-tooltip-label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
height: 100%;
|
||||
}
|
||||
.dashboard, .history {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.dropdown-wrapper {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.dropdown-list {
|
||||
background-color: #f8f9fa;
|
||||
padding: 5px 0;
|
||||
position: absolute;
|
||||
text-align: left;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
cursor: default;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.dropdown-item-selected {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.dropdown-item-selected::after {
|
||||
content: " ✔";
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.fa-as-link {
|
||||
cursor:pointer;
|
||||
color: #40578a;
|
||||
}
|
||||
|
||||
.fa-as-link:hover {
|
||||
color: #0056b3;
|
||||
}
|
||||
|
||||
.fa-question-circle {
|
||||
color: #6c757d;
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.fa-trophy {
|
||||
color: goldenrod;
|
||||
}
|
||||
|
||||
.fa-color {
|
||||
color: #405976;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background-color: #f8f9fa;
|
||||
bottom: 0;
|
||||
color: #8b8c8c;
|
||||
font-size: 0.9em;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Chrome, Safari, Edge, Opera */
|
||||
.form-disabled .form-group input::-webkit-outer-spin-button,
|
||||
.form-disabled .form-group input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
.form-disabled .form-group input[type=number] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.form-disabled .form-group input{
|
||||
border: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
.gpx-file {
|
||||
height: inherit;
|
||||
}
|
||||
|
||||
.huge {
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
.i18n-flag svg {
|
||||
height: 100%;
|
||||
opacity: .9;
|
||||
width: 15px;
|
||||
}
|
||||
|
||||
.inactive-link {
|
||||
color: lightgrey;
|
||||
}
|
||||
|
||||
.leaflet-container {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.loader {
|
||||
animation: spin 2s linear infinite;
|
||||
border: 8px solid #f3f3f3;
|
||||
border-top: 8px solid #3498db;
|
||||
border-radius: 50%;
|
||||
height: 60px;
|
||||
margin-left: 41%;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.map-attribution {
|
||||
bottom: 0;
|
||||
font-size: 11px;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.map-attribution-text {
|
||||
background-color: rgba(255, 255, 255, .5);
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.no-picture {
|
||||
color: #405976;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 2em;
|
||||
margin: 1em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.password-forget {
|
||||
margin: 10px;
|
||||
font-size: .9em;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.radioLabel {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.record-logo {
|
||||
margin-right: 5px;
|
||||
max-width: 25px;
|
||||
max-height: 25px;
|
||||
}
|
||||
|
||||
.record-table table, .record-table th, .record-table td{
|
||||
font-size: 0.85em;
|
||||
padding: 0.1em;
|
||||
}
|
||||
@media only screen and (min-width: 1200px) {
|
||||
.record-table table, .record-table th, .record-table td{
|
||||
font-size: 0.9em;
|
||||
padding: 0.1em;
|
||||
}
|
||||
}
|
||||
|
||||
.remaining-chars {
|
||||
font-size: 0.8em;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.sport-img {
|
||||
max-width: 35px;
|
||||
max-height: 35px;
|
||||
}
|
||||
|
||||
.sport-img-medium {
|
||||
max-width: 45px;
|
||||
max-height: 45px;
|
||||
}
|
||||
|
||||
.stats-disabled {
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.svg-icon {
|
||||
fill: #405976;
|
||||
height: 70px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.time-frames {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.time-frame label {
|
||||
float: left;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.time-frame label input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.time-frame label span {
|
||||
border: #a9a9a9 solid 1px;
|
||||
border-radius: 9%;
|
||||
color: #7b7b7b;
|
||||
display: block;
|
||||
font-size: 0.9em;
|
||||
padding: 2px 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.time-frame input:checked + span {
|
||||
background-color: #a9a9a9;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.timezone-custom {
|
||||
font-size: .9em !important;
|
||||
height: inherit !important;
|
||||
}
|
||||
|
||||
.timezone-custom input {
|
||||
border: 0 !important;
|
||||
padding: 5px 1px !important;
|
||||
}
|
||||
|
||||
.timezone-custom ul {
|
||||
background: white;
|
||||
}
|
||||
|
||||
.timezone-picker {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.timezone-picker-textfield {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.unlink {
|
||||
color: black;
|
||||
}
|
||||
|
||||
.user-bio, .workout-notes {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.user-filters {
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.user-label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.weather-img {
|
||||
max-width: 35px;
|
||||
max-height: 35px;
|
||||
}
|
||||
|
||||
.weather-img-small {
|
||||
max-width: 20px;
|
||||
max-height: 20px;
|
||||
}
|
||||
|
||||
.weather-table {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.weather-table table, .weather-table th, .weather-table td{
|
||||
font-size: 0.9em;
|
||||
padding: 0.1em;
|
||||
}
|
||||
|
||||
.workouts-result {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.workout-card {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.col-with-map {
|
||||
font-size: .87em;
|
||||
}
|
||||
@media only screen and (min-width: 1200px) {
|
||||
.col-with-map {
|
||||
font-size: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.workout-details {
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.workout-date {
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
.workout-filter {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.workout-filter .col-2, .col-5{
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.workout-label {
|
||||
font-size: 0.8em;
|
||||
color: #666
|
||||
}
|
||||
|
||||
.workout-logo {
|
||||
margin: 0 5px;
|
||||
max-width: 20px;
|
||||
max-height: 20px;
|
||||
}
|
||||
|
||||
.workout-map {
|
||||
background-color: #eaeaea;
|
||||
height: 225px;
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
.workout-no-map {
|
||||
background-color: #eaeaea;
|
||||
color: #666666;
|
||||
font-style: italic;
|
||||
height: 400px;
|
||||
line-height: 400px;
|
||||
}
|
||||
|
||||
.workout-notes, .actvity-segments {
|
||||
font-size: 0.9em;
|
||||
font-style: italic;
|
||||
margin-top: 10px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.workout-page {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.workout-segments-list {
|
||||
list-style: square;
|
||||
}
|
||||
|
||||
.workout-sport {
|
||||
margin-right: 1px;
|
||||
max-width: 18px;
|
||||
max-height: 18px;
|
||||
}
|
||||
|
||||
.workout-title img, .workout-title .map-attribution-list {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workout-title img {
|
||||
border: 1px solid lightgrey;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
|
||||
display: none;
|
||||
margin-left: 20px;
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.workout-title .map-attribution-list {
|
||||
display: none;
|
||||
font-size: 11px;
|
||||
margin-left: 20px;
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.workout-title:hover img, .workout-title:hover .map-attribution-list {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* responsive table */
|
||||
/* adapted from https://uglyduck.ca/making-tables-responsive-with-minimal-css/ */
|
||||
.heading-span,
|
||||
.heading-span-absolute {
|
||||
background: #eee;
|
||||
color: dimgrey;
|
||||
display: none;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
padding: 5px;
|
||||
text-transform: uppercase;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.heading-span-absolute {
|
||||
position: absolute;
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media(max-width: 1024px) {
|
||||
table thead {
|
||||
left: -9999px;
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
}
|
||||
table tr {
|
||||
border-bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
table td {
|
||||
border: 1px solid lightgrey;
|
||||
margin: 0 -1px -1px 0;
|
||||
padding-top: 30px !important;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
width: 50%;
|
||||
}
|
||||
.record-tr {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.record-td {
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
.heading-span, .heading-span-absolute {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* calendar */
|
||||
:root {
|
||||
--main-color: #1a8fff;
|
||||
--text-color: #777;
|
||||
--text-color-light: #ccc;
|
||||
--border-color: #eee;
|
||||
--bg-color: #f9f9f9;
|
||||
--neutral-color: #fff;
|
||||
}
|
||||
|
||||
.calendar .col-start {
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.calendar .col-center {
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.calendar .col-end {
|
||||
justify-content: flex-end;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.calendar {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: var(--neutral-color);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.calendar .header {
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
/*font-size: 115%;*/
|
||||
padding: 0.5em 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.calendar .header .icon {
|
||||
cursor: pointer;
|
||||
transition: .15s ease-out;
|
||||
}
|
||||
|
||||
.calendar .header .icon:hover {
|
||||
transform: scale(1.75);
|
||||
transition: .25s ease-out;
|
||||
color: var(--main-color);
|
||||
}
|
||||
|
||||
.calendar .header .icon:first-of-type {
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
.calendar .header .icon:last-of-type {
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
.calendar .days {
|
||||
text-transform: uppercase;
|
||||
font-weight: 400;
|
||||
color: var(--text-color-light);
|
||||
font-size: 70%;
|
||||
padding: .75em 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.calendar .body .cell {
|
||||
position: relative;
|
||||
height: 3em;
|
||||
border-right: 1px solid var(--border-color);
|
||||
background: var(--neutral-color);
|
||||
}
|
||||
|
||||
.calendar .body .cell:hover {
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.calendar .body .selected {
|
||||
border-left: 10px solid transparent;
|
||||
border-image: linear-gradient(45deg, #1a8fff 0%,#53cbf1 40%);
|
||||
}
|
||||
|
||||
.calendar .body .row {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.calendar .body .row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.calendar .body .cell:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.calendar .body .cell .number {
|
||||
position: absolute;
|
||||
font-size: 82.5%;
|
||||
line-height: 1;
|
||||
top: .75em;
|
||||
right: .75em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.calendar .body .disabled {
|
||||
color: var(--text-color-light);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.calendar .body .col {
|
||||
flex-grow: 0;
|
||||
flex-basis: calc(100%/7);
|
||||
width: calc(100%/7);
|
||||
}
|
||||
|
||||
.calendar .body .img-disabled {
|
||||
opacity: .4;
|
||||
}
|
||||
|
||||
.calendar .body .weekend {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.calendar .body .today {
|
||||
background: #eff1f3;
|
||||
}
|
||||
|
||||
.calendar-workout,
|
||||
.calendar-more {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.calendar-more {
|
||||
color: #405976;
|
||||
font-size: .7em;
|
||||
margin-left: 0.3em;
|
||||
}
|
||||
|
||||
.calendar-display-more {
|
||||
background: whitesmoke;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
|
||||
margin-bottom: 10px;
|
||||
padding: 10px 15px;
|
||||
position: absolute;
|
||||
min-width: 52px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.calendar-workout-more {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 992px) {
|
||||
|
||||
.calendar-workout:nth-child(-n+2),
|
||||
.calendar-workout:nth-child(n+3) ~ .calendar-more,
|
||||
.calendar-workout-more:nth-child(n+3) {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 992px) and (max-width: 1200px) {
|
||||
|
||||
.calendar-workout:nth-child(-n+4),
|
||||
.calendar-workout:nth-child(n+5) ~ .calendar-more,
|
||||
.calendar-workout-more:nth-child(n+5) {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 1200px) {
|
||||
|
||||
.calendar-workout:nth-child(-n+6),
|
||||
.calendar-workout:nth-child(n+7) ~ .calendar-more,
|
||||
.calendar-workout-more:nth-child(n+7) {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
}
|
@ -1,90 +0,0 @@
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Route, Switch } from 'react-router-dom'
|
||||
|
||||
import './App.css'
|
||||
import Admin from './Admin'
|
||||
import Workout from './Workout'
|
||||
import Workouts from './Workouts'
|
||||
import CurrentUserProfile from './User/CurrentUserProfile'
|
||||
import Dashboard from './Dashboard'
|
||||
import Footer from './Footer'
|
||||
import Logout from './User/Logout'
|
||||
import NavBar from './NavBar'
|
||||
import NotFound from './Others/NotFound'
|
||||
import PasswordReset from './User/PasswordReset'
|
||||
import ProfileEdit from './User/ProfileEdit'
|
||||
import Statistics from './Statistics'
|
||||
import UserForm from './User/UserForm'
|
||||
import UserProfile from './User/UserProfile'
|
||||
import { getAppData } from '../actions/application'
|
||||
|
||||
class App extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.props = props
|
||||
}
|
||||
componentDidMount() {
|
||||
this.props.loadAppConfig()
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="App">
|
||||
<NavBar />
|
||||
<Switch>
|
||||
<Route exact path="/" component={Dashboard} />
|
||||
<Route
|
||||
exact
|
||||
path="/register"
|
||||
render={() => <UserForm formType={'register'} />}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/login"
|
||||
render={() => <UserForm formType={'login'} />}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/password-reset"
|
||||
render={() => <UserForm formType={'password reset'} />}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/password-reset/request"
|
||||
render={() => <UserForm formType={'reset your password'} />}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/password-reset/sent"
|
||||
render={() => <PasswordReset action={'sent'} />}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/updated-password"
|
||||
render={() => <PasswordReset action={'updated'} />}
|
||||
/>
|
||||
<Route exact path="/password-reset/sent" component={PasswordReset} />
|
||||
<Route exact path="/logout" component={Logout} />
|
||||
<Route exact path="/profile/edit" component={ProfileEdit} />
|
||||
<Route exact path="/profile" component={CurrentUserProfile} />
|
||||
<Route exact path="/workouts/history" component={Workouts} />
|
||||
<Route exact path="/workouts/statistics" component={Statistics} />
|
||||
<Route exact path="/users/:userName" component={UserProfile} />
|
||||
<Route path="/workouts" component={Workout} />
|
||||
<Route path="/admin" component={Admin} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
export default connect(
|
||||
() => ({}),
|
||||
dispatch => ({
|
||||
loadAppConfig: () => {
|
||||
dispatch(getAppData('config'))
|
||||
},
|
||||
})
|
||||
)(App)
|
@ -1,9 +0,0 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
|
||||
import App from './App'
|
||||
|
||||
it('renders without crashing', () => {
|
||||
const div = document.createElement('div')
|
||||
ReactDOM.render(<App />, div)
|
||||
})
|
@ -1,44 +0,0 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function CustomModal(props) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="custom-modal-backdrop">
|
||||
<div className="custom-modal">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">{props.title}</h5>
|
||||
<button
|
||||
type="button"
|
||||
className="close"
|
||||
aria-label="Close"
|
||||
onClick={() => props.close()}
|
||||
>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p>{props.text}</p>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => props.confirm()}
|
||||
>
|
||||
{t('common:Yes')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => props.close()}
|
||||
>
|
||||
{t('common:No')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,45 +0,0 @@
|
||||
import React from 'react'
|
||||
import { withTranslation } from 'react-i18next'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
class CustomTextArea extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context)
|
||||
this.state = {
|
||||
text: props.defaultValue ? props.defaultValue : '',
|
||||
}
|
||||
}
|
||||
|
||||
handleOnChange(changeEvent) {
|
||||
this.setState({ text: changeEvent.target.value })
|
||||
if (this.props.onTextChange) {
|
||||
this.props.onTextChange(changeEvent)
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { charLimit, loading, name, t } = this.props
|
||||
const { text } = this.state
|
||||
return (
|
||||
<>
|
||||
<textarea
|
||||
name={name}
|
||||
defaultValue={text}
|
||||
disabled={loading ? loading : false}
|
||||
className="form-control input-lg"
|
||||
maxLength={charLimit}
|
||||
onChange={event => this.handleOnChange(event)}
|
||||
/>
|
||||
<div className="remaining-chars">
|
||||
{t('common:remaining characters')}: {text.length}/{charLimit}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default withTranslation()(
|
||||
connect(state => ({
|
||||
loading: state.loading,
|
||||
}))(CustomTextArea)
|
||||
)
|
@ -1,33 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
export default class Message extends React.PureComponent {
|
||||
render() {
|
||||
const { message, messages, t } = this.props
|
||||
const singleMessage =
|
||||
message === '' || !message
|
||||
? ''
|
||||
: message.split('|').length > 1
|
||||
? `${t(`messages:${message.split('|')[0]}`)}: ${t(
|
||||
`messages:${message.split('|')[1]}`
|
||||
)}`
|
||||
: t(`messages:${message}`)
|
||||
return (
|
||||
<div className="error-message">
|
||||
{singleMessage !== '' && <code>{singleMessage}</code>}
|
||||
{messages &&
|
||||
messages.length > 0 &&
|
||||
(messages.length === 1 ? (
|
||||
<code>{messages[0].value}</code>
|
||||
) : (
|
||||
<code>
|
||||
<ul>
|
||||
{messages.map(msg => (
|
||||
<li key={msg.id}>{t(`messages:${msg.value}`)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
@ -1,18 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default class NoWorkouts extends React.PureComponent {
|
||||
render() {
|
||||
const { t } = this.props
|
||||
return (
|
||||
<div className="card text-center">
|
||||
<div className="card-body">
|
||||
{t('common:No workouts.')}{' '}
|
||||
<Link to={{ pathname: '/workouts/add' }}>
|
||||
{t('dashboard:Upload one !')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
@ -1,72 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { formatUrl, rangePagination } from '../../utils'
|
||||
|
||||
export default class Pagination extends React.PureComponent {
|
||||
getUrl(value) {
|
||||
const { query, pathname } = this.props
|
||||
const newQuery = Object.assign({}, query)
|
||||
let page = query.page ? +query.page : 1
|
||||
switch (value) {
|
||||
case 'prev':
|
||||
page -= 1
|
||||
break
|
||||
case 'next':
|
||||
page += 1
|
||||
break
|
||||
default:
|
||||
page = +value
|
||||
}
|
||||
newQuery.page = page
|
||||
return formatUrl(pathname, newQuery)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { pagination, t } = this.props
|
||||
return (
|
||||
<>
|
||||
{pagination && Object.keys(pagination).length > 0 && (
|
||||
<nav aria-label="Page navigation example">
|
||||
<ul className="pagination justify-content-center">
|
||||
<li
|
||||
className={`page-item ${pagination.has_prev ? '' : 'disabled'}`}
|
||||
>
|
||||
<Link
|
||||
className="page-link"
|
||||
to={this.getUrl('prev')}
|
||||
aria-disabled={!pagination.has_prev}
|
||||
>
|
||||
{t('common:Previous')}
|
||||
</Link>
|
||||
</li>
|
||||
{rangePagination(pagination.pages).map(page => (
|
||||
<li
|
||||
key={page}
|
||||
className={`page-item ${
|
||||
page === pagination.page ? 'active' : ''
|
||||
}`}
|
||||
>
|
||||
<Link className="page-link" to={this.getUrl(page)}>
|
||||
{page}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
<li
|
||||
className={`page-item ${pagination.has_next ? '' : 'disabled'}`}
|
||||
>
|
||||
<Link
|
||||
className="page-link"
|
||||
to={this.getUrl('next')}
|
||||
aria-disabled={!pagination.has_next}
|
||||
>
|
||||
{t('common:Next')}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
@ -1,29 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
import { apiUrl } from '../../utils'
|
||||
|
||||
export default class StaticMap extends React.PureComponent {
|
||||
render() {
|
||||
const { display, workout } = this.props
|
||||
|
||||
return (
|
||||
<div className={`workout-map${display === 'list' ? '-list' : ''}`}>
|
||||
<img
|
||||
src={`${apiUrl}workouts/map/${workout.map}?${Date.now()}`}
|
||||
alt="workout map"
|
||||
/>
|
||||
<div className={`map-attribution${display === 'list' ? '-list' : ''}`}>
|
||||
<span className="map-attribution-text">©</span>
|
||||
<a
|
||||
className="map-attribution-text"
|
||||
href="http://www.openstreetmap.org/copyright"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
OpenStreetMap
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
@ -1,30 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
import { formatValue } from '../../../utils/stats'
|
||||
|
||||
/**
|
||||
* @return {null}
|
||||
*/
|
||||
export default function CustomLabel(props) {
|
||||
const { displayedData, x, y, width, value } = props
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
const radius = 10
|
||||
const formattedValue = formatValue(displayedData, value)
|
||||
|
||||
return (
|
||||
<g>
|
||||
<text
|
||||
x={x + width / 2}
|
||||
y={y - radius}
|
||||
fill="#666"
|
||||
fontSize="11"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
{formattedValue}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
}
|
@ -1,36 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
import { formatDuration } from '../../../utils/stats'
|
||||
|
||||
const formatValue = (displayedData, value) =>
|
||||
displayedData === 'duration'
|
||||
? formatDuration(value, true)
|
||||
: ['distance', 'ascent', 'descent'].includes(displayedData)
|
||||
? value.toFixed(2)
|
||||
: value
|
||||
|
||||
/**
|
||||
* @return {null}
|
||||
*/
|
||||
export default function CustomTooltip(props) {
|
||||
const { active } = props
|
||||
if (active) {
|
||||
const { displayedData, payload, label } = props
|
||||
let total = 0
|
||||
payload.map(p => (total += p.value))
|
||||
return (
|
||||
<div className="custom-tooltip">
|
||||
<p className="custom-tooltip-label">{label}</p>
|
||||
{payload.map(p => (
|
||||
<p key={p.name} style={{ color: p.fill }}>
|
||||
{p.name}: {formatValue(displayedData, p.value)} {p.unit}
|
||||
</p>
|
||||
))}
|
||||
{payload.length > 0 && (
|
||||
<p>Total: {formatValue(displayedData, total)}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
@ -1,122 +0,0 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
|
||||
import { formatValue } from '../../../utils/stats'
|
||||
import { workoutColors } from '../../../utils/workouts'
|
||||
import CustomTooltip from './CustomTooltip'
|
||||
import CustomLabel from './CustomLabel'
|
||||
|
||||
export default class StatsCharts extends React.PureComponent {
|
||||
constructor(props, context) {
|
||||
super(props, context)
|
||||
this.state = {
|
||||
displayedData: 'distance',
|
||||
}
|
||||
}
|
||||
handleRadioChange(changeEvent) {
|
||||
this.setState({
|
||||
displayedData: changeEvent.target.name,
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
const { displayedData } = this.state
|
||||
const { sports, stats, t, withElevation } = this.props
|
||||
if (Object.keys(stats).length === 0) {
|
||||
return t('common:No workouts.')
|
||||
}
|
||||
return (
|
||||
<div className="chart-stats">
|
||||
<div className="row chart-radio">
|
||||
<label className="radioLabel col">
|
||||
<input
|
||||
type="radio"
|
||||
name="distance"
|
||||
checked={displayedData === 'distance'}
|
||||
onChange={e => this.handleRadioChange(e)}
|
||||
/>
|
||||
{t('statistics:distance')}
|
||||
</label>
|
||||
<label className="radioLabel col">
|
||||
<input
|
||||
type="radio"
|
||||
name="duration"
|
||||
checked={displayedData === 'duration'}
|
||||
onChange={e => this.handleRadioChange(e)}
|
||||
/>
|
||||
{t('statistics:duration')}
|
||||
</label>
|
||||
{withElevation && (
|
||||
<>
|
||||
<label className="radioLabel col">
|
||||
<input
|
||||
type="radio"
|
||||
name="ascent"
|
||||
checked={displayedData === 'ascent'}
|
||||
onChange={e => this.handleRadioChange(e)}
|
||||
/>
|
||||
{t('statistics:ascent')}
|
||||
</label>
|
||||
<label className="radioLabel col">
|
||||
<input
|
||||
type="radio"
|
||||
name="descent"
|
||||
checked={displayedData === 'descent'}
|
||||
onChange={e => this.handleRadioChange(e)}
|
||||
/>
|
||||
{t('statistics:descent')}
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<label className="radioLabel col">
|
||||
<input
|
||||
type="radio"
|
||||
name="workouts"
|
||||
checked={displayedData === 'workouts'}
|
||||
onChange={e => this.handleRadioChange(e)}
|
||||
/>
|
||||
{t('statistics:workouts')}
|
||||
</label>
|
||||
</div>
|
||||
<ResponsiveContainer height={300}>
|
||||
<BarChart data={stats[displayedData]} margin={{ top: 15, bottom: 0 }}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
interval={0} // to force to display all ticks
|
||||
/>
|
||||
<YAxis tickFormatter={value => formatValue(displayedData, value)} />
|
||||
<Tooltip
|
||||
content={<CustomTooltip displayedData={displayedData} />}
|
||||
/>
|
||||
{sports.map((s, i) => (
|
||||
<Bar
|
||||
// disable for now due to problems w/ CustomLabel
|
||||
// see https://github.com/recharts/recharts/issues/829
|
||||
isAnimationActive={false}
|
||||
key={s.id}
|
||||
dataKey={s.label}
|
||||
stackId="a"
|
||||
fill={workoutColors[i]}
|
||||
label={
|
||||
i === sports.length - 1 ? (
|
||||
<CustomLabel displayedData={displayedData} />
|
||||
) : (
|
||||
''
|
||||
)
|
||||
}
|
||||
name={t(`sports:${s.label}`)}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
@ -1,88 +0,0 @@
|
||||
import { format } from 'date-fns'
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import { getStats } from '../../../actions/stats'
|
||||
import { formatStats } from '../../../utils/stats'
|
||||
import StatsChart from './StatsChart'
|
||||
|
||||
class Statistics extends React.PureComponent {
|
||||
componentDidMount() {
|
||||
this.updateData()
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (
|
||||
(this.props.user.username &&
|
||||
this.props.user.username !== prevProps.user.username) ||
|
||||
this.props.statsParams !== prevProps.statsParams
|
||||
) {
|
||||
this.updateData()
|
||||
}
|
||||
}
|
||||
|
||||
updateData() {
|
||||
if (this.props.user.username) {
|
||||
this.props.loadWorkouts(
|
||||
this.props.user.username,
|
||||
this.props.user.weekm,
|
||||
this.props.statsParams
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
displayedSports,
|
||||
sports,
|
||||
statistics,
|
||||
statsParams,
|
||||
displayEmpty,
|
||||
t,
|
||||
user,
|
||||
withElevation,
|
||||
} = this.props
|
||||
if (!displayEmpty && Object.keys(statistics).length === 0) {
|
||||
return <span>{t('common:No workouts.')}</span>
|
||||
}
|
||||
const stats = formatStats(
|
||||
statistics,
|
||||
sports,
|
||||
statsParams,
|
||||
displayedSports,
|
||||
user.weekm
|
||||
)
|
||||
return (
|
||||
<StatsChart
|
||||
sports={sports}
|
||||
stats={stats}
|
||||
t={t}
|
||||
withElevation={withElevation}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
sports: state.sports.data,
|
||||
statistics: state.statistics.data,
|
||||
user: state.user,
|
||||
}),
|
||||
dispatch => ({
|
||||
loadWorkouts: (userName, weekm, data) => {
|
||||
const dateFormat = 'yyyy-MM-dd'
|
||||
// depends on user config (first day of week)
|
||||
const time =
|
||||
data.duration === 'week'
|
||||
? `${data.duration}${weekm ? 'm' : ''}`
|
||||
: data.duration
|
||||
const params = {
|
||||
from: format(data.start, dateFormat),
|
||||
to: format(data.end, dateFormat),
|
||||
time: time,
|
||||
}
|
||||
dispatch(getStats(userName, data.type, params))
|
||||
},
|
||||
})
|
||||
)(Statistics)
|
@ -1,198 +0,0 @@
|
||||
// eslint-disable-next-line max-len
|
||||
// source: https://blog.flowandform.agency/create-a-custom-calendar-in-react-3df1bfd0b728
|
||||
import {
|
||||
addDays,
|
||||
addMonths,
|
||||
endOfMonth,
|
||||
endOfWeek,
|
||||
format,
|
||||
isSameDay,
|
||||
isSameMonth,
|
||||
isToday,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
subMonths,
|
||||
} from 'date-fns'
|
||||
import { enGB, fr } from 'date-fns/locale'
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import CalendarWorkouts from './CalendarWorkouts'
|
||||
import { getMonthWorkouts } from '../../actions/workouts'
|
||||
import { getDateWithTZ } from '../../utils'
|
||||
|
||||
const getStartAndEndMonth = (date, weekStartOnMonday) => {
|
||||
const monthStart = startOfMonth(date)
|
||||
const monthEnd = endOfMonth(date)
|
||||
const weekStartsOn = weekStartOnMonday ? 1 : 0
|
||||
return {
|
||||
start: startOfWeek(monthStart, { weekStartsOn }),
|
||||
end: endOfWeek(monthEnd),
|
||||
}
|
||||
}
|
||||
|
||||
class Calendar extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context)
|
||||
const calendarDate = new Date()
|
||||
this.state = {
|
||||
currentMonth: calendarDate,
|
||||
startDate: getStartAndEndMonth(calendarDate, props.weekm).start,
|
||||
endDate: getStartAndEndMonth(calendarDate, props.weekm).end,
|
||||
weekStartOnMonday: props.weekm,
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.loadMonthWorkouts(this.state.startDate, this.state.endDate)
|
||||
}
|
||||
|
||||
renderHeader(localeOptions) {
|
||||
const dateFormat = 'MMM yyyy'
|
||||
return (
|
||||
<div className="header row flex-middle">
|
||||
<div className="col col-start" onClick={() => this.handlePrevMonth()}>
|
||||
<i className="fa fa-chevron-left" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="col col-center">
|
||||
<span>
|
||||
{format(this.state.currentMonth, dateFormat, localeOptions)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="col col-end" onClick={() => this.handleNextMonth()}>
|
||||
<i className="fa fa-chevron-right" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
renderDays(localeOptions) {
|
||||
const dateFormat = 'EEE'
|
||||
const days = []
|
||||
const { startDate } = this.state
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
days.push(
|
||||
<div className="col col-center" key={i}>
|
||||
{format(addDays(startDate, i), dateFormat, localeOptions)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <div className="days row">{days}</div>
|
||||
}
|
||||
|
||||
filterWorkouts(day) {
|
||||
const { workouts, user } = this.props
|
||||
if (workouts) {
|
||||
return workouts
|
||||
.filter(act =>
|
||||
isSameDay(getDateWithTZ(act.workout_date, user.timezone), day)
|
||||
)
|
||||
.reverse()
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
renderCells() {
|
||||
const { currentMonth, startDate, endDate, weekStartOnMonday } = this.state
|
||||
const { sports } = this.props
|
||||
|
||||
const dateFormat = 'd'
|
||||
const rows = []
|
||||
|
||||
let days = []
|
||||
let day = startDate
|
||||
let formattedDate = ''
|
||||
|
||||
while (day <= endDate) {
|
||||
for (let i = 0; i < 7; i++) {
|
||||
formattedDate = format(day, dateFormat)
|
||||
const dayWorkouts = this.filterWorkouts(day)
|
||||
const isDisabled = isSameMonth(day, currentMonth) ? '' : '-disabled'
|
||||
const isWeekEnd = weekStartOnMonday
|
||||
? [5, 6].includes(i)
|
||||
: [0, 6].includes(i)
|
||||
days.push(
|
||||
<div
|
||||
className={`col cell ${isWeekEnd ? ' weekend' : ''}${
|
||||
isToday(day) ? ' today' : ''
|
||||
}`}
|
||||
key={day}
|
||||
>
|
||||
<div className={`img${isDisabled}`}>
|
||||
<span className="number">{formattedDate}</span>
|
||||
<CalendarWorkouts
|
||||
dayWorkouts={dayWorkouts}
|
||||
isDisabled={isDisabled}
|
||||
sports={sports}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
day = addDays(day, 1)
|
||||
}
|
||||
rows.push(
|
||||
<div className="row" key={day}>
|
||||
{days}
|
||||
</div>
|
||||
)
|
||||
days = []
|
||||
}
|
||||
return <div className="body">{rows}</div>
|
||||
}
|
||||
|
||||
updateStateDate(calendarDate) {
|
||||
const { start, end } = getStartAndEndMonth(
|
||||
calendarDate,
|
||||
this.state.weekStartOnMonday
|
||||
)
|
||||
this.setState({
|
||||
currentMonth: calendarDate,
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
})
|
||||
this.props.loadMonthWorkouts(start, end)
|
||||
}
|
||||
|
||||
handleNextMonth() {
|
||||
const calendarDate = addMonths(this.state.currentMonth, 1)
|
||||
this.updateStateDate(calendarDate)
|
||||
}
|
||||
|
||||
handlePrevMonth() {
|
||||
const calendarDate = subMonths(this.state.currentMonth, 1)
|
||||
this.updateStateDate(calendarDate)
|
||||
}
|
||||
|
||||
render() {
|
||||
const localeOptions = {
|
||||
locale: this.props.language === 'fr' ? fr : enGB,
|
||||
}
|
||||
return (
|
||||
<div className="card workout-card">
|
||||
<div className="calendar">
|
||||
{this.renderHeader(localeOptions)}
|
||||
{this.renderDays(localeOptions)}
|
||||
{this.renderCells()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
workouts: state.calendarWorkouts.data,
|
||||
language: state.language,
|
||||
sports: state.sports.data,
|
||||
user: state.user,
|
||||
}),
|
||||
dispatch => ({
|
||||
loadMonthWorkouts: (start, end) => {
|
||||
const dateFormat = 'yyyy-MM-dd'
|
||||
dispatch(
|
||||
getMonthWorkouts(format(start, dateFormat), format(end, dateFormat))
|
||||
)
|
||||
},
|
||||
})
|
||||
)(Calendar)
|
@ -1,39 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { recordsLabels } from '../../utils/workouts'
|
||||
|
||||
export default function CalendarWorkout(props) {
|
||||
const { isDisabled, isMore, sportImg, workout } = props
|
||||
return (
|
||||
<Link
|
||||
className={`calendar-workout${isMore}`}
|
||||
to={`/workouts/${workout.id}`}
|
||||
>
|
||||
<>
|
||||
<img
|
||||
alt="workout sport logo"
|
||||
className={`workout-sport ${isDisabled}`}
|
||||
src={sportImg}
|
||||
title={workout.title}
|
||||
/>
|
||||
{workout.records.length > 0 && (
|
||||
<sup>
|
||||
<i
|
||||
className="fa fa-trophy custom-fa-small"
|
||||
aria-hidden="true"
|
||||
title={workout.records.map(
|
||||
rec =>
|
||||
` ${
|
||||
recordsLabels.filter(
|
||||
r => r.record_type === rec.record_type
|
||||
)[0].label
|
||||
}`
|
||||
)}
|
||||
/>
|
||||
</sup>
|
||||
)}
|
||||
</>
|
||||
</Link>
|
||||
)
|
||||
}
|
@ -1,59 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
import CalendarWorkout from './CalendarWorkout'
|
||||
|
||||
export default class CalendarWorkouts extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context)
|
||||
this.state = {
|
||||
isHidden: true,
|
||||
}
|
||||
}
|
||||
|
||||
handleDisplayMore() {
|
||||
this.setState({
|
||||
isHidden: !this.state.isHidden,
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
const { dayWorkouts, isDisabled, sports } = this.props
|
||||
const { isHidden } = this.state
|
||||
return (
|
||||
<div>
|
||||
{dayWorkouts.map(act => (
|
||||
<CalendarWorkout
|
||||
key={act.id}
|
||||
workout={act}
|
||||
isDisabled={isDisabled}
|
||||
isMore=""
|
||||
sportImg={sports.filter(s => s.id === act.sport_id).map(s => s.img)}
|
||||
/>
|
||||
))}
|
||||
{dayWorkouts.length > 2 && (
|
||||
<i
|
||||
className={`fa fa-${isHidden ? 'plus' : 'times'} calendar-more`}
|
||||
aria-hidden="true"
|
||||
onClick={() => this.handleDisplayMore()}
|
||||
title="show more workouts"
|
||||
/>
|
||||
)}
|
||||
{!isHidden && (
|
||||
<div className="calendar-display-more">
|
||||
{dayWorkouts.map(act => (
|
||||
<CalendarWorkout
|
||||
key={act.id}
|
||||
workout={act}
|
||||
isDisabled={isDisabled}
|
||||
isMore="-more"
|
||||
sportImg={sports
|
||||
.filter(s => s.id === act.sport_id)
|
||||
.map(s => s.img)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
@ -1,74 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { formatRecord, translateSports } from '../../utils/workouts'
|
||||
|
||||
export default function RecordsCard(props) {
|
||||
const { records, sports, t, user } = props
|
||||
const translatedSports = translateSports(sports, t)
|
||||
const recordsBySport = records.reduce((sportList, record) => {
|
||||
const sport = translatedSports.find(s => s.id === record.sport_id)
|
||||
if (sportList[sport.label] === void 0) {
|
||||
sportList[sport.label] = {
|
||||
img: sport.img,
|
||||
records: [],
|
||||
}
|
||||
}
|
||||
sportList[sport.label].records.push(formatRecord(record, user.timezone))
|
||||
return sportList
|
||||
}, {})
|
||||
|
||||
return (
|
||||
<div className="card workout-card">
|
||||
<div className="card-header">{t('workouts:Personal records')}</div>
|
||||
<div className="card-body">
|
||||
{Object.keys(recordsBySport).length === 0
|
||||
? t('common:No records.')
|
||||
: Object.keys(recordsBySport)
|
||||
.sort()
|
||||
.map(sportLabel => (
|
||||
<div key={sportLabel}>
|
||||
<span className="heading-span">
|
||||
<img
|
||||
alt={`${sportLabel} logo`}
|
||||
className="record-logo"
|
||||
src={recordsBySport[sportLabel].img}
|
||||
/>
|
||||
{sportLabel}
|
||||
</span>
|
||||
{/* eslint-disable-next-line max-len */}
|
||||
<table className="table table-borderless table-sm record-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colSpan="3">
|
||||
<img
|
||||
alt={`${sportLabel} logo`}
|
||||
className="record-logo"
|
||||
src={recordsBySport[sportLabel].img}
|
||||
/>
|
||||
{sportLabel}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recordsBySport[sportLabel].records.map(rec => (
|
||||
<tr className="record-tr" key={rec.id}>
|
||||
<td className="record-td">
|
||||
{t(`workouts:${rec.record_type}`)}
|
||||
</td>
|
||||
<td className="record-td text-right">{rec.value}</td>
|
||||
<td className="record-td text-right">
|
||||
<Link to={`/workouts/${rec.workout_id}`}>
|
||||
{rec.workout_date}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,34 +0,0 @@
|
||||
import { endOfMonth, startOfMonth } from 'date-fns'
|
||||
import React from 'react'
|
||||
|
||||
import Stats from '../Common/Stats'
|
||||
|
||||
export default class Statistics extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context)
|
||||
const date = new Date()
|
||||
this.state = {
|
||||
start: startOfMonth(date),
|
||||
end: endOfMonth(date),
|
||||
duration: 'week',
|
||||
type: 'by_time',
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { t } = this.props
|
||||
return (
|
||||
<div className="card workout-card">
|
||||
<div className="card-header">{t('dashboard:This month')}</div>
|
||||
<div className="card-body">
|
||||
<Stats
|
||||
displayEmpty={false}
|
||||
statsParams={this.state}
|
||||
t={t}
|
||||
withElevation={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
@ -1,78 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function UserStatistics(props) {
|
||||
const { t, user } = props
|
||||
const days = user.total_duration.match(/day/g)
|
||||
? `${user.total_duration.split(' ')[0]} ${
|
||||
user.total_duration.match(/days/g) ? t('common:days') : t('common:day')
|
||||
}`
|
||||
: `0 ${t('common:days')},`
|
||||
let duration = user.total_duration.match(/day/g)
|
||||
? user.total_duration.split(', ')[1]
|
||||
: user.total_duration
|
||||
duration = `${duration.split(':')[0]}h ${duration.split(':')[1]}min`
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-lg-3 col-md-6 col-sm-6">
|
||||
<div className="card workout-card">
|
||||
<div className="card-body row">
|
||||
<div className="col-3">
|
||||
<i className="fa fa-calendar fa-3x fa-color" />
|
||||
</div>
|
||||
<div className="col-9 text-right">
|
||||
<div className="huge">{user.nb_workouts}</div>
|
||||
<div>{`${
|
||||
user.nb_workouts === 1
|
||||
? t('common:workout')
|
||||
: t('common:workouts')
|
||||
}`}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-lg-3 col-md-6 col-sm-6">
|
||||
<div className="card workout-card">
|
||||
<div className="card-body row">
|
||||
<div className="col-3">
|
||||
<i className="fa fa-road fa-3x fa-color" />
|
||||
</div>
|
||||
<div className="col-9 text-right">
|
||||
<div className="huge">
|
||||
{Number(user.total_distance).toFixed(2)}
|
||||
</div>
|
||||
<div>km</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-lg-3 col-md-6 col-sm-6">
|
||||
<div className="card workout-card">
|
||||
<div className="card-body row">
|
||||
<div className="col-3">
|
||||
<i className="fa fa-clock-o fa-3x fa-color" />
|
||||
</div>
|
||||
<div className="col-9 text-right">
|
||||
<div className="huge">{days}</div>
|
||||
<div>{duration}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-lg-3 col-md-6 col-sm-6">
|
||||
<div className="card workout-card">
|
||||
<div className="card-body row">
|
||||
<div className="col-3">
|
||||
<i className="fa fa-tags fa-3x fa-color" />
|
||||
</div>
|
||||
<div className="col-9 text-right">
|
||||
<div className="huge">{user.nb_sports}</div>
|
||||
<div>{`${
|
||||
user.nb_sports === 1 ? t('common:sport') : t('common:sports')
|
||||
}`}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,70 +0,0 @@
|
||||
import { format } from 'date-fns'
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import StaticMap from '../Common/StaticMap'
|
||||
import { getDateWithTZ } from '../../utils'
|
||||
|
||||
export default function WorkoutCard(props) {
|
||||
const { sports, t, user, workout } = props
|
||||
|
||||
return (
|
||||
<div className="card workout-card text-center">
|
||||
<div className="card-header">
|
||||
<Link to={`/workouts/${workout.id}`}>
|
||||
{sports
|
||||
.filter(sport => sport.id === workout.sport_id)
|
||||
.map(sport => t(`sports:${sport.label}`))}{' '}
|
||||
-{' '}
|
||||
{format(
|
||||
getDateWithTZ(workout.workout_date, user.timezone),
|
||||
'dd/MM/yyyy HH:mm'
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="row">
|
||||
{workout.map && (
|
||||
<div className="col">
|
||||
<StaticMap workout={workout} />
|
||||
</div>
|
||||
)}
|
||||
<div className={`col${workout.map ? ' col-with-map' : ''}`}>
|
||||
<p>
|
||||
<i className="fa fa-clock-o" aria-hidden="true" />{' '}
|
||||
{t('workouts:Duration')}: {workout.moving}
|
||||
{workout.map ? (
|
||||
<span>
|
||||
<br />
|
||||
<br />
|
||||
</span>
|
||||
) : (
|
||||
' - '
|
||||
)}
|
||||
<i className="fa fa-road" aria-hidden="true" />{' '}
|
||||
{t('workouts:Distance')}: {workout.distance} km
|
||||
<br />
|
||||
</p>
|
||||
{workout.min_alt && workout.max_alt && (
|
||||
<p>
|
||||
<i className="fi-mountains custom-fa" />
|
||||
{t('workouts:Min. altitude')}: {workout.min_alt}m
|
||||
<br />
|
||||
{t('workouts:Max. altitude')}: {workout.max_alt}m
|
||||
<br />
|
||||
</p>
|
||||
)}
|
||||
{workout.ascent && workout.descent && (
|
||||
<p>
|
||||
<i className="fa fa-location-arrow custom-fa" />
|
||||
{t('workouts:Ascent')}: {workout.ascent}m
|
||||
<br />
|
||||
{t('workouts:Descent')}: {workout.descent}m
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,114 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import { withTranslation } from 'react-i18next'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import Calendar from './Calendar'
|
||||
import Message from '../Common/Message'
|
||||
import NoWorkouts from '../Common/NoWorkouts'
|
||||
import Records from './Records'
|
||||
import Statistics from './Statistics'
|
||||
import UserStatistics from './UserStatistics'
|
||||
import WorkoutCard from './WorkoutCard'
|
||||
import { getOrUpdateData } from '../../actions'
|
||||
import { getMoreWorkouts } from '../../actions/workouts'
|
||||
|
||||
class DashBoard extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context)
|
||||
this.state = {
|
||||
page: 1,
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.loadWorkouts()
|
||||
}
|
||||
|
||||
render() {
|
||||
const { loadMoreWorkouts, message, records, sports, t, user, workouts } =
|
||||
this.props
|
||||
const paginationEnd =
|
||||
workouts.length > 0
|
||||
? workouts[workouts.length - 1].previous_workout === null
|
||||
: true
|
||||
const { page } = this.state
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>FitTrackee - {t('common:Dashboard')}</title>
|
||||
</Helmet>
|
||||
{message ? (
|
||||
<Message message={message} t={t} />
|
||||
) : (
|
||||
workouts &&
|
||||
user.total_duration &&
|
||||
sports.length > 0 && (
|
||||
<div className="container dashboard">
|
||||
<UserStatistics user={user} t={t} />
|
||||
<div className="row">
|
||||
<div className="col-md-4">
|
||||
<Statistics t={t} />
|
||||
<Records
|
||||
t={t}
|
||||
records={records}
|
||||
sports={sports}
|
||||
user={user}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-8">
|
||||
<Calendar weekm={user.weekm} />
|
||||
{workouts.length > 0 ? (
|
||||
workouts.map(workout => (
|
||||
<WorkoutCard
|
||||
workout={workout}
|
||||
key={workout.id}
|
||||
sports={sports}
|
||||
t={t}
|
||||
user={user}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<NoWorkouts t={t} />
|
||||
)}
|
||||
{!paginationEnd && (
|
||||
<input
|
||||
type="submit"
|
||||
className="btn btn-default btn-md btn-block"
|
||||
value="Load more workouts"
|
||||
onClick={() => {
|
||||
loadMoreWorkouts(page + 1)
|
||||
this.setState({ page: page + 1 })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default withTranslation()(
|
||||
connect(
|
||||
state => ({
|
||||
workouts: state.workouts.data,
|
||||
message: state.message,
|
||||
records: state.records.data,
|
||||
sports: state.sports.data,
|
||||
user: state.user,
|
||||
}),
|
||||
dispatch => ({
|
||||
loadWorkouts: () => {
|
||||
dispatch(getOrUpdateData('getData', 'workouts', { page: 1 }))
|
||||
dispatch(getOrUpdateData('getData', 'records'))
|
||||
},
|
||||
loadMoreWorkouts: page => {
|
||||
dispatch(getMoreWorkouts({ page }))
|
||||
},
|
||||
})
|
||||
)(DashBoard)
|
||||
)
|
@ -1,36 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
import { version } from './../../utils'
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="footer">
|
||||
<div className="container">
|
||||
<strong>FitTrackee</strong> v{version} -{' '}
|
||||
<a
|
||||
href="https://github.com/SamR1/FitTrackee"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
source code
|
||||
</a>{' '}
|
||||
under{' '}
|
||||
<a
|
||||
href="https://choosealicense.com/licenses/gpl-3.0/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
GPLv3
|
||||
</a>{' '}
|
||||
license -{' '}
|
||||
<a
|
||||
href="https://samr1.github.io/FitTrackee/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
documentation
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
@ -1,75 +0,0 @@
|
||||
import React, { Component } from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import { ReactComponent as EnFlag } from '../../images/flags/en.svg'
|
||||
import { ReactComponent as FrFlag } from '../../images/flags/fr.svg'
|
||||
import { updateLanguage } from '../../actions/index'
|
||||
|
||||
export const languages = [
|
||||
{
|
||||
name: 'en',
|
||||
selected: true,
|
||||
flag: <EnFlag />,
|
||||
},
|
||||
{
|
||||
name: 'fr',
|
||||
selected: false,
|
||||
flag: <FrFlag />,
|
||||
},
|
||||
]
|
||||
|
||||
class Dropdown extends Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
isOpen: false,
|
||||
}
|
||||
}
|
||||
|
||||
toggleDropdown() {
|
||||
this.setState(prevState => ({
|
||||
isOpen: !prevState.isOpen,
|
||||
}))
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isOpen } = this.state
|
||||
const { language: selected, onUpdateLanguage } = this.props
|
||||
return (
|
||||
<div className="dropdown-wrapper" onClick={() => this.toggleDropdown()}>
|
||||
<ul className="dropdown-list i18n-flag">
|
||||
{languages
|
||||
.filter(language =>
|
||||
isOpen ? language : language.name === selected
|
||||
)
|
||||
.map(language => (
|
||||
<li
|
||||
className={`dropdown-item${
|
||||
language.name === selected && isOpen
|
||||
? ' dropdown-item-selected'
|
||||
: ''
|
||||
}`}
|
||||
key={language.name}
|
||||
onClick={() => onUpdateLanguage(language.name, selected)}
|
||||
>
|
||||
{language.flag} {language.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
language: state.language,
|
||||
}),
|
||||
dispatch => ({
|
||||
onUpdateLanguage: (lang, selected) => {
|
||||
if (lang !== selected) {
|
||||
dispatch(updateLanguage(lang))
|
||||
}
|
||||
},
|
||||
})
|
||||
)(Dropdown)
|
@ -1,173 +0,0 @@
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { withTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import LanguageDropdown from './LanguageDropdown'
|
||||
import { apiUrl } from '../../utils'
|
||||
|
||||
class NavBar extends React.PureComponent {
|
||||
render() {
|
||||
const { admin, isAuthenticated, picture, t, username } = this.props
|
||||
return (
|
||||
<header>
|
||||
<nav className="navbar navbar-expand-lg navbar-light bg-light">
|
||||
<div className="container">
|
||||
<span className="navbar-brand">FitTrackee</span>
|
||||
<button
|
||||
className="navbar-toggler"
|
||||
type="button"
|
||||
data-toggle="collapse"
|
||||
data-target="#navbarSupportedContent"
|
||||
aria-controls="navbarSupportedContent"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<span className="navbar-toggler-icon" />
|
||||
</button>
|
||||
<div
|
||||
className="collapse navbar-collapse"
|
||||
id="navbarSupportedContent"
|
||||
>
|
||||
<ul className="navbar-nav mr-auto">
|
||||
<li className="nav-item">
|
||||
<Link
|
||||
className="nav-link"
|
||||
to={{
|
||||
pathname: '/',
|
||||
}}
|
||||
>
|
||||
{t('common:Dashboard')}
|
||||
</Link>
|
||||
</li>
|
||||
{isAuthenticated && (
|
||||
<li className="nav-item">
|
||||
<Link
|
||||
className="nav-link"
|
||||
to={{
|
||||
pathname: '/workouts/history',
|
||||
}}
|
||||
>
|
||||
{t('Workouts')}
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
{isAuthenticated && (
|
||||
<li className="nav-item">
|
||||
<Link
|
||||
className="nav-link"
|
||||
to={{
|
||||
pathname: '/workouts/statistics',
|
||||
}}
|
||||
>
|
||||
{t('common:Statistics')}
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
{admin && (
|
||||
<li className="nav-item">
|
||||
<Link
|
||||
className="nav-link"
|
||||
to={{
|
||||
pathname: '/admin',
|
||||
}}
|
||||
>
|
||||
Admin
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
{isAuthenticated && (
|
||||
<li className="nav-item">
|
||||
<Link
|
||||
className="nav-link"
|
||||
to={{
|
||||
pathname: '/workouts/add',
|
||||
}}
|
||||
>
|
||||
<strong>{t('common:Add workout')}</strong>
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
{/* prettier-ignore */}
|
||||
<ul
|
||||
className="navbar-nav flex-row ml-md-auto d-none d-md-flex"
|
||||
>
|
||||
{!isAuthenticated && (
|
||||
<li className="nav-item">
|
||||
<Link
|
||||
className="nav-link"
|
||||
to={{
|
||||
pathname: '/register',
|
||||
}}
|
||||
>
|
||||
{t('user:Register')}
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
{!isAuthenticated && (
|
||||
<li className="nav-item">
|
||||
<Link
|
||||
className="nav-link"
|
||||
to={{
|
||||
pathname: '/login',
|
||||
}}
|
||||
>
|
||||
{t('user:Login')}
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
{picture === true ? (
|
||||
<img
|
||||
alt="Avatar"
|
||||
src={`${apiUrl}users/${username}/picture?${Date.now()}`}
|
||||
className="img-fluid App-nav-profile-img"
|
||||
/>
|
||||
) : (
|
||||
<i
|
||||
className="fa fa-user-circle-o fa-2x no-picture"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<li className="nav-item">
|
||||
<Link
|
||||
className="nav-link"
|
||||
to={{
|
||||
pathname: '/profile',
|
||||
}}
|
||||
>
|
||||
{username}
|
||||
</Link>
|
||||
</li>
|
||||
<li className="nav-item">
|
||||
<Link
|
||||
className="nav-link"
|
||||
to={{
|
||||
pathname: '/logout',
|
||||
}}
|
||||
>
|
||||
{t('user:Logout')}
|
||||
</Link>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
<li><LanguageDropdown /></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default withTranslation()(
|
||||
connect(({ user }) => ({
|
||||
admin: user.admin,
|
||||
isAuthenticated: user.isAuthenticated,
|
||||
picture: user.picture,
|
||||
username: user.username,
|
||||
}))(NavBar)
|
||||
)
|
@ -1,24 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Helmet } from 'react-helmet'
|
||||
|
||||
export default function AccessDenied(props) {
|
||||
const { t } = props
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>FitTrackee - {t('Access denied')}</title>
|
||||
</Helmet>
|
||||
<div className="row">
|
||||
<div className="col-2" />
|
||||
<div className="card col-8">
|
||||
<div className="card-body">
|
||||
<div className="text-center">
|
||||
{t("You don't have permissions to access this page.")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-2" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function NotFound() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>fittrackee - 404</title>
|
||||
</Helmet>
|
||||
<h1 className="page-title">{t('Page not found')}</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Provider } from 'react-redux'
|
||||
import { ConnectedRouter } from 'connected-react-router'
|
||||
|
||||
export default function Root({ store, history, children }) {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<ConnectedRouter history={history}>{children}</ConnectedRouter>
|
||||
</Provider>
|
||||
)
|
||||
}
|
@ -1,209 +0,0 @@
|
||||
import {
|
||||
endOfMonth,
|
||||
endOfWeek,
|
||||
endOfYear,
|
||||
startOfMonth,
|
||||
startOfYear,
|
||||
startOfWeek,
|
||||
addMonths,
|
||||
addWeeks,
|
||||
addYears,
|
||||
subMonths,
|
||||
subWeeks,
|
||||
subYears,
|
||||
} from 'date-fns'
|
||||
import React from 'react'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import { withTranslation } from 'react-i18next'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import NoWorkouts from '../Common/NoWorkouts'
|
||||
import Stats from '../Common/Stats'
|
||||
import { workoutColors, translateSports } from '../../utils/workouts'
|
||||
|
||||
const durations = ['week', 'month', 'year']
|
||||
|
||||
class Statistics extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context)
|
||||
const date = new Date()
|
||||
this.state = {
|
||||
displayedSports: props.sports.map(sport => sport.id),
|
||||
statsParams: {
|
||||
start: startOfMonth(subMonths(date, 11)),
|
||||
end: endOfMonth(date),
|
||||
duration: 'month',
|
||||
type: 'by_time',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.sports !== prevProps.sports) {
|
||||
this.updateDisplayedSports()
|
||||
}
|
||||
}
|
||||
|
||||
updateDisplayedSports() {
|
||||
const { sports } = this.props
|
||||
this.setState({ displayedSports: sports.map(sport => sport.id) })
|
||||
}
|
||||
|
||||
handleOnChangeDuration(e) {
|
||||
const duration = e.target.name
|
||||
|
||||
const date = new Date()
|
||||
const start =
|
||||
duration === 'year'
|
||||
? startOfYear(subYears(date, 9))
|
||||
: duration === 'week'
|
||||
? startOfMonth(subMonths(date, 2))
|
||||
: startOfMonth(subMonths(date, 11))
|
||||
const end =
|
||||
duration === 'year'
|
||||
? endOfYear(date)
|
||||
: duration === 'week'
|
||||
? endOfWeek(date)
|
||||
: endOfMonth(date)
|
||||
this.setState({ statsParams: { duration, end, start, type: 'by_time' } })
|
||||
}
|
||||
|
||||
handleOnChangeSports(sportId) {
|
||||
const { displayedSports } = this.state
|
||||
if (displayedSports.includes(sportId)) {
|
||||
this.setState({
|
||||
displayedSports: displayedSports.filter(s => s !== sportId),
|
||||
})
|
||||
} else {
|
||||
this.setState({ displayedSports: displayedSports.concat([sportId]) })
|
||||
}
|
||||
}
|
||||
|
||||
handleOnClickArrows(forward) {
|
||||
const { start, end, duration } = this.state.statsParams
|
||||
let newStart, newEnd
|
||||
if (forward) {
|
||||
newStart =
|
||||
duration === 'year'
|
||||
? startOfYear(subYears(start, 1))
|
||||
: duration === 'week'
|
||||
? startOfWeek(subWeeks(start, 1))
|
||||
: startOfMonth(subMonths(start, 1))
|
||||
newEnd =
|
||||
duration === 'year'
|
||||
? endOfYear(subYears(end, 1))
|
||||
: duration === 'week'
|
||||
? endOfWeek(subWeeks(end, 1))
|
||||
: endOfMonth(subMonths(end, 1))
|
||||
} else {
|
||||
newStart =
|
||||
duration === 'year'
|
||||
? startOfYear(addYears(start, 1))
|
||||
: duration === 'week'
|
||||
? startOfWeek(addWeeks(start, 1))
|
||||
: startOfMonth(addMonths(start, 1))
|
||||
newEnd =
|
||||
duration === 'year'
|
||||
? endOfYear(addYears(end, 1))
|
||||
: duration === 'week'
|
||||
? endOfWeek(addWeeks(end, 1))
|
||||
: endOfMonth(addMonths(end, 1))
|
||||
}
|
||||
this.setState({
|
||||
statsParams: { duration, end: newEnd, start: newStart, type: 'by_time' },
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
const { displayedSports, statsParams } = this.state
|
||||
const { sports, t, user } = this.props
|
||||
const translatedSports = translateSports(
|
||||
sports.filter(sport => user.sports_list.includes(sport.id)),
|
||||
t
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>FitTrackee - {t('statistics:Statistics')}</title>
|
||||
</Helmet>
|
||||
<div className="container dashboard">
|
||||
<div className="card workout-card">
|
||||
<div className="card-header">{t('statistics:Statistics')}</div>
|
||||
<div
|
||||
className={`card-body${
|
||||
user.nb_workouts === 0 ? ' stats-disabled' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="chart-filters row">
|
||||
<div className="col chart-arrows">
|
||||
<p className="text-center">
|
||||
<i
|
||||
className="fa fa-chevron-left"
|
||||
aria-hidden="true"
|
||||
onClick={() => this.handleOnClickArrows(true)}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-md-3 time-frames justify-content-around">
|
||||
{durations.map(d => (
|
||||
<div className="time-frame" key={d}>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
id={d}
|
||||
name={d}
|
||||
checked={d === statsParams.duration}
|
||||
onChange={e => this.handleOnChangeDuration(e)}
|
||||
/>
|
||||
<span>{t(`statistics:${d}`)}</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="col chart-arrows">
|
||||
<p className="text-center">
|
||||
<i
|
||||
className="fa fa-chevron-right"
|
||||
aria-hidden="true"
|
||||
onClick={() => this.handleOnClickArrows(false)}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Stats
|
||||
displayEmpty
|
||||
displayedSports={displayedSports}
|
||||
statsParams={statsParams}
|
||||
t={t}
|
||||
withElevation
|
||||
/>
|
||||
<div className="row chart-workouts">
|
||||
{translatedSports.map(sport => (
|
||||
<label className="col workout-label" key={sport.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={displayedSports.includes(sport.id)}
|
||||
name={sport.label}
|
||||
onChange={() => this.handleOnChangeSports(sport.id)}
|
||||
/>
|
||||
<span style={{ color: workoutColors[sport.id - 1] }}>
|
||||
{` ${sport.label}`}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{user.nb_workouts === 0 && <NoWorkouts t={t} />}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default withTranslation()(
|
||||
connect(state => ({
|
||||
sports: state.sports.data,
|
||||
user: state.user,
|
||||
}))(Statistics)
|
||||
)
|
@ -1,19 +0,0 @@
|
||||
import React from 'react'
|
||||
import { withTranslation } from 'react-i18next'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import ProfileDetail from './ProfileDetail'
|
||||
|
||||
function CurrentUserProfile({ t, user }) {
|
||||
return (
|
||||
<div>
|
||||
<ProfileDetail editable t={t} user={user} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default withTranslation()(
|
||||
connect(state => ({
|
||||
user: state.user,
|
||||
}))(CurrentUserProfile)
|
||||
)
|
@ -1,125 +0,0 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { history } from '../../index'
|
||||
|
||||
export default function Form(props) {
|
||||
const { t } = useTranslation()
|
||||
const pageTitle = `user:${props.formType
|
||||
.charAt(0)
|
||||
.toUpperCase()}${props.formType.slice(1)}`
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>FitTrackee - {t(`user:${props.formType}`)}</title>
|
||||
</Helmet>
|
||||
<h1 className="page-title">{t(pageTitle)}</h1>
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-md-3" />
|
||||
<div className="col-md-6">
|
||||
<br />
|
||||
{props.formType === 'register' && !props.isRegistrationAllowed ? (
|
||||
<div className="card">
|
||||
<div className="card-body">Registration is disabled.</div>
|
||||
<div className="card-body">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-secondary btn-lg btn-block"
|
||||
onClick={() => history.go(-1)}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<form
|
||||
onSubmit={event =>
|
||||
props.handleUserFormSubmit(event, props.formType)
|
||||
}
|
||||
>
|
||||
{props.formType === 'register' && (
|
||||
<div className="form-group">
|
||||
<input
|
||||
className="form-control input-lg"
|
||||
name="username"
|
||||
placeholder={t('user:Enter a username')}
|
||||
required
|
||||
type="text"
|
||||
value={props.userForm.username}
|
||||
onChange={props.onHandleFormChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{props.formType !== 'password reset' && (
|
||||
<div className="form-group">
|
||||
<input
|
||||
className="form-control input-lg"
|
||||
name="email"
|
||||
placeholder={t('user:Enter an email address')}
|
||||
required
|
||||
type="email"
|
||||
value={props.userForm.email}
|
||||
onChange={props.onHandleFormChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{props.formType !== 'reset your password' && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<input
|
||||
className="form-control input-lg"
|
||||
name="password"
|
||||
placeholder={t('user:Enter a password')}
|
||||
required
|
||||
type="password"
|
||||
value={props.userForm.password}
|
||||
onChange={props.onHandleFormChange}
|
||||
/>
|
||||
</div>
|
||||
{props.formType !== 'login' && (
|
||||
<div className="form-group">
|
||||
<input
|
||||
className="form-control input-lg"
|
||||
name="password_conf"
|
||||
placeholder={t(
|
||||
'user:Enter the password confirmation'
|
||||
)}
|
||||
required
|
||||
type="password"
|
||||
value={props.userForm.password_conf}
|
||||
onChange={props.onHandleFormChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
type="submit"
|
||||
className="btn btn-primary btn-lg btn-block"
|
||||
value={t('Submit')}
|
||||
/>
|
||||
</form>
|
||||
<p className="password-forget">
|
||||
{props.formType === 'login' && (
|
||||
<Link
|
||||
to={{
|
||||
pathname: '/password-reset/request',
|
||||
}}
|
||||
>
|
||||
{t('user:Forgot password?')}
|
||||
</Link>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-md-3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,43 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Trans } from 'react-i18next'
|
||||
import { connect } from 'react-redux'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { logout } from '../../actions/user'
|
||||
|
||||
class Logout extends React.Component {
|
||||
componentDidMount() {
|
||||
this.props.UserLogout()
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<div className="container dashboard">
|
||||
<div className="row">
|
||||
<div className="col-2" />
|
||||
<div className="card col-8">
|
||||
<div className="card-body">
|
||||
<div className="text-center">
|
||||
<Trans i18nKey="user:loggedOut">
|
||||
You are now logged out. Click <Link to="/login">here</Link> to
|
||||
log back in.
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-2" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
user: state.user,
|
||||
}),
|
||||
dispatch => ({
|
||||
UserLogout: () => {
|
||||
dispatch(logout())
|
||||
},
|
||||
})
|
||||
)(Logout)
|
@ -1,48 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { ReactComponent as Password } from '../../images/password.svg'
|
||||
import { ReactComponent as MailSend } from '../../images/mail-send.svg'
|
||||
|
||||
export default function PasswordReset(props) {
|
||||
const { t } = useTranslation()
|
||||
const { action } = props
|
||||
return (
|
||||
<div className="container dashboard">
|
||||
<div className="row">
|
||||
<div className="col-2" />
|
||||
<div className="card col-8">
|
||||
<div className="card-body">
|
||||
<div className="text-center ">
|
||||
{action === 'sent' && (
|
||||
<>
|
||||
<div className="svg-icon">
|
||||
<MailSend />
|
||||
</div>
|
||||
{t(
|
||||
// eslint-disable-next-line max-len
|
||||
"user:Check your email. If your address is in our database, you'll received an email with a link to reset your password."
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{action === 'updated' && (
|
||||
<>
|
||||
<div className="svg-icon">
|
||||
<Password />
|
||||
</div>
|
||||
<Trans i18nKey="user:updatedPasswordText">
|
||||
{/* prettier-ignore */}
|
||||
Your password have been updated. Click
|
||||
<Link to="/login">here</Link> to log in.
|
||||
</Trans>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-2" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,199 +0,0 @@
|
||||
import { format } from 'date-fns'
|
||||
import React from 'react'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import { withTranslation } from 'react-i18next'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import Message from '../Common/Message'
|
||||
import { deletePicture, uploadPicture } from '../../actions/user'
|
||||
import { apiUrl, getFileSize } from '../../utils'
|
||||
import { history } from '../../index'
|
||||
|
||||
function ProfileDetail({
|
||||
appConfig,
|
||||
displayModal,
|
||||
editable,
|
||||
isDeletable,
|
||||
message,
|
||||
onDeletePicture,
|
||||
onUploadPicture,
|
||||
pathname,
|
||||
t,
|
||||
user,
|
||||
}) {
|
||||
const createdAt = user.created_at
|
||||
? format(new Date(user.created_at), 'dd/MM/yyyy HH:mm')
|
||||
: ''
|
||||
const birthDate = user.birth_date
|
||||
? format(new Date(user.birth_date), 'dd/MM/yyyy')
|
||||
: ''
|
||||
const fileSizeLimit = getFileSize(appConfig.max_single_file_size)
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>FitTrackee - {t('user:Profile')}</title>
|
||||
</Helmet>
|
||||
<Message message={message} t={t} />
|
||||
<div className="container">
|
||||
<h1 className="page-title">{t('user:Profile')}</h1>
|
||||
<div className="row">
|
||||
<div className="col-md-12">
|
||||
<div className="card">
|
||||
<div className="card-header userName">
|
||||
<strong>{user.username}</strong>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="row">
|
||||
<div className="col-md-8">
|
||||
<p>
|
||||
{/* eslint-disable-next-line max-len */}
|
||||
<span className="user-label">
|
||||
{t('user:Email')}
|
||||
</span>: {user.email}
|
||||
</p>
|
||||
<p>
|
||||
<span className="user-label">
|
||||
{t('user:Registration Date')}
|
||||
</span>
|
||||
: {createdAt}
|
||||
</p>
|
||||
<p>
|
||||
<span className="user-label">{t('user:First Name')}</span>
|
||||
: {user.first_name}
|
||||
</p>
|
||||
<p>
|
||||
{/* eslint-disable-next-line max-len */}
|
||||
<span className="user-label">
|
||||
{t('user:Last Name')}
|
||||
</span>: {user.last_name}
|
||||
</p>
|
||||
<p>
|
||||
<span className="user-label">{t('user:Birth Date')}</span>
|
||||
: {birthDate}
|
||||
</p>
|
||||
<p>
|
||||
{/* eslint-disable-next-line max-len */}
|
||||
<span className="user-label">
|
||||
{t('user:Location')}
|
||||
</span>: {user.location}
|
||||
</p>
|
||||
<p>
|
||||
<span className="user-label">{t('user:Bio')}</span>:{' '}
|
||||
<span className="user-bio">{user.bio}</span>
|
||||
</p>
|
||||
<p>
|
||||
{/* eslint-disable-next-line max-len */}
|
||||
<span className="user-label">
|
||||
{t('user:Language')}
|
||||
</span>: {user.language}
|
||||
</p>
|
||||
<p>
|
||||
{/* eslint-disable-next-line max-len */}
|
||||
<span className="user-label">
|
||||
{t('user:Timezone')}
|
||||
</span>: {user.timezone}
|
||||
</p>
|
||||
<p>
|
||||
<span className="user-label">
|
||||
{t('user:First day of week')}
|
||||
</span>
|
||||
: {user.weekm ? t('user:Monday') : t('user:Sunday')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
{user.picture === true && (
|
||||
<div>
|
||||
<img
|
||||
alt="Profile"
|
||||
src={
|
||||
`${apiUrl}users/${user.username}/picture` +
|
||||
`?${Date.now()}`
|
||||
}
|
||||
className="img-fluid App-profile-img-small"
|
||||
/>
|
||||
{editable && (
|
||||
<>
|
||||
<br />
|
||||
<button
|
||||
type="submit"
|
||||
onClick={() => onDeletePicture()}
|
||||
>
|
||||
{t('user:Delete picture')}
|
||||
</button>
|
||||
<br />
|
||||
<br />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{editable && (
|
||||
<form
|
||||
encType="multipart/form-data"
|
||||
onSubmit={event => onUploadPicture(event)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
name="picture"
|
||||
accept=".png,.jpg,.gif"
|
||||
/>
|
||||
<br />
|
||||
<button type="submit">{t('user:Send')}</button>
|
||||
{` (max. size: ${fileSizeLimit})`}
|
||||
</form>
|
||||
)}{' '}
|
||||
</div>
|
||||
</div>
|
||||
{editable && (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => history.push('/profile/edit')}
|
||||
>
|
||||
{t('common:Edit')}
|
||||
</button>
|
||||
)}
|
||||
{isDeletable && (
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => displayModal(true)}
|
||||
>
|
||||
{t('user:Delete user account')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() =>
|
||||
pathname === '/profile' ? history.push('/') : history.go(-1)
|
||||
}
|
||||
>
|
||||
{t(
|
||||
pathname === '/profile'
|
||||
? 'common:Back to home'
|
||||
: 'common:Back'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default withTranslation()(
|
||||
connect(
|
||||
state => ({
|
||||
appConfig: state.application.config,
|
||||
pathname: state.router.location.pathname,
|
||||
message: state.message,
|
||||
}),
|
||||
dispatch => ({
|
||||
onDeletePicture: () => {
|
||||
dispatch(deletePicture())
|
||||
},
|
||||
onUploadPicture: event => {
|
||||
dispatch(uploadPicture(event))
|
||||
},
|
||||
})
|
||||
)(ProfileDetail)
|
||||
)
|
@ -1,314 +0,0 @@
|
||||
import { format } from 'date-fns'
|
||||
import React from 'react'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import { withTranslation } from 'react-i18next'
|
||||
import { connect } from 'react-redux'
|
||||
import TimezonePicker from 'react-timezone'
|
||||
|
||||
import Message from '../Common/Message'
|
||||
import { deleteUser, handleProfileFormSubmit } from '../../actions/user'
|
||||
import { history } from '../../index'
|
||||
import { languages } from '../NavBar/LanguageDropdown'
|
||||
import CustomModal from '../Common/CustomModal'
|
||||
import CustomTextArea from '../Common/CustomTextArea'
|
||||
|
||||
class ProfileEdit extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context)
|
||||
this.state = {
|
||||
formData: {},
|
||||
displayModal: false,
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.initForm()
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.user !== this.props.user) {
|
||||
this.initForm()
|
||||
}
|
||||
}
|
||||
|
||||
initForm() {
|
||||
const { user } = this.props
|
||||
const formData = {}
|
||||
Object.keys(user).map(k =>
|
||||
user[k] === null
|
||||
? (formData[k] = '')
|
||||
: k === 'birth_date'
|
||||
? (formData[k] = format(new Date(user[k]), 'yyyy-MM-dd'))
|
||||
: (formData[k] = user[k])
|
||||
)
|
||||
this.setState({ formData })
|
||||
}
|
||||
|
||||
handleFormChange(e) {
|
||||
const { formData } = this.state
|
||||
if (e.target.name === 'weekm') {
|
||||
formData.weekm = e.target.value === 'Monday'
|
||||
} else {
|
||||
formData[e.target.name] = e.target.value
|
||||
}
|
||||
this.setState(formData)
|
||||
}
|
||||
|
||||
displayModal(value) {
|
||||
this.setState(prevState => ({
|
||||
...prevState,
|
||||
displayModal: value,
|
||||
}))
|
||||
}
|
||||
|
||||
render() {
|
||||
const { message, onDeleteUser, onHandleProfileFormSubmit, t, user } =
|
||||
this.props
|
||||
const { displayModal, formData } = this.state
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>FitTrackee - {t('user:Profile Edition')}</title>
|
||||
</Helmet>
|
||||
{formData.isAuthenticated && (
|
||||
<div className="container">
|
||||
{displayModal && (
|
||||
<CustomModal
|
||||
title={t('common:Confirmation')}
|
||||
text={t(
|
||||
'user:Are you sure you want to delete your account? ' +
|
||||
'All data will be deleted, this cannot be undone.'
|
||||
)}
|
||||
confirm={() => {
|
||||
onDeleteUser(user.username)
|
||||
this.displayModal(false)
|
||||
}}
|
||||
close={() => this.displayModal(false)}
|
||||
/>
|
||||
)}
|
||||
<h1 className="page-title">{t('user:Profile Edition')}</h1>
|
||||
<div className="row">
|
||||
<div className="col-md-2" />
|
||||
<div className="col-md-8">
|
||||
<div className="card">
|
||||
<div className="card-header">{user.username}</div>
|
||||
<div className="card-body">
|
||||
<div className="row">
|
||||
<div className="col-md-12">
|
||||
<form
|
||||
onSubmit={event => {
|
||||
event.preventDefault()
|
||||
onHandleProfileFormSubmit(formData)
|
||||
}}
|
||||
>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{t('user:Email')}:
|
||||
<input
|
||||
name="email"
|
||||
className="form-control input-lg"
|
||||
type="text"
|
||||
value={formData.email}
|
||||
readOnly
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{t('user:Registration Date')}:
|
||||
<input
|
||||
name="createdAt"
|
||||
className="form-control input-lg"
|
||||
type="text"
|
||||
value={formData.created_at}
|
||||
disabled
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{t('user:Password')}:
|
||||
<input
|
||||
name="password"
|
||||
className="form-control input-lg"
|
||||
type="password"
|
||||
onChange={e => this.handleFormChange(e)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{t('user:Password Confirmation')}:
|
||||
<input
|
||||
name="password_conf"
|
||||
className="form-control input-lg"
|
||||
type="password"
|
||||
onChange={e => this.handleFormChange(e)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<hr />
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{t('user:First Name')}:
|
||||
<input
|
||||
name="first_name"
|
||||
className="form-control input-lg"
|
||||
type="text"
|
||||
value={formData.first_name}
|
||||
onChange={e => this.handleFormChange(e)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{t('user:Last Name')}:
|
||||
<input
|
||||
name="last_name"
|
||||
className="form-control input-lg"
|
||||
type="text"
|
||||
value={formData.last_name}
|
||||
onChange={e => this.handleFormChange(e)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{t('user:Birth Date')}
|
||||
<input
|
||||
name="birth_date"
|
||||
className="form-control input-lg"
|
||||
type="date"
|
||||
value={formData.birth_date}
|
||||
onChange={e => this.handleFormChange(e)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{t('user:Location')}:
|
||||
<input
|
||||
name="location"
|
||||
className="form-control input-lg"
|
||||
type="text"
|
||||
value={formData.location}
|
||||
onChange={e => this.handleFormChange(e)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{t('user:Bio')}:
|
||||
<CustomTextArea
|
||||
charLimit={200}
|
||||
name="bio"
|
||||
defaultValue={formData.bio}
|
||||
onTextChange={e => this.handleFormChange(e)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{t('user:Language')}:
|
||||
<select
|
||||
name="language"
|
||||
className="form-control input-lg"
|
||||
value={formData.language}
|
||||
onChange={e => this.handleFormChange(e)}
|
||||
>
|
||||
{languages.map(lang => (
|
||||
<option value={lang.name} key={lang.name}>
|
||||
{lang.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{t('user:Timezone')}:
|
||||
<TimezonePicker
|
||||
className="form-control timezone-custom"
|
||||
onChange={tz => {
|
||||
const e = {
|
||||
target: {
|
||||
name: 'timezone',
|
||||
value: tz ? tz : 'Europe/Paris',
|
||||
},
|
||||
}
|
||||
this.handleFormChange(e)
|
||||
}}
|
||||
value={formData.timezone}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{t('user:First day of week')}:
|
||||
<select
|
||||
name="weekm"
|
||||
className="form-control input-lg"
|
||||
value={formData.weekm ? 'Monday' : 'Sunday'}
|
||||
onChange={e => this.handleFormChange(e)}
|
||||
>
|
||||
<option value="Sunday">
|
||||
{t('user:Sunday')}
|
||||
</option>
|
||||
<option value="Monday">
|
||||
{t('user:Monday')}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
{t('common:Submit')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
this.displayModal(true)
|
||||
}}
|
||||
>
|
||||
{t('user:Delete my account')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => history.push('/profile')}
|
||||
>
|
||||
{t('common:Cancel')}
|
||||
</button>
|
||||
</form>
|
||||
<Message message={message} t={t} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-2" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default withTranslation()(
|
||||
connect(
|
||||
state => ({
|
||||
location: state.router.location,
|
||||
message: state.message,
|
||||
user: state.user,
|
||||
}),
|
||||
dispatch => ({
|
||||
onDeleteUser: username => {
|
||||
dispatch(deleteUser(username))
|
||||
},
|
||||
onHandleProfileFormSubmit: formData => {
|
||||
dispatch(handleProfileFormSubmit(formData))
|
||||
},
|
||||
})
|
||||
)(ProfileEdit)
|
||||
)
|
@ -1,99 +0,0 @@
|
||||
import React from 'react'
|
||||
import { withTranslation } from 'react-i18next'
|
||||
import { connect } from 'react-redux'
|
||||
import { Redirect } from 'react-router-dom'
|
||||
|
||||
import Form from './Form'
|
||||
import Message from '../Common/Message'
|
||||
import { handleUserFormSubmit } from '../../actions/user'
|
||||
import { isLoggedIn } from '../../utils'
|
||||
|
||||
class UserForm extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context)
|
||||
this.state = {
|
||||
formData: {
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
password_conf: '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.location.pathname !== this.props.location.pathname) {
|
||||
this.emptyForm()
|
||||
}
|
||||
}
|
||||
|
||||
emptyForm() {
|
||||
const { formData } = this.state
|
||||
Object.keys(formData).map(k => (formData[k] = ''))
|
||||
this.setState(formData)
|
||||
}
|
||||
|
||||
onHandleFormChange(e) {
|
||||
const { formData } = this.state
|
||||
formData[e.target.name] = e.target.value
|
||||
this.setState(formData)
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
formType,
|
||||
isRegistrationAllowed,
|
||||
message,
|
||||
messages,
|
||||
onHandleUserFormSubmit,
|
||||
t,
|
||||
} = this.props
|
||||
const { formData } = this.state
|
||||
const { token } = this.props.location.query
|
||||
return (
|
||||
<div>
|
||||
{isLoggedIn() || (formType === 'password reset' && !token) ? (
|
||||
<Redirect to="/" />
|
||||
) : (
|
||||
<div>
|
||||
<Message message={message} messages={messages} t={t} />
|
||||
<Form
|
||||
isRegistrationAllowed={isRegistrationAllowed}
|
||||
formType={formType}
|
||||
userForm={formData}
|
||||
onHandleFormChange={event => this.onHandleFormChange(event)}
|
||||
handleUserFormSubmit={event => {
|
||||
event.preventDefault()
|
||||
if (formType === 'password reset') {
|
||||
formData.token = token
|
||||
}
|
||||
onHandleUserFormSubmit(formData, formType)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
export default withTranslation()(
|
||||
connect(
|
||||
state => ({
|
||||
isRegistrationAllowed: state.application.config.is_registration_enabled,
|
||||
location: state.router.location,
|
||||
message: state.message,
|
||||
messages: state.messages,
|
||||
}),
|
||||
dispatch => ({
|
||||
onHandleUserFormSubmit: (formData, formType) => {
|
||||
formType =
|
||||
formType === 'password reset'
|
||||
? 'password/update'
|
||||
: formType === 'reset your password'
|
||||
? 'password/reset-request'
|
||||
: formType
|
||||
dispatch(handleUserFormSubmit(formData, formType))
|
||||
},
|
||||
})
|
||||
)(UserForm)
|
||||
)
|
@ -1,86 +0,0 @@
|
||||
import React from 'react'
|
||||
import { withTranslation } from 'react-i18next'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import CustomModal from '../Common/CustomModal'
|
||||
import ProfileDetail from './ProfileDetail'
|
||||
import { getOrUpdateData } from '../../actions'
|
||||
import { deleteUser } from '../../actions/user'
|
||||
|
||||
class UserProfile extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context)
|
||||
this.state = {
|
||||
displayModal: false,
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.loadUser(this.props.match.params.userName)
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.match.params.userName !== this.props.match.params.userName) {
|
||||
this.props.loadUser(this.props.match.params.userName)
|
||||
}
|
||||
}
|
||||
|
||||
displayModal(value) {
|
||||
this.setState(prevState => ({
|
||||
...prevState,
|
||||
displayModal: value,
|
||||
}))
|
||||
}
|
||||
|
||||
render() {
|
||||
const { t, currentUser, onDeleteUser, users } = this.props
|
||||
const { displayModal } = this.state
|
||||
const [user] = users
|
||||
const editable = user ? currentUser.username === user.username : false
|
||||
return (
|
||||
<div>
|
||||
{displayModal && (
|
||||
<CustomModal
|
||||
title={t('common:Confirmation')}
|
||||
text={t(
|
||||
'user:Are you sure you want to delete this account? ' +
|
||||
'All data will be deleted, this cannot be undone.'
|
||||
)}
|
||||
confirm={() => {
|
||||
onDeleteUser(user.username)
|
||||
this.displayModal(false)
|
||||
}}
|
||||
close={() => this.displayModal(false)}
|
||||
/>
|
||||
)}
|
||||
{user && (
|
||||
<ProfileDetail
|
||||
editable={editable}
|
||||
isDeletable={currentUser.admin && !editable}
|
||||
onDeleteUser={onDeleteUser}
|
||||
displayModal={e => this.displayModal(e)}
|
||||
t={t}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default withTranslation()(
|
||||
connect(
|
||||
state => ({
|
||||
currentUser: state.user,
|
||||
users: state.users.data,
|
||||
}),
|
||||
dispatch => ({
|
||||
onDeleteUser: username => {
|
||||
dispatch(deleteUser(username, true))
|
||||
},
|
||||
loadUser: userName => {
|
||||
dispatch(getOrUpdateData('getData', 'users', { username: userName }))
|
||||
},
|
||||
})
|
||||
)(UserProfile)
|
||||
)
|
@ -1,19 +0,0 @@
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import WorkoutAddOrEdit from './WorkoutAddOrEdit'
|
||||
|
||||
function WorkoutAdd(props) {
|
||||
const { message, sports } = props
|
||||
return (
|
||||
<div>
|
||||
<WorkoutAddOrEdit workout={null} message={message} sports={sports} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default connect(state => ({
|
||||
message: state.message,
|
||||
sports: state.sports.data,
|
||||
user: state.user,
|
||||
}))(WorkoutAdd)
|
@ -1,118 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import { withTranslation } from 'react-i18next'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import FormWithGpx from './WorkoutForms/FormWithGpx'
|
||||
import FormWithoutGpx from './WorkoutForms/FormWithoutGpx'
|
||||
import Message from '../Common/Message'
|
||||
|
||||
class WorkoutAddEdit extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context)
|
||||
this.state = {
|
||||
withGpx: true,
|
||||
}
|
||||
}
|
||||
|
||||
handleRadioChange(changeEvent) {
|
||||
this.setState({
|
||||
withGpx:
|
||||
changeEvent.target.name === 'withGpx'
|
||||
? changeEvent.target.value
|
||||
: !changeEvent.target.value,
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
const { loading, message, sports, t, workout } = this.props
|
||||
const { withGpx } = this.state
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>
|
||||
FitTrackee -{' '}
|
||||
{workout
|
||||
? t('workouts:Edit a workout')
|
||||
: t('workouts:Add a workout')}
|
||||
</title>
|
||||
</Helmet>
|
||||
<br />
|
||||
<br />
|
||||
<Message message={message} t={t} />
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-md-2" />
|
||||
<div className="col-md-8">
|
||||
<div className="card add-workout">
|
||||
<h2 className="card-header text-center">
|
||||
{workout
|
||||
? t('workouts:Edit a workout')
|
||||
: t('workouts:Add a workout')}
|
||||
</h2>
|
||||
<div className="card-body">
|
||||
{workout ? (
|
||||
workout.with_gpx ? (
|
||||
<FormWithGpx workout={workout} sports={sports} t={t} />
|
||||
) : (
|
||||
<FormWithoutGpx workout={workout} sports={sports} t={t} />
|
||||
)
|
||||
) : (
|
||||
<div>
|
||||
<form>
|
||||
<div className="form-group row">
|
||||
<div className="col">
|
||||
<label className="radioLabel">
|
||||
<input
|
||||
className="add-workout-radio"
|
||||
type="radio"
|
||||
name="withGpx"
|
||||
disabled={loading}
|
||||
checked={withGpx}
|
||||
onChange={event =>
|
||||
this.handleRadioChange(event)
|
||||
}
|
||||
/>
|
||||
{t('workouts:with gpx file')}
|
||||
</label>
|
||||
</div>
|
||||
<div className="col">
|
||||
<label className="radioLabel">
|
||||
<input
|
||||
className="add-workout-radio"
|
||||
type="radio"
|
||||
name="withoutGpx"
|
||||
disabled={loading}
|
||||
checked={!withGpx}
|
||||
onChange={event =>
|
||||
this.handleRadioChange(event)
|
||||
}
|
||||
/>
|
||||
{t('workouts:without gpx file')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{withGpx ? (
|
||||
<FormWithGpx sports={sports} t={t} />
|
||||
) : (
|
||||
<FormWithoutGpx sports={sports} t={t} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default withTranslation()(
|
||||
connect(state => ({
|
||||
loading: state.loading,
|
||||
}))(WorkoutAddEdit)
|
||||
)
|
@ -1,27 +0,0 @@
|
||||
import React from 'react'
|
||||
import { GeoJSON, Marker, TileLayer, useMap } from 'react-leaflet'
|
||||
import hash from 'object-hash'
|
||||
|
||||
import { apiUrl } from '../../../utils'
|
||||
|
||||
export default function Map({ bounds, coordinates, jsonData, mapAttribution }) {
|
||||
const map = useMap()
|
||||
map.fitBounds(bounds)
|
||||
return (
|
||||
<>
|
||||
<TileLayer
|
||||
// eslint-disable-next-line max-len
|
||||
attribution={mapAttribution}
|
||||
url={`${apiUrl}workouts/map_tile/{s}/{z}/{x}/{y}.png`}
|
||||
/>
|
||||
<GeoJSON
|
||||
// hash as a key to force re-rendering
|
||||
key={hash(jsonData)}
|
||||
data={jsonData}
|
||||
/>
|
||||
{coordinates.latitude && (
|
||||
<Marker position={[coordinates.latitude, coordinates.longitude]} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,106 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { getDateWithTZ } from '../../../utils'
|
||||
import { formatWorkoutDate } from '../../../utils/workouts'
|
||||
|
||||
export default function WorkoutCardHeader(props) {
|
||||
const { dataType, displayModal, segmentId, sport, t, title, user, workout } =
|
||||
props
|
||||
const workoutDate = workout
|
||||
? formatWorkoutDate(getDateWithTZ(workout.workout_date, user.timezone))
|
||||
: null
|
||||
|
||||
const previousUrl =
|
||||
dataType === 'segment' && segmentId !== 1
|
||||
? `/workouts/${workout.id}/segment/${segmentId - 1}`
|
||||
: dataType === 'workout' && workout.previous_workout
|
||||
? `/workouts/${workout.previous_workout}`
|
||||
: null
|
||||
const nextUrl =
|
||||
dataType === 'segment' && segmentId < workout.segments.length
|
||||
? `/workouts/${workout.id}/segment/${segmentId + 1}`
|
||||
: dataType === 'workout' && workout.next_workout
|
||||
? `/workouts/${workout.next_workout}`
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-auto">
|
||||
{previousUrl ? (
|
||||
<Link className="unlink" to={previousUrl}>
|
||||
<i
|
||||
className="fa fa-chevron-left"
|
||||
aria-hidden="true"
|
||||
title={t(`workouts:See previous ${dataType}`)}
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<i
|
||||
className="fa fa-chevron-left inactive-link"
|
||||
aria-hidden="true"
|
||||
title={t(`workouts:No previous ${dataType}`)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-auto col-workout-logo">
|
||||
<img className="sport-img-medium" src={sport.img} alt="sport logo" />
|
||||
</div>
|
||||
<div className="col">
|
||||
{dataType === 'workout' ? (
|
||||
<>
|
||||
{title}{' '}
|
||||
<Link className="unlink" to={`/workouts/${workout.id}/edit`}>
|
||||
<i
|
||||
className="fa fa-edit custom-fa"
|
||||
aria-hidden="true"
|
||||
title={t('workouts:Edit workout')}
|
||||
/>
|
||||
</Link>
|
||||
<i
|
||||
className="fa fa-trash custom-fa"
|
||||
aria-hidden="true"
|
||||
onClick={() => displayModal(true)}
|
||||
title={t('workouts:Delete workout')}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* prettier-ignore */}
|
||||
<Link
|
||||
to={`/workouts/${workout.id}`}
|
||||
>
|
||||
{title}
|
||||
</Link>{' '}
|
||||
- {t('workouts:segment')} {segmentId}
|
||||
</>
|
||||
)}
|
||||
<br />
|
||||
{workoutDate && (
|
||||
<span className="workout-date">
|
||||
{`${workoutDate.workout_date} - ${workoutDate.workout_time}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
{nextUrl ? (
|
||||
<Link className="unlink" to={nextUrl}>
|
||||
<i
|
||||
className="fa fa-chevron-right"
|
||||
aria-hidden="true"
|
||||
title={t(`workouts:See next ${dataType}`)}
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<i
|
||||
className="fa fa-chevron-right inactive-link"
|
||||
aria-hidden="true"
|
||||
title={t(`workouts:No next ${dataType}`)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,240 +0,0 @@
|
||||
import { format } from 'date-fns'
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import {
|
||||
Area,
|
||||
ComposedChart,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
|
||||
import {
|
||||
getSegmentChartData,
|
||||
getWorkoutChartData,
|
||||
} from '../../../actions/workouts'
|
||||
|
||||
class WorkoutCharts extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context)
|
||||
this.state = {
|
||||
displayDistance: true,
|
||||
dataToHide: [],
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.dataType === 'workout') {
|
||||
this.props.loadWorkoutData(this.props.workout.id)
|
||||
} else {
|
||||
this.props.loadSegmentData(this.props.workout.id, this.props.segmentId)
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (
|
||||
(this.props.dataType === 'workout' &&
|
||||
prevProps.workout.id !== this.props.workout.id) ||
|
||||
(this.props.dataType === 'workout' && prevProps.dataType === 'segment')
|
||||
) {
|
||||
this.props.loadWorkoutData(this.props.workout.id)
|
||||
}
|
||||
if (
|
||||
this.props.dataType === 'segment' &&
|
||||
prevProps.segmentId !== this.props.segmentId
|
||||
) {
|
||||
this.props.loadSegmentData(this.props.workout.id, this.props.segmentId)
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.loadWorkoutData(null)
|
||||
}
|
||||
|
||||
handleRadioChange(changeEvent) {
|
||||
this.setState({
|
||||
displayDistance:
|
||||
changeEvent.target.name === 'distance'
|
||||
? changeEvent.target.value
|
||||
: !changeEvent.target.value,
|
||||
})
|
||||
}
|
||||
|
||||
handleLegendChange(e) {
|
||||
const { dataToHide } = this.state
|
||||
const name = e.target.name // eslint-disable-line prefer-destructuring
|
||||
if (dataToHide.find(d => d === name)) {
|
||||
dataToHide.splice(dataToHide.indexOf(name), 1)
|
||||
} else {
|
||||
dataToHide.push(name)
|
||||
}
|
||||
this.setState({ dataToHide })
|
||||
}
|
||||
|
||||
displayData(name) {
|
||||
const { dataToHide } = this.state
|
||||
return !dataToHide.find(d => d === name)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { chartData, t, updateCoordinates } = this.props
|
||||
const { displayDistance } = this.state
|
||||
const xInterval = chartData ? parseInt(chartData.length / 10, 10) : 0
|
||||
let xDataKey, xScale
|
||||
if (displayDistance) {
|
||||
xDataKey = 'distance'
|
||||
xScale = 'linear'
|
||||
} else {
|
||||
xDataKey = 'duration'
|
||||
xScale = 'time'
|
||||
}
|
||||
return (
|
||||
<div className="container">
|
||||
{chartData && chartData.length > 0 ? (
|
||||
<div>
|
||||
<div className="row chart-radio">
|
||||
<label className="radioLabel col-md-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="distance"
|
||||
checked={displayDistance}
|
||||
onChange={e => this.handleRadioChange(e)}
|
||||
/>
|
||||
{t('workouts:distance')}
|
||||
</label>
|
||||
<label className="radioLabel col-md-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="duration"
|
||||
checked={!displayDistance}
|
||||
onChange={e => this.handleRadioChange(e)}
|
||||
/>
|
||||
{t('workouts:duration')}
|
||||
</label>
|
||||
</div>
|
||||
<div className="row chart-radio">
|
||||
<div className="col-md-5" />
|
||||
<label className="radioLabel col-md-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="speed"
|
||||
checked={this.displayData('speed')}
|
||||
onChange={e => this.handleLegendChange(e)}
|
||||
/>
|
||||
{t('workouts:speed')}
|
||||
</label>
|
||||
<label className="radioLabel col-md-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="elevation"
|
||||
checked={this.displayData('elevation')}
|
||||
onChange={e => this.handleLegendChange(e)}
|
||||
/>
|
||||
{t('workouts:elevation')}
|
||||
</label>
|
||||
<div className="col-md-5" />
|
||||
</div>
|
||||
<div className="row chart">
|
||||
<ResponsiveContainer height={300}>
|
||||
<ComposedChart
|
||||
data={chartData}
|
||||
margin={{ top: 15, right: 30, left: 20, bottom: 15 }}
|
||||
onMouseMove={e => updateCoordinates(e.activePayload)}
|
||||
onMouseLeave={() => updateCoordinates(null)}
|
||||
>
|
||||
<XAxis
|
||||
allowDecimals={false}
|
||||
dataKey={xDataKey}
|
||||
label={{
|
||||
value: t(`workouts:${xDataKey}`),
|
||||
offset: 0,
|
||||
position: 'bottom',
|
||||
}}
|
||||
scale={xScale}
|
||||
interval={xInterval}
|
||||
tickFormatter={value =>
|
||||
displayDistance ? value : format(value, 'HH:mm:ss')
|
||||
}
|
||||
type="number"
|
||||
/>
|
||||
<YAxis
|
||||
label={{
|
||||
value: `${t('workouts:speed')} (km/h)`,
|
||||
angle: -90,
|
||||
position: 'left',
|
||||
}}
|
||||
yAxisId="left"
|
||||
/>
|
||||
<YAxis
|
||||
label={{
|
||||
value: `${t('workouts:elevation')} (m)`,
|
||||
angle: -90,
|
||||
position: 'right',
|
||||
}}
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
/>
|
||||
{this.displayData('elevation') && (
|
||||
<Area
|
||||
yAxisId="right"
|
||||
type="linear"
|
||||
dataKey="elevation"
|
||||
name={t('workouts:elevation')}
|
||||
fill="#e5e5e5"
|
||||
stroke="#cccccc"
|
||||
dot={false}
|
||||
unit=" m"
|
||||
/>
|
||||
)}
|
||||
{this.displayData('speed') && (
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="linear"
|
||||
dataKey="speed"
|
||||
name={t('workouts:speed')}
|
||||
stroke="#8884d8"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
unit=" km/h"
|
||||
/>
|
||||
)}
|
||||
<Tooltip
|
||||
labelFormatter={value =>
|
||||
displayDistance
|
||||
? `${t('workouts:distance')}: ${value} km`
|
||||
: `${t('workouts:duration')}: ${format(
|
||||
value,
|
||||
'HH:mm:ss'
|
||||
)}`
|
||||
}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="chart-info">
|
||||
{t('workouts:data from gpx, without any cleaning')}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
t('workouts:No data to display')
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
chartData: state.chartData,
|
||||
}),
|
||||
dispatch => ({
|
||||
loadWorkoutData: workoutId => {
|
||||
dispatch(getWorkoutChartData(workoutId))
|
||||
},
|
||||
loadSegmentData: (workoutId, segmentId) => {
|
||||
dispatch(getSegmentChartData(workoutId, segmentId))
|
||||
},
|
||||
})
|
||||
)(WorkoutCharts)
|
@ -1,73 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
import WorkoutWeather from './WorkoutWeather'
|
||||
|
||||
export default function WorkoutDetails(props) {
|
||||
const { t, workout } = props
|
||||
const withPauses = workout.pauses !== '0:00:00' && workout.pauses !== null
|
||||
return (
|
||||
<div className="workout-details">
|
||||
<p>
|
||||
<i className="fa fa-clock-o custom-fa" aria-hidden="true" />
|
||||
{t('workouts:Duration')}: {workout.moving}
|
||||
{workout.records &&
|
||||
workout.records.find(record => record.record_type === 'LD') && (
|
||||
<sup>
|
||||
<i className="fa fa-trophy custom-fa" aria-hidden="true" />
|
||||
</sup>
|
||||
)}
|
||||
{withPauses && (
|
||||
<span>
|
||||
<br />({t('workouts:pauses')}: {workout.pauses},{' '}
|
||||
{t('workouts:total duration')}: {workout.duration})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
<i className="fa fa-road custom-fa" aria-hidden="true" />
|
||||
{t('workouts:Distance')}: {workout.distance} km
|
||||
{workout.records &&
|
||||
workout.records.find(record => record.record_type === 'FD') && (
|
||||
<sup>
|
||||
<i className="fa fa-trophy custom-fa" aria-hidden="true" />
|
||||
</sup>
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
<i className="fa fa-tachometer custom-fa" aria-hidden="true" />
|
||||
{t('workouts:Average speed')}: {workout.ave_speed} km/h
|
||||
{workout.records &&
|
||||
workout.records.find(record => record.record_type === 'AS') && (
|
||||
<sup>
|
||||
<i className="fa fa-trophy custom-fa" aria-hidden="true" />
|
||||
</sup>
|
||||
)}
|
||||
<br />
|
||||
{t('workouts:Max. speed')}: {workout.max_speed} km/h
|
||||
{workout.records &&
|
||||
workout.records.find(record => record.record_type === 'MS') && (
|
||||
<sup>
|
||||
<i className="fa fa-trophy custom-fa" aria-hidden="true" />
|
||||
</sup>
|
||||
)}
|
||||
</p>
|
||||
{workout.min_alt && workout.max_alt && (
|
||||
<p>
|
||||
<i className="fi-mountains custom-fa" />
|
||||
{t('workouts:Min. altitude')}: {workout.min_alt}m
|
||||
<br />
|
||||
{t('workouts:Max. altitude')}: {workout.max_alt}m
|
||||
</p>
|
||||
)}
|
||||
{workout.ascent && workout.descent && (
|
||||
<p>
|
||||
<i className="fa fa-location-arrow custom-fa" />
|
||||
{t('workouts:Ascent')}: {workout.ascent}m
|
||||
<br />
|
||||
{t('workouts:Descent')}: {workout.descent}m
|
||||
</p>
|
||||
)}
|
||||
<WorkoutWeather workout={workout} t={t} />
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,87 +0,0 @@
|
||||
import React from 'react'
|
||||
import { MapContainer } from 'react-leaflet'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import Map from './Map'
|
||||
import { getSegmentGpx, getWorkoutGpx } from '../../../actions/workouts'
|
||||
import { getGeoJson } from '../../../utils/workouts'
|
||||
|
||||
class WorkoutMap extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context)
|
||||
this.state = {
|
||||
zoom: 13,
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.dataType === 'workout') {
|
||||
this.props.loadWorkoutGpx(this.props.workout.id)
|
||||
} else {
|
||||
this.props.loadSegmentGpx(this.props.workout.id, this.props.segmentId)
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (
|
||||
(this.props.dataType === 'workout' &&
|
||||
prevProps.workout.id !== this.props.workout.id) ||
|
||||
(this.props.dataType === 'workout' && prevProps.dataType === 'segment')
|
||||
) {
|
||||
this.props.loadWorkoutGpx(this.props.workout.id)
|
||||
}
|
||||
if (
|
||||
this.props.dataType === 'segment' &&
|
||||
prevProps.segmentId !== this.props.segmentId
|
||||
) {
|
||||
this.props.loadSegmentGpx(this.props.workout.id, this.props.segmentId)
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.loadWorkoutGpx(null)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { coordinates, gpxContent, mapAttribution, workout } = this.props
|
||||
const { jsonData } = getGeoJson(gpxContent)
|
||||
const bounds = [
|
||||
[workout.bounds[0], workout.bounds[1]],
|
||||
[workout.bounds[2], workout.bounds[3]],
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
{jsonData && (
|
||||
<MapContainer
|
||||
zoom={this.state.zoom}
|
||||
bounds={bounds}
|
||||
boundsOptions={{ padding: [10, 10] }}
|
||||
>
|
||||
<Map
|
||||
bounds={bounds}
|
||||
coordinates={coordinates}
|
||||
jsonData={jsonData}
|
||||
mapAttribution={mapAttribution}
|
||||
/>
|
||||
</MapContainer>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
gpxContent: state.gpx,
|
||||
mapAttribution: state.application.config.map_attribution,
|
||||
}),
|
||||
dispatch => ({
|
||||
loadWorkoutGpx: workoutId => {
|
||||
dispatch(getWorkoutGpx(workoutId))
|
||||
},
|
||||
loadSegmentGpx: (workoutId, segmentId) => {
|
||||
dispatch(getSegmentGpx(workoutId, segmentId))
|
||||
},
|
||||
})
|
||||
)(WorkoutMap)
|
@ -1,8 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function WorkoutNoMap(props) {
|
||||
const { t } = props
|
||||
return (
|
||||
<div className="workout-no-map text-center">{t('workouts:No Map')}</div>
|
||||
)
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function WorkoutNotes(props) {
|
||||
const { notes, t } = props
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col">
|
||||
<div className="card workout-card">
|
||||
<div className="card-body">
|
||||
Notes
|
||||
<div className="workout-notes">
|
||||
{notes && notes !== '' ? notes : t('workouts:No notes')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,38 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function WorkoutSegments(props) {
|
||||
const { segments, t } = props
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col">
|
||||
<div className="card workout-card">
|
||||
<div className="card-body">
|
||||
{t('workouts:Segments')}
|
||||
<div className="workout-segments">
|
||||
<ul>
|
||||
{segments.map((segment, index) => (
|
||||
<li
|
||||
className="workout-segments-list"
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={`segment-${index}`}
|
||||
>
|
||||
<Link
|
||||
to={`/workouts/${segment.workout_id}/segment/${
|
||||
index + 1
|
||||
}`}
|
||||
>
|
||||
{t('workouts:segment')} {index + 1}
|
||||
</Link>{' '}
|
||||
({t('workouts:distance')}: {segment.distance} km,{' '}
|
||||
{t('workouts:duration')}: {segment.duration})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,75 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function WorkoutWeather(props) {
|
||||
const { t, workout } = props
|
||||
return (
|
||||
<div className="container">
|
||||
{workout.weather_start && workout.weather_end && (
|
||||
<table className="table table-borderless weather-table text-center">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>
|
||||
{t('workouts:Start')}
|
||||
<br />
|
||||
<img
|
||||
className="weather-img"
|
||||
src={`/img/weather/${workout.weather_start.icon}.png`}
|
||||
alt={`workout weather (${workout.weather_start.icon})`}
|
||||
title={workout.weather_start.summary}
|
||||
/>
|
||||
</th>
|
||||
<th>
|
||||
{t('workouts:End')}
|
||||
<br />
|
||||
<img
|
||||
className="weather-img"
|
||||
src={`/img/weather/${workout.weather_end.icon}.png`}
|
||||
alt={`workout weather (${workout.weather_end.icon})`}
|
||||
title={workout.weather_end.summary}
|
||||
/>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<img
|
||||
className="weather-img-small"
|
||||
src="/img/weather/temperature.png"
|
||||
alt="Temperatures"
|
||||
/>
|
||||
</td>
|
||||
<td>{Number(workout.weather_start.temperature).toFixed(1)}°C</td>
|
||||
<td>{Number(workout.weather_end.temperature).toFixed(1)}°C</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<img
|
||||
className="weather-img-small"
|
||||
src="/img/weather/pour-rain.png"
|
||||
alt="Temperatures"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
{Number(workout.weather_start.humidity * 100).toFixed(1)}%
|
||||
</td>
|
||||
<td>{Number(workout.weather_end.humidity * 100).toFixed(1)}%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<img
|
||||
className="weather-img-small"
|
||||
src="/img/weather/breeze.png"
|
||||
alt="Temperatures"
|
||||
/>
|
||||
</td>
|
||||
<td>{Number(workout.weather_start.wind).toFixed(1)}m/s</td>
|
||||
<td>{Number(workout.weather_end.wind).toFixed(1)}m/s</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,202 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import { withTranslation } from 'react-i18next'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import CustomModal from '../../Common/CustomModal'
|
||||
import Message from '../../Common/Message'
|
||||
import WorkoutCardHeader from './WorkoutCardHeader'
|
||||
import WorkoutCharts from './WorkoutCharts'
|
||||
import WorkoutDetails from './WorkoutDetails'
|
||||
import WorkoutMap from './WorkoutMap'
|
||||
import WorkoutNoMap from './WorkoutNoMap'
|
||||
import WorkoutNotes from './WorkoutNotes'
|
||||
import WorkoutSegments from './WorkoutSegments'
|
||||
import { getOrUpdateData } from '../../../actions'
|
||||
import { deleteWorkout } from '../../../actions/workouts'
|
||||
|
||||
class WorkoutDisplay extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context)
|
||||
this.state = {
|
||||
displayModal: false,
|
||||
coordinates: {
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.loadWorkout(this.props.match.params.workoutId)
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (
|
||||
prevProps.match.params.workoutId !== this.props.match.params.workoutId
|
||||
) {
|
||||
this.props.loadWorkout(this.props.match.params.workoutId)
|
||||
}
|
||||
}
|
||||
|
||||
displayModal(value) {
|
||||
this.setState(prevState => ({
|
||||
...prevState,
|
||||
displayModal: value,
|
||||
}))
|
||||
}
|
||||
|
||||
updateCoordinates(activePayload) {
|
||||
const coordinates =
|
||||
activePayload && activePayload.length > 0
|
||||
? {
|
||||
latitude: activePayload[0].payload.latitude,
|
||||
longitude: activePayload[0].payload.longitude,
|
||||
}
|
||||
: {
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
}
|
||||
this.setState(prevState => ({
|
||||
...prevState,
|
||||
coordinates,
|
||||
}))
|
||||
}
|
||||
|
||||
render() {
|
||||
const { message, onDeleteWorkout, sports, t, user, workouts } = this.props
|
||||
const { coordinates, displayModal } = this.state
|
||||
const [workout] = workouts
|
||||
const title = workout ? workout.title : t('workouts:Workout')
|
||||
const [sport] = workout ? sports.filter(s => s.id === workout.sport_id) : []
|
||||
const segmentId = parseInt(this.props.match.params.segmentId)
|
||||
const dataType = segmentId >= 0 ? 'segment' : 'workout'
|
||||
return (
|
||||
<div className="workout-page">
|
||||
<Helmet>
|
||||
<title>FitTrackee - {title}</title>
|
||||
</Helmet>
|
||||
{message ? (
|
||||
<Message message={message} t={t} />
|
||||
) : (
|
||||
<div className="container">
|
||||
{displayModal && (
|
||||
<CustomModal
|
||||
title={t('common:Confirmation')}
|
||||
text={t(
|
||||
'workouts:Are you sure you want to delete this workout?'
|
||||
)}
|
||||
confirm={() => {
|
||||
onDeleteWorkout(workout.id)
|
||||
this.displayModal(false)
|
||||
}}
|
||||
close={() => this.displayModal(false)}
|
||||
/>
|
||||
)}
|
||||
{workout && sport && workouts.length === 1 && (
|
||||
<div>
|
||||
<div className="row">
|
||||
<div className="col">
|
||||
<div className="card workout-card">
|
||||
<div className="card-header">
|
||||
<WorkoutCardHeader
|
||||
workout={workout}
|
||||
dataType={dataType}
|
||||
segmentId={segmentId}
|
||||
sport={sport}
|
||||
t={t}
|
||||
title={title}
|
||||
user={user}
|
||||
displayModal={() => this.displayModal(true)}
|
||||
/>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="row">
|
||||
<div className="col-md-8">
|
||||
{workout.with_gpx ? (
|
||||
<WorkoutMap
|
||||
workout={workout}
|
||||
coordinates={coordinates}
|
||||
dataType={dataType}
|
||||
segmentId={segmentId}
|
||||
/>
|
||||
) : (
|
||||
<WorkoutNoMap t={t} />
|
||||
)}
|
||||
</div>
|
||||
<div className="col">
|
||||
<WorkoutDetails
|
||||
workout={
|
||||
dataType === 'workout'
|
||||
? workout
|
||||
: workout.segments[segmentId - 1]
|
||||
}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{workout.with_gpx && (
|
||||
<div className="row">
|
||||
<div className="col">
|
||||
<div className="card workout-card">
|
||||
<div className="card-body">
|
||||
<div className="row">
|
||||
<div className="col">
|
||||
<div className="chart-title">
|
||||
{t('workouts:Chart')}
|
||||
</div>
|
||||
<WorkoutCharts
|
||||
workout={workout}
|
||||
dataType={dataType}
|
||||
segmentId={segmentId}
|
||||
t={t}
|
||||
updateCoordinates={e =>
|
||||
this.updateCoordinates(e)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{dataType === 'workout' && (
|
||||
<>
|
||||
<WorkoutNotes notes={workout.notes} t={t} />
|
||||
{workout.segments.length > 1 && (
|
||||
<WorkoutSegments segments={workout.segments} t={t} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default withTranslation()(
|
||||
connect(
|
||||
state => ({
|
||||
workouts: state.workouts.data,
|
||||
message: state.message,
|
||||
sports: state.sports.data,
|
||||
user: state.user,
|
||||
}),
|
||||
dispatch => ({
|
||||
loadWorkout: workoutId => {
|
||||
dispatch(getOrUpdateData('getData', 'workouts', { id: workoutId }))
|
||||
},
|
||||
onDeleteWorkout: workoutId => {
|
||||
dispatch(deleteWorkout(workoutId))
|
||||
},
|
||||
})
|
||||
)(WorkoutDisplay)
|
||||
)
|
@ -1,41 +0,0 @@
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import WorkoutAddOrEdit from './WorkoutAddOrEdit'
|
||||
import { getOrUpdateData } from '../../actions'
|
||||
|
||||
class WorkoutEdit extends React.Component {
|
||||
componentDidMount() {
|
||||
this.props.loadWorkout(this.props.match.params.workoutId)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { message, sports, workouts } = this.props
|
||||
const [workout] = workouts
|
||||
return (
|
||||
<div>
|
||||
{sports.length > 0 && (
|
||||
<WorkoutAddOrEdit
|
||||
workout={workout}
|
||||
message={message}
|
||||
sports={sports}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
workouts: state.workouts.data,
|
||||
message: state.message,
|
||||
sports: state.sports.data,
|
||||
user: state.user,
|
||||
}),
|
||||
dispatch => ({
|
||||
loadWorkout: workoutId => {
|
||||
dispatch(getOrUpdateData('getData', 'workouts', { id: workoutId }))
|
||||
},
|
||||
})
|
||||
)(WorkoutEdit)
|
@ -1,170 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Trans } from 'react-i18next'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import { setLoading } from '../../../actions/index'
|
||||
import { addWorkout, editWorkout } from '../../../actions/workouts'
|
||||
import { history } from '../../../index'
|
||||
import { getFileSize } from '../../../utils'
|
||||
import { translateSports } from '../../../utils/workouts'
|
||||
import CustomTextArea from '../../Common/CustomTextArea'
|
||||
|
||||
function FormWithGpx(props) {
|
||||
const {
|
||||
appConfig,
|
||||
loading,
|
||||
onAddWorkout,
|
||||
onEditWorkout,
|
||||
sports,
|
||||
t,
|
||||
workout,
|
||||
} = props
|
||||
const sportId = workout ? workout.sport_id : ''
|
||||
const translatedSports = translateSports(sports, t, true)
|
||||
const zipTooltip = `${t('workouts:no folder inside')}, ${
|
||||
appConfig.gpx_limit_import
|
||||
} ${t('workouts:files max')}, ${t('workouts:max size')}: ${getFileSize(
|
||||
appConfig.max_zip_file_size
|
||||
)}`
|
||||
const fileSizeLimit = getFileSize(appConfig.max_single_file_size)
|
||||
return (
|
||||
<form
|
||||
encType="multipart/form-data"
|
||||
method="post"
|
||||
onSubmit={event => event.preventDefault()}
|
||||
>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{t('common:Sport')}:
|
||||
<select
|
||||
className="form-control input-lg"
|
||||
defaultValue={sportId}
|
||||
disabled={loading}
|
||||
name="sport"
|
||||
required
|
||||
>
|
||||
<option value="" />
|
||||
{translatedSports.map(sport => (
|
||||
<option key={sport.id} value={sport.id}>
|
||||
{sport.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{workout ? (
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{t('workouts:Title')}:
|
||||
<input
|
||||
name="title"
|
||||
defaultValue={workout ? workout.title : ''}
|
||||
disabled={loading}
|
||||
className="form-control input-lg"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<div className="form-group">
|
||||
<label>
|
||||
<Trans i18nKey="workouts:gpxFile">
|
||||
<strong>gpx</strong> file
|
||||
</Trans>
|
||||
<sup>
|
||||
<i
|
||||
className="fa fa-question-circle"
|
||||
aria-hidden="true"
|
||||
data-toggle="tooltip"
|
||||
title={`${t('workouts:max size')}: ${fileSizeLimit}`}
|
||||
/>
|
||||
</sup>{' '}
|
||||
<Trans i18nKey="workouts:zipFile">
|
||||
or <strong> zip</strong> file containing <strong>gpx </strong>
|
||||
files
|
||||
</Trans>
|
||||
<sup>
|
||||
<i
|
||||
className="fa fa-question-circle"
|
||||
aria-hidden="true"
|
||||
data-toggle="tooltip"
|
||||
data-placement="top"
|
||||
title={zipTooltip}
|
||||
/>
|
||||
</sup>{' '}
|
||||
:
|
||||
<input
|
||||
accept=".gpx, .zip"
|
||||
className="form-control form-control-file gpx-file"
|
||||
disabled={loading}
|
||||
name="gpxFile"
|
||||
required
|
||||
type="file"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{t('workouts:Notes')}:
|
||||
<CustomTextArea
|
||||
charLimit={500}
|
||||
defaultValue={workout ? workout.notes : ''}
|
||||
loading={loading}
|
||||
name="notes"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="loader" />
|
||||
) : (
|
||||
<div>
|
||||
<input
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
onClick={event =>
|
||||
workout ? onEditWorkout(event, workout) : onAddWorkout(event)
|
||||
}
|
||||
value={t('common:Submit')}
|
||||
/>
|
||||
<input
|
||||
type="submit"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => history.push('/')}
|
||||
value={t('common:Cancel')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
appConfig: state.application.config,
|
||||
loading: state.loading,
|
||||
}),
|
||||
dispatch => ({
|
||||
onAddWorkout: e => {
|
||||
dispatch(setLoading(true))
|
||||
const form = new FormData()
|
||||
form.append('file', e.target.form.gpxFile.files[0])
|
||||
/* prettier-ignore */
|
||||
form.append(
|
||||
'data',
|
||||
`{"sport_id": ${e.target.form.sport.value
|
||||
}, "notes": "${e.target.form.notes.value}"}`
|
||||
)
|
||||
dispatch(addWorkout(form))
|
||||
},
|
||||
onEditWorkout: (e, workout) => {
|
||||
dispatch(
|
||||
editWorkout({
|
||||
id: workout.id,
|
||||
notes: e.target.form.notes.value,
|
||||
sport_id: +e.target.form.sport.value,
|
||||
title: e.target.form.title.value,
|
||||
})
|
||||
)
|
||||
},
|
||||
})
|
||||
)(FormWithGpx)
|
@ -1,162 +0,0 @@
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import { addWorkoutWithoutGpx, editWorkout } from '../../../actions/workouts'
|
||||
import { history } from '../../../index'
|
||||
import { getDateWithTZ } from '../../../utils'
|
||||
import { formatWorkoutDate, translateSports } from '../../../utils/workouts'
|
||||
import CustomTextArea from '../../Common/CustomTextArea'
|
||||
|
||||
function FormWithoutGpx(props) {
|
||||
const { onAddOrEdit, sports, t, user, workout } = props
|
||||
const translatedSports = translateSports(sports, t, true)
|
||||
let workoutDate,
|
||||
workoutTime,
|
||||
sportId = ''
|
||||
if (workout) {
|
||||
const workoutDateTime = formatWorkoutDate(
|
||||
getDateWithTZ(workout.workout_date, user.timezone),
|
||||
'yyyy-MM-dd'
|
||||
)
|
||||
workoutDate = workoutDateTime.workout_date
|
||||
workoutTime = workoutDateTime.workout_time
|
||||
sportId = workout.sport_id
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={event => event.preventDefault()}>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{t('workouts:Title')}:
|
||||
<input
|
||||
name="title"
|
||||
defaultValue={workout ? workout.title : ''}
|
||||
className="form-control input-lg"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{t('common:Sport')}:
|
||||
<select
|
||||
className="form-control input-lg"
|
||||
defaultValue={sportId}
|
||||
name="sport_id"
|
||||
required
|
||||
>
|
||||
<option value="" />
|
||||
{translatedSports.map(sport => (
|
||||
<option key={sport.id} value={sport.id}>
|
||||
{sport.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{t('workouts:Workout Date')}:
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<input
|
||||
name="workout_date"
|
||||
defaultValue={workoutDate}
|
||||
className="form-control col-md"
|
||||
required
|
||||
type="date"
|
||||
/>
|
||||
<input
|
||||
name="workout_time"
|
||||
defaultValue={workoutTime}
|
||||
className="form-control col-md"
|
||||
required
|
||||
type="time"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{t('workouts:Duration')}:
|
||||
<input
|
||||
name="duration"
|
||||
defaultValue={workout ? workout.duration : ''}
|
||||
className="form-control col-xs-4"
|
||||
pattern="^([0-9]*[0-9]):([0-5][0-9]):([0-5][0-9])$"
|
||||
placeholder="hh:mm:ss"
|
||||
required
|
||||
type="text"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{t('workouts:Distance')} (km):
|
||||
<input
|
||||
name="distance"
|
||||
defaultValue={workout ? workout.distance : ''}
|
||||
className="form-control input-lg"
|
||||
min={0}
|
||||
required
|
||||
step="0.001"
|
||||
type="number"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{t('workouts:Notes')}:
|
||||
<CustomTextArea
|
||||
charLimit={500}
|
||||
defaultValue={workout ? workout.notes : ''}
|
||||
name="notes"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
onClick={event => onAddOrEdit(event, workout)}
|
||||
value={t('common:Submit')}
|
||||
/>
|
||||
<input
|
||||
type="submit"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => history.push('/')}
|
||||
value={t('common:Cancel')}
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
user: state.user,
|
||||
}),
|
||||
dispatch => ({
|
||||
onAddOrEdit: (e, workout) => {
|
||||
const d = e.target.form.duration.value.split(':')
|
||||
const duration = +d[0] * 60 * 60 + +d[1] * 60 + +d[2]
|
||||
|
||||
/* prettier-ignore */
|
||||
const workoutDate = `${e.target.form.workout_date.value
|
||||
} ${ e.target.form.workout_time.value}`
|
||||
|
||||
const data = {
|
||||
workout_date: workoutDate,
|
||||
distance: +e.target.form.distance.value,
|
||||
duration,
|
||||
notes: e.target.form.notes.value,
|
||||
sport_id: +e.target.form.sport_id.value,
|
||||
title: e.target.form.title.value,
|
||||
}
|
||||
if (workout) {
|
||||
data.id = workout.id
|
||||
dispatch(editWorkout(data))
|
||||
} else {
|
||||
dispatch(addWorkoutWithoutGpx(data))
|
||||
}
|
||||
},
|
||||
})
|
||||
)(FormWithoutGpx)
|
@ -1,38 +0,0 @@
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Redirect, Route, Switch } from 'react-router-dom'
|
||||
|
||||
import NotFound from './../Others/NotFound'
|
||||
import WorkoutAdd from './WorkoutAdd'
|
||||
import WorkoutDisplay from './WorkoutDisplay'
|
||||
import WorkoutEdit from './WorkoutEdit'
|
||||
import { isLoggedIn } from '../../utils'
|
||||
|
||||
function Workout() {
|
||||
return (
|
||||
<div>
|
||||
{isLoggedIn() ? (
|
||||
<Switch>
|
||||
<Route exact path="/workouts/add" component={WorkoutAdd} />
|
||||
<Route exact path="/workouts/:workoutId" component={WorkoutDisplay} />
|
||||
<Route
|
||||
exact
|
||||
path="/workouts/:workoutId/edit"
|
||||
component={WorkoutEdit}
|
||||
/>
|
||||
<Route
|
||||
path="/workouts/:workoutId/segment/:segmentId"
|
||||
component={WorkoutDisplay}
|
||||
/>
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
) : (
|
||||
<Redirect to="/login" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default connect(state => ({
|
||||
user: state.user,
|
||||
}))(Workout)
|
@ -1,189 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
import { translateSports } from '../../utils/workouts'
|
||||
|
||||
export default class WorkoutsFilter extends React.PureComponent {
|
||||
render() {
|
||||
const { loadWorkouts, sports, t, updateParams } = this.props
|
||||
const translatedSports = translateSports(sports, t)
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-body workout-filter">
|
||||
<form onSubmit={event => event.preventDefault()}>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{t('workouts:From')}:
|
||||
<input
|
||||
className="form-control col-md"
|
||||
name="from"
|
||||
onChange={e => updateParams(e)}
|
||||
type="date"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t('workouts:To')}:
|
||||
<input
|
||||
className="form-control col-md"
|
||||
name="to"
|
||||
onChange={e => updateParams(e)}
|
||||
type="date"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{t('common:Sport')}:
|
||||
<select
|
||||
className="form-control input-lg"
|
||||
name="sport_id"
|
||||
onChange={e => updateParams(e)}
|
||||
>
|
||||
<option value="" />
|
||||
{translatedSports.map(sport => (
|
||||
<option key={sport.id} value={sport.id}>
|
||||
{sport.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{t('workouts:Distance')} (km):
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-5">
|
||||
<input
|
||||
className="form-control"
|
||||
min={0}
|
||||
name="distance_from"
|
||||
onChange={e => updateParams(e)}
|
||||
step="1"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-2 align-middle text-center">
|
||||
{t('common:to')}
|
||||
</div>
|
||||
<div className="col-5">
|
||||
<input
|
||||
className="form-control"
|
||||
min={0}
|
||||
name="distance_to"
|
||||
onChange={e => updateParams(e)}
|
||||
step="1"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{t('workouts:Duration')}:
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-5">
|
||||
<input
|
||||
className="form-control"
|
||||
name="duration_from"
|
||||
onChange={e => updateParams(e)}
|
||||
pattern="^([0-9]*[0-9]):([0-5][0-9])$"
|
||||
placeholder="hh:mm"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-2 align-middle text-center">
|
||||
{t('common:to')}
|
||||
</div>
|
||||
<div className="col-5">
|
||||
<input
|
||||
className="form-control"
|
||||
name="duration_to"
|
||||
onChange={e => updateParams(e)}
|
||||
pattern="^([0-9]*[0-9]):([0-5][0-9])$"
|
||||
placeholder="hh:mm"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{t('workouts:Average speed')} (km/h):
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-5">
|
||||
<input
|
||||
className="form-control"
|
||||
min={0}
|
||||
name="ave_speed_from"
|
||||
onChange={e => updateParams(e)}
|
||||
step="1"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-2 align-middle text-center">
|
||||
{t('common:to')}
|
||||
</div>
|
||||
<div className="col-5">
|
||||
<input
|
||||
className="form-control"
|
||||
min={0}
|
||||
name="ave_speed_to"
|
||||
onChange={e => updateParams(e)}
|
||||
step="1"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
{t('workouts:Max. speed')} (km/h):
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-5">
|
||||
<input
|
||||
className="form-control"
|
||||
min={0}
|
||||
name="max_speed_from"
|
||||
onChange={e => updateParams(e)}
|
||||
step="1"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-2 align-middle text-center">
|
||||
{t('common:to')}
|
||||
</div>
|
||||
<div className="col-5">
|
||||
<input
|
||||
className="form-control"
|
||||
min={0}
|
||||
name="max_speed_to"
|
||||
onChange={e => updateParams(e)}
|
||||
step="1"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
className="btn btn-primary btn-lg btn-block"
|
||||
onClick={() => loadWorkouts()}
|
||||
type="submit"
|
||||
value={t('workouts:Filter')}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
@ -1,111 +0,0 @@
|
||||
import { format } from 'date-fns'
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import StaticMap from '../Common/StaticMap'
|
||||
import { getDateWithTZ } from '../../utils'
|
||||
|
||||
export default class WorkoutsList extends React.PureComponent {
|
||||
render() {
|
||||
const { loading, sports, t, user, workouts } = this.props
|
||||
return (
|
||||
<div className="card workout-card">
|
||||
<div className="card-body">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" />
|
||||
<th scope="col">{t('common:Workout')}</th>
|
||||
<th scope="col">{t('workouts:Date')}</th>
|
||||
<th scope="col">{t('workouts:Distance')}</th>
|
||||
<th scope="col">{t('workouts:Duration')}</th>
|
||||
<th scope="col">{t('workouts:Ave. speed')}</th>
|
||||
<th scope="col">{t('workouts:Max. speed')}</th>
|
||||
<th scope="col">{t('workouts:Ascent')}</th>
|
||||
<th scope="col">{t('workouts:Descent')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!loading &&
|
||||
sports &&
|
||||
workouts.map((workout, idx) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<tr key={idx}>
|
||||
<td>
|
||||
<span className="heading-span-absolute">
|
||||
{t('common:Sport')}
|
||||
</span>
|
||||
<img
|
||||
className="workout-sport"
|
||||
src={sports
|
||||
.filter(s => s.id === workout.sport_id)
|
||||
.map(s => s.img)}
|
||||
alt="workout sport logo"
|
||||
/>
|
||||
</td>
|
||||
<td className="workout-title">
|
||||
<span className="heading-span-absolute">
|
||||
{t('common:Workout')}
|
||||
</span>
|
||||
<Link to={`/workouts/${workout.id}`}>
|
||||
{workout.title}
|
||||
</Link>
|
||||
{workout.map && (
|
||||
<StaticMap workout={workout} display="list" />
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span className="heading-span-absolute">
|
||||
{t('workouts:Date')}
|
||||
</span>
|
||||
{format(
|
||||
getDateWithTZ(workout.workout_date, user.timezone),
|
||||
'dd/MM/yyyy HH:mm'
|
||||
)}
|
||||
</td>
|
||||
<td className="text-right">
|
||||
<span className="heading-span-absolute">
|
||||
{t('workouts:Distance')}
|
||||
</span>
|
||||
{Number(workout.distance).toFixed(2)} km
|
||||
</td>
|
||||
<td className="text-right">
|
||||
<span className="heading-span-absolute">
|
||||
{t('workouts:Duration')}
|
||||
</span>
|
||||
{workout.moving}
|
||||
</td>
|
||||
<td className="text-right">
|
||||
<span className="heading-span-absolute">
|
||||
{t('workouts:Ave. speed')}
|
||||
</span>
|
||||
{workout.ave_speed} km/h
|
||||
</td>
|
||||
<td className="text-right">
|
||||
<span className="heading-span-absolute">
|
||||
{t('workouts:Max. speed')}
|
||||
</span>
|
||||
{workout.max_speed} km/h
|
||||
</td>
|
||||
<td className="text-right">
|
||||
<span className="heading-span-absolute">
|
||||
{t('workouts:Ascent')}
|
||||
</span>
|
||||
{workout.ascent === null ? '' : `${workout.ascent} m`}
|
||||
</td>
|
||||
<td className="text-right">
|
||||
<span className="heading-span-absolute">
|
||||
{t('workouts:Descent')}
|
||||
</span>
|
||||
{workout.descent === null ? '' : `${workout.descent} m`}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{loading && <div className="loader" />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
@ -1,120 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import { withTranslation } from 'react-i18next'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import Message from '../Common/Message'
|
||||
import NoWorkouts from '../Common/NoWorkouts'
|
||||
import WorkoutsFilter from './WorkoutsFilter'
|
||||
import WorkoutsList from './WorkoutsList'
|
||||
import { getOrUpdateData } from '../../actions'
|
||||
import { getMoreWorkouts } from '../../actions/workouts'
|
||||
|
||||
class Workouts extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context)
|
||||
this.state = {
|
||||
params: {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.loadWorkouts(this.state.params)
|
||||
}
|
||||
|
||||
setParams(e) {
|
||||
const { params } = this.state
|
||||
if (e.target.value === '') {
|
||||
delete params[e.target.name]
|
||||
} else {
|
||||
params[e.target.name] = e.target.value
|
||||
}
|
||||
params.page = 1
|
||||
this.setState(params)
|
||||
}
|
||||
render() {
|
||||
const {
|
||||
loading,
|
||||
loadWorkouts,
|
||||
loadMoreWorkouts,
|
||||
message,
|
||||
sports,
|
||||
t,
|
||||
user,
|
||||
workouts,
|
||||
} = this.props
|
||||
const { params } = this.state
|
||||
const paginationEnd =
|
||||
workouts.length > 0
|
||||
? workouts[workouts.length - 1].previous_workout === null
|
||||
: true
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>FitTrackee - {t('common:Workouts')}</title>
|
||||
</Helmet>
|
||||
{message ? (
|
||||
<Message message={message} t={t} />
|
||||
) : (
|
||||
<div className="container history">
|
||||
<div className="row">
|
||||
<div className="col-md-3">
|
||||
<WorkoutsFilter
|
||||
sports={sports}
|
||||
loadWorkouts={() => loadWorkouts(params)}
|
||||
t={t}
|
||||
updateParams={e => this.setParams(e)}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-9 workouts-result">
|
||||
<WorkoutsList
|
||||
workouts={workouts}
|
||||
loading={loading}
|
||||
sports={sports}
|
||||
t={t}
|
||||
user={user}
|
||||
/>
|
||||
{!paginationEnd && (
|
||||
<input
|
||||
type="submit"
|
||||
className="btn btn-default btn-md btn-block"
|
||||
value="Load more workouts"
|
||||
onClick={() => {
|
||||
params.page += 1
|
||||
loadMoreWorkouts(params)
|
||||
this.setState(params)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{workouts.length === 0 && <NoWorkouts t={t} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default withTranslation()(
|
||||
connect(
|
||||
state => ({
|
||||
workouts: state.workouts.data,
|
||||
loading: state.loading,
|
||||
message: state.message,
|
||||
sports: state.sports.data,
|
||||
user: state.user,
|
||||
}),
|
||||
dispatch => ({
|
||||
loadWorkouts: params => {
|
||||
dispatch(getOrUpdateData('getData', 'workouts', params))
|
||||
},
|
||||
loadMoreWorkouts: params => {
|
||||
dispatch(getMoreWorkouts(params))
|
||||
},
|
||||
})
|
||||
)(Workouts)
|
||||
)
|
Reference in New Issue
Block a user