Track page
This commit is contained in:
parent
c1186e4074
commit
ec2d5bcf77
162
package-lock.json
generated
162
package-lock.json
generated
|
@ -1582,6 +1582,36 @@
|
||||||
"chalk": "^4.0.0"
|
"chalk": "^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@mapbox/jsonlint-lines-primitives": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
|
||||||
|
"integrity": "sha1-zlblOfg1UrWNENZy6k1vya3HsjQ="
|
||||||
|
},
|
||||||
|
"@mapbox/mapbox-gl-style-spec": {
|
||||||
|
"version": "13.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-style-spec/-/mapbox-gl-style-spec-13.19.0.tgz",
|
||||||
|
"integrity": "sha512-qA9P4WHU4a1iLKM/W2EIxCxcwlxa6isPF6P+jSPaIs4VlZKYO1DMVWNiY03SXu6a+K3dB3GEhRLvEh1f/8VG2w==",
|
||||||
|
"requires": {
|
||||||
|
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
|
||||||
|
"@mapbox/point-geometry": "^0.1.0",
|
||||||
|
"@mapbox/unitbezier": "^0.0.0",
|
||||||
|
"csscolorparser": "~1.0.2",
|
||||||
|
"json-stringify-pretty-compact": "^2.0.0",
|
||||||
|
"minimist": "^1.2.5",
|
||||||
|
"rw": "^1.3.3",
|
||||||
|
"sort-object": "^0.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@mapbox/point-geometry": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz",
|
||||||
|
"integrity": "sha1-ioP5M1x4YO/6Lu7KJUMyqgru2PI="
|
||||||
|
},
|
||||||
|
"@mapbox/unitbezier": {
|
||||||
|
"version": "0.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.0.tgz",
|
||||||
|
"integrity": "sha1-FWUb1VOme4WB+zmIEMmK2Go0Uk4="
|
||||||
|
},
|
||||||
"@nodelib/fs.scandir": {
|
"@nodelib/fs.scandir": {
|
||||||
"version": "2.1.4",
|
"version": "2.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.4.tgz",
|
||||||
|
@ -1977,6 +2007,16 @@
|
||||||
"integrity": "sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA==",
|
"integrity": "sha512-S78QIYirQcUoo6UJZx9CSP0O2ix9IaeAXwQi26Rhr/+mg7qqPy8TzaxHSUut7eGjL8WmLccT7/MXf304WjqHcA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"@types/hoist-non-react-statics": {
|
||||||
|
"version": "3.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz",
|
||||||
|
"integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"hoist-non-react-statics": "^3.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"@types/html-minifier-terser": {
|
"@types/html-minifier-terser": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-5.1.1.tgz",
|
||||||
|
@ -2022,6 +2062,12 @@
|
||||||
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
|
||||||
"integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4="
|
"integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4="
|
||||||
},
|
},
|
||||||
|
"@types/lodash": {
|
||||||
|
"version": "4.14.168",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.168.tgz",
|
||||||
|
"integrity": "sha512-oVfRvqHV/V6D1yifJbVRU3TMp8OT6o6BG+U9MkwuJ3U8/CsDHvalRpsxBqivn71ztOFZBTfJMvETbqHiaNSj7Q==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"@types/minimatch": {
|
"@types/minimatch": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
|
||||||
|
@ -2074,6 +2120,18 @@
|
||||||
"@types/react": "*"
|
"@types/react": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@types/react-redux": {
|
||||||
|
"version": "7.1.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.16.tgz",
|
||||||
|
"integrity": "sha512-f/FKzIrZwZk7YEO9E1yoxIuDNRiDducxkFlkw/GNMGEnK9n4K8wJzlJBghpSuOVDgEUHoDkDF7Gi9lHNQR4siw==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"@types/hoist-non-react-statics": "^3.3.0",
|
||||||
|
"@types/react": "*",
|
||||||
|
"hoist-non-react-statics": "^3.3.0",
|
||||||
|
"redux": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"@types/react-router": {
|
"@types/react-router": {
|
||||||
"version": "5.1.11",
|
"version": "5.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.11.tgz",
|
||||||
|
@ -4409,6 +4467,11 @@
|
||||||
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
||||||
"integrity": "sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s="
|
"integrity": "sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s="
|
||||||
},
|
},
|
||||||
|
"csscolorparser": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz",
|
||||||
|
"integrity": "sha1-s085HupNqPPpgjHizNjfnAQfFxs="
|
||||||
|
},
|
||||||
"cssdb": {
|
"cssdb": {
|
||||||
"version": "4.4.0",
|
"version": "4.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssdb/-/cssdb-4.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssdb/-/cssdb-4.4.0.tgz",
|
||||||
|
@ -8697,6 +8760,11 @@
|
||||||
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
||||||
"integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE="
|
"integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE="
|
||||||
},
|
},
|
||||||
|
"json-stringify-pretty-compact": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-WRitRfs6BGq4q8gTgOy4ek7iPFXjbra0H3PmDLKm2xnZ+Gh1HUhiKGgCZkSPNULlP7mvfu6FV/mOLhCarspADQ=="
|
||||||
|
},
|
||||||
"json-stringify-safe": {
|
"json-stringify-safe": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
|
||||||
|
@ -8953,6 +9021,11 @@
|
||||||
"yallist": "^4.0.0"
|
"yallist": "^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"luxon": {
|
||||||
|
"version": "1.25.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/luxon/-/luxon-1.25.0.tgz",
|
||||||
|
"integrity": "sha512-hEgLurSH8kQRjY6i4YLey+mcKVAWXbDNlZRmM6AgWDJ1cY3atl8Ztf5wEY7VBReFbmGnwQPz7KYJblL8B2k0jQ=="
|
||||||
|
},
|
||||||
"lz-string": {
|
"lz-string": {
|
||||||
"version": "1.4.4",
|
"version": "1.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz",
|
||||||
|
@ -9008,6 +9081,11 @@
|
||||||
"object-visit": "^1.0.0"
|
"object-visit": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"mapbox-to-css-font": {
|
||||||
|
"version": "2.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mapbox-to-css-font/-/mapbox-to-css-font-2.4.0.tgz",
|
||||||
|
"integrity": "sha512-v674D0WtpxCXlA6E+sBlG1QJWdUkz/s9qAD91bJSXBGuBL5lL4tJXpoJEftecphCh2SVQCjWMS2vhylc3AIQTg=="
|
||||||
|
},
|
||||||
"md5.js": {
|
"md5.js": {
|
||||||
"version": "1.3.5",
|
"version": "1.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
|
||||||
|
@ -9976,6 +10054,26 @@
|
||||||
"resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz",
|
||||||
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg=="
|
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg=="
|
||||||
},
|
},
|
||||||
|
"ol": {
|
||||||
|
"version": "6.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ol/-/ol-6.5.0.tgz",
|
||||||
|
"integrity": "sha512-a5ebahrjF5yCPFle1rc0aHzKp/9A4LlUnjh+S3I+x4EgcvcddDhpOX3WDOs0Pg9/wEElrikHSGEvbeej2Hh4Ug==",
|
||||||
|
"requires": {
|
||||||
|
"ol-mapbox-style": "^6.1.1",
|
||||||
|
"pbf": "3.2.1",
|
||||||
|
"rbush": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ol-mapbox-style": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ol-mapbox-style/-/ol-mapbox-style-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-hZsvPVkk1Y+qmifxRX/gCaZJ5Mo04vWj6lbFhXpHDloQquHD3kTY0q8o3xbg4FehucuG7HyQteKWeFJRh3FMww==",
|
||||||
|
"requires": {
|
||||||
|
"@mapbox/mapbox-gl-style-spec": "^13.14.0",
|
||||||
|
"mapbox-to-css-font": "^2.4.0",
|
||||||
|
"webfont-matcher": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"on-finished": {
|
"on-finished": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
|
||||||
|
@ -10292,6 +10390,15 @@
|
||||||
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
|
||||||
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="
|
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="
|
||||||
},
|
},
|
||||||
|
"pbf": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pbf/-/pbf-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-ClrV7pNOn7rtmoQVF4TS1vyU0WhYRnP92fzbfF75jAIwpnzdJXf8iTd4CMEqO4yUenH6NDqLiwjqlh6QgZzgLQ==",
|
||||||
|
"requires": {
|
||||||
|
"ieee754": "^1.1.12",
|
||||||
|
"resolve-protobuf-schema": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"pbkdf2": {
|
"pbkdf2": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.1.tgz",
|
||||||
|
@ -11557,6 +11664,11 @@
|
||||||
"react-is": "^16.8.1"
|
"react-is": "^16.8.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"protocol-buffers-schema": {
|
||||||
|
"version": "3.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.5.1.tgz",
|
||||||
|
"integrity": "sha512-YVCvdhxWNDP8/nJDyXLuM+UFsuPk4+1PB7WGPVDzm3HTHbzFLxQYeW2iZpS4mmnXrQJGBzt230t/BbEb7PrQaw=="
|
||||||
|
},
|
||||||
"proxy-addr": {
|
"proxy-addr": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.6.tgz",
|
||||||
|
@ -11670,6 +11782,11 @@
|
||||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||||
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="
|
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="
|
||||||
},
|
},
|
||||||
|
"quickselect": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw=="
|
||||||
|
},
|
||||||
"raf": {
|
"raf": {
|
||||||
"version": "3.4.1",
|
"version": "3.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||||
|
@ -11718,6 +11835,14 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"rbush": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/rbush/-/rbush-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==",
|
||||||
|
"requires": {
|
||||||
|
"quickselect": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"react": {
|
"react": {
|
||||||
"version": "17.0.1",
|
"version": "17.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-17.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-17.0.1.tgz",
|
||||||
|
@ -12426,6 +12551,14 @@
|
||||||
"resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz",
|
||||||
"integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng=="
|
"integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng=="
|
||||||
},
|
},
|
||||||
|
"resolve-protobuf-schema": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||||
|
"requires": {
|
||||||
|
"protocol-buffers-schema": "^3.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"resolve-url": {
|
"resolve-url": {
|
||||||
"version": "0.2.1",
|
"version": "0.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz",
|
||||||
|
@ -12689,6 +12822,11 @@
|
||||||
"aproba": "^1.1.1"
|
"aproba": "^1.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"rw": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
|
||||||
|
"integrity": "sha1-P4Yt+pGrdmsUiF700BEkv9oHT7Q="
|
||||||
|
},
|
||||||
"rxjs": {
|
"rxjs": {
|
||||||
"version": "6.6.3",
|
"version": "6.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.3.tgz",
|
||||||
|
@ -13524,6 +13662,16 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sort-asc": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/sort-asc/-/sort-asc-0.1.0.tgz",
|
||||||
|
"integrity": "sha1-q3md9h/HPqCVbHnEtTHtHp53J+k="
|
||||||
|
},
|
||||||
|
"sort-desc": {
|
||||||
|
"version": "0.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/sort-desc/-/sort-desc-0.1.1.tgz",
|
||||||
|
"integrity": "sha1-GYuMDN6wlcRjNBhh45JdTuNZqe4="
|
||||||
|
},
|
||||||
"sort-keys": {
|
"sort-keys": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz",
|
||||||
|
@ -13532,6 +13680,15 @@
|
||||||
"is-plain-obj": "^1.0.0"
|
"is-plain-obj": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sort-object": {
|
||||||
|
"version": "0.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/sort-object/-/sort-object-0.3.2.tgz",
|
||||||
|
"integrity": "sha1-mODRme3kDgfGGoRAPGHWw7KQ+eI=",
|
||||||
|
"requires": {
|
||||||
|
"sort-asc": "^0.1.0",
|
||||||
|
"sort-desc": "^0.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"source-list-map": {
|
"source-list-map": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz",
|
||||||
|
@ -15178,6 +15335,11 @@
|
||||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-0.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-0.2.4.tgz",
|
||||||
"integrity": "sha512-6BjspCO9VriYy12z356nL6JBS0GYeEcA457YyRzD+dD6XYCQ75NKhcOHUMHentOE7OcVCIXXDvOm0jKFfQG2Gg=="
|
"integrity": "sha512-6BjspCO9VriYy12z356nL6JBS0GYeEcA457YyRzD+dD6XYCQ75NKhcOHUMHentOE7OcVCIXXDvOm0jKFfQG2Gg=="
|
||||||
},
|
},
|
||||||
|
"webfont-matcher": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/webfont-matcher/-/webfont-matcher-1.1.0.tgz",
|
||||||
|
"integrity": "sha1-mM6VCXsp4x++czBT4Q5XFkLRxsc="
|
||||||
|
},
|
||||||
"webidl-conversions": {
|
"webidl-conversions": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz",
|
||||||
|
|
|
@ -10,7 +10,9 @@
|
||||||
"@types/node": "^14.14.25",
|
"@types/node": "^14.14.25",
|
||||||
"@types/react": "^17.0.1",
|
"@types/react": "^17.0.1",
|
||||||
"@types/react-dom": "^17.0.0",
|
"@types/react-dom": "^17.0.0",
|
||||||
|
"luxon": "^1.25.0",
|
||||||
"node-sass": "^4.14.1",
|
"node-sass": "^4.14.1",
|
||||||
|
"ol": "^6.5.0",
|
||||||
"react": "^17.0.1",
|
"react": "^17.0.1",
|
||||||
"react-dom": "^17.0.1",
|
"react-dom": "^17.0.1",
|
||||||
"react-redux": "^7.2.2",
|
"react-redux": "^7.2.2",
|
||||||
|
@ -52,6 +54,8 @@
|
||||||
"proxy": "http://localhost:3000",
|
"proxy": "http://localhost:3000",
|
||||||
"port": 3001,
|
"port": 3001,
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/lodash": "^4.14.168",
|
||||||
|
"@types/react-redux": "^7.1.16",
|
||||||
"@types/react-router-dom": "^5.1.7"
|
"@types/react-router-dom": "^5.1.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
144
src/App.js
144
src/App.js
|
@ -1,127 +1,13 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {connect} from 'react-redux'
|
import {connect} from 'react-redux'
|
||||||
import {Item, Tab, Button, Loader, Pagination, Icon} from 'semantic-ui-react'
|
import {Button} from 'semantic-ui-react'
|
||||||
import {useObservable} from 'rxjs-hooks'
|
import {BrowserRouter as Router, Switch, Route, Link} from 'react-router-dom'
|
||||||
import {BrowserRouter as Router, Switch, Route, Link, useParams, useHistory, useRouteMatch} from 'react-router-dom'
|
|
||||||
import {of, from, concat} from 'rxjs'
|
|
||||||
import {map, switchMap, distinctUntilChanged, debounceTime} from 'rxjs/operators'
|
|
||||||
import _ from 'lodash'
|
import _ from 'lodash'
|
||||||
|
|
||||||
import {Page} from './components'
|
|
||||||
import styles from './App.module.scss'
|
import styles from './App.module.scss'
|
||||||
import api from './api'
|
import api from './api'
|
||||||
|
|
||||||
import {LoginPage, LogoutPage, NotFoundPage} from './pages'
|
import {LoginPage, LogoutPage, NotFoundPage, TracksPage, TrackPage, HomePage} from './pages'
|
||||||
import {useQueryParam, stringifyParams} from './query.ts'
|
|
||||||
|
|
||||||
function TracksPageTabs() {
|
|
||||||
const history = useHistory()
|
|
||||||
const panes = React.useMemo(
|
|
||||||
() => [
|
|
||||||
{menuItem: 'Global Feed', url: '/'},
|
|
||||||
{menuItem: 'Your Feed', url: '/my-tracks'},
|
|
||||||
],
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
const onTabChange = React.useCallback(
|
|
||||||
(e, data) => {
|
|
||||||
history.push(panes[data.activeIndex].url)
|
|
||||||
},
|
|
||||||
[history, panes]
|
|
||||||
)
|
|
||||||
|
|
||||||
const isFeedPage = useRouteMatch('/my-tracks')
|
|
||||||
const activeIndex = isFeedPage ? 1 : 0
|
|
||||||
|
|
||||||
return <Tab menu={{secondary: true, pointing: true}} {...{panes, onTabChange, activeIndex}} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function TrackList({path}) {
|
|
||||||
const [page, setPage] = useQueryParam('page', 1)
|
|
||||||
|
|
||||||
const privateFeed = path === '/my-tracks'
|
|
||||||
const pageSize = 20
|
|
||||||
|
|
||||||
const data = useObservable(
|
|
||||||
(_$, inputs$) =>
|
|
||||||
inputs$.pipe(
|
|
||||||
map(([page, privateFeed]) => {
|
|
||||||
const url = '/tracks' + (privateFeed ? '/feed' : '')
|
|
||||||
const params = {limit: pageSize, offset: pageSize * (page - 1)}
|
|
||||||
return {url, params}
|
|
||||||
}),
|
|
||||||
debounceTime(100),
|
|
||||||
distinctUntilChanged(_.isEqual),
|
|
||||||
switchMap((request) => concat(of(null), from(api.fetch(request.url + '?' + stringifyParams(request.params)))))
|
|
||||||
),
|
|
||||||
null,
|
|
||||||
[page, privateFeed]
|
|
||||||
)
|
|
||||||
|
|
||||||
const {tracks, trackCount} = data || {}
|
|
||||||
const loading = !data
|
|
||||||
|
|
||||||
const totalPages = trackCount / pageSize
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Loader content="Loading" active={loading} />
|
|
||||||
{!loading && totalPages > 1 && <Pagination activePage={page} onPageChange={setPage} totalPages={totalPages} />}
|
|
||||||
|
|
||||||
{tracks && (
|
|
||||||
<Item.Group divided>
|
|
||||||
{tracks.map((track) => (
|
|
||||||
<Item key={track.slug}>
|
|
||||||
<Item.Image size="tiny" src={track.author.image} />
|
|
||||||
<Item.Content>
|
|
||||||
<Item.Header as="a">{track.title}</Item.Header>
|
|
||||||
<Item.Meta>
|
|
||||||
Created by {track.author.username} on {track.createdAt}
|
|
||||||
</Item.Meta>
|
|
||||||
<Item.Description>{track.description}</Item.Description>
|
|
||||||
<Item.Extra>
|
|
||||||
{track.visible ? (
|
|
||||||
<>
|
|
||||||
<Icon color="blue" name="eye" fitted /> Public
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Icon name="eye slash" fitted /> Private
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Item.Extra>
|
|
||||||
</Item.Content>
|
|
||||||
</Item>
|
|
||||||
))}
|
|
||||||
</Item.Group>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PublicTracksPage({login}) {
|
|
||||||
return (
|
|
||||||
<Page>
|
|
||||||
{login ? <TracksPageTabs /> : null}
|
|
||||||
<TrackList path="/" />
|
|
||||||
</Page>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function OwnTracksPage({login}) {
|
|
||||||
return (
|
|
||||||
<Page>
|
|
||||||
{login ? <TracksPageTabs /> : null}
|
|
||||||
<TrackList path="/my-tracks" />
|
|
||||||
</Page>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Track() {
|
|
||||||
let {slug} = useParams()
|
|
||||||
return <h3>Track {slug}</h3>
|
|
||||||
}
|
|
||||||
|
|
||||||
const App = connect((state) => ({login: state.login}))(function App({login}) {
|
const App = connect((state) => ({login: state.login}))(function App({login}) {
|
||||||
// update the API header on each render, the App is rerendered when the login changes
|
// update the API header on each render, the App is rerendered when the login changes
|
||||||
|
@ -139,10 +25,13 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
|
||||||
<nav className={styles.menu}>
|
<nav className={styles.menu}>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<Link to="/">Feed</Link>
|
<Link to="/">Home</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link to="https://openbikesensor.org/">About</Link>
|
<Link to="/feed">Feed</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://openbikesensor.org/" target="_blank">About</a>
|
||||||
</li>
|
</li>
|
||||||
{login ? (
|
{login ? (
|
||||||
<>
|
<>
|
||||||
|
@ -170,18 +59,21 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
|
||||||
|
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/" exact>
|
<Route path="/" exact>
|
||||||
<PublicTracksPage {...{login}} />
|
<HomePage />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/my-tracks">
|
<Route path="/feed" exact>
|
||||||
<OwnTracksPage {...{login}} />
|
<TracksPage />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path={`/track/:slug`}>
|
<Route path="/feed/my" exact>
|
||||||
<Track />
|
<TracksPage privateFeed />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/login">
|
<Route path={`/tracks/:slug`} exact>
|
||||||
|
<TrackPage />
|
||||||
|
</Route>
|
||||||
|
<Route path="/login" exact>
|
||||||
<LoginPage />
|
<LoginPage />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/logout">
|
<Route path="/logout" exact>
|
||||||
<LogoutPage />
|
<LogoutPage />
|
||||||
</Route>
|
</Route>
|
||||||
<Route>
|
<Route>
|
||||||
|
|
58
src/components/LoginForm.js
Normal file
58
src/components/LoginForm.js
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {connect} from 'react-redux'
|
||||||
|
import {Form, Button} from 'semantic-ui-react'
|
||||||
|
|
||||||
|
import {login as loginAction} from '../reducers/login'
|
||||||
|
|
||||||
|
async function fetchLogin(email, password) {
|
||||||
|
const response = await window.fetch('/api/users/login', {
|
||||||
|
body: JSON.stringify({user: {email, password}}),
|
||||||
|
method: 'post',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.user) {
|
||||||
|
return result.user
|
||||||
|
} else {
|
||||||
|
throw new Error('invalid credentials')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoginForm = connect(
|
||||||
|
(state) => ({loggedIn: Boolean(state.login)}),
|
||||||
|
(dispatch) => ({
|
||||||
|
dispatchLogin: (user) => dispatch(loginAction(user)),
|
||||||
|
})
|
||||||
|
)(function LoginForm({loggedIn, dispatchLogin, className}) {
|
||||||
|
const [email, setEmail] = React.useState('')
|
||||||
|
const [password, setPassword] = React.useState('')
|
||||||
|
const onChangeEmail = React.useCallback((e) => setEmail(e.target.value), [])
|
||||||
|
const onChangePassword = React.useCallback((e) => setPassword(e.target.value), [])
|
||||||
|
|
||||||
|
const onSubmit = React.useCallback(() => fetchLogin(email, password).then(dispatchLogin), [
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
dispatchLogin,
|
||||||
|
])
|
||||||
|
|
||||||
|
return loggedIn ? null :(
|
||||||
|
<Form className={className} onSubmit={onSubmit}>
|
||||||
|
<Form.Field>
|
||||||
|
<label>e-Mail</label>
|
||||||
|
<input value={email} onChange={onChangeEmail} />
|
||||||
|
</Form.Field>
|
||||||
|
<Form.Field>
|
||||||
|
<label>Password</label>
|
||||||
|
<input type="password" value={password} onChange={onChangePassword} />
|
||||||
|
</Form.Field>
|
||||||
|
<Button type="submit">Submit</Button>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default LoginForm
|
62
src/components/Map/index.js
Normal file
62
src/components/Map/index.js
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import OlMap from 'ol/Map';
|
||||||
|
import View from 'ol/View';
|
||||||
|
import OlTileLayer from 'ol/layer/Tile';
|
||||||
|
import {fromLonLat} from 'ol/proj';
|
||||||
|
import OSM from 'ol/source/OSM';
|
||||||
|
|
||||||
|
import "ol/ol.css";
|
||||||
|
|
||||||
|
|
||||||
|
const MapContext = React.createContext()
|
||||||
|
|
||||||
|
export function Map({children, ...props}) {
|
||||||
|
const ref = React.useRef()
|
||||||
|
|
||||||
|
const [map, setMap] = React.useState(null)
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
const map = new OlMap({
|
||||||
|
target: ref.current,
|
||||||
|
view: new View({
|
||||||
|
maxZoom: 22,
|
||||||
|
center: fromLonLat([10, 51]),
|
||||||
|
zoom: 5
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
setMap(map)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.setTarget(null)
|
||||||
|
setMap(null)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<div ref={ref} {...props}>
|
||||||
|
<MapContext.Provider value={map}>
|
||||||
|
{children}
|
||||||
|
</MapContext.Provider>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TileLayer() {
|
||||||
|
const map = React.useContext(MapContext)
|
||||||
|
|
||||||
|
const layer = React.useMemo(() => new OlTileLayer({
|
||||||
|
source: new OSM()
|
||||||
|
}), [])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
map?.addLayer(layer)
|
||||||
|
return () => map?.removeLayer(layer)
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
Map.TileLayer = TileLayer
|
||||||
|
export default Map;
|
|
@ -1 +1,3 @@
|
||||||
|
export {default as LoginForm} from './LoginForm'
|
||||||
|
export {default as Map} from './Map'
|
||||||
export {default as Page} from './Page'
|
export {default as Page} from './Page'
|
||||||
|
|
118
src/pages/HomePage.js
Normal file
118
src/pages/HomePage.js
Normal file
|
@ -0,0 +1,118 @@
|
||||||
|
import _ from 'lodash'
|
||||||
|
import React from 'react'
|
||||||
|
import {connect} from 'react-redux'
|
||||||
|
import {Message, Grid, Loader, Statistic, Segment, Header, Item} from 'semantic-ui-react'
|
||||||
|
import {useObservable} from 'rxjs-hooks'
|
||||||
|
import {of, pipe, from} from 'rxjs'
|
||||||
|
import {map, switchMap, distinctUntilChanged} from 'rxjs/operators'
|
||||||
|
import {Duration} from 'luxon'
|
||||||
|
|
||||||
|
|
||||||
|
import api from '../api'
|
||||||
|
import {Map, Page, LoginForm } from '../components'
|
||||||
|
|
||||||
|
import {TrackListItem} from './TracksPage'
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
return Duration.fromMillis((seconds ?? 0)* 1000).as('hours').toFixed(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function WelcomeMap() {
|
||||||
|
return <Map style={{height: '24rem', backgroundColor: '#FEFEF4'}}>
|
||||||
|
<Map.TileLayer />
|
||||||
|
</Map>
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stats() {
|
||||||
|
const stats = useObservable(pipe(
|
||||||
|
distinctUntilChanged(_.isEqual),
|
||||||
|
switchMap(() => api.fetch('/stats')),
|
||||||
|
))
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Header as='h2'>Statistics</Header>
|
||||||
|
|
||||||
|
<Segment>
|
||||||
|
<Loader active={stats == null} />
|
||||||
|
|
||||||
|
<Statistic.Group widths={4} size="tiny">
|
||||||
|
<Statistic>
|
||||||
|
<Statistic.Value>{Number(stats?.publicTrackLength/1000).toFixed(1)}</Statistic.Value>
|
||||||
|
<Statistic.Label>km track length</Statistic.Label>
|
||||||
|
</Statistic>
|
||||||
|
<Statistic>
|
||||||
|
<Statistic.Value>{formatDuration(stats?.trackDuration)}</Statistic.Value>
|
||||||
|
<Statistic.Label>hrs recorded</Statistic.Label>
|
||||||
|
</Statistic>
|
||||||
|
<Statistic>
|
||||||
|
<Statistic.Value>{stats?.numEvents}</Statistic.Value>
|
||||||
|
<Statistic.Label>events</Statistic.Label>
|
||||||
|
</Statistic>
|
||||||
|
<Statistic>
|
||||||
|
<Statistic.Value>{stats?.userCount}</Statistic.Value>
|
||||||
|
<Statistic.Label>members</Statistic.Label>
|
||||||
|
</Statistic>
|
||||||
|
</Statistic.Group>
|
||||||
|
</Segment>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoginState = connect(
|
||||||
|
(state) => ({login: state.login}),
|
||||||
|
)(function LoginState({login}) {
|
||||||
|
return login ? (
|
||||||
|
<>
|
||||||
|
<Header as='h2'>Logged in as {login.username} </Header>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Header as='h2'>Login</Header>
|
||||||
|
<LoginForm />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
function MostRecentTrack() {
|
||||||
|
const track: Track|null = useObservable(
|
||||||
|
() => of(null).pipe(
|
||||||
|
switchMap(() => from(api.fetch('/tracks?limit=1'))),
|
||||||
|
map(({tracks}) => tracks[0]),
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log(track)
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<h2>Most recent track</h2>
|
||||||
|
<Loader active={track === null} />
|
||||||
|
{track === undefined ? <Message>No track uploaded yet. Be the first!</Message> : track ? <Item.Group><TrackListItem track={track} /></Item.Group> : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<Grid>
|
||||||
|
<Grid.Row>
|
||||||
|
<Grid.Column width={16}>
|
||||||
|
<WelcomeMap />
|
||||||
|
</Grid.Column>
|
||||||
|
</Grid.Row>
|
||||||
|
<Grid.Row>
|
||||||
|
<Grid.Column width={10}>
|
||||||
|
<Stats />
|
||||||
|
<MostRecentTrack />
|
||||||
|
</Grid.Column>
|
||||||
|
<Grid.Column width={6}>
|
||||||
|
<LoginState />
|
||||||
|
</Grid.Column>
|
||||||
|
</Grid.Row>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,64 +1,19 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {connect} from 'react-redux'
|
import {connect} from 'react-redux'
|
||||||
import {Form, Button} from 'semantic-ui-react'
|
|
||||||
import {Redirect} from 'react-router-dom'
|
import {Redirect} from 'react-router-dom'
|
||||||
|
|
||||||
import {login as loginAction} from '../reducers/login'
|
|
||||||
import styles from './LoginPage.module.scss'
|
import styles from './LoginPage.module.scss'
|
||||||
import {Page} from '../components'
|
import {Page, LoginForm} from '../components'
|
||||||
|
|
||||||
async function fetchLogin(email, password) {
|
|
||||||
const response = await window.fetch('/api/users/login', {
|
|
||||||
body: JSON.stringify({user: {email, password}}),
|
|
||||||
method: 'post',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
|
|
||||||
if (result.user) {
|
|
||||||
return result.user
|
|
||||||
} else {
|
|
||||||
throw new Error('invalid credentials')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const LoginPage = connect(
|
const LoginPage = connect(
|
||||||
(state) => ({loggedIn: Boolean(state.login)}),
|
(state) => ({loggedIn: Boolean(state.login)}),
|
||||||
(dispatch) => ({
|
)(function LoginPage({loggedIn}) {
|
||||||
dispatchLogin: (user) => dispatch(loginAction(user)),
|
|
||||||
})
|
|
||||||
)(function LoginPage({loggedIn, dispatchLogin}) {
|
|
||||||
const [email, setEmail] = React.useState('')
|
|
||||||
const [password, setPassword] = React.useState('')
|
|
||||||
const onChangeEmail = React.useCallback((e) => setEmail(e.target.value), [])
|
|
||||||
const onChangePassword = React.useCallback((e) => setPassword(e.target.value), [])
|
|
||||||
|
|
||||||
const onSubmit = React.useCallback(() => fetchLogin(email, password).then(dispatchLogin), [
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
dispatchLogin,
|
|
||||||
])
|
|
||||||
|
|
||||||
return loggedIn ? (
|
return loggedIn ? (
|
||||||
<Redirect to="/" />
|
<Redirect to="/" />
|
||||||
) : (
|
) : (
|
||||||
<Page>
|
<Page>
|
||||||
<Form className={styles.loginForm} onSubmit={onSubmit}>
|
<h2>Login</h2>
|
||||||
<h2>Login</h2>
|
<LoginForm className={styles.loginForm} />
|
||||||
<Form.Field>
|
|
||||||
<label>e-Mail</label>
|
|
||||||
<input value={email} onChange={onChangeEmail} />
|
|
||||||
</Form.Field>
|
|
||||||
<Form.Field>
|
|
||||||
<label>Password</label>
|
|
||||||
<input type="password" value={password} onChange={onChangePassword} />
|
|
||||||
</Form.Field>
|
|
||||||
<Button type="submit">Submit</Button>
|
|
||||||
</Form>
|
|
||||||
</Page>
|
</Page>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
226
src/pages/TrackPage.tsx
Normal file
226
src/pages/TrackPage.tsx
Normal file
|
@ -0,0 +1,226 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {connect} from 'react-redux'
|
||||||
|
import {Link} from 'react-router-dom'
|
||||||
|
import {Segment, Dimmer ,Form, Button, List, Grid, Loader, Header, Comment} from 'semantic-ui-react'
|
||||||
|
import {useParams} from 'react-router-dom'
|
||||||
|
import {concat, combineLatest, of, from} from 'rxjs'
|
||||||
|
import {pluck, distinctUntilChanged, map, switchMap, startWith} from 'rxjs/operators'
|
||||||
|
import {useObservable} from 'rxjs-hooks'
|
||||||
|
import {Settings, DateTime, Duration} from 'luxon'
|
||||||
|
|
||||||
|
import api from '../api'
|
||||||
|
import {Map, Page} from '../components'
|
||||||
|
import type {Track, TrackData, TrackComment} from '../types'
|
||||||
|
|
||||||
|
// TODO: remove
|
||||||
|
Settings.defaultLocale = 'de-DE'
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
return Duration.fromMillis((seconds ?? 0) * 1000).toFormat("h'h' mm'm'")
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormattedDate({date, relative=false}) {
|
||||||
|
if (date == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateTime = typeof date === 'string' ? DateTime.fromISO(date) : date instanceof Date ? DateTime.fromJSDate(date) : date
|
||||||
|
|
||||||
|
let str
|
||||||
|
|
||||||
|
if (relative) {
|
||||||
|
str = dateTime.toRelative()
|
||||||
|
} else {
|
||||||
|
str = dateTime.toLocaleString(DateTime.DATETIME_MED)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span title={dateTime.toISO()}>{str}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrackDetails({track, isAuthor, trackData}) {
|
||||||
|
return (
|
||||||
|
<List>
|
||||||
|
{track.visible != null && isAuthor && (
|
||||||
|
<List.Item>
|
||||||
|
<List.Header>Visibility</List.Header>
|
||||||
|
{track.visible ? 'Public' : 'Private'}
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{track.originalFileName != null && (
|
||||||
|
<List.Item>
|
||||||
|
<List.Header>Original Filename</List.Header>
|
||||||
|
<code>{track.originalFileName}</code>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{track.uploadedByUserAgent != null && (
|
||||||
|
<List.Item>
|
||||||
|
<List.Header>Uploaded with</List.Header>
|
||||||
|
{track.uploadedByUserAgent}
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{track.duration == null && (
|
||||||
|
<List.Item>
|
||||||
|
<List.Header>Duration</List.Header>
|
||||||
|
{formatDuration(track.duration || 1402)}
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{track.createdAt != null && (
|
||||||
|
<List.Item>
|
||||||
|
<List.Header>Uploaded on</List.Header>
|
||||||
|
<FormattedDate date={track.createdAt} />
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Loader active={track != null && trackData == null} inline='centered' style={{marginTop: 16, marginBottom: 16}} />
|
||||||
|
|
||||||
|
{trackData?.recordedAt != null && (
|
||||||
|
<List.Item>
|
||||||
|
<List.Header>Recorded on</List.Header>
|
||||||
|
<FormattedDate date={trackData.recordedAt} />
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{trackData?.numEvents != null && (
|
||||||
|
<List.Item>
|
||||||
|
<List.Header>Confirmed events</List.Header>
|
||||||
|
{trackData.numEvents}
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{trackData?.trackLength != null && (
|
||||||
|
<List.Item>
|
||||||
|
<List.Header>Length</List.Header>
|
||||||
|
{(trackData.trackLength / 1000).toFixed(2)} km
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrackActions({slug}) {
|
||||||
|
return (
|
||||||
|
<Button.Group vertical>
|
||||||
|
<Link to={`/tracks/${slug}/edit`}>
|
||||||
|
<Button primary>Edit track</Button>
|
||||||
|
</Link>
|
||||||
|
</Button.Group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrackComments({comments, login, hideLoader}) {
|
||||||
|
return (
|
||||||
|
<Segment basic>
|
||||||
|
<Comment.Group>
|
||||||
|
<Header as="h2" dividing>
|
||||||
|
Comments
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<Loader active={!hideLoader && comments == null} inline />
|
||||||
|
|
||||||
|
{comments?.map((comment: TrackComment) => (
|
||||||
|
<Comment key={comment.id}>
|
||||||
|
<Comment.Avatar src={comment.author.image} />
|
||||||
|
<Comment.Content>
|
||||||
|
<Comment.Author as="a">{comment.author.username}</Comment.Author>
|
||||||
|
<Comment.Metadata>
|
||||||
|
<div><FormattedDate date={comment.createdAt} relative /></div>
|
||||||
|
</Comment.Metadata>
|
||||||
|
<Comment.Text>{comment.body}</Comment.Text>
|
||||||
|
</Comment.Content>
|
||||||
|
</Comment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
|
||||||
|
{login && comments != null && <Form reply>
|
||||||
|
<Form.TextArea rows={4} />
|
||||||
|
<Button content='Post comment' labelPosition='left' icon='edit' primary />
|
||||||
|
</Form>}
|
||||||
|
</Comment.Group>
|
||||||
|
</Segment>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TrackPage = connect((state) => ({login: state.login}))(function TrackPage({login}) {
|
||||||
|
const {slug} = useParams()
|
||||||
|
|
||||||
|
const data: {
|
||||||
|
track: null | Track
|
||||||
|
trackData: null | TrackData
|
||||||
|
comments: null | TrackComments
|
||||||
|
} | null = useObservable(
|
||||||
|
(_$, args$) => {
|
||||||
|
const slug$ = args$.pipe(pluck(0), distinctUntilChanged())
|
||||||
|
const track$ = slug$.pipe(
|
||||||
|
map((slug) => '/tracks/' + slug),
|
||||||
|
switchMap((url) => concat(of(null), from(api.fetch(url)))),
|
||||||
|
pluck('track')
|
||||||
|
)
|
||||||
|
|
||||||
|
const trackData$ = slug$.pipe(
|
||||||
|
map((slug) => '/tracks/' + slug + '/data'),
|
||||||
|
switchMap((url) => concat(of(null), from(api.fetch(url)))),
|
||||||
|
pluck('trackData'),
|
||||||
|
startWith(null) // show track infos before track data is loaded
|
||||||
|
)
|
||||||
|
|
||||||
|
const comments$ = slug$.pipe(
|
||||||
|
map((slug) => '/tracks/' + slug + '/comments'),
|
||||||
|
switchMap((url) => concat(of(null), from(api.fetch(url)))),
|
||||||
|
pluck('comments'),
|
||||||
|
startWith(null) // show track infos before comments are loaded
|
||||||
|
)
|
||||||
|
|
||||||
|
return combineLatest([track$, trackData$, comments$]).pipe(
|
||||||
|
map(([track, trackData, comments]) => ({track, trackData, comments}))
|
||||||
|
)
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
[slug]
|
||||||
|
)
|
||||||
|
|
||||||
|
const isAuthor = login?.username === data?.track?.author?.username
|
||||||
|
|
||||||
|
const {track, trackData, comments} = data || {}
|
||||||
|
|
||||||
|
const loading = track == null || trackData == null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<Grid stackable>
|
||||||
|
<Grid.Row>
|
||||||
|
<Grid.Column width={12}>
|
||||||
|
<div style={{position: 'relative'}}>
|
||||||
|
<Loader active={loading} />
|
||||||
|
<Dimmer.Dimmable blurring dimmed={loading}>
|
||||||
|
<Map style={{height: '60vh', minHeight: 400}}>
|
||||||
|
<Map.TileLayer />
|
||||||
|
</Map>
|
||||||
|
</Dimmer.Dimmable>
|
||||||
|
</div>
|
||||||
|
</Grid.Column>
|
||||||
|
<Grid.Column width={4}>
|
||||||
|
<Segment>
|
||||||
|
{track && (
|
||||||
|
<>
|
||||||
|
<Header as='h1'>{track.title}</Header>
|
||||||
|
<TrackDetails {...{track, trackData, isAuthor}} />
|
||||||
|
{isAuthor && <TrackActions {...{slug}} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Segment>
|
||||||
|
</Grid.Column>
|
||||||
|
</Grid.Row>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TrackComments {...{hideLoader: loading, comments, login}} />
|
||||||
|
|
||||||
|
{/* <pre>{JSON.stringify(data, null, 2)}</pre> */}
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default TrackPage
|
115
src/pages/TracksPage.tsx
Normal file
115
src/pages/TracksPage.tsx
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {connect} from 'react-redux'
|
||||||
|
import {Item, Tab, Loader, Pagination, Icon} from 'semantic-ui-react'
|
||||||
|
import {useObservable} from 'rxjs-hooks'
|
||||||
|
import {Link, useHistory, useRouteMatch} from 'react-router-dom'
|
||||||
|
import {of, from, concat} from 'rxjs'
|
||||||
|
import {map, switchMap, distinctUntilChanged, debounceTime} from 'rxjs/operators'
|
||||||
|
import _ from 'lodash'
|
||||||
|
|
||||||
|
import type {Track} from '../types'
|
||||||
|
import {Page} from '../components'
|
||||||
|
import api from '../api'
|
||||||
|
import {useQueryParam, stringifyParams} from '../query'
|
||||||
|
|
||||||
|
function TracksPageTabs() {
|
||||||
|
const history = useHistory()
|
||||||
|
const panes = React.useMemo(
|
||||||
|
() => [
|
||||||
|
{menuItem: 'Global Feed', url: '/feed'},
|
||||||
|
{menuItem: 'Your Feed', url: '/feed/my'},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const onTabChange = React.useCallback(
|
||||||
|
(e, data) => {
|
||||||
|
history.push(panes[data.activeIndex].url)
|
||||||
|
},
|
||||||
|
[history, panes]
|
||||||
|
)
|
||||||
|
|
||||||
|
const isFeedPage = useRouteMatch('/feed/my')
|
||||||
|
const activeIndex = isFeedPage ? 1 : 0
|
||||||
|
|
||||||
|
return <Tab menu={{secondary: true, pointing: true}} {...{panes, onTabChange, activeIndex}} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrackList({privateFeed}: {privateFeed: boolean}) {
|
||||||
|
const [page, setPage] = useQueryParam<number>('page', 1, Number)
|
||||||
|
console.log('page', page)
|
||||||
|
|
||||||
|
const pageSize = 10
|
||||||
|
|
||||||
|
const data: {
|
||||||
|
tracks: Track[],
|
||||||
|
tracksCount: number,
|
||||||
|
} | null = useObservable(
|
||||||
|
(_$, inputs$) =>
|
||||||
|
inputs$.pipe(
|
||||||
|
map(([page, privateFeed]) => {
|
||||||
|
const url = '/tracks' + (privateFeed ? '/feed' : '')
|
||||||
|
const params = {limit: pageSize, offset: pageSize * (page - 1)}
|
||||||
|
return {url, params}
|
||||||
|
}),
|
||||||
|
distinctUntilChanged(_.isEqual),
|
||||||
|
switchMap((request) => concat(of(null), from(api.fetch(request.url + '?' + stringifyParams(request.params)))))
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
[page, privateFeed]
|
||||||
|
)
|
||||||
|
|
||||||
|
const {tracks, tracksCount} = data || {tracks: [], tracksCount: 0}
|
||||||
|
const loading = !data
|
||||||
|
const totalPages = tracksCount / pageSize
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Loader content="Loading" active={loading} />
|
||||||
|
{!loading && totalPages > 1 && <Pagination activePage={page} onPageChange={(e, data) => setPage(data.activePage as number)} totalPages={totalPages} />}
|
||||||
|
|
||||||
|
{tracks && (
|
||||||
|
<Item.Group divided>
|
||||||
|
{tracks.map((track: Track) => (
|
||||||
|
<TrackListItem key={track.slug} {...{track, privateFeed}} />
|
||||||
|
))}
|
||||||
|
</Item.Group>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrackListItem({track, privateFeed = false}) {
|
||||||
|
return <Item key={track.slug}>
|
||||||
|
<Item.Image size="tiny" src={track.author.image} />
|
||||||
|
<Item.Content>
|
||||||
|
<Item.Header as={Link} to={`/tracks/${track.slug}`}>{track.title}</Item.Header>
|
||||||
|
<Item.Meta>
|
||||||
|
Created by {track.author.username} on {track.createdAt}
|
||||||
|
</Item.Meta>
|
||||||
|
<Item.Description>{track.description}</Item.Description>
|
||||||
|
{privateFeed && <Item.Extra>
|
||||||
|
{track.visible ? (
|
||||||
|
<>
|
||||||
|
<Icon color="blue" name="eye" fitted /> Public
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon name="eye slash" fitted /> Private
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Item.Extra>}
|
||||||
|
</Item.Content>
|
||||||
|
</Item>
|
||||||
|
}
|
||||||
|
|
||||||
|
const TracksPage = connect((state) => ({login: (state as any).login}))(function TracksPage({login, privateFeed}) {
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
{login ? <TracksPageTabs /> : null}
|
||||||
|
<TrackList {...{privateFeed}} />
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default TracksPage
|
|
@ -1,3 +1,6 @@
|
||||||
|
export {default as HomePage} from './HomePage'
|
||||||
export {default as LoginPage} from './LoginPage'
|
export {default as LoginPage} from './LoginPage'
|
||||||
export {default as LogoutPage} from './LogoutPage'
|
export {default as LogoutPage} from './LogoutPage'
|
||||||
export {default as NotFoundPage} from './NotFoundPage'
|
export {default as NotFoundPage} from './NotFoundPage'
|
||||||
|
export {default as TrackPage} from './TrackPage'
|
||||||
|
export {default as TracksPage} from './TracksPage'
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import {useMemo} from 'react'
|
import {useMemo} from 'react'
|
||||||
import {useHistory} from 'react-router-dom'
|
import {useHistory, useLocation} from 'react-router-dom'
|
||||||
|
|
||||||
type QueryValue = string | number
|
type QueryValue = string | number
|
||||||
type QueryParams = {[key: string]: QueryValue}
|
type QueryParams = {[key: string]: QueryValue}
|
||||||
|
@ -52,6 +52,7 @@ export function useQueryParam<T extends QueryValue>(
|
||||||
convert: (t: T | null) => T | null = (x) => x
|
convert: (t: T | null) => T | null = (x) => x
|
||||||
): [T, (newValue: T) => void] {
|
): [T, (newValue: T) => void] {
|
||||||
const history = useHistory()
|
const history = useHistory()
|
||||||
|
const _triggerReload = useLocation()
|
||||||
const {[name]: value = defaultValue} = (parseQuery(history.location.search) as unknown) as {
|
const {[name]: value = defaultValue} = (parseQuery(history.location.search) as unknown) as {
|
||||||
[name: string]: T
|
[name: string]: T
|
||||||
}
|
}
|
||||||
|
|
45
src/types.ts
Normal file
45
src/types.ts
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
export type UserProfile = {
|
||||||
|
username: string,
|
||||||
|
image: string,
|
||||||
|
bio?: string|null,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Track = {
|
||||||
|
slug: string,
|
||||||
|
author: UserProfile,
|
||||||
|
title: string,
|
||||||
|
description?: string,
|
||||||
|
createdAt: string,
|
||||||
|
visible?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export type TrackData = {
|
||||||
|
slug: string,
|
||||||
|
numEvents?: number|null,
|
||||||
|
recordedAt?: String|null,
|
||||||
|
recordedUntil?: String|null,
|
||||||
|
trackLength?: number|null,
|
||||||
|
points: TrackPoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TrackPoint = {
|
||||||
|
date: string|null,
|
||||||
|
time: string|null,
|
||||||
|
latitude: number|null,
|
||||||
|
longitude: number|null,
|
||||||
|
course: number|null,
|
||||||
|
speed: number|null,
|
||||||
|
d1: number|null,
|
||||||
|
d2: number|null,
|
||||||
|
flag: number|null,
|
||||||
|
private: number|null,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TrackComment = {
|
||||||
|
id: string,
|
||||||
|
body: string,
|
||||||
|
createdAt: string,
|
||||||
|
author: UserProfile
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue