217 Commits

Author SHA1 Message Date
Jan-Piet Mens
f780bbede3 Add note about auth/tls
As this is turning into a bit of a faq, I think we should add this to the README
2024-12-27 08:27:57 +01:00
Jan-Piet Mens
7877aaa9f8 Merge pull request #134 from gene1wood/patch-1
Fix typo in config.example.js comment
2024-11-27 08:20:18 +01:00
Gene Wood
341ce4c353 Fix typo in config.example.js comment 2024-11-26 20:31:32 -08:00
Maxim Baz
5ecad2bf40 nginx: increase read timeout 2024-07-28 18:25:39 +01:00
Jan Jaden Schmidt
3365959ed3 Add more options to the timespan selector (#123)
* Add more options to the timespan selector

Add more options to the timespan selector.

* Add 'Yesterday', '3 days', '15 days', '3 months', '6 months', and '1 year' options to the `shortcuts` array in `src/components/AppHeader.vue`.
* Ensure the new options are added in the same format as the existing options.
* Verify that the new options are correctly displayed in the timespan selector.

* Add translations for timespan selector

Add translations for timespan selector in multiple locales

* **cs-CZ.json**: Add translations for 'Today', 'Yesterday', '3 days', '7 days', '15 days', '30 days', '3 months', '6 months', and '1 year'.
* **da-DK.json**: Add translations for 'Today', 'Yesterday', '3 days', '7 days', '15 days', '30 days', '3 months', '6 months', and '1 year'.
* **de-DE.json**: Add translations for 'Today', 'Yesterday', '3 days', '7 days', '15 days', '30 days', '3 months', '6 months', and '1 year'.
* **en-GB.json**: Add translations for 'Yesterday', '3 days', '15 days', '3 months', '6 months', and '1 year'.
* **en-US.json**: Add translations for 'Yesterday', '3 days', '15 days', '3 months', '6 months', and '1 year'.
* **es-ES.json**: Add translations for 'Today', 'Yesterday', '3 days', '7 days', '15 days', '30 days', '3 months', '6 months', and '1 year'.
* **fr-FR.json**: Add translations for 'Today', 'Yesterday', '3 days', '7 days', '15 days', '30 days', '3 months', '6 months', and '1 year'.
* **sk-SK.json**: Add translations for 'Today', 'Yesterday', '3 days', '7 days', '15 days', '30 days', '3 months', '6 months', and '1 year'.
* **tr-TR.json**: Add translations for 'Today', 'Yesterday', '3 days', '7 days', '15 days', '30 days', '3 months', '6 months', and '1 year'.
2024-07-24 11:08:58 +01:00
Linus Groh
15a40f9c6c Upgrade dependencies 2024-07-06 10:06:16 +01:00
Linus Groh
9839b5acdd Release 2.15.3 2024-06-15 16:25:06 +02:00
Linus Groh
0300e2fb4f Force relative path for config/config.js
Closes #118, again.
2024-06-15 16:23:31 +02:00
Linus Groh
4c680590a4 Add eslint-config-prettier as a dev dependency 2024-06-14 17:56:53 +02:00
Linus Groh
551b226fd0 Release 2.15.2 2024-06-14 17:54:46 +02:00
Linus Groh
132f15c52b Recreate npm lockfile
Error: Cannot find module @rollup/rollup-linux-arm-gnueabihf. npm has a bug related to optional dependencies (https://github.com/npm/cli/issues/4828). Please try `npm i` again after removing both package-lock.json and node_modules directory.
2024-06-14 17:53:57 +02:00
Linus Groh
c60bfb5368 Release 2.15.1 2024-06-14 17:47:24 +02:00
Linus Groh
31bf39795c Bump nginx to version 1.27 2024-06-14 17:43:44 +02:00
Linus Groh
d5c87a8727 Upgrade dependencies 2024-06-14 17:40:56 +02:00
Linus Groh
0492b355bf Clean up index.html a bit 2024-06-14 17:39:28 +02:00
Linus Groh
998a97131b Update vite config to emit relative paths
Closes #118.
2024-06-14 17:37:26 +02:00
Linus Groh
a44965226c Release 2.15.0 2024-06-10 19:58:13 +01:00
Linus Groh
554ce3f585 Implement POI map layer
Closes #107.
2024-06-10 19:53:49 +01:00
Linus Groh
05ae629217 Release 2.14.0 2024-06-09 23:07:26 +01:00
jduar
b141444b56 Implement new date/time range picker
Co-authored-by: Christophe Chapuis <chris.chapuis@gmail.com>
2024-06-09 23:04:48 +01:00
Linus Groh
723ce684ae Release 2.13.1
Oops, I forgot to bump the version...
2024-06-09 15:37:00 +01:00
Linus Groh
ed3e6125e9 Release 2.13.0 2024-06-09 15:00:45 +01:00
Linus Groh
8df1f86ab9 Remove the download modal
I have not used this once since adding it. Just use the API directly.
2024-06-09 14:49:30 +01:00
Linus Groh
b5442363d6 Upgrade dependencies 2024-06-09 14:45:31 +01:00
Andreas Baumgartner
35d55b57b1 Add action for uploading dist/ on release 2024-06-09 14:38:36 +01:00
Jiri Mencak
976bb403d1 Add Czech language 2024-05-05 19:21:29 +02:00
Linus Groh
cecf7e797d Fix i18n message keys 2024-02-27 22:06:31 +00:00
Linus Groh
91d99cd8da Migrate from vue-cli / webpack to vite 2024-02-27 22:01:37 +00:00
Linus Groh
06faa73b70 Show isolocal and tzname properties on the popup
Closes #108.
2024-02-26 23:08:39 +00:00
Linus Groh
7398da74c5 Fix popup close button style 2024-02-26 22:58:46 +00:00
Linus Groh
7b954dfbe3 Replace Twitter link with Mastodon
Closes #109.
2024-02-26 22:25:05 +00:00
Linus Groh
c569aced1e Switch from yarn to npm 2024-02-26 22:20:25 +00:00
Linus Groh
3fad44509e Happy new year I guess? 2024-02-26 22:17:36 +00:00
Linus Groh
aa13ddd832 Bump nginx to version 1.25 2024-02-26 22:17:36 +00:00
Linus Groh
6a2b113fb2 Bump node to version 20 2024-02-26 22:17:36 +00:00
Linus Groh
6f047ffa77 Reformat _base.scss with updated prettier 2024-02-26 22:17:36 +00:00
Linus Groh
d5d6c1c268 Upgrade dependencies 2024-02-26 22:17:36 +00:00
Linus Groh
4e86d8fac3 Add newline to end of README.md 2024-02-26 21:45:10 +00:00
aasami
1cb6e3519e Add Slovak language (#110) 2024-02-26 12:14:03 +00:00
Ramazan Sancar
f5389b84ab Add Turkish translations 2023-05-26 09:51:45 +01:00
Robin Meis
9bb2edb78d Add manifest.json to use frontend as PWA 2023-05-01 13:36:25 +01:00
Linus Groh
6361d8f415 Release 2.12.0 2022-09-06 20:48:59 +01:00
Linus Groh
791b756d80 Upgrade dependencies 2022-09-06 20:41:54 +01:00
Anton
f1ef82d7bb Add Danish translation 2022-09-05 19:23:56 +01:00
Linus Groh
aaef181141 Upgrade dependencies 2022-04-20 16:25:50 +02:00
atjn
b2273c071b Define a face style 2022-04-18 22:05:36 +02:00
Jonas Wunderlich
865c89b43c Updated Mapbox tile server URL in documentation example 2022-03-28 17:56:58 +01:00
Sascha Hagedorn
32c64d18f5 Fix markdown linting issue 2022-03-18 18:01:18 +00:00
Sascha Hagedorn
5a64c06af0 Add tileSize option 2022-03-18 09:39:46 +00:00
Sascha Hagedorn
8c3681b6ad Add zoomOffset option 2022-03-18 09:39:46 +00:00
Linus Groh
89899de565 Release 2.11.0 2022-03-15 23:14:51 +00:00
Linus Groh
a386c15de1 Fix jest configuration 2022-03-15 23:12:50 +00:00
Linus Groh
8ac24c99aa Fix DefinePlugin "Conflicting values for 'process.env'" warning 2022-03-15 22:46:56 +00:00
Linus Groh
f3cbf877f9 Upgrade dependencies 2022-03-15 22:46:37 +00:00
Sascha Hagedorn
f5c1c82010 Display address in device location popup
`addr` is part of the response for reverse geolocation lookups. `address` is already used in `LDeviceLocationPopup` and needs to be passed in from the map.

Closes #31
2022-03-14 22:27:52 +00:00
Linus Groh
3ea1d02c65 Show SSID and BSSID in location popup if available 2022-02-11 23:46:26 +00:00
Linus Groh
f91341b205 Add new properties to the OTLocation interface 2022-02-11 23:23:29 +00:00
Linus Groh
0c983d6206 Upgrade dependencies
Also rename a few components to match the multi-word recommendation and
add "Map" to the ignorelist of the vue/multi-word-component-names ESLint
rule.

A couple of major version bumps are currently blocked by upgrading the
project to Vue 3.
2022-02-11 22:23:16 +00:00
Linus Groh
9e36d31997 Release 2.10.0 2021-11-28 18:04:58 +00:00
Linus Groh
5e37c7f4b8 Sort device location history entries by timestamp
Fixes #67.
2021-11-28 17:52:28 +00:00
Linus Groh
7dda60d457 Ensure all SCSS files are formatted with prettier 2021-11-28 17:33:32 +00:00
Linus Groh
228900ff9f Upgrade vue-js-modal to v2 2021-11-28 17:33:08 +00:00
Linus Groh
129446de1a Upgrade dependencies 2021-11-28 17:31:55 +00:00
Linus Groh
af6c308bd6 Rename master branch to main 2021-11-28 17:31:39 +00:00
Linus Groh
223e19a118 Upgrade dependencies 2021-08-30 16:02:53 +01:00
Linus Groh
1260814309 Upgrade dependencies 2021-08-30 16:02:53 +01:00
Andrew Rowson
cfffbe9472 Add trailing slashes to location blocks (#63)
On my configuration, having a trailing slash on `proxy_pass` but not on
the location block causes the sub-path after the `location` to be
appended to the `proxy_pass` url, causing a double-forward-slash.

E.g. going to `/api/0/list` actually ends up making a call to the proxy
of `http://otrecorder/api//0/list`, which fails for me.
2021-06-28 12:36:16 +01:00
Linus Groh
4031bda2f0 Reformat files with updated prettier 2021-05-28 17:37:35 +01:00
Linus Groh
69094e240e Upgrade dependencies 2021-05-28 17:37:08 +01:00
Linus Groh
dfa7a423fa Release 2.9.0 2021-05-01 22:46:02 +02:00
Linus Groh
411bc10b0b Upgrade dependencies 2021-05-01 22:32:17 +02:00
Linus Groh
a994051940 Upgrade dependencies 2021-04-28 20:30:06 +02:00
Linus Groh
d325543bc6 Replace "OwnTracks UI" with "OwnTracks Frontend"
"OwnTracks UI" was the name I initially used when developing this
separately, but since it's now known as the "OwnTracks frontend", let's
just call it that.
2021-03-24 19:00:35 +01:00
Linus Groh
80d3060fa8 Replace borales/actions-yarn with actions/setup-node 2021-03-24 18:26:52 +01:00
Linus Groh
e6c79ac606 Upgrade dependencies 2021-03-24 18:02:09 +01:00
Linus Groh
0b1271502f Upgrade dependencies 2021-02-24 20:18:56 +01:00
Linus Groh
fdddd8e035 Add cancel button to loading modal 2021-02-24 20:07:47 +01:00
Linus Groh
245c1295e5 Fix JSDoc return type annotations of async functions
These don't return the mentioned type directly, but a promise containing
this type, which has to be await'ed first. This was confusing my IDE's
tslint which suggested await would not be necessary.
2021-02-24 20:07:47 +01:00
Linus Groh
9786487646 Move .distance-travelled SCSS to scoped <style> 2021-02-24 20:07:47 +01:00
Linus Groh
b3529c211c Release 2.8.0 2021-02-19 18:57:19 +01:00
Linus Groh
55178c7cc8 Add "Elevation gain / loss" es-ES and fr-FR translations
Based on help from computers & friends, I'm sure both know better than I
do!
2021-02-19 18:54:27 +01:00
Linus Groh
2fcf2151fa Add elevation gain / loss to distance travelled stats
Closes #51.
2021-02-18 22:05:46 +01:00
Linus Groh
5c6370090f Release 2.7.0 2021-02-14 15:06:35 +01:00
Linus Groh
fc0189e5e2 Use official Docker Buildx action 2021-02-14 15:04:21 +01:00
Linus Groh
04fb50667b Update Dockerfile to use node:14 and nginx:1.18-alpine 2021-02-14 15:04:21 +01:00
Linus Groh
6359b4783c Add missing i18n for "Loading version..."
Hopefully DeepL got the translations for French and Spanish right...
2021-02-14 13:51:34 +01:00
Linus Groh
4679f7fbb7 Add en-GB translations 2021-02-14 13:23:29 +01:00
Linus Groh
b29cd12ed9 Use xx-XX format for translation files and default to en-US
Many languages have different variants, so instead of "en" we should be
using "en-US" - this will make it possible to add slightly different
British English translations, for example.
Note that this was already supported for the `locale` config option, we
were simply discarding the part after the dash when looking for the
right translation file.

Also make sure the en-US translations are actually American English,
I'll add en-GB separately.
2021-02-14 13:21:54 +01:00
Linus Groh
a9026c7a0a Upgrade dependencies 2021-02-14 12:49:46 +01:00
Elu43
27070812a4 Add French translations 2021-02-14 12:49:07 +01:00
Linus Groh
75e79fb0b1 Upgrade dependencies 2021-01-11 19:02:35 +01:00
Linus Groh
4bb9a20787 Fix incomplete Config interface in index.d.ts
Haven't looked at this in a while. :)
2021-01-11 18:53:34 +01:00
Linus Groh
1a47fd1b6c Release 2.6.0 2020-12-29 19:39:37 +01:00
Linus Groh
163e0e3ec7 Upgrade dependencies 2020-12-29 19:21:38 +01:00
Andrew Rowson
8d8664a338 Configure Vue to not assume it's on the web root
Fixes #21.
2020-12-29 19:17:11 +01:00
Linus Groh
045e635c21 Add router.basePath config option for deployments at non-webroot
This is in preparation of configuring Vue with publicPath: "".
2020-12-29 19:17:11 +01:00
dependabot[bot]
7db7837dfd Bump ini from 1.3.5 to 1.3.8 2020-12-24 13:56:38 +01:00
Andrew Rowson
beb522c03e NGINX should listen on IPv6 as well 2020-12-24 13:15:34 +01:00
Linus Groh
658cb6b223 Release 2.5.1 2020-10-27 18:07:21 +00:00
Linus Groh
7ab98be4ad Add tests for baseUrl with trailing slash 2020-10-27 17:58:01 +00:00
Karmanyaah Malhotra
6d4d47b5a1 Fix getApiUrl() for baseUrl with trailing slash 2020-10-27 17:52:07 +00:00
Linus Groh
159470181c Upgrade dependencies 2020-10-15 08:45:08 +02:00
Linus Groh
b53a0be707 Upgrade dependencies 2020-09-20 21:34:43 +01:00
Linus Groh
4e70d3a3ad Update dependencies 2020-09-12 13:34:10 +01:00
Linus Groh
b42f6db024 Release 2.5.0 2020-09-07 21:56:37 +01:00
Linus Groh
3fbf0a2ff1 Upgrade dependencies 2020-09-07 21:46:17 +01:00
Linus Groh
206eb268fa Add onLocationChange.fitView config option
It defaults to false now, which is a change - this seems like a sensible
choice though. I can see how it would be annoying to have the map change
while looking at it just because *something* moved.

Fixes #41.
2020-09-07 21:30:47 +01:00
Linus Groh
d7266f48f1 Upgrade dependencies 2020-07-31 20:44:36 +02:00
Linus Groh
9ec17e3e9c Replace node-sass with sass (dart-sass) 2020-07-22 13:15:35 +02:00
Linus Groh
e320441b5e Upgrade dependencies 2020-07-22 13:11:59 +02:00
Linus Groh
1282d93769 Remove datepicker buttons mobile style overrides
This has been fixed upstream.
2020-06-24 14:49:30 +01:00
Linus Groh
b8661b11fb Upgrade dependencies 2020-06-24 14:44:46 +01:00
Linus Groh
4f5bfefc36 Add more badges 2020-06-14 16:27:12 +01:00
Linus Groh
a1faf8153b Replace Travis CI with GitHub Actions build/lint/test workflows (#39) 2020-06-14 16:11:20 +01:00
Linus Groh
23d73461bc Hardcode owntracks/frontend as DOCKER_IMAGE in docker.yml
As we were using the DOCKER_USERNAME secret rather than "owntracks", the
images all ended up in @jpmens' personal account :)
2020-06-14 15:28:41 +01:00
Linus Groh
90dac4022a Rename main.yml to docker.yml
I'll attempt to switch over from Travis CI to GitHub actions completely,
so "main" is not the best naming choice.
2020-06-14 15:24:58 +01:00
Linus Groh
58b22aeebe Update GitHub action Docker workflow name 2020-06-14 15:23:43 +01:00
wollew
cb79a4de81 Use GitHub Actions to build Docker images for multiple archs (#38)
Co-authored-by: Wolfgang Miller-Reichling <wolfgang.miller-reichling@1und1.de>
2020-06-14 14:26:01 +01:00
Linus Groh
c5f491b6fb Upgrade dependencies 2020-06-09 15:41:09 +01:00
dependabot[bot]
49a9b54a5e Bump websocket-extensions from 0.1.3 to 0.1.4 (#37)
Bumps [websocket-extensions](https://github.com/faye/websocket-extensions-node) from 0.1.3 to 0.1.4.
- [Release notes](https://github.com/faye/websocket-extensions-node/releases)
- [Changelog](https://github.com/faye/websocket-extensions-node/blob/master/CHANGELOG.md)
- [Commits](https://github.com/faye/websocket-extensions-node/compare/0.1.3...0.1.4)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-06-07 14:58:07 +01:00
Linus Groh
c449fcaf21 Update features list in README.md 2020-06-03 21:08:45 +01:00
Linus Groh
1746c359f4 Remove additional screenshots
They don't really add much value.
2020-06-03 20:37:25 +01:00
Linus Groh
6180e52f71 Fix vertical offset of non-pin popups 2020-06-03 20:33:26 +01:00
Linus Groh
0f0f29fcee Show regions for location on popup 2020-06-03 20:28:48 +01:00
Linus Groh
cb0694b032 Release 2.4.0 2020-06-01 18:44:07 +01:00
Linus Groh
d0f2a99302 Upgrade dependencies 2020-06-01 18:39:01 +01:00
Linus Groh
c8b0ec8b9e Upgrade dependencies
This includes a major version bump of ESLint and Prettier, so also some
reformatting.
2020-05-16 13:58:48 +01:00
Linus Groh
906eb2a1b4 Fix typos causing the minAccuracy filter to work incorrectly 2020-05-16 13:50:57 +01:00
Linus Groh
005aab715f Improve wording in docs 2020-05-11 19:22:13 +01:00
Linus Groh
b76cbdc2e6 Add filters.minAccuracy config option
This allows us to ignore location points which do not meet the configured
accuracy requirement.

Closes #35.
2020-05-11 19:15:56 +01:00
Linus Groh
bb87ec01d4 Release 2.3.1 2020-05-09 15:50:52 +01:00
Linus Groh
c3e6b775f9 Re-format config.md 2020-05-09 15:50:01 +01:00
Linus Groh
a9998e8e3b Release 2.3.0 2020-05-09 15:45:19 +01:00
Linus Groh
2c4ead262a Add api.fetchOptions to allow customising fetch() behaviour
This allows sending custom HTTP headers or including cookies in the
request!
2020-05-09 15:40:50 +01:00
Linus Groh
6b1d35be51 Upgrade dependencies 2020-05-09 14:18:14 +01:00
Linus Groh
34cc4895b0 Upgrade dependencies 2020-04-02 19:45:43 +01:00
Linus Groh
57caacb548 Release 2.2.0 2020-03-18 17:56:11 +00:00
Linus Groh
9783b6f27d Upgrade dependencies 2020-03-18 17:55:43 +00:00
Linus Groh
b262ff602c Improve mobile layout further
- Reduce header paddings
- Align buttons/dropdowns
2020-03-18 17:50:49 +00:00
Linus Groh
458658865e Release 2.1.0 2020-03-18 00:35:24 +00:00
Linus Groh
e744e2c001 Make it usable on mobile
Closes #19
2020-03-18 00:28:11 +00:00
Linus Groh
feff6d5272 Upgrade dependencies 2020-03-14 16:17:31 +00:00
Linus Groh
7b88102c2b Merge pull request #28 from owntracks/dependabot/npm_and_yarn/acorn-5.7.4
Bump acorn from 5.7.3 to 5.7.4
2020-03-14 16:14:03 +00:00
dependabot[bot]
a2c7974f38 Bump acorn from 5.7.3 to 5.7.4
Bumps [acorn](https://github.com/acornjs/acorn) from 5.7.3 to 5.7.4.
- [Release notes](https://github.com/acornjs/acorn/releases)
- [Commits](https://github.com/acornjs/acorn/compare/5.7.3...5.7.4)

Signed-off-by: dependabot[bot] <support@github.com>
2020-03-14 09:44:52 +00:00
Linus Groh
b3fdf1eabe Improve verbose mode logging
- Support passing a log message function to `logging.log` for lazy
  evaluation
- Log results (or at least lengths) of all API functions
- Log merged config during initialisation
- Log distance calculation duration
2020-03-07 00:22:56 +00:00
Linus Groh
1482833e05 Fix a few parameter type definitions 2020-03-07 00:14:59 +00:00
Linus Groh
04fa3392f0 Fix typo in CHANGELOG.md 2020-03-03 14:05:08 +00:00
Linus Groh
a4334e5273 Fix manual installation instructions in README.md
`yarn install --production` does not actually install all dependencies
required for building - maybe we have to move some `devDependencies` to
`dependencies`, but for now this should do the trick.
2020-03-03 13:40:15 +00:00
Linus Groh
806526380d Replace default Leaflet marker with a custom one
Closes #2
2020-03-03 08:11:45 +00:00
Linus Groh
2e63f01438 Release 2.0.0 🎉 2020-03-01 21:16:59 +00:00
Linus Groh
fb97cd080f Release 2.0.0-beta.11 2020-03-01 21:11:33 +00:00
Linus Groh
f2d461d019 Merge pull request #20 from owntracks/clean-up-the-typing-mess
Clean up the typing mess
2020-03-01 20:59:36 +00:00
Linus Groh
798a0af97d Update function documentations 2020-03-01 20:56:28 +00:00
Linus Groh
3c92a77847 Fix typo in OTLocation.inregions comment 2020-03-01 20:55:52 +00:00
Linus Groh
b252a6580a Use TypeScript interface for websocket calllback 2020-03-01 20:53:38 +00:00
Linus Groh
9ce9933d11 Remove unused properties from Config interface 2020-03-01 20:34:21 +00:00
Linus Groh
f22db6301b Merge branch 'master' into clean-up-the-typing-mess 2020-03-01 20:33:00 +00:00
Linus Groh
3b18ab58ed Replace map initial center/zoom config with auto fitting
Closes #23
2020-03-01 20:28:15 +00:00
Linus Groh
bb81daaea4 Upgrade dependencies 2020-03-01 19:56:57 +00:00
Linus Groh
f491d63eb9 Mention Spanish translations in config docs 2020-02-09 11:07:35 +00:00
Linus Groh
e923fdc6c7 Merge pull request #25 from dtorner/patch-2
Create es.json
2020-02-09 11:00:30 +00:00
dtorner
b1ce1297ed Create es.json 2020-02-09 09:47:11 +01:00
Linus Groh
f4262efaa4 Change distance travelled label to title
See https://github.com/owntracks/frontend/issues/22#issuecomment-583698919
2020-02-08 11:50:48 +00:00
Linus Groh
a2109a5802 Fix humanReadableDistance JSDoc
That's what you get from copy & paste...
2020-02-07 21:51:39 +00:00
Linus Groh
00fbb7cd7c Release 2.0.0-beta.10 2020-02-07 21:42:03 +00:00
Linus Groh
4078597f7a Add "distance travelled" feature 2020-02-07 21:20:38 +00:00
Linus Groh
8dc9611a77 Release 2.0.0-beta.9 2020-02-06 21:30:41 +00:00
Linus Groh
c1f58c992e Support locale with language and region part 2020-02-06 21:26:21 +00:00
Linus Groh
6631929d6f Update screenshot 2020-02-06 21:13:11 +00:00
Linus Groh
36281db2e3 Add .github/FUNDING.yml 2020-02-06 20:35:08 +00:00
Linus Groh
5a8d261943 Fix typo in docs/config.md 2020-02-06 20:23:08 +00:00
Linus Groh
7b83349dc8 Improve CHANGELOG.md 2020-02-06 20:22:48 +00:00
Linus Groh
bc3670df99 Release 2.0.0-beta.8 2020-01-26 00:57:08 +00:00
Linus Groh
95613753a9 Enable ESLint max-len rule 2020-01-26 00:49:15 +00:00
Linus Groh
cfa3052a0a Show name and face on location history popups 2020-01-26 00:40:30 +00:00
Linus Groh
0bd84f4de5 s/@return/@returns 2020-01-26 00:38:36 +00:00
Linus Groh
85e51643bf Add missing alt/title to device face image 2020-01-25 23:37:41 +00:00
Linus Groh
6cbdf30580 Use computed prop for device name in location popup 2020-01-25 23:33:10 +00:00
Linus Groh
988b10de40 Release 2.0.0-beta.7 2020-01-24 21:20:22 +00:00
Linus Groh
a20fbf7e10 Remove all @typedef definitions 2020-01-24 21:02:24 +00:00
Linus Groh
d2eafd5a4a Merge branch 'master' into clean-up-the-typing-mess 2020-01-24 20:43:57 +00:00
Linus Groh
639e96cae8 Upgrade dependencies 2020-01-24 20:43:21 +00:00
Linus Groh
df3dcb60d8 Update year in LICENSE 2020-01-05 11:52:40 +00:00
Linus Groh
09ce3b7861 Lint code on Travis CI 2019-12-16 21:20:31 +00:00
Linus Groh
04f0b65480 Separate linting and formatting 2019-12-16 21:16:33 +00:00
Linus Groh
d3e3b82a13 Lint/format markdown files 2019-12-16 21:06:01 +00:00
Linus Groh
769185ee5a Set no-console/no-debugger to "warn" in dev mode 2019-12-16 20:24:15 +00:00
Linus Groh
7b6641e70d Add jsconfig.json 2019-12-16 19:35:17 +00:00
Linus Groh
871f9f0cb2 Add index.d.ts 2019-12-15 20:59:57 +00:00
Linus Groh
2827d85865 Release 2.0.0-beta.6 2019-12-14 19:39:56 +00:00
Linus Groh
693947c064 Fix heatmap 2019-12-14 19:35:24 +00:00
Linus Groh
a31c048060 Release 2.0.0-beta.5 2019-12-14 17:13:13 +00:00
Linus Groh
1f2be0aeb9 Upgrade dependencies 2019-12-14 17:10:22 +00:00
Linus Groh
92401eb6b1 Add $config Vue instance property 2019-12-14 16:59:12 +00:00
Linus Groh
8d2f22d3de Use configured locale for timestamp formatting 2019-12-14 16:48:18 +00:00
Linus Groh
1d106e45da Add Leaflet popup close button bg color transition 2019-12-14 16:33:17 +00:00
Linus Groh
1f07ae9266 Improve accessibility
* More title attributes
* Usage of aria-hidden and role attributes
* Focus style improvements
* Text contrast improvements
2019-12-14 16:32:10 +00:00
Linus Groh
8399476195 Release 2.0.0-beta.4 2019-12-14 09:43:07 -05:00
Linus Groh
7767a06875 Add support for time selection 2019-12-14 09:35:46 -05:00
Linus Groh
5bcb7a63bc Add missing translation of "to" 2019-12-14 13:37:56 +00:00
Linus Groh
f0b3ed2632 Add example commit for new locale 2019-12-13 20:33:54 +00:00
Linus Groh
185d6fd842 Clarify i18n development notes 2019-12-13 20:29:10 +00:00
Linus Groh
fac0479b25 Release 2.0.0-beta.3 2019-12-13 19:53:31 +00:00
Linus Groh
b2edda410f Add German translations 2019-12-13 19:45:53 +00:00
Linus Groh
73465268e2 Add i18 support 2019-12-13 19:45:21 +00:00
Linus Groh
4e449235b2 Add custom checkbox focus style 2019-12-13 18:41:27 +00:00
Linus Groh
1a7f969b59 Fix checkbox style issues 2019-12-13 18:41:02 +00:00
Linus Groh
e7e6ea7dda Fix hover/focus inconsistencies 2019-12-13 18:40:14 +00:00
Linus Groh
9f522dd727 Reimplement layer settings dropdown 2019-12-13 18:39:33 +00:00
Linus Groh
76e8a56cc7 Mention LISTEN_PORT environment variable in docs 2019-12-13 18:37:33 +00:00
Linus Groh
012eb74837 Update README.md 2019-12-11 22:25:59 +00:00
Linus Groh
207a63c0d8 Upgrade dependencies 2019-12-11 22:12:45 +00:00
Linus Groh
bbc381e70c Update README.md 2019-11-07 19:23:53 +00:00
Linus Groh
de45906860 Add drop shadow to window screenshot 2019-11-06 22:36:59 +00:00
Linus Groh
1734ef7c74 Update screenshots 2019-11-06 22:21:32 +00:00
Linus Groh
c4d368eee9 Fix Docker image labels
The LABEL instructions in docker/Dockerfile were not applied to the
final image, it only had the ones inherited from the nginx image.
2019-11-02 19:18:56 +00:00
Linus Groh
f0ff18c792 Add Travis CI build status badge 2019-11-02 18:23:49 +00:00
82 changed files with 8046 additions and 11988 deletions

View File

@@ -1,30 +0,0 @@
module.exports = {
root: true,
env: {
node: true,
},
extends: ["plugin:vue/essential", "@vue/prettier"],
rules: {
"no-console": process.env.NODE_ENV === "production" ? "error" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "error" : "off",
"prettier/prettier": [
"error",
{
trailingComma: "es5",
printWidth: 80,
htmlWhitespaceSensitivity: "ignore",
},
],
},
parserOptions: {
parser: "babel-eslint",
},
overrides: [
{
files: ["**/__tests__/*.{j,t}s?(x)"],
env: {
jest: true,
},
},
],
};

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
liberapay: owntracks.org

14
.github/workflows/build.yml vendored Normal file
View 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
View 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
View 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
View 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
View 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 }}

View File

@@ -1,6 +0,0 @@
language: node_js
node_js:
- 10
cache: yarn
script:
- yarn test

View File

@@ -1,4 +1,218 @@
# 2.0.0-beta.1 (2019-11-02)
# Changelog
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))
- Improve verbose mode logging
- Improve mobile usability ([#19](https://github.com/owntracks/frontend/issues/19))
- Upgrade dependencies
## 2.0.0 (2020-03-01)
Stable release of v2, finally! 🎉
_This is just a version bump, see all the beta releases below, especially the first one, for a list of changes._
## 2.0.0-beta.11 (2020-03-01)
- Add Spanish translations ([#25](https://github.com/owntracks/frontend/pull/25), [@dtorner](https://github.com/dtorner))
- Change "distance travelled" label to `title`
- Replace map initial center/zoom config with auto fitting ([#23](https://github.com/owntracks/frontend/issues/23))
- Enhance code type definitions using TypeScript features ([#20](https://github.com/owntracks/frontend/pull/20))
- Upgrade dependencies
## 2.0.0-beta.10 (2020-02-07)
- Add "distance travelled" feature
## 2.0.0-beta.9 (2020-02-06)
- Support locale with language and region part (`en-GB`)
- Update docs (screenshot, changelog improvements, typo fix)
- Add funding information
## 2.0.0-beta.8 (2020-01-26)
- Add friendly device name and face images to location history popups
- Add missing `alt`/`title` to device face image
- Fix all JSDoc `@return` directives to `@returns`
- Use computed prop for device name in location popup
- Enable ESLint `max-len` rule
## 2.0.0-beta.7 (2020-01-24)
This release doesn't really affect end-users but greatly improves the development experience.
- Add `jsconfig.json`
- Set `no-console`/`no-debugger` to `"warn"` in dev mode
- Linting and formatting:
- Separate npm scripts for linting and formatting
- Lint/format Markdown files
- Run lint on Travis CI
- Upgrade dependencies
## 2.0.0-beta.6 (2019-12-14)
- Fix heatmap - the upgrade of `vue2-leaflet` from 2.2.1 to 2.3.0 added an `activated` attribute to layers causing the heatmap to not show ([#18](https://github.com/owntracks/frontend/issues/18))
## 2.0.0-beta.5 (2019-12-14)
- Add Leaflet popup close button background color transition
- Add `$config` Vue instance property
- Improve accessibility ([#9](https://github.com/owntracks/frontend/issues/9))
- Use configured locale for timestamp formatting
- Upgrade dependencies
## 2.0.0-beta.4 (2019-12-14)
- Add support for time selection ([#10](https://github.com/owntracks/frontend/issues/10))
- New date/time picker component is properly translated/localised and keyboard accessible
- Config options are now `startDateTime`/`endDateTime` and format of URL parameters changed
- Changed default start/end date and time to use local timezone
- Fix missing translation of "[date] to [date]"
- Update i18n development notes in `README.md`
## 2.0.0-beta.3 (2019-12-13)
- Add i18 support (currently English and German, `locale` config option)
- Add custom checkbox focus style
- Fix layer dropdown issues ([#1](https://github.com/owntracks/frontend/issues/1))
- Fix checkbox style issues
- Fix hover/focus inconsistencies
- Fix Docker image labels
- `README.md` enhancements
- Upgrade dependencies
## 2.0.0-beta.2 (2019-11-02)
- Add `onLocationChange.reloadHistory` config option
- Add Travis CI config
@@ -7,7 +221,7 @@
- Fix table of content links in config documentation
- Upgrade dependencies
# 2.0.0-beta.1 (2019-10-26)
## 2.0.0-beta.1 (2019-10-26)
- Convert codebase to Node.js based development workflow, including:
- Package management using yarn
@@ -23,22 +237,22 @@
- Custom checkbox styles
- Switch from Font Awesome 4 to Feather Icons
- Application now uses Vuex and Vue Router
- Add URL query parameters to load and preserve application state: `lat`, `lng`, `zoom`, `start`, `end`, `user`, `device` and `layers`.
- Add a loading indicator.
- Add 'download data' modal, currently supporting formatted and minified JSON.
- Add a verbose mode.
- Add CORS proxy script toeasily use a production instance of the OwnTracks recorder in development.
- Add unit tests for util and API functions.
- Add URL query parameters to load and preserve application state: `lat`, `lng`, `zoom`, `start`, `end`, `user`, `device` and `layers`
- Add a loading indicator
- Add 'download data' modal, currently supporting formatted and minified JSON
- Add a verbose mode
- Add CORS proxy script to easily use a production instance of the OwnTracks recorder in development
- Add unit tests for util and API functions
- Add documentation for all public funtions
- Add documentation for all configuration options.
- Add more configuration options, including setting the API base URL ([#4](https://github.com/owntracks/frontend/issues/4)) and hiding the `ping/ping` location ([#12](https://github.com/owntracks/frontend/issues/12)).
- Add documentation for all configuration options
- Add more configuration options, including setting the API base URL ([#4](https://github.com/owntracks/frontend/issues/4)) and hiding the `ping/ping` location ([#12](https://github.com/owntracks/frontend/issues/12))
# 1.1.0 (2019-10-26)
## 1.1.0 (2019-10-26)
- Add support for Docker. [#7](https://github.com/owntracks/frontend/pull/7), [@sharkoz](https://github.com/sharkoz)
- Move project to the OwnTracks organisation on GitHub. [#8](https://github.com/owntracks/frontend/pull/8), [@jpmens](https://github.com/jpmens)
- Enable compression in nginx configuration used in Docker image. [#11](https://github.com/owntracks/frontend/pull/11), [@sharkoz](https://github.com/sharkoz)
- Add support for Docker ([#7](https://github.com/owntracks/frontend/pull/7), [@sharkoz](https://github.com/sharkoz))
- Move project to the OwnTracks organisation on GitHub ([#8](https://github.com/owntracks/frontend/pull/8), [@jpmens](https://github.com/jpmens))
- Enable compression in nginx configuration used in Docker image ([#11](https://github.com/owntracks/frontend/pull/11), [@sharkoz](https://github.com/sharkoz))
# 1.0.0 (2019-06-18)
## 1.0.0 (2019-06-18)
- Initial release

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2019 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

122
README.md
View File

@@ -1,14 +1,14 @@
# OwnTracks UI
> Web interface for OwnTracks
# OwnTracks Frontend
![Version](https://img.shields.io/github/package-json/v/owntracks/frontend)
[![Docker Pulls](https://img.shields.io/docker/pulls/owntracks/frontend)](https://hub.docker.com/r/owntracks/frontend)
[![License](https://img.shields.io/github/license/owntracks/frontend?color=d63e97)](https://github.com/owntracks/frontend/blob/master/LICENSE)
[![Build](https://github.com/owntracks/frontend/workflows/Build/badge.svg)](https://github.com/owntracks/frontend/actions?query=workflow%3ABuild+branch%3Amain)
[![Tests](https://github.com/owntracks/frontend/workflows/Tests/badge.svg)](https://github.com/owntracks/frontend/actions?query=workflow%3ATests+branch%3Amain)
[![Lint](https://github.com/owntracks/frontend/workflows/Lint/badge.svg)](https://github.com/owntracks/frontend/actions?query=workflow%3ALint+branch%3Amain)
[![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier)
[![License](https://img.shields.io/github/license/owntracks/frontend?color=d63e97)](https://github.com/owntracks/frontend/blob/main/LICENSE)
<p style="text-align: center;">
<img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/owntracks-ui.png" alt="OwnTracks UI">
</p>
![Screenshot](https://raw.githubusercontent.com/owntracks/frontend/main/docs/images/screenshot.png)
## Introduction
@@ -16,32 +16,47 @@ 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.
![Map features](https://raw.githubusercontent.com/owntracks/frontend/main/docs/images/map-features.png)
## Features
- Last known (i.e. live) locations:
- Accuracy visualization (circle)
- Device friendly name and icon
- 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 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
### Manually
- Run `yarn install --production` to install dependencies
- Run `yarn build` to compile and minify for production
- Copy the content of the `dist/` directory to your webroot
The API is expected to be reachable under the same domain as the web interface.
### Docker
You can launch directly via Docker run like this:
A pre-built Docker image is available on Docker Hub as [`owntracks/frontend`](https://hub.docker.com/r/owntracks/frontend).
You can start a container directly via `docker run`:
```console
$ docker run -d -p 80:80 -e SERVER_HOST=otrecorder-host -e SERVER_PORT=otrecorder-port owntracks/frontend
$ docker run -d -p 80:80 -e SERVER_HOST=otrecorder-host -e SERVER_PORT=8083 owntracks/frontend
```
Or you can use `docker-compose` (if you also run the OwnTracks Recorder with the default
compose config, and the service is named `otrecorder`):
```yaml
version: '3'
version: "3"
services:
owntracks-ui:
owntracks-frontend:
image: owntracks/frontend
ports:
- 80:80
@@ -53,6 +68,26 @@ services:
restart: unless-stopped
```
To change the port on which the nginx server will listen on, set the
`LISTEN_PORT` enviroment variable - default is 80.
To build the image from source replace `image:` with:
```yaml
build:
context: ./owntracks-frontend
dockerfile: docker/Dockerfile
```
(assuming you have this repository cloned to `owntracks-frontend` in the same
directory as `docker-compose.yml`)
### Manually
- 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
It's possible to get started without any configuration change whatsoever, assuming your
@@ -65,17 +100,25 @@ 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` to lint and fix 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
You can use the [`corsProxy.js`](scripts/corsProxy.js) script to use your production
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)
enabled, create a `.env` file with your credentials:
@@ -87,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`
@@ -95,29 +138,20 @@ and `OT_PROXY_PORT` environment variables.
Finally update `api.baseUrl` in your config to `"http://0.0.0.0:8888/https://owntracks.example.com"`.
## Features
### I18n
- 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
- 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
- Highly customisable
This project uses [Vue I18n](https://kazupon.github.io/vue-i18n/). To see missing and
unused i18n entries, run:
## Screenshots
```console
$ npm run i18n:report
```
_Click to enlarge._
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)!
<a href="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/live.png" target="_blank"><img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/live.png" alt="Live" height="200"></a>
<a href="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/multiple.png" target="_blank"><img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/multiple.png" alt="Multiple" height="200"></a>
<a href="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/date-selection.png" target="_blank"><img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/date-selection.png" alt="Date selection" height="200"></a>
<a href="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/heatmap.png" target="_blank"><img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/heatmap.png" alt="Heatmap" height="200"></a>
<a href="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/customized.png" target="_blank"><img src="https://raw.githubusercontent.com/owntracks/frontend/master/docs/images/customized.png" alt="Customized" height="200"></a>
For a specific example see commit [`b2edda4`](https://github.com/owntracks/frontend/commit/b2edda410f16633aa6fd9cd4e5250f2031536c7d)
where German translations were added.
## Contributing

View File

@@ -1,3 +0,0 @@
module.exports = {
presets: ["@vue/cli-plugin-babel/preset"],
};

View File

@@ -1,14 +1,14 @@
FROM node:10 as build
LABEL version="2.0.0-beta.2"
LABEL description="OwnTracks UI"
LABEL maintainer="Linus Groh <mail@linusgroh.de>"
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
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 \
SERVER_PORT=80

View File

@@ -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;
}
}

View File

@@ -24,13 +24,14 @@ window.owntracks.config = {};
- `api`
- [`baseUrl`](#apibaseurl)
- [`endDate`](#enddate)
- [`fetchOptions`](#apifetchoptions)
- [`endDateTime`](#enddatetime)
- `filters`
- [`minAccuracy`](#filtersminaccuracy)
- [`ignorePingLocation`](#ignorepinglocation)
- [`locale`](#locale)
- `map`
- [`attribution`](#mapattribution)
- `center`
- [`lat`](#mapcenterlat)
- [`lng`](#mapcenterlng)
- [`circle`](#mapcircle)
- [`circleMarker`](#mapcirclemarker)
- `controls`
@@ -52,19 +53,24 @@ 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)
- [`zoom`](#mapzoom)
- `onLocationChange`
- [`fitView`](#onlocationchangefitview)
- [`reloadHistory`](#onlocationchangereloadhistory)
- [`primaryColor`](#primarycolor)
- `router`
- [`basePath`](#routerbasepath)
- [`selectedDevice`](#selecteddevice)
- [`selectedUser`](#selecteduser)
- [`startDate`](#startdate)
- [`showDistanceTravelled`](#showdistancetravelled)
- [`startDateTime`](#startdatetime)
- [`verbose`](#verbose)
### `api.baseUrl`
@@ -78,30 +84,70 @@ 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/",
},
};
```
### `endDate`
### `api.fetchOptions`
Initial end date for fetched data.
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",
},
},
};
```
### `endDateTime`
Initial end date and time (browser timezone) for fetched data.
- Type: [`Date`]
- Default: today
- Default: today, 23:59:59
- Example:
```js
// Data will be fetched up to 1970-01-01
window.owntracks.config = {
endDate: 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,
},
};
```
@@ -116,10 +162,33 @@ 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,
};
```
### `locale`
The locale to use for the user interface, this affects the language and date/time
formats.
Available languages:
- `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)
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-US"`
### `map.attribution`
Attribution for map tiles.
@@ -131,25 +200,11 @@ Attribution for map tiles.
// Make sure to add proper attribution!
window.owntracks.config = {
map: {
attribution: "Map tiles &copy; MyTileServerProvider"
}
attribution: "Map tiles &copy; MyTileServerProvider",
},
};
```
### `map.center.lat`
Initial map center latitude.
- Type: [`Number`]
- Default: `0`
### `map.center.lng`
Initial map center longitude.
- Type: [`Number`]
- Default: `0`
### `map.circle`
Location accuracy indicator configuation. `color` and `fillColor` default to
@@ -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,17 +440,27 @@ 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.zoom`
### `map.zoomOffset`
Initial map zoom level.
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: `19`
- 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`
@@ -384,14 +480,30 @@ 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",
},
};
```
### `selectedDevice`
Initial selected device. All devices will be shown by default if `null`. Will be ignored
if [`selectedUser`](#selectedUser) is `null`;
if [`selectedUser`](#selectedUser) is `null`.
Only data for the selected user/device will be fetched, so you can use this to limit the
amount of data fetched after page load.
@@ -403,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",
};
```
@@ -420,24 +532,36 @@ amount of data fetched after page load.
```js
// Select all devices from user 'foo' by default
window.owntracks.config = {
selectedUser: "foo"
selectedUser: "foo",
};
```
### `startDate`
### `showDistanceTravelled`
Initial start date for fetched data.
Whether to calculate and show the travelled distance of the last fetched data in the
header bar. `maxPointDistance` is being takein into account, if a distance between two
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`
### `startDateTime`
Initial start date and time (browser timezone) for fetched data.
- Type: [`Date`]
- Default: one month ago
- Default: one month ago, 00:00:00
- Example:
```js
// Data will be fetched from the first day of the current month
const startDate = new Date();
startDate.setUTCHours(0, 0, 0, 0);
startDate.setUTCDate(1);
const startDateTime = new Date();
startDateTime.setHours(0, 0, 0, 0);
startDateTime.setDate(1);
window.owntracks.config = {
startDate
startDateTime,
};
```
@@ -448,9 +572,10 @@ Whether to enable verbose mode or not.
- Type: [`Boolean`]
- Default: `false`
[`Boolean`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Boolean
[`Date`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date
[`Number`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number
[`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
[`boolean`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Boolean
[`date`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date
[`number`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number
[`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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 752 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 540 KiB

BIN
docs/images/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 653 KiB

47
eslint.config.js Normal file
View 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"],
},
],
},
},
];

View File

@@ -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>

View File

@@ -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"],
};

9
jsconfig.json Normal file
View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules"]
}

4911
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,54 +1,62 @@
{
"name": "owntracks-ui",
"version": "2.0.0-beta.2",
"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",
"lint": "vue-cli-service lint",
"cors-proxy": "node scripts/corsProxy.js",
"test": "vue-cli-service test:unit"
},
"dependencies": {
"clipboard-copy": "^3.1.0",
"core-js": "^3.3.6",
"deepmerge": "^4.2.2",
"leaflet": "^1.5.1",
"leaflet.heat": "^0.2.0",
"vue": "^2.6.6",
"vue-feather-icons": "^5.0.0",
"vue-js-modal": "^1.3.31",
"vue-router": "^3.1.3",
"vue2-leaflet": "^2.2.1",
"vuejs-datepicker": "^1.6.2",
"vuex": "^3.1.1"
},
"devDependencies": {
"@vue/cli-plugin-babel": "^4.0.5",
"@vue/cli-plugin-eslint": "^4.0.5",
"@vue/cli-plugin-unit-jest": "^4.0.5",
"@vue/cli-service": "^4.0.5",
"@vue/eslint-config-prettier": "^5.0.0",
"@vue/test-utils": "1.0.0-beta.29",
"babel-core": "7.0.0-bridge.0",
"babel-eslint": "^10.0.3",
"babel-jest": "^24.9.0",
"cors-anywhere": "^0.4.1",
"eslint": "^6.6.0",
"eslint-plugin-prettier": "^3.1.1",
"eslint-plugin-vue": "^5.2.3",
"jest-fetch-mock": "^2.1.2",
"lint-staged": "^9.4.2",
"node-sass": "^4.13.0",
"sass-loader": "^8.0.0",
"vue-template-compiler": "^2.5.21"
},
"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"
}
}

View File

@@ -1,5 +0,0 @@
module.exports = {
plugins: {
autoprefixer: {},
},
};

189
public/OwnTracks.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

20
public/manifest.json Normal file
View 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"
}

View File

@@ -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}`);
});

View File

@@ -4,44 +4,37 @@
<main>
<router-view />
</main>
<DownloadModal />
<InformationModal />
<LoadingModal />
</div>
</template>
<style lang="scss">
@import "styles/main";
</style>
<script>
import { mapActions } from "vuex";
import config from "@/config";
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",
config.primaryColor
this.$config.primaryColor
);
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,
types.SET_SELECTED_DEVICE,
types.SET_START_DATE,
types.SET_END_DATE,
types.SET_START_DATE_TIME,
types.SET_END_DATE_TIME,
types.SET_MAP_CENTER,
types.SET_MAP_ZOOM,
types.SET_MAP_LAYER_VISIBILITY,
@@ -68,20 +61,20 @@ export default {
updateUrlQuery() {
const {
map,
startDate: start,
endDate: end,
startDateTime: start,
endDateTime: end,
selectedUser: user,
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,
lng: map.center.lng,
zoom: map.zoom,
start: start.toISOString().split("T")[0],
end: end.toISOString().split("T")[0],
start,
end,
...(user !== null && { user }),
...(user !== null && device !== null && { device }),
...(activeLayers.length > 0 && { layers: activeLayers.join(",") }),
@@ -96,3 +89,7 @@ export default {
},
};
</script>
<style lang="scss">
@import "styles/main";
</style>

View File

@@ -1,72 +1,84 @@
import config from "@/config";
import { log, logLevels } from "@/logging";
import { getApiUrl } from "@/util";
/** @typedef {import("./types").QueryParams} QueryParams */
/** @typedef {import("./types").User} User */
/** @typedef {import("./types").Device} Device */
/** @typedef {import("./types").LastLocation} LastLocation */
/** @typedef {import("./types").LocationHistory} LocationHistory */
/**
* Callback for new WebSocket location messages.
*
* @callback webSocketLocationCallback
*/
import { getApiUrl, getLocationHistoryCount } from "@/util";
/**
* Fetch an API resource.
*
* @param {String} path API resource path
* @param {QueryParams} [params] Query parameters
* @return {Promise} Promise returned by the fetch function
* @param {Object} [params] Query parameters
* @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).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.
*
* @return {String} Version
* @returns {Promise<String>} Version
*/
export const getVersion = async () => {
const response = await fetchApi("/api/0/version");
const json = await response.json();
const version = json.version;
log("API", () => `[getVersion] ${version}`);
return version;
};
/**
* Get all users.
*
* @return {Array.<User>} Array of usernames
* @returns {Promise<User[]>} Array of usernames
*/
export const getUsers = async () => {
const response = await fetchApi("/api/0/list");
const json = await response.json();
const users = json.results;
log("API", () => `[getUsers] Fetched ${users.length} users`);
return users;
};
/**
* Get all devices for the provided users.
*
* @param {Array.<User>} users Array of usernames
* @return {Object.<User, Array.<Device>>} Object mapping each username to an array of device names
* @param {User[]} users Array of usernames
* @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;
devices[user] = userDevices;
})
);
log("API", () => {
const devicesCount = Object.keys(devices)
.map((user) => devices[user].length)
.reduce((a, b) => a + b, 0);
return (
`[getDevices] Fetched ${devicesCount} ` +
`devices for ${users.length} users`
);
});
return devices;
};
@@ -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
* @return {Array.<LastLocation>} Array of last location objects
* @returns {Promise<OTLocation[]>} Array of last location objects
*/
export const getLastLocations = async (user, device) => {
const params = {};
@@ -87,7 +99,12 @@ export const getLastLocations = async (user, device) => {
}
const response = await fetchApi("/api/0/last", params);
const json = await response.json();
return json;
const lastLocations = json;
log(
"API",
() => `[getLastLocations] Fetched ${lastLocations.length} last locations`
);
return lastLocations;
};
/**
@@ -95,63 +112,90 @@ export const getLastLocations = async (user, device) => {
*
* @param {User} user Username
* @param {Device} device Device name
* @param {Date} start Start date
* @param {Date} end End date
* @return {LocationHistory} Array of location history objects
* @param {String} start Start date and time in UTC
* @param {String} end End date and time in UTC
* @param {Object} [fetchOptions] fetch() options
* @returns {Promise<OTLocation[]>} Array of location history objects
*/
export const getUserDeviceLocationHistory = async (
user,
device,
start,
end
end,
fetchOptions
) => {
const startDate = start.toISOString().split("T")[0];
const endDate = end.toISOString().split("T")[0];
const response = await fetchApi("/api/0/locations", {
from: `${startDate}T00:00:00`,
to: `${endDate}T23:59:59`,
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();
return 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",
() =>
`[getUserDeviceLocationHistory] Fetched ` +
`${userDeviceLocationHistory.length} locations for ` +
`${user}/${device} from ${start} - ${end}`
);
return userDeviceLocationHistory;
};
/**
* Get the location history of multiple devices.
*
* @param {Object.<User, Array.<Device>>} devices Devices of which the history should be fetched
* @param {Date} start Start date
* @param {Date} end End date
* @return {Object.<User, Object.<Device, LocationHistory>>} Array of location history objects
* @param {{User: Device[]}} devices
* 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
* @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
);
})
);
})
);
log("API", () => {
const locationHistoryCount = getLocationHistoryCount(locationHistory);
return (
"[getLocationHistory] Fetched " +
`${locationHistoryCount} locations in total`
);
});
return locationHistory;
};
/**
* Connect to the WebSocket API, reconnect when necessary and handle received messages.
* Connect to the WebSocket API, reconnect when necessary and handle received
* messages.
*
* @param {webSocketLocationCallback} [callback] Callback for location messages
* @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;
@@ -161,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);

View File

@@ -1,145 +1,143 @@
<template>
<header>
<nav>
<header :class="$mq === 'sm' ? 'header-sm' : null">
<div v-if="$mq === 'sm'" class="header-item">
<button
class="button button-flat button-icon"
@click="showMobileNav = !showMobileNav"
>
<MenuIcon size="1x" aria-hidden="true" role="img" />
</button>
</div>
<nav
v-if="$mq === 'sm' ? showMobileNav : true"
class="header-item header-item-grow"
:class="$mq === 'sm' ? 'nav-sm' : null"
>
<div class="nav-item">
<CrosshairIcon
v-if="$mq === 'sm'"
size="1x"
aria-hidden="true"
role="img"
/>
<button
class="button button-outline"
title="Automatically center the map view and zoom in to relevant data"
:title="
$t('Automatically center the map view and zoom in to relevant data')
"
@click="$root.$emit('fitView')"
>
Fit View
{{ $t("Fit view") }}
</button>
</div>
<div class="nav-item">
<LayersIcon size="1x" />
<div class="dropdown">
<button class="dropdown-button button" title="Show/hide layers">
Layer Settings
</button>
<div class="dropdown-body">
<label tabindex="0">
<input
type="checkbox"
:checked="map.layers.last"
@change="
setMapLayerVisibility({
layer: 'last',
visibility: $event.target.checked,
})
"
/>
Show last known locations
</label>
<label tabindex="0">
<input
type="checkbox"
:checked="map.layers.line"
@change="
setMapLayerVisibility({
layer: 'line',
visibility: $event.target.checked,
})
"
/>
Show location history (line)
</label>
<label tabindex="0">
<input
type="checkbox"
:checked="map.layers.points"
@change="
setMapLayerVisibility({
layer: 'points',
visibility: $event.target.checked,
})
"
/>
Show location history (points)
</label>
<label tabindex="0">
<input
type="checkbox"
:checked="map.layers.heatmap"
@change="
setMapLayerVisibility({
layer: 'heatmap',
visibility: $event.target.checked,
})
"
/>
Show location heatmap
</label>
</div>
</div>
<LayersIcon size="1x" aria-hidden="true" role="img" />
<DropdownButton
:label="$t('Layer settings')"
:title="$t('Show/hide layers')"
>
<label v-for="option in layerSettingsOptions" :key="option.layer">
<input
type="checkbox"
:checked="map.layers[option.layer]"
@change="
setMapLayerVisibility({
layer: option.layer,
visibility: $event.target.checked,
})
"
/>
{{ option.label }}
</label>
</DropdownButton>
</div>
<div class="nav-item">
<CalendarIcon size="1x" />
<Datepicker
v-model="startDate"
:use-utc="true"
:disabled-dates="startDateDisabledDates"
title="Select start date"
/>
to
<Datepicker
v-model="endDate"
:use-utc="true"
:disabled-dates="endDateDisabledDates"
title="Select end date"
/>
<CalendarIcon size="1x" aria-hidden="true" role="img" />
<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"
>
<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" />
<UserIcon size="1x" aria-hidden="true" role="img" />
<select
v-model="selectedUser"
class="dropdown-button button"
title="Select user"
:title="$t('Select user')"
>
<option :value="null">
Show All
{{ $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>
</div>
<div v-if="selectedUser" class="nav-item">
<SmartphoneIcon size="1x" />
<SmartphoneIcon size="1x" aria-hidden="true" role="img" />
<select
v-model="selectedDevice"
class="dropdown-button button"
title="Select device"
:title="$t('Select device')"
>
<option :value="null">
Show All
{{ $t("Show all") }}
</option>
<option
v-for="device in devices[selectedUser]"
:value="device"
:key="`${selectedUser}-${device}`"
:value="device"
>
{{ device }}
</option>
</select>
</div>
</nav>
<nav class="nav-shrink">
<div class="nav-item">
<button
class="button button-flat button-icon"
title="Download raw data"
@click="$modal.show('download')"
>
<DownloadIcon size="1x" />
</button>
<nav class="header-item header-item-right">
<div
v-if="$config.showDistanceTravelled && distanceTravelled"
class="nav-item distance-travelled"
>
<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
class="button button-flat button-icon"
title="Information"
:title="$t('Information')"
@click="$modal.show('information')"
>
<InfoIcon size="1x" />
<InfoIcon size="1x" :aria-label="$t('Information')" role="img" />
</button>
</div>
</nav>
@@ -147,32 +145,165 @@
</template>
<script>
import moment from "moment";
import { mapActions, mapGetters, mapMutations, mapState } from "vuex";
import {
ArrowDownIcon,
ArrowUpIcon,
CalendarIcon,
DownloadIcon,
CrosshairIcon,
InfoIcon,
LayersIcon,
MenuIcon,
SmartphoneIcon,
UserIcon,
} from "vue-feather-icons";
import Datepicker from "vuejs-datepicker";
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,
DownloadIcon,
CrosshairIcon,
DatePicker,
InfoIcon,
LayersIcon,
MenuIcon,
SmartphoneIcon,
UserIcon,
Datepicker,
DropdownButton,
},
data() {
return {
DATE_TIME_FORMAT,
layerSettingsOptions: [
{ layer: "last", label: this.$t("Show last known locations") },
{ 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"]),
...mapGetters(["startDateDisabledDates", "endDateDisabledDates"]),
...mapState([
"users",
"devices",
"map",
"distanceTravelled",
"elevationGain",
"elevationLoss",
]),
selectedUser: {
get() {
return this.$store.state.selectedUser;
@@ -189,20 +320,26 @@ export default {
this.setSelectedDevice(value);
},
},
startDate: {
dateTimeRange: {
get() {
return this.$store.state.startDate;
const startDateTime = moment
.utc(this.$store.state.startDateTime, DATE_TIME_FORMAT)
.local()
.toDate();
const endDateTime = moment
.utc(this.$store.state.endDateTime, DATE_TIME_FORMAT)
.local()
.toDate();
return [startDateTime, endDateTime];
},
set(value) {
this.setStartDate(value);
},
},
endDate: {
get() {
return this.$store.state.endDate;
},
set(value) {
this.setEndDate(value);
set([startDateTime, endDateTime]) {
this.setStartDateTime(
moment(startDateTime).utc().format(DATE_TIME_FORMAT)
);
this.setEndDateTime(
moment(endDateTime).set("seconds", 59).utc().format(DATE_TIME_FORMAT)
);
},
},
},
@@ -213,9 +350,29 @@ export default {
...mapActions([
"setSelectedUser",
"setSelectedDevice",
"setStartDate",
"setEndDate",
"setStartDateTime",
"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>

View File

@@ -0,0 +1,38 @@
<template>
<div v-focus-outside="hide" v-click-outside="hide" class="dropdown">
<button class="dropdown-button button" :title="title" @click="toggle">
{{ label }}
</button>
<div v-if="active" class="dropdown-body">
<slot />
</div>
</div>
</template>
<script>
export default {
props: {
label: {
type: String,
required: true,
},
title: {
type: String,
default: "",
},
},
data() {
return {
active: false,
};
},
methods: {
toggle() {
this.active = !this.active;
},
hide() {
this.active = false;
},
},
};
</script>

View File

@@ -0,0 +1,19 @@
/* eslint-disable max-len */
const svg = `
<svg
xmlns="http://www.w3.org/2000/svg"
width="691.429"
height="1007.429"
viewBox="0 0 182.94 266.549"
>
<path
d="M182.94 91.47c0 50.518-55.748 139.357-91.47 175.079C55.75 230.827 0 141.988 0 91.47 0 40.953 40.953 0 91.47 0c50.518 0 91.47 40.953 91.47 91.47z"
/>
</svg>
`;
/* eslint-enable */
export default L.divIcon({
className: "",
html: `<span class="pin">${svg}</span>`,
});

View File

@@ -1,65 +1,66 @@
<template>
<LPopup>
<div v-if="name" class="device">{{ name }}</div>
<div v-else class="device">{{ user }}/{{ device }}</div>
<LPopup :options="options">
<div class="device">{{ deviceName }}</div>
<div class="wrapper">
<img v-if="face" :src="faceImageDataURI" />
<img
v-if="face"
:src="faceImageDataURI"
:alt="$t('Image of {deviceName}', { deviceName })"
:title="$t('Image of {deviceName}', { deviceName })"
class="face"
/>
<ul class="info-list">
<li>
<ClockIcon size="1x" />
{{ new Date(timestamp * 1000).toLocaleString() }}
<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>
<MapPinIcon size="1x" />
<li :title="$t('Location')">
<MapPinIcon size="1x" aria-hidden="true" role="img" />
{{ lat }}
<br />
{{ lon }}
<br />
{{ alt }}m
</li>
<li v-if="address">
<HomeIcon size="1x" />
<li v-if="address" :title="$t('Address')">
<HomeIcon size="1x" aria-hidden="true" role="img" />
{{ address }}
</li>
<li v-if="typeof battery === 'number'">
<BatteryIcon size="1x" />
<li v-if="typeof battery === 'number'" :title="$t('Battery')">
<BatteryIcon size="1x" aria-hidden="true" role="img" />
{{ battery }} %
</li>
<li v-if="typeof speed === 'number'">
<ZapIcon size="1x" />
<li v-if="typeof speed === 'number'" :title="$t('Speed')">
<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";
@@ -71,6 +72,7 @@ export default {
ClockIcon,
HomeIcon,
MapPinIcon,
WifiIcon,
ZapIcon,
LPopup,
},
@@ -95,6 +97,14 @@ export default {
type: Number,
default: 0,
},
isoLocal: {
type: String,
default: "",
},
timeZone: {
type: String,
default: "",
},
lat: {
type: Number,
default: 0,
@@ -119,16 +129,62 @@ export default {
type: Number,
default: null,
},
regions: {
type: Array,
default: () => [],
},
wifi: {
type: Object,
default: () => {},
},
options: {
type: Object,
default: () => {},
},
},
computed: {
/**
* Return the face image as a data URI string which can be used for an image's src attribute
* Return the face image as a data URI string which can be used for an
* image's src attribute.
*
* @return {String} base64-encoded face image data URI
* @returns {String} base64-encoded face image data URI
*/
faceImageDataURI() {
return `data:image/png;base64,${this.face}`;
},
/**
* Return the device name for displaying with <user identifier>/<device
* identifier> as fallback.
*
* @returns {String} device name for displaying
*/
deviceName() {
return this.name ? this.name : `${this.user}/${this.device}`;
},
},
};
</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>

View File

@@ -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>`
@@ -58,6 +52,11 @@ const props = {
custom: true,
default: true,
},
activated: {
type: Boolean,
custom: true,
default: true,
},
};
export default {
@@ -89,7 +88,7 @@ export default {
this.parentContainer.addLayer(this, !this.visible);
this.$watch(
"latLng",
newVal => {
(newVal) => {
this.mapObject.setLatLngs(newVal);
},
{ deep: true }
@@ -131,3 +130,9 @@ export default {
},
};
</script>
<style scoped>
div {
display: none;
}
</style>

View File

@@ -1,102 +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">
Minify JSON
</label>
</div>
<div class="buttons">
<button class="button button-outline button-primary" @click="copy">
Copy to Clipboard
</button>
<button class="button button-primary" @click="download">
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([
"startDate",
"endDate",
"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.startDate.toISOString().split("T")[0];
const end = this.endDate.toISOString().split("T")[0];
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>

View File

@@ -1,55 +0,0 @@
<template>
<modal name="information" adaptive>
<ul class="info-list">
<li>
<GithubIcon size="1x" />
<a href="https://github.com/owntracks/frontend">
owntracks/frontend
</a>
({{ frontendVersion }})
</li>
<li>
<GithubIcon size="1x" />
<a href="https://github.com/owntracks/recorder">
owntracks/recorder
</a>
({{ recorderVersion || "Loading version..." }})
</li>
<li>
<GlobeIcon size="1x" />
<a href="https://owntracks.org">
OwnTracks Website
</a>
</li>
<li>
<BookIcon size="1x" />
<a href="https://owntracks.org/booklet/">
OwnTracks Documentation
</a>
</li>
<li>
<TwitterIcon size="1x" />
<a href="https://twitter.com/OwnTracks">
OwnTracks on Twitter
</a>
</li>
</ul>
</modal>
</template>
<script>
import { mapState } from "vuex";
import {
BookIcon,
GithubIcon,
GlobeIcon,
TwitterIcon,
} from "vue-feather-icons";
export default {
components: { BookIcon, GithubIcon, GlobeIcon, TwitterIcon },
computed: {
...mapState(["frontendVersion", "recorderVersion"]),
},
};
</script>

View File

@@ -0,0 +1,46 @@
<template>
<modal name="information" adaptive>
<ul class="info-list">
<li>
<GithubIcon size="1x" aria-hidden="true" role="img" />
<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 || $t("Loading version...") }})
</li>
<li>
<GlobeIcon size="1x" aria-hidden="true" role="img" />
<a href="https://owntracks.org">
{{ $t("OwnTracks website") }}
</a>
</li>
<li>
<BookIcon size="1x" aria-hidden="true" role="img" />
<a href="https://owntracks.org/booklet/">
{{ $t("OwnTracks documentation") }}
</a>
</li>
<li>
<AtSignIcon size="1x" aria-hidden="true" role="img" />
<a href="https://fosstodon.org/@owntracks">
{{ $t("OwnTracks on Mastodon") }}
</a>
</li>
</ul>
</modal>
</template>
<script>
import { mapState } from "vuex";
import { AtSignIcon, BookIcon, GithubIcon, GlobeIcon } from "vue-feather-icons";
export default {
components: { AtSignIcon, BookIcon, GithubIcon, GlobeIcon },
computed: {
...mapState(["frontendVersion", "recorderVersion"]),
},
};
</script>

View File

@@ -1,32 +0,0 @@
<template>
<modal name="loading" :click-to-close="false" adaptive>
<LoaderIcon class="loader" size="1.5x" />
Loading data, please wait...
</modal>
</template>
<style scoped>
.loader {
animation: spinning 2s linear infinite;
margin-right: 5px;
}
@keyframes spinning {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
<script>
import { LoaderIcon } from "vue-feather-icons";
export default {
components: {
LoaderIcon,
},
};
</script>

View File

@@ -0,0 +1,49 @@
<template>
<modal name="loading" :click-to-close="false" adaptive>
<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-icon {
animation: spinning 2s linear infinite;
margin-right: 5px;
}
.button-cancel {
display: block;
margin: 20px auto 0;
}
@keyframes spinning {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -1,24 +1,26 @@
import deepmerge from "deepmerge";
const endDate = new Date();
endDate.setUTCHours(0, 0, 0, 0);
const endDateTime = new Date();
endDateTime.setHours(23, 59, 59, 0);
const startDate = new Date(endDate);
startDate.setUTCMonth(startDate.getMonth() - 1);
const startDateTime = new Date(endDateTime);
startDateTime.setMonth(startDateTime.getMonth() - 1);
startDateTime.setHours(0, 0, 0, 0);
const DEFAULT_CONFIG = {
api: {
baseUrl: `${window.location.protocol}//${window.location.host}`,
fetchOptions: {},
},
endDateTime,
filters: {
minAccuracy: null,
},
endDate,
ignorePingLocation: true,
locale: "en-US",
map: {
attribution:
'&copy; <a href="https://osm.org/copyright">OpenStreetMap</a> contributors',
center: {
lat: 0,
lng: 0,
},
circle: {
color: null,
fillColor: null,
@@ -53,25 +55,38 @@ 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",
zoom: 19,
zoomOffset: 0,
},
onLocationChange: {
fitView: false,
reloadHistory: false,
},
primaryColor: "#3f51b5",
router: {
basePath: "/",
},
selectedDevice: null,
selectedUser: null,
startDate,
showDistanceTravelled: true,
startDateTime,
verbose: false,
};

View File

@@ -1,8 +1,7 @@
// Regular expression for an ISO 8601 YYYY-MM-DD date.
// Used to validate dates from URL query parameters.
export const ISO_DATE_REGEXP = new RegExp(
/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/
);
// date and time format as expected by the OwnTracks recorder,
// using moment.js formatting tokens.
// https://momentjs.com/docs/#/displaying/format/
export const DATE_TIME_FORMAT = "YYYY-MM-DDTHH:mm:ss";
// https://en.wikipedia.org/wiki/Earth_radius
// Used to calculate the distance between two coordinates.

37
src/i18n.js Normal file
View File

@@ -0,0 +1,37 @@
import Vue from "vue";
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 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,
fallbackLocale: "en-US",
formatFallbackMessages: true,
messages,
});

256
src/index.d.ts vendored Normal file
View File

@@ -0,0 +1,256 @@
/** Configuration object. */
interface Config {
api: {
baseUrl: string;
fetchOptions: object;
};
endDateTime: Date;
filters: {
minAccuracy: number | null,
};
ignorePingLocation: boolean;
locale: string;
map: {
attribution: string;
circle: {
color: OptionalColor;
fillColor: OptionalColor;
fillOpacity: number;
};
circleMarker: {
color: OptionalColor;
fillColor: OptionalColor;
fillOpacity: number;
radius: number;
};
controls: {
scale: {
display: boolean;
imperial: boolean;
maxWidth: number;
metric: boolean;
position: string;
};
zoom: {
display: boolean;
position: string;
};
};
heatmap: {
blur: number;
gradient: { number: Color } | null;
max: number;
radius: number;
};
layers: {
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;
router: {
basePath: string;
};
selectedUser: User | null;
selectedDevice: Device | null;
showDistanceTravelled: boolean;
startDateTime: Date;
verbose: boolean;
}
/** Vuex state. */
interface State {
isLoading: boolean;
frontendVersion: string;
recorderVersion: string;
users: User[];
devices: { User: Device[] };
lastLocations: OTLocation[];
locationHistory: LocationHistory;
selectedUser: User | null;
selectedDevice: Device | null;
startDateTime: string;
endDateTime: string;
map: {
center: {
lat: number;
lng: number;
};
layers: {
heatmap: boolean;
last: boolean;
line: boolean;
poi: boolean;
points: boolean;
};
zoom: number;
};
}
/**
* A location object as returned by the OwnTracks recorder.
* https://owntracks.org/booklet/tech/json/#_typelocation
*/
interface OTLocation {
_http: boolean;
/**
* In this case always "location"
* https://owntracks.org/booklet/tech/json/#types
*/
_type: string;
/** Accuracy in meters */
acc?: number;
/** Altitude above sea level in meters */
alt?: number;
/** Device battery level in percent */
batt?: number;
/**
* Battery status (iOS only)
*
* - `0` = unknown
* - `1` = unplugged
* - `2` = charging
* - `3` = full
*/
bs?: number;
/** Course over ground in degrees (iOS only) */
cog?: number;
/**
* Internet connectivity status (route to host) when the message is created
*
* - `"w"` = phone is connected to a WiFi connection
* - `"o"` = phone is offline
* - `"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 */
disptst: string;
/** Base64-encoded face image (device icon) */
face?: string;
/**
* Geohash of the location
* https://en.wikipedia.org/wiki/Geohash
*/
ghash?: string;
/** 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
* https://github.com/owntracks/recorder/blob/df009f791a845012e9cce24923e6203a079ca1ed/storage.c#L704
*/
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
*
* - `"p"` = ping issued randomly by background task
* - `"c"` = circular region enter/leave event
* - `"b"` = beacon region enter/leave event (iOS only)
* - `"r"` = response to a reportLocation cmd message
* - `"u"` = manual publish requested by the user
* - `"t"` = timer based publish in move move (iOS only)
* - `"v"` = updated by Settings/Privacy/Locations Services/System Services/Frequent Locations monitoring (iOS only)
*/
t?: string;
/** Tracker ID used to display the initials of a user */
tid?: string;
/**
* Original publish topic
* https://owntracks.org/booklet/tech/json/#topics
*/
topic?: string;
/** UNIX epoch timestamp of the location fix in seconds */
tst: number;
/** User */
username?: User;
/** Vertical accuracy of the alt element in meters */
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). */
interface QueryParams {
/** Map center latitude */
lat?: string;
/** Map center longitude */
lng?: string;
/** Start date and time of selected time range */
start?: string;
/** End date and time of selected time range */
end?: string;
/** Selected user */
user?: string;
/** Selected device */
device?: string;
/** Comma-separated list of active layers */
layers?: string;
}
/** Callback for new WebSocket location messages. */
interface WebSocketLocationCallback { (): void }
/** Function for lazy evaluation of log messages. */
interface LogMessageFunction { (): string }
/** A CSS color. */
type Color = string;
/** A CSS color that will use `primaryColor` as fallback. */
type OptionalColor = Color | null;
/** A user's name. */
type User = string;
/** A device's name. */
type Device = string;
/** Multiple location histories mapped to user and devices. */
type LocationHistory = { User: { Device: OTLocation[] } };

45
src/locales/cs-CZ.json Normal file
View 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
View 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"
}

45
src/locales/de-DE.json Normal file
View File

@@ -0,0 +1,45 @@
{
"Automatically center the map view and zoom in to relevant data": "Kartenansicht automatisch zentrieren und zu relevanten Daten hereinzoomen",
"Fit view": "Ansicht anpassen",
"Layer settings": "Ebeneneinstellungen",
"Show/hide layers": "Ebenen ein-/ausblenden",
"Now": "Jetzt",
"Select start date": "Startdatum auswählen",
"to": "bis",
"Select end date": "Enddatum auswählen",
"Select user": "Benutzer auswählen",
"Show all": "Alle anzeigen",
"Select device": "Gerät auswählen",
"Distance travelled": "Gereiste Entfernung",
"Elevation gain / loss": "Höhengewinn / -verlust",
"Information": "Information",
"Show last known locations": "Zeige letzte bekannte Standorte",
"Show location history (line)": "Zeige Standortverlauf (Linie)",
"Show location history (points)": "Zeige Standortverlauf (Punkte)",
"Show location heatmap": "Zeige Standort-Heatmap",
"Minify JSON": "JSON minimieren",
"Copy to clipboard": "In die Zwischenablage kopieren",
"Loading version...": "Version wird abgerufen...",
"OwnTracks website": "OwnTracks Webseite",
"OwnTracks documentation": "OwnTracks Dokumentation",
"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",
"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
View 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"
}

48
src/locales/en-US.json Normal file
View File

@@ -0,0 +1,48 @@
{
"Automatically center the map view and zoom in to relevant data": "Automatically center 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 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",
"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"
}

45
src/locales/es-ES.json Normal file
View File

@@ -0,0 +1,45 @@
{
"Automatically center the map view and zoom in to relevant data": "Centrar automáticamente el zoom y la vista del mapa a los datos",
"Fit view": "Ajustar vista",
"Layer settings": "Configuración de capas",
"Show/hide layers": "Mostrar/ocultar capas",
"Now": "Ahora",
"Select start date": "Seleccionar fecha inicio",
"to": "hasta",
"Select end date": "Seleccionar fecha fin",
"Select user": "Seleccionar usuario",
"Show all": "Mostrar todos",
"Select device": "Seleccionar dispositivo",
"Distance travelled": "Distancia recorrida",
"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)",
"Show location history (points)": "Mostrar historial (puntos)",
"Show location heatmap": "Mostra mapa de calor",
"Minify JSON": "Reducir JSON",
"Copy to clipboard": "Copiar al portapapeles",
"Loading version...": "Cargando versión...",
"OwnTracks website": "OwnTracks - Sitio web",
"OwnTracks documentation": "OwnTracks - documentación",
"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",
"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
View 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
View 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
View 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"
}

View File

@@ -20,6 +20,16 @@ const logColors = {
[logLevels.ERROR]: "#ad1515",
};
/**
* Log a message to the browser's console.
*
* Convenience wrapper for `console.{info,warn,error}` doing some formatting
* and taking the `verbose` config option into account.
*
* @param {String} label Log message label, useful for filtering
* @param {String|LogMessageFunction} message Log message
* @param {String} [level] Log level, use `logLevels` constants
*/
export const log = (label, message, level = logLevels.INFO) => {
if (!Object.keys(logLevels).includes(level)) {
log("WARNING", `invalid log level: ${level}`, logLevels.WARNING);
@@ -35,5 +45,9 @@ export const log = (label, message, level = logLevels.INFO) => {
padding: 3px;
`;
const logFunc = logFunctions[level];
logFunc(`%c${label}`, css, message);
logFunc(
`%c${label}`,
css,
typeof message === "function" ? message() : message
);
};

View File

@@ -1,15 +1,33 @@
import Vue from "vue";
import VueModal from "vue-js-modal";
import VueOutsideEvents from "vue-outside-events";
import VueMq from "vue-mq";
import App from "@/App.vue";
import config from "@/config";
import { log } from "@/logging";
import i18n from "@/i18n";
import router from "@/router";
import store from "@/store";
import VModal from "vue-js-modal";
Vue.use(VModal);
Vue.use(VueModal);
Vue.use(VueOutsideEvents);
Vue.use(VueMq, {
breakpoints: {
sm: 1300,
lg: Infinity,
},
});
Vue.config.productionTip = false;
log("CONFIG", config);
Vue.prototype.$config = config;
new Vue({
i18n,
router,
store,
render: h => h(App),
render: (h) => h(App),
}).$mount("#app");

View File

@@ -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: "/",

View File

@@ -1,11 +1,12 @@
import * as types from "@/store/mutation-types";
import * as api from "@/api";
import config from "@/config";
import { isIsoDate } from "@/util";
/** @typedef {import("./types").QueryParams} QueryParams */
/** @typedef {import("./types").User} User */
/** @typedef {import("./types").Device} Device */
import { log } from "@/logging";
import {
distanceBetweenCoordinates,
isIsoDateTime,
getLocationHistoryCount,
} from "@/util";
/**
* Populate the state from URL query parameters.
@@ -28,11 +29,11 @@ const populateStateFromQuery = ({ state, commit }, query) => {
if (query.zoom && !isNaN(parseInt(query.zoom))) {
commit(types.SET_MAP_ZOOM, parseInt(query.zoom));
}
if (query.start && isIsoDate(query.start)) {
commit(types.SET_START_DATE, new Date(query.start));
if (query.start && isIsoDateTime(query.start)) {
commit(types.SET_START_DATE_TIME, query.start);
}
if (query.end && isIsoDate(query.end)) {
commit(types.SET_END_DATE, new Date(query.end));
if (query.end && isIsoDateTime(query.end)) {
commit(types.SET_END_DATE_TIME, query.end);
}
if (query.user) {
commit(types.SET_SELECTED_USER, query.user);
@@ -42,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 });
@@ -115,12 +116,59 @@ 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 _getTravelStats = (locationHistory) => {
const start = Date.now();
let distanceTravelled = 0;
let elevationGain = 0;
let elevationLoss = 0;
Object.keys(locationHistory).forEach((user) => {
Object.keys(locationHistory[user]).forEach((device) => {
let lastLatLng = null;
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
) {
distanceTravelled += distance;
if (elevationChange >= 0) elevationGain += elevationChange;
else elevationLoss += -elevationChange;
}
}
lastLatLng = latLng;
});
});
});
const end = Date.now();
log("PERFORMANCE", () => {
const locationHistoryCount = getLocationHistoryCount(locationHistory);
const duration = (end - start) / 1000;
return (
`[_getTravelStats] Took ${duration} seconds to calculate distance ` +
`and elevation gain/loss of ${locationHistoryCount} locations`
);
});
return { distanceTravelled, elevationGain, elevationLoss };
};
/**
* Load location history of all devices, in the selected date range.
*/
@@ -136,11 +184,29 @@ const getLocationHistory = async ({ commit, state }) => {
} else {
devices = state.devices;
}
commit(
types.SET_LOCATION_HISTORY,
await api.getLocationHistory(devices, state.startDate, state.endDate)
);
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) {
const { distanceTravelled, elevationGain, elevationLoss } =
_getTravelStats(locationHistory);
commit(types.SET_DISTANCE_TRAVELLED, distanceTravelled);
commit(types.SET_ELEVATION_GAIN, elevationGain);
commit(types.SET_ELEVATION_LOSS, elevationLoss);
}
};
/**
@@ -172,22 +238,22 @@ const setSelectedDevice = async ({ commit, dispatch }, device) => {
};
/**
* Set the start date for loading data and reload the location history.
* Set the start date and time for loading data and reload the location history.
*
* @param {Date} startDate Start date for loading data
* @param {String} startDateTime Start date and time in UTC for loading data
*/
const setStartDate = async ({ commit, dispatch }, startDate) => {
commit(types.SET_START_DATE, startDate);
const setStartDateTime = async ({ commit, dispatch }, startDateTime) => {
commit(types.SET_START_DATE_TIME, startDateTime);
await dispatch("reloadData");
};
/**
* Set the end date for loading data and reload the location history.
* Set the end date and time for loading data and reload the location history.
*
* @param {Date} endDate End date for loading data
* @param {String} endDateTime End date and time in UTC for loading data
*/
const setEndDate = async ({ commit, dispatch }, endDate) => {
commit(types.SET_END_DATE, endDate);
const setEndDateTime = async ({ commit, dispatch }, endDateTime) => {
commit(types.SET_END_DATE_TIME, endDateTime);
await dispatch("reloadData");
};
@@ -203,6 +269,6 @@ export default {
getRecorderVersion,
setSelectedUser,
setSelectedDevice,
setStartDate,
setEndDate,
setStartDateTime,
setEndDateTime,
};

View File

@@ -3,24 +3,47 @@ import L from "leaflet";
import config from "@/config";
import { distanceBetweenCoordinates } from "@/util";
/** @typedef {import("./types").State} State */
/** @typedef {import("./types").MultiLocationHistory} MultiLocationHistory */
/** @typedef {import("./types").DatepickerConfig} DatepickerConfig */
/**
* 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
* @param {MultiLocationHistory} state.locationHistory Location history of selected users and devices
* @return {Array.<L.LatLng>} All coordinates
* @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));
});
});
});
@@ -33,19 +56,23 @@ const locationHistoryLatLngs = state => {
* coordinates does not exceed `config.map.maxPointDistance`.
*
* @param {State} state
* @param {MultiLocationHistory} state.locationHistory Location history of selected users and devices
* @return {Array.<Array.<L.LatLng>>} Groups of coherent coordinates
* @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 (config.map.maxPointDistance !== null && latLngs.length > 0) {
if (
typeof config.map.maxPointDistance === "number" &&
config.map.maxPointDistance > 0 &&
latLngs.length > 0
) {
const lastLatLng = latLngs.slice(-1)[0];
if (
distanceBetweenCoordinates(lastLatLng, latLng) >
@@ -65,37 +92,8 @@ const locationHistoryLatLngGroups = state => {
return groups;
};
/**
* For the start date selector, disable all dates above the end date
* or current date.
*
* @param {State} state
* @param {Date} state.endDate End date
* @return {DatepickerConfig} Configuration for the `disabled-dates` prop of the `vuejs-datepicker` component
*/
const startDateDisabledDates = state => {
return {
customPredictor: date => date > state.endDate || date > new Date(),
};
};
/**
* For the end date selector, disable all dates below the start date
* or above the current date.
*
* @param {State} state
* @param {Date} state.startDate Start date
* @return {DatepickerConfig} Configuration for the `disabled-dates` prop of the `vuejs-datepicker` component
*/
const endDateDisabledDates = state => {
return {
customPredictor: date => date < state.startDate || date > new Date(),
};
};
export default {
locationHistoryLatLngs,
locationHistoryLatLngGroups,
startDateDisabledDates,
endDateDisabledDates,
filteredLocationHistory,
filteredLocationHistoryLatLngs,
filteredLocationHistoryLatLngGroups,
};

View File

@@ -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: {},
@@ -19,13 +19,21 @@ export default new Vuex.Store({
locationHistory: {},
selectedUser: config.selectedUser,
selectedDevice: config.selectedUser !== null ? config.selectedDevice : null,
startDate: config.startDate,
endDate: config.endDate,
// Convert to UTC and get rid of milliseconds
startDateTime: config.startDateTime.toISOString().slice(0, 19),
endDateTime: config.endDateTime.toISOString().slice(0, 19),
map: {
center: config.map.center,
zoom: config.map.zoom,
center: {
lat: 0,
lng: 0,
},
zoom: 19,
layers: config.map.layers,
},
distanceTravelled: 0,
elevationGain: 0,
elevationLoss: 0,
requestAbortController: null,
},
getters,
mutations,

View File

@@ -6,8 +6,12 @@ export const SET_LAST_LOCATIONS = "SET_LAST_LOCATIONS";
export const SET_LOCATION_HISTORY = "SET_LOCATION_HISTORY";
export const SET_SELECTED_USER = "SET_SELECTED_USER";
export const SET_SELECTED_DEVICE = "SET_SELECTED_DEVICE";
export const SET_START_DATE = "SET_START_DATE";
export const SET_END_DATE = "SET_END_DATE";
export const SET_START_DATE_TIME = "SET_START_DATE_TIME";
export const SET_END_DATE_TIME = "SET_END_DATE_TIME";
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";

View File

@@ -25,11 +25,11 @@ export default {
[types.SET_SELECTED_DEVICE](state, selectedDevice) {
state.selectedDevice = selectedDevice;
},
[types.SET_START_DATE](state, startDate) {
state.startDate = startDate;
[types.SET_START_DATE_TIME](state, startDateTime) {
state.startDateTime = startDateTime;
},
[types.SET_END_DATE](state, endDate) {
state.endDate = endDate;
[types.SET_END_DATE_TIME](state, endDateTime) {
state.endDateTime = endDateTime;
},
[types.SET_MAP_CENTER](state, center) {
state.map.center = center;
@@ -40,4 +40,16 @@ export default {
[types.SET_MAP_LAYER_VISIBILITY](state, { layer, visibility }) {
state.map.layers[layer] = visibility;
},
[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;
},
};

View File

@@ -9,11 +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%;
}
@@ -33,9 +36,17 @@ ul {
input[type="checkbox"] {
appearance: none;
border: 0; // Remove the unchecked checkbox outline in Safari on iOS
border-radius: 4px; // Round the focus box-shadow
cursor: pointer;
margin-right: 3px;
position: relative;
vertical-align: bottom;
vertical-align: top;
&:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.2);
}
&::before {
border: 2px solid var(--color-primary);
@@ -75,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 {
@@ -89,38 +101,127 @@ 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;
overflow-x: auto;
color: var(--color-primary-text);
background: var(--color-primary);
nav {
&.header-sm {
padding: 10px;
.header-item:not(.nav-sm) .nav-item:not(:first-child) {
margin-left: 10px;
}
}
.header-item {
display: flex;
flex: 1;
align-items: center;
&:not(:first-child) {
margin-left: 20px;
}
&.nav-shrink {
flex: 0 1 auto;
&-grow {
flex: 1;
}
.nav-item:not(:first-child) {
margin-left: 20px;
&-right {
margin-left: auto !important;
}
.feather {
font-size: 20px;
margin-right: 10px;
}
.button-icon .feather {
.nav-item {
&:not(:first-child) {
margin-left: 20px;
}
> span {
margin: 0 5px;
}
.feather {
margin-right: 10px;
}
.button-icon .feather {
margin: 0;
}
}
&.nav-sm {
background: var(--color-primary);
border-top: 1px solid rgba(0, 0, 0, 0.2);
bottom: 0;
display: block;
left: 0;
margin: 0;
overflow-x: auto;
padding: 20px;
position: absolute;
right: 0;
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-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;
}
// THIS IS TERRIBLE (but it works for now)
> :not(:nth-child(1)):not(:nth-child(2)) {
display: block;
margin-left: 30px;
margin-top: 5px;
}
}
}
}
}
@@ -155,16 +256,6 @@ pre {
}
}
.vdp-datepicker input {
cursor: pointer;
color: var(--color-text);
background: var(--color-background);
border: 0;
border-radius: 18px;
padding: 8px 16px;
min-width: 130px;
}
.button {
cursor: pointer;
color: var(--color-text);
@@ -174,15 +265,21 @@ pre {
overflow: hidden;
padding: 8px 16px;
text-overflow: ellipsis;
transition: box-shadow 0.2s;
white-space: nowrap;
&:focus {
outline: none;
box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.2);
}
&:focus::-moz-focus-inner {
border-color: transparent;
}
&.button-primary {
color: var(--color-primary-text);
background: var(--color-primary);
&:focus::-moz-focus-inner {
border-color: var(--color-primary-text)e;
}
}
&.button-outline {
@@ -207,7 +304,10 @@ pre {
&.button-outline,
&.button-flat {
transition: background 0.2s;
transition:
background-color 0.2s,
box-shadow 0.2s;
&:hover,
&:focus {
background: rgba(0, 0, 0, 0.2);
@@ -221,12 +321,11 @@ pre {
.dropdown {
display: inline-block;
position: relative;
}
.dropdown-button,
.vdp-datepicker input {
-webkit-appearance: none;
-moz-appearance: none;
// Not nested so it works as the button alone
.dropdown-button {
appearance: none;
background-image: var(--dropdown-arrow);
background-repeat: no-repeat;
@@ -236,7 +335,6 @@ pre {
}
.dropdown-body {
display: none;
position: absolute;
margin-top: 12px;
padding: 8px 0;
@@ -246,33 +344,28 @@ pre {
filter: var(--drop-shadow);
z-index: 1;
&::before {
content: "";
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-top: 10px solid transparent;
border-bottom: 10px solid var(--color-background);
position: absolute;
top: -20px;
left: 20px;
}
label {
cursor: pointer;
display: block;
padding: 8px 15px;
&:hover {
background: rgba(0, 0, 0, 0.1);
background: rgba(0, 0, 0, 0.2);
}
}
}
.dropdown-body::before,
.vdp-datepicker .vdp-datepicker__calendar::before {
content: "";
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-top: 10px solid transparent;
border-bottom: 10px solid var(--color-background);
position: absolute;
top: -20px;
left: 20px;
}
.dropdown:focus-within .dropdown-body {
display: block;
}
.feather {
vertical-align: middle;
}

View File

@@ -1,32 +1,45 @@
.vdp-datepicker {
position: static !important;
display: inline-block;
white-space: initial;
overflow: initial;
.mx-datepicker {
width: 280px;
input {
width: 120px;
}
.vdp-datepicker__calendar {
color: var(--color-text);
.mx-input {
border: 0;
border-radius: 3px;
margin-top: 12px;
filter: var(--drop-shadow);
border-radius: 18px;
text-align: center;
height: 33px;
padding-right: 0px;
padding-left: 0px;
}
}
.cell {
&:not(.blank):not(.disabled).day:hover,
&:not(.blank):not(.disabled).month:hover,
&:not(.blank):not(.disabled).year:hover {
border-color: var(--color-primary);
}
@media screen and (max-width: 400px) {
.mx-datepicker-main {
display: flex;
top: 0 !important;
left: 0 !important;
width: 100%;
height: 100%;
&.selected,
&.selected:hover {
background: var(--color-primary);
color: var(--color-primary-text);
}
.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;
}

View File

@@ -4,7 +4,10 @@
.leaflet-popup {
filter: var(--drop-shadow);
margin-bottom: 25px;
&--for-pin {
margin-bottom: calc(var(--pin-width) * 1.5 + 20px);
}
.leaflet-popup-content-wrapper {
border-radius: 3px;
@@ -12,21 +15,29 @@
.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,
&:focus {
background: rgba(0, 0, 0, 0.1);
background: rgba(0, 0, 0, 0.2);
color: inherit;
}
}
@@ -35,4 +46,46 @@
.leaflet-popup-tip {
box-shadow: none;
}
.leaflet-control-container .leaflet-control-attribution {
background: var(--color-background);
a {
color: var(--color-primary);
}
}
.leaflet-marker-icon {
width: 0 !important;
height: 0 !important;
margin: 0 !important;
.pin {
display: block;
margin-left: calc(-1 * var(--pin-width) / 2);
margin-top: calc(-1 * var(--pin-width) * 1.5);
position: relative;
width: var(--pin-width);
&::before {
background: var(--color-background);
border-radius: 100%;
content: "";
position: absolute;
width: calc(var(--pin-width) / 2);
height: calc(var(--pin-width) / 2);
top: calc(var(--pin-width) / 4);
left: calc(var(--pin-width) / 4);
}
svg {
height: auto;
width: 100%;
path {
fill: var(--color-primary);
}
}
}
}
}

View File

@@ -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);
}
}

View File

@@ -1,55 +0,0 @@
/**
* A coordinate with latitude and longitude.
*
* @typedef Coordinate
* @type {(Object|L.LatLng)}
* @property {Number} lat Latitude
* @property {Number} lng Longitude
*/
/**
* Vuex state.
*
* @typedef {Object.<String, *>} State
*/
/**
* URL query parameter object.
*
* @typedef {Object.<String, *>} QueryParams
*/
/**
* @typedef {Object} DatepickerConfig
* @property {Function} DatepickerConfig.customPredictor Custom predictor function
*/
/**
* A user's name.
*
* @typedef {String} User
*/
/**
* A device's name.
*
* @typedef {String} Device
*/
/**
* A last location object.
*
* @typedef {Object.<String, *>} LastLocation
*/
/**
* An array of location history objects
*
* @typedef {Array.<Object.<String, *>>} LocationHistory
*/
/**
* Multiple arrays of location history objects mapped to user and devices.
*
* @typedef {Object.<User, Object.<Device, LocationHistory>>} MultiLocationHistory
*/

View File

@@ -1,38 +1,38 @@
import config from "@/config";
import { ISO_DATE_REGEXP, EARTH_RADIUS_IN_KM } from "@/constants";
import moment from "moment";
/** @typedef {import("./types").Coordinate} Coordinate */
import config from "@/config";
import { DATE_TIME_FORMAT, EARTH_RADIUS_IN_KM } from "@/constants";
/**
* Get a complete URL for any API resource, taking the
* base URL configuration into account.
*
* @param {String} path Path to the API resource
* @return {URL} Final API URL
* @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}`);
};
/**
* Check if the given string is an ISO 8601 YYYY-MM-DD date.
* Check if the given string is an ISO 8601 YYYY-MM-DDTHH:MM:SS datetime.
*
* @param {String} s Input value to be tested
* @return {Boolean} Whether the input is an ISO 8601 date
* @returns {Boolean} Whether the input matches the expected format
*/
export const isIsoDate = s => ISO_DATE_REGEXP.test(s);
export const isIsoDateTime = (s) => moment(s, DATE_TIME_FORMAT, true).isValid();
/**
* Convert degrees to radians.
*
* @param {Number} degrees Angle in degrees
* @return {Number} Angle in radians
* @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,
@@ -42,7 +42,7 @@ export const degreesToRadians = degrees => (degrees * Math.PI) / 180;
*
* @param {Coordinate} c1 First coordinate
* @param {Coordinate} c2 Second coordinate
* @return {Number} Distance in meters
* @returns {Number} Distance in meters
*/
export const distanceBetweenCoordinates = (c1, c2) => {
const r = EARTH_RADIUS_IN_KM * 1000;
@@ -65,19 +65,36 @@ export const distanceBetweenCoordinates = (c1, c2) => {
};
/**
* Let the user download a string as file.
* Format a distance in meters into a human-readable string with unit.
*
* @param {String} text Content of the file
* @param {String} filename Suggested filename for the browser
* @param {String} [mimeType] Content mime type
* This only supports m / km for now, but could read a config option and return
* ft / mi.
*
* @param {Number} distance Distance in meters
* @returns {String} Formatted string including unit
*/
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);
export const humanReadableDistance = (distance) => {
let unit = "m";
if (Math.abs(distance) >= 1000) {
distance = distance / 1000;
unit = "km";
}
return `${distance.toLocaleString(config.locale, {
maximumFractionDigits: 1,
})} ${unit}`;
};
/**
* Get the total number of locations from a nested location history.
*
* @param {LocationHistory} locationHistory Location history
* @returns {Number} Total number of locations
*/
export const getLocationHistoryCount = (locationHistory) =>
Object.keys(locationHistory)
.map((user) =>
Object.keys(locationHistory[user])
.map((device) => locationHistory[user][device].length)
.reduce((a, b) => a + b, 0)
)
.reduce((a, b) => a + b, 0);

View File

@@ -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"
@@ -37,6 +94,7 @@
v-for="l in lastLocations"
:key="`${l.topic}-marker`"
:lat-lng="[l.lat, l.lon]"
:icon="markerIcon"
>
<LDeviceLocationPopup
:user="l.username"
@@ -44,52 +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 deviceLocations"
:key="`${user}-${device}-${n}`"
:lat-lng="[l.lat, l.lon]"
v-bind="circleMarker"
>
<LDeviceLocationPopup
:user="user"
:device="device"
: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"
@@ -111,24 +142,13 @@ import {
LCircleMarker,
LCircle,
LPolyline,
LTooltip,
} from "vue2-leaflet";
import "leaflet/dist/leaflet.css";
import markerIcon from "leaflet/dist/images/marker-icon.png";
import markerIcon2x from "leaflet/dist/images/marker-icon-2x.png";
import markerShadow from "leaflet/dist/images/marker-shadow.png";
import * as types from "@/store/mutation-types";
import config from "@/config";
import LHeatmap from "@/components/LHeatmap";
import LDeviceLocationPopup from "@/components/LDeviceLocationPopup";
// See https://github.com/KoRiGaN/Vue2Leaflet/issues/28#issuecomment-299038157
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconUrl: markerIcon,
iconRetinaUrl: markerIcon2x,
shadowUrl: markerShadow,
});
import LCustomMarker from "@/components/LCustomMarker";
import LHeatmap from "@/components/LHeatmap.vue";
import LDeviceLocationPopup from "@/components/LDeviceLocationPopup.vue";
export default {
components: {
@@ -142,41 +162,61 @@ export default {
LPolyline,
LDeviceLocationPopup,
LHeatmap,
LTooltip,
},
data() {
return {
attribution: config.map.attribution,
attribution: this.$config.map.attribution,
center: this.$store.state.map.center,
controls: config.map.controls,
heatmap: config.map.heatmap,
maxZoom: config.map.maxZoom,
maxNativeZoom: config.map.maxNativeZoom,
url: config.map.url,
controls: this.$config.map.controls,
heatmap: this.$config.map.heatmap,
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: {
...config.map.circle,
color: config.map.circle.color || config.primaryColor,
fillColor: config.map.circle.fillColor || config.primaryColor,
...this.$config.map.circle,
color: this.$config.map.circle.color || this.$config.primaryColor,
fillColor:
this.$config.map.circle.fillColor || this.$config.primaryColor,
},
circleMarker: {
...config.map.circleMarker,
color: config.map.circleMarker.color || config.primaryColor,
...this.$config.map.circleMarker,
color: this.$config.map.circleMarker.color || this.$config.primaryColor,
},
poiMarker: this.$config.map.poiMarker,
polyline: {
...config.map.polyline,
color: config.map.polyline.color || config.primaryColor,
...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,
@@ -189,19 +229,43 @@ 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,
});
}
},
/**
* Find a the last location object for a user/device combination from the
* local cache and backfill name and face attributes to each item from the
* passed array of location objects.
*
* @param {User} user Username
* @param {Device} device Device name
* @param {OTLocation[]} deviceLocations Device name
* @returns {OTLocation[]} Updated locations
*/
deviceLocationsWithNameAndFace(user, device, deviceLocations) {
const lastLocation = this.lastLocations.find(
(l) => l.username === user && l.device === device
);
if (!lastLocation) {
return deviceLocations;
}
return deviceLocations.map((l) => ({
...l,
name: lastLocation.name,
face: lastLocation.face,
}));
},
},
};
</script>

View File

@@ -1,5 +0,0 @@
module.exports = {
env: {
jest: true,
},
};

View File

@@ -1,34 +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].href).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].href).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"] })]
);
@@ -36,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].href).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].href).toEqual(
"http://localhost/api/0/list?user=bar"
expect(fetchMocker.mock.calls[1][0]).toEqual(
"http://localhost:3000/api/0/list?user=bar"
);
});
@@ -62,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].href).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 () => {
@@ -83,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].href).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"
);
});
@@ -102,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].href).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"
);
});
@@ -139,24 +149,24 @@ describe("API", () => {
],
status: 200,
};
fetch.mockResponse(JSON.stringify(response));
fetchMocker.mockResponse(JSON.stringify(response));
const locationHistory = await api.getUserDeviceLocationHistory(
"foo",
"phone",
new Date(Date.UTC(1970, 0, 1)),
new Date(Date.UTC(1970, 11, 31))
"1970-01-01T00:00:00",
"1970-12-31T23:59:59"
);
expect(locationHistory).toEqual(response.data);
expect(fetch.mock.calls.length).toEqual(1);
expect(fetch.mock.calls[0][0].href).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,
@@ -194,8 +204,8 @@ describe("API", () => {
const locationHistory = await api.getLocationHistory(
{ foo: ["phone", "tablet"], bar: ["laptop"] },
new Date(Date.UTC(1970, 0, 1)),
new Date(Date.UTC(1970, 11, 31))
"1970-01-01T00:00:00",
"1970-12-31T23:59:59"
);
expect(locationHistory).toEqual({
foo: {
@@ -205,15 +215,15 @@ describe("API", () => {
bar: { laptop: [{ topic: "owntracks/bar/laptop" }] },
});
expect(fetch.mock.calls.length).toEqual(3);
expect(fetch.mock.calls[0][0].href).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].href).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].href).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"
);
});
});

View File

@@ -1 +1 @@
global.fetch = require("jest-fetch-mock");
require("jest-fetch-mock").enableMocks();

View File

@@ -1,17 +1,20 @@
import { describe, expect, test } from "vitest";
import config from "@/config";
import {
getApiUrl,
isIsoDate,
isIsoDateTime,
degreesToRadians,
distanceBetweenCoordinates,
humanReadableDistance,
} from "@/util";
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", () => {
@@ -21,29 +24,46 @@ 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"
);
});
});
describe("isIsoDate", () => {
describe("isIsoDateTime", () => {
test("no match", () => {
expect(isIsoDate("foo")).toBe(false);
expect(isIsoDate("2019")).toBe(false);
expect(isIsoDate("2019-09")).toBe(false);
expect(isIsoDate("2019.09.27")).toBe(false);
expect(isIsoDate("2019_09_27")).toBe(false);
expect(isIsoDate("2019/09/27")).toBe(false);
expect(isIsoDate("27-09-2019")).toBe(false);
expect(isIsoDate("27.09.2019")).toBe(false);
expect(isIsoDate("27_09_2019")).toBe(false);
expect(isIsoDate("27/09/2019")).toBe(false);
expect(isIsoDate("0000-00-00")).toBe(false);
expect(isIsoDate("1234-56-78")).toBe(false);
expect(isIsoDateTime("foo")).toBe(false);
expect(isIsoDateTime("2019")).toBe(false);
expect(isIsoDateTime("2019-09")).toBe(false);
expect(isIsoDateTime("2019.09.27")).toBe(false);
expect(isIsoDateTime("2019_09_27")).toBe(false);
expect(isIsoDateTime("2019/09/27")).toBe(false);
expect(isIsoDateTime("27-09-2019")).toBe(false);
expect(isIsoDateTime("27.09.2019")).toBe(false);
expect(isIsoDateTime("27_09_2019")).toBe(false);
expect(isIsoDateTime("27/09/2019")).toBe(false);
expect(isIsoDateTime("0000-00-00")).toBe(false);
expect(isIsoDateTime("1234-56-78")).toBe(false);
expect(isIsoDateTime("0000-00-00T00:00:00")).toBe(false);
expect(isIsoDateTime("0000-01-01T25:60:60")).toBe(false);
expect(isIsoDateTime("2019-12-14T99:00:00")).toBe(false);
expect(isIsoDateTime("2019-12-14 25:60:60")).toBe(false);
});
test("match", () => {
expect(isIsoDate("0000-01-01")).toBe(true);
expect(isIsoDate("2019-09-27")).toBe(true);
expect(isIsoDate("9999-12-31")).toBe(true);
expect(isIsoDateTime("0000-01-01T00:00:00")).toBe(true);
expect(isIsoDateTime("0000-01-01T12:34:56")).toBe(true);
expect(isIsoDateTime("0000-01-01T23:59:59")).toBe(true);
expect(isIsoDateTime("2019-09-27T00:00:00")).toBe(true);
expect(isIsoDateTime("2019-09-27T12:34:56")).toBe(true);
expect(isIsoDateTime("2019-09-27T23:59:59")).toBe(true);
expect(isIsoDateTime("9999-12-31T00:00:00")).toBe(true);
expect(isIsoDateTime("9999-12-31T12:34:56")).toBe(true);
expect(isIsoDateTime("9999-12-31T23:59:59")).toBe(true);
});
});
@@ -92,3 +112,22 @@ describe("distanceBetweenCoordinates", () => {
).toBe(9105627.810109457);
});
});
describe("humanReadableDistance", () => {
test("expected results", () => {
expect(humanReadableDistance(0)).toBe("0 m");
expect(humanReadableDistance(1)).toBe("1 m");
expect(humanReadableDistance(123)).toBe("123 m");
expect(humanReadableDistance(123.4567)).toBe("123.5 m");
expect(humanReadableDistance(999)).toBe("999 m");
expect(humanReadableDistance(1000)).toBe("1 km");
expect(humanReadableDistance(9000)).toBe("9 km");
expect(humanReadableDistance(9900)).toBe("9.9 km");
expect(humanReadableDistance(9990)).toBe("10 km");
expect(humanReadableDistance(9999)).toBe("10 km");
expect(humanReadableDistance(9999.0)).toBe("10 km");
expect(humanReadableDistance(9999.9999)).toBe("10 km");
expect(humanReadableDistance(100000)).toBe("100 km");
expect(humanReadableDistance(-42)).toBe("-42 m");
});
});

19
vite.config.js Normal file
View 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",
},
});

View File

@@ -1,17 +0,0 @@
const fs = require("fs");
const webpack = require("webpack");
const packageJson = fs.readFileSync("./package.json");
const version = JSON.parse(packageJson).version;
module.exports = {
configureWebpack: {
plugins: [
new webpack.DefinePlugin({
"process.env": {
PACKAGE_VERSION: `"${version}"`,
},
}),
],
},
};

10869
yarn.lock

File diff suppressed because it is too large Load Diff