Client - rename existing front

This commit is contained in:
Sam
2021-09-01 20:08:06 +02:00
parent 6ba3f6d54e
commit 6d1de3c3bb
134 changed files with 0 additions and 0 deletions

View File

@ -1,433 +0,0 @@
{
"root": true,
"parser": "babel-eslint",
"parserOptions": {
"ecmaVersion": 2017,
"sourceType": "module",
"ecmaFeatures": {
"jsx": true,
"module": true,
"experimentalObjectRestSpread": true
}
},
"env": {
"browser": true,
"node": true,
"es6": true,
"jest": true
},
"extends": ["plugin:prettier/recommended"],
"rules": {
"no-alert": "warn",
"no-array-constructor": "off",
"no-await-in-loop": "off",
"no-bitwise": "off",
"no-caller": "warn",
"no-case-declarations": "error",
"no-catch-shadow": "off",
"no-class-assign": "error",
"no-compare-neg-zero": "warn",
"no-cond-assign": "error",
"no-confusing-arrow": "off",
"no-console": [
"error",
{
"allow": ["warn", "error", "info"]
}
],
"no-const-assign": "error",
"no-constant-condition": "error",
"no-continue": "off",
"no-control-regex": "error",
"no-debugger": "off",
"no-delete-var": "error",
"no-div-regex": "off",
"no-dupe-args": "error",
"no-dupe-class-members": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-duplicate-imports": "warn",
"no-else-return": "warn",
"no-empty": "error",
"no-empty-character-class": "error",
"no-empty-function": "off",
"no-empty-pattern": "error",
"no-eq-null": "warn",
"no-eval": "error",
"no-ex-assign": "error",
"no-extend-native": "error",
"no-extra-bind": "warn",
"no-extra-boolean-cast": "error",
"no-extra-label": "warn",
"no-extra-parens": "off",
"no-extra-semi": "off",
"no-fallthrough": "error",
"no-floating-decimal": "off",
"no-func-assign": "error",
"no-global-assign": "error",
"no-implicit-coercion": "off",
"no-implicit-globals": "off",
"no-implied-eval": "error",
"no-inline-comments": "off",
"no-inner-declarations": "error",
"no-invalid-regexp": "error",
"no-invalid-this": "error",
"no-irregular-whitespace": [
"warn",
{
"skipStrings": true,
"skipTemplates": true
}
],
"no-iterator": "off",
"no-label-var": "off",
"no-labels": "off",
"no-lone-blocks": "warn",
"no-lonely-if": "warn",
"no-loop-func": "warn",
"no-magic-numbers": "off",
"no-mixed-operators": "off",
"no-mixed-requires": "off",
"no-mixed-spaces-and-tabs": "error",
"no-multi-assign": "off",
"no-multi-spaces": "error",
"no-multi-str": "off",
"no-multiple-empty-lines": "warn",
"no-native-reassign": "off",
"no-negated-condition": "warn",
"no-negated-in-lhs": "off",
"no-nested-ternary": "off",
"no-new": "off",
"no-new-func": "off",
"no-new-object": "off",
"no-new-require": "off",
"no-new-symbol": "error",
"no-new-wrappers": "off",
"no-obj-calls": "error",
"no-octal": "error",
"no-octal-escape": "off",
"no-param-reassign": "off",
"no-path-concat": "warn",
"no-plusplus": "off",
"no-process-env": "off",
"no-process-exit": "off",
"no-proto": "off",
"no-prototype-builtins": "off",
"no-redeclare": "error",
"no-regex-spaces": "error",
"no-restricted-globals": "off",
"no-restricted-imports": "off",
"no-restricted-modules": "off",
"no-restricted-properties": "off",
"no-restricted-syntax": "off",
"no-return-assign": "off",
"no-return-await": "warn",
"no-script-url": "error",
"no-self-assign": "error",
"no-self-compare": "error",
"no-sequences": "warn",
"no-shadow": "warn",
"no-shadow-restricted-names": "warn",
"no-whitespace-before-property": "error",
"no-spaced-func": "off",
"no-sparse-arrays": "error",
"no-sync": "off",
"no-tabs": "error",
"no-ternary": "off",
"no-trailing-spaces": "error",
"no-this-before-super": "error",
"no-throw-literal": "warn",
"no-undef": "error",
"no-undef-init": "warn",
"no-undefined": "error",
"no-unexpected-multiline": "error",
"no-underscore-dangle": "off",
"no-unmodified-loop-condition": "off",
"no-unneeded-ternary": "error",
"no-unreachable": "error",
"no-unsafe-finally": "error",
"no-unsafe-negation": "error",
"no-unused-expressions": [
"error",
{
"allowShortCircuit": true,
"allowTernary": true
}
],
"no-unused-labels": "error",
"no-unused-vars": "error",
"no-use-before-define": "off",
"no-useless-call": "error",
"no-useless-computed-key": "off",
"no-useless-concat": "off",
"no-useless-constructor": "error",
"no-useless-escape": "warn",
"no-useless-rename": "error",
"no-useless-return": "error",
"no-void": "off",
"no-var": "off",
"no-warning-comments": [
"warn",
{
"terms": ["todo", "fixme", "xxx"],
"location": "start"
}
],
"no-with": "off",
"array-bracket-spacing": ["error", "never"],
"array-callback-return": "off",
"arrow-body-style": ["error", "as-needed"],
"arrow-parens": ["error", "as-needed"],
"arrow-spacing": "error",
"accessor-pairs": "off",
"block-scoped-var": "warn",
"block-spacing": "error",
"brace-style": ["error", "1tbs"],
"callback-return": "off",
"camelcase": [
"warn",
{
"properties": "never"
}
],
"capitalized-comments": "off",
"class-methods-use-this": "off",
"comma-dangle": "off",
"comma-spacing": "error",
"comma-style": ["error", "last"],
"complexity": "off",
"computed-property-spacing": ["error", "never"],
"consistent-return": "off",
"consistent-this": "off",
"constructor-super": "error",
"curly": "error",
"default-case": "off",
"dot-location": ["warn", "property"],
"dot-notation": "error",
"eol-last": "error",
"eqeqeq": ["error", "smart"],
"func-call-spacing": "error",
"func-names": "off",
"func-name-matching": "off",
"func-style": "off",
"generator-star-spacing": "error",
"global-require": "off",
"guard-for-in": "warn",
"handle-callback-err": "off",
"id-blacklist": "off",
"id-length": "off",
"id-match": "off",
"indent": "off",
"init-declarations": "off",
"jsx-quotes": "error",
"key-spacing": "error",
"keyword-spacing": "error",
"linebreak-style": "error",
"line-comment-position": "off",
"lines-around-comment": "off",
"lines-around-directive": "off",
"max-depth": "warn",
"max-len": "warn",
"max-lines": ["warn", 500],
"max-nested-callbacks": "warn",
"max-params": "off",
"max-statements": "off",
"max-statements-per-line": "error",
"multiline-ternary": "off",
"new-cap": "off",
"new-parens": "error",
"newline-after-var": "off",
"newline-before-return": "off",
"newline-per-chained-call": "off",
"object-curly-newline": "off",
"object-curly-spacing": ["error", "always"],
"object-property-newline": [
"error",
{
"allowMultiplePropertiesPerLine": true
}
],
"object-shorthand": "off",
"one-var": "off",
"one-var-declaration-per-line": "off",
"operator-assignment": "warn",
"operator-linebreak": "error",
"padded-blocks": "off",
"prefer-arrow-callback": "off",
"prefer-const": "warn",
"prefer-destructuring": "warn",
"prefer-numeric-literals": "warn",
"prefer-promise-reject-errors": "warn",
"prefer-reflect": "off",
"prefer-rest-params": "error",
"prefer-spread": "error",
"prefer-template": "warn",
"quote-props": ["error", "as-needed"],
"quotes": [
"warn",
"single",
{
"avoidEscape": true
}
],
"radix": "off",
"require-await": "error",
"require-jsdoc": "off",
"require-yield": "error",
"rest-spread-spacing": "error",
"semi": ["error", "never"],
"semi-spacing": "error",
"sort-keys": "off",
"sort-imports": "off",
"sort-vars": "off",
"space-before-blocks": "error",
"space-before-function-paren": "off",
"space-in-parens": "error",
"space-infix-ops": "error",
"space-unary-ops": "error",
"spaced-comment": "error",
"strict": "off",
"symbol-description": "off",
"template-curly-spacing": "off",
"template-tag-spacing": "off",
"unicode-bom": "error",
"use-isnan": "error",
"valid-jsdoc": "off",
"valid-typeof": "error",
"vars-on-top": "warn",
"wrap-iife": "off",
"wrap-regex": "off",
"no-template-curly-in-string": "warn",
"yield-star-spacing": "error",
"yoda": "off",
"react/display-name": "error",
"react/forbid-component-props": "off",
"react/forbid-prop-types": "off",
"react/no-array-index-key": "error",
"react/no-children-prop": "error",
"react/no-danger": "warn",
"react/no-danger-with-children": "error",
"react/no-deprecated": "error",
"react/no-did-mount-set-state": "error",
"react/no-did-update-set-state": "error",
"react/no-direct-mutation-state": "error",
"react/no-find-dom-node": "error",
"react/no-is-mounted": "error",
"react/no-multi-comp": "error",
"react/no-render-return-value": "error",
"react/no-set-state": "off",
"react/no-string-refs": "error",
"react/no-unescaped-entities": "error",
"react/no-unknown-property": "error",
"react/no-unused-prop-types": "off",
"react/prefer-es6-class": "error",
"react/prefer-stateless-function": [1, { "ignorePureComponents": true }],
"react/prop-types": "off",
"react/react-in-jsx-scope": "error",
"react/react-default-props": "off",
"react/require-optimization": "off",
"react/require-render-return": "error",
"react/self-closing-comp": "error",
"react/sort-comp": "error",
"react/sort-prop-types": "error",
"react/style-prop-object": "off",
"react/jsx-boolean-value": ["error", "never"],
"react/jsx-closing-bracket-location": "error",
"react/jsx-curly-spacing": ["error", "never"],
"react/jsx-equals-spacing": "error",
"react/jsx-filename-extension": "error",
"react/jsx-first-prop-new-line": ["error", "multiline-multiprop"],
"react/jsx-handler-names": "error",
"react/jsx-indent": "off",
"react/jsx-indent-props": "off",
"react/jsx-key": "error",
"react/jsx-max-props-per-line": "off",
"react/jsx-no-bind": "off",
"react/jsx-no-comment-textnodes": "error",
"react/jsx-no-duplicate-props": "error",
"react/jsx-no-literals": "off",
"react/jsx-no-target-blank": "error",
"react/jsx-no-undef": "error",
"react/jsx-pascal-case": [
"error",
{
"ignore": ["_"]
}
],
"react/jsx-sort-props": "off",
"react/jsx-tag-spacing": [
"error",
{
"beforeSelfClosing": "always"
}
],
"react/jsx-uses-react": "error",
"react/jsx-uses-vars": "error",
"react/jsx-wrap-multilines": "error",
"import/no-unresolved": "error",
"import/named": "error",
"import/default": "error",
"import/namespace": "error",
"import/no-restricted-paths": "off",
"import/no-absolute-path": "error",
"import/no-dynamic-require": "off",
"import/no-internal-modules": "off",
"import/no-webpack-loader-syntax": "error",
"import/export": "error",
"import/no-named-as-default": "warn",
"import/no-named-as-default-member": "warn",
"import/no-deprecated": "warn",
"import/no-extraneous-dependencies": [
"warn",
{
"devDependencies": true,
"packageDir": "."
}
],
"import/no-mutable-exports": "warn",
"import/unambiguous": "off",
"import/no-commonjs": "off",
"import/no-amd": "error",
"import/no-nodejs-modules": "off",
"import/first": "warn",
"import/no-duplicates": "error",
"import/no-namespace": "off",
"import/extensions": "warn",
"import/order": [
"error",
{
"newlines-between": "always",
"groups": ["builtin", "external", ["parent", "sibling", "index"]]
}
],
"import/newline-after-import": [
"error",
{
"count": 1
}
],
"import/prefer-default-export": "off",
"import/max-dependencies": "off",
"import/no-unassigned-import": "off",
"import/no-named-default": "warn",
"import/no-anonymous-default-export": "off"
},
"plugins": ["react", "import", "prettier"],
"settings": {
"import/resolver": {
"node": {
"extensions": [".js", ".jsx"]
}
},
"react": {
"createClass": "createReactClass",
"pragma": "React",
"version": "16.13"
},
"propWrapperFunctions": [
"forbidExtraProps",
{"property": "freeze", "object": "Object"},
{"property": "myFavoriteWrapper"}
]
}
}

View File

@ -1,10 +0,0 @@
{
"tabWidth": 2,
"useTabs": false,
"semi": false,
"singleQuote": true,
"arrowParens": "avoid",
"trailingComma": "es5",
"bracketSpacing": true,
"jsxBracketSameLine": false
}

View File

@ -1,24 +0,0 @@
FROM node:16
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 NODE_ENV
ARG REACT_APP_API_URL
ENV NODE_ENV $NODE_ENV
ENV REACT_APP_API_URL $REACT_APP_API_URL
# install dependencies
COPY package.json /usr/src/app/package.json
RUN yarn install --silent
RUN yarn global add react-scripts
# copy source
COPY . /usr/src/app/

View File

@ -1,51 +0,0 @@
{
"name": "fittrackee_client",
"version": "0.4.9",
"private": true,
"dependencies": {
"@mapbox/togeojson": "^0.16.0",
"connected-react-router": "^6.9.1",
"date-fns": "^2.23.0",
"history": "^4.10.1",
"i18next": "^20.6.0",
"i18next-browser-languagedetector": "^6.1.2",
"i18next-xhr-backend": "^3.2.2",
"leaflet": "^1.7.1",
"luxon": "^2.0.2",
"object-hash": "^2.2.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-helmet": "^6.1.0",
"react-i18next": "^11.11.0",
"react-leaflet": "^3.2.2",
"react-redux": "^7.2.5",
"react-router-dom": "^5.3.0",
"react-scripts": "^4.0.3",
"react-timezone": "^2.4.0",
"recharts": "^1.8.5",
"redux": "^4.1.1",
"redux-thunk": "^2.3.0"
},
"scripts": {
"start": "PORT=$CLIENT_PORT react-scripts start",
"build": "NODE_ENV=production react-scripts build && rm -rf ../fittrackee/dist/* && cp -a build/. ../fittrackee/dist",
"eject": "react-scripts eject",
"lint": "node_modules/.bin/eslint --cache --ext .jsx --ext .js src",
"lint-fix": "node_modules/.bin/eslint --cache --ext .jsx --ext .js src --fix"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": [
">0.2%",
"not dead",
"not ie <= 11",
"not op_mini all"
],
"devDependencies": {
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-testcafe": "^0.2.1",
"prettier": "^2.4.1"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1012 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -1,62 +0,0 @@
<!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.4.1/css/bootstrap.min.css"
integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh"
crossorigin="anonymous"
>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/fork-awesome@1.1.7/css/fork-awesome.min.css"
integrity="sha256-gsmEoJAws/Kd3CjuOQzLie5Q3yshhvmo7YNtBG7aaEY="
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.6.0/dist/leaflet.css"
integrity="sha512-xwE/Az9zrjBIphAcBb3F6JVqxf46+CDLwfLMHloNu6KEQCAWi6HcDUbeOfBIptF7tcCzusKFjFw2yuvEpDL9wQ=="
crossorigin=""
>
<title>FitTrackee</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
<script
src="https://code.jquery.com/jquery-3.4.1.slim.min.js"
integrity="sha384-J6qa4849blE2+poT4WnyKhv5vZF5SrPo0iEjwBvKU7imGFAV0wwj1yYfoRSJoZ+n"
crossorigin="anonymous"
></script>
<script
src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js"
integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo"
crossorigin="anonymous"
></script>
<script
src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js"
integrity="sha384-wfSDF2E50Y2D1uUdj0O3uMBJnjuUD4Ih7YwaYd1iqfktj0Uod8GCExl3Og8ifwB6"
crossorigin="anonymous"
></script>
<script type="text/javascript">
$( document ).ready(function() {
$("li.nav-item").click(function(){
$("button.navbar-toggler").toggleClass("collapsed");
$("#navbarSupportedContent").toggleClass("show");
});
});
</script>
</body>
</html>

View File

@ -1,16 +0,0 @@
{
"short_name": "FitTrackee",
"name": "Self hosted workout/activity tracker",
"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",
"version": "0.2.0-beta"
}

View File

@ -1,47 +0,0 @@
import FitTrackeeGenericApi from '../fitTrackeeApi'
import { history } from '../index'
import { generateIds } from '../utils'
import { emptyMessages, setError } from './index'
export const setAppConfig = data => ({
type: 'SET_APP_CONFIG',
data,
})
export const setAppStats = data => ({
type: 'SET_APP_STATS',
data,
})
const SetAppErrors = messages => ({ type: 'APP_ERRORS', messages })
export const getAppData = target => dispatch =>
FitTrackeeGenericApi.getData(target)
.then(ret => {
if (ret.status === 'success') {
if (target === 'config') {
dispatch(setAppConfig(ret.data))
} else if (target === 'stats/all') {
dispatch(setAppStats(ret.data))
}
} else {
dispatch(setError(`application|${ret.message}`))
}
})
.catch(error => dispatch(setError(`application|${error}`)))
export const updateAppConfig = formData => dispatch => {
dispatch(emptyMessages())
FitTrackeeGenericApi.updateData('config', formData)
.then(ret => {
if (ret.status === 'success') {
dispatch(setAppConfig(ret.data))
history.push('/admin/application')
} else if (Array.isArray(ret.message)) {
dispatch(SetAppErrors(generateIds(ret.message)))
} else {
dispatch(setError(ret.message))
}
})
.catch(error => dispatch(setError(`application|${error}`)))
}

View File

@ -1,111 +0,0 @@
import i18next from 'i18next'
import FitTrackeeApi from '../fitTrackeeApi/index'
import { history } from '../index'
export const emptyMessages = () => ({
type: 'CLEAN_ALL_MESSAGES',
})
export const setData = (target, data) => ({
type: 'SET_DATA',
data,
target,
})
export const setPaginatedData = (target, data, pagination) => ({
type: 'SET_PAGINATED_DATA',
data,
pagination,
target,
})
export const setError = message => ({
type: 'SET_ERROR',
message,
})
export const setLanguage = language => ({
type: 'SET_LANGUAGE',
language,
})
export const setLoading = loading => ({
type: 'SET_LOADING',
loading,
})
export const updateSportsData = data => ({
type: 'UPDATE_SPORT_DATA',
data,
})
export const updateUsersData = data => ({
type: 'UPDATE_USER_DATA',
data,
})
export const getOrUpdateData =
(action, target, data, canDispatch = true) =>
dispatch => {
dispatch(setLoading(true))
if (data && data.id && target !== 'workouts' && isNaN(data.id)) {
dispatch(setLoading(false))
return dispatch(setError(`${target}|Incorrect id`))
}
dispatch(emptyMessages())
return FitTrackeeApi[action](target, data)
.then(ret => {
if (ret.status === 'success') {
if (canDispatch) {
if (target === 'users' && action === 'getData') {
return dispatch(
setPaginatedData(target, ret.data, ret.pagination)
)
}
dispatch(setData(target, ret.data))
} else if (action === 'updateData' && target === 'sports') {
dispatch(updateSportsData(ret.data.sports[0]))
} else if (action === 'updateData' && target === 'users') {
dispatch(updateUsersData(ret.data.users[0]))
}
} else {
dispatch(setError(`${target}|${ret.message || ret.status}`))
}
dispatch(setLoading(false))
})
.catch(error => {
dispatch(setLoading(false))
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 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 || ret.status}`))
}
})
.catch(error => dispatch(setError(`${target}|${error}`)))
}
export const updateLanguage = language => dispatch => {
i18next.changeLanguage(language).then(dispatch(setLanguage(language)))
}

View File

@ -1,13 +0,0 @@
import FitTrackeeGenericApi from '../fitTrackeeApi'
import { setData, setError } from './index'
export const getStats = (userName, type, data) => dispatch =>
FitTrackeeGenericApi.getData(`stats/${userName}/${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}`)))

View File

@ -1,174 +0,0 @@
import FitTrackeeGenericApi from '../fitTrackeeApi'
import FitTrackeeApi from '../fitTrackeeApi/auth'
import { history } from '../index'
import { generateIds } from '../utils'
import { getOrUpdateData, setError, updateLanguage } from './index'
import { getAppData } from './application'
const AuthError = message => ({ type: 'AUTH_ERROR', message })
const AuthErrors = messages => ({ type: 'AUTH_ERRORS', messages })
const PictureError = message => ({ type: 'PICTURE_ERROR', message })
const ProfileSuccess = profil => ({ type: 'PROFILE_SUCCESS', profil })
const ProfileError = message => ({ type: 'PROFILE_ERROR', message })
const ProfileUpdateError = message => ({
type: 'PROFILE_UPDATE_ERROR',
message,
})
export const logout = () => ({ type: 'LOGOUT' })
export const loadProfile = () => dispatch => {
if (window.localStorage.getItem('authToken')) {
return dispatch(getProfile())
}
return { type: 'LOGOUT' }
}
export const getProfile = () => dispatch =>
FitTrackeeGenericApi.getData('auth/profile')
.then(ret => {
if (ret.status === 'success') {
dispatch(getOrUpdateData('getData', 'sports'))
ret.data.isAuthenticated = true
if (ret.data.language) {
dispatch(updateLanguage(ret.data.language))
}
return dispatch(ProfileSuccess(ret.data))
}
return dispatch(ProfileError(ret.message))
})
.catch(error => {
throw error
})
export const loginOrRegisterOrPasswordReset = (target, formData) => dispatch =>
FitTrackeeApi.loginOrRegisterOrPasswordReset(target, formData)
.then(ret => {
if (ret.status === 'success') {
if (target === 'password/reset-request') {
return history.push({
pathname: '/password-reset/sent',
})
}
if (target === 'password/update') {
return history.push({
pathname: '/updated-password',
})
}
if (target === 'login' || target === 'register') {
window.localStorage.setItem('authToken', ret.auth_token)
if (target === 'register') {
dispatch(getAppData('config'))
}
return dispatch(getProfile())
}
}
return dispatch(AuthError(ret.message))
})
.catch(error => {
throw error
})
const RegisterFormControl = (formData, onlyPasswords = false) => {
const errMsg = []
if (
!onlyPasswords &&
(formData.username.length < 3 || formData.username.length > 12)
) {
errMsg.push('3 to 12 characters required for username.')
}
if (formData.password !== formData.password_conf) {
errMsg.push("Password and password confirmation don't match.")
}
if (formData.password.length < 8) {
errMsg.push('8 characters required for password.')
}
return errMsg
}
export const handleUserFormSubmit = (formData, formType) => dispatch => {
if (formType === 'register' || formType === 'password/update') {
const ret = RegisterFormControl(formData, formType === 'password/update')
if (ret.length > 0) {
return dispatch(AuthErrors(generateIds(ret)))
}
}
return dispatch(loginOrRegisterOrPasswordReset(formType, formData))
}
export const handleProfileFormSubmit = formData => dispatch => {
if (!formData.password === formData.password_conf) {
return dispatch(
ProfileUpdateError("Password and password confirmation don't match.")
)
}
delete formData.id
return FitTrackeeGenericApi.postData('auth/profile/edit', formData)
.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 FitTrackeeGenericApi.addDataWithFile('auth/picture', form)
.then(ret => {
if (ret.status === 'success') {
return dispatch(getProfile())
}
const msg =
ret.status === 413
? 'Error during picture update, file size exceeds max size.'
: ret.message
return dispatch(PictureError(msg))
})
.catch(error => {
throw error
})
}
export const deletePicture = () => dispatch =>
FitTrackeeApi.deletePicture()
.then(ret => {
if (ret.status === 204) {
return dispatch(getProfile())
}
return dispatch(PictureError(ret.message))
})
.catch(error => {
throw error
})
export const deleteUser =
(username, isAdmin = false) =>
dispatch =>
FitTrackeeGenericApi.deleteData('users', username)
.then(ret => {
if (ret.status === 204) {
dispatch(getAppData('config'))
if (isAdmin) {
history.push('/admin/users')
} else {
dispatch(logout())
history.push('/')
}
} else {
ret.json().then(r => dispatch(setError(`${r.message}`)))
}
})
.catch(error => dispatch(setError(`user|${error}`)))

View File

@ -1,192 +0,0 @@
import FitTrackeeGenericApi from '../fitTrackeeApi'
import { history } from '../index'
import { formatChartData } from '../utils/workouts'
import { setError, setLoading } from './index'
import { loadProfile } from './user'
export const pushWorkouts = workouts => ({
type: 'PUSH_WORKOUTS',
workouts,
})
export const removeWorkout = workoutId => ({
type: 'REMOVE_WORKOUT',
workoutId,
})
export const updateCalendar = workouts => ({
type: 'UPDATE_CALENDAR',
workouts,
})
export const setGpx = gpxContent => ({
type: 'SET_GPX',
gpxContent,
})
export const setChartData = chartData => ({
type: 'SET_CHART_DATA',
chartData,
})
export const addWorkout = form => dispatch =>
FitTrackeeGenericApi.addDataWithFile('workouts', form)
.then(ret => {
if (ret.status === 'created') {
if (ret.data.workouts.length === 0) {
dispatch(setError('workouts|no correct file.'))
} else if (ret.data.workouts.length === 1) {
dispatch(loadProfile())
history.push(`/workouts/${ret.data.workouts[0].id}`)
} else {
// ret.data.workouts.length > 1
dispatch(loadProfile())
history.push('/')
}
} else if (ret.status === 413) {
dispatch(
setError('workouts|File size is greater than the allowed size')
)
} else {
dispatch(setError(`workouts|${ret.message}`))
}
dispatch(setLoading(false))
})
.catch(error => {
dispatch(setLoading(false))
dispatch(setError(`workouts|${error}`))
})
export const addWorkoutWithoutGpx = form => dispatch =>
FitTrackeeGenericApi.addData('workouts/no_gpx', form)
.then(ret => {
if (ret.status === 'created') {
dispatch(loadProfile())
history.push(`/workouts/${ret.data.workouts[0].id}`)
} else {
dispatch(setError(`workouts|${ret.message}`))
}
})
.catch(error => dispatch(setError(`workouts|${error}`)))
export const getWorkoutGpx = workoutId => dispatch => {
if (workoutId) {
return FitTrackeeGenericApi.getData(`workouts/${workoutId}/gpx`)
.then(ret => {
if (ret.status === 'success') {
dispatch(setGpx(ret.data.gpx))
} else {
dispatch(setError(`workouts|${ret.message}`))
}
})
.catch(error => dispatch(setError(`workouts|${error}`)))
}
dispatch(setGpx(null))
}
export const getSegmentGpx = (workoutId, segmentId) => dispatch => {
if (workoutId) {
return FitTrackeeGenericApi.getData(
`workouts/${workoutId}/gpx/segment/${segmentId}`
)
.then(ret => {
if (ret.status === 'success') {
dispatch(setGpx(ret.data.gpx))
} else {
dispatch(setError(`workouts|${ret.message}`))
}
})
.catch(error => dispatch(setError(`workouts|${error}`)))
}
dispatch(setGpx(null))
}
export const getWorkoutChartData = workoutId => dispatch => {
if (workoutId) {
return FitTrackeeGenericApi.getData(`workouts/${workoutId}/chart_data`)
.then(ret => {
if (ret.status === 'success') {
dispatch(setChartData(formatChartData(ret.data.chart_data)))
} else {
dispatch(setError(`workouts|${ret.message}`))
}
})
.catch(error => dispatch(setError(`workouts|${error}`)))
}
dispatch(setChartData(null))
}
export const getSegmentChartData = (workoutId, segmentId) => dispatch => {
if (workoutId) {
return FitTrackeeGenericApi.getData(
`workouts/${workoutId}/chart_data/segment/${segmentId}`
)
.then(ret => {
if (ret.status === 'success') {
dispatch(setChartData(formatChartData(ret.data.chart_data)))
} else {
dispatch(setError(`workouts|${ret.message}`))
}
})
.catch(error => dispatch(setError(`workouts|${error}`)))
}
dispatch(setChartData(null))
}
export const deleteWorkout = id => dispatch =>
FitTrackeeGenericApi.deleteData('workouts', id)
.then(ret => {
if (ret.status === 204) {
Promise.resolve(dispatch(removeWorkout(id)))
.then(() => dispatch(loadProfile()))
.then(() => history.push('/'))
} else {
dispatch(setError(`workouts|${ret.status}`))
}
})
.catch(error => dispatch(setError(`workouts|${error}`)))
export const editWorkout = form => dispatch =>
FitTrackeeGenericApi.updateData('workouts', form)
.then(ret => {
if (ret.status === 'success') {
dispatch(loadProfile())
history.push(`/workouts/${ret.data.workouts[0].id}`)
} else {
dispatch(setError(`workouts|${ret.message}`))
}
dispatch(setLoading(false))
})
.catch(error => {
dispatch(setLoading(false))
dispatch(setError(`workouts|${error}`))
})
export const getMoreWorkouts = params => dispatch =>
FitTrackeeGenericApi.getData('workouts', params)
.then(ret => {
if (ret.status === 'success') {
if (ret.data.workouts.length > 0) {
dispatch(pushWorkouts(ret.data.workouts))
}
} else {
dispatch(setError(`workouts|${ret.message}`))
}
})
.catch(error => dispatch(setError(`workouts|${error}`)))
export const getMonthWorkouts = (from, to) => dispatch =>
FitTrackeeGenericApi.getData('workouts', {
from,
to,
order: 'desc',
per_page: 100,
})
.then(ret => {
if (ret.status === 'success') {
dispatch(updateCalendar(ret.data.workouts))
} else {
dispatch(setError(`workouts|${ret.message}`))
}
})
.catch(error => dispatch(setError(`workouts|${error}`)))

View File

@ -1,228 +0,0 @@
import React from 'react'
import { connect } from 'react-redux'
import Message from '../Common/Message'
import { getAppData, updateAppConfig } from '../../actions/application'
import { history } from '../../index'
import { getFileSizeInMB } from '../../utils'
class AdminApplication extends React.Component {
constructor(props, context) {
super(props, context)
this.state = {
formData: {},
}
}
componentDidMount() {
this.initForm()
}
componentDidUpdate(prevProps) {
if (this.props.appConfig !== prevProps.appConfig) {
this.initForm()
}
}
initForm() {
const { appConfig } = this.props
const formData = {}
Object.keys(appConfig).map(k =>
appConfig[k] === null
? (formData[k] = '')
: ['max_single_file_size', 'max_zip_file_size'].includes(k)
? (formData[k] = getFileSizeInMB(appConfig[k]))
: (formData[k] = appConfig[k])
)
this.setState({ formData })
}
handleFormChange(e) {
const { formData } = this.state
formData[e.target.name] = +e.target.value
this.setState(formData)
}
render() {
const {
isInEdition,
loadAppConfig,
message,
messages,
onHandleConfigFormSubmit,
t,
} = this.props
const { formData } = this.state
return (
<div>
{(message || messages) && (
<Message message={message} messages={messages} t={t} />
)}
{Object.keys(formData).length > 0 && (
<div className="row">
<div className="col-md-12">
<div className="card">
<div className="card-header">
<strong>
{t('administration:Application configuration')}
</strong>
</div>
<div className="card-body">
<form
className={`app-config-form ${
isInEdition ? '' : 'form-disabled'
}`}
onSubmit={e => {
e.preventDefault()
onHandleConfigFormSubmit(formData)
}}
>
<div className="form-group row">
<label
className="col-sm-6 col-form-label"
htmlFor="max_users"
>
{t(
// eslint-disable-next-line max-len
'administration:Max. number of active users'
)}
<sup>
<i
className="fa fa-question-circle"
aria-hidden="true"
title={t('administration:if 0, no limitation')}
/>
</sup>
:
</label>
<input
className="col-sm-5"
id="max_users"
name="max_users"
type="number"
min="0"
value={formData.max_users}
onChange={e => this.handleFormChange(e)}
/>
</div>
<div className="form-group row">
<label
className="col-sm-6 col-form-label"
htmlFor="max_single_file_size"
>
{t(
'administration:Max. size of uploaded files (in Mb)'
)}
:
</label>
<input
className="col-sm-5"
id="max_single_file_size"
name="max_single_file_size"
type="number"
step="0.1"
min="0"
value={formData.max_single_file_size}
onChange={e => this.handleFormChange(e)}
/>
</div>
<div className="form-group row">
<label
className="col-sm-6 col-form-label"
htmlFor="max_zip_file_size"
>
{t('administration:Max. size of zip archive (in Mb)')}:
</label>
<input
className="col-sm-5"
id="max_zip_file_size"
name="max_zip_file_size"
type="number"
step="0.1"
min="0"
value={formData.max_zip_file_size}
onChange={e => this.handleFormChange(e)}
/>
</div>
<div className="form-group row">
<label
className="col-sm-6 col-form-label"
htmlFor="gpx_limit_import"
>
{t('administration:Max. files of zip archive')}
</label>
<input
className="col-sm-5"
id="gpx_limit_import"
name="gpx_limit_import"
type="number"
min="0"
value={formData.gpx_limit_import}
onChange={e => this.handleFormChange(e)}
/>
</div>
{isInEdition ? (
<>
<input
type="submit"
className="btn btn-primary"
value={t('common:Submit')}
/>
<input
type="submit"
className="btn btn-secondary"
onClick={e => {
e.preventDefault()
loadAppConfig()
history.push('/admin/application')
}}
value={t('common:Cancel')}
/>
</>
) : (
<>
<input
type="submit"
className="btn btn-primary"
onClick={e => {
e.preventDefault()
history.push('/admin/application/edit')
}}
value={t('common:Edit')}
/>
<input
type="submit"
className="btn btn-secondary"
onClick={() => history.push('/admin')}
value={t('common:Back')}
/>
</>
)}
</form>
</div>
</div>
</div>
</div>
)}
</div>
)
}
}
export default connect(
state => ({
message: state.message,
messages: state.messages,
}),
dispatch => ({
loadAppConfig: () => {
dispatch(getAppData('config'))
},
onHandleConfigFormSubmit: formData => {
const data = Object.assign({}, formData)
data.max_single_file_size *= 1048576
data.max_zip_file_size *= 1048576
dispatch(updateAppConfig(data))
},
})
)(AdminApplication)

View File

@ -1,71 +0,0 @@
import React from 'react'
import { Link } from 'react-router-dom'
import AdminStats from './AdminStats'
export default function AdminDashboard(props) {
const { appConfig, t } = props
return (
<div className="card workout-card">
<div className="card-header">
<strong>{t('administration:Administration')}</strong>
</div>
<div className="card-body">
<AdminStats />
<br />
<dl className="admin-items">
<dt>
<Link
to={{
pathname: '/admin/application',
}}
>
{t('administration:Application')}
</Link>
</dt>
<dd>
{t(
'administration:Update application configuration ' +
'(maximum number of registered users, maximum files size).'
)}
<br />
<strong>
{t(
`administration:Registration is currently ${
appConfig.is_registration_enabled ? 'enabled' : 'disabled'
}.`
)}
</strong>
</dd>
<br />
<dt>
<Link
to={{
pathname: '/admin/sports',
}}
>
{t('administration:Sports')}
</Link>
</dt>
<dd>{t('administration:Enable/disable sports.')}</dd>
<br />
<dt>
<Link
to={{
pathname: '/admin/users',
}}
>
{t('administration:Users')}
</Link>
</dt>
<dd>
{t(
'administration:Add/remove admin rights, ' +
'delete user account.'
)}
</dd>
</dl>
</div>
</div>
)
}

View File

@ -1,142 +0,0 @@
import React from 'react'
import { connect } from 'react-redux'
import Message from '../Common/Message'
import { getOrUpdateData } from '../../actions'
import { history } from '../../index'
class AdminSports extends React.Component {
componentDidMount() {
this.props.loadSports()
}
render() {
const { message, sports, t, updateSport } = this.props
return (
<div>
{message && <Message message={message} t={t} />}
<div className="row">
<div className="col">
<div className="card">
<div className="card-header">
<strong>{t('administration:Sports')}</strong>
</div>
<div className="card-body">
{sports.length > 0 && (
<table className="table">
<thead>
<tr>
<th>{t('administration:id')}</th>
<th>{t('administration:Image')}</th>
<th>{t('administration:Label')}</th>
<th>{t('administration:Active')}</th>
<th>{t('administration:Actions')}</th>
</tr>
</thead>
<tbody>
{sports.map(sport => (
<tr key={sport.id}>
<td>
<span className="heading-span-absolute">
{t('administration:id')}
</span>
{sport.id}
</td>
<td>
<span className="heading-span-absolute">
{t('administration:Image')}
</span>
<img
className="admin-img"
src={sport.img ? sport.img : '/img/photo.png'}
alt="sport logo"
/>
</td>
<td>
<span className="heading-span-absolute">
{t('administration:Label')}
</span>
{t(`sports:${sport.label}`)}
</td>
<td>
<span className="heading-span-absolute">
{t('administration:Active')}
</span>
{sport.is_active ? (
<i
className="fa fa-check-square-o custom-fa"
aria-hidden="true"
data-toggle="tooltip"
/>
) : (
<i
className="fa fa-square-o custom-fa"
aria-hidden="true"
data-toggle="tooltip"
/>
)}
</td>
<td>
<span className="heading-span-absolute">
{t('administration:Actions')}
</span>
<input
type="submit"
className={`btn btn-${
sport.is_active ? 'dark' : 'primary'
} btn-sm`}
value={
sport.is_active
? t('administration:Disable')
: t('administration:Enable')
}
onClick={() =>
updateSport(sport.id, !sport.is_active)
}
/>
{sport.has_workouts && (
<span className="admin-message">
<i
className="fa fa-warning custom-fa"
aria-hidden="true"
/>
{t('administration:workouts exist')}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
<input
type="submit"
className="btn btn-secondary"
onClick={() => history.push('/admin/')}
value={t('common:Back')}
/>
</div>
</div>
</div>
</div>
</div>
)
}
}
export default connect(
state => ({
message: state.message,
sports: state.sports.data,
user: state.user,
}),
dispatch => ({
loadSports: () => {
dispatch(getOrUpdateData('getData', 'sports'))
},
updateSport: (sportId, isActive) => {
const data = { id: sportId, is_active: isActive }
dispatch(getOrUpdateData('updateData', 'sports', data, false))
},
})
)(AdminSports)

View File

@ -1,104 +0,0 @@
import React from 'react'
import { withTranslation } from 'react-i18next'
import { connect } from 'react-redux'
import { getAppData } from '../../actions/application'
import { getFileSize } from '../../utils'
class AdminStats extends React.Component {
componentDidMount() {
this.props.loadAppStats()
}
render() {
const { appStats, t } = this.props
const uploadDirSize = getFileSize(appStats.uploads_dir_size, false)
return (
<div className="row">
<div className="col-lg-3 col-md-6 col-sm-6">
<div className="card workout-card">
<div className="card-body row">
<div className="col-3">
<i className="fa fa-users fa-3x fa-color" />
</div>
<div className="col-9 text-right">
<div className="huge">
{appStats.users ? appStats.users : 0}
</div>
<div>{`${
appStats.users === 1
? t('administration:user')
: t('administration:users')
}`}</div>
</div>
</div>
</div>
</div>
<div className="col-lg-3 col-md-6 col-sm-6">
<div className="card workout-card">
<div className="card-body row">
<div className="col-3">
<i className="fa fa-tags fa-3x fa-color" />
</div>
<div className="col-9 text-right">
<div className="huge">
{appStats.sports ? appStats.sports : 0}
</div>
<div>{`${
appStats.sports === 1 ? t('common:sport') : t('common:sports')
}`}</div>
</div>
</div>
</div>
</div>
<div className="col-lg-3 col-md-6 col-sm-6">
<div className="card workout-card">
<div className="card-body row">
<div className="col-3">
<i className="fa fa-calendar fa-3x fa-color" />
</div>
<div className="col-9 text-right">
<div className="huge">
{appStats.workouts ? appStats.workouts : 0}
</div>
<div>{`${
appStats.workouts === 1
? t('common:workout')
: t('common:workouts')
}`}</div>
</div>
</div>
</div>
</div>
<div className="col-lg-3 col-md-6 col-sm-6">
<div className="card workout-card">
<div className="card-body row">
<div className="col-3">
<i className="fa fa-folder-open fa-3x fa-color" />
</div>
<div className="col-9 text-right">
<div className="huge">{uploadDirSize.size}</div>
<div>
{uploadDirSize.suffix} ({t('administration:uploads')})
</div>
</div>
</div>
</div>
</div>
</div>
)
}
}
export default withTranslation()(
connect(
state => ({
appStats: state.application.statistics,
}),
dispatch => ({
loadAppStats: () => {
dispatch(getAppData('stats/all'))
},
})
)(AdminStats)
)

View File

@ -1,257 +0,0 @@
import { format } from 'date-fns'
import React from 'react'
import { connect } from 'react-redux'
import { Link } from 'react-router-dom'
import Message from '../Common/Message'
import Pagination from '../Common/Pagination'
import { history } from '../../index'
import { getOrUpdateData } from '../../actions'
import {
apiUrl,
formatUrl,
sortOrders,
translateValues,
userFilters,
} from '../../utils'
class AdminUsers extends React.Component {
constructor(props, context) {
super(props, context)
this.state = {
page: null,
per_page: null,
order_by: 'created_at',
order: 'asc',
}
}
componentDidMount() {
this.props.loadUsers(this.initState())
}
componentDidUpdate(prevProps) {
if (prevProps.location.query !== this.props.location.query) {
this.props.loadUsers(this.props.location.query)
}
}
initState() {
const { query } = this.props.location
const newQuery = {
page: query.page,
per_page: query.per_page,
order_by: query.order_by ? query.order_by : 'created_at',
order: query.order ? query.order : 'asc',
}
this.setState(newQuery)
return newQuery
}
updatePage(key, value) {
const query = Object.assign({}, this.state)
query[key] = value
this.setState(query)
const url = formatUrl(this.props.location.pathname, query)
history.push(url)
}
render() {
const { authUser, location, message, t, pagination, updateUser, users } =
this.props
const translatedFilters = translateValues(t, userFilters)
const translatedSortOrders = translateValues(t, sortOrders)
return (
<div>
{message && <Message message={message} t={t} />}
<div className="container">
<div className="row">
<div className="col">
<div className="card">
<div className="card-header">
<strong>{t('administration:Users')}</strong>
</div>
<div className="card-body">
<div className="row user-filters">
<div className="col-lg-4 col-md-6 col-sm-12">
<label htmlFor="order_by">
{t('common:Sort by')}:{' '}
<select
id="order_by"
name="order_by"
value={this.state.order_by}
onChange={e =>
this.updatePage('order_by', e.target.value)
}
>
{translatedFilters.map(filter => (
<option key={filter.key} value={filter.key}>
{filter.label}
</option>
))}
</select>{' '}
</label>
</div>
<div className="col-lg-4 col-md-6 col-sm-12">
<label htmlFor="sort">
{t('common:Sort')}:{' '}
<select
id="sort"
name="sort"
value={this.state.order}
onChange={e =>
this.updatePage('order', e.target.value)
}
>
{translatedSortOrders.map(sort => (
<option key={sort.key} value={sort.key}>
{sort.label}
</option>
))}
</select>{' '}
</label>
</div>
</div>
<table className="table">
<thead>
<tr>
<th>#</th>
<th>{t('user:Username')}</th>
<th>{t('user:Email')}</th>
<th>{t('user:Registration Date')}</th>
<th>{t('workouts:Workouts')}</th>
<th>{t('user:Admin')}</th>
<th>{t('administration:Actions')}</th>
</tr>
</thead>
<tbody>
{users.map(user => (
<tr key={user.username}>
<td>
<span className="heading-span-absolute">#</span>
{user.picture === true ? (
<img
alt="Avatar"
src={`${apiUrl}users/${
user.username
}/picture?${Date.now()}`}
className="img-fluid App-nav-profile-img"
/>
) : (
<i
className="fa fa-user-circle-o fa-2x no-picture"
aria-hidden="true"
/>
)}
</td>
<td>
<span className="heading-span-absolute">
{t('user:Username')}
</span>
<Link to={`/users/${user.username}`}>
{user.username}
</Link>
</td>
<td>
<span className="heading-span-absolute">
{t('user:Email')}
</span>
{user.email}
</td>
<td>
<span className="heading-span-absolute">
{t('user:Registration Date')}
</span>
{format(
new Date(user.created_at),
'dd/MM/yyyy HH:mm'
)}
</td>
<td>
<span className="heading-span-absolute">
{t('workouts:Workouts')}
</span>
{user.nb_workouts}
</td>
<td>
<span className="heading-span-absolute">
{t('user:Admin')}
</span>
{user.admin ? (
<i
className="fa fa-check-square-o custom-fa"
aria-hidden="true"
data-toggle="tooltip"
/>
) : (
<i
className="fa fa-square-o custom-fa"
aria-hidden="true"
data-toggle="tooltip"
/>
)}
</td>
<td>
<span className="heading-span-absolute">
{t('administration:Actions')}
</span>
<input
type="submit"
className={`btn btn-${
user.admin ? 'dark' : 'primary'
} btn-sm`}
disabled={user.username === authUser.username}
value={
user.admin
? t('administration:Remove admin rights')
: t('administration:Add admin rights')
}
onClick={() =>
updateUser(user.username, !user.admin)
}
/>
</td>
</tr>
))}
</tbody>
</table>
<Pagination
pagination={pagination}
pathname={location.pathname}
query={this.state}
t={t}
/>
<input
type="submit"
className="btn btn-secondary"
onClick={() => history.push('/admin/')}
value={t('common:Back')}
/>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
}
export default connect(
state => ({
authUser: state.user,
location: state.router.location,
message: state.message,
pagination: state.users.pagination,
users: state.users.data,
}),
dispatch => ({
loadUsers: query => {
dispatch(getOrUpdateData('getData', 'users', query))
},
updateUser: (userName, isAdmin) => {
const data = { username: userName, admin: isAdmin }
dispatch(getOrUpdateData('updateData', 'users', data, false))
},
})
)(AdminUsers)

View File

@ -1,71 +0,0 @@
import React from 'react'
import { Helmet } from 'react-helmet'
import { withTranslation } from 'react-i18next'
import { connect } from 'react-redux'
import { Route, Switch } from 'react-router-dom'
import AdminApplication from './AdminApplication'
import AdminDashboard from './AdminDashboard'
import AdminSports from './AdminSports'
import AdminUsers from './AdminUsers'
import NotFound from './../Others/NotFound'
function Admin(props) {
const { appConfig, t, user } = props
return (
<>
<Helmet>
<title>FitTrackee - {t('administration:Administration')}</title>
</Helmet>
<div className="container dashboard">
{user.admin ? (
<Switch>
<Route
exact
path="/admin"
render={() => <AdminDashboard appConfig={appConfig} t={t} />}
/>
<Route
exact
path="/admin/application"
render={() => (
<AdminApplication
appConfig={appConfig}
t={t}
isInEdition={false}
/>
)}
/>
<Route
exact
path="/admin/application/edit"
render={() => (
<AdminApplication appConfig={appConfig} t={t} isInEdition />
)}
/>
<Route
exact
path="/admin/sports"
render={() => <AdminSports t={t} />}
/>
<Route
exact
path="/admin/users"
render={() => <AdminUsers t={t} />}
/>
<Route component={NotFound} />
</Switch>
) : (
<NotFound />
)}
</div>
</>
)
}
export default withTranslation()(
connect(state => ({
appConfig: state.application.config,
user: state.user,
}))(Admin)
)

View File

@ -1,826 +0,0 @@
html {
height: 100vh;
}
body {
background-color: #eaeaea;
margin: 0;
min-height: 100vh;
padding-bottom: 50px;
position: relative;
}
.App {
padding-bottom: 20px;
text-align: center;
}
.App-logo {
animation: App-logo-spin infinite 20s linear;
height: 80px;
}
.App-header {
background-color: #222;
height: 150px;
padding: 20px;
color: white;
}
.App-title {
font-size: 1.5em;
}
.App-intro {
font-size: large;
}
.App-nav-profile-img {
max-width: 32px;
max-height: 32px;
border-radius: 50%;
}
.App-profile-img-small {
max-width: 150px;
max-height: 150px;
border-radius: 50%;
}
@keyframes App-logo-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
a {
color: #40578a;
}
input[type="text"], textarea {
width: 100%;
}
label {
width: 100%;
}
.add-workout {
margin-top: 50px;
}
.add-workout-radio {
margin-right: 10px;
}
.admin-img {
max-width: 35px;
max-height: 35px;
}
.admin-items {
list-style-type: square;
}
.admin-message {
color: #7c7c7d;
font-size: 0.9em;
font-style: italic;
margin-left: 10px;
}
.app-config-form label {
font-weight: bold;
}
.btn {
margin-right: 10px;
}
.card {
text-align: left;
}
.chart {
font-size: 0.9em;
}
.chart-workouts {
margin-left: 60px;
}
.chart-arrows {
margin-top: 7px;
}
.chart-filters {
padding-bottom: 10px;
}
.chart-info {
font-size: 0.8em;
font-style: italic;
}
.chart-radio {
display: flex;
font-size: 0.9em;
}
.chart-radio label {
/* display: flex; */
}
.chart-radio input {
margin-right: 10px;
}
.chart-stats {
font-size: 0.8em;
}
.chart-title {
font-size: 1.1em;
margin-bottom: 10px;
}
.col-workout-logo{
padding-right: 0;
}
.custom-modal {
background-color: #fff;
border-radius: 5px;
max-width: 500px;
margin: 20% auto;
z-index: 1250;
}
.custom-modal-backdrop {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: rgba(0,0,0,0.3);
padding: 50px;
z-index: 1240;
}
.custom-fa {
margin-right: 5px;
}
.custom-fa-small {
font-size: 0.8em;
margin-left: -0.8em;
}
@media only screen and (max-width: 992px) {
.custom-fa-small {
font-size: 0.6em;
}
}
.custom-tooltip {
background-color: #fff;
border: 1px solid lightgrey;
padding: 10px;
}
.custom-tooltip p {
margin: 5px;
}
.custom-tooltip-label {
font-weight: bold;
}
.dashboard {
height: 100%;
}
.dashboard, .history {
margin-top: 30px;
}
.dropdown-wrapper {
width: 50px;
}
.dropdown-list {
background-color: #f8f9fa;
padding: 5px 0;
position: absolute;
text-align: left;
z-index: 10;
}
.dropdown-item {
cursor: default;
font-size: 0.9em;
}
.dropdown-item-selected {
font-weight: bold;
}
.dropdown-item-selected::after {
content: " ✔";
}
.error-message {
margin: 10px 0;
}
.fa-as-link {
cursor:pointer;
color: #40578a;
}
.fa-as-link:hover {
color: #0056b3;
}
.fa-question-circle {
color: #6c757d;
margin-left: 3px;
}
.fa-trophy {
color: goldenrod;
}
.fa-color {
color: #405976;
}
.footer {
background-color: #f8f9fa;
bottom: 0;
color: #8b8c8c;
font-size: 0.9em;
height: 50px;
line-height: 50px;
position: absolute;
width: 100%;
}
/* Chrome, Safari, Edge, Opera */
.form-disabled .form-group input::-webkit-outer-spin-button,
.form-disabled .form-group input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
.form-disabled .form-group input[type=number] {
-moz-appearance: textfield;
}
.form-disabled .form-group input{
border: none;
pointer-events: none;
}
.gpx-file {
height: inherit;
}
.huge {
font-size: 25px;
}
.i18n-flag svg {
height: 100%;
opacity: .9;
width: 15px;
}
.inactive-link {
color: lightgrey;
}
.leaflet-container {
height: 400px;
}
.loader {
animation: spin 2s linear infinite;
border: 8px solid #f3f3f3;
border-top: 8px solid #3498db;
border-radius: 50%;
height: 60px;
margin-left: 41%;
width: 60px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.map-attribution {
bottom: 0;
font-size: 11px;
position: absolute;
}
.map-attribution-text {
background-color: rgba(255, 255, 255, .5);
padding-left: 2px;
padding-right: 2px;
}
.no-picture {
color: #405976;
}
.page-title {
font-size: 2em;
margin: 1em;
text-align: center;
}
.password-forget {
margin: 10px;
font-size: .9em;
font-style: italic;
}
.radioLabel {
text-align: center;
}
.record-logo {
margin-right: 5px;
max-width: 25px;
max-height: 25px;
}
.record-table table, .record-table th, .record-table td{
font-size: 0.85em;
padding: 0.1em;
}
@media only screen and (min-width: 1200px) {
.record-table table, .record-table th, .record-table td{
font-size: 0.9em;
padding: 0.1em;
}
}
.remaining-chars {
font-size: 0.8em;
font-style: italic;
}
.sport-img {
max-width: 35px;
max-height: 35px;
}
.sport-img-medium {
max-width: 45px;
max-height: 45px;
}
.stats-disabled {
opacity: 0.3;
pointer-events: none;
}
.svg-icon {
fill: #405976;
height: 70px;
margin-left: auto;
margin-right: auto;
width: 70px;
}
.time-frames {
align-items: center;
display: inline-flex;
}
.time-frame label {
float: left;
padding: 0 5px;
}
.time-frame label input {
display: none;
}
.time-frame label span {
border: #a9a9a9 solid 1px;
border-radius: 9%;
color: #7b7b7b;
display: block;
font-size: 0.9em;
padding: 2px 6px;
text-align: center;
}
.time-frame input:checked + span {
background-color: #a9a9a9;
color: #ffffff;
}
.timezone-custom {
font-size: .9em !important;
height: inherit !important;
}
.timezone-custom input {
border: 0 !important;
padding: 5px 1px !important;
}
.timezone-custom ul {
background: white;
}
.timezone-picker {
padding: 0;
}
.timezone-picker-textfield {
font-size: 15px;
}
.unlink {
color: black;
}
.user-bio, .workout-notes {
white-space: pre-wrap;
}
.user-filters {
font-size: 0.9em;
margin-bottom: 10px;
}
.user-label {
font-weight: bold;
}
.weather-img {
max-width: 35px;
max-height: 35px;
}
.weather-img-small {
max-width: 20px;
max-height: 20px;
}
.weather-table {
margin-bottom: 0;
}
.weather-table table, .weather-table th, .weather-table td{
font-size: 0.9em;
padding: 0.1em;
}
.workouts-result {
font-size: 0.85em;
}
.workout-card {
margin-bottom: 15px;
}
.col-with-map {
font-size: .87em;
}
@media only screen and (min-width: 1200px) {
.col-with-map {
font-size: 1em;
}
}
.workout-details {
font-size: 0.95em;
}
.workout-date {
font-size: 0.75em;
}
.workout-filter {
font-size: 0.9em;
}
.workout-filter .col-2, .col-5{
padding: 0;
}
.workout-label {
font-size: 0.8em;
color: #666
}
.workout-logo {
margin: 0 5px;
max-width: 20px;
max-height: 20px;
}
.workout-map {
background-color: #eaeaea;
height: 225px;
width: 400px;
}
.workout-no-map {
background-color: #eaeaea;
color: #666666;
font-style: italic;
height: 400px;
line-height: 400px;
}
.workout-notes, .actvity-segments {
font-size: 0.9em;
font-style: italic;
margin-top: 10px;
padding: 5px;
}
.workout-page {
margin-top: 20px;
}
.workout-segments-list {
list-style: square;
}
.workout-sport {
margin-right: 1px;
max-width: 18px;
max-height: 18px;
}
.workout-title img, .workout-title .map-attribution-list {
display: none;
}
.workout-title img {
border: 1px solid lightgrey;
border-radius: 4px;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
display: none;
margin-left: 20px;
position: absolute;
z-index: 1000;
}
.workout-title .map-attribution-list {
display: none;
font-size: 11px;
margin-left: 20px;
position: absolute;
z-index: 1000;
}
.workout-title:hover img, .workout-title:hover .map-attribution-list {
display: block;
}
/* responsive table */
/* adapted from https://uglyduck.ca/making-tables-responsive-with-minimal-css/ */
.heading-span,
.heading-span-absolute {
background: #eee;
color: dimgrey;
display: none;
font-size: 10px;
font-weight: bold;
padding: 5px;
text-transform: uppercase;
top: 0;
left: 0;
}
.heading-span-absolute {
position: absolute;
display: none;
}
@media(max-width: 1024px) {
table thead {
left: -9999px;
position: absolute;
visibility: hidden;
}
table tr {
border-bottom: 0;
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin-bottom: 40px;
}
table td {
border: 1px solid lightgrey;
margin: 0 -1px -1px 0;
padding-top: 30px !important;
position: relative;
text-align: center;
width: 50%;
}
.record-tr {
margin-bottom: 0;
}
.record-td {
padding-top: 0 !important;
}
.heading-span, .heading-span-absolute {
display: block;
}
}
/* calendar */
:root {
--main-color: #1a8fff;
--text-color: #777;
--text-color-light: #ccc;
--border-color: #eee;
--bg-color: #f9f9f9;
--neutral-color: #fff;
}
.calendar .col-start {
justify-content: flex-start;
text-align: left;
}
.calendar .col-center {
justify-content: center;
text-align: center;
}
.calendar .col-end {
justify-content: flex-end;
text-align: right;
}
.calendar {
display: block;
position: relative;
width: 100%;
background: var(--neutral-color);
border: 1px solid var(--border-color);
}
.calendar .header {
text-transform: uppercase;
font-weight: 700;
/*font-size: 115%;*/
padding: 0.5em 0;
border-bottom: 1px solid var(--border-color);
}
.calendar .header .icon {
cursor: pointer;
transition: .15s ease-out;
}
.calendar .header .icon:hover {
transform: scale(1.75);
transition: .25s ease-out;
color: var(--main-color);
}
.calendar .header .icon:first-of-type {
margin-left: 1em;
}
.calendar .header .icon:last-of-type {
margin-right: 1em;
}
.calendar .days {
text-transform: uppercase;
font-weight: 400;
color: var(--text-color-light);
font-size: 70%;
padding: .75em 0;
border-bottom: 1px solid var(--border-color);
}
.calendar .body .cell {
position: relative;
height: 3em;
border-right: 1px solid var(--border-color);
background: var(--neutral-color);
}
.calendar .body .cell:hover {
background: var(--bg-color);
}
.calendar .body .selected {
border-left: 10px solid transparent;
border-image: linear-gradient(45deg, #1a8fff 0%,#53cbf1 40%);
}
.calendar .body .row {
border-bottom: 1px solid var(--border-color);
margin: 0;
}
.calendar .body .row:last-child {
border-bottom: none;
}
.calendar .body .cell:last-child {
border-right: none;
}
.calendar .body .cell .number {
position: absolute;
font-size: 82.5%;
line-height: 1;
top: .75em;
right: .75em;
font-weight: 700;
}
.calendar .body .disabled {
color: var(--text-color-light);
pointer-events: none;
}
.calendar .body .col {
flex-grow: 0;
flex-basis: calc(100%/7);
width: calc(100%/7);
}
.calendar .body .img-disabled {
opacity: .4;
}
.calendar .body .weekend {
background: #f5f5f5;
}
.calendar .body .today {
background: #eff1f3;
}
.calendar-workout,
.calendar-more {
display: none;
}
.calendar-more {
color: #405976;
font-size: .7em;
margin-left: 0.3em;
}
.calendar-display-more {
background: whitesmoke;
border-radius: 4px;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
margin-bottom: 10px;
padding: 10px 15px;
position: absolute;
min-width: 52px;
z-index: 1000;
}
.calendar-workout-more {
display: none;
}
@media only screen and (max-width: 992px) {
.calendar-workout:nth-child(-n+2),
.calendar-workout:nth-child(n+3) ~ .calendar-more,
.calendar-workout-more:nth-child(n+3) {
display: inline-block;
}
}
@media only screen and (min-width: 992px) and (max-width: 1200px) {
.calendar-workout:nth-child(-n+4),
.calendar-workout:nth-child(n+5) ~ .calendar-more,
.calendar-workout-more:nth-child(n+5) {
display: inline-block;
}
}
@media only screen and (min-width: 1200px) {
.calendar-workout:nth-child(-n+6),
.calendar-workout:nth-child(n+7) ~ .calendar-more,
.calendar-workout-more:nth-child(n+7) {
display: inline-block;
}
}

View File

@ -1,90 +0,0 @@
import React from 'react'
import { connect } from 'react-redux'
import { Route, Switch } from 'react-router-dom'
import './App.css'
import Admin from './Admin'
import Workout from './Workout'
import Workouts from './Workouts'
import CurrentUserProfile from './User/CurrentUserProfile'
import Dashboard from './Dashboard'
import Footer from './Footer'
import Logout from './User/Logout'
import NavBar from './NavBar'
import NotFound from './Others/NotFound'
import PasswordReset from './User/PasswordReset'
import ProfileEdit from './User/ProfileEdit'
import Statistics from './Statistics'
import UserForm from './User/UserForm'
import UserProfile from './User/UserProfile'
import { getAppData } from '../actions/application'
class App extends React.Component {
constructor(props) {
super(props)
this.props = props
}
componentDidMount() {
this.props.loadAppConfig()
}
render() {
return (
<div className="App">
<NavBar />
<Switch>
<Route exact path="/" component={Dashboard} />
<Route
exact
path="/register"
render={() => <UserForm formType={'register'} />}
/>
<Route
exact
path="/login"
render={() => <UserForm formType={'login'} />}
/>
<Route
exact
path="/password-reset"
render={() => <UserForm formType={'password reset'} />}
/>
<Route
exact
path="/password-reset/request"
render={() => <UserForm formType={'reset your password'} />}
/>
<Route
exact
path="/password-reset/sent"
render={() => <PasswordReset action={'sent'} />}
/>
<Route
exact
path="/updated-password"
render={() => <PasswordReset action={'updated'} />}
/>
<Route exact path="/password-reset/sent" component={PasswordReset} />
<Route exact path="/logout" component={Logout} />
<Route exact path="/profile/edit" component={ProfileEdit} />
<Route exact path="/profile" component={CurrentUserProfile} />
<Route exact path="/workouts/history" component={Workouts} />
<Route exact path="/workouts/statistics" component={Statistics} />
<Route exact path="/users/:userName" component={UserProfile} />
<Route path="/workouts" component={Workout} />
<Route path="/admin" component={Admin} />
<Route component={NotFound} />
</Switch>
<Footer />
</div>
)
}
}
export default connect(
() => ({}),
dispatch => ({
loadAppConfig: () => {
dispatch(getAppData('config'))
},
})
)(App)

View File

@ -1,9 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'
it('renders without crashing', () => {
const div = document.createElement('div')
ReactDOM.render(<App />, div)
})

View File

@ -1,44 +0,0 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
export default function CustomModal(props) {
const { t } = useTranslation()
return (
<div className="custom-modal-backdrop">
<div className="custom-modal">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">{props.title}</h5>
<button
type="button"
className="close"
aria-label="Close"
onClick={() => props.close()}
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div className="modal-body">
<p>{props.text}</p>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-primary"
onClick={() => props.confirm()}
>
{t('common:Yes')}
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => props.close()}
>
{t('common:No')}
</button>
</div>
</div>
</div>
</div>
)
}

View File

@ -1,45 +0,0 @@
import React from 'react'
import { withTranslation } from 'react-i18next'
import { connect } from 'react-redux'
class CustomTextArea extends React.Component {
constructor(props, context) {
super(props, context)
this.state = {
text: props.defaultValue ? props.defaultValue : '',
}
}
handleOnChange(changeEvent) {
this.setState({ text: changeEvent.target.value })
if (this.props.onTextChange) {
this.props.onTextChange(changeEvent)
}
}
render() {
const { charLimit, loading, name, t } = this.props
const { text } = this.state
return (
<>
<textarea
name={name}
defaultValue={text}
disabled={loading ? loading : false}
className="form-control input-lg"
maxLength={charLimit}
onChange={event => this.handleOnChange(event)}
/>
<div className="remaining-chars">
{t('common:remaining characters')}: {text.length}/{charLimit}
</div>
</>
)
}
}
export default withTranslation()(
connect(state => ({
loading: state.loading,
}))(CustomTextArea)
)

View File

@ -1,33 +0,0 @@
import React from 'react'
export default class Message extends React.PureComponent {
render() {
const { message, messages, t } = this.props
const singleMessage =
message === '' || !message
? ''
: message.split('|').length > 1
? `${t(`messages:${message.split('|')[0]}`)}: ${t(
`messages:${message.split('|')[1]}`
)}`
: t(`messages:${message}`)
return (
<div className="error-message">
{singleMessage !== '' && <code>{singleMessage}</code>}
{messages &&
messages.length > 0 &&
(messages.length === 1 ? (
<code>{messages[0].value}</code>
) : (
<code>
<ul>
{messages.map(msg => (
<li key={msg.id}>{t(`messages:${msg.value}`)}</li>
))}
</ul>
</code>
))}
</div>
)
}
}

View File

@ -1,18 +0,0 @@
import React from 'react'
import { Link } from 'react-router-dom'
export default class NoWorkouts extends React.PureComponent {
render() {
const { t } = this.props
return (
<div className="card text-center">
<div className="card-body">
{t('common:No workouts.')}{' '}
<Link to={{ pathname: '/workouts/add' }}>
{t('dashboard:Upload one !')}
</Link>
</div>
</div>
)
}
}

View File

@ -1,72 +0,0 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { formatUrl, rangePagination } from '../../utils'
export default class Pagination extends React.PureComponent {
getUrl(value) {
const { query, pathname } = this.props
const newQuery = Object.assign({}, query)
let page = query.page ? +query.page : 1
switch (value) {
case 'prev':
page -= 1
break
case 'next':
page += 1
break
default:
page = +value
}
newQuery.page = page
return formatUrl(pathname, newQuery)
}
render() {
const { pagination, t } = this.props
return (
<>
{pagination && Object.keys(pagination).length > 0 && (
<nav aria-label="Page navigation example">
<ul className="pagination justify-content-center">
<li
className={`page-item ${pagination.has_prev ? '' : 'disabled'}`}
>
<Link
className="page-link"
to={this.getUrl('prev')}
aria-disabled={!pagination.has_prev}
>
{t('common:Previous')}
</Link>
</li>
{rangePagination(pagination.pages).map(page => (
<li
key={page}
className={`page-item ${
page === pagination.page ? 'active' : ''
}`}
>
<Link className="page-link" to={this.getUrl(page)}>
{page}
</Link>
</li>
))}
<li
className={`page-item ${pagination.has_next ? '' : 'disabled'}`}
>
<Link
className="page-link"
to={this.getUrl('next')}
aria-disabled={!pagination.has_next}
>
{t('common:Next')}
</Link>
</li>
</ul>
</nav>
)}
</>
)
}
}

View File

@ -1,29 +0,0 @@
import React from 'react'
import { apiUrl } from '../../utils'
export default class StaticMap extends React.PureComponent {
render() {
const { display, workout } = this.props
return (
<div className={`workout-map${display === 'list' ? '-list' : ''}`}>
<img
src={`${apiUrl}workouts/map/${workout.map}?${Date.now()}`}
alt="workout map"
/>
<div className={`map-attribution${display === 'list' ? '-list' : ''}`}>
<span className="map-attribution-text">©</span>
<a
className="map-attribution-text"
href="http://www.openstreetmap.org/copyright"
target="_blank"
rel="noopener noreferrer"
>
OpenStreetMap
</a>
</div>
</div>
)
}
}

View File

@ -1,30 +0,0 @@
import React from 'react'
import { formatValue } from '../../../utils/stats'
/**
* @return {null}
*/
export default function CustomLabel(props) {
const { displayedData, x, y, width, value } = props
if (!value) {
return null
}
const radius = 10
const formattedValue = formatValue(displayedData, value)
return (
<g>
<text
x={x + width / 2}
y={y - radius}
fill="#666"
fontSize="11"
textAnchor="middle"
dominantBaseline="middle"
>
{formattedValue}
</text>
</g>
)
}

View File

@ -1,36 +0,0 @@
import React from 'react'
import { formatDuration } from '../../../utils/stats'
const formatValue = (displayedData, value) =>
displayedData === 'duration'
? formatDuration(value, true)
: ['distance', 'ascent', 'descent'].includes(displayedData)
? value.toFixed(2)
: value
/**
* @return {null}
*/
export default function CustomTooltip(props) {
const { active } = props
if (active) {
const { displayedData, payload, label } = props
let total = 0
payload.map(p => (total += p.value))
return (
<div className="custom-tooltip">
<p className="custom-tooltip-label">{label}</p>
{payload.map(p => (
<p key={p.name} style={{ color: p.fill }}>
{p.name}: {formatValue(displayedData, p.value)} {p.unit}
</p>
))}
{payload.length > 0 && (
<p>Total: {formatValue(displayedData, total)}</p>
)}
</div>
)
}
return null
}

View File

@ -1,122 +0,0 @@
import React from 'react'
import {
Bar,
BarChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { formatValue } from '../../../utils/stats'
import { workoutColors } from '../../../utils/workouts'
import CustomTooltip from './CustomTooltip'
import CustomLabel from './CustomLabel'
export default class StatsCharts extends React.PureComponent {
constructor(props, context) {
super(props, context)
this.state = {
displayedData: 'distance',
}
}
handleRadioChange(changeEvent) {
this.setState({
displayedData: changeEvent.target.name,
})
}
render() {
const { displayedData } = this.state
const { sports, stats, t, withElevation } = this.props
if (Object.keys(stats).length === 0) {
return t('common:No workouts.')
}
return (
<div className="chart-stats">
<div className="row chart-radio">
<label className="radioLabel col">
<input
type="radio"
name="distance"
checked={displayedData === 'distance'}
onChange={e => this.handleRadioChange(e)}
/>
{t('statistics:distance')}
</label>
<label className="radioLabel col">
<input
type="radio"
name="duration"
checked={displayedData === 'duration'}
onChange={e => this.handleRadioChange(e)}
/>
{t('statistics:duration')}
</label>
{withElevation && (
<>
<label className="radioLabel col">
<input
type="radio"
name="ascent"
checked={displayedData === 'ascent'}
onChange={e => this.handleRadioChange(e)}
/>
{t('statistics:ascent')}
</label>
<label className="radioLabel col">
<input
type="radio"
name="descent"
checked={displayedData === 'descent'}
onChange={e => this.handleRadioChange(e)}
/>
{t('statistics:descent')}
</label>
</>
)}
<label className="radioLabel col">
<input
type="radio"
name="workouts"
checked={displayedData === 'workouts'}
onChange={e => this.handleRadioChange(e)}
/>
{t('statistics:workouts')}
</label>
</div>
<ResponsiveContainer height={300}>
<BarChart data={stats[displayedData]} margin={{ top: 15, bottom: 0 }}>
<XAxis
dataKey="date"
interval={0} // to force to display all ticks
/>
<YAxis tickFormatter={value => formatValue(displayedData, value)} />
<Tooltip
content={<CustomTooltip displayedData={displayedData} />}
/>
{sports.map((s, i) => (
<Bar
// disable for now due to problems w/ CustomLabel
// see https://github.com/recharts/recharts/issues/829
isAnimationActive={false}
key={s.id}
dataKey={s.label}
stackId="a"
fill={workoutColors[i]}
label={
i === sports.length - 1 ? (
<CustomLabel displayedData={displayedData} />
) : (
''
)
}
name={t(`sports:${s.label}`)}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
)
}
}

View File

@ -1,88 +0,0 @@
import { format } from 'date-fns'
import React from 'react'
import { connect } from 'react-redux'
import { getStats } from '../../../actions/stats'
import { formatStats } from '../../../utils/stats'
import StatsChart from './StatsChart'
class Statistics extends React.PureComponent {
componentDidMount() {
this.updateData()
}
componentDidUpdate(prevProps) {
if (
(this.props.user.username &&
this.props.user.username !== prevProps.user.username) ||
this.props.statsParams !== prevProps.statsParams
) {
this.updateData()
}
}
updateData() {
if (this.props.user.username) {
this.props.loadWorkouts(
this.props.user.username,
this.props.user.weekm,
this.props.statsParams
)
}
}
render() {
const {
displayedSports,
sports,
statistics,
statsParams,
displayEmpty,
t,
user,
withElevation,
} = this.props
if (!displayEmpty && Object.keys(statistics).length === 0) {
return <span>{t('common:No workouts.')}</span>
}
const stats = formatStats(
statistics,
sports,
statsParams,
displayedSports,
user.weekm
)
return (
<StatsChart
sports={sports}
stats={stats}
t={t}
withElevation={withElevation}
/>
)
}
}
export default connect(
state => ({
sports: state.sports.data,
statistics: state.statistics.data,
user: state.user,
}),
dispatch => ({
loadWorkouts: (userName, weekm, data) => {
const dateFormat = 'yyyy-MM-dd'
// depends on user config (first day of week)
const time =
data.duration === 'week'
? `${data.duration}${weekm ? 'm' : ''}`
: data.duration
const params = {
from: format(data.start, dateFormat),
to: format(data.end, dateFormat),
time: time,
}
dispatch(getStats(userName, data.type, params))
},
})
)(Statistics)

View File

@ -1,198 +0,0 @@
// eslint-disable-next-line max-len
// source: https://blog.flowandform.agency/create-a-custom-calendar-in-react-3df1bfd0b728
import {
addDays,
addMonths,
endOfMonth,
endOfWeek,
format,
isSameDay,
isSameMonth,
isToday,
startOfMonth,
startOfWeek,
subMonths,
} from 'date-fns'
import { enGB, fr } from 'date-fns/locale'
import React from 'react'
import { connect } from 'react-redux'
import CalendarWorkouts from './CalendarWorkouts'
import { getMonthWorkouts } from '../../actions/workouts'
import { getDateWithTZ } from '../../utils'
const getStartAndEndMonth = (date, weekStartOnMonday) => {
const monthStart = startOfMonth(date)
const monthEnd = endOfMonth(date)
const weekStartsOn = weekStartOnMonday ? 1 : 0
return {
start: startOfWeek(monthStart, { weekStartsOn }),
end: endOfWeek(monthEnd),
}
}
class Calendar extends React.Component {
constructor(props, context) {
super(props, context)
const calendarDate = new Date()
this.state = {
currentMonth: calendarDate,
startDate: getStartAndEndMonth(calendarDate, props.weekm).start,
endDate: getStartAndEndMonth(calendarDate, props.weekm).end,
weekStartOnMonday: props.weekm,
}
}
componentDidMount() {
this.props.loadMonthWorkouts(this.state.startDate, this.state.endDate)
}
renderHeader(localeOptions) {
const dateFormat = 'MMM yyyy'
return (
<div className="header row flex-middle">
<div className="col col-start" onClick={() => this.handlePrevMonth()}>
<i className="fa fa-chevron-left" aria-hidden="true" />
</div>
<div className="col col-center">
<span>
{format(this.state.currentMonth, dateFormat, localeOptions)}
</span>
</div>
<div className="col col-end" onClick={() => this.handleNextMonth()}>
<i className="fa fa-chevron-right" aria-hidden="true" />
</div>
</div>
)
}
renderDays(localeOptions) {
const dateFormat = 'EEE'
const days = []
const { startDate } = this.state
for (let i = 0; i < 7; i++) {
days.push(
<div className="col col-center" key={i}>
{format(addDays(startDate, i), dateFormat, localeOptions)}
</div>
)
}
return <div className="days row">{days}</div>
}
filterWorkouts(day) {
const { workouts, user } = this.props
if (workouts) {
return workouts
.filter(act =>
isSameDay(getDateWithTZ(act.workout_date, user.timezone), day)
)
.reverse()
}
return []
}
renderCells() {
const { currentMonth, startDate, endDate, weekStartOnMonday } = this.state
const { sports } = this.props
const dateFormat = 'd'
const rows = []
let days = []
let day = startDate
let formattedDate = ''
while (day <= endDate) {
for (let i = 0; i < 7; i++) {
formattedDate = format(day, dateFormat)
const dayWorkouts = this.filterWorkouts(day)
const isDisabled = isSameMonth(day, currentMonth) ? '' : '-disabled'
const isWeekEnd = weekStartOnMonday
? [5, 6].includes(i)
: [0, 6].includes(i)
days.push(
<div
className={`col cell ${isWeekEnd ? ' weekend' : ''}${
isToday(day) ? ' today' : ''
}`}
key={day}
>
<div className={`img${isDisabled}`}>
<span className="number">{formattedDate}</span>
<CalendarWorkouts
dayWorkouts={dayWorkouts}
isDisabled={isDisabled}
sports={sports}
/>
</div>
</div>
)
day = addDays(day, 1)
}
rows.push(
<div className="row" key={day}>
{days}
</div>
)
days = []
}
return <div className="body">{rows}</div>
}
updateStateDate(calendarDate) {
const { start, end } = getStartAndEndMonth(
calendarDate,
this.state.weekStartOnMonday
)
this.setState({
currentMonth: calendarDate,
startDate: start,
endDate: end,
})
this.props.loadMonthWorkouts(start, end)
}
handleNextMonth() {
const calendarDate = addMonths(this.state.currentMonth, 1)
this.updateStateDate(calendarDate)
}
handlePrevMonth() {
const calendarDate = subMonths(this.state.currentMonth, 1)
this.updateStateDate(calendarDate)
}
render() {
const localeOptions = {
locale: this.props.language === 'fr' ? fr : enGB,
}
return (
<div className="card workout-card">
<div className="calendar">
{this.renderHeader(localeOptions)}
{this.renderDays(localeOptions)}
{this.renderCells()}
</div>
</div>
)
}
}
export default connect(
state => ({
workouts: state.calendarWorkouts.data,
language: state.language,
sports: state.sports.data,
user: state.user,
}),
dispatch => ({
loadMonthWorkouts: (start, end) => {
const dateFormat = 'yyyy-MM-dd'
dispatch(
getMonthWorkouts(format(start, dateFormat), format(end, dateFormat))
)
},
})
)(Calendar)

View File

@ -1,39 +0,0 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { recordsLabels } from '../../utils/workouts'
export default function CalendarWorkout(props) {
const { isDisabled, isMore, sportImg, workout } = props
return (
<Link
className={`calendar-workout${isMore}`}
to={`/workouts/${workout.id}`}
>
<>
<img
alt="workout sport logo"
className={`workout-sport ${isDisabled}`}
src={sportImg}
title={workout.title}
/>
{workout.records.length > 0 && (
<sup>
<i
className="fa fa-trophy custom-fa-small"
aria-hidden="true"
title={workout.records.map(
rec =>
` ${
recordsLabels.filter(
r => r.record_type === rec.record_type
)[0].label
}`
)}
/>
</sup>
)}
</>
</Link>
)
}

View File

@ -1,59 +0,0 @@
import React from 'react'
import CalendarWorkout from './CalendarWorkout'
export default class CalendarWorkouts extends React.Component {
constructor(props, context) {
super(props, context)
this.state = {
isHidden: true,
}
}
handleDisplayMore() {
this.setState({
isHidden: !this.state.isHidden,
})
}
render() {
const { dayWorkouts, isDisabled, sports } = this.props
const { isHidden } = this.state
return (
<div>
{dayWorkouts.map(act => (
<CalendarWorkout
key={act.id}
workout={act}
isDisabled={isDisabled}
isMore=""
sportImg={sports.filter(s => s.id === act.sport_id).map(s => s.img)}
/>
))}
{dayWorkouts.length > 2 && (
<i
className={`fa fa-${isHidden ? 'plus' : 'times'} calendar-more`}
aria-hidden="true"
onClick={() => this.handleDisplayMore()}
title="show more workouts"
/>
)}
{!isHidden && (
<div className="calendar-display-more">
{dayWorkouts.map(act => (
<CalendarWorkout
key={act.id}
workout={act}
isDisabled={isDisabled}
isMore="-more"
sportImg={sports
.filter(s => s.id === act.sport_id)
.map(s => s.img)}
/>
))}
</div>
)}
</div>
)
}
}

View File

@ -1,74 +0,0 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { formatRecord, translateSports } from '../../utils/workouts'
export default function RecordsCard(props) {
const { records, sports, t, user } = props
const translatedSports = translateSports(sports, t)
const recordsBySport = records.reduce((sportList, record) => {
const sport = translatedSports.find(s => s.id === record.sport_id)
if (sportList[sport.label] === void 0) {
sportList[sport.label] = {
img: sport.img,
records: [],
}
}
sportList[sport.label].records.push(formatRecord(record, user.timezone))
return sportList
}, {})
return (
<div className="card workout-card">
<div className="card-header">{t('workouts:Personal records')}</div>
<div className="card-body">
{Object.keys(recordsBySport).length === 0
? t('common:No records.')
: Object.keys(recordsBySport)
.sort()
.map(sportLabel => (
<div key={sportLabel}>
<span className="heading-span">
<img
alt={`${sportLabel} logo`}
className="record-logo"
src={recordsBySport[sportLabel].img}
/>
{sportLabel}
</span>
{/* eslint-disable-next-line max-len */}
<table className="table table-borderless table-sm record-table">
<thead>
<tr>
<th colSpan="3">
<img
alt={`${sportLabel} logo`}
className="record-logo"
src={recordsBySport[sportLabel].img}
/>
{sportLabel}
</th>
</tr>
</thead>
<tbody>
{recordsBySport[sportLabel].records.map(rec => (
<tr className="record-tr" key={rec.id}>
<td className="record-td">
{t(`workouts:${rec.record_type}`)}
</td>
<td className="record-td text-right">{rec.value}</td>
<td className="record-td text-right">
<Link to={`/workouts/${rec.workout_id}`}>
{rec.workout_date}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</div>
</div>
)
}

View File

@ -1,34 +0,0 @@
import { endOfMonth, startOfMonth } from 'date-fns'
import React from 'react'
import Stats from '../Common/Stats'
export default class Statistics extends React.Component {
constructor(props, context) {
super(props, context)
const date = new Date()
this.state = {
start: startOfMonth(date),
end: endOfMonth(date),
duration: 'week',
type: 'by_time',
}
}
render() {
const { t } = this.props
return (
<div className="card workout-card">
<div className="card-header">{t('dashboard:This month')}</div>
<div className="card-body">
<Stats
displayEmpty={false}
statsParams={this.state}
t={t}
withElevation={false}
/>
</div>
</div>
)
}
}

View File

@ -1,78 +0,0 @@
import React from 'react'
export default function UserStatistics(props) {
const { t, user } = props
const days = user.total_duration.match(/day/g)
? `${user.total_duration.split(' ')[0]} ${
user.total_duration.match(/days/g) ? t('common:days') : t('common:day')
}`
: `0 ${t('common:days')},`
let duration = user.total_duration.match(/day/g)
? user.total_duration.split(', ')[1]
: user.total_duration
duration = `${duration.split(':')[0]}h ${duration.split(':')[1]}min`
return (
<div className="row">
<div className="col-lg-3 col-md-6 col-sm-6">
<div className="card workout-card">
<div className="card-body row">
<div className="col-3">
<i className="fa fa-calendar fa-3x fa-color" />
</div>
<div className="col-9 text-right">
<div className="huge">{user.nb_workouts}</div>
<div>{`${
user.nb_workouts === 1
? t('common:workout')
: t('common:workouts')
}`}</div>
</div>
</div>
</div>
</div>
<div className="col-lg-3 col-md-6 col-sm-6">
<div className="card workout-card">
<div className="card-body row">
<div className="col-3">
<i className="fa fa-road fa-3x fa-color" />
</div>
<div className="col-9 text-right">
<div className="huge">
{Number(user.total_distance).toFixed(2)}
</div>
<div>km</div>
</div>
</div>
</div>
</div>
<div className="col-lg-3 col-md-6 col-sm-6">
<div className="card workout-card">
<div className="card-body row">
<div className="col-3">
<i className="fa fa-clock-o fa-3x fa-color" />
</div>
<div className="col-9 text-right">
<div className="huge">{days}</div>
<div>{duration}</div>
</div>
</div>
</div>
</div>
<div className="col-lg-3 col-md-6 col-sm-6">
<div className="card workout-card">
<div className="card-body row">
<div className="col-3">
<i className="fa fa-tags fa-3x fa-color" />
</div>
<div className="col-9 text-right">
<div className="huge">{user.nb_sports}</div>
<div>{`${
user.nb_sports === 1 ? t('common:sport') : t('common:sports')
}`}</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -1,70 +0,0 @@
import { format } from 'date-fns'
import React from 'react'
import { Link } from 'react-router-dom'
import StaticMap from '../Common/StaticMap'
import { getDateWithTZ } from '../../utils'
export default function WorkoutCard(props) {
const { sports, t, user, workout } = props
return (
<div className="card workout-card text-center">
<div className="card-header">
<Link to={`/workouts/${workout.id}`}>
{sports
.filter(sport => sport.id === workout.sport_id)
.map(sport => t(`sports:${sport.label}`))}{' '}
-{' '}
{format(
getDateWithTZ(workout.workout_date, user.timezone),
'dd/MM/yyyy HH:mm'
)}
</Link>
</div>
<div className="card-body">
<div className="row">
{workout.map && (
<div className="col">
<StaticMap workout={workout} />
</div>
)}
<div className={`col${workout.map ? ' col-with-map' : ''}`}>
<p>
<i className="fa fa-clock-o" aria-hidden="true" />{' '}
{t('workouts:Duration')}: {workout.moving}
{workout.map ? (
<span>
<br />
<br />
</span>
) : (
' - '
)}
<i className="fa fa-road" aria-hidden="true" />{' '}
{t('workouts:Distance')}: {workout.distance} km
<br />
</p>
{workout.min_alt && workout.max_alt && (
<p>
<i className="fi-mountains custom-fa" />
{t('workouts:Min. altitude')}: {workout.min_alt}m
<br />
{t('workouts:Max. altitude')}: {workout.max_alt}m
<br />
</p>
)}
{workout.ascent && workout.descent && (
<p>
<i className="fa fa-location-arrow custom-fa" />
{t('workouts:Ascent')}: {workout.ascent}m
<br />
{t('workouts:Descent')}: {workout.descent}m
</p>
)}
</div>
</div>
</div>
</div>
)
}

View File

@ -1,114 +0,0 @@
import React from 'react'
import { Helmet } from 'react-helmet'
import { withTranslation } from 'react-i18next'
import { connect } from 'react-redux'
import Calendar from './Calendar'
import Message from '../Common/Message'
import NoWorkouts from '../Common/NoWorkouts'
import Records from './Records'
import Statistics from './Statistics'
import UserStatistics from './UserStatistics'
import WorkoutCard from './WorkoutCard'
import { getOrUpdateData } from '../../actions'
import { getMoreWorkouts } from '../../actions/workouts'
class DashBoard extends React.Component {
constructor(props, context) {
super(props, context)
this.state = {
page: 1,
}
}
componentDidMount() {
this.props.loadWorkouts()
}
render() {
const { loadMoreWorkouts, message, records, sports, t, user, workouts } =
this.props
const paginationEnd =
workouts.length > 0
? workouts[workouts.length - 1].previous_workout === null
: true
const { page } = this.state
return (
<div>
<Helmet>
<title>FitTrackee - {t('common:Dashboard')}</title>
</Helmet>
{message ? (
<Message message={message} t={t} />
) : (
workouts &&
user.total_duration &&
sports.length > 0 && (
<div className="container dashboard">
<UserStatistics user={user} t={t} />
<div className="row">
<div className="col-md-4">
<Statistics t={t} />
<Records
t={t}
records={records}
sports={sports}
user={user}
/>
</div>
<div className="col-md-8">
<Calendar weekm={user.weekm} />
{workouts.length > 0 ? (
workouts.map(workout => (
<WorkoutCard
workout={workout}
key={workout.id}
sports={sports}
t={t}
user={user}
/>
))
) : (
<NoWorkouts t={t} />
)}
{!paginationEnd && (
<input
type="submit"
className="btn btn-default btn-md btn-block"
value="Load more workouts"
onClick={() => {
loadMoreWorkouts(page + 1)
this.setState({ page: page + 1 })
}}
/>
)}
</div>
</div>
</div>
)
)}
</div>
)
}
}
export default withTranslation()(
connect(
state => ({
workouts: state.workouts.data,
message: state.message,
records: state.records.data,
sports: state.sports.data,
user: state.user,
}),
dispatch => ({
loadWorkouts: () => {
dispatch(getOrUpdateData('getData', 'workouts', { page: 1 }))
dispatch(getOrUpdateData('getData', 'records'))
},
loadMoreWorkouts: page => {
dispatch(getMoreWorkouts({ page }))
},
})
)(DashBoard)
)

View File

@ -1,36 +0,0 @@
import React from 'react'
import { version } from './../../utils'
export default function Footer() {
return (
<footer className="footer">
<div className="container">
<strong>FitTrackee</strong> v{version} -{' '}
<a
href="https://github.com/SamR1/FitTrackee"
target="_blank"
rel="noopener noreferrer"
>
source code
</a>{' '}
under{' '}
<a
href="https://choosealicense.com/licenses/gpl-3.0/"
target="_blank"
rel="noopener noreferrer"
>
GPLv3
</a>{' '}
license -{' '}
<a
href="https://samr1.github.io/FitTrackee/"
target="_blank"
rel="noopener noreferrer"
>
documentation
</a>
</div>
</footer>
)
}

View File

@ -1,75 +0,0 @@
import React, { Component } from 'react'
import { connect } from 'react-redux'
import { ReactComponent as EnFlag } from '../../images/flags/en.svg'
import { ReactComponent as FrFlag } from '../../images/flags/fr.svg'
import { updateLanguage } from '../../actions/index'
export const languages = [
{
name: 'en',
selected: true,
flag: <EnFlag />,
},
{
name: 'fr',
selected: false,
flag: <FrFlag />,
},
]
class Dropdown extends Component {
constructor(props) {
super(props)
this.state = {
isOpen: false,
}
}
toggleDropdown() {
this.setState(prevState => ({
isOpen: !prevState.isOpen,
}))
}
render() {
const { isOpen } = this.state
const { language: selected, onUpdateLanguage } = this.props
return (
<div className="dropdown-wrapper" onClick={() => this.toggleDropdown()}>
<ul className="dropdown-list i18n-flag">
{languages
.filter(language =>
isOpen ? language : language.name === selected
)
.map(language => (
<li
className={`dropdown-item${
language.name === selected && isOpen
? ' dropdown-item-selected'
: ''
}`}
key={language.name}
onClick={() => onUpdateLanguage(language.name, selected)}
>
{language.flag} {language.name}
</li>
))}
</ul>
</div>
)
}
}
export default connect(
state => ({
language: state.language,
}),
dispatch => ({
onUpdateLanguage: (lang, selected) => {
if (lang !== selected) {
dispatch(updateLanguage(lang))
}
},
})
)(Dropdown)

View File

@ -1,173 +0,0 @@
import React from 'react'
import { connect } from 'react-redux'
import { withTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import LanguageDropdown from './LanguageDropdown'
import { apiUrl } from '../../utils'
class NavBar extends React.PureComponent {
render() {
const { admin, isAuthenticated, picture, t, username } = this.props
return (
<header>
<nav className="navbar navbar-expand-lg navbar-light bg-light">
<div className="container">
<span className="navbar-brand">FitTrackee</span>
<button
className="navbar-toggler"
type="button"
data-toggle="collapse"
data-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span className="navbar-toggler-icon" />
</button>
<div
className="collapse navbar-collapse"
id="navbarSupportedContent"
>
<ul className="navbar-nav mr-auto">
<li className="nav-item">
<Link
className="nav-link"
to={{
pathname: '/',
}}
>
{t('common:Dashboard')}
</Link>
</li>
{isAuthenticated && (
<li className="nav-item">
<Link
className="nav-link"
to={{
pathname: '/workouts/history',
}}
>
{t('Workouts')}
</Link>
</li>
)}
{isAuthenticated && (
<li className="nav-item">
<Link
className="nav-link"
to={{
pathname: '/workouts/statistics',
}}
>
{t('common:Statistics')}
</Link>
</li>
)}
{admin && (
<li className="nav-item">
<Link
className="nav-link"
to={{
pathname: '/admin',
}}
>
Admin
</Link>
</li>
)}
{isAuthenticated && (
<li className="nav-item">
<Link
className="nav-link"
to={{
pathname: '/workouts/add',
}}
>
<strong>{t('common:Add workout')}</strong>
</Link>
</li>
)}
</ul>
{/* prettier-ignore */}
<ul
className="navbar-nav flex-row ml-md-auto d-none d-md-flex"
>
{!isAuthenticated && (
<li className="nav-item">
<Link
className="nav-link"
to={{
pathname: '/register',
}}
>
{t('user:Register')}
</Link>
</li>
)}
{!isAuthenticated && (
<li className="nav-item">
<Link
className="nav-link"
to={{
pathname: '/login',
}}
>
{t('user:Login')}
</Link>
</li>
)}
{isAuthenticated && (
<>
{picture === true ? (
<img
alt="Avatar"
src={`${apiUrl}users/${username}/picture?${Date.now()}`}
className="img-fluid App-nav-profile-img"
/>
) : (
<i
className="fa fa-user-circle-o fa-2x no-picture"
aria-hidden="true"
/>
)}
<li className="nav-item">
<Link
className="nav-link"
to={{
pathname: '/profile',
}}
>
{username}
</Link>
</li>
<li className="nav-item">
<Link
className="nav-link"
to={{
pathname: '/logout',
}}
>
{t('user:Logout')}
</Link>
</li>
</>
)}
<li><LanguageDropdown /></li>
</ul>
</div>
</div>
</nav>
</header>
)
}
}
export default withTranslation()(
connect(({ user }) => ({
admin: user.admin,
isAuthenticated: user.isAuthenticated,
picture: user.picture,
username: user.username,
}))(NavBar)
)

View File

@ -1,24 +0,0 @@
import React from 'react'
import { Helmet } from 'react-helmet'
export default function AccessDenied(props) {
const { t } = props
return (
<div>
<Helmet>
<title>FitTrackee - {t('Access denied')}</title>
</Helmet>
<div className="row">
<div className="col-2" />
<div className="card col-8">
<div className="card-body">
<div className="text-center">
{t("You don't have permissions to access this page.")}
</div>
</div>
</div>
<div className="col-2" />
</div>
</div>
)
}

View File

@ -1,15 +0,0 @@
import React from 'react'
import { Helmet } from 'react-helmet'
import { useTranslation } from 'react-i18next'
export default function NotFound() {
const { t } = useTranslation()
return (
<div>
<Helmet>
<title>fittrackee - 404</title>
</Helmet>
<h1 className="page-title">{t('Page not found')}</h1>
</div>
)
}

View File

@ -1,11 +0,0 @@
import React from 'react'
import { Provider } from 'react-redux'
import { ConnectedRouter } from 'connected-react-router'
export default function Root({ store, history, children }) {
return (
<Provider store={store}>
<ConnectedRouter history={history}>{children}</ConnectedRouter>
</Provider>
)
}

View File

@ -1,209 +0,0 @@
import {
endOfMonth,
endOfWeek,
endOfYear,
startOfMonth,
startOfYear,
startOfWeek,
addMonths,
addWeeks,
addYears,
subMonths,
subWeeks,
subYears,
} from 'date-fns'
import React from 'react'
import { Helmet } from 'react-helmet'
import { withTranslation } from 'react-i18next'
import { connect } from 'react-redux'
import NoWorkouts from '../Common/NoWorkouts'
import Stats from '../Common/Stats'
import { workoutColors, translateSports } from '../../utils/workouts'
const durations = ['week', 'month', 'year']
class Statistics extends React.Component {
constructor(props, context) {
super(props, context)
const date = new Date()
this.state = {
displayedSports: props.sports.map(sport => sport.id),
statsParams: {
start: startOfMonth(subMonths(date, 11)),
end: endOfMonth(date),
duration: 'month',
type: 'by_time',
},
}
}
componentDidUpdate(prevProps) {
if (this.props.sports !== prevProps.sports) {
this.updateDisplayedSports()
}
}
updateDisplayedSports() {
const { sports } = this.props
this.setState({ displayedSports: sports.map(sport => sport.id) })
}
handleOnChangeDuration(e) {
const duration = e.target.name
const date = new Date()
const start =
duration === 'year'
? startOfYear(subYears(date, 9))
: duration === 'week'
? startOfMonth(subMonths(date, 2))
: startOfMonth(subMonths(date, 11))
const end =
duration === 'year'
? endOfYear(date)
: duration === 'week'
? endOfWeek(date)
: endOfMonth(date)
this.setState({ statsParams: { duration, end, start, type: 'by_time' } })
}
handleOnChangeSports(sportId) {
const { displayedSports } = this.state
if (displayedSports.includes(sportId)) {
this.setState({
displayedSports: displayedSports.filter(s => s !== sportId),
})
} else {
this.setState({ displayedSports: displayedSports.concat([sportId]) })
}
}
handleOnClickArrows(forward) {
const { start, end, duration } = this.state.statsParams
let newStart, newEnd
if (forward) {
newStart =
duration === 'year'
? startOfYear(subYears(start, 1))
: duration === 'week'
? startOfWeek(subWeeks(start, 1))
: startOfMonth(subMonths(start, 1))
newEnd =
duration === 'year'
? endOfYear(subYears(end, 1))
: duration === 'week'
? endOfWeek(subWeeks(end, 1))
: endOfMonth(subMonths(end, 1))
} else {
newStart =
duration === 'year'
? startOfYear(addYears(start, 1))
: duration === 'week'
? startOfWeek(addWeeks(start, 1))
: startOfMonth(addMonths(start, 1))
newEnd =
duration === 'year'
? endOfYear(addYears(end, 1))
: duration === 'week'
? endOfWeek(addWeeks(end, 1))
: endOfMonth(addMonths(end, 1))
}
this.setState({
statsParams: { duration, end: newEnd, start: newStart, type: 'by_time' },
})
}
render() {
const { displayedSports, statsParams } = this.state
const { sports, t, user } = this.props
const translatedSports = translateSports(
sports.filter(sport => user.sports_list.includes(sport.id)),
t
)
return (
<>
<Helmet>
<title>FitTrackee - {t('statistics:Statistics')}</title>
</Helmet>
<div className="container dashboard">
<div className="card workout-card">
<div className="card-header">{t('statistics:Statistics')}</div>
<div
className={`card-body${
user.nb_workouts === 0 ? ' stats-disabled' : ''
}`}
>
<div className="chart-filters row">
<div className="col chart-arrows">
<p className="text-center">
<i
className="fa fa-chevron-left"
aria-hidden="true"
onClick={() => this.handleOnClickArrows(true)}
/>
</p>
</div>
<div className="col-md-3 time-frames justify-content-around">
{durations.map(d => (
<div className="time-frame" key={d}>
<label>
<input
type="radio"
id={d}
name={d}
checked={d === statsParams.duration}
onChange={e => this.handleOnChangeDuration(e)}
/>
<span>{t(`statistics:${d}`)}</span>
</label>
</div>
))}
</div>
<div className="col chart-arrows">
<p className="text-center">
<i
className="fa fa-chevron-right"
aria-hidden="true"
onClick={() => this.handleOnClickArrows(false)}
/>
</p>
</div>
</div>
<Stats
displayEmpty
displayedSports={displayedSports}
statsParams={statsParams}
t={t}
withElevation
/>
<div className="row chart-workouts">
{translatedSports.map(sport => (
<label className="col workout-label" key={sport.id}>
<input
type="checkbox"
checked={displayedSports.includes(sport.id)}
name={sport.label}
onChange={() => this.handleOnChangeSports(sport.id)}
/>
<span style={{ color: workoutColors[sport.id - 1] }}>
{` ${sport.label}`}
</span>
</label>
))}
</div>
</div>
</div>
{user.nb_workouts === 0 && <NoWorkouts t={t} />}
</div>
</>
)
}
}
export default withTranslation()(
connect(state => ({
sports: state.sports.data,
user: state.user,
}))(Statistics)
)

View File

@ -1,19 +0,0 @@
import React from 'react'
import { withTranslation } from 'react-i18next'
import { connect } from 'react-redux'
import ProfileDetail from './ProfileDetail'
function CurrentUserProfile({ t, user }) {
return (
<div>
<ProfileDetail editable t={t} user={user} />
</div>
)
}
export default withTranslation()(
connect(state => ({
user: state.user,
}))(CurrentUserProfile)
)

View File

@ -1,125 +0,0 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { Helmet } from 'react-helmet'
import { Link } from 'react-router-dom'
import { history } from '../../index'
export default function Form(props) {
const { t } = useTranslation()
const pageTitle = `user:${props.formType
.charAt(0)
.toUpperCase()}${props.formType.slice(1)}`
return (
<div>
<Helmet>
<title>FitTrackee - {t(`user:${props.formType}`)}</title>
</Helmet>
<h1 className="page-title">{t(pageTitle)}</h1>
<div className="container">
<div className="row">
<div className="col-md-3" />
<div className="col-md-6">
<br />
{props.formType === 'register' && !props.isRegistrationAllowed ? (
<div className="card">
<div className="card-body">Registration is disabled.</div>
<div className="card-body">
<button
type="submit"
className="btn btn-secondary btn-lg btn-block"
onClick={() => history.go(-1)}
>
Back
</button>
</div>
</div>
) : (
<>
<form
onSubmit={event =>
props.handleUserFormSubmit(event, props.formType)
}
>
{props.formType === 'register' && (
<div className="form-group">
<input
className="form-control input-lg"
name="username"
placeholder={t('user:Enter a username')}
required
type="text"
value={props.userForm.username}
onChange={props.onHandleFormChange}
/>
</div>
)}
{props.formType !== 'password reset' && (
<div className="form-group">
<input
className="form-control input-lg"
name="email"
placeholder={t('user:Enter an email address')}
required
type="email"
value={props.userForm.email}
onChange={props.onHandleFormChange}
/>
</div>
)}
{props.formType !== 'reset your password' && (
<>
<div className="form-group">
<input
className="form-control input-lg"
name="password"
placeholder={t('user:Enter a password')}
required
type="password"
value={props.userForm.password}
onChange={props.onHandleFormChange}
/>
</div>
{props.formType !== 'login' && (
<div className="form-group">
<input
className="form-control input-lg"
name="password_conf"
placeholder={t(
'user:Enter the password confirmation'
)}
required
type="password"
value={props.userForm.password_conf}
onChange={props.onHandleFormChange}
/>
</div>
)}
</>
)}
<input
type="submit"
className="btn btn-primary btn-lg btn-block"
value={t('Submit')}
/>
</form>
<p className="password-forget">
{props.formType === 'login' && (
<Link
to={{
pathname: '/password-reset/request',
}}
>
{t('user:Forgot password?')}
</Link>
)}
</p>
</>
)}
</div>
<div className="col-md-3" />
</div>
</div>
</div>
)
}

View File

@ -1,43 +0,0 @@
import React from 'react'
import { Trans } from 'react-i18next'
import { connect } from 'react-redux'
import { Link } from 'react-router-dom'
import { logout } from '../../actions/user'
class Logout extends React.Component {
componentDidMount() {
this.props.UserLogout()
}
render() {
return (
<div className="container dashboard">
<div className="row">
<div className="col-2" />
<div className="card col-8">
<div className="card-body">
<div className="text-center">
<Trans i18nKey="user:loggedOut">
You are now logged out. Click <Link to="/login">here</Link> to
log back in.
</Trans>
</div>
</div>
</div>
<div className="col-2" />
</div>
</div>
)
}
}
export default connect(
state => ({
user: state.user,
}),
dispatch => ({
UserLogout: () => {
dispatch(logout())
},
})
)(Logout)

View File

@ -1,48 +0,0 @@
import React from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import { ReactComponent as Password } from '../../images/password.svg'
import { ReactComponent as MailSend } from '../../images/mail-send.svg'
export default function PasswordReset(props) {
const { t } = useTranslation()
const { action } = props
return (
<div className="container dashboard">
<div className="row">
<div className="col-2" />
<div className="card col-8">
<div className="card-body">
<div className="text-center ">
{action === 'sent' && (
<>
<div className="svg-icon">
<MailSend />
</div>
{t(
// eslint-disable-next-line max-len
"user:Check your email. If your address is in our database, you'll received an email with a link to reset your password."
)}
</>
)}
{action === 'updated' && (
<>
<div className="svg-icon">
<Password />
</div>
<Trans i18nKey="user:updatedPasswordText">
{/* prettier-ignore */}
Your password have been updated. Click
<Link to="/login">here</Link> to log in.
</Trans>
</>
)}
</div>
</div>
</div>
<div className="col-2" />
</div>
</div>
)
}

View File

@ -1,199 +0,0 @@
import { format } from 'date-fns'
import React from 'react'
import { Helmet } from 'react-helmet'
import { withTranslation } from 'react-i18next'
import { connect } from 'react-redux'
import Message from '../Common/Message'
import { deletePicture, uploadPicture } from '../../actions/user'
import { apiUrl, getFileSize } from '../../utils'
import { history } from '../../index'
function ProfileDetail({
appConfig,
displayModal,
editable,
isDeletable,
message,
onDeletePicture,
onUploadPicture,
pathname,
t,
user,
}) {
const createdAt = user.created_at
? format(new Date(user.created_at), 'dd/MM/yyyy HH:mm')
: ''
const birthDate = user.birth_date
? format(new Date(user.birth_date), 'dd/MM/yyyy')
: ''
const fileSizeLimit = getFileSize(appConfig.max_single_file_size)
return (
<div>
<Helmet>
<title>FitTrackee - {t('user:Profile')}</title>
</Helmet>
<Message message={message} t={t} />
<div className="container">
<h1 className="page-title">{t('user:Profile')}</h1>
<div className="row">
<div className="col-md-12">
<div className="card">
<div className="card-header userName">
<strong>{user.username}</strong>
</div>
<div className="card-body">
<div className="row">
<div className="col-md-8">
<p>
{/* eslint-disable-next-line max-len */}
<span className="user-label">
{t('user:Email')}
</span>: {user.email}
</p>
<p>
<span className="user-label">
{t('user:Registration Date')}
</span>
: {createdAt}
</p>
<p>
<span className="user-label">{t('user:First Name')}</span>
: {user.first_name}
</p>
<p>
{/* eslint-disable-next-line max-len */}
<span className="user-label">
{t('user:Last Name')}
</span>: {user.last_name}
</p>
<p>
<span className="user-label">{t('user:Birth Date')}</span>
: {birthDate}
</p>
<p>
{/* eslint-disable-next-line max-len */}
<span className="user-label">
{t('user:Location')}
</span>: {user.location}
</p>
<p>
<span className="user-label">{t('user:Bio')}</span>:{' '}
<span className="user-bio">{user.bio}</span>
</p>
<p>
{/* eslint-disable-next-line max-len */}
<span className="user-label">
{t('user:Language')}
</span>: {user.language}
</p>
<p>
{/* eslint-disable-next-line max-len */}
<span className="user-label">
{t('user:Timezone')}
</span>: {user.timezone}
</p>
<p>
<span className="user-label">
{t('user:First day of week')}
</span>
: {user.weekm ? t('user:Monday') : t('user:Sunday')}
</p>
</div>
<div className="col-md-4">
{user.picture === true && (
<div>
<img
alt="Profile"
src={
`${apiUrl}users/${user.username}/picture` +
`?${Date.now()}`
}
className="img-fluid App-profile-img-small"
/>
{editable && (
<>
<br />
<button
type="submit"
onClick={() => onDeletePicture()}
>
{t('user:Delete picture')}
</button>
<br />
<br />
</>
)}
</div>
)}
{editable && (
<form
encType="multipart/form-data"
onSubmit={event => onUploadPicture(event)}
>
<input
type="file"
name="picture"
accept=".png,.jpg,.gif"
/>
<br />
<button type="submit">{t('user:Send')}</button>
{` (max. size: ${fileSizeLimit})`}
</form>
)}{' '}
</div>
</div>
{editable && (
<button
className="btn btn-primary"
onClick={() => history.push('/profile/edit')}
>
{t('common:Edit')}
</button>
)}
{isDeletable && (
<button
className="btn btn-danger"
onClick={() => displayModal(true)}
>
{t('user:Delete user account')}
</button>
)}
<button
className="btn btn-secondary"
onClick={() =>
pathname === '/profile' ? history.push('/') : history.go(-1)
}
>
{t(
pathname === '/profile'
? 'common:Back to home'
: 'common:Back'
)}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
export default withTranslation()(
connect(
state => ({
appConfig: state.application.config,
pathname: state.router.location.pathname,
message: state.message,
}),
dispatch => ({
onDeletePicture: () => {
dispatch(deletePicture())
},
onUploadPicture: event => {
dispatch(uploadPicture(event))
},
})
)(ProfileDetail)
)

View File

@ -1,314 +0,0 @@
import { format } from 'date-fns'
import React from 'react'
import { Helmet } from 'react-helmet'
import { withTranslation } from 'react-i18next'
import { connect } from 'react-redux'
import TimezonePicker from 'react-timezone'
import Message from '../Common/Message'
import { deleteUser, handleProfileFormSubmit } from '../../actions/user'
import { history } from '../../index'
import { languages } from '../NavBar/LanguageDropdown'
import CustomModal from '../Common/CustomModal'
import CustomTextArea from '../Common/CustomTextArea'
class ProfileEdit extends React.Component {
constructor(props, context) {
super(props, context)
this.state = {
formData: {},
displayModal: false,
}
}
componentDidMount() {
this.initForm()
}
componentDidUpdate(prevProps) {
if (prevProps.user !== this.props.user) {
this.initForm()
}
}
initForm() {
const { user } = this.props
const formData = {}
Object.keys(user).map(k =>
user[k] === null
? (formData[k] = '')
: k === 'birth_date'
? (formData[k] = format(new Date(user[k]), 'yyyy-MM-dd'))
: (formData[k] = user[k])
)
this.setState({ formData })
}
handleFormChange(e) {
const { formData } = this.state
if (e.target.name === 'weekm') {
formData.weekm = e.target.value === 'Monday'
} else {
formData[e.target.name] = e.target.value
}
this.setState(formData)
}
displayModal(value) {
this.setState(prevState => ({
...prevState,
displayModal: value,
}))
}
render() {
const { message, onDeleteUser, onHandleProfileFormSubmit, t, user } =
this.props
const { displayModal, formData } = this.state
return (
<div>
<Helmet>
<title>FitTrackee - {t('user:Profile Edition')}</title>
</Helmet>
{formData.isAuthenticated && (
<div className="container">
{displayModal && (
<CustomModal
title={t('common:Confirmation')}
text={t(
'user:Are you sure you want to delete your account? ' +
'All data will be deleted, this cannot be undone.'
)}
confirm={() => {
onDeleteUser(user.username)
this.displayModal(false)
}}
close={() => this.displayModal(false)}
/>
)}
<h1 className="page-title">{t('user:Profile Edition')}</h1>
<div className="row">
<div className="col-md-2" />
<div className="col-md-8">
<div className="card">
<div className="card-header">{user.username}</div>
<div className="card-body">
<div className="row">
<div className="col-md-12">
<form
onSubmit={event => {
event.preventDefault()
onHandleProfileFormSubmit(formData)
}}
>
<div className="form-group">
<label>
{t('user:Email')}:
<input
name="email"
className="form-control input-lg"
type="text"
value={formData.email}
readOnly
/>
</label>
</div>
<div className="form-group">
<label>
{t('user:Registration Date')}:
<input
name="createdAt"
className="form-control input-lg"
type="text"
value={formData.created_at}
disabled
/>
</label>
</div>
<div className="form-group">
<label>
{t('user:Password')}:
<input
name="password"
className="form-control input-lg"
type="password"
onChange={e => this.handleFormChange(e)}
/>
</label>
</div>
<div className="form-group">
<label>
{t('user:Password Confirmation')}:
<input
name="password_conf"
className="form-control input-lg"
type="password"
onChange={e => this.handleFormChange(e)}
/>
</label>
</div>
<hr />
<div className="form-group">
<label>
{t('user:First Name')}:
<input
name="first_name"
className="form-control input-lg"
type="text"
value={formData.first_name}
onChange={e => this.handleFormChange(e)}
/>
</label>
</div>
<div className="form-group">
<label>
{t('user:Last Name')}:
<input
name="last_name"
className="form-control input-lg"
type="text"
value={formData.last_name}
onChange={e => this.handleFormChange(e)}
/>
</label>
</div>
<div className="form-group">
<label>
{t('user:Birth Date')}
<input
name="birth_date"
className="form-control input-lg"
type="date"
value={formData.birth_date}
onChange={e => this.handleFormChange(e)}
/>
</label>
</div>
<div className="form-group">
<label>
{t('user:Location')}:
<input
name="location"
className="form-control input-lg"
type="text"
value={formData.location}
onChange={e => this.handleFormChange(e)}
/>
</label>
</div>
<div className="form-group">
<label>
{t('user:Bio')}:
<CustomTextArea
charLimit={200}
name="bio"
defaultValue={formData.bio}
onTextChange={e => this.handleFormChange(e)}
/>
</label>
</div>
<div className="form-group">
<label>
{t('user:Language')}:
<select
name="language"
className="form-control input-lg"
value={formData.language}
onChange={e => this.handleFormChange(e)}
>
{languages.map(lang => (
<option value={lang.name} key={lang.name}>
{lang.name}
</option>
))}
</select>
</label>
</div>
<div className="form-group">
<label>
{t('user:Timezone')}:
<TimezonePicker
className="form-control timezone-custom"
onChange={tz => {
const e = {
target: {
name: 'timezone',
value: tz ? tz : 'Europe/Paris',
},
}
this.handleFormChange(e)
}}
value={formData.timezone}
/>
</label>
</div>
<div className="form-group">
<label>
{t('user:First day of week')}:
<select
name="weekm"
className="form-control input-lg"
value={formData.weekm ? 'Monday' : 'Sunday'}
onChange={e => this.handleFormChange(e)}
>
<option value="Sunday">
{t('user:Sunday')}
</option>
<option value="Monday">
{t('user:Monday')}
</option>
</select>
</label>
</div>
<button type="submit" className="btn btn-primary">
{t('common:Submit')}
</button>
<button
className="btn btn-danger"
onClick={event => {
event.preventDefault()
this.displayModal(true)
}}
>
{t('user:Delete my account')}
</button>
<button
type="submit"
className="btn btn-secondary"
onClick={() => history.push('/profile')}
>
{t('common:Cancel')}
</button>
</form>
<Message message={message} t={t} />
</div>
</div>
</div>
</div>
</div>
<div className="col-md-2" />
</div>
</div>
)}
</div>
)
}
}
export default withTranslation()(
connect(
state => ({
location: state.router.location,
message: state.message,
user: state.user,
}),
dispatch => ({
onDeleteUser: username => {
dispatch(deleteUser(username))
},
onHandleProfileFormSubmit: formData => {
dispatch(handleProfileFormSubmit(formData))
},
})
)(ProfileEdit)
)

View File

@ -1,99 +0,0 @@
import React from 'react'
import { withTranslation } from 'react-i18next'
import { connect } from 'react-redux'
import { Redirect } from 'react-router-dom'
import Form from './Form'
import Message from '../Common/Message'
import { handleUserFormSubmit } from '../../actions/user'
import { isLoggedIn } from '../../utils'
class UserForm extends React.Component {
constructor(props, context) {
super(props, context)
this.state = {
formData: {
username: '',
email: '',
password: '',
password_conf: '',
},
}
}
componentDidUpdate(prevProps) {
if (prevProps.location.pathname !== this.props.location.pathname) {
this.emptyForm()
}
}
emptyForm() {
const { formData } = this.state
Object.keys(formData).map(k => (formData[k] = ''))
this.setState(formData)
}
onHandleFormChange(e) {
const { formData } = this.state
formData[e.target.name] = e.target.value
this.setState(formData)
}
render() {
const {
formType,
isRegistrationAllowed,
message,
messages,
onHandleUserFormSubmit,
t,
} = this.props
const { formData } = this.state
const { token } = this.props.location.query
return (
<div>
{isLoggedIn() || (formType === 'password reset' && !token) ? (
<Redirect to="/" />
) : (
<div>
<Message message={message} messages={messages} t={t} />
<Form
isRegistrationAllowed={isRegistrationAllowed}
formType={formType}
userForm={formData}
onHandleFormChange={event => this.onHandleFormChange(event)}
handleUserFormSubmit={event => {
event.preventDefault()
if (formType === 'password reset') {
formData.token = token
}
onHandleUserFormSubmit(formData, formType)
}}
/>
</div>
)}
</div>
)
}
}
export default withTranslation()(
connect(
state => ({
isRegistrationAllowed: state.application.config.is_registration_enabled,
location: state.router.location,
message: state.message,
messages: state.messages,
}),
dispatch => ({
onHandleUserFormSubmit: (formData, formType) => {
formType =
formType === 'password reset'
? 'password/update'
: formType === 'reset your password'
? 'password/reset-request'
: formType
dispatch(handleUserFormSubmit(formData, formType))
},
})
)(UserForm)
)

View File

@ -1,86 +0,0 @@
import React from 'react'
import { withTranslation } from 'react-i18next'
import { connect } from 'react-redux'
import CustomModal from '../Common/CustomModal'
import ProfileDetail from './ProfileDetail'
import { getOrUpdateData } from '../../actions'
import { deleteUser } from '../../actions/user'
class UserProfile extends React.Component {
constructor(props, context) {
super(props, context)
this.state = {
displayModal: false,
}
}
componentDidMount() {
this.props.loadUser(this.props.match.params.userName)
}
componentDidUpdate(prevProps) {
if (prevProps.match.params.userName !== this.props.match.params.userName) {
this.props.loadUser(this.props.match.params.userName)
}
}
displayModal(value) {
this.setState(prevState => ({
...prevState,
displayModal: value,
}))
}
render() {
const { t, currentUser, onDeleteUser, users } = this.props
const { displayModal } = this.state
const [user] = users
const editable = user ? currentUser.username === user.username : false
return (
<div>
{displayModal && (
<CustomModal
title={t('common:Confirmation')}
text={t(
'user:Are you sure you want to delete this account? ' +
'All data will be deleted, this cannot be undone.'
)}
confirm={() => {
onDeleteUser(user.username)
this.displayModal(false)
}}
close={() => this.displayModal(false)}
/>
)}
{user && (
<ProfileDetail
editable={editable}
isDeletable={currentUser.admin && !editable}
onDeleteUser={onDeleteUser}
displayModal={e => this.displayModal(e)}
t={t}
user={user}
/>
)}
</div>
)
}
}
export default withTranslation()(
connect(
state => ({
currentUser: state.user,
users: state.users.data,
}),
dispatch => ({
onDeleteUser: username => {
dispatch(deleteUser(username, true))
},
loadUser: userName => {
dispatch(getOrUpdateData('getData', 'users', { username: userName }))
},
})
)(UserProfile)
)

View File

@ -1,19 +0,0 @@
import React from 'react'
import { connect } from 'react-redux'
import WorkoutAddOrEdit from './WorkoutAddOrEdit'
function WorkoutAdd(props) {
const { message, sports } = props
return (
<div>
<WorkoutAddOrEdit workout={null} message={message} sports={sports} />
</div>
)
}
export default connect(state => ({
message: state.message,
sports: state.sports.data,
user: state.user,
}))(WorkoutAdd)

View File

@ -1,118 +0,0 @@
import React from 'react'
import { Helmet } from 'react-helmet'
import { withTranslation } from 'react-i18next'
import { connect } from 'react-redux'
import FormWithGpx from './WorkoutForms/FormWithGpx'
import FormWithoutGpx from './WorkoutForms/FormWithoutGpx'
import Message from '../Common/Message'
class WorkoutAddEdit extends React.Component {
constructor(props, context) {
super(props, context)
this.state = {
withGpx: true,
}
}
handleRadioChange(changeEvent) {
this.setState({
withGpx:
changeEvent.target.name === 'withGpx'
? changeEvent.target.value
: !changeEvent.target.value,
})
}
render() {
const { loading, message, sports, t, workout } = this.props
const { withGpx } = this.state
return (
<div>
<Helmet>
<title>
FitTrackee -{' '}
{workout
? t('workouts:Edit a workout')
: t('workouts:Add a workout')}
</title>
</Helmet>
<br />
<br />
<Message message={message} t={t} />
<div className="container">
<div className="row">
<div className="col-md-2" />
<div className="col-md-8">
<div className="card add-workout">
<h2 className="card-header text-center">
{workout
? t('workouts:Edit a workout')
: t('workouts:Add a workout')}
</h2>
<div className="card-body">
{workout ? (
workout.with_gpx ? (
<FormWithGpx workout={workout} sports={sports} t={t} />
) : (
<FormWithoutGpx workout={workout} sports={sports} t={t} />
)
) : (
<div>
<form>
<div className="form-group row">
<div className="col">
<label className="radioLabel">
<input
className="add-workout-radio"
type="radio"
name="withGpx"
disabled={loading}
checked={withGpx}
onChange={event =>
this.handleRadioChange(event)
}
/>
{t('workouts:with gpx file')}
</label>
</div>
<div className="col">
<label className="radioLabel">
<input
className="add-workout-radio"
type="radio"
name="withoutGpx"
disabled={loading}
checked={!withGpx}
onChange={event =>
this.handleRadioChange(event)
}
/>
{t('workouts:without gpx file')}
</label>
</div>
</div>
</form>
{withGpx ? (
<FormWithGpx sports={sports} t={t} />
) : (
<FormWithoutGpx sports={sports} t={t} />
)}
</div>
)}
</div>
</div>
</div>
<div className="col-md-2" />
</div>
</div>
</div>
)
}
}
export default withTranslation()(
connect(state => ({
loading: state.loading,
}))(WorkoutAddEdit)
)

View File

@ -1,27 +0,0 @@
import React from 'react'
import { GeoJSON, Marker, TileLayer, useMap } from 'react-leaflet'
import hash from 'object-hash'
import { apiUrl } from '../../../utils'
export default function Map({ bounds, coordinates, jsonData, mapAttribution }) {
const map = useMap()
map.fitBounds(bounds)
return (
<>
<TileLayer
// eslint-disable-next-line max-len
attribution={mapAttribution}
url={`${apiUrl}workouts/map_tile/{s}/{z}/{x}/{y}.png`}
/>
<GeoJSON
// hash as a key to force re-rendering
key={hash(jsonData)}
data={jsonData}
/>
{coordinates.latitude && (
<Marker position={[coordinates.latitude, coordinates.longitude]} />
)}
</>
)
}

View File

@ -1,106 +0,0 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { getDateWithTZ } from '../../../utils'
import { formatWorkoutDate } from '../../../utils/workouts'
export default function WorkoutCardHeader(props) {
const { dataType, displayModal, segmentId, sport, t, title, user, workout } =
props
const workoutDate = workout
? formatWorkoutDate(getDateWithTZ(workout.workout_date, user.timezone))
: null
const previousUrl =
dataType === 'segment' && segmentId !== 1
? `/workouts/${workout.id}/segment/${segmentId - 1}`
: dataType === 'workout' && workout.previous_workout
? `/workouts/${workout.previous_workout}`
: null
const nextUrl =
dataType === 'segment' && segmentId < workout.segments.length
? `/workouts/${workout.id}/segment/${segmentId + 1}`
: dataType === 'workout' && workout.next_workout
? `/workouts/${workout.next_workout}`
: null
return (
<div className="container">
<div className="row">
<div className="col-auto">
{previousUrl ? (
<Link className="unlink" to={previousUrl}>
<i
className="fa fa-chevron-left"
aria-hidden="true"
title={t(`workouts:See previous ${dataType}`)}
/>
</Link>
) : (
<i
className="fa fa-chevron-left inactive-link"
aria-hidden="true"
title={t(`workouts:No previous ${dataType}`)}
/>
)}
</div>
<div className="col-auto col-workout-logo">
<img className="sport-img-medium" src={sport.img} alt="sport logo" />
</div>
<div className="col">
{dataType === 'workout' ? (
<>
{title}{' '}
<Link className="unlink" to={`/workouts/${workout.id}/edit`}>
<i
className="fa fa-edit custom-fa"
aria-hidden="true"
title={t('workouts:Edit workout')}
/>
</Link>
<i
className="fa fa-trash custom-fa"
aria-hidden="true"
onClick={() => displayModal(true)}
title={t('workouts:Delete workout')}
/>
</>
) : (
<>
{/* prettier-ignore */}
<Link
to={`/workouts/${workout.id}`}
>
{title}
</Link>{' '}
- {t('workouts:segment')} {segmentId}
</>
)}
<br />
{workoutDate && (
<span className="workout-date">
{`${workoutDate.workout_date} - ${workoutDate.workout_time}`}
</span>
)}
</div>
<div className="col-auto">
{nextUrl ? (
<Link className="unlink" to={nextUrl}>
<i
className="fa fa-chevron-right"
aria-hidden="true"
title={t(`workouts:See next ${dataType}`)}
/>
</Link>
) : (
<i
className="fa fa-chevron-right inactive-link"
aria-hidden="true"
title={t(`workouts:No next ${dataType}`)}
/>
)}
</div>
</div>
</div>
)
}

View File

@ -1,240 +0,0 @@
import { format } from 'date-fns'
import React from 'react'
import { connect } from 'react-redux'
import {
Area,
ComposedChart,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import {
getSegmentChartData,
getWorkoutChartData,
} from '../../../actions/workouts'
class WorkoutCharts extends React.Component {
constructor(props, context) {
super(props, context)
this.state = {
displayDistance: true,
dataToHide: [],
}
}
componentDidMount() {
if (this.props.dataType === 'workout') {
this.props.loadWorkoutData(this.props.workout.id)
} else {
this.props.loadSegmentData(this.props.workout.id, this.props.segmentId)
}
}
componentDidUpdate(prevProps) {
if (
(this.props.dataType === 'workout' &&
prevProps.workout.id !== this.props.workout.id) ||
(this.props.dataType === 'workout' && prevProps.dataType === 'segment')
) {
this.props.loadWorkoutData(this.props.workout.id)
}
if (
this.props.dataType === 'segment' &&
prevProps.segmentId !== this.props.segmentId
) {
this.props.loadSegmentData(this.props.workout.id, this.props.segmentId)
}
}
componentWillUnmount() {
this.props.loadWorkoutData(null)
}
handleRadioChange(changeEvent) {
this.setState({
displayDistance:
changeEvent.target.name === 'distance'
? changeEvent.target.value
: !changeEvent.target.value,
})
}
handleLegendChange(e) {
const { dataToHide } = this.state
const name = e.target.name // eslint-disable-line prefer-destructuring
if (dataToHide.find(d => d === name)) {
dataToHide.splice(dataToHide.indexOf(name), 1)
} else {
dataToHide.push(name)
}
this.setState({ dataToHide })
}
displayData(name) {
const { dataToHide } = this.state
return !dataToHide.find(d => d === name)
}
render() {
const { chartData, t, updateCoordinates } = this.props
const { displayDistance } = this.state
const xInterval = chartData ? parseInt(chartData.length / 10, 10) : 0
let xDataKey, xScale
if (displayDistance) {
xDataKey = 'distance'
xScale = 'linear'
} else {
xDataKey = 'duration'
xScale = 'time'
}
return (
<div className="container">
{chartData && chartData.length > 0 ? (
<div>
<div className="row chart-radio">
<label className="radioLabel col-md-1">
<input
type="radio"
name="distance"
checked={displayDistance}
onChange={e => this.handleRadioChange(e)}
/>
{t('workouts:distance')}
</label>
<label className="radioLabel col-md-1">
<input
type="radio"
name="duration"
checked={!displayDistance}
onChange={e => this.handleRadioChange(e)}
/>
{t('workouts:duration')}
</label>
</div>
<div className="row chart-radio">
<div className="col-md-5" />
<label className="radioLabel col-md-1">
<input
type="checkbox"
name="speed"
checked={this.displayData('speed')}
onChange={e => this.handleLegendChange(e)}
/>
{t('workouts:speed')}
</label>
<label className="radioLabel col-md-1">
<input
type="checkbox"
name="elevation"
checked={this.displayData('elevation')}
onChange={e => this.handleLegendChange(e)}
/>
{t('workouts:elevation')}
</label>
<div className="col-md-5" />
</div>
<div className="row chart">
<ResponsiveContainer height={300}>
<ComposedChart
data={chartData}
margin={{ top: 15, right: 30, left: 20, bottom: 15 }}
onMouseMove={e => updateCoordinates(e.activePayload)}
onMouseLeave={() => updateCoordinates(null)}
>
<XAxis
allowDecimals={false}
dataKey={xDataKey}
label={{
value: t(`workouts:${xDataKey}`),
offset: 0,
position: 'bottom',
}}
scale={xScale}
interval={xInterval}
tickFormatter={value =>
displayDistance ? value : format(value, 'HH:mm:ss')
}
type="number"
/>
<YAxis
label={{
value: `${t('workouts:speed')} (km/h)`,
angle: -90,
position: 'left',
}}
yAxisId="left"
/>
<YAxis
label={{
value: `${t('workouts:elevation')} (m)`,
angle: -90,
position: 'right',
}}
yAxisId="right"
orientation="right"
/>
{this.displayData('elevation') && (
<Area
yAxisId="right"
type="linear"
dataKey="elevation"
name={t('workouts:elevation')}
fill="#e5e5e5"
stroke="#cccccc"
dot={false}
unit=" m"
/>
)}
{this.displayData('speed') && (
<Line
yAxisId="left"
type="linear"
dataKey="speed"
name={t('workouts:speed')}
stroke="#8884d8"
strokeWidth={2}
dot={false}
unit=" km/h"
/>
)}
<Tooltip
labelFormatter={value =>
displayDistance
? `${t('workouts:distance')}: ${value} km`
: `${t('workouts:duration')}: ${format(
value,
'HH:mm:ss'
)}`
}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
<div className="chart-info">
{t('workouts:data from gpx, without any cleaning')}
</div>
</div>
) : (
t('workouts:No data to display')
)}
</div>
)
}
}
export default connect(
state => ({
chartData: state.chartData,
}),
dispatch => ({
loadWorkoutData: workoutId => {
dispatch(getWorkoutChartData(workoutId))
},
loadSegmentData: (workoutId, segmentId) => {
dispatch(getSegmentChartData(workoutId, segmentId))
},
})
)(WorkoutCharts)

View File

@ -1,73 +0,0 @@
import React from 'react'
import WorkoutWeather from './WorkoutWeather'
export default function WorkoutDetails(props) {
const { t, workout } = props
const withPauses = workout.pauses !== '0:00:00' && workout.pauses !== null
return (
<div className="workout-details">
<p>
<i className="fa fa-clock-o custom-fa" aria-hidden="true" />
{t('workouts:Duration')}: {workout.moving}
{workout.records &&
workout.records.find(record => record.record_type === 'LD') && (
<sup>
<i className="fa fa-trophy custom-fa" aria-hidden="true" />
</sup>
)}
{withPauses && (
<span>
<br />({t('workouts:pauses')}: {workout.pauses},{' '}
{t('workouts:total duration')}: {workout.duration})
</span>
)}
</p>
<p>
<i className="fa fa-road custom-fa" aria-hidden="true" />
{t('workouts:Distance')}: {workout.distance} km
{workout.records &&
workout.records.find(record => record.record_type === 'FD') && (
<sup>
<i className="fa fa-trophy custom-fa" aria-hidden="true" />
</sup>
)}
</p>
<p>
<i className="fa fa-tachometer custom-fa" aria-hidden="true" />
{t('workouts:Average speed')}: {workout.ave_speed} km/h
{workout.records &&
workout.records.find(record => record.record_type === 'AS') && (
<sup>
<i className="fa fa-trophy custom-fa" aria-hidden="true" />
</sup>
)}
<br />
{t('workouts:Max. speed')}: {workout.max_speed} km/h
{workout.records &&
workout.records.find(record => record.record_type === 'MS') && (
<sup>
<i className="fa fa-trophy custom-fa" aria-hidden="true" />
</sup>
)}
</p>
{workout.min_alt && workout.max_alt && (
<p>
<i className="fi-mountains custom-fa" />
{t('workouts:Min. altitude')}: {workout.min_alt}m
<br />
{t('workouts:Max. altitude')}: {workout.max_alt}m
</p>
)}
{workout.ascent && workout.descent && (
<p>
<i className="fa fa-location-arrow custom-fa" />
{t('workouts:Ascent')}: {workout.ascent}m
<br />
{t('workouts:Descent')}: {workout.descent}m
</p>
)}
<WorkoutWeather workout={workout} t={t} />
</div>
)
}

View File

@ -1,87 +0,0 @@
import React from 'react'
import { MapContainer } from 'react-leaflet'
import { connect } from 'react-redux'
import Map from './Map'
import { getSegmentGpx, getWorkoutGpx } from '../../../actions/workouts'
import { getGeoJson } from '../../../utils/workouts'
class WorkoutMap extends React.Component {
constructor(props, context) {
super(props, context)
this.state = {
zoom: 13,
}
}
componentDidMount() {
if (this.props.dataType === 'workout') {
this.props.loadWorkoutGpx(this.props.workout.id)
} else {
this.props.loadSegmentGpx(this.props.workout.id, this.props.segmentId)
}
}
componentDidUpdate(prevProps) {
if (
(this.props.dataType === 'workout' &&
prevProps.workout.id !== this.props.workout.id) ||
(this.props.dataType === 'workout' && prevProps.dataType === 'segment')
) {
this.props.loadWorkoutGpx(this.props.workout.id)
}
if (
this.props.dataType === 'segment' &&
prevProps.segmentId !== this.props.segmentId
) {
this.props.loadSegmentGpx(this.props.workout.id, this.props.segmentId)
}
}
componentWillUnmount() {
this.props.loadWorkoutGpx(null)
}
render() {
const { coordinates, gpxContent, mapAttribution, workout } = this.props
const { jsonData } = getGeoJson(gpxContent)
const bounds = [
[workout.bounds[0], workout.bounds[1]],
[workout.bounds[2], workout.bounds[3]],
]
return (
<div>
{jsonData && (
<MapContainer
zoom={this.state.zoom}
bounds={bounds}
boundsOptions={{ padding: [10, 10] }}
>
<Map
bounds={bounds}
coordinates={coordinates}
jsonData={jsonData}
mapAttribution={mapAttribution}
/>
</MapContainer>
)}
</div>
)
}
}
export default connect(
state => ({
gpxContent: state.gpx,
mapAttribution: state.application.config.map_attribution,
}),
dispatch => ({
loadWorkoutGpx: workoutId => {
dispatch(getWorkoutGpx(workoutId))
},
loadSegmentGpx: (workoutId, segmentId) => {
dispatch(getSegmentGpx(workoutId, segmentId))
},
})
)(WorkoutMap)

View File

@ -1,8 +0,0 @@
import React from 'react'
export default function WorkoutNoMap(props) {
const { t } = props
return (
<div className="workout-no-map text-center">{t('workouts:No Map')}</div>
)
}

View File

@ -1,19 +0,0 @@
import React from 'react'
export default function WorkoutNotes(props) {
const { notes, t } = props
return (
<div className="row">
<div className="col">
<div className="card workout-card">
<div className="card-body">
Notes
<div className="workout-notes">
{notes && notes !== '' ? notes : t('workouts:No notes')}
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -1,38 +0,0 @@
import React from 'react'
import { Link } from 'react-router-dom'
export default function WorkoutSegments(props) {
const { segments, t } = props
return (
<div className="row">
<div className="col">
<div className="card workout-card">
<div className="card-body">
{t('workouts:Segments')}
<div className="workout-segments">
<ul>
{segments.map((segment, index) => (
<li
className="workout-segments-list"
// eslint-disable-next-line react/no-array-index-key
key={`segment-${index}`}
>
<Link
to={`/workouts/${segment.workout_id}/segment/${
index + 1
}`}
>
{t('workouts:segment')} {index + 1}
</Link>{' '}
({t('workouts:distance')}: {segment.distance} km,{' '}
{t('workouts:duration')}: {segment.duration})
</li>
))}
</ul>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -1,75 +0,0 @@
import React from 'react'
export default function WorkoutWeather(props) {
const { t, workout } = props
return (
<div className="container">
{workout.weather_start && workout.weather_end && (
<table className="table table-borderless weather-table text-center">
<thead>
<tr>
<th />
<th>
{t('workouts:Start')}
<br />
<img
className="weather-img"
src={`/img/weather/${workout.weather_start.icon}.png`}
alt={`workout weather (${workout.weather_start.icon})`}
title={workout.weather_start.summary}
/>
</th>
<th>
{t('workouts:End')}
<br />
<img
className="weather-img"
src={`/img/weather/${workout.weather_end.icon}.png`}
alt={`workout weather (${workout.weather_end.icon})`}
title={workout.weather_end.summary}
/>
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<img
className="weather-img-small"
src="/img/weather/temperature.png"
alt="Temperatures"
/>
</td>
<td>{Number(workout.weather_start.temperature).toFixed(1)}°C</td>
<td>{Number(workout.weather_end.temperature).toFixed(1)}°C</td>
</tr>
<tr>
<td>
<img
className="weather-img-small"
src="/img/weather/pour-rain.png"
alt="Temperatures"
/>
</td>
<td>
{Number(workout.weather_start.humidity * 100).toFixed(1)}%
</td>
<td>{Number(workout.weather_end.humidity * 100).toFixed(1)}%</td>
</tr>
<tr>
<td>
<img
className="weather-img-small"
src="/img/weather/breeze.png"
alt="Temperatures"
/>
</td>
<td>{Number(workout.weather_start.wind).toFixed(1)}m/s</td>
<td>{Number(workout.weather_end.wind).toFixed(1)}m/s</td>
</tr>
</tbody>
</table>
)}
</div>
)
}

View File

@ -1,202 +0,0 @@
import React from 'react'
import { Helmet } from 'react-helmet'
import { withTranslation } from 'react-i18next'
import { connect } from 'react-redux'
import CustomModal from '../../Common/CustomModal'
import Message from '../../Common/Message'
import WorkoutCardHeader from './WorkoutCardHeader'
import WorkoutCharts from './WorkoutCharts'
import WorkoutDetails from './WorkoutDetails'
import WorkoutMap from './WorkoutMap'
import WorkoutNoMap from './WorkoutNoMap'
import WorkoutNotes from './WorkoutNotes'
import WorkoutSegments from './WorkoutSegments'
import { getOrUpdateData } from '../../../actions'
import { deleteWorkout } from '../../../actions/workouts'
class WorkoutDisplay extends React.Component {
constructor(props, context) {
super(props, context)
this.state = {
displayModal: false,
coordinates: {
latitude: null,
longitude: null,
},
}
}
componentDidMount() {
this.props.loadWorkout(this.props.match.params.workoutId)
}
componentDidUpdate(prevProps) {
if (
prevProps.match.params.workoutId !== this.props.match.params.workoutId
) {
this.props.loadWorkout(this.props.match.params.workoutId)
}
}
displayModal(value) {
this.setState(prevState => ({
...prevState,
displayModal: value,
}))
}
updateCoordinates(activePayload) {
const coordinates =
activePayload && activePayload.length > 0
? {
latitude: activePayload[0].payload.latitude,
longitude: activePayload[0].payload.longitude,
}
: {
latitude: null,
longitude: null,
}
this.setState(prevState => ({
...prevState,
coordinates,
}))
}
render() {
const { message, onDeleteWorkout, sports, t, user, workouts } = this.props
const { coordinates, displayModal } = this.state
const [workout] = workouts
const title = workout ? workout.title : t('workouts:Workout')
const [sport] = workout ? sports.filter(s => s.id === workout.sport_id) : []
const segmentId = parseInt(this.props.match.params.segmentId)
const dataType = segmentId >= 0 ? 'segment' : 'workout'
return (
<div className="workout-page">
<Helmet>
<title>FitTrackee - {title}</title>
</Helmet>
{message ? (
<Message message={message} t={t} />
) : (
<div className="container">
{displayModal && (
<CustomModal
title={t('common:Confirmation')}
text={t(
'workouts:Are you sure you want to delete this workout?'
)}
confirm={() => {
onDeleteWorkout(workout.id)
this.displayModal(false)
}}
close={() => this.displayModal(false)}
/>
)}
{workout && sport && workouts.length === 1 && (
<div>
<div className="row">
<div className="col">
<div className="card workout-card">
<div className="card-header">
<WorkoutCardHeader
workout={workout}
dataType={dataType}
segmentId={segmentId}
sport={sport}
t={t}
title={title}
user={user}
displayModal={() => this.displayModal(true)}
/>
</div>
<div className="card-body">
<div className="row">
<div className="col-md-8">
{workout.with_gpx ? (
<WorkoutMap
workout={workout}
coordinates={coordinates}
dataType={dataType}
segmentId={segmentId}
/>
) : (
<WorkoutNoMap t={t} />
)}
</div>
<div className="col">
<WorkoutDetails
workout={
dataType === 'workout'
? workout
: workout.segments[segmentId - 1]
}
t={t}
/>
</div>
</div>
</div>
</div>
</div>
</div>
{workout.with_gpx && (
<div className="row">
<div className="col">
<div className="card workout-card">
<div className="card-body">
<div className="row">
<div className="col">
<div className="chart-title">
{t('workouts:Chart')}
</div>
<WorkoutCharts
workout={workout}
dataType={dataType}
segmentId={segmentId}
t={t}
updateCoordinates={e =>
this.updateCoordinates(e)
}
/>
</div>
</div>
</div>
</div>
</div>
</div>
)}
{dataType === 'workout' && (
<>
<WorkoutNotes notes={workout.notes} t={t} />
{workout.segments.length > 1 && (
<WorkoutSegments segments={workout.segments} t={t} />
)}
</>
)}
</div>
)}
</div>
)}
</div>
)
}
}
export default withTranslation()(
connect(
state => ({
workouts: state.workouts.data,
message: state.message,
sports: state.sports.data,
user: state.user,
}),
dispatch => ({
loadWorkout: workoutId => {
dispatch(getOrUpdateData('getData', 'workouts', { id: workoutId }))
},
onDeleteWorkout: workoutId => {
dispatch(deleteWorkout(workoutId))
},
})
)(WorkoutDisplay)
)

View File

@ -1,41 +0,0 @@
import React from 'react'
import { connect } from 'react-redux'
import WorkoutAddOrEdit from './WorkoutAddOrEdit'
import { getOrUpdateData } from '../../actions'
class WorkoutEdit extends React.Component {
componentDidMount() {
this.props.loadWorkout(this.props.match.params.workoutId)
}
render() {
const { message, sports, workouts } = this.props
const [workout] = workouts
return (
<div>
{sports.length > 0 && (
<WorkoutAddOrEdit
workout={workout}
message={message}
sports={sports}
/>
)}
</div>
)
}
}
export default connect(
state => ({
workouts: state.workouts.data,
message: state.message,
sports: state.sports.data,
user: state.user,
}),
dispatch => ({
loadWorkout: workoutId => {
dispatch(getOrUpdateData('getData', 'workouts', { id: workoutId }))
},
})
)(WorkoutEdit)

View File

@ -1,170 +0,0 @@
import React from 'react'
import { Trans } from 'react-i18next'
import { connect } from 'react-redux'
import { setLoading } from '../../../actions/index'
import { addWorkout, editWorkout } from '../../../actions/workouts'
import { history } from '../../../index'
import { getFileSize } from '../../../utils'
import { translateSports } from '../../../utils/workouts'
import CustomTextArea from '../../Common/CustomTextArea'
function FormWithGpx(props) {
const {
appConfig,
loading,
onAddWorkout,
onEditWorkout,
sports,
t,
workout,
} = props
const sportId = workout ? workout.sport_id : ''
const translatedSports = translateSports(sports, t, true)
const zipTooltip = `${t('workouts:no folder inside')}, ${
appConfig.gpx_limit_import
} ${t('workouts:files max')}, ${t('workouts:max size')}: ${getFileSize(
appConfig.max_zip_file_size
)}`
const fileSizeLimit = getFileSize(appConfig.max_single_file_size)
return (
<form
encType="multipart/form-data"
method="post"
onSubmit={event => event.preventDefault()}
>
<div className="form-group">
<label>
{t('common:Sport')}:
<select
className="form-control input-lg"
defaultValue={sportId}
disabled={loading}
name="sport"
required
>
<option value="" />
{translatedSports.map(sport => (
<option key={sport.id} value={sport.id}>
{sport.label}
</option>
))}
</select>
</label>
</div>
{workout ? (
<div className="form-group">
<label>
{t('workouts:Title')}:
<input
name="title"
defaultValue={workout ? workout.title : ''}
disabled={loading}
className="form-control input-lg"
/>
</label>
</div>
) : (
<div className="form-group">
<label>
<Trans i18nKey="workouts:gpxFile">
<strong>gpx</strong> file
</Trans>
<sup>
<i
className="fa fa-question-circle"
aria-hidden="true"
data-toggle="tooltip"
title={`${t('workouts:max size')}: ${fileSizeLimit}`}
/>
</sup>{' '}
<Trans i18nKey="workouts:zipFile">
or <strong> zip</strong> file containing <strong>gpx </strong>
files
</Trans>
<sup>
<i
className="fa fa-question-circle"
aria-hidden="true"
data-toggle="tooltip"
data-placement="top"
title={zipTooltip}
/>
</sup>{' '}
:
<input
accept=".gpx, .zip"
className="form-control form-control-file gpx-file"
disabled={loading}
name="gpxFile"
required
type="file"
/>
</label>
</div>
)}
<div className="form-group">
<label>
{t('workouts:Notes')}:
<CustomTextArea
charLimit={500}
defaultValue={workout ? workout.notes : ''}
loading={loading}
name="notes"
/>
</label>
</div>
{loading ? (
<div className="loader" />
) : (
<div>
<input
type="submit"
className="btn btn-primary"
onClick={event =>
workout ? onEditWorkout(event, workout) : onAddWorkout(event)
}
value={t('common:Submit')}
/>
<input
type="submit"
className="btn btn-secondary"
onClick={() => history.push('/')}
value={t('common:Cancel')}
/>
</div>
)}
</form>
)
}
export default connect(
state => ({
appConfig: state.application.config,
loading: state.loading,
}),
dispatch => ({
onAddWorkout: e => {
dispatch(setLoading(true))
const form = new FormData()
form.append('file', e.target.form.gpxFile.files[0])
/* prettier-ignore */
form.append(
'data',
`{"sport_id": ${e.target.form.sport.value
}, "notes": "${e.target.form.notes.value}"}`
)
dispatch(addWorkout(form))
},
onEditWorkout: (e, workout) => {
dispatch(
editWorkout({
id: workout.id,
notes: e.target.form.notes.value,
sport_id: +e.target.form.sport.value,
title: e.target.form.title.value,
})
)
},
})
)(FormWithGpx)

View File

@ -1,162 +0,0 @@
import React from 'react'
import { connect } from 'react-redux'
import { addWorkoutWithoutGpx, editWorkout } from '../../../actions/workouts'
import { history } from '../../../index'
import { getDateWithTZ } from '../../../utils'
import { formatWorkoutDate, translateSports } from '../../../utils/workouts'
import CustomTextArea from '../../Common/CustomTextArea'
function FormWithoutGpx(props) {
const { onAddOrEdit, sports, t, user, workout } = props
const translatedSports = translateSports(sports, t, true)
let workoutDate,
workoutTime,
sportId = ''
if (workout) {
const workoutDateTime = formatWorkoutDate(
getDateWithTZ(workout.workout_date, user.timezone),
'yyyy-MM-dd'
)
workoutDate = workoutDateTime.workout_date
workoutTime = workoutDateTime.workout_time
sportId = workout.sport_id
}
return (
<form onSubmit={event => event.preventDefault()}>
<div className="form-group">
<label>
{t('workouts:Title')}:
<input
name="title"
defaultValue={workout ? workout.title : ''}
className="form-control input-lg"
/>
</label>
</div>
<div className="form-group">
<label>
{t('common:Sport')}:
<select
className="form-control input-lg"
defaultValue={sportId}
name="sport_id"
required
>
<option value="" />
{translatedSports.map(sport => (
<option key={sport.id} value={sport.id}>
{sport.label}
</option>
))}
</select>
</label>
</div>
<div className="form-group">
<label>
{t('workouts:Workout Date')}:
<div className="container">
<div className="row">
<input
name="workout_date"
defaultValue={workoutDate}
className="form-control col-md"
required
type="date"
/>
<input
name="workout_time"
defaultValue={workoutTime}
className="form-control col-md"
required
type="time"
/>
</div>
</div>
</label>
</div>
<div className="form-group">
<label>
{t('workouts:Duration')}:
<input
name="duration"
defaultValue={workout ? workout.duration : ''}
className="form-control col-xs-4"
pattern="^([0-9]*[0-9]):([0-5][0-9]):([0-5][0-9])$"
placeholder="hh:mm:ss"
required
type="text"
/>
</label>
</div>
<div className="form-group">
<label>
{t('workouts:Distance')} (km):
<input
name="distance"
defaultValue={workout ? workout.distance : ''}
className="form-control input-lg"
min={0}
required
step="0.001"
type="number"
/>
</label>
</div>
<div className="form-group">
<label>
{t('workouts:Notes')}:
<CustomTextArea
charLimit={500}
defaultValue={workout ? workout.notes : ''}
name="notes"
/>
</label>
</div>
<input
type="submit"
className="btn btn-primary"
onClick={event => onAddOrEdit(event, workout)}
value={t('common:Submit')}
/>
<input
type="submit"
className="btn btn-secondary"
onClick={() => history.push('/')}
value={t('common:Cancel')}
/>
</form>
)
}
export default connect(
state => ({
user: state.user,
}),
dispatch => ({
onAddOrEdit: (e, workout) => {
const d = e.target.form.duration.value.split(':')
const duration = +d[0] * 60 * 60 + +d[1] * 60 + +d[2]
/* prettier-ignore */
const workoutDate = `${e.target.form.workout_date.value
} ${ e.target.form.workout_time.value}`
const data = {
workout_date: workoutDate,
distance: +e.target.form.distance.value,
duration,
notes: e.target.form.notes.value,
sport_id: +e.target.form.sport_id.value,
title: e.target.form.title.value,
}
if (workout) {
data.id = workout.id
dispatch(editWorkout(data))
} else {
dispatch(addWorkoutWithoutGpx(data))
}
},
})
)(FormWithoutGpx)

View File

@ -1,38 +0,0 @@
import React from 'react'
import { connect } from 'react-redux'
import { Redirect, Route, Switch } from 'react-router-dom'
import NotFound from './../Others/NotFound'
import WorkoutAdd from './WorkoutAdd'
import WorkoutDisplay from './WorkoutDisplay'
import WorkoutEdit from './WorkoutEdit'
import { isLoggedIn } from '../../utils'
function Workout() {
return (
<div>
{isLoggedIn() ? (
<Switch>
<Route exact path="/workouts/add" component={WorkoutAdd} />
<Route exact path="/workouts/:workoutId" component={WorkoutDisplay} />
<Route
exact
path="/workouts/:workoutId/edit"
component={WorkoutEdit}
/>
<Route
path="/workouts/:workoutId/segment/:segmentId"
component={WorkoutDisplay}
/>
<Route component={NotFound} />
</Switch>
) : (
<Redirect to="/login" />
)}
</div>
)
}
export default connect(state => ({
user: state.user,
}))(Workout)

View File

@ -1,189 +0,0 @@
import React from 'react'
import { translateSports } from '../../utils/workouts'
export default class WorkoutsFilter extends React.PureComponent {
render() {
const { loadWorkouts, sports, t, updateParams } = this.props
const translatedSports = translateSports(sports, t)
return (
<div className="card">
<div className="card-body workout-filter">
<form onSubmit={event => event.preventDefault()}>
<div className="form-group">
<label>
{t('workouts:From')}:
<input
className="form-control col-md"
name="from"
onChange={e => updateParams(e)}
type="date"
/>
</label>
<label>
{t('workouts:To')}:
<input
className="form-control col-md"
name="to"
onChange={e => updateParams(e)}
type="date"
/>
</label>
</div>
<div className="form-group">
<label>
{t('common:Sport')}:
<select
className="form-control input-lg"
name="sport_id"
onChange={e => updateParams(e)}
>
<option value="" />
{translatedSports.map(sport => (
<option key={sport.id} value={sport.id}>
{sport.label}
</option>
))}
</select>
</label>
</div>
<div className="form-group">
<label>
{t('workouts:Distance')} (km):
<div className="container">
<div className="row">
<div className="col-5">
<input
className="form-control"
min={0}
name="distance_from"
onChange={e => updateParams(e)}
step="1"
type="number"
/>
</div>
<div className="col-2 align-middle text-center">
{t('common:to')}
</div>
<div className="col-5">
<input
className="form-control"
min={0}
name="distance_to"
onChange={e => updateParams(e)}
step="1"
type="number"
/>
</div>
</div>
</div>
</label>
</div>
<div className="form-group">
<label>
{t('workouts:Duration')}:
<div className="container">
<div className="row">
<div className="col-5">
<input
className="form-control"
name="duration_from"
onChange={e => updateParams(e)}
pattern="^([0-9]*[0-9]):([0-5][0-9])$"
placeholder="hh:mm"
type="text"
/>
</div>
<div className="col-2 align-middle text-center">
{t('common:to')}
</div>
<div className="col-5">
<input
className="form-control"
name="duration_to"
onChange={e => updateParams(e)}
pattern="^([0-9]*[0-9]):([0-5][0-9])$"
placeholder="hh:mm"
type="text"
/>
</div>
</div>
</div>
</label>
</div>
<div className="form-group">
<label>
{t('workouts:Average speed')} (km/h):
<div className="container">
<div className="row">
<div className="col-5">
<input
className="form-control"
min={0}
name="ave_speed_from"
onChange={e => updateParams(e)}
step="1"
type="number"
/>
</div>
<div className="col-2 align-middle text-center">
{t('common:to')}
</div>
<div className="col-5">
<input
className="form-control"
min={0}
name="ave_speed_to"
onChange={e => updateParams(e)}
step="1"
type="number"
/>
</div>
</div>
</div>
</label>
</div>
<div className="form-group">
<label>
{t('workouts:Max. speed')} (km/h):
<div className="container">
<div className="row">
<div className="col-5">
<input
className="form-control"
min={0}
name="max_speed_from"
onChange={e => updateParams(e)}
step="1"
type="number"
/>
</div>
<div className="col-2 align-middle text-center">
{t('common:to')}
</div>
<div className="col-5">
<input
className="form-control"
min={0}
name="max_speed_to"
onChange={e => updateParams(e)}
step="1"
type="number"
/>
</div>
</div>
</div>
</label>
</div>
<input
className="btn btn-primary btn-lg btn-block"
onClick={() => loadWorkouts()}
type="submit"
value={t('workouts:Filter')}
/>
</form>
</div>
</div>
)
}
}

View File

@ -1,111 +0,0 @@
import { format } from 'date-fns'
import React from 'react'
import { Link } from 'react-router-dom'
import StaticMap from '../Common/StaticMap'
import { getDateWithTZ } from '../../utils'
export default class WorkoutsList extends React.PureComponent {
render() {
const { loading, sports, t, user, workouts } = this.props
return (
<div className="card workout-card">
<div className="card-body">
<table className="table">
<thead>
<tr>
<th scope="col" />
<th scope="col">{t('common:Workout')}</th>
<th scope="col">{t('workouts:Date')}</th>
<th scope="col">{t('workouts:Distance')}</th>
<th scope="col">{t('workouts:Duration')}</th>
<th scope="col">{t('workouts:Ave. speed')}</th>
<th scope="col">{t('workouts:Max. speed')}</th>
<th scope="col">{t('workouts:Ascent')}</th>
<th scope="col">{t('workouts:Descent')}</th>
</tr>
</thead>
<tbody>
{!loading &&
sports &&
workouts.map((workout, idx) => (
// eslint-disable-next-line react/no-array-index-key
<tr key={idx}>
<td>
<span className="heading-span-absolute">
{t('common:Sport')}
</span>
<img
className="workout-sport"
src={sports
.filter(s => s.id === workout.sport_id)
.map(s => s.img)}
alt="workout sport logo"
/>
</td>
<td className="workout-title">
<span className="heading-span-absolute">
{t('common:Workout')}
</span>
<Link to={`/workouts/${workout.id}`}>
{workout.title}
</Link>
{workout.map && (
<StaticMap workout={workout} display="list" />
)}
</td>
<td>
<span className="heading-span-absolute">
{t('workouts:Date')}
</span>
{format(
getDateWithTZ(workout.workout_date, user.timezone),
'dd/MM/yyyy HH:mm'
)}
</td>
<td className="text-right">
<span className="heading-span-absolute">
{t('workouts:Distance')}
</span>
{Number(workout.distance).toFixed(2)} km
</td>
<td className="text-right">
<span className="heading-span-absolute">
{t('workouts:Duration')}
</span>
{workout.moving}
</td>
<td className="text-right">
<span className="heading-span-absolute">
{t('workouts:Ave. speed')}
</span>
{workout.ave_speed} km/h
</td>
<td className="text-right">
<span className="heading-span-absolute">
{t('workouts:Max. speed')}
</span>
{workout.max_speed} km/h
</td>
<td className="text-right">
<span className="heading-span-absolute">
{t('workouts:Ascent')}
</span>
{workout.ascent === null ? '' : `${workout.ascent} m`}
</td>
<td className="text-right">
<span className="heading-span-absolute">
{t('workouts:Descent')}
</span>
{workout.descent === null ? '' : `${workout.descent} m`}
</td>
</tr>
))}
</tbody>
</table>
{loading && <div className="loader" />}
</div>
</div>
)
}
}

View File

@ -1,120 +0,0 @@
import React from 'react'
import { Helmet } from 'react-helmet'
import { withTranslation } from 'react-i18next'
import { connect } from 'react-redux'
import Message from '../Common/Message'
import NoWorkouts from '../Common/NoWorkouts'
import WorkoutsFilter from './WorkoutsFilter'
import WorkoutsList from './WorkoutsList'
import { getOrUpdateData } from '../../actions'
import { getMoreWorkouts } from '../../actions/workouts'
class Workouts extends React.Component {
constructor(props, context) {
super(props, context)
this.state = {
params: {
page: 1,
per_page: 10,
},
}
}
componentDidMount() {
this.props.loadWorkouts(this.state.params)
}
setParams(e) {
const { params } = this.state
if (e.target.value === '') {
delete params[e.target.name]
} else {
params[e.target.name] = e.target.value
}
params.page = 1
this.setState(params)
}
render() {
const {
loading,
loadWorkouts,
loadMoreWorkouts,
message,
sports,
t,
user,
workouts,
} = this.props
const { params } = this.state
const paginationEnd =
workouts.length > 0
? workouts[workouts.length - 1].previous_workout === null
: true
return (
<div>
<Helmet>
<title>FitTrackee - {t('common:Workouts')}</title>
</Helmet>
{message ? (
<Message message={message} t={t} />
) : (
<div className="container history">
<div className="row">
<div className="col-md-3">
<WorkoutsFilter
sports={sports}
loadWorkouts={() => loadWorkouts(params)}
t={t}
updateParams={e => this.setParams(e)}
/>
</div>
<div className="col-md-9 workouts-result">
<WorkoutsList
workouts={workouts}
loading={loading}
sports={sports}
t={t}
user={user}
/>
{!paginationEnd && (
<input
type="submit"
className="btn btn-default btn-md btn-block"
value="Load more workouts"
onClick={() => {
params.page += 1
loadMoreWorkouts(params)
this.setState(params)
}}
/>
)}
{workouts.length === 0 && <NoWorkouts t={t} />}
</div>
</div>
</div>
)}
</div>
)
}
}
export default withTranslation()(
connect(
state => ({
workouts: state.workouts.data,
loading: state.loading,
message: state.message,
sports: state.sports.data,
user: state.user,
}),
dispatch => ({
loadWorkouts: params => {
dispatch(getOrUpdateData('getData', 'workouts', params))
},
loadMoreWorkouts: params => {
dispatch(getMoreWorkouts(params))
},
})
)(Workouts)
)

View File

@ -1,22 +0,0 @@
import { createApiRequest } from '../utils'
export default class FitTrackeeApi {
static loginOrRegisterOrPasswordReset(target, data) {
const params = {
url: `auth/${target}`,
method: 'POST',
noAuthorization: true,
body: data,
type: 'application/json',
}
return createApiRequest(params)
}
static deletePicture() {
const params = {
url: 'auth/picture',
method: 'DELETE',
}
return createApiRequest(params)
}
}

View File

@ -1,63 +0,0 @@
import { createApiRequest, formatUrl } from '../utils'
export default class FitTrackeeApi {
static getData(target, data = {}) {
const url = formatUrl(target, data)
const params = {
url: url,
method: 'GET',
type: 'application/json',
}
return createApiRequest(params)
}
static addData(target, data) {
const params = {
url: target,
method: 'POST',
body: data,
type: 'application/json',
}
return createApiRequest(params)
}
static addDataWithFile(target, data) {
const params = {
url: target,
method: 'POST',
body: data,
}
return createApiRequest(params)
}
static postData(target, data) {
const params = {
url: `${target}${data.id ? `/${data.id}` : ''}`,
method: 'POST',
body: data,
type: 'application/json',
}
return createApiRequest(params)
}
static updateData(target, data) {
const params = {
url: `${target}${
data.id ? `/${data.id}` : data.username ? `/${data.username}` : ''
}`,
method: 'PATCH',
body: data,
type: 'application/json',
}
return createApiRequest(params)
}
static deleteData(target, id) {
const params = {
url: `${target}/${id}`,
method: 'DELETE',
type: 'application/json',
}
return createApiRequest(params)
}
}

Some files were not shown because too many files have changed in this diff Show More