Compare commits
139 Commits
v2.1.0
...
jpmens-aut
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f780bbede3 | ||
|
|
7877aaa9f8 | ||
|
|
341ce4c353 | ||
|
|
5ecad2bf40 | ||
|
|
3365959ed3 | ||
|
|
15a40f9c6c | ||
|
|
9839b5acdd | ||
|
|
0300e2fb4f | ||
|
|
4c680590a4 | ||
|
|
551b226fd0 | ||
|
|
132f15c52b | ||
|
|
c60bfb5368 | ||
|
|
31bf39795c | ||
|
|
d5c87a8727 | ||
|
|
0492b355bf | ||
|
|
998a97131b | ||
|
|
a44965226c | ||
|
|
554ce3f585 | ||
|
|
05ae629217 | ||
|
|
b141444b56 | ||
|
|
723ce684ae | ||
|
|
ed3e6125e9 | ||
|
|
8df1f86ab9 | ||
|
|
b5442363d6 | ||
|
|
35d55b57b1 | ||
|
|
976bb403d1 | ||
|
|
cecf7e797d | ||
|
|
91d99cd8da | ||
|
|
06faa73b70 | ||
|
|
7398da74c5 | ||
|
|
7b954dfbe3 | ||
|
|
c569aced1e | ||
|
|
3fad44509e | ||
|
|
aa13ddd832 | ||
|
|
6a2b113fb2 | ||
|
|
6f047ffa77 | ||
|
|
d5d6c1c268 | ||
|
|
4e86d8fac3 | ||
|
|
1cb6e3519e | ||
|
|
f5389b84ab | ||
|
|
9bb2edb78d | ||
|
|
6361d8f415 | ||
|
|
791b756d80 | ||
|
|
f1ef82d7bb | ||
|
|
aaef181141 | ||
|
|
b2273c071b | ||
|
|
865c89b43c | ||
|
|
32c64d18f5 | ||
|
|
5a64c06af0 | ||
|
|
8c3681b6ad | ||
|
|
89899de565 | ||
|
|
a386c15de1 | ||
|
|
8ac24c99aa | ||
|
|
f3cbf877f9 | ||
|
|
f5c1c82010 | ||
|
|
3ea1d02c65 | ||
|
|
f91341b205 | ||
|
|
0c983d6206 | ||
|
|
9e36d31997 | ||
|
|
5e37c7f4b8 | ||
|
|
7dda60d457 | ||
|
|
228900ff9f | ||
|
|
129446de1a | ||
|
|
af6c308bd6 | ||
|
|
223e19a118 | ||
|
|
1260814309 | ||
|
|
cfffbe9472 | ||
|
|
4031bda2f0 | ||
|
|
69094e240e | ||
|
|
dfa7a423fa | ||
|
|
411bc10b0b | ||
|
|
a994051940 | ||
|
|
d325543bc6 | ||
|
|
80d3060fa8 | ||
|
|
e6c79ac606 | ||
|
|
0b1271502f | ||
|
|
fdddd8e035 | ||
|
|
245c1295e5 | ||
|
|
9786487646 | ||
|
|
b3529c211c | ||
|
|
55178c7cc8 | ||
|
|
2fcf2151fa | ||
|
|
5c6370090f | ||
|
|
fc0189e5e2 | ||
|
|
04fb50667b | ||
|
|
6359b4783c | ||
|
|
4679f7fbb7 | ||
|
|
b29cd12ed9 | ||
|
|
a9026c7a0a | ||
|
|
27070812a4 | ||
|
|
75e79fb0b1 | ||
|
|
4bb9a20787 | ||
|
|
1a47fd1b6c | ||
|
|
163e0e3ec7 | ||
|
|
8d8664a338 | ||
|
|
045e635c21 | ||
|
|
7db7837dfd | ||
|
|
beb522c03e | ||
|
|
658cb6b223 | ||
|
|
7ab98be4ad | ||
|
|
6d4d47b5a1 | ||
|
|
159470181c | ||
|
|
b53a0be707 | ||
|
|
4e70d3a3ad | ||
|
|
b42f6db024 | ||
|
|
3fbf0a2ff1 | ||
|
|
206eb268fa | ||
|
|
d7266f48f1 | ||
|
|
9ec17e3e9c | ||
|
|
e320441b5e | ||
|
|
1282d93769 | ||
|
|
b8661b11fb | ||
|
|
4f5bfefc36 | ||
|
|
a1faf8153b | ||
|
|
23d73461bc | ||
|
|
90dac4022a | ||
|
|
58b22aeebe | ||
|
|
cb79a4de81 | ||
|
|
c5f491b6fb | ||
|
|
49a9b54a5e | ||
|
|
c449fcaf21 | ||
|
|
1746c359f4 | ||
|
|
6180e52f71 | ||
|
|
0f0f29fcee | ||
|
|
cb0694b032 | ||
|
|
d0f2a99302 | ||
|
|
c8b0ec8b9e | ||
|
|
906eb2a1b4 | ||
|
|
005aab715f | ||
|
|
b76cbdc2e6 | ||
|
|
bb87ec01d4 | ||
|
|
c3e6b775f9 | ||
|
|
a9998e8e3b | ||
|
|
2c4ead262a | ||
|
|
6b1d35be51 | ||
|
|
34cc4895b0 | ||
|
|
57caacb548 | ||
|
|
9783b6f27d | ||
|
|
b262ff602c |
36
.eslintrc.js
@@ -1,36 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
extends: ["plugin:vue/essential", "@vue/prettier"],
|
||||
rules: {
|
||||
"no-console": process.env.NODE_ENV === "production" ? "error" : "warn",
|
||||
"no-debugger": process.env.NODE_ENV === "production" ? "error" : "warn",
|
||||
"max-len": [
|
||||
"error",
|
||||
{
|
||||
ignoreUrls: true,
|
||||
},
|
||||
],
|
||||
"prettier/prettier": [
|
||||
"error",
|
||||
{
|
||||
trailingComma: "es5",
|
||||
printWidth: 80,
|
||||
htmlWhitespaceSensitivity: "ignore",
|
||||
},
|
||||
],
|
||||
},
|
||||
parserOptions: {
|
||||
parser: "babel-eslint",
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ["**/__tests__/*.{j,t}s?(x)"],
|
||||
env: {
|
||||
jest: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
14
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
name: Build
|
||||
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- run: npm install
|
||||
- run: npm run build
|
||||
79
.github/workflows/docker.yml
vendored
Normal file
@@ -0,0 +1,79 @@
|
||||
name: Build Docker images
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 3 * * *' # everyday at 3am
|
||||
pull_request:
|
||||
branches: main
|
||||
push:
|
||||
branches: main
|
||||
tags:
|
||||
- v*
|
||||
release:
|
||||
types: [published]
|
||||
branches: main
|
||||
tags:
|
||||
- v*
|
||||
|
||||
jobs:
|
||||
build-docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
- name: Prepare
|
||||
id: prepare
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
run: |
|
||||
DOCKER_IMAGE=owntracks/frontend
|
||||
DOCKER_PLATFORMS=linux/amd64,linux/arm/v7,linux/arm64
|
||||
VERSION=main
|
||||
|
||||
if [[ $GITHUB_REF == refs/tags/* ]]; then
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
fi
|
||||
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||
VERSION=nightly
|
||||
fi
|
||||
|
||||
TAGS="--tag ${DOCKER_IMAGE}:${VERSION}"
|
||||
if [[ $VERSION =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
|
||||
TAGS="$TAGS --tag ${DOCKER_IMAGE}:latest"
|
||||
fi
|
||||
|
||||
echo ::set-output name=docker_image::${DOCKER_IMAGE}
|
||||
echo ::set-output name=version::${VERSION}
|
||||
echo ::set-output name=buildx_args::--platform ${DOCKER_PLATFORMS} \
|
||||
--build-arg VERSION=${VERSION} \
|
||||
--build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') \
|
||||
--build-arg VCS_REF=${GITHUB_SHA::8} \
|
||||
${TAGS} --file ./docker/Dockerfile .
|
||||
# https://github.com/docker/setup-qemu-action
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
# https://github.com/docker/setup-buildx-action
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
- name: Docker Buildx (build)
|
||||
run: |
|
||||
docker buildx build --output "type=image,push=false" ${{ steps.prepare.outputs.buildx_args }}
|
||||
- name: Docker Login
|
||||
if: success() && github.event_name != 'pull_request'
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
run: |
|
||||
echo "${DOCKER_PASSWORD}" | docker login --username "${DOCKER_USERNAME}" --password-stdin
|
||||
- name: Docker Buildx (push)
|
||||
if: success() && github.event_name != 'pull_request'
|
||||
run: |
|
||||
docker buildx build --output "type=image,push=true" ${{ steps.prepare.outputs.buildx_args }}
|
||||
- name: Docker Check Manifest
|
||||
if: always() && github.event_name != 'pull_request'
|
||||
run: |
|
||||
docker run --rm mplatform/mquery ${{ steps.prepare.outputs.docker_image }}:${{ steps.prepare.outputs.version }}
|
||||
- name: Clear
|
||||
if: always() && github.event_name != 'pull_request'
|
||||
run: |
|
||||
rm -f ${HOME}/.docker/config.json
|
||||
16
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
name: Lint
|
||||
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- run: npm install
|
||||
- run: npm run lint:js
|
||||
- run: npm run lint:md
|
||||
- run: npm run lint:scss
|
||||
14
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
name: Tests
|
||||
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- run: npm install
|
||||
- run: npm test
|
||||
20
.github/workflows/upload-dist.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
name: Upload dist/
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
upload-dist:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- run: npm install
|
||||
- run: npm run build
|
||||
- run: zip -r $GITHUB_REF_NAME-dist.zip dist/
|
||||
- run: gh release upload $GITHUB_REF_NAME $GITHUB_REF_NAME-dist.zip
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,8 +0,0 @@
|
||||
language: node_js
|
||||
node_js:
|
||||
- 10
|
||||
cache: yarn
|
||||
script:
|
||||
- yarn lint:js
|
||||
- yarn lint:md
|
||||
- yarn test
|
||||
127
CHANGELOG.md
@@ -2,6 +2,133 @@
|
||||
|
||||
Dates are in UTC.
|
||||
|
||||
## 2.15.3 (2024-06-15)
|
||||
|
||||
- Force relative path for `config/config.js` even if it doesn't exist at build time
|
||||
|
||||
## 2.15.2 (2024-06-14)
|
||||
|
||||
- Fix npm lockfile
|
||||
|
||||
## 2.15.1 (2024-06-14)
|
||||
|
||||
- Update `index.html` to emit relative paths again, allowing deployment under a subpath
|
||||
- Update Docker image to use nginx 1.27
|
||||
- Upgrade dependencies
|
||||
|
||||
## 2.15.0 (2024-06-10)
|
||||
|
||||
- Implement POI map layer (see [Booklet](https://owntracks.org/booklet/features/poi/))
|
||||
|
||||
- Use the `map.poiMarker` config option to tweak the appearance, defaults to a red circle slightly larger than the default location points
|
||||
- Use `map.layers.poi` to change the layer visibility, defaults to `true`
|
||||
|
||||
## 2.14.0 (2024-06-09)
|
||||
|
||||
- Implement new date/time range picker ([#116](https://github.com/owntracks/frontend/pull/116), [@jduar](https://github.com/jduar) / [@Tofee](https://github.com/Tofee))
|
||||
|
||||
## 2.13.1 (2024-06-09)
|
||||
|
||||
- Bump versions, just to make sure the frontend shows the right one
|
||||
|
||||
## 2.13.0 (2024-06-09)
|
||||
|
||||
- Enable use of the frontend as a progressive web app (PWA) ([#98](https://github.com/owntracks/frontend/pull/98), [@RobinMeis](https://github.com/RobinMeis))
|
||||
- Add Turkish translations ([#94](https://github.com/owntracks/frontend/pull/94), [@ramazansancar](https://github.com/ramazansancar))
|
||||
- Add Slovak translations ([#110](https://github.com/owntracks/frontend/pull/110), [@aasami](https://github.com/aasami))
|
||||
- Add Czech translations ([#115](https://github.com/owntracks/frontend/pull/115), [@jmencak](https://github.com/jmencak))
|
||||
- Add action for uploading dist/ on release ([#114](https://github.com/owntracks/frontend/pull/114), [@abaumg](https://github.com/abaumg))
|
||||
- Replace outdated Twitter link with Mastodon
|
||||
- Remove the download modal
|
||||
- Show isolocal and tzname properties on the popup
|
||||
- Various changes to the underlying frontend build system:
|
||||
- Bump node to version 20
|
||||
- Switch from yarn to npm
|
||||
- Migrate from vue-cli / webpack to vite
|
||||
- Upgrade dependencies
|
||||
|
||||
## 2.12.0 (2022-09-06)
|
||||
|
||||
- Add Danish translation ([#87](https://github.com/owntracks/frontend/pull/87), [@atjn](https://github.com/atjn))
|
||||
- Ensure correct display of larger (192x192) face images ([#83](https://github.com/owntracks/frontend/pull/83), [@atjn](https://github.com/atjn))
|
||||
- Add `map.tileSize` and `map.zoomOffset` options ([#75](https://github.com/owntracks/frontend/pull/75), [@saesh](https://github.com/saesh))
|
||||
- Upgrade dependencies
|
||||
|
||||
## 2.11.0 (2022-03-16)
|
||||
|
||||
- Show WiFi SSID and BSSID in location popup, if available
|
||||
- Show address in location popup, if available ([#73](https://github.com/owntracks/frontend/pull/73), [@saesh](https://github.com/saesh))
|
||||
- Upgrade dependencies
|
||||
|
||||
## 2.10.0 (2021-11-28)
|
||||
|
||||
- Ensure location history line segments are drawn in chronological order ([#67](https://github.com/owntracks/frontend/issues/67))
|
||||
- Add trailing slashes to paths used by Docker nginx config ([#63](https://github.com/owntracks/frontend/pull/63), [@growse](https://github.com/growse))
|
||||
- Upgrade dependencies
|
||||
|
||||
## 2.9.0 (2021-05-01)
|
||||
|
||||
- Add a cancel button to the loading data modal
|
||||
- Replace remaining uses of "OwnTracks UI" with "OwnTracks Frontend"
|
||||
- Upgrade dependencies
|
||||
|
||||
## 2.8.0 (2021-02-19)
|
||||
|
||||
- Add elevation gain / loss to "distance travelled" calculation ([#51](https://github.com/owntracks/frontend/issues/51))
|
||||
|
||||
## 2.7.0 (2021-02-14)
|
||||
|
||||
- Rename translation files from `xx` to `xx-XX` format to allow different language variants
|
||||
- Separate `en` translations into British English (`en-GB`) and American English (`en-US`, default)
|
||||
- Add French translations ([#49](https://github.com/owntracks/frontend/pull/49), [@Elu43](https://github.com/Elu43))
|
||||
- Update Docker image to use Node 14 and nginx 1.18
|
||||
- Upgrade dependencies
|
||||
|
||||
## 2.6.0 (2020-12-29)
|
||||
|
||||
- Add `router.basePath` config option for non-webroot deployments
|
||||
- Configure Vue to not assume it's on the web root ([#47](https://github.com/owntracks/frontend/pull/47), [@growse](https://github.com/growse))
|
||||
- Update Docker NGINX config to listen on IPv6 as well ([#46](https://github.com/owntracks/frontend/pull/46), [@growse](https://github.com/growse))
|
||||
- Upgrade dependencies
|
||||
|
||||
## 2.5.1 (2020-10-27)
|
||||
|
||||
- Fix incorrect handling of `api.baseUrl` with trailing slash ([#44](https://github.com/owntracks/frontend/pull/44), [@karmanyaahm](https://github.com/karmanyaahm))
|
||||
- Upgrade dependencies
|
||||
|
||||
## 2.5.0 (2020-09-07)
|
||||
|
||||
- Add `filters.fitView` config option - this will prevent the map from re-fitting automatically by default when a live location changes ([#41](https://github.com/owntracks/frontend/issues/41))
|
||||
- Show regions for location on popup
|
||||
- Fix vertical offset of non-pin popups
|
||||
- Build Docker images for multiple architectures (linux/amd64, linux/arm/v7, linux/arm64) using GitHub Actions ([#38](https://github.com/owntracks/frontend/pull/38), [@wollew](https://github.com/wollew))
|
||||
- Replace Travis CI with GitHub Actions build/lint/test workflows ([#39](https://github.com/owntracks/frontend/pull/39))
|
||||
- Replace node-sass with sass (dart-sass)
|
||||
- Upgrade dependencies
|
||||
|
||||
## 2.4.0 (2020-06-01)
|
||||
|
||||
- Add `filters.minAccuracy` config option - this allows ignoring location points which do
|
||||
not meet the configured accuracy requirement ([#35](https://github.com/owntracks/frontend/issues/35))
|
||||
- Upgrade dependencies
|
||||
|
||||
## 2.3.1 (2020-05-09)
|
||||
|
||||
- Fix linting issue in `config.md`
|
||||
|
||||
## 2.3.0 (2020-05-09)
|
||||
|
||||
- Add `api.fetchOptions` config option - this allows sending custom HTTP headers or including
|
||||
cookies in the request
|
||||
- Upgrade dependencies
|
||||
|
||||
## 2.2.0 (2020-03-18)
|
||||
|
||||
- Improve mobile layout further:
|
||||
- Reduce header paddings
|
||||
- Align buttons/dropdowns
|
||||
- Upgrade dependencies
|
||||
|
||||
## 2.1.0 (2020-03-18)
|
||||
|
||||
- Replace default Leaflet marker with a custom one ([#2](https://github.com/owntracks/frontend/issues/2))
|
||||
|
||||
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019-2020 Linus Groh
|
||||
Copyright (c) 2019-2024 Linus Groh
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
69
README.md
@@ -1,11 +1,14 @@
|
||||
# OwnTracks UI
|
||||
# OwnTracks Frontend
|
||||
|
||||

|
||||
[](https://hub.docker.com/r/owntracks/frontend)
|
||||
[](https://travis-ci.org/owntracks/frontend)
|
||||
[](https://github.com/owntracks/frontend/blob/master/LICENSE)
|
||||
[](https://github.com/owntracks/frontend/actions?query=workflow%3ABuild+branch%3Amain)
|
||||
[](https://github.com/owntracks/frontend/actions?query=workflow%3ATests+branch%3Amain)
|
||||
[](https://github.com/owntracks/frontend/actions?query=workflow%3ALint+branch%3Amain)
|
||||
[](https://github.com/prettier/prettier)
|
||||
[](https://github.com/owntracks/frontend/blob/main/LICENSE)
|
||||
|
||||

|
||||

|
||||
|
||||
## Introduction
|
||||
|
||||
@@ -13,23 +16,27 @@ This is a web interface for [OwnTracks](https://github.com/owntracks/recorder) b
|
||||
a Vue.js single page application. The recorder itself already ships with some basic web
|
||||
pages, this is a more advanced interface with more functionality, all in one place.
|
||||
|
||||

|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- Last known (i.e. live) locations:
|
||||
- Accuracy visualization (circle)
|
||||
- Device friendly name and icon
|
||||
- Detailed information (if available): time, latitude, longitude, height, battery and
|
||||
speed
|
||||
- Detailed information (if available): time, latitude, longitude, height, battery,
|
||||
speed and regions
|
||||
- Location history (data points, line or both)
|
||||
- Location heatmap
|
||||
- Quickly fit all shown objects on the map into view
|
||||
- Display data in a specific date range
|
||||
- Filter by user and device
|
||||
- Download selected location data as JSON
|
||||
- Display data in a specific date and time range
|
||||
- Filter by user or specific device
|
||||
- Calculation of distance travelled
|
||||
- Highly customisable
|
||||
|
||||
## Notes
|
||||
|
||||
Frontend has no provision for user management, authentication, or for configuring TLS; these are tasks which a HTTP server you use will have to provide, and an explanation on how to do that is beyond our scope; you should be able to find a myriad explanatory documents for the server you wish to use. Also note, that even if you set up authentication, any user successfully being able to access Frontend will be able to see all Recorder data, as the API of the latter doesn't distinguish querying users.
|
||||
|
||||
## Installation
|
||||
|
||||
### Docker
|
||||
@@ -49,7 +56,7 @@ compose config, and the service is named `otrecorder`):
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
owntracks-ui:
|
||||
owntracks-frontend:
|
||||
image: owntracks/frontend
|
||||
ports:
|
||||
- 80:80
|
||||
@@ -77,8 +84,8 @@ directory as `docker-compose.yml`)
|
||||
|
||||
### Manually
|
||||
|
||||
- Run `yarn install` to install dependencies
|
||||
- Run `yarn build` to compile and minify for production
|
||||
- Run `npm install` to install dependencies
|
||||
- Run `npm run build` to compile and minify for production
|
||||
- Copy the content of the `dist/` directory to your webroot
|
||||
|
||||
## Configuration
|
||||
@@ -93,13 +100,15 @@ See [`docs/config.md`](docs/config.md) for all available options.
|
||||
|
||||
## Development
|
||||
|
||||
- Run `yarn install` to install dependencies
|
||||
- Run `yarn serve` to compile for development and start the hot-reload server
|
||||
- Run `yarn lint:js` to lint JavaScript/Vue files
|
||||
- Run `yarn lint:md` to lint Markdown files
|
||||
- Run `yarn format:js` to format JavaScript/Vue files
|
||||
- Run `yarn format:md` to format Markdown files
|
||||
- Run `yarn test` to run unit tests
|
||||
- Run `npm install` to install dependencies
|
||||
- Run `npm run dev` to compile for development and start the hot-reload server
|
||||
- Run `npm run lint:js` to lint JavaScript/Vue files
|
||||
- Run `npm run lint:md` to lint Markdown files
|
||||
- Run `npm run lint:scss` to lint SCSS files
|
||||
- Run `npm run format:js` to format JavaScript/Vue files
|
||||
- Run `npm run format:md` to format Markdown files
|
||||
- Run `npm run format:scss` to format SCSS files
|
||||
- Run `npm test` to run unit tests
|
||||
|
||||
### CORS-Proxy
|
||||
|
||||
@@ -107,7 +116,7 @@ You can use the [`corsProxy.js`](scripts/corsProxy.js) script to use your produc
|
||||
instance of OwnTracks for development without making changes to its CORS-Headers:
|
||||
|
||||
```console
|
||||
$ yarn cors-proxy
|
||||
$ npm run cors-proxy
|
||||
```
|
||||
|
||||
If you have [basic authentication](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#Basic_authentication_scheme)
|
||||
@@ -121,7 +130,7 @@ OT_BASIC_AUTH_PASSWORD='P@$$w0rd'
|
||||
Then run:
|
||||
|
||||
```console
|
||||
$ env $(cat .env | xargs) yarn cors-proxy
|
||||
$ env $(cat .env | xargs) npm run cors-proxy
|
||||
```
|
||||
|
||||
The default host and port it binds to is `0.0.0.0:8888`. Change using the `OT_PROXY_HOST`
|
||||
@@ -135,27 +144,15 @@ This project uses [Vue I18n](https://kazupon.github.io/vue-i18n/). To see missin
|
||||
unused i18n entries, run:
|
||||
|
||||
```console
|
||||
$ yarn i18n:report
|
||||
$ npm run i18n:report
|
||||
```
|
||||
|
||||
To add a new locale, copy `en.json` to `<locale>.json` in [`src/locales`](src/locales)
|
||||
To add a new locale, copy `en-US.json` to `<locale>.json` in [`src/locales`](src/locales)
|
||||
and start translating the individual strings. Make sure to [mention the new locale to the docs](docs/config.md#locale)!
|
||||
|
||||
For a specific example see commit [`b2edda4`](https://github.com/owntracks/frontend/commit/b2edda410f16633aa6fd9cd4e5250f2031536c7d)
|
||||
where German translations were added.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/loading.gif" alt="Loading...">
|
||||
<br>
|
||||
<br>
|
||||
<img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/downloader.png" alt="Download location data">
|
||||
<br>
|
||||
<br>
|
||||
<img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/info.png" alt="Info">
|
||||
</p>
|
||||
|
||||
## Contributing
|
||||
|
||||
Please feel free to open an issue and discuss your ideas and report bugs. If you think
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
module.exports = {
|
||||
presets: ["@vue/cli-plugin-babel/preset"],
|
||||
};
|
||||
@@ -1,13 +1,13 @@
|
||||
FROM node:10 as build
|
||||
FROM node:20 as build
|
||||
WORKDIR /usr/src/app
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm install
|
||||
COPY . ./
|
||||
RUN yarn build
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.17-alpine
|
||||
LABEL version="2.1.0"
|
||||
LABEL description="OwnTracks UI"
|
||||
FROM nginx:1.27-alpine
|
||||
LABEL version="2.15.3"
|
||||
LABEL description="OwnTracks Frontend"
|
||||
LABEL maintainer="Linus Groh <mail@linusgroh.de>"
|
||||
ENV LISTEN_PORT=80 \
|
||||
SERVER_HOST=otrecorder \
|
||||
|
||||
@@ -8,11 +8,12 @@ http {
|
||||
}
|
||||
server {
|
||||
listen ${LISTEN_PORT};
|
||||
listen [::]:${LISTEN_PORT};
|
||||
root /usr/share/nginx/html;
|
||||
location /api {
|
||||
location /api/ {
|
||||
proxy_pass http://otrecorder/api/;
|
||||
}
|
||||
location /ws {
|
||||
location /ws/ {
|
||||
proxy_pass http://otrecorder/ws/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
@@ -30,6 +31,7 @@ http {
|
||||
gzip_buffers 16 8k;
|
||||
gzip_http_version 1.1;
|
||||
gzip_types text/plain text/css application/json application/javascript text/javascript;
|
||||
proxy_read_timeout 600;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
169
docs/config.md
@@ -24,7 +24,10 @@ window.owntracks.config = {};
|
||||
|
||||
- `api`
|
||||
- [`baseUrl`](#apibaseurl)
|
||||
- [`fetchOptions`](#apifetchoptions)
|
||||
- [`endDateTime`](#enddatetime)
|
||||
- `filters`
|
||||
- [`minAccuracy`](#filtersminaccuracy)
|
||||
- [`ignorePingLocation`](#ignorepinglocation)
|
||||
- [`locale`](#locale)
|
||||
- `map`
|
||||
@@ -50,15 +53,20 @@ window.owntracks.config = {};
|
||||
- [`heatmap`](#maplayersheatmap)
|
||||
- [`last`](#maplayerslast)
|
||||
- [`line`](#maplayersline)
|
||||
- [`poi`](#maplayerspoi)
|
||||
- [`points`](#maplayerspoints)
|
||||
- [`maxNativeZoom`](#mapmaxnativezoom)
|
||||
- [`maxPointDistance`](#mapmaxpointdistance)
|
||||
- [`maxZoom`](#mapmaxzoom)
|
||||
- [`poiMarker`](#mappoimarker)
|
||||
- [`polyline`](#mappolyline)
|
||||
- [`url`](#mapurl)
|
||||
- `onLocationChange`
|
||||
- [`fitView`](#onlocationchangefitview)
|
||||
- [`reloadHistory`](#onlocationchangereloadhistory)
|
||||
- [`primaryColor`](#primarycolor)
|
||||
- `router`
|
||||
- [`basePath`](#routerbasepath)
|
||||
- [`selectedDevice`](#selecteddevice)
|
||||
- [`selectedUser`](#selecteduser)
|
||||
- [`showDistanceTravelled`](#showdistancetravelled)
|
||||
@@ -76,16 +84,36 @@ Base URL for the recorder's HTTP and WebSocket API. Keep CORS in mind.
|
||||
// API requests will be made to https://owntracks.example.com/api/0/...
|
||||
window.owntracks.config = {
|
||||
api: {
|
||||
baseUrl: "https://owntracks.example.com"
|
||||
}
|
||||
baseUrl: "https://owntracks.example.com",
|
||||
},
|
||||
};
|
||||
```
|
||||
```js
|
||||
// API requests will be made to https://example.com/owntracks/api/0/...
|
||||
window.owntracks.config = {
|
||||
api: {
|
||||
baseUrl: "https://example.com/owntracks/"
|
||||
}
|
||||
baseUrl: "https://example.com/owntracks/",
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### `api.fetchOptions`
|
||||
|
||||
Options for API requests (made with `fetch()`). See [`fetch()` docs on MDN] for details.
|
||||
|
||||
You can use this for example to send custom HTTP headers or to include cookies in the request.
|
||||
|
||||
- Type: [`Object`]
|
||||
- Default: `{}`
|
||||
- Example:
|
||||
```js
|
||||
// Include credentials (e.g. cookies)
|
||||
window.owntracks.config = {
|
||||
api: {
|
||||
fetchOptions: {
|
||||
credentials: "include",
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
@@ -99,7 +127,27 @@ Initial end date and time (browser timezone) for fetched data.
|
||||
```js
|
||||
// Data will be fetched up to 1970-01-01
|
||||
window.owntracks.config = {
|
||||
endDateTime: new Date(1970, 1, 1)
|
||||
endDateTime: new Date(1970, 1, 1),
|
||||
};
|
||||
```
|
||||
|
||||
### `filters.minAccuracy`
|
||||
|
||||
Minimum accuracy in meters for location points to be rendered & included in the travelled distance.
|
||||
|
||||
This filter is disabled by default as accuracies can vary across devices an locations, but you're
|
||||
encouraged to set it as it can be a simple way to remove outliers and vastly improve the travelled
|
||||
distance calculation.
|
||||
|
||||
- Type: [`Number`] or `null`
|
||||
- Default: `null`
|
||||
- Example:
|
||||
```js
|
||||
// Don't include location points with an accuracy exceeding 100 meters
|
||||
window.owntracks.config = {
|
||||
filters: {
|
||||
minAccuracy: 100,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
@@ -114,7 +162,7 @@ Remove the `ping/ping` location from the fetched data. This is useful when using
|
||||
```js
|
||||
// Don't ignore ping/ping location. Not sure why you'd do this :)
|
||||
window.owntracks.config = {
|
||||
ignorePingLocation: false
|
||||
ignorePingLocation: false,
|
||||
};
|
||||
```
|
||||
|
||||
@@ -125,14 +173,21 @@ formats.
|
||||
|
||||
Available languages:
|
||||
|
||||
- `de` (German)
|
||||
- `en` (English)
|
||||
- `es` (Spanish)
|
||||
- `cs-CZ` (Standard Czech)
|
||||
- `da-DK` (Standard Danish)
|
||||
- `de-DE` (Standard German)
|
||||
- `en-GB` (British English)
|
||||
- `en-US` (American English)
|
||||
- `es-ES` (Castilian Spanish)
|
||||
- `fr-FR` (Standard French)
|
||||
- `sk-SK` (Standard Slovak)
|
||||
- `tr-TR` (Standard Turkish)
|
||||
|
||||
You can use formats like `en-GB`, `en-US`, `de-DE`.
|
||||
Using a locale with non-existent translations is possible and will affect date/time formats, but
|
||||
use `en-US` for translations.
|
||||
|
||||
- Type: [`String`]
|
||||
- Default: `"en"`
|
||||
- Default: `"en-US"`
|
||||
|
||||
### `map.attribution`
|
||||
|
||||
@@ -145,8 +200,8 @@ Attribution for map tiles.
|
||||
// Make sure to add proper attribution!
|
||||
window.owntracks.config = {
|
||||
map: {
|
||||
attribution: "Map tiles © MyTileServerProvider"
|
||||
}
|
||||
attribution: "Map tiles © MyTileServerProvider",
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
@@ -284,9 +339,16 @@ Initial visibility of the line layer.
|
||||
- Type: [`Boolean`]
|
||||
- Default: `true`
|
||||
|
||||
### `map.layers.poi`
|
||||
|
||||
Initial visibility of the POI layer.
|
||||
|
||||
- Type: [`Boolean`]
|
||||
- Default: `true`
|
||||
|
||||
### `map.layers.points`
|
||||
|
||||
Initial visibility of the points layer.
|
||||
Initial visibility of the location points layer.
|
||||
|
||||
- Type: [`Boolean`]
|
||||
- Default: `false`
|
||||
@@ -313,8 +375,8 @@ splitting into separate lines.
|
||||
// Don't connect points with a distance of more than 1km
|
||||
window.owntracks.config = {
|
||||
map: {
|
||||
maxPointDistance: 1000
|
||||
}
|
||||
maxPointDistance: 1000,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
@@ -327,9 +389,25 @@ to disable.
|
||||
- Type: [`Number`]
|
||||
- Default: `21`
|
||||
|
||||
### `map.poiMarker`
|
||||
|
||||
POI marker configuration. See [Vue2Leaflet `l-circle-marker` documentation](https://korigan.github.io/Vue2Leaflet/#/components/l-circle-marker/)
|
||||
for all possible values.
|
||||
|
||||
- Type: [`Object`]
|
||||
- Default:
|
||||
```js
|
||||
{
|
||||
color: "red",
|
||||
fillColor: "red",
|
||||
fillOpacity: 0.2,
|
||||
radius: 12
|
||||
}
|
||||
```
|
||||
|
||||
### `map.polyline`
|
||||
|
||||
Location point marker configuation. `color` defaults to `primaryColor` if `null`. See
|
||||
Location point marker configuration. `color` defaults to `primaryColor` if `null`. See
|
||||
[Vue2Leaflet `l-polyline` documentation](https://korigan.github.io/Vue2Leaflet/#/components/l-polyline/)
|
||||
for all possible values.
|
||||
|
||||
@@ -342,6 +420,14 @@ for all possible values.
|
||||
}
|
||||
```
|
||||
|
||||
### `map.tileSize`
|
||||
|
||||
Size of the tiles in pixels returned by the tile server. Can be used together with
|
||||
[`map.zoomOffset`](#map.zoomOffset) to configure bigger tile sizes.
|
||||
|
||||
- Type: [`Number`]
|
||||
- Default: `256`
|
||||
|
||||
### `map.url`
|
||||
|
||||
Tile server URL. For more information see [Leaflet tile layer documentation](https://leafletjs.com/reference-1.5.0.html#tilelayer-url-template)
|
||||
@@ -354,12 +440,28 @@ and [this Wikipedia article](https://en.wikipedia.org/wiki/Tiled_web_map).
|
||||
// Use dark HDPI tiles from Mapbox
|
||||
window.owntracks.config = {
|
||||
map: {
|
||||
url:
|
||||
"https://api.mapbox.com/v4/mapbox.dark/{z}/{x}/{y}@2x.png?access_token=xxxxxxxxxxxxxxxx"
|
||||
}
|
||||
url: "https://api.mapbox.com/styles/v1/mapbox/dark-v10/tiles/{z}/{x}/{y}@2x?access_token=xxxxxxxxxxxxxxxx",
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### `map.zoomOffset`
|
||||
|
||||
Offset the zoom level to account for different tile sizes. For example tiles with a
|
||||
size of 512x512 need an offset of -1 and for 1024x1024 an offset of -2.
|
||||
|
||||
- Type: [`Number`]
|
||||
- Default: `0`
|
||||
|
||||
### `onLocationChange.fitView`
|
||||
|
||||
Whether to re-fit the map's content into view or not when a location update is received.
|
||||
|
||||
This can be useful if you're showing live locations and don't want them to "leave" the map.
|
||||
|
||||
- Type: [`Boolean`]
|
||||
- Default: `false`
|
||||
|
||||
### `onLocationChange.reloadHistory`
|
||||
|
||||
Whether to reload the location history (of selected date range) or not when a location
|
||||
@@ -378,7 +480,23 @@ Primary color for the user interface (navigation bar and various map elements).
|
||||
```js
|
||||
// Set the UI's primary color to 'rebeccapurple'
|
||||
window.owntracks.config = {
|
||||
primaryColor: "rebeccapurple"
|
||||
primaryColor: "rebeccapurple",
|
||||
};
|
||||
```
|
||||
|
||||
### `router.basePath`
|
||||
|
||||
Base path of the application deployment.
|
||||
|
||||
- Type: [`String`]
|
||||
- Default: `"/"`
|
||||
- Example:
|
||||
```js
|
||||
// Frontend will be reachable at https://example.com/owntracks
|
||||
window.owntracks.config = {
|
||||
router: {
|
||||
basePath: "/owntracks",
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
@@ -397,7 +515,7 @@ amount of data fetched after page load.
|
||||
// Select the device 'phone' from user 'foo' by default
|
||||
window.owntracks.config = {
|
||||
selectedUser: "foo",
|
||||
selectedDevice: "phone"
|
||||
selectedDevice: "phone",
|
||||
};
|
||||
```
|
||||
|
||||
@@ -414,7 +532,7 @@ amount of data fetched after page load.
|
||||
```js
|
||||
// Select all devices from user 'foo' by default
|
||||
window.owntracks.config = {
|
||||
selectedUser: "foo"
|
||||
selectedUser: "foo",
|
||||
};
|
||||
```
|
||||
|
||||
@@ -425,6 +543,8 @@ header bar. `maxPointDistance` is being takein into account, if a distance betwe
|
||||
subsequent points is greater than `maxPointDistance`, it will not contibute to the
|
||||
calculated travelled distance.
|
||||
|
||||
This also includes a calculation of elevation gain / loss.
|
||||
|
||||
- Type: [`Boolean`]
|
||||
- Default: `true`
|
||||
|
||||
@@ -441,7 +561,7 @@ Initial start date and time (browser timezone) for fetched data.
|
||||
startDateTime.setHours(0, 0, 0, 0);
|
||||
startDateTime.setDate(1);
|
||||
window.owntracks.config = {
|
||||
startDateTime
|
||||
startDateTime,
|
||||
};
|
||||
```
|
||||
|
||||
@@ -458,3 +578,4 @@ Whether to enable verbose mode or not.
|
||||
[`object`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object
|
||||
[`string`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String
|
||||
[css `<color>`]: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value
|
||||
[`fetch()` docs on mdn]: https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters
|
||||
|
||||
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 370 KiB |
|
Before Width: | Height: | Size: 653 KiB After Width: | Height: | Size: 653 KiB |
47
eslint.config.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import eslintPluginVue from "eslint-plugin-vue";
|
||||
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
|
||||
import vueParser from "vue-eslint-parser";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const eslintrc = new FlatCompat({
|
||||
baseDirectory: dirname(fileURLToPath(import.meta.url)),
|
||||
});
|
||||
|
||||
export default [
|
||||
...eslintrc.extends("plugin:vue/essential"),
|
||||
eslintPluginPrettierRecommended,
|
||||
{
|
||||
languageOptions: {
|
||||
parser: vueParser,
|
||||
},
|
||||
plugins: {
|
||||
vue: eslintPluginVue,
|
||||
},
|
||||
rules: {
|
||||
"no-console": process.env.NODE_ENV === "production" ? "error" : "warn",
|
||||
"no-debugger": process.env.NODE_ENV === "production" ? "error" : "warn",
|
||||
"max-len": [
|
||||
"error",
|
||||
{
|
||||
ignoreUrls: true,
|
||||
},
|
||||
],
|
||||
"prettier/prettier": [
|
||||
"error",
|
||||
{
|
||||
trailingComma: "es5",
|
||||
printWidth: 80,
|
||||
htmlWhitespaceSensitivity: "ignore",
|
||||
},
|
||||
],
|
||||
"vue/multi-word-component-names": [
|
||||
"error",
|
||||
{
|
||||
ignores: ["Map"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,18 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||
<title>OwnTracks UI</title>
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<link rel="manifest" crossorigin="use-credentials" href="/manifest.json">
|
||||
<title>OwnTracks Frontend</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<strong>We're sorry but OwnTracks doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
<div id="app"></div>
|
||||
<script src="<%= BASE_URL %>config/config.js"></script>
|
||||
<!-- built files will be auto injected -->
|
||||
<script src="./config/config.js"></script>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,23 +0,0 @@
|
||||
module.exports = {
|
||||
moduleFileExtensions: ["js", "jsx", "json", "vue"],
|
||||
transform: {
|
||||
"^.+\\.vue$": "vue-jest",
|
||||
".+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$":
|
||||
"jest-transform-stub",
|
||||
"^.+\\.jsx?$": "babel-jest",
|
||||
},
|
||||
transformIgnorePatterns: ["/node_modules/"],
|
||||
moduleNameMapper: {
|
||||
"^@/(.*)$": "<rootDir>/src/$1",
|
||||
},
|
||||
snapshotSerializers: ["jest-serializer-vue"],
|
||||
testMatch: [
|
||||
"**/tests/**/*.test.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)",
|
||||
],
|
||||
testURL: "http://localhost/",
|
||||
watchPlugins: [
|
||||
"jest-watch-typeahead/filename",
|
||||
"jest-watch-typeahead/testname",
|
||||
],
|
||||
setupFiles: ["<rootDir>/tests/setup.js"],
|
||||
};
|
||||
4911
package-lock.json
generated
Normal file
106
package.json
@@ -1,64 +1,62 @@
|
||||
{
|
||||
"name": "owntracks-ui",
|
||||
"version": "2.1.0",
|
||||
"name": "owntracks-frontend",
|
||||
"version": "2.15.3",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
"name": "Linus Groh",
|
||||
"email": "mail@linusgroh.de"
|
||||
},
|
||||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"cors-proxy": "node scripts/corsProxy.js",
|
||||
"format:js": "vue-cli-service lint",
|
||||
"format:md": "prettier --write '{*.md,docs/**/*.md,src/**/*.md}'",
|
||||
"i18n:report": "vue-cli-service i18n:report --src './src/**/*.?(js|vue)' --locales './src/locales/**/*.json'",
|
||||
"lint:js": "vue-cli-service lint --no-fix",
|
||||
"lint:md": "prettier --check '{*.md,docs/**/*.md,src/**/*.md}'",
|
||||
"test": "vue-cli-service test:unit"
|
||||
},
|
||||
"dependencies": {
|
||||
"clipboard-copy": "^3.1.0",
|
||||
"core-js": "^3.6.4",
|
||||
"deepmerge": "^4.2.2",
|
||||
"leaflet": "^1.6.0",
|
||||
"leaflet.heat": "^0.2.0",
|
||||
"moment": "^2.24.0",
|
||||
"vue": "^2.6.11",
|
||||
"vue-ctk-date-time-picker": "^2.4.0",
|
||||
"vue-feather-icons": "^5.0.0",
|
||||
"vue-i18n": "^8.15.5",
|
||||
"vue-js-modal": "^1.3.33",
|
||||
"vue-mq": "^1.0.1",
|
||||
"vue-outside-events": "^1.1.3",
|
||||
"vue-router": "^3.1.6",
|
||||
"vue2-leaflet": "^2.5.2",
|
||||
"vuex": "^3.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-babel": "^4.2.3",
|
||||
"@vue/cli-plugin-eslint": "^4.2.3",
|
||||
"@vue/cli-plugin-unit-jest": "^4.2.3",
|
||||
"@vue/cli-service": "^4.2.3",
|
||||
"@vue/eslint-config-prettier": "^6.0.0",
|
||||
"@vue/test-utils": "1.0.0-beta.32",
|
||||
"babel-core": "7.0.0-bridge.0",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"babel-jest": "^25.1.0",
|
||||
"cors-anywhere": "^0.4.1",
|
||||
"eslint": "^6.8.0",
|
||||
"eslint-plugin-prettier": "^3.1.2",
|
||||
"eslint-plugin-vue": "^6.2.2",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"lint-staged": "^10.0.8",
|
||||
"moment-locales-webpack-plugin": "^1.1.2",
|
||||
"node-sass": "^4.13.1",
|
||||
"sass-loader": "^8.0.2",
|
||||
"vue-cli-plugin-i18n": "^0.6.1",
|
||||
"vue-template-compiler": "^2.6.11"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/owntracks/frontend.git"
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"cors-proxy": "node scripts/corsProxy.js",
|
||||
"format:js": "eslint --fix 'src/**/*.{js,vue}'",
|
||||
"format:md": "prettier --write '{*.md,docs/**/*.md,src/**/*.md}'",
|
||||
"format:scss": "prettier --write 'src/**/*.scss'",
|
||||
"lint:js": "eslint 'src/**/*.{js,vue}'",
|
||||
"lint:md": "prettier --check '{*.md,docs/**/*.md,src/**/*.md}'",
|
||||
"lint:scss": "prettier --check 'src/**/*.scss'",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"deepmerge": "^4.3.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.heat": "^0.2.0",
|
||||
"moment": "^2.30.1",
|
||||
"vue": "^2.7.16",
|
||||
"vue-feather-icons": "^5.1.0",
|
||||
"vue-i18n": "^8.28.2",
|
||||
"vue-js-modal": "^2.0.1",
|
||||
"vue-mq": "^1.0.1",
|
||||
"vue-outside-events": "^1.1.3",
|
||||
"vue-router": "^3.6.5",
|
||||
"vue2-datepicker": "^3.11.1",
|
||||
"vue2-leaflet": "^2.7.1",
|
||||
"vuex": "^3.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.1.0",
|
||||
"@vitejs/plugin-vue2": "^2.3.1",
|
||||
"cors-anywhere": "^0.4.4",
|
||||
"eslint": "^9.6.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-vue": "^9.27.0",
|
||||
"jsdom": "^24.1.0",
|
||||
"lint-staged": "^15.2.7",
|
||||
"moment-locales-webpack-plugin": "^1.2.0",
|
||||
"prettier": "^3.3.2",
|
||||
"sass": "^1.77.6",
|
||||
"vite": "^5.3.3",
|
||||
"vite-plugin-package-version": "^1.1.0",
|
||||
"vitest": "^1.6.0",
|
||||
"vitest-fetch-mock": "^0.2.2",
|
||||
"vue-eslint-parser": "^9.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
189
public/OwnTracks.svg
Normal file
|
After Width: | Height: | Size: 35 KiB |
@@ -1,3 +1,3 @@
|
||||
// Here you can overwite the default configuration values
|
||||
// Here you can overwrite the default configuration values
|
||||
window.owntracks = window.owntracks || {};
|
||||
window.owntracks.config = {};
|
||||
|
||||
BIN
public/icon-180x180.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
20
public/manifest.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "OwnTracks Frontend",
|
||||
"description": "OwnTracks Frontend",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icon-180x180.png",
|
||||
"type": "image/png",
|
||||
"sizes": "180x180"
|
||||
},
|
||||
{
|
||||
"src": "OwnTracks.svg",
|
||||
"sizes": "any"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"background_color": "#3f51b5",
|
||||
"display": "standalone",
|
||||
"scope": ".",
|
||||
"theme_color": "#3f51b5"
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
const corsProxy = require("cors-anywhere");
|
||||
import { createServer } from "cors-anywhere";
|
||||
|
||||
const host = process.env.OT_PROXY_HOST || "0.0.0.0";
|
||||
const port = process.env.OT_PROXY_PORT || 8888;
|
||||
@@ -20,6 +20,6 @@ if (username !== null && password !== null) {
|
||||
};
|
||||
}
|
||||
|
||||
corsProxy.createServer(options).listen(port, host, () => {
|
||||
createServer(options).listen(port, host, () => {
|
||||
console.log(`Running CORS Anywhere on http://${host}:${port}`);
|
||||
});
|
||||
|
||||
22
src/App.vue
@@ -4,28 +4,22 @@
|
||||
<main>
|
||||
<router-view />
|
||||
</main>
|
||||
<DownloadModal />
|
||||
<InformationModal />
|
||||
<LoadingModal />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@import "styles/main";
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { mapActions } from "vuex";
|
||||
|
||||
import * as types from "@/store/mutation-types";
|
||||
import { log } from "@/logging";
|
||||
import AppHeader from "@/components/AppHeader";
|
||||
import DownloadModal from "@/components/modals/Download";
|
||||
import InformationModal from "@/components/modals/Information";
|
||||
import LoadingModal from "@/components/modals/Loading";
|
||||
import AppHeader from "@/components/AppHeader.vue";
|
||||
import InformationModal from "@/components/modals/InformationModal.vue";
|
||||
import LoadingModal from "@/components/modals/LoadingModal.vue";
|
||||
|
||||
export default {
|
||||
components: { AppHeader, DownloadModal, InformationModal, LoadingModal },
|
||||
components: { AppHeader, InformationModal, LoadingModal },
|
||||
created() {
|
||||
document.documentElement.style.setProperty(
|
||||
"--color-primary",
|
||||
@@ -34,7 +28,7 @@ export default {
|
||||
this.populateStateFromQuery(this.$route.query);
|
||||
this.loadData();
|
||||
// Update URL query params when relevant values changes
|
||||
this.$store.subscribe(mutation => {
|
||||
this.$store.subscribe((mutation) => {
|
||||
if (
|
||||
[
|
||||
types.SET_SELECTED_USER,
|
||||
@@ -73,7 +67,7 @@ export default {
|
||||
selectedDevice: device,
|
||||
} = this.$store.state;
|
||||
const activeLayers = Object.keys(map.layers).filter(
|
||||
key => map.layers[key] === true
|
||||
(key) => map.layers[key] === true
|
||||
);
|
||||
const query = {
|
||||
lat: map.center.lat,
|
||||
@@ -95,3 +89,7 @@ export default {
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import "styles/main";
|
||||
</style>
|
||||
|
||||
87
src/api.js
@@ -1,3 +1,4 @@
|
||||
import config from "@/config";
|
||||
import { log, logLevels } from "@/logging";
|
||||
import { getApiUrl, getLocationHistoryCount } from "@/util";
|
||||
|
||||
@@ -6,19 +7,30 @@ import { getApiUrl, getLocationHistoryCount } from "@/util";
|
||||
*
|
||||
* @param {String} path API resource path
|
||||
* @param {Object} [params] Query parameters
|
||||
* @returns {Promise} Promise returned by the fetch function
|
||||
* @param {Object} [fetchOptions]
|
||||
* fetch() options (merged with config.api.fetchOptions)
|
||||
* @returns {Promise<Response>} Response returned by the fetch call
|
||||
*/
|
||||
const fetchApi = (path, params = {}) => {
|
||||
const fetchApi = (path, params = {}, fetchOptions = {}) => {
|
||||
const url = getApiUrl(path);
|
||||
Object.keys(params).forEach(key => url.searchParams.append(key, params[key]));
|
||||
Object.keys(params).forEach((key) => url.searchParams.set(key, params[key]));
|
||||
log("HTTP", `GET ${url.href}`);
|
||||
return fetch(url.href).catch(error => log("HTTP", error, logLevels.ERROR));
|
||||
return fetch(url.href, {
|
||||
...fetchOptions,
|
||||
...config.api.fetchOptions,
|
||||
}).catch((error) => {
|
||||
if (error.name === "AbortError") {
|
||||
log("HTTP", `GET ${url.href} - Request was aborted`, logLevels.WARNING);
|
||||
} else {
|
||||
log("HTTP", error, logLevels.ERROR);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the recorder's version.
|
||||
*
|
||||
* @returns {String} Version
|
||||
* @returns {Promise<String>} Version
|
||||
*/
|
||||
export const getVersion = async () => {
|
||||
const response = await fetchApi("/api/0/version");
|
||||
@@ -31,7 +43,7 @@ export const getVersion = async () => {
|
||||
/**
|
||||
* Get all users.
|
||||
*
|
||||
* @returns {User[]} Array of usernames
|
||||
* @returns {Promise<User[]>} Array of usernames
|
||||
*/
|
||||
export const getUsers = async () => {
|
||||
const response = await fetchApi("/api/0/list");
|
||||
@@ -45,13 +57,13 @@ export const getUsers = async () => {
|
||||
* Get all devices for the provided users.
|
||||
*
|
||||
* @param {User[]} users Array of usernames
|
||||
* @returns {{User: Device[]}}
|
||||
* @returns {Promise<{User: Device[]}>}
|
||||
* Object mapping each username to an array of device names
|
||||
*/
|
||||
export const getDevices = async users => {
|
||||
export const getDevices = async (users) => {
|
||||
const devices = {};
|
||||
await Promise.all(
|
||||
users.map(async user => {
|
||||
users.map(async (user) => {
|
||||
const response = await fetchApi(`/api/0/list`, { user });
|
||||
const json = await response.json();
|
||||
const userDevices = json.results;
|
||||
@@ -60,7 +72,7 @@ export const getDevices = async users => {
|
||||
);
|
||||
log("API", () => {
|
||||
const devicesCount = Object.keys(devices)
|
||||
.map(user => devices[user].length)
|
||||
.map((user) => devices[user].length)
|
||||
.reduce((a, b) => a + b, 0);
|
||||
return (
|
||||
`[getDevices] Fetched ${devicesCount} ` +
|
||||
@@ -75,7 +87,7 @@ export const getDevices = async users => {
|
||||
*
|
||||
* @param {User} [user] Get last locations of all devices from this user
|
||||
* @param {Device} [device] Get last location of specific device
|
||||
* @returns {OTLocation[]} Array of last location objects
|
||||
* @returns {Promise<OTLocation[]>} Array of last location objects
|
||||
*/
|
||||
export const getLastLocations = async (user, device) => {
|
||||
const params = {};
|
||||
@@ -102,23 +114,33 @@ export const getLastLocations = async (user, device) => {
|
||||
* @param {Device} device Device name
|
||||
* @param {String} start Start date and time in UTC
|
||||
* @param {String} end End date and time in UTC
|
||||
* @returns {OTLocation[]} Array of location history objects
|
||||
* @param {Object} [fetchOptions] fetch() options
|
||||
* @returns {Promise<OTLocation[]>} Array of location history objects
|
||||
*/
|
||||
export const getUserDeviceLocationHistory = async (
|
||||
user,
|
||||
device,
|
||||
start,
|
||||
end
|
||||
end,
|
||||
fetchOptions
|
||||
) => {
|
||||
const response = await fetchApi("/api/0/locations", {
|
||||
from: start,
|
||||
to: end,
|
||||
user,
|
||||
device,
|
||||
format: "json",
|
||||
});
|
||||
const response = await fetchApi(
|
||||
"/api/0/locations",
|
||||
{
|
||||
from: start,
|
||||
to: end,
|
||||
user,
|
||||
device,
|
||||
format: "json",
|
||||
},
|
||||
fetchOptions
|
||||
);
|
||||
const json = await response.json();
|
||||
const userDeviceLocationHistory = json.data;
|
||||
// We need to manually sort by timestamp, otherwise the line segments may be
|
||||
// drawn in the wrong order. The recorder API simply returns entries in the
|
||||
// same order in which they are in each *.rec file.
|
||||
// See https://github.com/owntracks/frontend/issues/67.
|
||||
const userDeviceLocationHistory = json.data.sort((a, b) => a.tst - b.tst);
|
||||
log(
|
||||
"API",
|
||||
() =>
|
||||
@@ -136,20 +158,22 @@ export const getUserDeviceLocationHistory = async (
|
||||
* Devices of which the history should be fetched
|
||||
* @param {String} start Start date and time in UTC
|
||||
* @param {String} end End date and time in UTC
|
||||
* @returns {LocationHistory} Location history
|
||||
* @param {Object} [fetchOptions] fetch() options
|
||||
* @returns {Promise<LocationHistory>} Location history
|
||||
*/
|
||||
export const getLocationHistory = async (devices, start, end) => {
|
||||
export const getLocationHistory = async (devices, start, end, fetchOptions) => {
|
||||
const locationHistory = {};
|
||||
await Promise.all(
|
||||
Object.keys(devices).map(async user => {
|
||||
Object.keys(devices).map(async (user) => {
|
||||
locationHistory[user] = {};
|
||||
await Promise.all(
|
||||
devices[user].map(async device => {
|
||||
devices[user].map(async (device) => {
|
||||
locationHistory[user][device] = await getUserDeviceLocationHistory(
|
||||
user,
|
||||
device,
|
||||
start,
|
||||
end
|
||||
end,
|
||||
fetchOptions
|
||||
);
|
||||
})
|
||||
);
|
||||
@@ -171,7 +195,7 @@ export const getLocationHistory = async (devices, start, end) => {
|
||||
*
|
||||
* @param {WebSocketLocationCallback} [callback] Callback for location messages
|
||||
*/
|
||||
export const connectWebsocket = async callback => {
|
||||
export const connectWebsocket = async (callback) => {
|
||||
let url = getApiUrl("/ws/last");
|
||||
url.protocol = url.protocol.replace("http", "ws");
|
||||
url = url.href;
|
||||
@@ -181,16 +205,17 @@ export const connectWebsocket = async callback => {
|
||||
log("WS", "Connected");
|
||||
ws.send("LAST");
|
||||
};
|
||||
ws.onclose = event => {
|
||||
ws.onclose = (event) => {
|
||||
log(
|
||||
"WS",
|
||||
`Disconnected unexpectedly (reason: ${event.reason ||
|
||||
"unknown"}). Reconnecting in one second.`,
|
||||
`Disconnected unexpectedly (reason: ${
|
||||
event.reason || "unknown"
|
||||
}). Reconnecting in one second.`,
|
||||
logLevels.WARNING
|
||||
);
|
||||
setTimeout(connectWebsocket, 1000);
|
||||
};
|
||||
ws.onmessage = async msg => {
|
||||
ws.onmessage = async (msg) => {
|
||||
if (msg.data) {
|
||||
try {
|
||||
const data = JSON.parse(msg.data);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<header>
|
||||
<header :class="$mq === 'sm' ? 'header-sm' : null">
|
||||
<div v-if="$mq === 'sm'" class="header-item">
|
||||
<button
|
||||
class="button button-flat button-icon"
|
||||
@@ -32,7 +32,10 @@
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<LayersIcon size="1x" aria-hidden="true" role="img" />
|
||||
<Dropdown :label="$t('Layer settings')" :title="$t('Show/hide layers')">
|
||||
<DropdownButton
|
||||
:label="$t('Layer settings')"
|
||||
:title="$t('Show/hide layers')"
|
||||
>
|
||||
<label v-for="option in layerSettingsOptions" :key="option.layer">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -46,39 +49,35 @@
|
||||
/>
|
||||
{{ option.label }}
|
||||
</label>
|
||||
</Dropdown>
|
||||
</DropdownButton>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<CalendarIcon size="1x" aria-hidden="true" role="img" />
|
||||
<VueCtkDateTimePicker
|
||||
v-model="startDateTime"
|
||||
:format="DATE_TIME_FORMAT"
|
||||
:color="$config.primaryColor"
|
||||
:locale="$config.locale"
|
||||
:max-date="endDateTime"
|
||||
:button-now-translation="$t('Now')"
|
||||
<date-picker
|
||||
v-model="dateTimeRange"
|
||||
type="datetime"
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
:editable="false"
|
||||
:clearable="false"
|
||||
:confirm="true"
|
||||
:show-second="false"
|
||||
:range="true"
|
||||
range-separator=" – "
|
||||
:shortcuts="shortcuts"
|
||||
:show-time-panel="showTimeRangePanel"
|
||||
:disabled-date="(date, _) => date > new Date()"
|
||||
@change="handleDateTimeRangeChange"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-button button"
|
||||
:title="$t('Select start date')"
|
||||
/>
|
||||
</VueCtkDateTimePicker>
|
||||
<span>{{ $t("to") }}</span>
|
||||
<VueCtkDateTimePicker
|
||||
v-model="endDateTime"
|
||||
:format="DATE_TIME_FORMAT"
|
||||
:color="$config.primaryColor"
|
||||
:locale="$config.locale"
|
||||
:min-date="startDateTime"
|
||||
:button-now-translation="$t('Now')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="dropdown-button button"
|
||||
:title="$t('Select end date')"
|
||||
/>
|
||||
</VueCtkDateTimePicker>
|
||||
<template v-slot:footer>
|
||||
<button
|
||||
class="mx-btn toggle-date-btn"
|
||||
type="button"
|
||||
@click="toggleTimeRangePanel"
|
||||
>
|
||||
{{ showTimeRangePanel ? $t("Select date") : $t("Select time") }}
|
||||
</button>
|
||||
</template>
|
||||
</date-picker>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<UserIcon size="1x" aria-hidden="true" role="img" />
|
||||
@@ -90,7 +89,7 @@
|
||||
<option :value="null">
|
||||
{{ $t("Show all") }}
|
||||
</option>
|
||||
<option v-for="user in users" :value="user" :key="user">
|
||||
<option v-for="user in users" :key="user" :value="user">
|
||||
{{ user }}
|
||||
</option>
|
||||
</select>
|
||||
@@ -107,8 +106,8 @@
|
||||
</option>
|
||||
<option
|
||||
v-for="device in devices[selectedUser]"
|
||||
:value="device"
|
||||
:key="`${selectedUser}-${device}`"
|
||||
:value="device"
|
||||
>
|
||||
{{ device }}
|
||||
</option>
|
||||
@@ -118,23 +117,19 @@
|
||||
<nav class="header-item header-item-right">
|
||||
<div
|
||||
v-if="$config.showDistanceTravelled && distanceTravelled"
|
||||
class="nav-item"
|
||||
:title="$t('Distance travelled')"
|
||||
class="nav-item distance-travelled"
|
||||
>
|
||||
{{ humanReadableDistance(distanceTravelled) }}
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<button
|
||||
class="button button-flat button-icon"
|
||||
:title="$t('Download raw data')"
|
||||
@click="$modal.show('download')"
|
||||
>
|
||||
<DownloadIcon
|
||||
size="1x"
|
||||
:aria-label="$t('Download raw data')"
|
||||
role="img"
|
||||
/>
|
||||
</button>
|
||||
<span :title="$t('Distance travelled')">
|
||||
{{ humanReadableDistance(distanceTravelled) }}
|
||||
</span>
|
||||
<br />
|
||||
<span :title="$t('Elevation gain / loss')">
|
||||
<ArrowUpIcon size="0.8x" role="img" />
|
||||
{{ humanReadableDistance(elevationGain) }}
|
||||
/
|
||||
<ArrowDownIcon size="0.8x" role="img" />
|
||||
{{ humanReadableDistance(elevationLoss) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="nav-item">
|
||||
<button
|
||||
@@ -153,35 +148,38 @@
|
||||
import moment from "moment";
|
||||
import { mapActions, mapGetters, mapMutations, mapState } from "vuex";
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CalendarIcon,
|
||||
CrosshairIcon,
|
||||
DownloadIcon,
|
||||
InfoIcon,
|
||||
LayersIcon,
|
||||
MenuIcon,
|
||||
SmartphoneIcon,
|
||||
UserIcon,
|
||||
} from "vue-feather-icons";
|
||||
import VueCtkDateTimePicker from "vue-ctk-date-time-picker";
|
||||
import "vue-ctk-date-time-picker/dist/vue-ctk-date-time-picker.css";
|
||||
|
||||
import Dropdown from "@/components/Dropdown";
|
||||
import DatePicker from "vue2-datepicker";
|
||||
import "vue2-datepicker/index.css";
|
||||
|
||||
import DropdownButton from "@/components/DropdownButton.vue";
|
||||
import { DATE_TIME_FORMAT } from "@/constants";
|
||||
import * as types from "@/store/mutation-types";
|
||||
import { humanReadableDistance } from "@/util";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CalendarIcon,
|
||||
CrosshairIcon,
|
||||
DownloadIcon,
|
||||
DatePicker,
|
||||
InfoIcon,
|
||||
LayersIcon,
|
||||
MenuIcon,
|
||||
SmartphoneIcon,
|
||||
UserIcon,
|
||||
VueCtkDateTimePicker,
|
||||
Dropdown,
|
||||
DropdownButton,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -191,12 +189,121 @@ export default {
|
||||
{ layer: "line", label: this.$t("Show location history (line)") },
|
||||
{ layer: "points", label: this.$t("Show location history (points)") },
|
||||
{ layer: "heatmap", label: this.$t("Show location heatmap") },
|
||||
{ layer: "poi", label: this.$t("Show points of interest") },
|
||||
],
|
||||
showMobileNav: false,
|
||||
shortcuts: [
|
||||
{
|
||||
text: this.$t("Today"),
|
||||
onClick() {
|
||||
const end = new Date();
|
||||
end.setHours(23, 59, 59, 0);
|
||||
const start = new Date();
|
||||
start.setHours(0, 0, 0, 0);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
{
|
||||
text: this.$t("Yesterday"),
|
||||
onClick() {
|
||||
const end = new Date();
|
||||
end.setDate(end.getDate() - 1);
|
||||
end.setHours(23, 59, 59, 0);
|
||||
const start = new Date(end);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
{
|
||||
text: this.$t("3 days"),
|
||||
onClick() {
|
||||
const end = new Date();
|
||||
end.setHours(23, 59, 59, 0);
|
||||
const start = new Date();
|
||||
start.setDate(end.getDate() - 3);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
{
|
||||
text: this.$t("7 days"),
|
||||
onClick() {
|
||||
const end = new Date();
|
||||
end.setHours(23, 59, 59, 0);
|
||||
const start = new Date();
|
||||
start.setDate(end.getDate() - 7);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
{
|
||||
text: this.$t("15 days"),
|
||||
onClick() {
|
||||
const end = new Date();
|
||||
end.setHours(23, 59, 59, 0);
|
||||
const start = new Date();
|
||||
start.setDate(end.getDate() - 15);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
{
|
||||
text: this.$t("30 days"),
|
||||
onClick() {
|
||||
const end = new Date();
|
||||
end.setHours(23, 59, 59, 0);
|
||||
const start = new Date();
|
||||
start.setDate(end.getDate() - 30);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
{
|
||||
text: this.$t("3 months"),
|
||||
onClick() {
|
||||
const end = new Date();
|
||||
end.setHours(23, 59, 59, 0);
|
||||
const start = new Date();
|
||||
start.setMonth(end.getMonth() - 3);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
{
|
||||
text: this.$t("6 months"),
|
||||
onClick() {
|
||||
const end = new Date();
|
||||
end.setHours(23, 59, 59, 0);
|
||||
const start = new Date();
|
||||
start.setMonth(end.getMonth() - 6);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
{
|
||||
text: this.$t("1 year"),
|
||||
onClick() {
|
||||
const end = new Date();
|
||||
end.setHours(23, 59, 59, 0);
|
||||
const start = new Date();
|
||||
start.setFullYear(end.getFullYear() - 1);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
return [start, end];
|
||||
},
|
||||
},
|
||||
],
|
||||
showTimeRangePanel: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState(["users", "devices", "map", "distanceTravelled"]),
|
||||
...mapState([
|
||||
"users",
|
||||
"devices",
|
||||
"map",
|
||||
"distanceTravelled",
|
||||
"elevationGain",
|
||||
"elevationLoss",
|
||||
]),
|
||||
selectedUser: {
|
||||
get() {
|
||||
return this.$store.state.selectedUser;
|
||||
@@ -213,34 +320,25 @@ export default {
|
||||
this.setSelectedDevice(value);
|
||||
},
|
||||
},
|
||||
startDateTime: {
|
||||
dateTimeRange: {
|
||||
get() {
|
||||
return moment
|
||||
const startDateTime = moment
|
||||
.utc(this.$store.state.startDateTime, DATE_TIME_FORMAT)
|
||||
.local()
|
||||
.format(DATE_TIME_FORMAT);
|
||||
},
|
||||
set(value) {
|
||||
this.setStartDateTime(
|
||||
moment(value, DATE_TIME_FORMAT)
|
||||
.utc()
|
||||
.format(DATE_TIME_FORMAT)
|
||||
);
|
||||
},
|
||||
},
|
||||
endDateTime: {
|
||||
get() {
|
||||
return moment
|
||||
.toDate();
|
||||
const endDateTime = moment
|
||||
.utc(this.$store.state.endDateTime, DATE_TIME_FORMAT)
|
||||
.local()
|
||||
.format(DATE_TIME_FORMAT);
|
||||
.toDate();
|
||||
return [startDateTime, endDateTime];
|
||||
},
|
||||
set(value) {
|
||||
set([startDateTime, endDateTime]) {
|
||||
this.setStartDateTime(
|
||||
moment(startDateTime).utc().format(DATE_TIME_FORMAT)
|
||||
);
|
||||
|
||||
this.setEndDateTime(
|
||||
moment(value, DATE_TIME_FORMAT)
|
||||
.set("seconds", 59)
|
||||
.utc()
|
||||
.format(DATE_TIME_FORMAT)
|
||||
moment(endDateTime).set("seconds", 59).utc().format(DATE_TIME_FORMAT)
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -256,6 +354,25 @@ export default {
|
||||
"setEndDateTime",
|
||||
]),
|
||||
humanReadableDistance,
|
||||
toggleTimeRangePanel() {
|
||||
this.showTimeRangePanel = !this.showTimeRangePanel;
|
||||
},
|
||||
// Resetting to date choice after value change
|
||||
handleDateTimeRangeChange(value, type) {
|
||||
this.showTimeRangePanel = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.distance-travelled {
|
||||
text-align: right;
|
||||
line-height: 1.2;
|
||||
|
||||
.feather {
|
||||
margin-top: 3px;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="dropdown" v-focus-outside="hide" v-click-outside="hide">
|
||||
<div v-focus-outside="hide" v-click-outside="hide" class="dropdown">
|
||||
<button class="dropdown-button button" :title="title" @click="toggle">
|
||||
{{ label }}
|
||||
</button>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<LPopup>
|
||||
<LPopup :options="options">
|
||||
<div class="device">{{ deviceName }}</div>
|
||||
<div class="wrapper">
|
||||
<img
|
||||
@@ -7,11 +7,18 @@
|
||||
:src="faceImageDataURI"
|
||||
:alt="$t('Image of {deviceName}', { deviceName })"
|
||||
:title="$t('Image of {deviceName}', { deviceName })"
|
||||
class="face"
|
||||
/>
|
||||
<ul class="info-list">
|
||||
<li :title="$t('Timestamp')">
|
||||
<ClockIcon size="1x" aria-hidden="true" role="img" />
|
||||
{{ new Date(timestamp * 1000).toLocaleString($config.locale) }}
|
||||
<span v-if="isoLocal && timeZone">
|
||||
<br />
|
||||
<code style="font-size: 0.7rem">
|
||||
{{ isoLocal }}[{{ timeZone }}]
|
||||
</code>
|
||||
</span>
|
||||
</li>
|
||||
<li :title="$t('Location')">
|
||||
<MapPinIcon size="1x" aria-hidden="true" role="img" />
|
||||
@@ -33,37 +40,27 @@
|
||||
<ZapIcon size="1x" aria-hidden="true" role="img" />
|
||||
{{ speed }} km/h
|
||||
</li>
|
||||
<li v-if="wifi.ssid" :title="$t('WiFi')">
|
||||
<WifiIcon size="1x" aria-hidden="true" role="img" />
|
||||
{{ wifi.ssid }}
|
||||
<span v-if="wifi.bssid">({{ wifi.bssid }})</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="regions.length" class="regions">
|
||||
{{ $t("Regions:") }}
|
||||
{{ regions.join(", ") }}
|
||||
</div>
|
||||
</LPopup>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.device {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: -5px;
|
||||
color: var(--color-primary);
|
||||
font-weight: bold;
|
||||
}
|
||||
.wrapper {
|
||||
display: flex;
|
||||
margin-top: 10px;
|
||||
margin-right: 20px;
|
||||
|
||||
img {
|
||||
align-self: start;
|
||||
margin-right: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import {
|
||||
BatteryIcon,
|
||||
ClockIcon,
|
||||
HomeIcon,
|
||||
MapPinIcon,
|
||||
WifiIcon,
|
||||
ZapIcon,
|
||||
} from "vue-feather-icons";
|
||||
import { LPopup } from "vue2-leaflet";
|
||||
@@ -75,6 +72,7 @@ export default {
|
||||
ClockIcon,
|
||||
HomeIcon,
|
||||
MapPinIcon,
|
||||
WifiIcon,
|
||||
ZapIcon,
|
||||
LPopup,
|
||||
},
|
||||
@@ -99,6 +97,14 @@ export default {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
isoLocal: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
timeZone: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
lat: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
@@ -123,6 +129,18 @@ export default {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
regions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
wifi: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
/**
|
||||
@@ -146,3 +164,27 @@ export default {
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.device {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: -5px;
|
||||
color: var(--color-primary);
|
||||
font-weight: bold;
|
||||
}
|
||||
.wrapper {
|
||||
display: flex;
|
||||
margin-top: 10px;
|
||||
|
||||
img {
|
||||
align-self: start;
|
||||
margin-right: 20px;
|
||||
}
|
||||
}
|
||||
.regions {
|
||||
border-top: 1px solid var(--color-separator);
|
||||
margin-top: 15px;
|
||||
padding-top: 15px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,12 +2,6 @@
|
||||
<div />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
div {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// See https://github.com/KoRiGaN/Vue2Leaflet/blob/e0cf0f29bc519f0a70f0f1eb6e579f947e7ea4ce/src/utils/utils.js
|
||||
// to understand the `custom` attribute of each prop, how the `set<Prop>`
|
||||
@@ -94,7 +88,7 @@ export default {
|
||||
this.parentContainer.addLayer(this, !this.visible);
|
||||
this.$watch(
|
||||
"latLng",
|
||||
newVal => {
|
||||
(newVal) => {
|
||||
this.mapObject.setLatLngs(newVal);
|
||||
},
|
||||
{ deep: true }
|
||||
@@ -136,3 +130,9 @@ export default {
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
div {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
<template>
|
||||
<modal name="download" adaptive>
|
||||
<pre class="data"><code>{{ data }}</code></pre>
|
||||
<div class="options">
|
||||
<input
|
||||
v-model="options.minifyJson"
|
||||
type="checkbox"
|
||||
id="option-minify-json"
|
||||
/>
|
||||
<label for="option-minify-json">
|
||||
{{ $t("Minify JSON") }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<button
|
||||
class="button button-outline button-primary"
|
||||
:title="$t('Copy to clipboard')"
|
||||
@click="copy"
|
||||
>
|
||||
{{ $t("Copy to clipboard") }}
|
||||
</button>
|
||||
<button
|
||||
class="button button-primary"
|
||||
:title="$t('Download')"
|
||||
@click="download"
|
||||
>
|
||||
{{ $t("Download") }}
|
||||
</button>
|
||||
</div>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.data {
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.options {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
margin-top: 30px;
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
|
||||
&:first-child {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { mapState } from "vuex";
|
||||
import copy from "clipboard-copy";
|
||||
|
||||
import { download } from "@/util";
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
options: {
|
||||
minifyJson: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapState([
|
||||
"startDateTime",
|
||||
"endDateTime",
|
||||
"selectedUser",
|
||||
"selectedDevice",
|
||||
"locationHistory",
|
||||
]),
|
||||
data() {
|
||||
return this.locationHistory;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
copy() {
|
||||
const data = JSON.stringify(
|
||||
this.data,
|
||||
null,
|
||||
this.options.minifyJson ? 0 : 2
|
||||
);
|
||||
copy(data);
|
||||
},
|
||||
download() {
|
||||
const data = JSON.stringify(
|
||||
this.data,
|
||||
null,
|
||||
this.options.minifyJson ? 0 : 2
|
||||
);
|
||||
const start = this.startDateTime;
|
||||
const end = this.endDateTime;
|
||||
const user = this.selectedUser ? `_${this.selectedUser}` : "";
|
||||
const device = this.selectedDevice ? `_${this.selectedDevice}` : "";
|
||||
const filename = `data_${start}_${end}${user}${device}.json`;
|
||||
download(data, filename, "application/json");
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -3,17 +3,13 @@
|
||||
<ul class="info-list">
|
||||
<li>
|
||||
<GithubIcon size="1x" aria-hidden="true" role="img" />
|
||||
<a href="https://github.com/owntracks/frontend">
|
||||
owntracks/frontend
|
||||
</a>
|
||||
<a href="https://github.com/owntracks/frontend">owntracks/frontend</a>
|
||||
({{ frontendVersion }})
|
||||
</li>
|
||||
<li>
|
||||
<GithubIcon size="1x" aria-hidden="true" role="img" />
|
||||
<a href="https://github.com/owntracks/recorder">
|
||||
owntracks/recorder
|
||||
</a>
|
||||
({{ recorderVersion || "Loading version..." }})
|
||||
<a href="https://github.com/owntracks/recorder">owntracks/recorder</a>
|
||||
({{ recorderVersion || $t("Loading version...") }})
|
||||
</li>
|
||||
<li>
|
||||
<GlobeIcon size="1x" aria-hidden="true" role="img" />
|
||||
@@ -28,9 +24,9 @@
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<TwitterIcon size="1x" aria-hidden="true" role="img" />
|
||||
<a href="https://twitter.com/OwnTracks">
|
||||
{{ $t("OwnTracks on Twitter") }}
|
||||
<AtSignIcon size="1x" aria-hidden="true" role="img" />
|
||||
<a href="https://fosstodon.org/@owntracks">
|
||||
{{ $t("OwnTracks on Mastodon") }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -39,15 +35,10 @@
|
||||
|
||||
<script>
|
||||
import { mapState } from "vuex";
|
||||
import {
|
||||
BookIcon,
|
||||
GithubIcon,
|
||||
GlobeIcon,
|
||||
TwitterIcon,
|
||||
} from "vue-feather-icons";
|
||||
import { AtSignIcon, BookIcon, GithubIcon, GlobeIcon } from "vue-feather-icons";
|
||||
|
||||
export default {
|
||||
components: { BookIcon, GithubIcon, GlobeIcon, TwitterIcon },
|
||||
components: { AtSignIcon, BookIcon, GithubIcon, GlobeIcon },
|
||||
computed: {
|
||||
...mapState(["frontendVersion", "recorderVersion"]),
|
||||
},
|
||||
@@ -1,16 +1,43 @@
|
||||
<template>
|
||||
<modal name="loading" :click-to-close="false" adaptive>
|
||||
<LoaderIcon class="loader" size="1.5x" />
|
||||
<LoaderIcon class="loader-icon" size="1.5x" />
|
||||
{{ $t("Loading data, please wait...") }}
|
||||
<br />
|
||||
<button
|
||||
type="button"
|
||||
class="button button-primary button-cancel"
|
||||
@click="requestAbortController.abort()"
|
||||
>
|
||||
{{ $t("Cancel") }}
|
||||
</button>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapState } from "vuex";
|
||||
import { LoaderIcon } from "vue-feather-icons";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
LoaderIcon,
|
||||
},
|
||||
computed: {
|
||||
...mapState(["requestAbortController"]),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loader {
|
||||
.loader-icon {
|
||||
animation: spinning 2s linear infinite;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.button-cancel {
|
||||
display: block;
|
||||
margin: 20px auto 0;
|
||||
}
|
||||
|
||||
@keyframes spinning {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
@@ -20,13 +47,3 @@
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { LoaderIcon } from "vue-feather-icons";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
LoaderIcon,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -10,10 +10,14 @@ startDateTime.setHours(0, 0, 0, 0);
|
||||
const DEFAULT_CONFIG = {
|
||||
api: {
|
||||
baseUrl: `${window.location.protocol}//${window.location.host}`,
|
||||
fetchOptions: {},
|
||||
},
|
||||
endDateTime,
|
||||
filters: {
|
||||
minAccuracy: null,
|
||||
},
|
||||
ignorePingLocation: true,
|
||||
locale: "en",
|
||||
locale: "en-US",
|
||||
map: {
|
||||
attribution:
|
||||
'© <a href="https://osm.org/copyright">OpenStreetMap</a> contributors',
|
||||
@@ -51,21 +55,34 @@ const DEFAULT_CONFIG = {
|
||||
heatmap: false,
|
||||
last: true,
|
||||
line: true,
|
||||
poi: true,
|
||||
points: false,
|
||||
},
|
||||
maxNativeZoom: 19,
|
||||
maxPointDistance: null,
|
||||
maxZoom: 21,
|
||||
poiMarker: {
|
||||
color: "red",
|
||||
fillColor: "red",
|
||||
fillOpacity: 0.2,
|
||||
radius: 12,
|
||||
},
|
||||
polyline: {
|
||||
color: null,
|
||||
fillColor: "transparent",
|
||||
},
|
||||
tileSize: 256,
|
||||
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
zoomOffset: 0,
|
||||
},
|
||||
onLocationChange: {
|
||||
fitView: false,
|
||||
reloadHistory: false,
|
||||
},
|
||||
primaryColor: "#3f51b5",
|
||||
router: {
|
||||
basePath: "/",
|
||||
},
|
||||
selectedDevice: null,
|
||||
selectedUser: null,
|
||||
showDistanceTravelled: true,
|
||||
|
||||
36
src/i18n.js
@@ -3,21 +3,35 @@ import VueI18n from "vue-i18n";
|
||||
|
||||
import config from "@/config";
|
||||
|
||||
// TODO: This should be possible to do with https://github.com/intlify/bundle-tools/tree/main/packages/unplugin-vue-i18n,
|
||||
// but that breaks at runtime - may only work with vue-i18n@9?
|
||||
import cs_CZ from "@/locales/cs-CZ.json";
|
||||
import da_DK from "@/locales/da-DK.json";
|
||||
import de_DE from "@/locales/de-DE.json";
|
||||
import en_GB from "@/locales/en-GB.json";
|
||||
import en_US from "@/locales/en-US.json";
|
||||
import es_ES from "@/locales/es-ES.json";
|
||||
import fr_FR from "@/locales/fr-FR.json";
|
||||
import sk_SK from "@/locales/sk-SK.json";
|
||||
import tr_TR from "@/locales/tr-TR.json";
|
||||
|
||||
Vue.use(VueI18n);
|
||||
|
||||
const locales = require.context("./locales", true, /[A-Za-z0-9-_,\s]+\.json$/i);
|
||||
const messages = {};
|
||||
locales.keys().forEach(key => {
|
||||
const matched = key.match(/([A-Za-z0-9-_]+)\./i);
|
||||
if (matched && matched.length > 1) {
|
||||
const locale = matched[1];
|
||||
messages[locale] = locales(key);
|
||||
}
|
||||
});
|
||||
const messages = {
|
||||
"cs-CZ": cs_CZ,
|
||||
"da-DK": da_DK,
|
||||
"de-DE": de_DE,
|
||||
"en-GB": en_GB,
|
||||
"en-US": en_US,
|
||||
"es-ES": es_ES,
|
||||
"fr-FR": fr_FR,
|
||||
"sk-SK": sk_SK,
|
||||
"tr-TR": tr_TR,
|
||||
};
|
||||
|
||||
export default new VueI18n({
|
||||
locale: config.locale.split("-")[0],
|
||||
fallbackLocale: "en",
|
||||
locale: config.locale,
|
||||
fallbackLocale: "en-US",
|
||||
formatFallbackMessages: true,
|
||||
messages,
|
||||
});
|
||||
|
||||
43
src/index.d.ts
vendored
@@ -2,8 +2,12 @@
|
||||
interface Config {
|
||||
api: {
|
||||
baseUrl: string;
|
||||
fetchOptions: object;
|
||||
};
|
||||
endDateTime: Date;
|
||||
filters: {
|
||||
minAccuracy: number | null,
|
||||
};
|
||||
ignorePingLocation: boolean;
|
||||
locale: string;
|
||||
map: {
|
||||
@@ -42,23 +46,37 @@ interface Config {
|
||||
heatmap: boolean;
|
||||
last: boolean;
|
||||
line: boolean;
|
||||
poi: boolean;
|
||||
points: boolean;
|
||||
};
|
||||
maxNativeZoom: number;
|
||||
maxPointDistance: number | null;
|
||||
maxZoom: number;
|
||||
poiMarker: {
|
||||
color: OptionalColor;
|
||||
fillColor: OptionalColor;
|
||||
fillOpacity: number;
|
||||
radius: number;
|
||||
};
|
||||
polyline: {
|
||||
color: OptionalColor;
|
||||
fillColor: OptionalColor;
|
||||
};
|
||||
tileSize: number;
|
||||
url: string;
|
||||
zoomOffset: number;
|
||||
};
|
||||
onLocationChange: {
|
||||
fitView: boolean;
|
||||
reloadHistory: boolean;
|
||||
};
|
||||
primaryColor: Color;
|
||||
selectedUser: User| null;
|
||||
selectedDevice: Device| null;
|
||||
router: {
|
||||
basePath: string;
|
||||
};
|
||||
selectedUser: User | null;
|
||||
selectedDevice: Device | null;
|
||||
showDistanceTravelled: boolean;
|
||||
startDateTime: Date;
|
||||
verbose: boolean;
|
||||
}
|
||||
@@ -72,8 +90,8 @@ interface State {
|
||||
devices: { User: Device[] };
|
||||
lastLocations: OTLocation[];
|
||||
locationHistory: LocationHistory;
|
||||
selectedUser: User| null;
|
||||
selectedDevice: Device| null;
|
||||
selectedUser: User | null;
|
||||
selectedDevice: Device | null;
|
||||
startDateTime: string;
|
||||
endDateTime: string;
|
||||
map: {
|
||||
@@ -85,6 +103,7 @@ interface State {
|
||||
heatmap: boolean;
|
||||
last: boolean;
|
||||
line: boolean;
|
||||
poi: boolean;
|
||||
points: boolean;
|
||||
};
|
||||
zoom: number;
|
||||
@@ -127,6 +146,8 @@ interface OTLocation {
|
||||
* - `"m"` = mobile data
|
||||
*/
|
||||
conn?: string;
|
||||
/** identifies the time at which the message is constructed (vs. `tst` which is the timestamp of the GPS fix) */
|
||||
created_at?: string;
|
||||
/** Device name */
|
||||
device?: Device;
|
||||
/** Timestamp in a readable format */
|
||||
@@ -138,8 +159,10 @@ interface OTLocation {
|
||||
* https://en.wikipedia.org/wiki/Geohash
|
||||
*/
|
||||
ghash?: string;
|
||||
/** Regions the device is currently in (e.g. `["Home", "Garage"]`). Might be empty. */
|
||||
/** contains a list of regions the device is currently in (e.g. ["Home","Garage"]). Might be empty. */
|
||||
inregions?: string[];
|
||||
/** contains a list of region IDs the device is currently in (e.g. ["6da9cf","3defa7"]). Might be empty. */
|
||||
inrids?: string[];
|
||||
/**
|
||||
* No idea; some kind of timestamp as well - figure it out yourself. :)
|
||||
* https://github.com/owntracks/recorder/blob/df009f791a845012e9cce24923e6203a079ca1ed/storage.c#L659
|
||||
@@ -148,12 +171,18 @@ interface OTLocation {
|
||||
isorcv?: string;
|
||||
/** ISO 8601 timestamp */
|
||||
isotst?: string;
|
||||
/** ISO 8601 timestamp in local time */
|
||||
isolocal?: string;
|
||||
/** tzdb time zone name */
|
||||
tzname?: string;
|
||||
/** Latitude in degrees */
|
||||
lat: number;
|
||||
/** Longitude in degrees */
|
||||
lon: number;
|
||||
/** Friendly device name */
|
||||
name?: string;
|
||||
/** Point of interest name */
|
||||
poi?: string;
|
||||
/**
|
||||
* Trigger for the location report
|
||||
*
|
||||
@@ -181,6 +210,10 @@ interface OTLocation {
|
||||
vac?: number;
|
||||
/** Velocity in km/h */
|
||||
vel?: number;
|
||||
/** SSID, if available, is the unique name of the WLAN. */
|
||||
SSID?: string;
|
||||
/** BSSID, if available, identifies the access point. */
|
||||
BSSID?: string;
|
||||
}
|
||||
|
||||
/** URL query parameters (prior to any parsing so it's all strings). */
|
||||
|
||||
45
src/locales/cs-CZ.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"Automatically center the map view and zoom in to relevant data": "Automaticky vystředit pohled na mapu a přiblížit na příslušné údaje",
|
||||
"Fit view": "Napasovat pohled",
|
||||
"Layer settings": "Nastavení vrstvy",
|
||||
"Show/hide layers": "Zobrazit/skrýť vrstvy",
|
||||
"Now": "Teď",
|
||||
"Select start date": "Zvolit počáteční datum",
|
||||
"to": "do",
|
||||
"Select end date": "Zvolit konečný datum",
|
||||
"Select user": "Zvolit uživatele",
|
||||
"Show all": "Zobrazit všechno",
|
||||
"Select device": "Zvolit zařízení",
|
||||
"Distance travelled": "Procestovaná vzdálenost",
|
||||
"Elevation gain / loss": "Výškový výstup / pokles",
|
||||
"Information": "Informace",
|
||||
"Show last known locations": "Zobrazit naposledy známé polohy",
|
||||
"Show location history (line)": "Zobrazit historii poloh (čára)",
|
||||
"Show location history (points)": "Zobrazit historii poloh (body)",
|
||||
"Show location heatmap": "Zobrazit tepelnou mapu poloh",
|
||||
"Minify JSON": "Zminimalizovat JSON",
|
||||
"Copy to clipboard": "Zkopírovat do schránky",
|
||||
"Loading version...": "Nahrávám verzi...",
|
||||
"OwnTracks website": "Web Stránka OwnTracks",
|
||||
"OwnTracks documentation": "Dokumentace OwnTracks",
|
||||
"OwnTracks on Mastodon": "OwnTracks na Mastodon",
|
||||
"Loading data, please wait...": "Nahrávám údaje, prosím počkejte...",
|
||||
"Cancel": "Zrušit",
|
||||
"Image of {deviceName}": "Obrázek {deviceName}",
|
||||
"Timestamp": "Čas",
|
||||
"Location": "Poloha",
|
||||
"Address": "Adresa",
|
||||
"Battery": "Baterie",
|
||||
"Speed": "Rychlost",
|
||||
"Regions:": "Oblasti:",
|
||||
"WiFi": "WiFi",
|
||||
"Today": "Dnes",
|
||||
"Yesterday": "Včera",
|
||||
"3 days": "3 dny",
|
||||
"7 days": "7 dní",
|
||||
"15 days": "15 dní",
|
||||
"30 days": "30 dní",
|
||||
"3 months": "3 měsíce",
|
||||
"6 months": "6 měsíců",
|
||||
"1 year": "1 rok"
|
||||
}
|
||||
45
src/locales/da-DK.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"Automatically center the map view and zoom in to relevant data": "Centrér automatisk kortvisningen og zoom ind på relevant data",
|
||||
"Fit view": "Tilpas visning",
|
||||
"Layer settings": "Lag-indstillinger",
|
||||
"Show/hide layers": "Vis/skjul lag",
|
||||
"Now": "Nu",
|
||||
"Select start date": "Vælg startdato",
|
||||
"to": "til",
|
||||
"Select end date": "Vælg slutdato",
|
||||
"Select user": "Vælg bruger",
|
||||
"Show all": "Vis alt",
|
||||
"Select device": "Vælg enhed",
|
||||
"Distance travelled": "Afstand rejst",
|
||||
"Elevation gain / loss": "Højde vundet / tabt",
|
||||
"Information": "Information",
|
||||
"Show last known locations": "Vis sidst kendte positioner",
|
||||
"Show location history (line)": "Vis positionshistorik (linje)",
|
||||
"Show location history (points)": "Vis positionshistorik (punkter)",
|
||||
"Show location heatmap": "Vis positions-heatmap",
|
||||
"Minify JSON": "Minificér JSON",
|
||||
"Copy to clipboard": "Kopiér til udklipsholder",
|
||||
"Loading version...": "Indlæser version...",
|
||||
"OwnTracks website": "OwnTracks hjemmeside",
|
||||
"OwnTracks documentation": "OwnTracks dokumentation",
|
||||
"OwnTracks on Mastodon": "OwnTracks på Mastodon",
|
||||
"Loading data, please wait...": "Indlæser data, vent venligst...",
|
||||
"Cancel": "Fortryd",
|
||||
"Image of {deviceName}": "Billede af {deviceName}",
|
||||
"Timestamp": "Tidspunkt",
|
||||
"Location": "Position",
|
||||
"Address": "Adresse",
|
||||
"Battery": "Batteri",
|
||||
"Speed": "Hastighed",
|
||||
"Regions:": "Regioner:",
|
||||
"WiFi": "WiFi",
|
||||
"Today": "I dag",
|
||||
"Yesterday": "I går",
|
||||
"3 days": "3 dage",
|
||||
"7 days": "7 dage",
|
||||
"15 days": "15 dage",
|
||||
"30 days": "30 dage",
|
||||
"3 months": "3 måneder",
|
||||
"6 months": "6 måneder",
|
||||
"1 year": "1 år"
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
"Show all": "Alle anzeigen",
|
||||
"Select device": "Gerät auswählen",
|
||||
"Distance travelled": "Gereiste Entfernung",
|
||||
"Download raw data": "Rohdaten herunterladen",
|
||||
"Elevation gain / loss": "Höhengewinn / -verlust",
|
||||
"Information": "Information",
|
||||
"Show last known locations": "Zeige letzte bekannte Standorte",
|
||||
"Show location history (line)": "Zeige Standortverlauf (Linie)",
|
||||
@@ -19,15 +19,27 @@
|
||||
"Show location heatmap": "Zeige Standort-Heatmap",
|
||||
"Minify JSON": "JSON minimieren",
|
||||
"Copy to clipboard": "In die Zwischenablage kopieren",
|
||||
"Download": "Herunterladen",
|
||||
"Loading version...": "Version wird abgerufen...",
|
||||
"OwnTracks website": "OwnTracks Webseite",
|
||||
"OwnTracks documentation": "OwnTracks Dokumentation",
|
||||
"OwnTracks on Twitter": "OwnTracks auf Twitter",
|
||||
"OwnTracks on Mastodon": "OwnTracks auf Mastodon",
|
||||
"Loading data, please wait...": "Daten werden geladen, bitte warten...",
|
||||
"Cancel": "Abbrechen",
|
||||
"Image of {deviceName}": "Bild von {deviceName}",
|
||||
"Timestamp": "Zeitstempel",
|
||||
"Location": "Standort",
|
||||
"Address": "Adresse",
|
||||
"Battery": "Akku",
|
||||
"Speed": "Geschwindigkeit"
|
||||
"Speed": "Geschwindigkeit",
|
||||
"Regions:": "Regionen:",
|
||||
"WiFi": "WLAN",
|
||||
"Today": "Heute",
|
||||
"Yesterday": "Gestern",
|
||||
"3 days": "3 Tage",
|
||||
"7 days": "7 Tage",
|
||||
"15 days": "15 Tage",
|
||||
"30 days": "30 Tage",
|
||||
"3 months": "3 Monate",
|
||||
"6 months": "6 Monate",
|
||||
"1 year": "1 Jahr"
|
||||
}
|
||||
48
src/locales/en-GB.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"Automatically center the map view and zoom in to relevant data": "Automatically centre the map view and zoom in to relevant data",
|
||||
"Fit view": "Fit view",
|
||||
"Layer settings": "Layer settings",
|
||||
"Show/hide layers": "Show/hide layers",
|
||||
"Now": "Now",
|
||||
"Select start date": "Select start date",
|
||||
"to": "to",
|
||||
"Select end date": "Select end date",
|
||||
"Select user": "Select user",
|
||||
"Show all": "Show all",
|
||||
"Select device": "Select device",
|
||||
"Distance travelled": "Distance travelled",
|
||||
"Elevation gain / loss": "Elevation gain / loss",
|
||||
"Information": "Information",
|
||||
"Show last known locations": "Show last known locations",
|
||||
"Show location history (line)": "Show location history (line)",
|
||||
"Show location history (points)": "Show location history (points)",
|
||||
"Show location heatmap": "Show location heatmap",
|
||||
"Show points of interest": "Show points of interest",
|
||||
"Minify JSON": "Minify JSON",
|
||||
"Copy to clipboard": "Copy to clipboard",
|
||||
"Loading version...": "Loading version...",
|
||||
"OwnTracks website": "OwnTracks website",
|
||||
"OwnTracks documentation": "OwnTracks documentation",
|
||||
"OwnTracks on Mastodon": "OwnTracks on Mastodon",
|
||||
"Loading data, please wait...": "Loading data, please wait...",
|
||||
"Cancel": "Cancel",
|
||||
"Image of {deviceName}": "Image of {deviceName}",
|
||||
"Timestamp": "Timestamp",
|
||||
"Location": "Location",
|
||||
"Address": "Address",
|
||||
"Battery": "Battery",
|
||||
"Speed": "Speed",
|
||||
"Regions:": "Regions:",
|
||||
"WiFi": "WiFi",
|
||||
"Select date": "Select date",
|
||||
"Select time": "Select time",
|
||||
"Today": "Today",
|
||||
"Yesterday": "Yesterday",
|
||||
"3 days": "3 days",
|
||||
"7 days": "7 days",
|
||||
"15 days": "15 days",
|
||||
"30 days": "30 days",
|
||||
"3 months": "3 months",
|
||||
"6 months": "6 months",
|
||||
"1 year": "1 year"
|
||||
}
|
||||
@@ -10,24 +10,39 @@
|
||||
"Select user": "Select user",
|
||||
"Show all": "Show all",
|
||||
"Select device": "Select device",
|
||||
"Distance travelled": "Distance travelled",
|
||||
"Download raw data": "Download raw data",
|
||||
"Distance travelled": "Distance traveled",
|
||||
"Elevation gain / loss": "Elevation gain / loss",
|
||||
"Information": "Information",
|
||||
"Show last known locations": "Show last known locations",
|
||||
"Show location history (line)": "Show location history (line)",
|
||||
"Show location history (points)": "Show location history (points)",
|
||||
"Show location heatmap": "Show location heatmap",
|
||||
"Show points of interest": "Show points of interest",
|
||||
"Minify JSON": "Minify JSON",
|
||||
"Copy to clipboard": "Copy to clipboard",
|
||||
"Download": "Download",
|
||||
"Loading version...": "Loading version...",
|
||||
"OwnTracks website": "OwnTracks website",
|
||||
"OwnTracks documentation": "OwnTracks documentation",
|
||||
"OwnTracks on Twitter": "OwnTracks on Twitter",
|
||||
"OwnTracks on Mastodon": "OwnTracks on Mastodon",
|
||||
"Loading data, please wait...": "Loading data, please wait...",
|
||||
"Cancel": "Cancel",
|
||||
"Image of {deviceName}": "Image of {deviceName}",
|
||||
"Timestamp": "Timestamp",
|
||||
"Location": "Location",
|
||||
"Address": "Address",
|
||||
"Battery": "Battery",
|
||||
"Speed": "Speed"
|
||||
"Speed": "Speed",
|
||||
"Regions:": "Regions:",
|
||||
"WiFi": "WiFi",
|
||||
"Select date": "Select date",
|
||||
"Select time": "Select time",
|
||||
"Today": "Today",
|
||||
"Yesterday": "Yesterday",
|
||||
"3 days": "3 days",
|
||||
"7 days": "7 days",
|
||||
"15 days": "15 days",
|
||||
"30 days": "30 days",
|
||||
"3 months": "3 months",
|
||||
"6 months": "6 months",
|
||||
"1 year": "1 year"
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
"Show all": "Mostrar todos",
|
||||
"Select device": "Seleccionar dispositivo",
|
||||
"Distance travelled": "Distancia recorrida",
|
||||
"Download raw data": "Descargar datos en crudo",
|
||||
"Elevation gain / loss": "Aumento / disminución de la altura",
|
||||
"Information": "Información",
|
||||
"Show last known locations": "Mostrar última ubicación conocida",
|
||||
"Show location history (line)": "Mostrar historial (línea)",
|
||||
@@ -19,15 +19,27 @@
|
||||
"Show location heatmap": "Mostra mapa de calor",
|
||||
"Minify JSON": "Reducir JSON",
|
||||
"Copy to clipboard": "Copiar al portapapeles",
|
||||
"Download": "Descarga",
|
||||
"Loading version...": "Cargando versión...",
|
||||
"OwnTracks website": "OwnTracks - Sitio web",
|
||||
"OwnTracks documentation": "OwnTracks - documentación",
|
||||
"OwnTracks on Twitter": "OwnTracks en Twitter",
|
||||
"OwnTracks on Mastodon": "OwnTracks en Mastodon",
|
||||
"Loading data, please wait...": "Cargando datos, por favor, espera...",
|
||||
"Cancel": "Cancelar",
|
||||
"Image of {deviceName}": "Imágen de {deviceName}",
|
||||
"Timestamp": "Fecha / Hora",
|
||||
"Location": "Ubicación",
|
||||
"Address": "Dirección",
|
||||
"Battery": "Bateria",
|
||||
"Speed": "Velocidad"
|
||||
"Speed": "Velocidad",
|
||||
"Regions:": "Regiones:",
|
||||
"WiFi": "WiFi",
|
||||
"Today": "Hoy",
|
||||
"Yesterday": "Ayer",
|
||||
"3 days": "3 días",
|
||||
"7 days": "7 días",
|
||||
"15 days": "15 días",
|
||||
"30 days": "30 días",
|
||||
"3 months": "3 meses",
|
||||
"6 months": "6 meses",
|
||||
"1 year": "1 año"
|
||||
}
|
||||
45
src/locales/fr-FR.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"Automatically center the map view and zoom in to relevant data": "Centrer automatiquement la vue de la carte et zoomer sur les données pertinentes",
|
||||
"Fit view": "Vue d'ensemble",
|
||||
"Layer settings": "Paramètres des couches",
|
||||
"Show/hide layers": "Montrer/cacher certaines couches",
|
||||
"Now": "Maintenant",
|
||||
"Select start date": "Sélectionner une date de début",
|
||||
"to": "à",
|
||||
"Select end date": "Sélectionner une date de fin",
|
||||
"Select user": "Sélectionner un utilisateur",
|
||||
"Show all": "Tout afficher",
|
||||
"Select device": "Sélectionner un appareil",
|
||||
"Distance travelled": "Distance parcourue",
|
||||
"Elevation gain / loss": "Augmentation / diminution de l'altitude",
|
||||
"Information": "Informations",
|
||||
"Show last known locations": "Afficher les dernières localisations connues",
|
||||
"Show location history (line)": "Afficher l'historique de localisation (lignes)",
|
||||
"Show location history (points)": "Afficher l'historique de localisation (points)",
|
||||
"Show location heatmap": "Afficher la carte de fréquentation",
|
||||
"Minify JSON": "Minifier JSON",
|
||||
"Copy to clipboard": "Copier dans le presse-papier",
|
||||
"Loading version...": "Chargement de la version...",
|
||||
"OwnTracks website": "Site d'OwnTracks",
|
||||
"OwnTracks documentation": "Documentation d'OwnTracks",
|
||||
"OwnTracks on Mastodon": "OwnTracks sur Mastodon",
|
||||
"Loading data, please wait...": "Chargement des données, merci de patienter ...",
|
||||
"Cancel": "Annuler",
|
||||
"Image of {deviceName}": "Image de {deviceName}",
|
||||
"Timestamp": "Horodatage",
|
||||
"Location": "Localisation",
|
||||
"Address": "Addresse",
|
||||
"Battery": "Batterie",
|
||||
"Speed": "Vitesse",
|
||||
"Regions:": "Régions:",
|
||||
"WiFi": "WiFi",
|
||||
"Today": "Aujourd'hui",
|
||||
"Yesterday": "Hier",
|
||||
"3 days": "3 jours",
|
||||
"7 days": "7 jours",
|
||||
"15 days": "15 jours",
|
||||
"30 days": "30 jours",
|
||||
"3 months": "3 mois",
|
||||
"6 months": "6 mois",
|
||||
"1 year": "1 an"
|
||||
}
|
||||
45
src/locales/sk-SK.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"Automatically center the map view and zoom in to relevant data": "Automaticky vystrediť pohľad na mapu a priblížiť na príslušné údaje",
|
||||
"Fit view": "Napasovať pohľad",
|
||||
"Layer settings": "Nastavenia vrstvy",
|
||||
"Show/hide layers": "Zobraziť/skryť vrstvy",
|
||||
"Now": "Teraz",
|
||||
"Select start date": "Zvoliť začiatočný dátum",
|
||||
"to": "do",
|
||||
"Select end date": "Zvoliť konečný dátum",
|
||||
"Select user": "Zvoliť používateľa",
|
||||
"Show all": "Zobraziť všetko",
|
||||
"Select device": "Zvoliť zariadenie",
|
||||
"Distance travelled": "Prejdená vzdialenosť",
|
||||
"Elevation gain / loss": "Výškový výstup / pokles",
|
||||
"Information": "Informácie",
|
||||
"Show last known locations": "Zobraziť posledné známe polohy",
|
||||
"Show location history (line)": "Zobraziť históriu polôh (čiara)",
|
||||
"Show location history (points)": "Zobraziť históriu polôh (body)",
|
||||
"Show location heatmap": "Zobraziť tepelnú mapu polôh",
|
||||
"Minify JSON": "Zostručniť JSON",
|
||||
"Copy to clipboard": "Skopírovať do schránky",
|
||||
"Loading version...": "Nahrávam verziu...",
|
||||
"OwnTracks website": "Web Stránka OwnTracks",
|
||||
"OwnTracks documentation": "Dokumentácia OwnTracks",
|
||||
"OwnTracks on Mastodon": "OwnTracks na Mastodon",
|
||||
"Loading data, please wait...": "Nahrávam údaje, prosím počkajte...",
|
||||
"Cancel": "Zrušiť",
|
||||
"Image of {deviceName}": "Obrázok {deviceName}",
|
||||
"Timestamp": "Časová pečiatka",
|
||||
"Location": "Poloha",
|
||||
"Address": "Adresa",
|
||||
"Battery": "Batéria",
|
||||
"Speed": "Rýchlosť",
|
||||
"Regions:": "Oblasti:",
|
||||
"WiFi": "WiFi",
|
||||
"Today": "Dnes",
|
||||
"Yesterday": "Včera",
|
||||
"3 days": "3 dni",
|
||||
"7 days": "7 dní",
|
||||
"15 days": "15 dní",
|
||||
"30 days": "30 dní",
|
||||
"3 months": "3 mesiace",
|
||||
"6 months": "6 mesiacov",
|
||||
"1 year": "1 rok"
|
||||
}
|
||||
45
src/locales/tr-TR.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"Automatically center the map view and zoom in to relevant data": "Harita görünümünü otomatik olarak ortalayın ve ilgili verileri yakınlaştırın",
|
||||
"Fit view": "Görünümü sığdır",
|
||||
"Layer settings": "Katman ayarları",
|
||||
"Show/hide layers": "Katmanları göster/gizle",
|
||||
"Now": "Şimdi",
|
||||
"Select start date": "Başlangıç tarihini seçin",
|
||||
"to": "ile",
|
||||
"Select end date": "Bitiş tarihini seçin",
|
||||
"Select user": "Kullanıcı seç",
|
||||
"Show all": "Tümünü göster",
|
||||
"Select device": "Cihaz Seç",
|
||||
"Distance travelled": "Gidilen mesafe",
|
||||
"Elevation gain / loss": "Yükseklik kazancı / kaybı",
|
||||
"Information": "Bilgi",
|
||||
"Show last known locations": "Bilinen son yerleri göster",
|
||||
"Show location history (line)": "Konum geçmişini göster (çizgi)",
|
||||
"Show location history (points)": "Konum geçmişini göster (nokta)",
|
||||
"Show location heatmap": "Konum ısı haritasını göster",
|
||||
"Minify JSON": "JSON'u Küçült",
|
||||
"Copy to clipboard": "Panoya kopyala",
|
||||
"Loading version...": "Versiyon yükleniyor...",
|
||||
"OwnTracks website": "OwnTracks internet sitesi",
|
||||
"OwnTracks documentation": "OwnTracks dokümanı",
|
||||
"OwnTracks on Mastodon": "Mastodon'da OwnTracks",
|
||||
"Loading data, please wait...": "Veriler yükleniyor, lüften bekleyin...",
|
||||
"Cancel": "İptal",
|
||||
"Image of {deviceName}": "{deviceName} resmi",
|
||||
"Timestamp": "Zaman Damgası",
|
||||
"Location": "Konum",
|
||||
"Address": "Adres",
|
||||
"Battery": "Batarya",
|
||||
"Speed": "Hız",
|
||||
"Regions:": "Bölgeler:",
|
||||
"WiFi": "WiFi",
|
||||
"Today": "Bugün",
|
||||
"Yesterday": "Dün",
|
||||
"3 days": "3 gün",
|
||||
"7 days": "7 gün",
|
||||
"15 days": "15 gün",
|
||||
"30 days": "30 gün",
|
||||
"3 months": "3 ay",
|
||||
"6 months": "6 ay",
|
||||
"1 year": "1 yıl"
|
||||
}
|
||||
@@ -29,5 +29,5 @@ new Vue({
|
||||
i18n,
|
||||
router,
|
||||
store,
|
||||
render: h => h(App),
|
||||
render: (h) => h(App),
|
||||
}).$mount("#app");
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import Vue from "vue";
|
||||
import Router from "vue-router";
|
||||
import Map from "./views/Map.vue";
|
||||
import config from "@/config";
|
||||
import Map from "@/views/Map.vue";
|
||||
|
||||
Vue.use(Router);
|
||||
|
||||
export default new Router({
|
||||
mode: "history",
|
||||
base: process.env.BASE_URL,
|
||||
base: config.router.basePath,
|
||||
routes: [
|
||||
{
|
||||
path: "/",
|
||||
|
||||
@@ -43,7 +43,7 @@ const populateStateFromQuery = ({ state, commit }, query) => {
|
||||
}
|
||||
if (query.layers) {
|
||||
const activeLayers = query.layers.split(",");
|
||||
Object.keys(state.map.layers).forEach(layer => {
|
||||
Object.keys(state.map.layers).forEach((layer) => {
|
||||
const visibility = activeLayers.includes(layer);
|
||||
if (state.map.layers[layer] !== visibility) {
|
||||
commit(types.SET_MAP_LAYER_VISIBILITY, { layer, visibility });
|
||||
@@ -116,33 +116,41 @@ const getLastLocations = async ({ commit, state }) => {
|
||||
// Remove ping/ping from the owntracks/recorder Docker image
|
||||
// https://github.com/owntracks/frontend/issues/12
|
||||
lastLocations = lastLocations.filter(
|
||||
l => !(l.username === "ping" && l.device === "ping")
|
||||
(l) => !(l.username === "ping" && l.device === "ping")
|
||||
);
|
||||
}
|
||||
commit(types.SET_LAST_LOCATIONS, lastLocations);
|
||||
};
|
||||
|
||||
const _getDistanceTravelled = locationHistory => {
|
||||
const _getTravelStats = (locationHistory) => {
|
||||
const start = Date.now();
|
||||
let distanceTravelled = 0;
|
||||
Object.keys(locationHistory).forEach(user => {
|
||||
Object.keys(locationHistory[user]).forEach(device => {
|
||||
let elevationGain = 0;
|
||||
let elevationLoss = 0;
|
||||
Object.keys(locationHistory).forEach((user) => {
|
||||
Object.keys(locationHistory[user]).forEach((device) => {
|
||||
let lastLatLng = null;
|
||||
locationHistory[user][device].forEach(coordinate => {
|
||||
const latLng = L.latLng(coordinate.lat, coordinate.lon);
|
||||
locationHistory[user][device].forEach((location) => {
|
||||
if (
|
||||
config.filters.minAccuracy !== null &&
|
||||
location.acc > config.filters.minAccuracy
|
||||
)
|
||||
return;
|
||||
const latLng = L.latLng(location.lat, location.lon, location.alt ?? 0);
|
||||
if (lastLatLng !== null) {
|
||||
const distance = distanceBetweenCoordinates(lastLatLng, latLng);
|
||||
const elevationChange = latLng.alt - lastLatLng.alt;
|
||||
if (
|
||||
typeof config.map.maxPointDistance === "number" &&
|
||||
config.map.maxPointDistance > 0
|
||||
? // If part of the current group, add to total
|
||||
distance <= config.map.maxPointDistance
|
||||
: // If grouping is disabled, always add to total
|
||||
true
|
||||
) {
|
||||
if (distance <= config.map.maxPointDistance) {
|
||||
// Part of the current group, add calculated distance to total
|
||||
distanceTravelled += distance;
|
||||
}
|
||||
} else {
|
||||
// If grouping is disabled always add calculated distance to total
|
||||
distanceTravelled += distance;
|
||||
if (elevationChange >= 0) elevationGain += elevationChange;
|
||||
else elevationLoss += -elevationChange;
|
||||
}
|
||||
}
|
||||
lastLatLng = latLng;
|
||||
@@ -150,15 +158,15 @@ const _getDistanceTravelled = locationHistory => {
|
||||
});
|
||||
});
|
||||
const end = Date.now();
|
||||
log("DISTANCE", () => {
|
||||
log("PERFORMANCE", () => {
|
||||
const locationHistoryCount = getLocationHistoryCount(locationHistory);
|
||||
const duration = (end - start) / 1000;
|
||||
return (
|
||||
`[_getDistanceTravelled] Took ${duration} seconds to ` +
|
||||
`calculate distance of ${locationHistoryCount} locations`
|
||||
`[_getTravelStats] Took ${duration} seconds to calculate distance ` +
|
||||
`and elevation gain/loss of ${locationHistoryCount} locations`
|
||||
);
|
||||
});
|
||||
return distanceTravelled;
|
||||
return { distanceTravelled, elevationGain, elevationLoss };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -176,18 +184,28 @@ const getLocationHistory = async ({ commit, state }) => {
|
||||
} else {
|
||||
devices = state.devices;
|
||||
}
|
||||
const locationHistory = await api.getLocationHistory(
|
||||
devices,
|
||||
state.startDateTime,
|
||||
state.endDateTime
|
||||
);
|
||||
commit(types.SET_IS_LOADING, false);
|
||||
commit(types.SET_REQUEST_ABORT_CONTROLLER, new AbortController());
|
||||
let locationHistory;
|
||||
try {
|
||||
locationHistory = await api.getLocationHistory(
|
||||
devices,
|
||||
state.startDateTime,
|
||||
state.endDateTime,
|
||||
{ signal: state.requestAbortController.signal }
|
||||
);
|
||||
} catch (error) {
|
||||
return;
|
||||
} finally {
|
||||
commit(types.SET_REQUEST_ABORT_CONTROLLER, null);
|
||||
commit(types.SET_IS_LOADING, false);
|
||||
}
|
||||
commit(types.SET_LOCATION_HISTORY, locationHistory);
|
||||
if (config.showDistanceTravelled) {
|
||||
commit(
|
||||
types.SET_DISTANCE_TRAVELLED,
|
||||
_getDistanceTravelled(locationHistory)
|
||||
);
|
||||
const { distanceTravelled, elevationGain, elevationLoss } =
|
||||
_getTravelStats(locationHistory);
|
||||
commit(types.SET_DISTANCE_TRAVELLED, distanceTravelled);
|
||||
commit(types.SET_ELEVATION_GAIN, elevationGain);
|
||||
commit(types.SET_ELEVATION_LOSS, elevationLoss);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -4,20 +4,46 @@ import config from "@/config";
|
||||
import { distanceBetweenCoordinates } from "@/util";
|
||||
|
||||
/**
|
||||
* From the selected users' and devices' location histories, create an
|
||||
* array of all coordinates.
|
||||
* Apply filters to the selected users' and devices' location histories.
|
||||
*
|
||||
* @param {State} state
|
||||
* @param {LocationHistory} state.locationHistory
|
||||
* Location history of selected users and devices
|
||||
* @returns {LocationHistory} Filtered location history
|
||||
*/
|
||||
const filteredLocationHistory = (state) => {
|
||||
const locationHistory = {};
|
||||
Object.keys(state.locationHistory).forEach((user) => {
|
||||
locationHistory[user] = {};
|
||||
Object.keys(state.locationHistory[user]).forEach((device) => {
|
||||
locationHistory[user][device] = [];
|
||||
state.locationHistory[user][device].forEach((location) => {
|
||||
if (
|
||||
config.filters.minAccuracy !== null &&
|
||||
location.acc > config.filters.minAccuracy
|
||||
)
|
||||
return;
|
||||
locationHistory[user][device].push(location);
|
||||
});
|
||||
});
|
||||
});
|
||||
return locationHistory;
|
||||
};
|
||||
|
||||
/**
|
||||
* From the selected users' and devices' location histories, create an
|
||||
* array of all coordinates.
|
||||
*
|
||||
* @param {State} state
|
||||
* @returns {L.LatLng[]} All coordinates
|
||||
*/
|
||||
const locationHistoryLatLngs = state => {
|
||||
const filteredLocationHistoryLatLngs = (state) => {
|
||||
const latLngs = [];
|
||||
Object.keys(state.locationHistory).forEach(user => {
|
||||
Object.keys(state.locationHistory[user]).forEach(device => {
|
||||
state.locationHistory[user][device].forEach(coordinate => {
|
||||
latLngs.push(L.latLng(coordinate.lat, coordinate.lon));
|
||||
const locationHistory = filteredLocationHistory(state);
|
||||
Object.keys(locationHistory).forEach((user) => {
|
||||
Object.keys(locationHistory[user]).forEach((device) => {
|
||||
locationHistory[user][device].forEach((location) => {
|
||||
latLngs.push(L.latLng(location.lat, location.lon));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -30,17 +56,16 @@ const locationHistoryLatLngs = state => {
|
||||
* coordinates does not exceed `config.map.maxPointDistance`.
|
||||
*
|
||||
* @param {State} state
|
||||
* @param {LocationHistory} state.locationHistory
|
||||
* Location history of selected users and devices
|
||||
* @returns {L.LatLng[][]} Groups of coherent coordinates
|
||||
*/
|
||||
const locationHistoryLatLngGroups = state => {
|
||||
const filteredLocationHistoryLatLngGroups = (state) => {
|
||||
const groups = [];
|
||||
Object.keys(state.locationHistory).forEach(user => {
|
||||
Object.keys(state.locationHistory[user]).forEach(device => {
|
||||
const locationHistory = filteredLocationHistory(state);
|
||||
Object.keys(locationHistory).forEach((user) => {
|
||||
Object.keys(locationHistory[user]).forEach((device) => {
|
||||
let latLngs = [];
|
||||
state.locationHistory[user][device].forEach(coordinate => {
|
||||
const latLng = L.latLng(coordinate.lat, coordinate.lon);
|
||||
locationHistory[user][device].forEach((location) => {
|
||||
const latLng = L.latLng(location.lat, location.lon);
|
||||
// Skip if group splitting is disabled or this is the first
|
||||
// coordinate in the current group
|
||||
if (
|
||||
@@ -68,6 +93,7 @@ const locationHistoryLatLngGroups = state => {
|
||||
};
|
||||
|
||||
export default {
|
||||
locationHistoryLatLngs,
|
||||
locationHistoryLatLngGroups,
|
||||
filteredLocationHistory,
|
||||
filteredLocationHistoryLatLngs,
|
||||
filteredLocationHistoryLatLngGroups,
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ Vue.use(Vuex);
|
||||
export default new Vuex.Store({
|
||||
state: {
|
||||
isLoading: false,
|
||||
frontendVersion: process.env.PACKAGE_VERSION,
|
||||
frontendVersion: import.meta.env.PACKAGE_VERSION,
|
||||
recorderVersion: "",
|
||||
users: [],
|
||||
devices: {},
|
||||
@@ -30,7 +30,10 @@ export default new Vuex.Store({
|
||||
zoom: 19,
|
||||
layers: config.map.layers,
|
||||
},
|
||||
distanceTravelled: null,
|
||||
distanceTravelled: 0,
|
||||
elevationGain: 0,
|
||||
elevationLoss: 0,
|
||||
requestAbortController: null,
|
||||
},
|
||||
getters,
|
||||
mutations,
|
||||
|
||||
@@ -12,3 +12,6 @@ export const SET_MAP_CENTER = "SET_MAP_CENTER";
|
||||
export const SET_MAP_ZOOM = "SET_MAP_ZOOM";
|
||||
export const SET_MAP_LAYER_VISIBILITY = "SET_MAP_LAYER_VISIBILITY";
|
||||
export const SET_DISTANCE_TRAVELLED = "SET_DISTANCE_TRAVELLED";
|
||||
export const SET_ELEVATION_GAIN = "SET_ELEVATION_GAIN";
|
||||
export const SET_ELEVATION_LOSS = "SET_ELEVATION_LOSS";
|
||||
export const SET_REQUEST_ABORT_CONTROLLER = "SET_REQUEST_ABORT_CONTROLLER";
|
||||
|
||||
@@ -43,4 +43,13 @@ export default {
|
||||
[types.SET_DISTANCE_TRAVELLED](state, distanceTravelled) {
|
||||
state.distanceTravelled = distanceTravelled;
|
||||
},
|
||||
[types.SET_ELEVATION_GAIN](state, elevationGain) {
|
||||
state.elevationGain = elevationGain;
|
||||
},
|
||||
[types.SET_ELEVATION_LOSS](state, elevationLoss) {
|
||||
state.elevationLoss = elevationLoss;
|
||||
},
|
||||
[types.SET_REQUEST_ABORT_CONTROLLER](state, requestAbortController) {
|
||||
state.requestAbortController = requestAbortController;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -9,12 +9,14 @@
|
||||
--color-background: #fff;
|
||||
--color-primary: #3f51b5;
|
||||
--color-primary-text: #fff;
|
||||
--color-separator: #ddd;
|
||||
--drop-shadow: drop-shadow(0 10px 10px rgb(0, 0, 0, 0.2));
|
||||
--dropdown-arrow: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2225%22%20height%3D%2210%22%3E%3Cpath%20fill%3D%22%23333%22%20fill-opacity%3D%221%22%20stroke%3D%22none%22%20d%3D%22M0%2C0%20L0%2C0%20L1%2C0%20L1%2C6%20L7%2C6%20L7%2C7%20L0%2C7%20z%22%20transform%3D%22rotate(-45%205%200)%22%20%2F%3E%3C%2Fsvg%3E");
|
||||
--pin-width: 32px;
|
||||
}
|
||||
|
||||
html, body {
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -84,7 +86,8 @@ pre {
|
||||
display: block;
|
||||
font-family: Consolas, "Andale Mono WT", "Andale Mono", "Lucida Console",
|
||||
"Lucida Sans Typewriter", "DejaVu Sans Mono", "Bitstream Vera Sans Mono",
|
||||
"Liberation Mono", "Nimbus Mono L", Monaco, "Courier New", Courier, monospace;
|
||||
"Liberation Mono", "Nimbus Mono L", Monaco, "Courier New", Courier,
|
||||
monospace;
|
||||
overflow-x: auto;
|
||||
|
||||
code {
|
||||
@@ -98,14 +101,21 @@ pre {
|
||||
min-height: 100%;
|
||||
flex-direction: column;
|
||||
|
||||
// Only select immediate child as the datepicker contains a <header> as well
|
||||
> header {
|
||||
header {
|
||||
display: flex;
|
||||
padding: 20px;
|
||||
white-space: nowrap;
|
||||
color: var(--color-primary-text);
|
||||
background: var(--color-primary);
|
||||
|
||||
&.header-sm {
|
||||
padding: 10px;
|
||||
|
||||
.header-item:not(.nav-sm) .nav-item:not(:first-child) {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -152,19 +162,56 @@ pre {
|
||||
left: 0;
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
padding: 30px;
|
||||
padding: 20px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 76px;
|
||||
top: 56px;
|
||||
z-index: 1;
|
||||
|
||||
.nav-item {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 400px;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-left: 0;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
> .button,
|
||||
> .mx-datepicker,
|
||||
> .mx-input,
|
||||
> .dropdown {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
> .dropdown .dropdown-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mx-datepicker {
|
||||
display: flex;
|
||||
width: auto;
|
||||
|
||||
.mx-datepicker-range {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.mx-input-wrapper {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
||||
.mx-input {
|
||||
font-size: 13px;
|
||||
letter-spacing: -0.6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> span {
|
||||
flex-basis: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -257,7 +304,9 @@ pre {
|
||||
|
||||
&.button-outline,
|
||||
&.button-flat {
|
||||
transition: background-color 0.2s, box-shadow 0.2s;
|
||||
transition:
|
||||
background-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
|
||||
@@ -1,19 +1,45 @@
|
||||
.date-time-picker {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
.mx-datepicker {
|
||||
width: 280px;
|
||||
|
||||
.datepicker {
|
||||
box-shadow: none !important;
|
||||
filter: var(--drop-shadow);
|
||||
margin-top: 5px;
|
||||
.mx-input {
|
||||
border: 0;
|
||||
border-radius: 18px;
|
||||
text-align: center;
|
||||
height: 33px;
|
||||
padding-right: 0px;
|
||||
padding-left: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 415px) {
|
||||
// Fix buttons being off screen
|
||||
.datepicker-buttons-container {
|
||||
bottom: 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
@media screen and (max-width: 400px) {
|
||||
.mx-datepicker-main {
|
||||
display: flex;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.mx-datepicker-sidebar {
|
||||
flex: 0.7;
|
||||
}
|
||||
|
||||
.mx-datepicker-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
margin-left: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mx-time {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toggle-date-btn {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.mx-icon-calendar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
|
||||
.leaflet-popup {
|
||||
filter: var(--drop-shadow);
|
||||
margin-bottom: calc(var(--pin-width) * 1.5 + 20px);
|
||||
|
||||
&--for-pin {
|
||||
margin-bottom: calc(var(--pin-width) * 1.5 + 20px);
|
||||
}
|
||||
|
||||
.leaflet-popup-content-wrapper {
|
||||
border-radius: 3px;
|
||||
@@ -12,17 +15,24 @@
|
||||
|
||||
.leaflet-popup-content {
|
||||
margin: 30px;
|
||||
|
||||
.face {
|
||||
width: 40px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a.leaflet-popup-close-button {
|
||||
color: inherit;
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin-top: 15px;
|
||||
margin-right: 15px;
|
||||
border-radius: 100px;
|
||||
border-radius: 100%;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover,
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
.v--modal-overlay {
|
||||
background: rgba(0, 0, 0, 0.5) !important;
|
||||
|
||||
.v--modal-background-click {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.v--modal-box.v--modal {
|
||||
top: initial !important;
|
||||
left: initial !important;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
max-width: 95vw;
|
||||
max-height: 95vh;
|
||||
overflow: auto;
|
||||
padding: 30px;
|
||||
border-radius: 3px;
|
||||
background: var(--color-background);
|
||||
}
|
||||
.vm--container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
.vm--overlay {
|
||||
background: rgba(0, 0, 0, 0.5) !important;
|
||||
}
|
||||
.vm--modal {
|
||||
top: initial !important;
|
||||
left: initial !important;
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
max-width: 95vw;
|
||||
max-height: 95vh;
|
||||
overflow: auto;
|
||||
padding: 30px;
|
||||
border-radius: 3px;
|
||||
background: var(--color-background);
|
||||
}
|
||||
}
|
||||
|
||||
34
src/util.js
@@ -10,9 +10,9 @@ import { DATE_TIME_FORMAT, EARTH_RADIUS_IN_KM } from "@/constants";
|
||||
* @param {String} path Path to the API resource
|
||||
* @returns {URL} Final API URL
|
||||
*/
|
||||
export const getApiUrl = path => {
|
||||
export const getApiUrl = (path) => {
|
||||
const normalizedBaseUrl = config.api.baseUrl.endsWith("/")
|
||||
? config.api.baseUrl.slice(1)
|
||||
? config.api.baseUrl.slice(0, -1)
|
||||
: config.api.baseUrl;
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
return new URL(`${normalizedBaseUrl}${normalizedPath}`);
|
||||
@@ -24,7 +24,7 @@ export const getApiUrl = path => {
|
||||
* @param {String} s Input value to be tested
|
||||
* @returns {Boolean} Whether the input matches the expected format
|
||||
*/
|
||||
export const isIsoDateTime = s => moment(s, DATE_TIME_FORMAT, true).isValid();
|
||||
export const isIsoDateTime = (s) => moment(s, DATE_TIME_FORMAT, true).isValid();
|
||||
|
||||
/**
|
||||
* Convert degrees to radians.
|
||||
@@ -32,7 +32,7 @@ export const isIsoDateTime = s => moment(s, DATE_TIME_FORMAT, true).isValid();
|
||||
* @param {Number} degrees Angle in degrees
|
||||
* @returns {Number} Angle in radians
|
||||
*/
|
||||
export const degreesToRadians = degrees => (degrees * Math.PI) / 180;
|
||||
export const degreesToRadians = (degrees) => (degrees * Math.PI) / 180;
|
||||
|
||||
/**
|
||||
* Calculate the distance between two coordinates. Uses the haversine formula,
|
||||
@@ -64,24 +64,6 @@ export const distanceBetweenCoordinates = (c1, c2) => {
|
||||
return d;
|
||||
};
|
||||
|
||||
/**
|
||||
* Let the user download a string as file.
|
||||
*
|
||||
* @param {String} text Content of the file
|
||||
* @param {String} filename Suggested filename for the browser
|
||||
* @param {String} [mimeType] Content mime type
|
||||
*/
|
||||
export const download = (text, filename, mimeType = "text/plain") => {
|
||||
const dataUrl = `data:${mimeType},${encodeURIComponent(text)}`;
|
||||
const element = document.createElement("a");
|
||||
element.href = dataUrl;
|
||||
element.download = filename;
|
||||
element.style.display = "none";
|
||||
document.body.appendChild(element);
|
||||
element.click();
|
||||
document.body.removeChild(element);
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a distance in meters into a human-readable string with unit.
|
||||
*
|
||||
@@ -91,7 +73,7 @@ export const download = (text, filename, mimeType = "text/plain") => {
|
||||
* @param {Number} distance Distance in meters
|
||||
* @returns {String} Formatted string including unit
|
||||
*/
|
||||
export const humanReadableDistance = distance => {
|
||||
export const humanReadableDistance = (distance) => {
|
||||
let unit = "m";
|
||||
if (Math.abs(distance) >= 1000) {
|
||||
distance = distance / 1000;
|
||||
@@ -108,11 +90,11 @@ export const humanReadableDistance = distance => {
|
||||
* @param {LocationHistory} locationHistory Location history
|
||||
* @returns {Number} Total number of locations
|
||||
*/
|
||||
export const getLocationHistoryCount = locationHistory =>
|
||||
export const getLocationHistoryCount = (locationHistory) =>
|
||||
Object.keys(locationHistory)
|
||||
.map(user =>
|
||||
.map((user) =>
|
||||
Object.keys(locationHistory[user])
|
||||
.map(device => locationHistory[user][device].length)
|
||||
.map((device) => locationHistory[user][device].length)
|
||||
.reduce((a, b) => a + b, 0)
|
||||
)
|
||||
.reduce((a, b) => a + b, 0);
|
||||
|
||||
@@ -14,16 +14,73 @@
|
||||
<LControlScale
|
||||
v-if="controls.scale.display"
|
||||
:position="controls.scale.position"
|
||||
:maxWidth="controls.scale.maxWidth"
|
||||
:max-width="controls.scale.maxWidth"
|
||||
:metric="controls.scale.metric"
|
||||
:imperial="controls.scale.imperial"
|
||||
/>
|
||||
<LTileLayer
|
||||
:url="url"
|
||||
:attribution="attribution"
|
||||
:options="{ maxNativeZoom, maxZoom }"
|
||||
:tile-size="tileSize"
|
||||
:options="{ maxNativeZoom, maxZoom, zoomOffset }"
|
||||
/>
|
||||
|
||||
<template v-if="map.layers.line">
|
||||
<LPolyline
|
||||
v-for="(group, i) in filteredLocationHistoryLatLngGroups"
|
||||
:key="i"
|
||||
:lat-lngs="group"
|
||||
v-bind="polyline"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-for="(userDevices, user) in filteredLocationHistory">
|
||||
<template v-for="(deviceLocations, device) in userDevices">
|
||||
<template
|
||||
v-for="(l, n) in deviceLocationsWithNameAndFace(
|
||||
user,
|
||||
device,
|
||||
deviceLocations
|
||||
)"
|
||||
>
|
||||
<LCircleMarker
|
||||
v-if="map.layers.poi && l.poi"
|
||||
:key="`${l.topic}-poi-${n}`"
|
||||
:lat-lng="[l.lat, l.lon]"
|
||||
v-bind="poiMarker"
|
||||
>
|
||||
<LTooltip :options="{ permanent: true }">
|
||||
{{ l.poi }}
|
||||
</LTooltip>
|
||||
</LCircleMarker>
|
||||
<LCircleMarker
|
||||
v-if="map.layers.points"
|
||||
:key="`${l.topic}-location-${n}`"
|
||||
:lat-lng="[l.lat, l.lon]"
|
||||
v-bind="circleMarker"
|
||||
>
|
||||
<LDeviceLocationPopup
|
||||
:user="user"
|
||||
:device="device"
|
||||
:name="l.name"
|
||||
:face="l.face"
|
||||
:timestamp="l.tst"
|
||||
:iso-local="l.isolocal"
|
||||
:time-zone="l.tzname"
|
||||
:lat="l.lat"
|
||||
:lon="l.lon"
|
||||
:alt="l.alt"
|
||||
:battery="l.batt"
|
||||
:speed="l.vel"
|
||||
:regions="l.inregions"
|
||||
:wifi="{ ssid: l.SSID, bssid: l.BSSID }"
|
||||
:address="l.addr"
|
||||
></LDeviceLocationPopup>
|
||||
</LCircleMarker>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-if="map.layers.last">
|
||||
<LCircle
|
||||
v-for="l in lastLocations"
|
||||
@@ -45,58 +102,25 @@
|
||||
:name="l.name"
|
||||
:face="l.face"
|
||||
:timestamp="l.tst"
|
||||
:iso-local="l.isolocal"
|
||||
:time-zone="l.tzname"
|
||||
:lat="l.lat"
|
||||
:lon="l.lon"
|
||||
:alt="l.alt"
|
||||
:battery="l.batt"
|
||||
:speed="l.vel"
|
||||
:regions="l.inregions"
|
||||
:wifi="{ ssid: l.SSID, bssid: l.BSSID }"
|
||||
:options="{ className: 'leaflet-popup--for-pin', maxWidth: 400 }"
|
||||
:address="l.addr"
|
||||
/>
|
||||
</LMarker>
|
||||
</template>
|
||||
|
||||
<template v-if="map.layers.line">
|
||||
<LPolyline
|
||||
v-for="(group, i) in locationHistoryLatLngGroups"
|
||||
:key="i"
|
||||
:lat-lngs="group"
|
||||
v-bind="polyline"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="map.layers.points">
|
||||
<template v-for="(userDevices, user) in locationHistory">
|
||||
<template v-for="(deviceLocations, device) in userDevices">
|
||||
<LCircleMarker
|
||||
v-for="(l, n) in deviceLocationsWithNameAndFace(
|
||||
user,
|
||||
device,
|
||||
deviceLocations
|
||||
)"
|
||||
:key="`${user}-${device}-${n}`"
|
||||
:lat-lng="[l.lat, l.lon]"
|
||||
v-bind="circleMarker"
|
||||
>
|
||||
<LDeviceLocationPopup
|
||||
:user="user"
|
||||
:device="device"
|
||||
:name="l.name"
|
||||
:face="l.face"
|
||||
:timestamp="l.tst"
|
||||
:lat="l.lat"
|
||||
:lon="l.lon"
|
||||
:alt="l.alt"
|
||||
:battery="l.batt"
|
||||
:speed="l.vel"
|
||||
></LDeviceLocationPopup>
|
||||
</LCircleMarker>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-if="map.layers.heatmap">
|
||||
<LHeatmap
|
||||
v-if="locationHistoryLatLngs.length"
|
||||
:lat-lng="locationHistoryLatLngs"
|
||||
v-if="filteredLocationHistoryLatLngs.length"
|
||||
:lat-lng="filteredLocationHistoryLatLngs"
|
||||
:max="heatmap.max"
|
||||
:radius="heatmap.radius"
|
||||
:blur="heatmap.blur"
|
||||
@@ -118,12 +142,13 @@ import {
|
||||
LCircleMarker,
|
||||
LCircle,
|
||||
LPolyline,
|
||||
LTooltip,
|
||||
} from "vue2-leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import * as types from "@/store/mutation-types";
|
||||
import LCustomMarker from "@/components/LCustomMarker";
|
||||
import LHeatmap from "@/components/LHeatmap";
|
||||
import LDeviceLocationPopup from "@/components/LDeviceLocationPopup";
|
||||
import LHeatmap from "@/components/LHeatmap.vue";
|
||||
import LDeviceLocationPopup from "@/components/LDeviceLocationPopup.vue";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -137,6 +162,7 @@ export default {
|
||||
LPolyline,
|
||||
LDeviceLocationPopup,
|
||||
LHeatmap,
|
||||
LTooltip,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -147,8 +173,10 @@ export default {
|
||||
markerIcon: LCustomMarker,
|
||||
maxZoom: this.$config.map.maxZoom,
|
||||
maxNativeZoom: this.$config.map.maxNativeZoom,
|
||||
tileSize: this.$config.map.tileSize,
|
||||
url: this.$config.map.url,
|
||||
zoom: this.$store.state.map.zoom,
|
||||
zoomOffset: this.$config.map.zoomOffset,
|
||||
circle: {
|
||||
...this.$config.map.circle,
|
||||
color: this.$config.map.circle.color || this.$config.primaryColor,
|
||||
@@ -159,21 +187,36 @@ export default {
|
||||
...this.$config.map.circleMarker,
|
||||
color: this.$config.map.circleMarker.color || this.$config.primaryColor,
|
||||
},
|
||||
poiMarker: this.$config.map.poiMarker,
|
||||
polyline: {
|
||||
...this.$config.map.polyline,
|
||||
color: this.$config.map.polyline.color || this.$config.primaryColor,
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters([
|
||||
"filteredLocationHistory",
|
||||
"filteredLocationHistoryLatLngs",
|
||||
"filteredLocationHistoryLatLngGroups",
|
||||
]),
|
||||
...mapState(["lastLocations", "map"]),
|
||||
},
|
||||
watch: {
|
||||
lastLocations() {
|
||||
if (this.$config.onLocationChange.fitView) {
|
||||
this.fitView();
|
||||
}
|
||||
},
|
||||
filteredLocationHistory() {
|
||||
this.fitView();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$root.$on("fitView", () => {
|
||||
this.fitView();
|
||||
});
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(["locationHistoryLatLngs", "locationHistoryLatLngGroups"]),
|
||||
...mapState(["lastLocations", "locationHistory", "map"]),
|
||||
},
|
||||
methods: {
|
||||
...mapMutations({
|
||||
setMapCenter: types.SET_MAP_CENTER,
|
||||
@@ -186,14 +229,15 @@ export default {
|
||||
if (
|
||||
(this.map.layers.line ||
|
||||
this.map.layers.points ||
|
||||
this.map.layers.poi ||
|
||||
this.map.layers.heatmap) &&
|
||||
this.locationHistoryLatLngs.length > 0
|
||||
this.filteredLocationHistoryLatLngs.length > 0
|
||||
) {
|
||||
this.$refs.map.mapObject.fitBounds(
|
||||
new L.LatLngBounds(this.locationHistoryLatLngs)
|
||||
new L.LatLngBounds(this.filteredLocationHistoryLatLngs)
|
||||
);
|
||||
} else if (this.map.layers.last && this.lastLocations.length > 0) {
|
||||
const locations = this.lastLocations.map(l => L.latLng(l.lat, l.lon));
|
||||
const locations = this.lastLocations.map((l) => L.latLng(l.lat, l.lon));
|
||||
this.$refs.map.mapObject.fitBounds(new L.LatLngBounds(locations), {
|
||||
maxZoom: this.maxNativeZoom,
|
||||
});
|
||||
@@ -211,25 +255,17 @@ export default {
|
||||
*/
|
||||
deviceLocationsWithNameAndFace(user, device, deviceLocations) {
|
||||
const lastLocation = this.lastLocations.find(
|
||||
l => l.username === user && l.device === device
|
||||
(l) => l.username === user && l.device === device
|
||||
);
|
||||
if (!lastLocation) {
|
||||
return deviceLocations;
|
||||
}
|
||||
return deviceLocations.map(l => ({
|
||||
return deviceLocations.map((l) => ({
|
||||
...l,
|
||||
name: lastLocation.name,
|
||||
face: lastLocation.face,
|
||||
}));
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
lastLocations() {
|
||||
this.fitView();
|
||||
},
|
||||
locationHistory() {
|
||||
this.fitView();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
jest: true,
|
||||
},
|
||||
};
|
||||
@@ -1,32 +1,42 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import createFetchMock from "vitest-fetch-mock";
|
||||
|
||||
import * as api from "@/api";
|
||||
|
||||
const fetchMocker = createFetchMock(vi);
|
||||
|
||||
describe("API", () => {
|
||||
beforeEach(() => {
|
||||
fetch.resetMocks();
|
||||
fetchMocker.enableMocks();
|
||||
fetchMocker.resetMocks();
|
||||
});
|
||||
|
||||
test("getVersion", async () => {
|
||||
fetch.mockResponse(JSON.stringify({ version: "1.2.3" }));
|
||||
fetchMocker.mockResponse(JSON.stringify({ version: "1.2.3" }));
|
||||
|
||||
const version = await api.getVersion();
|
||||
expect(version).toBe("1.2.3");
|
||||
|
||||
expect(fetch.mock.calls.length).toEqual(1);
|
||||
expect(fetch.mock.calls[0][0]).toEqual("http://localhost/api/0/version");
|
||||
expect(fetchMocker.mock.calls.length).toEqual(1);
|
||||
expect(fetchMocker.mock.calls[0][0]).toEqual(
|
||||
"http://localhost:3000/api/0/version"
|
||||
);
|
||||
});
|
||||
|
||||
test("getUsers", async () => {
|
||||
fetch.mockResponse(JSON.stringify({ results: ["foo", "bar"] }));
|
||||
fetchMocker.mockResponse(JSON.stringify({ results: ["foo", "bar"] }));
|
||||
|
||||
const users = await api.getUsers();
|
||||
expect(users).toEqual(["foo", "bar"]);
|
||||
|
||||
expect(fetch.mock.calls.length).toEqual(1);
|
||||
expect(fetch.mock.calls[0][0]).toEqual("http://localhost/api/0/list");
|
||||
expect(fetchMocker.mock.calls.length).toEqual(1);
|
||||
expect(fetchMocker.mock.calls[0][0]).toEqual(
|
||||
"http://localhost:3000/api/0/list"
|
||||
);
|
||||
});
|
||||
|
||||
test("getDevices", async () => {
|
||||
fetch.mockResponses(
|
||||
fetchMocker.mockResponses(
|
||||
[JSON.stringify({ results: ["phone", "tablet"] })],
|
||||
[JSON.stringify({ results: ["laptop"] })]
|
||||
);
|
||||
@@ -34,12 +44,12 @@ describe("API", () => {
|
||||
const devices = await api.getDevices(["foo", "bar"]);
|
||||
expect(devices).toEqual({ foo: ["phone", "tablet"], bar: ["laptop"] });
|
||||
|
||||
expect(fetch.mock.calls.length).toEqual(2);
|
||||
expect(fetch.mock.calls[0][0]).toEqual(
|
||||
"http://localhost/api/0/list?user=foo"
|
||||
expect(fetchMocker.mock.calls.length).toEqual(2);
|
||||
expect(fetchMocker.mock.calls[0][0]).toEqual(
|
||||
"http://localhost:3000/api/0/list?user=foo"
|
||||
);
|
||||
expect(fetch.mock.calls[1][0]).toEqual(
|
||||
"http://localhost/api/0/list?user=bar"
|
||||
expect(fetchMocker.mock.calls[1][0]).toEqual(
|
||||
"http://localhost:3000/api/0/list?user=bar"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -60,13 +70,15 @@ describe("API", () => {
|
||||
disptst: "1970-01-01 00:00:00",
|
||||
},
|
||||
];
|
||||
fetch.mockResponse(JSON.stringify(response));
|
||||
fetchMocker.mockResponse(JSON.stringify(response));
|
||||
|
||||
const lastLocation = await api.getLastLocations();
|
||||
expect(lastLocation).toEqual(response);
|
||||
|
||||
expect(fetch.mock.calls.length).toEqual(1);
|
||||
expect(fetch.mock.calls[0][0]).toEqual("http://localhost/api/0/last");
|
||||
expect(fetchMocker.mock.calls.length).toEqual(1);
|
||||
expect(fetchMocker.mock.calls[0][0]).toEqual(
|
||||
"http://localhost:3000/api/0/last"
|
||||
);
|
||||
});
|
||||
|
||||
test("getLastLocations with user", async () => {
|
||||
@@ -81,14 +93,14 @@ describe("API", () => {
|
||||
device: "tablet",
|
||||
},
|
||||
];
|
||||
fetch.mockResponse(JSON.stringify(response));
|
||||
fetchMocker.mockResponse(JSON.stringify(response));
|
||||
|
||||
const lastLocation = await api.getLastLocations("foo");
|
||||
expect(lastLocation).toEqual(response);
|
||||
|
||||
expect(fetch.mock.calls.length).toEqual(1);
|
||||
expect(fetch.mock.calls[0][0]).toEqual(
|
||||
"http://localhost/api/0/last?user=foo"
|
||||
expect(fetchMocker.mock.calls.length).toEqual(1);
|
||||
expect(fetchMocker.mock.calls[0][0]).toEqual(
|
||||
"http://localhost:3000/api/0/last?user=foo"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -100,14 +112,14 @@ describe("API", () => {
|
||||
device: "phone",
|
||||
},
|
||||
];
|
||||
fetch.mockResponse(JSON.stringify(response));
|
||||
fetchMocker.mockResponse(JSON.stringify(response));
|
||||
|
||||
const lastLocation = await api.getLastLocations("foo", "phone");
|
||||
expect(lastLocation).toEqual(response);
|
||||
|
||||
expect(fetch.mock.calls.length).toEqual(1);
|
||||
expect(fetch.mock.calls[0][0]).toEqual(
|
||||
"http://localhost/api/0/last?user=foo&device=phone"
|
||||
expect(fetchMocker.mock.calls.length).toEqual(1);
|
||||
expect(fetchMocker.mock.calls[0][0]).toEqual(
|
||||
"http://localhost:3000/api/0/last?user=foo&device=phone"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -137,7 +149,7 @@ describe("API", () => {
|
||||
],
|
||||
status: 200,
|
||||
};
|
||||
fetch.mockResponse(JSON.stringify(response));
|
||||
fetchMocker.mockResponse(JSON.stringify(response));
|
||||
|
||||
const locationHistory = await api.getUserDeviceLocationHistory(
|
||||
"foo",
|
||||
@@ -147,14 +159,14 @@ describe("API", () => {
|
||||
);
|
||||
expect(locationHistory).toEqual(response.data);
|
||||
|
||||
expect(fetch.mock.calls.length).toEqual(1);
|
||||
expect(fetch.mock.calls[0][0]).toEqual(
|
||||
"http://localhost/api/0/locations?from=1970-01-01T00%3A00%3A00&to=1970-12-31T23%3A59%3A59&user=foo&device=phone&format=json"
|
||||
expect(fetchMocker.mock.calls.length).toEqual(1);
|
||||
expect(fetchMocker.mock.calls[0][0]).toEqual(
|
||||
"http://localhost:3000/api/0/locations?from=1970-01-01T00%3A00%3A00&to=1970-12-31T23%3A59%3A59&user=foo&device=phone&format=json"
|
||||
);
|
||||
});
|
||||
|
||||
test("getLocationHistory", async () => {
|
||||
fetch.mockResponses(
|
||||
fetchMocker.mockResponses(
|
||||
[
|
||||
JSON.stringify({
|
||||
count: 1,
|
||||
@@ -203,15 +215,15 @@ describe("API", () => {
|
||||
bar: { laptop: [{ topic: "owntracks/bar/laptop" }] },
|
||||
});
|
||||
|
||||
expect(fetch.mock.calls.length).toEqual(3);
|
||||
expect(fetch.mock.calls[0][0]).toEqual(
|
||||
"http://localhost/api/0/locations?from=1970-01-01T00%3A00%3A00&to=1970-12-31T23%3A59%3A59&user=foo&device=phone&format=json"
|
||||
expect(fetchMocker.mock.calls.length).toEqual(3);
|
||||
expect(fetchMocker.mock.calls[0][0]).toEqual(
|
||||
"http://localhost:3000/api/0/locations?from=1970-01-01T00%3A00%3A00&to=1970-12-31T23%3A59%3A59&user=foo&device=phone&format=json"
|
||||
);
|
||||
expect(fetch.mock.calls[1][0]).toEqual(
|
||||
"http://localhost/api/0/locations?from=1970-01-01T00%3A00%3A00&to=1970-12-31T23%3A59%3A59&user=foo&device=tablet&format=json"
|
||||
expect(fetchMocker.mock.calls[1][0]).toEqual(
|
||||
"http://localhost:3000/api/0/locations?from=1970-01-01T00%3A00%3A00&to=1970-12-31T23%3A59%3A59&user=foo&device=tablet&format=json"
|
||||
);
|
||||
expect(fetch.mock.calls[2][0]).toEqual(
|
||||
"http://localhost/api/0/locations?from=1970-01-01T00%3A00%3A00&to=1970-12-31T23%3A59%3A59&user=bar&device=laptop&format=json"
|
||||
expect(fetchMocker.mock.calls[2][0]).toEqual(
|
||||
"http://localhost:3000/api/0/locations?from=1970-01-01T00%3A00%3A00&to=1970-12-31T23%3A59%3A59&user=bar&device=laptop&format=json"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import config from "@/config";
|
||||
import {
|
||||
getApiUrl,
|
||||
@@ -10,9 +12,9 @@ import {
|
||||
describe("getApiUrl", () => {
|
||||
test("without base URL", () => {
|
||||
// See testURL in jest.config.js
|
||||
expect(getApiUrl("foo").href).toBe("http://localhost/foo");
|
||||
expect(getApiUrl("/foo").href).toBe("http://localhost/foo");
|
||||
expect(getApiUrl("/foo/bar").href).toBe("http://localhost/foo/bar");
|
||||
expect(getApiUrl("foo").href).toBe("http://localhost:3000/foo");
|
||||
expect(getApiUrl("/foo").href).toBe("http://localhost:3000/foo");
|
||||
expect(getApiUrl("/foo/bar").href).toBe("http://localhost:3000/foo/bar");
|
||||
});
|
||||
|
||||
test("with base URL", () => {
|
||||
@@ -22,6 +24,13 @@ describe("getApiUrl", () => {
|
||||
expect(getApiUrl("/foo/bar").href).toBe(
|
||||
"http://example.com/owntracks/foo/bar"
|
||||
);
|
||||
|
||||
config.api.baseUrl = "http://example.com/owntracks/";
|
||||
expect(getApiUrl("foo").href).toBe("http://example.com/owntracks/foo");
|
||||
expect(getApiUrl("/foo").href).toBe("http://example.com/owntracks/foo");
|
||||
expect(getApiUrl("/foo/bar").href).toBe(
|
||||
"http://example.com/owntracks/foo/bar"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
19
vite.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue2";
|
||||
import version from "vite-plugin-package-version";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
base: "",
|
||||
plugins: [vue(), version()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(dirname(fileURLToPath(import.meta.url)), "./src"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
},
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
const fs = require("fs");
|
||||
const webpack = require("webpack");
|
||||
const MomentLocalesPlugin = require("moment-locales-webpack-plugin");
|
||||
|
||||
const packageJson = fs.readFileSync("./package.json");
|
||||
const version = JSON.parse(packageJson).version;
|
||||
|
||||
module.exports = {
|
||||
configureWebpack: {
|
||||
plugins: [
|
||||
new webpack.DefinePlugin({
|
||||
"process.env": {
|
||||
PACKAGE_VERSION: `"${version}"`,
|
||||
},
|
||||
}),
|
||||
new MomentLocalesPlugin(),
|
||||
],
|
||||
},
|
||||
|
||||
pluginOptions: {
|
||||
i18n: {
|
||||
locale: "en",
|
||||
fallbackLocale: "en",
|
||||
localeDir: "locales",
|
||||
},
|
||||
},
|
||||
};
|
||||