API & Client: refactor (rename mpwo to fittrackee)
27
fittrackee_client/Dockerfile
Normal file
@ -0,0 +1,27 @@
|
||||
FROM node:9.4.0
|
||||
|
||||
MAINTAINER SamR1@users.noreply.github.com
|
||||
|
||||
# set working directory
|
||||
RUN mkdir /usr/src/app
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# add `/usr/src/app/node_modules/.bin` to $PATH
|
||||
ENV PATH /usr/src/app/node_modules/.bin:$PATH
|
||||
|
||||
# add environment variables
|
||||
ARG REACT_APP_API_URL
|
||||
ARG NODE_ENV
|
||||
ENV NODE_ENV $NODE_ENV
|
||||
ENV REACT_APP_API_URL $REACT_APP_API_URL
|
||||
|
||||
# install and cache app dependencies
|
||||
COPY package.json /usr/src/app/package.json
|
||||
RUN yarn install --silent
|
||||
RUN yarn global add react-scripts
|
||||
|
||||
# add app
|
||||
COPY . /usr/src/app/
|
||||
|
||||
# start app
|
||||
CMD yarn start
|
43
fittrackee_client/e2e/activities.test.js
Normal file
@ -0,0 +1,43 @@
|
||||
import { Selector } from 'testcafe'
|
||||
|
||||
import { TEST_URL } from './utils'
|
||||
|
||||
const randomstring = require('randomstring')
|
||||
|
||||
const username = randomstring.generate(8)
|
||||
const email = `${username}@test.com`
|
||||
const password = 'lentghOk'
|
||||
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
fixture('/activities').page(`${TEST_URL}/activities`)
|
||||
|
||||
test('standard user should be able to add a workout (w/o gpx)', async t => {
|
||||
await t
|
||||
.navigateTo(`${TEST_URL}/register`)
|
||||
.typeText('input[name="username"]', username)
|
||||
.typeText('input[name="email"]', email)
|
||||
.typeText('input[name="password"]', password)
|
||||
.typeText('input[name="passwordConf"]', password)
|
||||
.click(Selector('input[type="submit"]'))
|
||||
|
||||
await t
|
||||
.navigateTo(`${TEST_URL}/activities/add`)
|
||||
.expect(Selector('H1').withText('Dashboard').exists).notOk()
|
||||
.expect(Selector('H2').withText('Add a workout').exists).ok()
|
||||
.click(Selector('input[name="withoutGpx"]'))
|
||||
.click(Selector('select').filter('[name="sport_id"]'))
|
||||
.click(Selector('option').filter('[value="1"]'))
|
||||
.typeText('input[name="activity_date"]', '2018-12-20')
|
||||
.typeText('input[name="activity_time"]', '14:05')
|
||||
.typeText('input[name="duration"]', '01:00:00')
|
||||
.typeText('input[name="distance"]', '10')
|
||||
.click(Selector('input[type="submit"]'))
|
||||
|
||||
// pb w/ chromium to check
|
||||
// await t
|
||||
// .expect(Selector('H1').withText('Dashboard').exists).notOk()
|
||||
// .expect(Selector('H1').withText('Activity').exists).ok()
|
||||
|
||||
})
|
||||
|
25
fittrackee_client/e2e/admin-sports.test.js
Normal file
@ -0,0 +1,25 @@
|
||||
import { Selector } from 'testcafe'
|
||||
|
||||
import { TEST_URL } from './utils'
|
||||
|
||||
// database must be initialiazed
|
||||
const adminEmail = 'admin@example.com'
|
||||
const adminPassword = 'mpwoadmin'
|
||||
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
fixture('/admin/sports').page(`${TEST_URL}/admin/sports`)
|
||||
|
||||
test('admin should be able to access sports administration page', async t => {
|
||||
// admin login
|
||||
await t
|
||||
.navigateTo(`${TEST_URL}/login`)
|
||||
.typeText('input[name="email"]', adminEmail)
|
||||
.typeText('input[name="password"]', adminPassword)
|
||||
.click(Selector('input[type="submit"]'))
|
||||
|
||||
await t
|
||||
.navigateTo(`${TEST_URL}/admin/sports`)
|
||||
.expect(Selector('H1').withText('Administration - Sports').exists).ok()
|
||||
.expect(Selector('TD').withText('Hiking').exists).ok()
|
||||
})
|
50
fittrackee_client/e2e/admin.test.js
Normal file
@ -0,0 +1,50 @@
|
||||
import { Selector } from 'testcafe'
|
||||
|
||||
import { TEST_URL } from './utils'
|
||||
|
||||
const randomstring = require('randomstring')
|
||||
|
||||
const username = randomstring.generate(8)
|
||||
const email = `${username}@test.com`
|
||||
const password = 'lentghOk'
|
||||
|
||||
// database must be initialiazed
|
||||
const adminEmail = 'admin@example.com'
|
||||
const adminPassword = 'mpwoadmin'
|
||||
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
fixture('/admin').page(`${TEST_URL}/admin`)
|
||||
|
||||
test('standard user should not be able to access admin page', async t => {
|
||||
// register user
|
||||
await t
|
||||
.navigateTo(`${TEST_URL}/register`)
|
||||
.typeText('input[name="username"]', username)
|
||||
.typeText('input[name="email"]', email)
|
||||
.typeText('input[name="password"]', password)
|
||||
.typeText('input[name="passwordConf"]', password)
|
||||
.click(Selector('input[type="submit"]'))
|
||||
|
||||
await t
|
||||
.navigateTo(`${TEST_URL}/admin`)
|
||||
.expect(Selector('H1').withText('Access denied').exists).ok()
|
||||
.expect(Selector('H1').withText('Dashboard').exists).notOk()
|
||||
|
||||
})
|
||||
|
||||
test('admin should be able to access admin page', async t => {
|
||||
// admin login
|
||||
await t
|
||||
.navigateTo(`${TEST_URL}/login`)
|
||||
.typeText('input[name="email"]', adminEmail)
|
||||
.typeText('input[name="password"]', adminPassword)
|
||||
.click(Selector('input[type="submit"]'))
|
||||
|
||||
await t
|
||||
.navigateTo(`${TEST_URL}/admin`)
|
||||
.expect(Selector('H1').withText('Access denied').exists).notOk()
|
||||
.expect(Selector('H1').withText('Administration').exists).ok()
|
||||
.expect(Selector('.admin-items').withText('Sports').exists).ok()
|
||||
|
||||
})
|
15
fittrackee_client/e2e/index.test.js
Normal file
@ -0,0 +1,15 @@
|
||||
import { Selector } from 'testcafe'
|
||||
|
||||
import { TEST_URL } from './utils'
|
||||
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
fixture('/').page(`${TEST_URL}/`)
|
||||
|
||||
test('users should be able to view the \'/\' page', async t => {
|
||||
|
||||
await t
|
||||
.navigateTo(TEST_URL)
|
||||
.expect(Selector('A').withText('Dashboard').exists).ok()
|
||||
|
||||
})
|
97
fittrackee_client/e2e/login.test.js
Normal file
@ -0,0 +1,97 @@
|
||||
import { Selector } from 'testcafe'
|
||||
|
||||
import { TEST_URL } from './utils'
|
||||
|
||||
const randomstring = require('randomstring')
|
||||
|
||||
const username = randomstring.generate(8)
|
||||
const email = `${username}@test.com`
|
||||
const password = 'lentghOk'
|
||||
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
fixture('/login').page(`${TEST_URL}/login`)
|
||||
|
||||
test('should display the registration form', async t => {
|
||||
await t
|
||||
.navigateTo(`${TEST_URL}/login`)
|
||||
.expect(Selector('H1').withText('Login').exists).ok()
|
||||
.expect(Selector('form').exists).ok()
|
||||
.expect(Selector('input[name="username"]').exists).notOk()
|
||||
.expect(Selector('input[name="email"]').exists).ok()
|
||||
.expect(Selector('input[name="password"]').exists).ok()
|
||||
.expect(Selector('input[name="passwordConf"]').exists).notOk()
|
||||
})
|
||||
|
||||
test('should throw an error if the user is not registered', async t => {
|
||||
|
||||
// register user with duplicate user name
|
||||
await t
|
||||
.navigateTo(`${TEST_URL}/login`)
|
||||
.typeText('input[name="email"]', email)
|
||||
.typeText('input[name="password"]', password)
|
||||
.click(Selector('input[type="submit"]'))
|
||||
|
||||
// assert user registration failed
|
||||
await t
|
||||
.expect(Selector('H1').withText('Login').exists).ok()
|
||||
.expect(Selector('code').withText(
|
||||
'Invalid credentials.').exists).ok()
|
||||
})
|
||||
|
||||
test('should allow a user to login', async t => {
|
||||
|
||||
// register user
|
||||
await t
|
||||
.navigateTo(`${TEST_URL}/register`)
|
||||
.typeText('input[name="username"]', username)
|
||||
.typeText('input[name="email"]', email)
|
||||
.typeText('input[name="password"]', password)
|
||||
.typeText('input[name="passwordConf"]', password)
|
||||
.click(Selector('input[type="submit"]'))
|
||||
|
||||
await t
|
||||
.navigateTo(`${TEST_URL}/logout`)
|
||||
.navigateTo(`${TEST_URL}/login`)
|
||||
.expect(Selector('H1').withText('Login').exists).ok()
|
||||
.typeText('input[name="email"]', email)
|
||||
.typeText('input[name="password"]', password)
|
||||
.click(Selector('input[type="submit"]'))
|
||||
|
||||
// assert user is redirected to '/'
|
||||
await t
|
||||
.expect(Selector('H1').withText('Login').exists).notOk()
|
||||
|
||||
})
|
||||
|
||||
test('should throw an error if the email is invalid', async t => {
|
||||
|
||||
// register user with duplicate user name
|
||||
await t
|
||||
.navigateTo(`${TEST_URL}/login`)
|
||||
.typeText('input[name="email"]', `${email}2`)
|
||||
.typeText('input[name="password"]', password)
|
||||
.click(Selector('input[type="submit"]'))
|
||||
|
||||
// assert user registration failed
|
||||
await t
|
||||
.expect(Selector('H1').withText('Login').exists).ok()
|
||||
.expect(Selector('code').withText(
|
||||
'Invalid credentials.').exists).ok()
|
||||
})
|
||||
|
||||
test('should throw an error if the password is invalid', async t => {
|
||||
|
||||
// register user with duplicate user name
|
||||
await t
|
||||
.navigateTo(`${TEST_URL}/login`)
|
||||
.typeText('input[name="email"]', email)
|
||||
.typeText('input[name="password"]', `${password}2`)
|
||||
.click(Selector('input[type="submit"]'))
|
||||
|
||||
// assert user registration failed
|
||||
await t
|
||||
.expect(Selector('H1').withText('Login').exists).ok()
|
||||
.expect(Selector('code').withText(
|
||||
'Invalid credentials.').exists).ok()
|
||||
})
|
40
fittrackee_client/e2e/profile.test.js
Normal file
@ -0,0 +1,40 @@
|
||||
import { Selector } from 'testcafe'
|
||||
|
||||
import { TEST_URL } from './utils'
|
||||
|
||||
const randomstring = require('randomstring')
|
||||
|
||||
const username = randomstring.generate(8)
|
||||
const email = `${username}@test.com`
|
||||
const password = 'lentghOk'
|
||||
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
fixture('/profile').page(`${TEST_URL}/profile`)
|
||||
|
||||
test('should be able to access his profile page', async t => {
|
||||
// register user
|
||||
await t
|
||||
.navigateTo(`${TEST_URL}/register`)
|
||||
.typeText('input[name="username"]', username)
|
||||
.typeText('input[name="email"]', email)
|
||||
.typeText('input[name="password"]', password)
|
||||
.typeText('input[name="passwordConf"]', password)
|
||||
.click(Selector('input[type="submit"]'))
|
||||
|
||||
await t
|
||||
.navigateTo(`${TEST_URL}/logout`)
|
||||
.navigateTo(`${TEST_URL}/login`)
|
||||
.expect(Selector('H1').withText('Login').exists).ok()
|
||||
.typeText('input[name="email"]', email)
|
||||
.typeText('input[name="password"]', password)
|
||||
.click(Selector('input[type="submit"]'))
|
||||
|
||||
await t
|
||||
.navigateTo(`${TEST_URL}/profile`)
|
||||
.expect(Selector('H1').withText('Login').exists).notOk()
|
||||
.expect(Selector('H1').withText('Dashboard').exists).notOk()
|
||||
.expect(Selector('H1').withText('Profile').exists).ok()
|
||||
.expect(Selector('.userName').withText(username).exists).ok()
|
||||
|
||||
})
|
177
fittrackee_client/e2e/register.test.js
Normal file
@ -0,0 +1,177 @@
|
||||
import { Selector } from 'testcafe'
|
||||
|
||||
import { TEST_URL } from './utils'
|
||||
|
||||
const randomstring = require('randomstring')
|
||||
|
||||
let username = randomstring.generate(8)
|
||||
const email = `${username}@test.com`
|
||||
const password = 'lentghOk'
|
||||
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
fixture('/register').page(`${TEST_URL}/register`)
|
||||
|
||||
test('should display the registration form', async t => {
|
||||
await t
|
||||
.navigateTo(`${TEST_URL}/register`)
|
||||
.expect(Selector('H1').withText('Register').exists).ok()
|
||||
.expect(Selector('form').exists).ok()
|
||||
.expect(Selector('input[name="username"]').exists).ok()
|
||||
.expect(Selector('input[name="email"]').exists).ok()
|
||||
.expect(Selector('input[name="password"]').exists).ok()
|
||||
.expect(Selector('input[name="passwordConf"]').exists).ok()
|
||||
})
|
||||
|
||||
test('should allow a user to register', async t => {
|
||||
|
||||
// register user
|
||||
await t
|
||||
.navigateTo(`${TEST_URL}/register`)
|
||||
.expect(Selector('H1').withText('Register').exists).ok()
|
||||
.typeText('input[name="username"]', username)
|
||||
.typeText('input[name="email"]', email)
|
||||
.typeText('input[name="password"]', password)
|
||||
.typeText('input[name="passwordConf"]', password)
|
||||
.click(Selector('input[type="submit"]'))
|
||||
|
||||
// assert user is redirected to '/'
|
||||
await t
|
||||
.expect(Selector('H1').withText('Register').exists).notOk()
|
||||
})
|
||||
|
||||
test('should throw an error if the username is taken', async t => {
|
||||
|
||||
// register user with duplicate user name
|
||||
await t
|
||||
.navigateTo(`${TEST_URL}/register`)
|
||||
.typeText('input[name="username"]', username)
|
||||
.typeText('input[name="email"]', `${email}2`)
|
||||
.typeText('input[name="password"]', password)
|
||||
.typeText('input[name="passwordConf"]', password)
|
||||
.click(Selector('input[type="submit"]'))
|
||||
|
||||
// assert user registration failed
|
||||
await t
|
||||
.expect(Selector('H1').withText('Register').exists).ok()
|
||||
.expect(Selector('code').withText(
|
||||
'Sorry. That user already exists.').exists).ok()
|
||||
})
|
||||
|
||||
username = randomstring.generate(8)
|
||||
|
||||
test('should throw an error if the email is taken', async t => {
|
||||
|
||||
// register user with duplicate email
|
||||
await t
|
||||
.navigateTo(`${TEST_URL}/register`)
|
||||
.typeText('input[name="username"]', `${username}2`)
|
||||
.typeText('input[name="email"]', email)
|
||||
.typeText('input[name="password"]', password)
|
||||
.typeText('input[name="passwordConf"]', password)
|
||||
.click(Selector('input[type="submit"]'))
|
||||
|
||||
// assert user registration failed
|
||||
await t
|
||||
.expect(Selector('H1').withText('Register').exists).ok()
|
||||
.expect(Selector('code').withText(
|
||||
'Sorry. That user already exists.').exists).ok()
|
||||
})
|
||||
|
||||
test('should throw an error if the username is too short', async t => {
|
||||
|
||||
const shortUsername = 'a'
|
||||
|
||||
// register user with duplicate email
|
||||
await t
|
||||
.navigateTo(`${TEST_URL}/register`)
|
||||
.typeText('input[name="username"]', `${shortUsername}`)
|
||||
.typeText('input[name="email"]', email)
|
||||
.typeText('input[name="password"]', password)
|
||||
.typeText('input[name="passwordConf"]', password)
|
||||
.click(Selector('input[type="submit"]'))
|
||||
|
||||
// assert user registration failed
|
||||
await t
|
||||
.expect(Selector('H1').withText('Register').exists).ok()
|
||||
.expect(Selector('code').withText(
|
||||
'Username: 3 to 12 characters required.').exists).ok()
|
||||
})
|
||||
|
||||
test('should throw an error if the user is too long', async t => {
|
||||
|
||||
const longUsername = randomstring.generate(20)
|
||||
|
||||
// register user with duplicate email
|
||||
await t
|
||||
.navigateTo(`${TEST_URL}/register`)
|
||||
.typeText('input[name="username"]', `${longUsername}`)
|
||||
.typeText('input[name="email"]', email)
|
||||
.typeText('input[name="password"]', password)
|
||||
.typeText('input[name="passwordConf"]', password)
|
||||
.click(Selector('input[type="submit"]'))
|
||||
|
||||
// assert user registration failed
|
||||
await t
|
||||
.expect(Selector('H1').withText('Register').exists).ok()
|
||||
.expect(Selector('code').withText(
|
||||
'Username: 3 to 12 characters required.').exists).ok()
|
||||
})
|
||||
|
||||
test('should throw an error if the email is invalid', async t => {
|
||||
|
||||
const invalidEmail = `${username}@test`
|
||||
|
||||
// register user with duplicate email
|
||||
await t
|
||||
.navigateTo(`${TEST_URL}/register`)
|
||||
.typeText('input[name="username"]', username)
|
||||
.typeText('input[name="email"]', invalidEmail)
|
||||
.typeText('input[name="password"]', password)
|
||||
.typeText('input[name="passwordConf"]', password)
|
||||
.click(Selector('input[type="submit"]'))
|
||||
|
||||
// assert user registration failed
|
||||
await t
|
||||
.expect(Selector('H1').withText('Register').exists).ok()
|
||||
.expect(Selector('code').withText(
|
||||
'Valid email must be provided.').exists).ok()
|
||||
})
|
||||
|
||||
test('should throw an error if passwords don\'t match', async t => {
|
||||
|
||||
// register user with duplicate email
|
||||
await t
|
||||
.navigateTo(`${TEST_URL}/register`)
|
||||
.typeText('input[name="username"]', username)
|
||||
.typeText('input[name="email"]', email)
|
||||
.typeText('input[name="password"]', password)
|
||||
.typeText('input[name="passwordConf"]', `${password}2`)
|
||||
.click(Selector('input[type="submit"]'))
|
||||
|
||||
// assert user registration failed
|
||||
await t
|
||||
.expect(Selector('H1').withText('Register').exists).ok()
|
||||
.expect(Selector('code').withText(
|
||||
'Password and password confirmation don\'t match.').exists).ok()
|
||||
})
|
||||
|
||||
test('should throw an error if the password is too short', async t => {
|
||||
|
||||
const invalidPassword = '1234567'
|
||||
|
||||
// register user with duplicate email
|
||||
await t
|
||||
.navigateTo(`${TEST_URL}/register`)
|
||||
.typeText('input[name="username"]', username)
|
||||
.typeText('input[name="email"]', email)
|
||||
.typeText('input[name="password"]', invalidPassword)
|
||||
.typeText('input[name="passwordConf"]', invalidPassword)
|
||||
.click(Selector('input[type="submit"]'))
|
||||
|
||||
// assert user registration failed
|
||||
await t
|
||||
.expect(Selector('H1').withText('Register').exists).ok()
|
||||
.expect(Selector('code').withText(
|
||||
'Password: 8 characters required.').exists).ok()
|
||||
})
|
1
fittrackee_client/e2e/utils.js
Normal file
@ -0,0 +1 @@
|
||||
export const TEST_URL = process.env.TEST_URL
|
4
fittrackee_client/package.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "fittrackee_client",
|
||||
"version": "0.1.0"
|
||||
}
|
BIN
fittrackee_client/public/favicon.ico
Normal file
After Width: | Height: | Size: 3.8 KiB |
BIN
fittrackee_client/public/img/photo.png
Normal file
After Width: | Height: | Size: 2.9 KiB |
BIN
fittrackee_client/public/img/sports/cycling-sport.png
Normal file
After Width: | Height: | Size: 5.4 KiB |
BIN
fittrackee_client/public/img/sports/cycling-transport.png
Normal file
After Width: | Height: | Size: 5.5 KiB |
BIN
fittrackee_client/public/img/sports/hiking.png
Normal file
After Width: | Height: | Size: 3.0 KiB |
BIN
fittrackee_client/public/img/sports/mountain-biking.png
Normal file
After Width: | Height: | Size: 4.3 KiB |
BIN
fittrackee_client/public/img/sports/running.png
Normal file
After Width: | Height: | Size: 3.0 KiB |
BIN
fittrackee_client/public/img/sports/walking.png
Normal file
After Width: | Height: | Size: 3.5 KiB |
38
fittrackee_client/public/index.html
Normal file
@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="theme-color" content="#000000">
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.1/css/bootstrap.min.css"
|
||||
integrity="sha384-WskhaSGFgHYWDcbwN70/dfYBj47jz9qbsMId/iRN3ewGhXQFZCSftd1LZCfmhktB"
|
||||
crossorigin="anonymous">
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/fork-awesome@1.0.11/css/fork-awesome.min.css"
|
||||
integrity="sha256-MGU/JUq/40CFrfxjXb5pZjpoZmxiP2KuICN5ElLFNd8="
|
||||
crossorigin="anonymous"
|
||||
>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/foundation-icons/3.0/foundation-icons.min.css"
|
||||
>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://unpkg.com/leaflet@1.3.1/dist/leaflet.css"
|
||||
integrity="sha512-Rksm5RenBEKSKFjgI3a41vrjkw4EVPlJ3+OiI65vTjIdo9brlAacEuKOiQ5OFh7cOI1bkDwLqdLw3Zg0cRJAAQ=="
|
||||
crossorigin=""
|
||||
>
|
||||
<title>FitTrackee</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
You need to enable JavaScript to run this app.
|
||||
</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
15
fittrackee_client/public/manifest.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
}
|
||||
],
|
||||
"start_url": "./index.html",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
155
fittrackee_client/src/actions/activities.js
Normal file
@ -0,0 +1,155 @@
|
||||
import { parse } from 'date-fns'
|
||||
|
||||
import FitTrackeeGenericApi from '../fitTrackeeApi'
|
||||
import FitTrackeeApi from '../fitTrackeeApi/activities'
|
||||
import { history } from '../index'
|
||||
import { formatChartData } from '../utils'
|
||||
import { setError, setLoading } from './index'
|
||||
import { loadProfile } from './user'
|
||||
|
||||
export const pushActivities = activities => ({
|
||||
type: 'PUSH_ACTIVITIES',
|
||||
activities,
|
||||
})
|
||||
|
||||
export const updateCalendar = activities => ({
|
||||
type: 'UPDATE_CALENDAR',
|
||||
activities,
|
||||
})
|
||||
|
||||
export const setGpx = gpxContent => ({
|
||||
type: 'SET_GPX',
|
||||
gpxContent,
|
||||
})
|
||||
|
||||
export const setChartData = chartData => ({
|
||||
type: 'SET_CHART_DATA',
|
||||
chartData,
|
||||
})
|
||||
|
||||
export const addActivity = form => dispatch => FitTrackeeApi
|
||||
.addActivity(form)
|
||||
.then(ret => {
|
||||
if (ret.status === 'created') {
|
||||
if (ret.data.activities.length === 0) {
|
||||
dispatch(setError('activities: no correct file'))
|
||||
} else if (ret.data.activities.length === 1) {
|
||||
dispatch(loadProfile())
|
||||
history.push(`/activities/${ret.data.activities[0].id}`)
|
||||
} else { // ret.data.activities.length > 1
|
||||
dispatch(loadProfile())
|
||||
history.push('/')
|
||||
}
|
||||
} else {
|
||||
dispatch(setError(`activities: ${ret.message}`))
|
||||
}
|
||||
dispatch(setLoading())
|
||||
})
|
||||
.catch(error => dispatch(setError(`activities: ${error}`)))
|
||||
|
||||
|
||||
export const addActivityWithoutGpx = form => dispatch => FitTrackeeApi
|
||||
.addActivityWithoutGpx(form)
|
||||
.then(ret => {
|
||||
if (ret.status === 'created') {
|
||||
dispatch(loadProfile())
|
||||
history.push(`/activities/${ret.data.activities[0].id}`)
|
||||
} else {
|
||||
dispatch(setError(`activities: ${ret.message}`))
|
||||
}
|
||||
})
|
||||
.catch(error => dispatch(setError(`activities: ${error}`)))
|
||||
|
||||
|
||||
export const getActivityGpx = activityId => dispatch => {
|
||||
if (activityId) {
|
||||
return FitTrackeeApi
|
||||
.getActivityGpx(activityId)
|
||||
.then(ret => {
|
||||
if (ret.status === 'success') {
|
||||
dispatch(setGpx(ret.data.gpx))
|
||||
} else {
|
||||
dispatch(setError(`activities: ${ret.message}`))
|
||||
}
|
||||
})
|
||||
.catch(error => dispatch(setError(`activities: ${error}`)))
|
||||
}
|
||||
dispatch(setGpx(null))
|
||||
}
|
||||
|
||||
|
||||
export const getActivityChartData = activityId => dispatch => {
|
||||
if (activityId) {
|
||||
return FitTrackeeApi
|
||||
.getActivityChartData(activityId)
|
||||
.then(ret => {
|
||||
if (ret.status === 'success') {
|
||||
dispatch(setChartData(formatChartData(ret.data.chart_data)))
|
||||
} else {
|
||||
dispatch(setError(`activities: ${ret.message}`))
|
||||
}
|
||||
})
|
||||
.catch(error => dispatch(setError(`activities: ${error}`)))
|
||||
}
|
||||
dispatch(setChartData(null))
|
||||
}
|
||||
|
||||
|
||||
export const deleteActivity = id => dispatch => FitTrackeeGenericApi
|
||||
.deleteData('activities', id)
|
||||
.then(ret => {
|
||||
if (ret.status === 204) {
|
||||
dispatch(loadProfile())
|
||||
history.push('/')
|
||||
} else {
|
||||
dispatch(setError(`activities: ${ret.status}`))
|
||||
}
|
||||
})
|
||||
.catch(error => dispatch(setError(`activities: ${error}`)))
|
||||
|
||||
|
||||
export const editActivity = form => dispatch => FitTrackeeGenericApi
|
||||
.updateData('activities', form)
|
||||
.then(ret => {
|
||||
if (ret.status === 'success') {
|
||||
dispatch(loadProfile())
|
||||
history.push(`/activities/${ret.data.activities[0].id}`)
|
||||
} else {
|
||||
dispatch(setError(`activities: ${ret.message}`))
|
||||
}
|
||||
dispatch(setLoading())
|
||||
})
|
||||
.catch(error => dispatch(setError(`activities: ${error}`)))
|
||||
|
||||
|
||||
export const getMoreActivities = page => dispatch => FitTrackeeGenericApi
|
||||
.getData('activities', { page })
|
||||
.then(ret => {
|
||||
if (ret.status === 'success') {
|
||||
if (ret.data.activities.length > 0) {
|
||||
dispatch(pushActivities(ret.data.activities))
|
||||
}
|
||||
} else {
|
||||
dispatch(setError(`activities: ${ret.message}`))
|
||||
}
|
||||
})
|
||||
.catch(error => dispatch(setError(`activities: ${error}`)))
|
||||
|
||||
export const getMonthActivities = (start, end) => dispatch =>
|
||||
FitTrackeeGenericApi
|
||||
.getData('activities', { start, end, order: 'asc', per_page: 100 })
|
||||
.then(ret => {
|
||||
if (ret.status === 'success') {
|
||||
if (ret.data.activities.length > 0) {
|
||||
for (let i = 0; i < ret.data.activities.length; i++) {
|
||||
ret.data.activities[i].activity_date = parse(
|
||||
ret.data.activities[i].activity_date
|
||||
)
|
||||
}
|
||||
dispatch(updateCalendar(ret.data.activities))
|
||||
}
|
||||
} else {
|
||||
dispatch(setError(`activities: ${ret.message}`))
|
||||
}
|
||||
})
|
||||
.catch(error => dispatch(setError(`activities: ${error}`)))
|
77
fittrackee_client/src/actions/index.js
Normal file
@ -0,0 +1,77 @@
|
||||
import FitTrackeeApi from '../fitTrackeeApi/index'
|
||||
import { history } from '../index'
|
||||
|
||||
|
||||
export const setData = (target, data) => ({
|
||||
type: 'SET_DATA',
|
||||
data,
|
||||
target,
|
||||
})
|
||||
|
||||
export const setError = message => ({
|
||||
type: 'SET_ERROR',
|
||||
message,
|
||||
})
|
||||
|
||||
export const setLoading = () => ({
|
||||
type: 'SET_LOADING',
|
||||
})
|
||||
|
||||
export const getData = (target, data) => dispatch => {
|
||||
if (data && data.id && isNaN(data.id)) {
|
||||
return dispatch(setError(target, `${target}: Incorrect id`))
|
||||
}
|
||||
return FitTrackeeApi
|
||||
.getData(target, data)
|
||||
.then(ret => {
|
||||
if (ret.status === 'success') {
|
||||
dispatch(setData(target, ret.data))
|
||||
} else {
|
||||
dispatch(setError(`${target}: ${ret.message}`))
|
||||
}
|
||||
})
|
||||
.catch(error => dispatch(setError(`${target}: ${error}`)))
|
||||
}
|
||||
|
||||
export const addData = (target, data) => dispatch => FitTrackeeApi
|
||||
.addData(target, data)
|
||||
.then(ret => {
|
||||
if (ret.status === 'created') {
|
||||
history.push(`/admin/${target}`)
|
||||
} else {
|
||||
dispatch(setError(`${target}: ${ret.status}`))
|
||||
}
|
||||
})
|
||||
.catch(error => dispatch(setError(`${target}: ${error}`)))
|
||||
|
||||
export const updateData = (target, data) => dispatch => {
|
||||
if (isNaN(data.id)) {
|
||||
return dispatch(setError(target, `${target}: Incorrect id`))
|
||||
}
|
||||
return FitTrackeeApi
|
||||
.updateData(target, data)
|
||||
.then(ret => {
|
||||
if (ret.status === 'success') {
|
||||
dispatch(setData(target, ret.data))
|
||||
} else {
|
||||
dispatch(setError(`${target}: ${ret.message}`))
|
||||
}
|
||||
})
|
||||
.catch(error => dispatch(setError(`${target}: ${error}`)))
|
||||
}
|
||||
|
||||
export const deleteData = (target, id) => dispatch => {
|
||||
if (isNaN(id)) {
|
||||
return dispatch(setError(target, `${target}: Incorrect id`))
|
||||
}
|
||||
return FitTrackeeApi
|
||||
.deleteData(target, id)
|
||||
.then(ret => {
|
||||
if (ret.status === 204) {
|
||||
history.push(`/admin/${target}`)
|
||||
} else {
|
||||
dispatch(setError(`${target}: ${ret.message}`))
|
||||
}
|
||||
})
|
||||
.catch(error => dispatch(setError(`${target}: ${error}`)))
|
||||
}
|
13
fittrackee_client/src/actions/stats.js
Normal file
@ -0,0 +1,13 @@
|
||||
import FitTrackeeApi from '../fitTrackeeApi/stats'
|
||||
import { setData, setError } from './index'
|
||||
|
||||
export const getStats = (userId, type, data) => dispatch => FitTrackeeApi
|
||||
.getStats(userId, type, data)
|
||||
.then(ret => {
|
||||
if (ret.status === 'success') {
|
||||
dispatch(setData('statistics', ret.data))
|
||||
} else {
|
||||
dispatch(setError(`statistics: ${ret.message}`))
|
||||
}
|
||||
})
|
||||
.catch(error => dispatch(setError(`statistics: ${error}`)))
|
182
fittrackee_client/src/actions/user.js
Normal file
@ -0,0 +1,182 @@
|
||||
import FitTrackeeApi from '../fitTrackeeApi/user'
|
||||
import { history } from '../index'
|
||||
import { generateIds } from '../utils'
|
||||
import { getData } from './index'
|
||||
|
||||
|
||||
const AuthError = message => ({ type: 'AUTH_ERROR', message })
|
||||
|
||||
const AuthErrors = messages => ({ type: 'AUTH_ERRORS', messages })
|
||||
|
||||
const PictureError = message => ({ type: 'PICTURE_ERROR', message })
|
||||
|
||||
const ProfileSuccess = message => ({ type: 'PROFILE_SUCCESS', message })
|
||||
|
||||
const ProfileError = message => ({ type: 'PROFILE_ERROR', message })
|
||||
|
||||
const ProfileUpdateError = message => ({
|
||||
type: 'PROFILE_UPDATE_ERROR', message
|
||||
})
|
||||
|
||||
const initProfileFormData = user => ({ type: 'INIT_PROFILE_FORM', user })
|
||||
|
||||
export const emptyForm = () => ({
|
||||
type: 'EMPTY_USER_FORMDATA'
|
||||
})
|
||||
|
||||
export const handleFormChange = (target, value) => ({
|
||||
type: 'UPDATE_USER_FORMDATA',
|
||||
target,
|
||||
value,
|
||||
})
|
||||
|
||||
export const updateProfileFormData = (target, value) => ({
|
||||
type: 'UPDATE_PROFILE_FORMDATA',
|
||||
target,
|
||||
value,
|
||||
})
|
||||
|
||||
export const getProfile = () => dispatch => FitTrackeeApi
|
||||
.getProfile()
|
||||
.then(ret => {
|
||||
if (ret.status === 'success') {
|
||||
dispatch(getData('sports'))
|
||||
return dispatch(ProfileSuccess(ret))
|
||||
}
|
||||
return dispatch(ProfileError(ret.message))
|
||||
})
|
||||
.catch(error => {
|
||||
throw error
|
||||
})
|
||||
|
||||
|
||||
export const register = formData => dispatch => FitTrackeeApi
|
||||
.register(
|
||||
formData.username,
|
||||
formData.email,
|
||||
formData.password,
|
||||
formData.passwordConf)
|
||||
.then(ret => {
|
||||
if (ret.status === 'success') {
|
||||
window.localStorage.setItem('authToken', ret.auth_token)
|
||||
return dispatch(getProfile())
|
||||
}
|
||||
return dispatch(AuthError(ret.message))
|
||||
})
|
||||
.catch(error => {
|
||||
throw error
|
||||
})
|
||||
|
||||
|
||||
export const login = formData => dispatch => FitTrackeeApi
|
||||
.login(formData.email, formData.password)
|
||||
.then(ret => {
|
||||
if (ret.status === 'success') {
|
||||
window.localStorage.setItem('authToken', ret.auth_token)
|
||||
return dispatch(getProfile())
|
||||
}
|
||||
return dispatch(AuthError(ret.message))
|
||||
})
|
||||
.catch(error => {
|
||||
throw error
|
||||
})
|
||||
|
||||
export const loadProfile = () => dispatch => {
|
||||
if (window.localStorage.getItem('authToken')) {
|
||||
return dispatch(getProfile())
|
||||
}
|
||||
return { type: 'LOGOUT' }
|
||||
}
|
||||
|
||||
export const logout = () => ({ type: 'LOGOUT' })
|
||||
|
||||
const RegisterFormControl = formData => {
|
||||
const errMsg = []
|
||||
if (formData.username.length < 3 || formData.username.length > 12) {
|
||||
errMsg.push('Username: 3 to 12 characters required.')
|
||||
}
|
||||
if (formData.password !== formData.passwordConf) {
|
||||
errMsg.push('Password and password confirmation don\'t match.')
|
||||
}
|
||||
if (formData.password.length < 8) {
|
||||
errMsg.push('Password: 8 characters required.')
|
||||
}
|
||||
return errMsg
|
||||
}
|
||||
|
||||
export const handleUserFormSubmit = (event, formType) => (
|
||||
dispatch,
|
||||
getState
|
||||
) => {
|
||||
event.preventDefault()
|
||||
const state = getState()
|
||||
const { formData } = state.formData
|
||||
formData.formData = state.formData.formData
|
||||
if (formType === 'Login') {
|
||||
return dispatch(login(formData.formData))
|
||||
}
|
||||
// formType === 'Register'
|
||||
const ret = RegisterFormControl(formData.formData)
|
||||
if (ret.length === 0) {
|
||||
return dispatch(register(formData.formData))
|
||||
}
|
||||
return dispatch(AuthErrors(generateIds(ret)))
|
||||
}
|
||||
|
||||
export const initProfileForm = () => (dispatch, getState) => {
|
||||
const state = getState()
|
||||
return dispatch(initProfileFormData(state.user))
|
||||
}
|
||||
|
||||
export const handleProfileFormSubmit = event => (dispatch, getState) => {
|
||||
event.preventDefault()
|
||||
const state = getState()
|
||||
if (!state.formProfile.formProfile.password ===
|
||||
state.formProfile.formProfile.passwordConf) {
|
||||
return dispatch(ProfileUpdateError(
|
||||
'Password and password confirmation don\'t match.'
|
||||
))
|
||||
}
|
||||
return FitTrackeeApi
|
||||
.updateProfile(state.formProfile.formProfile)
|
||||
.then(ret => {
|
||||
if (ret.status === 'success') {
|
||||
dispatch(getProfile())
|
||||
return history.push('/profile')
|
||||
}
|
||||
dispatch(ProfileUpdateError(ret.message))
|
||||
})
|
||||
.catch(error => {
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
export const uploadPicture = event => dispatch => {
|
||||
event.preventDefault()
|
||||
const form = new FormData()
|
||||
form.append('file', event.target.picture.files[0])
|
||||
event.target.reset()
|
||||
return FitTrackeeApi
|
||||
.updatePicture(form)
|
||||
.then(ret => {
|
||||
if (ret.status === 'success') {
|
||||
return dispatch(getProfile())
|
||||
}
|
||||
return dispatch(PictureError(ret.message))
|
||||
})
|
||||
.catch(error => {
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
export const deletePicture = () => dispatch => FitTrackeeApi
|
||||
.deletePicture()
|
||||
.then(ret => {
|
||||
if (ret.status === 'success') {
|
||||
return dispatch(getProfile())
|
||||
}
|
||||
dispatch(PictureError(ret.message))
|
||||
})
|
||||
.catch(error => {
|
||||
throw error
|
||||
})
|
26
fittrackee_client/src/components/Activity/ActivityAdd.jsx
Normal file
@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import ActivityAddOrEdit from './ActivityAddOrEdit'
|
||||
|
||||
|
||||
function ActivityAdd (props) {
|
||||
const { message, sports } = props
|
||||
return (
|
||||
<div>
|
||||
<ActivityAddOrEdit
|
||||
activity={null}
|
||||
message={message}
|
||||
sports={sports}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
message: state.message,
|
||||
sports: state.sports.data,
|
||||
user: state.user,
|
||||
}),
|
||||
)(ActivityAdd)
|
106
fittrackee_client/src/components/Activity/ActivityAddOrEdit.jsx
Normal file
@ -0,0 +1,106 @@
|
||||
import React from 'react'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import FormWithGpx from './ActivityForms/FormWithGpx'
|
||||
import FormWithoutGpx from './ActivityForms/FormWithoutGpx'
|
||||
|
||||
class ActivityAddEdit 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 { activity, loading, message, sports } = this.props
|
||||
const { withGpx } = this.state
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>FitTrackee - {activity
|
||||
? 'Edit a workout'
|
||||
: 'Add a workout'}
|
||||
</title>
|
||||
</Helmet>
|
||||
<br /><br />
|
||||
{message && (
|
||||
<code>{message}</code>
|
||||
)}
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-md-2" />
|
||||
<div className="col-md-8">
|
||||
<div className="card add-activity">
|
||||
<h2 className="card-header text-center">
|
||||
{activity ? 'Edit a workout' : 'Add a workout'}
|
||||
</h2>
|
||||
<div className="card-body">
|
||||
{activity ? (
|
||||
activity.with_gpx ? (
|
||||
<FormWithGpx activity={activity} sports={sports} />
|
||||
) : (
|
||||
<FormWithoutGpx activity={activity} sports={sports} />
|
||||
)
|
||||
) : (
|
||||
<div>
|
||||
<form>
|
||||
<div className="form-group row">
|
||||
<div className="col">
|
||||
<label className="radioLabel">
|
||||
<input
|
||||
type="radio"
|
||||
name="withGpx"
|
||||
disabled={loading}
|
||||
checked={withGpx}
|
||||
onChange={event => this.handleRadioChange(event)}
|
||||
/>
|
||||
with gpx file
|
||||
</label>
|
||||
</div>
|
||||
<div className="col">
|
||||
<label className="radioLabel">
|
||||
<input
|
||||
type="radio"
|
||||
name="withoutGpx"
|
||||
disabled={loading}
|
||||
checked={!withGpx}
|
||||
onChange={event => this.handleRadioChange(event)}
|
||||
/>
|
||||
without gpx file
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{withGpx ? (
|
||||
<FormWithGpx sports={sports} />
|
||||
) : (
|
||||
<FormWithoutGpx sports={sports} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
loading: state.loading
|
||||
}),
|
||||
)(ActivityAddEdit)
|
@ -0,0 +1,83 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { formatActivityDate } from '../../../utils'
|
||||
|
||||
|
||||
export default function ActivityCardHeader(props) {
|
||||
const { activity, displayModal, sport, title } = props
|
||||
const activityDate = activity
|
||||
? formatActivityDate(activity.activity_date)
|
||||
: null
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-auto">
|
||||
{activity.next_activity ? (
|
||||
<Link
|
||||
className="unlink"
|
||||
to={`/activities/${activity.next_activity}`}
|
||||
>
|
||||
<i
|
||||
className="fa fa-chevron-left"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<i
|
||||
className="fa fa-chevron-left inactive-link"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-auto col-activity-logo">
|
||||
<img
|
||||
className="sport-img-medium"
|
||||
src={sport.img}
|
||||
alt="sport logo"
|
||||
/>
|
||||
</div>
|
||||
<div className="col">
|
||||
{title}{' '}
|
||||
<Link
|
||||
className="unlink"
|
||||
to={`/activities/${activity.id}/edit`}
|
||||
>
|
||||
<i
|
||||
className="fa fa-edit custom-fa"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
<i
|
||||
className="fa fa-trash custom-fa"
|
||||
aria-hidden="true"
|
||||
onClick={() => displayModal(true)}
|
||||
/><br />
|
||||
{activityDate && (
|
||||
<span className="activity-date">
|
||||
{`${activityDate.activity_date} - ${activityDate.activity_time}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
{activity.previous_activity ? (
|
||||
<Link
|
||||
className="unlink"
|
||||
to={`/activities/${activity.previous_activity}`}
|
||||
>
|
||||
<i
|
||||
className="fa fa-chevron-right"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<i
|
||||
className="fa fa-chevron-right inactive-link"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1,196 @@
|
||||
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 { getActivityChartData } from '../../../actions/activities'
|
||||
|
||||
|
||||
class ActivityCharts extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context)
|
||||
this.state = {
|
||||
displayDistance: true,
|
||||
dataToHide: []
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.loadActivityData(this.props.activity.id)
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.activity.id !==
|
||||
this.props.activity.id) {
|
||||
this.props.loadActivityData(this.props.activity.id)
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.loadActivityData(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 } = 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)}
|
||||
/>
|
||||
distance
|
||||
</label>
|
||||
<label className="radioLabel col-md-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="duration"
|
||||
checked={!displayDistance}
|
||||
onChange={e => this.handleRadioChange(e)}
|
||||
/>
|
||||
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)}
|
||||
/>
|
||||
speed
|
||||
</label>
|
||||
<label className="radioLabel col-md-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="elevation"
|
||||
checked={this.displayData('elevation')}
|
||||
onChange={e => this.handleLegendChange(e)}
|
||||
/>
|
||||
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 }}
|
||||
>
|
||||
<XAxis
|
||||
allowDecimals={false}
|
||||
dataKey={xDataKey}
|
||||
label={{ value: xDataKey, offset: 0, position: 'bottom' }}
|
||||
scale={xScale}
|
||||
interval={xInterval}
|
||||
tickFormatter={value => displayDistance
|
||||
? value
|
||||
: format(value, 'HH:mm:ss')}
|
||||
type="number"
|
||||
/>
|
||||
<YAxis
|
||||
label={{
|
||||
value: 'speed (km/h)', angle: -90, position: 'left'
|
||||
}}
|
||||
yAxisId="left"
|
||||
/>
|
||||
<YAxis
|
||||
label={{
|
||||
value: 'altitude (m)', angle: -90, position: 'right'
|
||||
}}
|
||||
yAxisId="right" orientation="right"
|
||||
/>
|
||||
{this.displayData('elevation') && (
|
||||
<Area
|
||||
yAxisId="right"
|
||||
type="linear"
|
||||
dataKey="elevation"
|
||||
fill="#e5e5e5"
|
||||
stroke="#cccccc"
|
||||
dot={false}
|
||||
/>
|
||||
)}
|
||||
{this.displayData('speed') && (
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="linear"
|
||||
dataKey="speed"
|
||||
stroke="#8884d8"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
)}
|
||||
<Tooltip
|
||||
labelFormatter={value => displayDistance
|
||||
? `distance: ${value} km`
|
||||
: `duration: ${format(value, 'HH:mm:ss')}`}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="chart-info">
|
||||
data from gpx, without any cleaning
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
'No data to display'
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
chartData: state.chartData
|
||||
}),
|
||||
dispatch => ({
|
||||
loadActivityData: activityId => {
|
||||
dispatch(getActivityChartData(activityId))
|
||||
},
|
||||
})
|
||||
)(ActivityCharts)
|
@ -0,0 +1,93 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function ActivityDetails(props) {
|
||||
const { activity } = props
|
||||
const withPauses = activity.pauses !== '0:00:00' && activity.pauses !== null
|
||||
const recordLDexists = activity.records.find(r => r.record_type === 'LD')
|
||||
return (
|
||||
<div>
|
||||
<p>
|
||||
<i
|
||||
className="fa fa-clock-o custom-fa"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Duration: {activity.duration}
|
||||
{withPauses && (
|
||||
<span>
|
||||
{' '}
|
||||
(pauses: {activity.pauses})
|
||||
<br />
|
||||
Moving duration: {activity.moving}
|
||||
</span>
|
||||
)}
|
||||
{recordLDexists && (
|
||||
<sup>
|
||||
<i
|
||||
className="fa fa-trophy custom-fa"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</sup>
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
<i
|
||||
className="fa fa-road custom-fa"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Distance: {activity.distance} km
|
||||
{activity.records.find(r => r.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"
|
||||
/>
|
||||
Average speed: {activity.ave_speed} km/h
|
||||
{activity.records.find(r => r.record_type === 'AS'
|
||||
) && (
|
||||
<sup>
|
||||
<i
|
||||
className="fa fa-trophy custom-fa"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</sup>
|
||||
)}
|
||||
<br />
|
||||
Max speed : {activity.max_speed} km/h
|
||||
{activity.records.find(r => r.record_type === 'MS'
|
||||
) && (
|
||||
<sup>
|
||||
<i
|
||||
className="fa fa-trophy custom-fa"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</sup>
|
||||
)}
|
||||
</p>
|
||||
{activity.min_alt && activity.max_alt && (
|
||||
<p>
|
||||
<i className="fi-mountains custom-fa" />
|
||||
Min altitude: {activity.min_alt}m
|
||||
<br />
|
||||
Max altitude: {activity.max_alt}m
|
||||
</p>
|
||||
)}
|
||||
{activity.ascent && activity.descent && (
|
||||
<p>
|
||||
<i className="fa fa-location-arrow custom-fa" />
|
||||
Ascent: {activity.ascent}m
|
||||
<br />
|
||||
Descent: {activity.descent}m
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1,77 @@
|
||||
import hash from 'object-hash'
|
||||
import React from 'react'
|
||||
import { GeoJSON, Map, TileLayer } from 'react-leaflet'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import { getActivityGpx } from '../../../actions/activities'
|
||||
import { getGeoJson, thunderforestApiKey } from '../../../utils'
|
||||
|
||||
class ActivityMap extends React.Component {
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context)
|
||||
this.state = {
|
||||
zoom: 13,
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.loadActivityGpx(this.props.activity.id)
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.activity.id !==
|
||||
this.props.activity.id) {
|
||||
this.props.loadActivityGpx(this.props.activity.id)
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.loadActivityGpx(null)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { activity, gpxContent } = this.props
|
||||
const { jsonData } = getGeoJson(gpxContent)
|
||||
const bounds = [
|
||||
[activity.bounds[0], activity.bounds[1]],
|
||||
[activity.bounds[2], activity.bounds[3]]
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
{jsonData && (
|
||||
<Map
|
||||
zoom={this.state.zoom}
|
||||
bounds={bounds}
|
||||
boundsOptions={{ padding: [10, 10] }}
|
||||
>
|
||||
<TileLayer
|
||||
// eslint-disable-next-line max-len
|
||||
attribution='© <a href="http://www.thunderforest.com/">Thunderforest</a>, © <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
// eslint-disable-next-line max-len
|
||||
url={`https://{s}.tile.thunderforest.com/outdoors/{z}/{x}/{y}.png?apikey=${thunderforestApiKey}`}
|
||||
/>
|
||||
<GeoJSON
|
||||
// hash as a key to force re-rendering
|
||||
key={hash(jsonData)}
|
||||
data={jsonData}
|
||||
/>
|
||||
</Map>
|
||||
)}
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
gpxContent: state.gpx
|
||||
}),
|
||||
dispatch => ({
|
||||
loadActivityGpx: activityId => {
|
||||
dispatch(getActivityGpx(activityId))
|
||||
},
|
||||
})
|
||||
)(ActivityMap)
|
@ -0,0 +1,132 @@
|
||||
import React from 'react'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import ActivityCardHeader from './ActivityCardHeader'
|
||||
import ActivityCharts from './ActivityCharts'
|
||||
import ActivityDetails from './ActivityDetails'
|
||||
import ActivityMap from './ActivityMap'
|
||||
import CustomModal from './../../Others/CustomModal'
|
||||
import { getData } from '../../../actions'
|
||||
import { deleteActivity } from '../../../actions/activities'
|
||||
|
||||
class ActivityDisplay extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context)
|
||||
this.state = {
|
||||
displayModal: false,
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.loadActivity(this.props.match.params.activityId)
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.match.params.activityId !==
|
||||
this.props.match.params.activityId) {
|
||||
this.props.loadActivity(this.props.match.params.activityId)
|
||||
}
|
||||
}
|
||||
|
||||
displayModal(value) {
|
||||
this.setState({ displayModal: value })
|
||||
}
|
||||
|
||||
render() {
|
||||
const { activities, message, onDeleteActivity, sports } = this.props
|
||||
const { displayModal } = this.state
|
||||
const [activity] = activities
|
||||
const title = activity ? activity.title : 'Activity'
|
||||
const [sport] = activity
|
||||
? sports.filter(s => s.id === activity.sport_id)
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="activity-page">
|
||||
<Helmet>
|
||||
<title>FitTrackee - {title}</title>
|
||||
</Helmet>
|
||||
{message ? (
|
||||
<code>{message}</code>
|
||||
) : (
|
||||
<div className="container">
|
||||
{displayModal &&
|
||||
<CustomModal
|
||||
title="Confirmation"
|
||||
text="Are you sure you want to delete this activity?"
|
||||
confirm={() => {
|
||||
onDeleteActivity(activity.id)
|
||||
this.displayModal(false)
|
||||
}}
|
||||
close={() => this.displayModal(false)}
|
||||
/>}
|
||||
{activity && sport && activities.length === 1 && (
|
||||
<div>
|
||||
<div className="row">
|
||||
<div className="col">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<ActivityCardHeader
|
||||
activity={activity}
|
||||
sport={sport}
|
||||
title={title}
|
||||
displayModal={() => this.displayModal(true)}
|
||||
/>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="row">
|
||||
{activity.with_gpx && (
|
||||
<div className="col-8">
|
||||
<ActivityMap activity={activity} />
|
||||
</div>
|
||||
)}
|
||||
<div className="col">
|
||||
<ActivityDetails activity={activity} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{activity.with_gpx && (
|
||||
<div className="row">
|
||||
<div className="col">
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<div className="row">
|
||||
<div className="col">
|
||||
<div className="chart-title">Chart</div>
|
||||
<ActivityCharts activity={activity} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
activities: state.activities.data,
|
||||
message: state.message,
|
||||
sports: state.sports.data,
|
||||
user: state.user,
|
||||
}),
|
||||
dispatch => ({
|
||||
loadActivity: activityId => {
|
||||
dispatch(getData('activities', { id: activityId }))
|
||||
},
|
||||
onDeleteActivity: activityId => {
|
||||
dispatch(deleteActivity(activityId))
|
||||
},
|
||||
})
|
||||
)(ActivityDisplay)
|
44
fittrackee_client/src/components/Activity/ActivityEdit.jsx
Normal file
@ -0,0 +1,44 @@
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import ActivityAddOrEdit from './ActivityAddOrEdit'
|
||||
import { getData } from '../../actions'
|
||||
|
||||
|
||||
class ActivityEdit extends React.Component {
|
||||
componentDidMount() {
|
||||
this.props.loadActivity(
|
||||
this.props.match.params.activityId
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { activities, message, sports } = this.props
|
||||
const [activity] = activities
|
||||
return (
|
||||
<div>
|
||||
{sports.length > 0 && (
|
||||
<ActivityAddOrEdit
|
||||
activity={activity}
|
||||
message={message}
|
||||
sports={sports}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
activities: state.activities.data,
|
||||
message: state.message,
|
||||
sports: state.sports.data,
|
||||
user: state.user,
|
||||
}),
|
||||
dispatch => ({
|
||||
loadActivity: activityId => {
|
||||
dispatch(getData('activities', { id: activityId }))
|
||||
},
|
||||
})
|
||||
)(ActivityEdit)
|
@ -0,0 +1,116 @@
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import { setLoading } from '../../../actions/index'
|
||||
import { addActivity, editActivity } from '../../../actions/activities'
|
||||
import { history } from '../../../index'
|
||||
|
||||
|
||||
function FormWithGpx (props) {
|
||||
const {
|
||||
activity, loading, onAddActivity, onEditActivity, sports
|
||||
} = props
|
||||
const sportId = activity ? activity.sport_id : ''
|
||||
return (
|
||||
<form
|
||||
encType="multipart/form-data"
|
||||
method="post"
|
||||
onSubmit={event => event.preventDefault()}
|
||||
>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Sport:
|
||||
<select
|
||||
className="form-control input-lg"
|
||||
defaultValue={sportId}
|
||||
disabled={loading}
|
||||
name="sport"
|
||||
required
|
||||
>
|
||||
<option value="" />
|
||||
{sports.map(sport => (
|
||||
<option key={sport.id} value={sport.id}>
|
||||
{sport.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{activity ? (
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Title:
|
||||
<input
|
||||
name="title"
|
||||
defaultValue={activity ? activity.title : ''}
|
||||
disabled={loading}
|
||||
className="form-control input-lg"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<div className="form-group">
|
||||
<label>
|
||||
<strong>gpx</strong> file or <strong>zip</strong>{' '}
|
||||
file containing <strong>gpx</strong> (no folder inside):
|
||||
<input
|
||||
accept=".gpx, .zip"
|
||||
className="form-control input-lg"
|
||||
disabled={loading}
|
||||
name="gpxFile"
|
||||
required
|
||||
type="file"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{loading ? (
|
||||
<div className="loader" />
|
||||
) : (
|
||||
<div>
|
||||
<input
|
||||
type="submit"
|
||||
className="btn btn-primary btn-lg btn-block"
|
||||
onClick={
|
||||
event => activity
|
||||
? onEditActivity(event, activity)
|
||||
: onAddActivity(event)
|
||||
}
|
||||
value="Submit"
|
||||
/>
|
||||
<input
|
||||
type="submit"
|
||||
className="btn btn-secondary btn-lg btn-block"
|
||||
onClick={() => history.go(-1)}
|
||||
value="Cancel"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
loading: state.loading
|
||||
}),
|
||||
dispatch => ({
|
||||
onAddActivity: e => {
|
||||
dispatch(setLoading())
|
||||
const form = new FormData()
|
||||
form.append('file', e.target.form.gpxFile.files[0])
|
||||
form.append(
|
||||
'data', `{"sport_id": ${e.target.form.sport.value}}`
|
||||
)
|
||||
dispatch(addActivity(form))
|
||||
},
|
||||
onEditActivity: (e, activity) => {
|
||||
dispatch(setLoading())
|
||||
dispatch(editActivity({
|
||||
id: activity.id,
|
||||
sport_id: +e.target.form.sport.value,
|
||||
title: e.target.form.title.value,
|
||||
}))
|
||||
},
|
||||
})
|
||||
)(FormWithGpx)
|
@ -0,0 +1,144 @@
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import {
|
||||
addActivityWithoutGpx, editActivity
|
||||
} from '../../../actions/activities'
|
||||
import { history } from '../../../index'
|
||||
import { formatActivityDate } from '../../../utils'
|
||||
|
||||
function FormWithoutGpx (props) {
|
||||
const { activity, onAddOrEdit, sports } = props
|
||||
let activityDate, activityTime, sportId = ''
|
||||
if (activity) {
|
||||
const activityDateTime = formatActivityDate(activity.activity_date)
|
||||
activityDate = activityDateTime.activity_date
|
||||
activityTime = activityDateTime.activity_time
|
||||
sportId = activity.sport_id
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={event => event.preventDefault()}
|
||||
>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Title:
|
||||
<input
|
||||
name="title"
|
||||
defaultValue={activity ? activity.title : ''}
|
||||
className="form-control input-lg"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Sport:
|
||||
<select
|
||||
className="form-control input-lg"
|
||||
defaultValue={sportId}
|
||||
name="sport_id"
|
||||
required
|
||||
>
|
||||
<option value="" />
|
||||
{sports.map(sport => (
|
||||
<option key={sport.id} value={sport.id}>
|
||||
{sport.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Activity Date:
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<input
|
||||
name="activity_date"
|
||||
defaultValue={activityDate}
|
||||
className="form-control col-md"
|
||||
required
|
||||
type="date"
|
||||
/>
|
||||
<input
|
||||
name="activity_time"
|
||||
defaultValue={activityTime}
|
||||
className="form-control col-md"
|
||||
required
|
||||
type="time"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Duration:
|
||||
<input
|
||||
name="duration"
|
||||
defaultValue={activity ? activity.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>
|
||||
Distance (km):
|
||||
<input
|
||||
name="distance"
|
||||
defaultValue={activity ? activity.distance : ''}
|
||||
className="form-control input-lg"
|
||||
min={0}
|
||||
required
|
||||
step="0.001"
|
||||
type="number"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
type="submit"
|
||||
className="btn btn-primary btn-lg btn-block"
|
||||
onClick={event => onAddOrEdit(event, activity)}
|
||||
value="Submit"
|
||||
/>
|
||||
<input
|
||||
type="submit"
|
||||
className="btn btn-secondary btn-lg btn-block"
|
||||
onClick={() => history.go(-1)}
|
||||
value="Cancel"
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default connect(
|
||||
() => ({ }),
|
||||
dispatch => ({
|
||||
onAddOrEdit: (e, activity) => {
|
||||
const d = e.target.form.duration.value.split(':')
|
||||
const duration = +d[0] * 60 * 60 + +d[1] * 60 + +d[2]
|
||||
|
||||
const activityDate = `${e.target.form.activity_date.value
|
||||
} ${ e.target.form.activity_time.value}`
|
||||
|
||||
const data = {
|
||||
activity_date: activityDate,
|
||||
distance: +e.target.form.distance.value,
|
||||
duration,
|
||||
sport_id: +e.target.form.sport_id.value,
|
||||
title: e.target.form.title.value,
|
||||
}
|
||||
if (activity) {
|
||||
data.id = activity.id
|
||||
dispatch(editActivity(data))
|
||||
} else {
|
||||
dispatch(addActivityWithoutGpx(data))
|
||||
}
|
||||
},
|
||||
})
|
||||
)(FormWithoutGpx)
|
40
fittrackee_client/src/components/Activity/index.jsx
Normal file
@ -0,0 +1,40 @@
|
||||
import React from 'react'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import { connect } from 'react-redux'
|
||||
import { Redirect, Route, Switch } from 'react-router-dom'
|
||||
|
||||
import ActivityAdd from './ActivityAdd'
|
||||
import ActivityDisplay from './ActivityDisplay'
|
||||
import ActivityEdit from './ActivityEdit'
|
||||
import NotFound from './../Others/NotFound'
|
||||
import { isLoggedIn } from '../../utils'
|
||||
|
||||
function Activity () {
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>FitTrackee - Admin</title>
|
||||
</Helmet>
|
||||
{isLoggedIn() ? (
|
||||
<Switch>
|
||||
<Route exact path="/activities/add" component={ActivityAdd} />
|
||||
<Route
|
||||
exact path="/activities/:activityId"
|
||||
component={ActivityDisplay}
|
||||
/>
|
||||
<Route
|
||||
exact path="/activities/:activityId/edit"
|
||||
component={ActivityEdit}
|
||||
/>
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
) : (<Redirect to="/login" />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
user: state.user,
|
||||
})
|
||||
)(Activity)
|
35
fittrackee_client/src/components/Admin/Sports/AdminMenu.jsx
Normal file
@ -0,0 +1,35 @@
|
||||
import React from 'react'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function AdminMenu () {
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>FitTrackee - Admin - Sports</title>
|
||||
</Helmet>
|
||||
<h1 className="page-title">Administration</h1>
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-md-2" />
|
||||
<div className="col-md-8 card">
|
||||
<div className="card-body">
|
||||
<ul className="admin-items">
|
||||
<li>
|
||||
<Link
|
||||
to={{
|
||||
pathname: '/admin/sports',
|
||||
}}
|
||||
>
|
||||
Sports
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
39
fittrackee_client/src/components/Admin/Sports/AdminSport.jsx
Normal file
@ -0,0 +1,39 @@
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import { getData } from '../../../actions'
|
||||
import AdminDetail from '../generic/AdminDetail'
|
||||
|
||||
class AdminSports extends React.Component {
|
||||
componentDidMount() {
|
||||
this.props.loadSport(this.props.match.params.sportId)
|
||||
}
|
||||
componentWillUnmount() {
|
||||
// reload all Sports
|
||||
this.props.loadSport(null)
|
||||
}
|
||||
render() {
|
||||
const { sports } = this.props
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminDetail
|
||||
results={sports}
|
||||
target="sports"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
sports: state.sports.data,
|
||||
user: state.user,
|
||||
}),
|
||||
dispatch => ({
|
||||
loadSport: sportId => {
|
||||
dispatch(getData('sports', { id: sportId }))
|
||||
},
|
||||
})
|
||||
)(AdminSports)
|
@ -0,0 +1,35 @@
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import { getData } from '../../../actions'
|
||||
import AdminPage from '../generic/AdminPage'
|
||||
|
||||
class AdminSports extends React.Component {
|
||||
componentDidMount() {
|
||||
this.props.loadSports()
|
||||
}
|
||||
render() {
|
||||
const { sports } = this.props
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminPage
|
||||
data={sports}
|
||||
target="sports"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
sports: state.sports,
|
||||
user: state.user,
|
||||
}),
|
||||
dispatch => ({
|
||||
loadSports: () => {
|
||||
dispatch(getData('sports'))
|
||||
},
|
||||
})
|
||||
)(AdminSports)
|
@ -0,0 +1,83 @@
|
||||
import React from 'react'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import { addData } from '../../../actions/index'
|
||||
import { history } from '../../../index'
|
||||
|
||||
class AdminSportsAdd extends React.Component {
|
||||
componentDidMount() { }
|
||||
|
||||
render() {
|
||||
const { message, onAddSport } = this.props
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>FitTrackee - Admin - Add Sport</title>
|
||||
</Helmet>
|
||||
<h1 className="page-title">
|
||||
Administration - Sport
|
||||
</h1>
|
||||
{message && (
|
||||
<code>{message}</code>
|
||||
)}
|
||||
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-md-2" />
|
||||
<div className="col-md-8">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
Add a sport
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<form onSubmit={event =>
|
||||
event.preventDefault()}
|
||||
>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Label:
|
||||
<input
|
||||
name="label"
|
||||
className="form-control input-lg"
|
||||
type="text"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
type="submit"
|
||||
className="btn btn-primary btn-lg btn-block"
|
||||
onClick={event => onAddSport(event)}
|
||||
value="Submit"
|
||||
/>
|
||||
<input
|
||||
type="submit"
|
||||
className="btn btn-secondary btn-lg btn-block"
|
||||
onClick={() => history.push('/admin/sports')}
|
||||
value="Cancel"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
message: state.message,
|
||||
user: state.user,
|
||||
}),
|
||||
dispatch => ({
|
||||
onAddSport: e => {
|
||||
const data = { label: e.target.form.label.value }
|
||||
dispatch(addData('sports', data))
|
||||
},
|
||||
})
|
||||
)(AdminSportsAdd)
|
155
fittrackee_client/src/components/Admin/generic/AdminDetail.jsx
Normal file
@ -0,0 +1,155 @@
|
||||
import React from 'react'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import { deleteData, updateData } from '../../../actions/index'
|
||||
import { history } from '../../../index'
|
||||
|
||||
class AdminDetail extends React.Component {
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context)
|
||||
this.state = {
|
||||
isInEdition: false,
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
message,
|
||||
onDataUpdate,
|
||||
onDataDelete,
|
||||
results,
|
||||
target,
|
||||
} = this.props
|
||||
const { isInEdition } = this.state
|
||||
const title = target.charAt(0).toUpperCase() + target.slice(1)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>FitTrackee - Admin</title>
|
||||
</Helmet>
|
||||
<h1 className="page-title">
|
||||
Administration - {title}
|
||||
</h1>
|
||||
{message ? (
|
||||
<code>{message}</code>
|
||||
) : (
|
||||
results.length === 1 && (
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-md-2" />
|
||||
<div className="col-md-8 card">
|
||||
<div className="card-body">
|
||||
<form onSubmit={event =>
|
||||
event.preventDefault()}
|
||||
>
|
||||
{ Object.keys(results[0])
|
||||
.filter(key => key.charAt(0) !== '_')
|
||||
.map(key => (
|
||||
<div className="form-group" key={key}>
|
||||
<label>
|
||||
{key}:
|
||||
{key === 'img' ? (
|
||||
<img
|
||||
src={results[0][key]
|
||||
? results[0][key]
|
||||
: '/img/photo.png'}
|
||||
alt="property"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className="form-control input-lg"
|
||||
name={key}
|
||||
readOnly={key === 'id' || !isInEdition}
|
||||
defaultValue={results[0][key]}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
{isInEdition ? (
|
||||
<div>
|
||||
<input
|
||||
type="submit"
|
||||
className="btn btn-primary btn-lg btn-block"
|
||||
onClick={event => {
|
||||
onDataUpdate(event, target)
|
||||
this.setState({ isInEdition: false })
|
||||
}
|
||||
}
|
||||
value="Submit"
|
||||
/>
|
||||
<input
|
||||
type="submit"
|
||||
className="btn btn-secondary btn-lg btn-block"
|
||||
onClick={event => {
|
||||
event.target.form.reset()
|
||||
this.setState({ isInEdition: false })
|
||||
}}
|
||||
value="Cancel"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<input
|
||||
type="submit"
|
||||
className="btn btn-primary btn-lg btn-block"
|
||||
onClick={() => this.setState({ isInEdition: true })}
|
||||
value="Edit"
|
||||
/>
|
||||
<input
|
||||
type="submit"
|
||||
className="btn btn-danger btn-lg btn-block"
|
||||
disabled={!results[0]._can_be_deleted}
|
||||
onClick={event => onDataDelete(event, target)}
|
||||
title={results[0]._can_be_deleted
|
||||
? ''
|
||||
: 'Can\'t be deleted, associated data exist'}
|
||||
value="Delete"
|
||||
/>
|
||||
<input
|
||||
type="submit"
|
||||
className="btn btn-secondary btn-lg btn-block"
|
||||
onClick={() => history.push(`/admin/${target}`)}
|
||||
value="Back to the list"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-2" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
message: state.message,
|
||||
}),
|
||||
dispatch => ({
|
||||
onDataDelete: (e, target) => {
|
||||
const id = e.target.form.id.value
|
||||
dispatch(deleteData(target, id))
|
||||
},
|
||||
onDataUpdate: (e, target) => {
|
||||
const data = [].slice
|
||||
.call(e.target.form.elements)
|
||||
.reduce(function(map, obj) {
|
||||
if (obj.name) {
|
||||
map[obj.name] = obj.value
|
||||
}
|
||||
return map
|
||||
}, {})
|
||||
dispatch(updateData(target, data))
|
||||
},
|
||||
})
|
||||
)(AdminDetail)
|
97
fittrackee_client/src/components/Admin/generic/AdminPage.jsx
Normal file
@ -0,0 +1,97 @@
|
||||
import React from 'react'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { history } from '../../../index'
|
||||
|
||||
export default function AdminPage(props) {
|
||||
|
||||
const { data, target } = props
|
||||
const { error } = data
|
||||
const results = data.data
|
||||
const tbKeys = []
|
||||
if (results.length > 0) {
|
||||
Object.keys(results[0])
|
||||
.filter(key => key.charAt(0) !== '_')
|
||||
.map(key => tbKeys.push(key))
|
||||
}
|
||||
const title = target.charAt(0).toUpperCase() + target.slice(1)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>FitTrackee - Admin</title>
|
||||
</Helmet>
|
||||
<h1 className="page-title">
|
||||
Administration - {title}
|
||||
</h1>
|
||||
{error ? (
|
||||
<code>{error}</code>
|
||||
) : (
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-md-2" />
|
||||
<div className="col-md-8 card">
|
||||
<div className="card-body">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
{tbKeys.map(
|
||||
tbKey => <th key={tbKey} scope="col">{tbKey}</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ results.map((result, idx) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<tr key={idx}>
|
||||
{ Object.keys(result)
|
||||
.filter(key => key.charAt(0) !== '_')
|
||||
.map(key => {
|
||||
if (key === 'id') {
|
||||
return (
|
||||
<th key={key} scope="row">
|
||||
<Link to={`/admin/${target}/${result[key]}`}>
|
||||
{result[key]}
|
||||
</Link>
|
||||
</th>
|
||||
)
|
||||
} else if (key === 'img') {
|
||||
return (<td key={key}>
|
||||
<img
|
||||
className="admin-img"
|
||||
src={result[key]
|
||||
? result[key]
|
||||
: '/img/photo.png'}
|
||||
alt="logo"
|
||||
/>
|
||||
</td>)
|
||||
}
|
||||
return <td key={key}>{result[key]}</td>
|
||||
})
|
||||
}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<input
|
||||
type="submit"
|
||||
className="btn btn-primary btn-lg btn-block"
|
||||
onClick={() => history.push(`/admin/${target}/add`)}
|
||||
value="Add a new item"
|
||||
/>
|
||||
<input
|
||||
type="submit"
|
||||
className="btn btn-secondary btn-lg btn-block"
|
||||
onClick={() => history.push('/admin/')}
|
||||
value="Back"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-2" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
48
fittrackee_client/src/components/Admin/index.jsx
Normal file
@ -0,0 +1,48 @@
|
||||
import React from 'react'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import { connect } from 'react-redux'
|
||||
import { Redirect, Route, Switch } from 'react-router-dom'
|
||||
|
||||
import AdminMenu from './Sports/AdminMenu'
|
||||
import AdminSport from './Sports/AdminSport'
|
||||
import AdminSports from './Sports/AdminSports'
|
||||
import AdminSportsAdd from './Sports/AdminSportsAdd'
|
||||
import AccessDenied from './../Others/AccessDenied'
|
||||
import NotFound from './../Others/NotFound'
|
||||
import { isLoggedIn } from '../../utils'
|
||||
|
||||
function Admin (props) {
|
||||
const { user } = props
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>FitTrackee - Admin</title>
|
||||
</Helmet>
|
||||
{isLoggedIn() ? (
|
||||
user.isAdmin ? (
|
||||
<Switch>
|
||||
<Route exact path="/admin" component={AdminMenu} />
|
||||
<Route exact path="/admin/sports" component={AdminSports} />
|
||||
<Route
|
||||
exact path="/admin/sports/add"
|
||||
component={AdminSportsAdd}
|
||||
/>
|
||||
<Route
|
||||
exact path="/admin/sports/:sportId"
|
||||
component={AdminSport}
|
||||
/>
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
) : (
|
||||
<AccessDenied />
|
||||
)
|
||||
) : (<Redirect to="/login" />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
user: state.user,
|
||||
})
|
||||
)(Admin)
|
341
fittrackee_client/src/components/App.css
Normal file
@ -0,0 +1,341 @@
|
||||
.App {
|
||||
background-color: #eaeaea;
|
||||
min-height: 100vh;
|
||||
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: 35px;
|
||||
max-height: 35px;
|
||||
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;
|
||||
}
|
||||
|
||||
.card {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
label {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 2em;
|
||||
margin: 1em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.activity-card {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.activity-date {
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
.activity-page {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.activity-sport {
|
||||
margin-right: 1px;
|
||||
max-width: 20px;
|
||||
max-height: 20px;
|
||||
}
|
||||
|
||||
.add-activity {
|
||||
margin-top: 50px;
|
||||
}
|
||||
|
||||
.admin-img {
|
||||
max-width: 35px;
|
||||
max-height: 35px;
|
||||
}
|
||||
|
||||
.col-activity-logo{
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.fa-trophy {
|
||||
color: goldenrod;
|
||||
}
|
||||
|
||||
.fa-color {
|
||||
color: #405976;
|
||||
}
|
||||
|
||||
.leaflet-container {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.radioLabel {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chart {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.chart-info {
|
||||
font-size: 0.8em;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.chart-month {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.chart-radio {
|
||||
display: flex;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.chart-radio label {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.chart-radio input {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 1.1em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.sport-img {
|
||||
max-width: 35px;
|
||||
max-height: 35px;
|
||||
}
|
||||
|
||||
.huge {
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
.inactive-link {
|
||||
color: lightgrey;
|
||||
}
|
||||
|
||||
.img-disabled {
|
||||
opacity: .4;
|
||||
}
|
||||
|
||||
.record-logo {
|
||||
margin-right: 5px;
|
||||
max-width: 25px;
|
||||
max-height: 25px;
|
||||
}
|
||||
|
||||
.record-table table, .record-table th, .record-table td{
|
||||
font-size: 0.9em;
|
||||
padding: 0.1em;
|
||||
}
|
||||
|
||||
.sport-img-medium {
|
||||
max-width: 45px;
|
||||
max-height: 45px;
|
||||
}
|
||||
.unlink {
|
||||
color: black;
|
||||
}
|
||||
|
||||
.loader {
|
||||
animation: spin 2s linear infinite;
|
||||
border: 16px solid #f3f3f3;
|
||||
border-top: 16px solid #3498db;
|
||||
border-radius: 50%;
|
||||
height: 120px;
|
||||
margin-left: 41%;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
|
||||
/* 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);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
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);
|
||||
}
|
94
fittrackee_client/src/components/App.jsx
Normal file
@ -0,0 +1,94 @@
|
||||
import React from 'react'
|
||||
import { Redirect, Route, Switch } from 'react-router-dom'
|
||||
|
||||
import './App.css'
|
||||
import Admin from './Admin'
|
||||
import Activity from './Activity/index'
|
||||
import Dashboard from './Dashboard'
|
||||
import Logout from './User/Logout'
|
||||
import NavBar from './NavBar'
|
||||
import NotFound from './Others/NotFound'
|
||||
import Profile from './User/Profile'
|
||||
import ProfileEdit from './User/ProfileEdit'
|
||||
import UserForm from './User/UserForm'
|
||||
import { isLoggedIn } from '../utils'
|
||||
|
||||
export default class App extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.props = props
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="App">
|
||||
<NavBar />
|
||||
<Switch>
|
||||
<Route
|
||||
exact path="/"
|
||||
render={() => (
|
||||
isLoggedIn() ? (
|
||||
<Dashboard />
|
||||
) : (
|
||||
<Redirect to="/login" />
|
||||
)
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact path="/register"
|
||||
render={() => (
|
||||
isLoggedIn() ? (
|
||||
<Redirect to="/" />
|
||||
) : (
|
||||
<UserForm
|
||||
formType={'Register'}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact path="/login"
|
||||
render={() => (
|
||||
isLoggedIn() ? (
|
||||
<Redirect to="/" />
|
||||
) : (
|
||||
<UserForm
|
||||
formType={'Login'}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
/>
|
||||
<Route exact path="/logout" component={Logout} />
|
||||
<Route
|
||||
exact path="/profile/edit"
|
||||
render={() => (
|
||||
isLoggedIn() ? (
|
||||
<ProfileEdit />
|
||||
) : (
|
||||
<UserForm
|
||||
formType={'Login'}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
exact path="/profile"
|
||||
render={() => (
|
||||
isLoggedIn() ? (
|
||||
<Profile />
|
||||
) : (
|
||||
<UserForm
|
||||
formType={'Login'}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
/>
|
||||
<Route path="/activities" component={Activity} />
|
||||
<Route path="/admin" component={Admin} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
9
fittrackee_client/src/components/App.test.jsx
Normal file
@ -0,0 +1,9 @@
|
||||
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)
|
||||
})
|
48
fittrackee_client/src/components/Dashboard/ActivityCard.jsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { format } from 'date-fns'
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { apiUrl } from '../../utils'
|
||||
|
||||
export default function ActivityCard (props) {
|
||||
const { activity, sports } = props
|
||||
|
||||
return (
|
||||
<div className="card activity-card text-center">
|
||||
<div className="card-header">
|
||||
<Link to={`/activities/${activity.id}`}>
|
||||
{sports.filter(sport => sport.id === activity.sport_id)
|
||||
.map(sport => sport.label)} -{' '}
|
||||
{format(activity.activity_date, 'DD/MM/YYYY HH:mm')}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="row">
|
||||
{activity.map && (
|
||||
<div className="col">
|
||||
<img
|
||||
alt="Map"
|
||||
src={`${apiUrl}activities/map/${activity.map}` +
|
||||
`?${Date.now()}`}
|
||||
className="img-fluid"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="col">
|
||||
<p>
|
||||
<i className="fa fa-clock-o" aria-hidden="true" />{' '}
|
||||
Duration: {activity.duration}
|
||||
{activity.map ? (
|
||||
<span><br /><br /></span>
|
||||
) : (
|
||||
' - '
|
||||
)}
|
||||
<i className="fa fa-road" aria-hidden="true" />{' '}
|
||||
Distance: {activity.distance} km
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
69
fittrackee_client/src/components/Dashboard/Records.jsx
Normal file
@ -0,0 +1,69 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { formatRecord } from '../../utils'
|
||||
|
||||
export default function RecordsCard (props) {
|
||||
const { records, sports } = props
|
||||
const recordsBySport = records.reduce((sportList, record) => {
|
||||
const sport = sports.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))
|
||||
return sportList
|
||||
}, {})
|
||||
|
||||
return (
|
||||
<div className="card activity-card">
|
||||
<div className="card-header">
|
||||
Personal records
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{Object.keys(recordsBySport).length === 0
|
||||
? 'No records'
|
||||
: (Object.keys(recordsBySport).map(sportLabel => (
|
||||
<table
|
||||
className="table table-borderless record-table"
|
||||
key={sportLabel}
|
||||
>
|
||||
<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 key={rec.id}>
|
||||
<td>
|
||||
{rec.record_type}
|
||||
</td>
|
||||
<td>
|
||||
{rec.value}
|
||||
</td>
|
||||
<td>
|
||||
<Link to={`/activities/${rec.activity_id}`}>
|
||||
{rec.activity_date}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>))
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
138
fittrackee_client/src/components/Dashboard/Statistics.jsx
Normal file
@ -0,0 +1,138 @@
|
||||
import { endOfMonth, format, startOfMonth } from 'date-fns'
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import {
|
||||
Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis
|
||||
} from 'recharts'
|
||||
|
||||
import { getStats } from '../../actions/stats'
|
||||
import { activityColors, formatStats } from '../../utils'
|
||||
|
||||
|
||||
class Statistics extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context)
|
||||
const date = new Date()
|
||||
this.state = {
|
||||
start: startOfMonth(date),
|
||||
end: endOfMonth(date),
|
||||
displayedData: 'distance'
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.loadMonthActivities(
|
||||
this.props.user.id,
|
||||
this.state.start,
|
||||
this.state.end,
|
||||
)
|
||||
}
|
||||
|
||||
handleRadioChange (changeEvent) {
|
||||
this.setState({
|
||||
displayedData: changeEvent.target.name
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
const { sports, statistics } = this.props
|
||||
const { displayedData, end, start } = this.state
|
||||
const stats = formatStats(statistics, sports, start, end)
|
||||
return (
|
||||
<div className="card activity-card">
|
||||
<div className="card-header">
|
||||
This month
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{Object.keys(statistics).length === 0 ? (
|
||||
'No workouts'
|
||||
) : (
|
||||
<div className="chart-month">
|
||||
<div className="row chart-radio">
|
||||
<label className="radioLabel col">
|
||||
<input
|
||||
type="radio"
|
||||
name="distance"
|
||||
checked={displayedData === 'distance'}
|
||||
onChange={e => this.handleRadioChange(e)}
|
||||
/>
|
||||
distance
|
||||
</label>
|
||||
<label className="radioLabel col">
|
||||
<input
|
||||
type="radio"
|
||||
name="duration"
|
||||
checked={displayedData === 'duration'}
|
||||
onChange={e => this.handleRadioChange(e)}
|
||||
/>
|
||||
duration
|
||||
</label>
|
||||
<label className="radioLabel col">
|
||||
<input
|
||||
type="radio"
|
||||
name="activities"
|
||||
checked={displayedData === 'activities'}
|
||||
onChange={e => this.handleRadioChange(e)}
|
||||
/>
|
||||
activities
|
||||
</label>
|
||||
</div>
|
||||
<ResponsiveContainer height={300}>
|
||||
<BarChart
|
||||
data={stats[displayedData]}
|
||||
margin={{ top: 15, bottom: 15 }}
|
||||
>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
interval={0} // to force to display all ticks
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={value => displayedData === 'distance'
|
||||
? `${value} km`
|
||||
: displayedData === 'duration'
|
||||
? format(new Date(value * 1000), 'HH:mm')
|
||||
: value
|
||||
}
|
||||
/>
|
||||
<Tooltip />
|
||||
{sports.map((s, i) => (
|
||||
<Bar
|
||||
key={s.id}
|
||||
dataKey={s.label}
|
||||
formatter={value => displayedData === 'duration'
|
||||
? format(new Date(value * 1000), 'HH:mm')
|
||||
: value
|
||||
}
|
||||
stackId="a"
|
||||
fill={activityColors[i]}
|
||||
unit={displayedData === 'distance' ? ' km' : ''}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
sports: state.sports.data,
|
||||
statistics: state.statistics.data,
|
||||
user: state.user,
|
||||
}),
|
||||
dispatch => ({
|
||||
loadMonthActivities: (userId, start, end) => {
|
||||
const dateFormat = 'YYYY-MM-DD'
|
||||
const params = {
|
||||
start: format(start, dateFormat),
|
||||
end: format(end, dateFormat),
|
||||
time: 'week'
|
||||
}
|
||||
dispatch(getStats(userId, 'by_time', params))
|
||||
},
|
||||
})
|
||||
)(Statistics)
|
@ -0,0 +1,64 @@
|
||||
import React from 'react'
|
||||
|
||||
|
||||
export default function UserStatistics (props) {
|
||||
const { user } = props
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col">
|
||||
<div className="card activity-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.nbActivities}</div>
|
||||
<div>{`workout${user.nbActivities === 1 ? '' : 's'}`}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col">
|
||||
<div className="card activity-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">
|
||||
{Math.round(user.totalDistance * 100) / 100}
|
||||
</div>
|
||||
<div>km</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col">
|
||||
<div className="card activity-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">{user.totalDuration}</div>
|
||||
<div>total duration</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col">
|
||||
<div className="card activity-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.nbSports}</div>
|
||||
<div>{`sport${user.nbSports === 1 ? '' : 's'}`}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
107
fittrackee_client/src/components/Dashboard/index.jsx
Normal file
@ -0,0 +1,107 @@
|
||||
import React from 'react'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import { connect } from 'react-redux'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import ActivityCard from './ActivityCard'
|
||||
import Calendar from './../Others/Calendar'
|
||||
import Records from './Records'
|
||||
import Statistics from './Statistics'
|
||||
import UserStatistics from './UserStatistics'
|
||||
import { getData } from '../../actions'
|
||||
import { getMoreActivities } from '../../actions/activities'
|
||||
|
||||
class DashBoard extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context)
|
||||
this.state = {
|
||||
page: 1,
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.loadActivities()
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
activities, loadMoreActivities, message, records, sports, user
|
||||
} = this.props
|
||||
const paginationEnd = activities.length > 0
|
||||
? activities[activities.length - 1].previous_activity === null
|
||||
: true
|
||||
const { page } = this.state
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>FitTrackee - Dashboard</title>
|
||||
</Helmet>
|
||||
{message ? (
|
||||
<code>{message}</code>
|
||||
) : (
|
||||
(activities && sports.length > 0) && (
|
||||
<div className="container dashboard">
|
||||
<UserStatistics user={user} />
|
||||
<div className="row">
|
||||
<div className="col-md-4">
|
||||
<Statistics />
|
||||
<Records records={records} sports={sports} />
|
||||
</div>
|
||||
<div className="col-md-8">
|
||||
<Calendar />
|
||||
{activities.length > 0 ? (
|
||||
activities.map(activity => (
|
||||
<ActivityCard
|
||||
activity={activity}
|
||||
key={activity.id}
|
||||
sports={sports}
|
||||
/>)
|
||||
)) : (
|
||||
<div className="card text-center">
|
||||
<div className="card-body">
|
||||
No workouts. {' '}
|
||||
<Link to={{ pathname: '/activities/add' }}>
|
||||
Upload one !
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!paginationEnd &&
|
||||
<input
|
||||
type="submit"
|
||||
className="btn btn-default btn-md btn-block"
|
||||
value="Load more activities"
|
||||
onClick={() => {
|
||||
loadMoreActivities(page + 1)
|
||||
this.setState({ page: page + 1 })
|
||||
}}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
activities: state.activities.data,
|
||||
message: state.message,
|
||||
records: state.records.data,
|
||||
sports: state.sports.data,
|
||||
user: state.user,
|
||||
}),
|
||||
dispatch => ({
|
||||
loadActivities: () => {
|
||||
dispatch(getData('activities', { page: 1 }))
|
||||
dispatch(getData('records'))
|
||||
},
|
||||
loadMoreActivities: page => {
|
||||
dispatch(getMoreActivities(page))
|
||||
},
|
||||
})
|
||||
)(DashBoard)
|
131
fittrackee_client/src/components/NavBar/index.jsx
Normal file
@ -0,0 +1,131 @@
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { apiUrl } from '../../utils'
|
||||
|
||||
|
||||
function NavBar(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: '/',
|
||||
}}
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
</li>
|
||||
{props.user.isAuthenticated && (
|
||||
<li className="nav-item">
|
||||
<Link
|
||||
className="nav-link"
|
||||
to={{
|
||||
pathname: '/activities/add',
|
||||
}}
|
||||
>
|
||||
Add workout
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
{props.user.isAdmin && (
|
||||
<li className="nav-item">
|
||||
<Link
|
||||
className="nav-link"
|
||||
to={{
|
||||
pathname: '/admin',
|
||||
}}
|
||||
>
|
||||
Admin
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<ul className="navbar-nav flex-row ml-md-auto d-none d-md-flex">
|
||||
{!props.user.isAuthenticated && (
|
||||
<li className="nav-item">
|
||||
<Link
|
||||
className="nav-link"
|
||||
to={{
|
||||
pathname: '/register',
|
||||
}}
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
{!props.user.isAuthenticated && (
|
||||
<li className="nav-item">
|
||||
<Link
|
||||
className="nav-link"
|
||||
to={{
|
||||
pathname: '/login',
|
||||
}}
|
||||
>
|
||||
Login
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
{props.user.picture === true && (
|
||||
<img
|
||||
alt="Avatar"
|
||||
src={`${apiUrl}users/${props.user.id}/picture` +
|
||||
`?${Date.now()}`}
|
||||
className="img-fluid App-nav-profile-img"
|
||||
/>
|
||||
)}
|
||||
{props.user.isAuthenticated && (
|
||||
<li className="nav-item">
|
||||
<Link
|
||||
className="nav-link"
|
||||
to={{
|
||||
pathname: '/profile',
|
||||
}}
|
||||
>
|
||||
{props.user.username}
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
{props.user.isAuthenticated && (
|
||||
<li className="nav-item">
|
||||
<Link
|
||||
className="nav-link"
|
||||
to={{
|
||||
pathname: '/logout',
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
user: state.user,
|
||||
})
|
||||
)(NavBar)
|
16
fittrackee_client/src/components/Others/AccessDenied.jsx
Normal file
@ -0,0 +1,16 @@
|
||||
import React from 'react'
|
||||
import { Helmet } from 'react-helmet'
|
||||
|
||||
export default function AccessDenied () {
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>FitTrackee - Access denied</title>
|
||||
</Helmet>
|
||||
<h1 className="page-title">Access denied</h1>
|
||||
<p className="App-center">
|
||||
{'You don\'t have permissions to access this page.'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
180
fittrackee_client/src/components/Others/Calendar.jsx
Normal file
@ -0,0 +1,180 @@
|
||||
// eslint-disable-next-line max-len
|
||||
// source: https://blog.flowandform.agency/create-a-custom-calendar-in-react-3df1bfd0b728
|
||||
import dateFns from 'date-fns'
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { getMonthActivities } from '../../actions/activities'
|
||||
|
||||
const getStartAndEndMonth = date => {
|
||||
const monthStart = dateFns.startOfMonth(date)
|
||||
const monthEnd = dateFns.endOfMonth(date)
|
||||
return {
|
||||
start: dateFns.startOfWeek(monthStart),
|
||||
end: dateFns.endOfWeek(monthEnd),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class Calendar extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context)
|
||||
const calendarDate = new Date()
|
||||
this.state = {
|
||||
currentMonth: calendarDate,
|
||||
startDate: getStartAndEndMonth(calendarDate).start,
|
||||
endDate: getStartAndEndMonth(calendarDate).end,
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.loadMonthActivities(this.state.startDate, this.state.endDate)
|
||||
}
|
||||
|
||||
renderHeader() {
|
||||
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>
|
||||
{dateFns.format(this.state.currentMonth, dateFormat)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="col col-end" onClick={() => this.handleNextMonth()}>
|
||||
<i
|
||||
className="fa fa-chevron-right"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
renderDays() {
|
||||
const dateFormat = 'ddd'
|
||||
const days = []
|
||||
const { startDate } = this.state
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
days.push(
|
||||
<div className="col col-center" key={i}>
|
||||
{dateFns.format(dateFns.addDays(startDate, i), dateFormat)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <div className="days row">{days}</div>
|
||||
}
|
||||
|
||||
filterActivities(day) {
|
||||
const { activities } = this.props
|
||||
if (activities) {
|
||||
return activities
|
||||
.filter(act => dateFns.isSameDay(act.activity_date, day))
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
renderCells() {
|
||||
const { currentMonth, startDate, endDate } = 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 = dateFns.format(day, dateFormat)
|
||||
const dayActivities = this.filterActivities(day)
|
||||
const isDisabled = dateFns.isSameMonth(day, currentMonth)
|
||||
? ''
|
||||
: 'disabled'
|
||||
days.push(
|
||||
<div
|
||||
className={`col cell img-${isDisabled}`}
|
||||
key={day}
|
||||
>
|
||||
<span className="number">{formattedDate}</span>
|
||||
{dayActivities.map(act => (
|
||||
<Link key={act.id} to={`/activities/${act.id}`}>
|
||||
<img
|
||||
className={`activity-sport ${isDisabled}`}
|
||||
src={sports
|
||||
.filter(s => s.id === act.sport_id)
|
||||
.map(s => s.img)}
|
||||
alt="activity sport logo"
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
day = dateFns.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.setState({
|
||||
currentMonth: calendarDate,
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
})
|
||||
this.props.loadMonthActivities(start, end)
|
||||
}
|
||||
|
||||
handleNextMonth () {
|
||||
const calendarDate = dateFns.addMonths(this.state.currentMonth, 1)
|
||||
this.updateStateDate(calendarDate)
|
||||
}
|
||||
|
||||
handlePrevMonth () {
|
||||
const calendarDate = dateFns.subMonths(this.state.currentMonth, 1)
|
||||
this.updateStateDate(calendarDate)
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="card activity-card">
|
||||
<div className="calendar">
|
||||
{this.renderHeader()}
|
||||
{this.renderDays()}
|
||||
{this.renderCells()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
activities: state.calendarActivities.data,
|
||||
sports: state.sports.data,
|
||||
}),
|
||||
dispatch => ({
|
||||
loadMonthActivities: (start, end) => {
|
||||
const dateFormat = 'YYYY-MM-DD'
|
||||
dispatch(getMonthActivities(
|
||||
dateFns.format(start, dateFormat),
|
||||
dateFns.format(end, dateFormat),
|
||||
))
|
||||
},
|
||||
})
|
||||
)(Calendar)
|
42
fittrackee_client/src/components/Others/CustomModal.jsx
Normal file
@ -0,0 +1,42 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function CustomModal(props) {
|
||||
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()}
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => props.close()}
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
13
fittrackee_client/src/components/Others/NotFound.jsx
Normal file
@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import { Helmet } from 'react-helmet'
|
||||
|
||||
export default function NotFound () {
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>fittrackee - 404</title>
|
||||
</Helmet>
|
||||
<h1 className="page-title">Page not found</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
11
fittrackee_client/src/components/Root.jsx
Normal file
@ -0,0 +1,11 @@
|
||||
import React from 'react'
|
||||
import { Provider } from 'react-redux'
|
||||
import { ConnectedRouter } from 'react-router-redux'
|
||||
|
||||
export default function Root({ store, history, children }) {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<ConnectedRouter history={history}>{children}</ConnectedRouter>
|
||||
</Provider>
|
||||
)
|
||||
}
|
79
fittrackee_client/src/components/User/Form.jsx
Normal file
@ -0,0 +1,79 @@
|
||||
import React from 'react'
|
||||
import { Helmet } from 'react-helmet'
|
||||
|
||||
export default function Form (props) {
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>FitTrackee - {props.formType}</title>
|
||||
</Helmet>
|
||||
<h1 className="page-title">{props.formType}</h1>
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
<div className="col-md-3" />
|
||||
<div className="col-md-6">
|
||||
<hr /><br />
|
||||
<form onSubmit={event =>
|
||||
props.handleUserFormSubmit(event, props.formType)}
|
||||
>
|
||||
{props.formType === 'Register' &&
|
||||
<div className="form-group">
|
||||
<input
|
||||
className="form-control input-lg"
|
||||
name="username"
|
||||
placeholder="Enter a username"
|
||||
required
|
||||
type="text"
|
||||
value={props.userForm.username}
|
||||
onChange={props.onHandleFormChange}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
<div className="form-group">
|
||||
<input
|
||||
className="form-control input-lg"
|
||||
name="email"
|
||||
placeholder="Enter an email address"
|
||||
required
|
||||
type="email"
|
||||
value={props.userForm.email}
|
||||
onChange={props.onHandleFormChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<input
|
||||
className="form-control input-lg"
|
||||
name="password"
|
||||
placeholder="Enter a password"
|
||||
required
|
||||
type="password"
|
||||
value={props.userForm.password}
|
||||
onChange={props.onHandleFormChange}
|
||||
/>
|
||||
</div>
|
||||
{props.formType === 'Register' &&
|
||||
<div className="form-group">
|
||||
<input
|
||||
className="form-control input-lg"
|
||||
name="passwordConf"
|
||||
placeholder="Enter the password confirmation"
|
||||
required
|
||||
type="password"
|
||||
value={props.userForm.passwordConf}
|
||||
onChange={props.onHandleFormChange}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
<input
|
||||
type="submit"
|
||||
className="btn btn-primary btn-lg btn-block"
|
||||
value="Submit"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
<div className="col-md-3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
31
fittrackee_client/src/components/User/Logout.jsx
Normal file
@ -0,0 +1,31 @@
|
||||
import React from 'react'
|
||||
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>
|
||||
<p className="App-center">
|
||||
You are now logged out.
|
||||
Click <Link to="/login">here</Link> to log back in.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
user: state.user,
|
||||
}),
|
||||
dispatch => ({
|
||||
UserLogout: () => {
|
||||
dispatch(logout())
|
||||
}
|
||||
})
|
||||
)(Logout)
|
102
fittrackee_client/src/components/User/Profile.jsx
Normal file
@ -0,0 +1,102 @@
|
||||
import { format } from 'date-fns'
|
||||
import React from 'react'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import { connect } from 'react-redux'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import { deletePicture, uploadPicture } from '../../actions/user'
|
||||
import { apiUrl } from '../../utils'
|
||||
|
||||
function Profile ({ message, onDeletePicture, onUploadPicture, user }) {
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>FitTrackee - {user.username} - Profile</title>
|
||||
</Helmet>
|
||||
{ message !== '' && (
|
||||
<code>{message}</code>
|
||||
)}
|
||||
<div className="container">
|
||||
<h1 className="page-title">Profile</h1>
|
||||
<div className="row">
|
||||
<div className="col-md-12">
|
||||
<div className="card">
|
||||
<div className="card-header userName">
|
||||
{user.username} {' '}
|
||||
<Link
|
||||
to={{
|
||||
pathname: '/profile/edit',
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-pencil-square-o" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="row">
|
||||
<div className="col-md-8">
|
||||
<p>Email: {user.email}</p>
|
||||
<p>Registration Date: {
|
||||
format(new Date(user.createdAt), 'DD/MM/YYYY HH:mm')
|
||||
}</p>
|
||||
<p>First Name: {user.firstName}</p>
|
||||
<p>Last Name: {user.lastName}</p>
|
||||
<p>Birth Date: {user.birthDate}</p>
|
||||
<p>Location: {user.location}</p>
|
||||
<p>Bio: {user.bio}</p>
|
||||
</div>
|
||||
<div className="col-md-4">
|
||||
{ user.picture === true && (
|
||||
<div>
|
||||
<img
|
||||
alt="Profile"
|
||||
src={`${apiUrl}users/${user.id}/picture` +
|
||||
`?${Date.now()}`}
|
||||
className="img-fluid App-profile-img-small"
|
||||
/>
|
||||
<br />
|
||||
<button
|
||||
type="submit"
|
||||
onClick={() => onDeletePicture()}
|
||||
>
|
||||
Delete picture
|
||||
</button>
|
||||
<br /><br />
|
||||
</div>
|
||||
)}
|
||||
<form
|
||||
encType="multipart/form-data"
|
||||
onSubmit={event => onUploadPicture(event)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
name="picture"
|
||||
accept=".png,.jpg,.gif"
|
||||
/>
|
||||
<br />
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
message: state.message,
|
||||
user: state.user,
|
||||
}),
|
||||
dispatch => ({
|
||||
onDeletePicture: () => {
|
||||
dispatch(deletePicture())
|
||||
},
|
||||
onUploadPicture: event => {
|
||||
dispatch(uploadPicture(event))
|
||||
},
|
||||
})
|
||||
)(Profile)
|
197
fittrackee_client/src/components/User/ProfileEdit.jsx
Normal file
@ -0,0 +1,197 @@
|
||||
import React from 'react'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import { connect } from 'react-redux'
|
||||
|
||||
import {
|
||||
initProfileForm,
|
||||
updateProfileFormData,
|
||||
handleProfileFormSubmit
|
||||
} from '../../actions/user'
|
||||
import { history } from '../../index'
|
||||
|
||||
|
||||
class ProfileEdit extends React.Component {
|
||||
componentDidMount() {
|
||||
this.props.initForm(this.props.user)
|
||||
}
|
||||
render () {
|
||||
const { formProfile,
|
||||
onHandleFormChange,
|
||||
onHandleProfileFormSubmit,
|
||||
message,
|
||||
user
|
||||
} = this.props
|
||||
return (
|
||||
<div>
|
||||
<Helmet>
|
||||
<title>FitTrackee - {user.username} - Edit Profile</title>
|
||||
</Helmet>
|
||||
{ message !== '' && (
|
||||
<code>{message}</code>
|
||||
)}
|
||||
<div className="container">
|
||||
<h1 className="page-title">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 =>
|
||||
onHandleProfileFormSubmit(event)}
|
||||
>
|
||||
<div className="form-group">
|
||||
<label>Email:
|
||||
<input
|
||||
name="email"
|
||||
className="form-control input-lg"
|
||||
type="text"
|
||||
value={user.email}
|
||||
readOnly
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Registration Date:
|
||||
<input
|
||||
name="createdAt"
|
||||
className="form-control input-lg"
|
||||
type="text"
|
||||
value={user.createdAt}
|
||||
readOnly
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Password:
|
||||
<input
|
||||
name="password"
|
||||
className="form-control input-lg"
|
||||
type="password"
|
||||
onChange={onHandleFormChange}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Password Confirmation:
|
||||
<input
|
||||
name="passwordConf"
|
||||
className="form-control input-lg"
|
||||
type="password"
|
||||
onChange={onHandleFormChange}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<hr />
|
||||
<div className="form-group">
|
||||
<label>
|
||||
First Name:
|
||||
<input
|
||||
name="firstName"
|
||||
className="form-control input-lg"
|
||||
type="text"
|
||||
value={formProfile.firstName}
|
||||
onChange={onHandleFormChange}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Last Name:
|
||||
<input
|
||||
name="lastName"
|
||||
className="form-control input-lg"
|
||||
type="text"
|
||||
value={formProfile.lastName}
|
||||
onChange={onHandleFormChange}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Birth Date
|
||||
<input
|
||||
name="birthDate"
|
||||
className="form-control input-lg"
|
||||
type="date"
|
||||
value={formProfile.birthDate}
|
||||
onChange={onHandleFormChange}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Location:
|
||||
<input
|
||||
name="location"
|
||||
className="form-control input-lg"
|
||||
type="text"
|
||||
value={formProfile.location}
|
||||
onChange={onHandleFormChange}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Bio:
|
||||
<textarea
|
||||
name="bio"
|
||||
className="form-control input-lg"
|
||||
maxLength="200"
|
||||
type="text"
|
||||
value={formProfile.bio}
|
||||
onChange={onHandleFormChange}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
type="submit"
|
||||
className="btn btn-primary btn-lg btn-block"
|
||||
value="Submit"
|
||||
/>
|
||||
<input
|
||||
type="submit"
|
||||
className="btn btn-secondary btn-lg btn-block"
|
||||
onClick={() => history.push('/profile')}
|
||||
value="Cancel"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
formProfile: state.formProfile.formProfile,
|
||||
message: state.message,
|
||||
user: state.user,
|
||||
}),
|
||||
dispatch => ({
|
||||
initForm: () => {
|
||||
dispatch(initProfileForm())
|
||||
},
|
||||
onHandleFormChange: event => {
|
||||
dispatch(updateProfileFormData(event.target.name, event.target.value))
|
||||
},
|
||||
onHandleProfileFormSubmit: event => {
|
||||
dispatch(handleProfileFormSubmit(event))
|
||||
},
|
||||
})
|
||||
)(ProfileEdit)
|
84
fittrackee_client/src/components/User/UserForm.jsx
Normal file
@ -0,0 +1,84 @@
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import { Redirect } from 'react-router-dom'
|
||||
|
||||
import Form from './Form'
|
||||
import {
|
||||
emptyForm,
|
||||
handleFormChange,
|
||||
handleUserFormSubmit
|
||||
} from '../../actions/user'
|
||||
import { isLoggedIn } from '../../utils'
|
||||
|
||||
class UserForm extends React.Component {
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (
|
||||
(prevProps.location.pathname !== this.props.location.pathname)
|
||||
) {
|
||||
this.props.onEmptyForm()
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
formData,
|
||||
formType,
|
||||
message,
|
||||
messages,
|
||||
onHandleFormChange,
|
||||
onHandleUserFormSubmit
|
||||
} = this.props
|
||||
return (
|
||||
<div>
|
||||
{isLoggedIn() ? (
|
||||
<Redirect to="/" />
|
||||
) : (
|
||||
<div>
|
||||
{message !== '' && (
|
||||
<code>{message}</code>
|
||||
)}
|
||||
{messages.length > 0 && (
|
||||
<code>
|
||||
<ul>
|
||||
{messages.map(msg => (
|
||||
<li key={msg.id}>
|
||||
{msg.value}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</code>
|
||||
)}
|
||||
<Form
|
||||
formType={formType}
|
||||
userForm={formData}
|
||||
onHandleFormChange={event => onHandleFormChange(event)}
|
||||
handleUserFormSubmit={event =>
|
||||
onHandleUserFormSubmit(event, formType)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
export default connect(
|
||||
state => ({
|
||||
formData: state.formData.formData,
|
||||
location: state.router.location,
|
||||
message: state.message,
|
||||
messages: state.messages,
|
||||
}),
|
||||
dispatch => ({
|
||||
onEmptyForm: () => {
|
||||
dispatch(emptyForm())
|
||||
},
|
||||
onHandleFormChange: event => {
|
||||
dispatch(handleFormChange(event.target.name, event.target.value))
|
||||
},
|
||||
onHandleUserFormSubmit: (event, formType) => {
|
||||
dispatch(handleUserFormSubmit(event, formType))
|
||||
},
|
||||
})
|
||||
)(UserForm)
|
40
fittrackee_client/src/fitTrackeeApi/activities.js
Normal file
@ -0,0 +1,40 @@
|
||||
import { apiUrl, createRequest } from '../utils'
|
||||
|
||||
export default class FitTrackeeApi {
|
||||
|
||||
static addActivity(formData) {
|
||||
const params = {
|
||||
url: `${apiUrl}activities`,
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
}
|
||||
return createRequest(params)
|
||||
}
|
||||
|
||||
static addActivityWithoutGpx(data) {
|
||||
const params = {
|
||||
url: `${apiUrl}activities/no_gpx`,
|
||||
method: 'POST',
|
||||
body: data,
|
||||
type: 'application/json',
|
||||
}
|
||||
return createRequest(params)
|
||||
}
|
||||
|
||||
static getActivityGpx(activityId) {
|
||||
const params = {
|
||||
url: `${apiUrl}activities/${activityId}/gpx`,
|
||||
method: 'GET',
|
||||
}
|
||||
return createRequest(params)
|
||||
}
|
||||
|
||||
static getActivityChartData(activityId) {
|
||||
const params = {
|
||||
url: `${apiUrl}activities/${activityId}/chart_data`,
|
||||
method: 'GET',
|
||||
}
|
||||
return createRequest(params)
|
||||
}
|
||||
|
||||
}
|
59
fittrackee_client/src/fitTrackeeApi/index.js
Normal file
@ -0,0 +1,59 @@
|
||||
import { apiUrl, createRequest } from '../utils'
|
||||
|
||||
export default class FitTrackeeApi {
|
||||
|
||||
static getData(target,
|
||||
data = {}) {
|
||||
let url = `${apiUrl}${target}`
|
||||
if (data.id) {
|
||||
url = `${url}/${data.id}`
|
||||
} else if (Object.keys(data).length > 0) {
|
||||
url = `${url}?${
|
||||
data.page ? `&page=${data.page}` : ''
|
||||
}${
|
||||
data.start ? `&from=${data.start}` : ''
|
||||
}${
|
||||
data.end ? `&to=${data.end}` : ''
|
||||
}${
|
||||
data.order ? `&order=${data.order}` : ''
|
||||
}${
|
||||
data.per_page ? `&per_page=${data.per_page}` : ''
|
||||
}`
|
||||
}
|
||||
const params = {
|
||||
url: url,
|
||||
method: 'GET',
|
||||
type: 'application/json',
|
||||
}
|
||||
return createRequest(params)
|
||||
}
|
||||
|
||||
static addData(target, data) {
|
||||
const params = {
|
||||
url: `${apiUrl}${target}`,
|
||||
method: 'POST',
|
||||
body: data,
|
||||
type: 'application/json',
|
||||
}
|
||||
return createRequest(params)
|
||||
}
|
||||
|
||||
static updateData(target, data) {
|
||||
const params = {
|
||||
url: `${apiUrl}${target}/${data.id}`,
|
||||
method: 'PATCH',
|
||||
body: data,
|
||||
type: 'application/json',
|
||||
}
|
||||
return createRequest(params)
|
||||
}
|
||||
|
||||
static deleteData(target, id) {
|
||||
const params = {
|
||||
url: `${apiUrl}${target}/${id}`,
|
||||
method: 'DELETE',
|
||||
type: 'application/json',
|
||||
}
|
||||
return createRequest(params)
|
||||
}
|
||||
}
|
25
fittrackee_client/src/fitTrackeeApi/stats.js
Normal file
@ -0,0 +1,25 @@
|
||||
import { apiUrl, createRequest } from '../utils'
|
||||
|
||||
export default class FitTrackeeApi {
|
||||
|
||||
static getStats(userID, type, data = {}) {
|
||||
let url = `${apiUrl}stats/${userID}/${type}`
|
||||
if (Object.keys(data).length > 0) {
|
||||
url = `${url}?${
|
||||
data.start ? `&from=${data.start}` : ''
|
||||
}${
|
||||
data.end ? `&to=${data.end}` : ''
|
||||
}${
|
||||
data.time ? `&time=${data.time}` : ''
|
||||
}${
|
||||
data.sport_id ? `&sport_id=${data.sport_id}` : ''
|
||||
}`
|
||||
}
|
||||
const params = {
|
||||
url: url,
|
||||
method: 'GET',
|
||||
}
|
||||
return createRequest(params)
|
||||
}
|
||||
|
||||
}
|
78
fittrackee_client/src/fitTrackeeApi/user.js
Normal file
@ -0,0 +1,78 @@
|
||||
import { apiUrl, createRequest } from '../utils'
|
||||
|
||||
export default class FitTrackeeApi {
|
||||
|
||||
static login(email, password) {
|
||||
const params = {
|
||||
url: `${apiUrl}auth/login`,
|
||||
method: 'POST',
|
||||
noAuthorization: true,
|
||||
body: {
|
||||
email: email,
|
||||
password: password,
|
||||
},
|
||||
type: 'application/json',
|
||||
}
|
||||
return createRequest(params)
|
||||
}
|
||||
|
||||
static register(username, email, password, passwordConf) {
|
||||
const params = {
|
||||
url: `${apiUrl}auth/register`,
|
||||
method: 'POST',
|
||||
noAuthorization: true,
|
||||
body: {
|
||||
username: username,
|
||||
email: email,
|
||||
password: password,
|
||||
password_conf: passwordConf,
|
||||
},
|
||||
type: 'application/json',
|
||||
}
|
||||
return createRequest(params)
|
||||
}
|
||||
|
||||
static getProfile() {
|
||||
const params = {
|
||||
url: `${apiUrl}auth/profile`,
|
||||
method: 'GET',
|
||||
type: 'application/json',
|
||||
}
|
||||
return createRequest(params)
|
||||
}
|
||||
|
||||
static updateProfile(form) {
|
||||
const params = {
|
||||
url: `${apiUrl}auth/profile/edit`,
|
||||
method: 'POST',
|
||||
body: {
|
||||
first_name: form.firstName,
|
||||
last_name: form.lastName,
|
||||
bio: form.bio,
|
||||
location: form.location,
|
||||
birth_date: form.birthDate,
|
||||
password: form.password,
|
||||
password_conf: form.passwordConf,
|
||||
},
|
||||
type: 'application/json',
|
||||
}
|
||||
return createRequest(params)
|
||||
}
|
||||
|
||||
static updatePicture(form) {
|
||||
const params = {
|
||||
url: `${apiUrl}auth/picture`,
|
||||
method: 'POST',
|
||||
body: form,
|
||||
}
|
||||
return createRequest(params)
|
||||
}
|
||||
|
||||
static deletePicture() {
|
||||
const params = {
|
||||
url: `${apiUrl}auth/picture`,
|
||||
method: 'DELETE',
|
||||
}
|
||||
return createRequest(params)
|
||||
}
|
||||
}
|
37
fittrackee_client/src/index.js
Normal file
@ -0,0 +1,37 @@
|
||||
/* eslint-disable react/jsx-filename-extension */
|
||||
import { createBrowserHistory } from 'history'
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { routerMiddleware } from 'react-router-redux'
|
||||
import { applyMiddleware, createStore, compose } from 'redux'
|
||||
import thunk from 'redux-thunk'
|
||||
|
||||
import App from './components/App'
|
||||
import Root from './components/Root'
|
||||
import registerServiceWorker from './registerServiceWorker'
|
||||
import reducers from './reducers'
|
||||
import { loadProfile } from './actions/user'
|
||||
|
||||
export const history = createBrowserHistory()
|
||||
|
||||
export const rootNode = document.getElementById('root')
|
||||
|
||||
export const store = createStore(
|
||||
reducers,
|
||||
window.__STATE__, // Server state
|
||||
(window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose)(
|
||||
applyMiddleware(routerMiddleware(history), thunk)
|
||||
)
|
||||
)
|
||||
|
||||
if (window.localStorage.authToken !== null) {
|
||||
store.dispatch(loadProfile())
|
||||
}
|
||||
|
||||
ReactDOM.render(
|
||||
<Root store={store} history={history}>
|
||||
<App />
|
||||
</Root>,
|
||||
rootNode
|
||||
)
|
||||
registerServiceWorker()
|
7
fittrackee_client/src/logo.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
|
||||
<g fill="#61DAFB">
|
||||
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
|
||||
<circle cx="420.9" cy="296.5" r="45.7"/>
|
||||
<path d="M520.5 78.1z"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 2.6 KiB |
218
fittrackee_client/src/reducers/index.js
Normal file
@ -0,0 +1,218 @@
|
||||
import { format } from 'date-fns'
|
||||
import { routerReducer } from 'react-router-redux'
|
||||
import { combineReducers } from 'redux'
|
||||
|
||||
import initial from './initial'
|
||||
|
||||
const handleDataAndError = (state, type, action) => {
|
||||
if (action.target !== type) {
|
||||
return state
|
||||
}
|
||||
switch (action.type) {
|
||||
case 'SET_DATA':
|
||||
return {
|
||||
...state,
|
||||
data: action.data[action.target],
|
||||
}
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
const activities = (state = initial.activities, action) => {
|
||||
switch (action.type) {
|
||||
case 'PUSH_ACTIVITIES':
|
||||
return {
|
||||
...state,
|
||||
data: state.data.concat(action.activities),
|
||||
}
|
||||
default:
|
||||
return handleDataAndError(state, 'activities', action)
|
||||
}
|
||||
}
|
||||
|
||||
const calendarActivities = (state = initial.calendarActivities, action) => {
|
||||
switch (action.type) {
|
||||
case 'UPDATE_CALENDAR':
|
||||
return {
|
||||
...state,
|
||||
data: action.activities,
|
||||
}
|
||||
default:
|
||||
return handleDataAndError(state, 'calendarActivities', action)
|
||||
}
|
||||
}
|
||||
|
||||
const chartData = (state = initial.chartData, action) => {
|
||||
switch (action.type) {
|
||||
case 'SET_CHART_DATA':
|
||||
return action.chartData
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
const formData = (state = initial.formData, action) => {
|
||||
switch (action.type) {
|
||||
case 'UPDATE_USER_FORMDATA':
|
||||
return {
|
||||
formData: {
|
||||
...state.formData,
|
||||
[action.target]: action.value
|
||||
},
|
||||
}
|
||||
case 'PROFILE_SUCCESS':
|
||||
case 'EMPTY_USER_FORMDATA':
|
||||
return initial.formData
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
const formProfile = (state = initial.formProfile, action) => {
|
||||
switch (action.type) {
|
||||
case 'UPDATE_PROFILE_FORMDATA':
|
||||
return {
|
||||
formProfile: {
|
||||
...state.formProfile,
|
||||
[action.target]: action.value
|
||||
},
|
||||
}
|
||||
case 'INIT_PROFILE_FORM':
|
||||
return {
|
||||
formProfile: {
|
||||
...state.formProfile,
|
||||
firstName: action.user.firstName,
|
||||
lastName: action.user.lastName,
|
||||
birthDate: action.user.birthDate,
|
||||
location: action.user.location,
|
||||
bio: action.user.bio,
|
||||
},
|
||||
}
|
||||
case 'PROFILE_SUCCESS':
|
||||
return initial.formProfile
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
const gpx = (state = initial.gpx, action) => {
|
||||
switch (action.type) {
|
||||
case 'SET_GPX':
|
||||
return action.gpxContent
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
const loading = (state = initial.loading, action) => {
|
||||
switch (action.type) {
|
||||
case 'SET_LOADING':
|
||||
return !state
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
const message = (state = initial.message, action) => {
|
||||
switch (action.type) {
|
||||
case 'AUTH_ERROR':
|
||||
case 'PROFILE_ERROR':
|
||||
case 'PROFILE_UPDATE_ERROR':
|
||||
case 'PICTURE_ERROR':
|
||||
case 'SET_ERROR':
|
||||
return action.message
|
||||
case 'LOGOUT':
|
||||
case 'PROFILE_SUCCESS':
|
||||
case 'SET_RESULTS':
|
||||
case '@@router/LOCATION_CHANGE':
|
||||
return ''
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
const messages = (state = initial.messages, action) => {
|
||||
switch (action.type) {
|
||||
case 'AUTH_ERRORS':
|
||||
return action.messages
|
||||
case 'LOGOUT':
|
||||
case 'PROFILE_SUCCESS':
|
||||
case '@@router/LOCATION_CHANGE':
|
||||
return []
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
const records = (state = initial.records, action) =>
|
||||
handleDataAndError(state, 'records', action)
|
||||
|
||||
const sports = (state = initial.sports, action) =>
|
||||
handleDataAndError(state, 'sports', action)
|
||||
|
||||
const user = (state = initial.user, action) => {
|
||||
switch (action.type) {
|
||||
case 'AUTH_ERROR':
|
||||
case 'PROFILE_ERROR':
|
||||
case 'LOGOUT':
|
||||
window.localStorage.removeItem('authToken')
|
||||
return initial.user
|
||||
case 'PROFILE_SUCCESS':
|
||||
return {
|
||||
id: action.message.data.id,
|
||||
username: action.message.data.username,
|
||||
email: action.message.data.email,
|
||||
isAdmin: action.message.data.admin,
|
||||
createdAt: action.message.data.created_at,
|
||||
isAuthenticated: true,
|
||||
firstName: action.message.data.first_name
|
||||
? action.message.data.first_name
|
||||
: '',
|
||||
lastName: action.message.data.last_name
|
||||
? action.message.data.last_name
|
||||
: '',
|
||||
bio: action.message.data.bio
|
||||
? action.message.data.bio
|
||||
: '',
|
||||
location: action.message.data.location
|
||||
? action.message.data.location
|
||||
: '',
|
||||
birthDate: action.message.data.birth_date
|
||||
? format(new Date(action.message.data.birth_date),
|
||||
'DD/MM/YYYY')
|
||||
: '',
|
||||
picture: action.message.data.picture === true
|
||||
? action.message.data.picture
|
||||
: false,
|
||||
nbActivities: action.message.data.nb_activities,
|
||||
nbSports: action.message.data.nb_sports,
|
||||
totalDistance: action.message.data.total_distance,
|
||||
totalDuration: action.message.data.total_duration,
|
||||
}
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
const statistics = (state = initial.statistics, action) =>
|
||||
handleDataAndError(state, 'statistics', action)
|
||||
|
||||
const reducers = combineReducers({
|
||||
activities,
|
||||
calendarActivities,
|
||||
chartData,
|
||||
formData,
|
||||
formProfile,
|
||||
gpx,
|
||||
loading,
|
||||
message,
|
||||
messages,
|
||||
records,
|
||||
router: routerReducer,
|
||||
sports,
|
||||
statistics,
|
||||
user,
|
||||
})
|
||||
|
||||
export default reducers
|
60
fittrackee_client/src/reducers/initial.js
Normal file
@ -0,0 +1,60 @@
|
||||
const emptyData = {
|
||||
data: [],
|
||||
}
|
||||
|
||||
export default {
|
||||
message: '',
|
||||
messages: [],
|
||||
user: {
|
||||
id: '',
|
||||
username: '',
|
||||
email: '',
|
||||
createdAt: '',
|
||||
isAdmin: false,
|
||||
isAuthenticated: false,
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
bio: '',
|
||||
location: '',
|
||||
birthDate: '',
|
||||
picture: false
|
||||
},
|
||||
formData: {
|
||||
formData: {
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
passwordConf: '',
|
||||
}
|
||||
},
|
||||
formProfile: {
|
||||
formProfile: {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
bio: '',
|
||||
location: '',
|
||||
birthDate: '',
|
||||
password: '',
|
||||
passwordConf: '',
|
||||
}
|
||||
},
|
||||
activities: {
|
||||
...emptyData,
|
||||
},
|
||||
calendarActivities: {
|
||||
...emptyData,
|
||||
},
|
||||
chartData: [],
|
||||
// check if storing gpx content is OK
|
||||
gpx: null,
|
||||
loading: false,
|
||||
records: {
|
||||
...emptyData,
|
||||
},
|
||||
sports: {
|
||||
...emptyData,
|
||||
},
|
||||
statistics: {
|
||||
data: {},
|
||||
},
|
||||
}
|
113
fittrackee_client/src/registerServiceWorker.js
Normal file
@ -0,0 +1,113 @@
|
||||
// In production, we register a service worker to serve assets from local cache.
|
||||
|
||||
// This lets the app load faster on subsequent visits in production, and gives
|
||||
// it offline capabilities. However, it also means that developers (and users)
|
||||
// will only see deployed updates on the "N+1" visit to a page, since previously
|
||||
// cached resources are updated in the background.
|
||||
|
||||
// To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
|
||||
// This link also includes instructions on opting out of this behavior.
|
||||
|
||||
const isLocalhost = Boolean(
|
||||
window.location.hostname === 'localhost' ||
|
||||
// [::1] is the IPv6 localhost address.
|
||||
window.location.hostname === '[::1]' ||
|
||||
// 127.0.0.1/8 is considered localhost for IPv4.
|
||||
window.location.hostname.match(
|
||||
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
|
||||
)
|
||||
)
|
||||
|
||||
export default function register() {
|
||||
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
|
||||
// The URL constructor is available in all browsers that support SW.
|
||||
const publicUrl = new URL(process.env.PUBLIC_URL, window.location)
|
||||
if (publicUrl.origin !== window.location.origin) {
|
||||
// Our service worker won't work if PUBLIC_URL is on a different origin
|
||||
// from what our page is served on. This might happen if a CDN is used to
|
||||
// serve assets;
|
||||
// see https://github.com/facebookincubator/create-react-app/issues/2374
|
||||
return
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`
|
||||
|
||||
if (isLocalhost) {
|
||||
// This is running on localhost.
|
||||
// Lets check if a service worker still exists or not.
|
||||
checkValidServiceWorker(swUrl)
|
||||
} else {
|
||||
// Is not local host. Just register service worker
|
||||
registerValidSW(swUrl)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function registerValidSW(swUrl) {
|
||||
navigator.serviceWorker
|
||||
.register(swUrl)
|
||||
.then(registration => {
|
||||
registration.onupdatefound = () => {
|
||||
const installingWorker = registration.installing
|
||||
installingWorker.onstatechange = () => {
|
||||
if (installingWorker.state === 'installed') {
|
||||
if (navigator.serviceWorker.controller) {
|
||||
// At this point, the old content will have been purged and
|
||||
// the fresh content will have been added to the cache.
|
||||
// It's the perfect time to display a "New content is
|
||||
// available; please refresh." message in your web app.
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('New content is available; please refresh.')
|
||||
} else {
|
||||
// At this point, everything has been precached.
|
||||
// It's the perfect time to display a
|
||||
// "Content is cached for offline use." message.
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Content is cached for offline use.')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error during service worker registration:', error)
|
||||
})
|
||||
}
|
||||
|
||||
function checkValidServiceWorker(swUrl) {
|
||||
// Check if the service worker can be found. If it can't reload the page.
|
||||
fetch(swUrl)
|
||||
.then(response => {
|
||||
// Ensure service worker exists, and that we really are getting a JS file.
|
||||
if (
|
||||
response.status === 404 ||
|
||||
response.headers.get('content-type').indexOf('javascript') === -1
|
||||
) {
|
||||
// No service worker found. Probably a different app. Reload the page.
|
||||
navigator.serviceWorker.ready.then(registration => {
|
||||
registration.unregister().then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// Service worker found. Proceed as normal.
|
||||
registerValidSW(swUrl)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
'No internet connection found. App is running in offline mode.'
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function unregister() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.ready.then(registration => {
|
||||
registration.unregister()
|
||||
})
|
||||
}
|
||||
}
|
153
fittrackee_client/src/utils.js
Normal file
@ -0,0 +1,153 @@
|
||||
import togeojson from '@mapbox/togeojson'
|
||||
import { addDays, format, parse, startOfWeek, subHours } from 'date-fns'
|
||||
|
||||
export const apiUrl = `${process.env.REACT_APP_API_URL}/api/`
|
||||
export const thunderforestApiKey = `${
|
||||
process.env.REACT_APP_THUNDERFOREST_API_KEY
|
||||
}`
|
||||
export const activityColors = [
|
||||
'#55a8a3',
|
||||
'#98C3A9',
|
||||
'#D0838A',
|
||||
'#ECC77E',
|
||||
'#926692',
|
||||
'#929292',
|
||||
'#428bca',
|
||||
]
|
||||
|
||||
export const isLoggedIn = () => !!window.localStorage.authToken
|
||||
|
||||
export const generateIds = arr => {
|
||||
let i = 0
|
||||
return arr.map(val => {
|
||||
const obj = { id: i, value: val }
|
||||
i++
|
||||
return obj
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
export const createRequest = params => {
|
||||
const headers = {}
|
||||
if (!params.noAuthorization) {
|
||||
headers.Authorization = `Bearer ${
|
||||
window.localStorage.getItem('authToken')}`
|
||||
}
|
||||
if (params.type) {
|
||||
headers['Content-Type'] = params.type
|
||||
}
|
||||
const requestParams = {
|
||||
method: params.method,
|
||||
headers: headers,
|
||||
}
|
||||
if (params.type === 'application/json' && params.body) {
|
||||
requestParams.body = JSON.stringify(params.body)
|
||||
} else if (params.body) {
|
||||
requestParams.body = params.body
|
||||
}
|
||||
const request = new Request(params.url, requestParams)
|
||||
return fetch(request)
|
||||
.then(response => params.method === 'DELETE'
|
||||
? response
|
||||
: response.json())
|
||||
.catch(error => error)
|
||||
}
|
||||
|
||||
|
||||
export const getGeoJson = gpxContent => {
|
||||
let jsonData
|
||||
if (gpxContent) {
|
||||
const gpx = new DOMParser().parseFromString(gpxContent, 'text/xml')
|
||||
jsonData = togeojson.gpx(gpx)
|
||||
}
|
||||
return { jsonData }
|
||||
}
|
||||
|
||||
export const formatActivityDate = activityDateTime => {
|
||||
if (activityDateTime) {
|
||||
const dateTime = parse(activityDateTime)
|
||||
return {
|
||||
activity_date: format(dateTime, 'DD/MM/YYYY'),
|
||||
activity_time: activityDateTime.match(/[0-2][0-9]:[0-5][0-9]/)[0]
|
||||
}
|
||||
}
|
||||
return {
|
||||
activity_date: null,
|
||||
activity_time: null,
|
||||
}
|
||||
}
|
||||
|
||||
export const formatRecord = record => {
|
||||
let value, recordType = null
|
||||
switch (record.record_type) {
|
||||
case 'AS':
|
||||
case 'MS':
|
||||
value = `${record.value} km/h`
|
||||
recordType = record.record_type === 'AS' ? 'Avg speed' : 'Max speed'
|
||||
break
|
||||
case 'FD':
|
||||
value = `${record.value} km`
|
||||
recordType = 'Farest distance'
|
||||
break
|
||||
default: // 'LD'
|
||||
value = record.value // eslint-disable-line prefer-destructuring
|
||||
recordType = 'Longest duration'
|
||||
}
|
||||
return {
|
||||
activity_date: formatActivityDate(record.activity_date).activity_date,
|
||||
activity_id: record.activity_id,
|
||||
id: record.id,
|
||||
record_type: recordType,
|
||||
value: value,
|
||||
}
|
||||
}
|
||||
|
||||
const formatDuration = seconds => {
|
||||
let newDate = new Date(0)
|
||||
newDate = subHours(newDate.setSeconds(seconds), 1)
|
||||
return newDate.getTime()
|
||||
}
|
||||
|
||||
export const formatChartData = chartData => {
|
||||
for (let i = 0; i < chartData.length; i++) {
|
||||
chartData[i].time = new Date(chartData[i].time).getTime()
|
||||
chartData[i].duration = formatDuration(chartData[i].duration)
|
||||
}
|
||||
return chartData
|
||||
}
|
||||
|
||||
export const formatStats = (stats, sports, startDate, endDate) => {
|
||||
const nbActivitiesStats = []
|
||||
const distanceStats = []
|
||||
const durationStats = []
|
||||
|
||||
for (let day = startOfWeek(startDate);
|
||||
day <= endDate;
|
||||
day = addDays(day, 7)
|
||||
) {
|
||||
const date = format(day, 'YYYY-MM-DD')
|
||||
const xAxis = format(day, 'DD/MM')
|
||||
const dataNbActivities = { date: xAxis }
|
||||
const dataDistance = { date: xAxis }
|
||||
const dataDuration = { date: xAxis }
|
||||
|
||||
if (stats[date]) {
|
||||
Object.keys(stats[date]).map(sportId => {
|
||||
const sportLabel = sports.filter(s => s.id === +sportId)[0].label
|
||||
dataNbActivities[sportLabel] = stats[date][sportId].nb_activities
|
||||
dataDistance[sportLabel] = stats[date][sportId].total_distance
|
||||
dataDuration[sportLabel] = stats[date][sportId].total_duration
|
||||
return null
|
||||
})
|
||||
}
|
||||
nbActivitiesStats.push(dataNbActivities)
|
||||
distanceStats.push(dataDistance)
|
||||
durationStats.push(dataDuration)
|
||||
}
|
||||
|
||||
return {
|
||||
activities: nbActivitiesStats,
|
||||
distance: distanceStats,
|
||||
duration: durationStats
|
||||
}
|
||||
}
|