306 Commits
v1.0.0 ... main

Author SHA1 Message Date
Luke Blaney
f8d1d0dea3 Update documentation links in config.md 2025-04-23 17:52:59 +01:00
Martin Schreiber
22a605e9a9 Add missing german translations (#154) 2025-04-23 17:51:18 +01:00
Ramazan Sancar
773c217919 Add missing Turkish translations 2025-02-02 22:49:24 +00: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
Linus Groh
220bda6ef3 Release 2.0.0-beta.2 2019-11-02 18:17:48 +00:00
Linus Groh
6209c806a2 Upgrade dependencies 2019-11-02 17:30:13 +00:00
Linus Groh
c85e6fedf2 Fix timezone issues in tests 2019-11-02 17:25:17 +00:00
Linus Groh
edff370dc8 Enable Travis CI 2019-11-02 16:54:59 +00:00
Linus Groh
69edbc6ce4 Fix typo 2019-11-02 16:34:11 +00:00
Linus Groh
76f1d4980c Add onLocationChange.reloadHistory config option 2019-11-01 22:37:51 +00:00
Linus Groh
39fd7727f4 Upgrade dependencies 2019-10-27 12:34:01 +00:00
Linus Groh
418a2fe808 Fix ESLint errors 2019-10-27 12:28:29 +00:00
Linus Groh
f0c4ba43cb Fix config TOC links 2019-10-26 22:52:42 +01:00
Linus Groh
5d6208d57a Release 2.0.0-beta.1 2019-10-26 22:39:18 +01:00
Linus Groh
bd25881199 Fix branch name for version badge in README.md 2019-10-26 21:51:15 +01:00
Linus Groh
d029fb5360 Merge pull request #16 from owntracks/v2.0.0-alpha
v2.0.0 alpha
2019-10-26 21:48:31 +01:00
Linus Groh
de3d83e28f Add volume for config.js to docker-compose example 2019-10-26 19:33:09 +01:00
Linus Groh
9bd7fb8681 Upgrade dependencies 2019-10-26 19:20:06 +01:00
Linus Groh
9dbf6e78f1 Fix build by upgrading eslint-loader 2019-10-26 19:13:50 +01:00
Linus Groh
d5e21a2ada Upgrade Vue CLI packages 2019-10-26 19:08:50 +01:00
Linus Groh
942df6d001 Merge branch 'master' into v2.0.0-alpha 2019-10-26 18:34:10 +01:00
Linus Groh
310170525d Add CHANGELOG.md 2019-10-26 18:32:02 +01:00
Linus Groh
5ffe6025ae Rename config.default.js to config.example.js 2019-10-26 18:11:54 +01:00
Linus Groh
037b140311 Add newline to end of .dockerignore 2019-10-26 18:04:57 +01:00
Linus Groh
5a24cac5a1 Add .dockerignore 2019-10-26 17:57:39 +01:00
Linus Groh
3444b75345 Update Dockerfile for v2 2019-10-26 17:57:33 +01:00
Linus Groh
1c05bb17b4 Fix button text overflow 2019-10-22 22:13:12 +01:00
Linus Groh
f14f97b416 Add verbose mode 2019-10-22 22:12:59 +01:00
Linus Groh
50a513d144 Add custom checkbox styles 2019-10-22 20:19:13 +01:00
Linus Groh
31101a9818 Update cors-proxy instructions to use .env file 2019-10-22 19:55:25 +01:00
Linus Groh
28803bfd2d Add version and license badges 2019-10-21 13:59:27 +02:00
Linus Groh
da7a0aa5d6 Update distanceBetweenCoordinates 2019-10-02 19:23:45 +01:00
Linus Groh
12910fe66d Fix comment in test 2019-10-02 19:06:31 +01:00
Linus Groh
193882c4e7 Remove console.log 2019-10-02 19:04:27 +01:00
Linus Groh
968355cfb7 Use v-bind for LCircle 2019-10-02 19:04:09 +01:00
Linus Groh
c6181c77b1 Update heatmap when latLng changes 2019-10-02 19:03:36 +01:00
Linus Groh
9e61b7f174 Fix icon alignment 2019-10-02 19:03:12 +01:00
Linus Groh
432ec4bac4 Add loading indicator 2019-10-02 19:02:29 +01:00
Linus Groh
64a820a218 Add gradient config support to heatmap 2019-10-01 22:03:30 +01:00
Linus Groh
96a0daa05e Fix heatmap 2019-10-01 21:56:37 +01:00
Linus Groh
3571ac2724 Add "Loading version..." label to info modal 2019-10-01 21:21:41 +01:00
Linus Groh
5ff89c5484 Add OwnTracks website to info modal 2019-10-01 21:18:34 +01:00
Linus Groh
4a64b939be Upgrade outdated dependencies 2019-10-01 21:01:01 +01:00
Linus Groh
efbf980924 Implement data copying & download 2019-10-01 20:56:51 +01:00
Linus Groh
6c6763ebfc Refactor modals 2019-10-01 19:23:15 +01:00
Linus Groh
874847d22f Don't cut of button / date select labels 2019-10-01 19:02:39 +01:00
Linus Groh
937bafc3f0 Add tests for API functions 2019-09-30 19:10:11 +01:00
Linus Groh
506f12b66e Update JSDoc type definitions 2019-09-29 08:10:28 +01:00
Linus Groh
98cb52b31b Add JSDoc to more functions 2019-09-29 08:01:45 +01:00
Linus Groh
03ecce52af Fix typo 2019-09-29 08:00:29 +01:00
Linus Groh
be63a12607 Clean up and document configuration options 2019-09-29 08:00:16 +01:00
Linus Groh
69619665f4 Add linebreaks between lat lon alt 2019-09-28 22:32:57 +01:00
Linus Groh
b78d915751 Update location popup 2019-09-28 22:05:01 +01:00
Linus Groh
a12290d343 Enhance outline & flat button accessibility 2019-09-28 19:25:31 +01:00
Linus Groh
cf993d11dd Update information modal 2019-09-28 19:16:29 +01:00
Linus Groh
a443393bba Increase base font size to 14px 2019-09-28 19:06:24 +01:00
Linus Groh
4d06e1c07e Use vue-js-modal for modals 2019-09-28 19:01:57 +01:00
Linus Groh
31a85e42a6 Refactor styles 2019-09-28 14:50:05 +01:00
Linus Groh
81d9b63dd4 Reset selected device when changing user 2019-09-28 14:06:37 +01:00
Linus Groh
2d9ad44a23 Ignore selectedDevice if selectedUser is null 2019-09-28 14:06:12 +01:00
Linus Groh
4fc79adf81 Update default config comments 2019-09-28 12:33:21 +01:00
Linus Groh
6d4ff0d96b Rename accentColor to primaryColor 2019-09-28 12:32:59 +01:00
Linus Groh
d01de41e1f Return URL instance from getApiUrl 2019-09-27 22:12:49 +01:00
Linus Groh
600934183a Add tests 2019-09-27 21:13:40 +01:00
Linus Groh
a2de94fd44 Fix getApiUrl implementation 2019-09-27 20:52:16 +01:00
Linus Groh
4eb89abf3d Remove unused util function 2019-09-27 20:25:54 +01:00
Linus Groh
87647c81d3 Add config option to ignore ping/ping 2019-09-27 19:46:34 +01:00
Linus Groh
03712ef2a1 Add reloadData action 2019-09-27 19:33:55 +01:00
Linus Groh
c64ddd9e18 Fix typo 2019-09-27 19:26:27 +01:00
Linus Groh
75c3462eae Clarify cors-proxy usage 2019-09-27 19:19:38 +01:00
Linus Groh
9f35dbd5f2 Update README.md 2019-09-27 19:16:00 +01:00
Linus Groh
4d971d57f7 Publish 2.0.0-alpha source 2019-09-27 18:34:41 +01:00
Linus Groh
ed2118339d Use absolute URLs for images for Docker Hub 2019-09-24 19:46:30 +01:00
Linus Groh
d06a87f874 Merge pull request #11 from sharkoz/patch-1
Enable compression for faster download of history
2019-09-24 19:43:56 +01:00
sharkoz
959d71fccd Update README.md 2019-09-24 20:37:31 +02:00
sharkoz
845a2a25ed markdown syntax for image
Hoping it makes it visible on docker hub
2019-09-24 19:56:18 +02:00
sharkoz
088a36e585 Enable compression for faster download of history 2019-09-24 19:38:40 +02:00
Linus Groh
6dc1cd67fd Add Docker Hub pulls badge 2019-09-24 13:09:00 +01:00
Linus Groh
6f687f07e4 Update Docker instructions in README.md 2019-09-24 12:53:46 +01:00
Linus Groh
f467eb48a6 Docker install (#7)
Add Dockerfile to support usage via Docker. Upload images on Dockerhub.
2019-09-24 12:51:42 +01:00
sharkoz
4fca3d4cd7 Update Dockerfile 2019-09-16 16:52:17 +02:00
sharkoz
f6eb70c408 Update nginx.tmpl
Co-Authored-By: Linus Groh <mail@linusgroh.de>
2019-09-03 21:37:47 +02:00
sharkoz
1008d99144 Update nginx.tmpl
Co-Authored-By: Linus Groh <mail@linusgroh.de>
2019-09-03 21:37:37 +02:00
sharkoz
95d8200338 remove useless command 2019-09-03 20:55:26 +02:00
Linus Groh
4c45387ea3 Now official (#8)
Remove disclaimer from README.md after project was transferred to the owntracks organisation
2019-09-03 17:20:22 +01:00
JP Mens
b98203707b Now official 2019-09-03 18:08:51 +02:00
sharkoz
8b19d2bc63 Update README.md 2019-09-03 16:15:32 +02:00
sharkoz
00bb4e7fa7 Apply suggestions from code review
Co-Authored-By: Linus Groh <mail@linusgroh.de>
2019-09-03 14:33:38 +02:00
SharkoZ
2ec596e09a update readme for docker install 2019-09-03 10:53:32 +02:00
SharkoZ
6c58bb2bc8 fix nginx mime types 2019-09-03 09:00:26 +02:00
SharkoZ
cbcb25a25c Dockerise app 2019-09-03 08:57:13 +02:00
82 changed files with 10365 additions and 1031 deletions

3
.browserslistrc Normal file
View File

@@ -0,0 +1,3 @@
> 1%
last 2 versions
not ie > 0

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
.git
node_modules
docs
scripts
tests
LICENSE
README.md
*Dockerfile*
*docker-compose*

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

23
.gitignore vendored
View File

@@ -1,3 +1,26 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw*
public/config/config.js
# Logs
logs
*.log

258
CHANGELOG.md Normal file
View File

@@ -0,0 +1,258 @@
# 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
- Fix timezone issues in tests
- Fix ESLint errors in production mode
- Fix table of content links in config documentation
- Upgrade dependencies
## 2.0.0-beta.1 (2019-10-26)
- Convert codebase to Node.js based development workflow, including:
- Package management using yarn
- Build step using Webpack and Babel
- Usage of Vue single file components
- SCSS and PostCSS
- ESLint configuration for linting and consistent code style
- `package.json` scripts: `serve`, `build`, `lint`, `cors-proxy` and `test`
- Design updates, including:
- New default primary color (same as OwnTracks Android app)
- Improved hover and focus styles as a first attempt to improve accessibility
- Improved modals and location popups
- 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 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))
## 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))
## 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

176
README.md
View File

@@ -1,71 +1,155 @@
# OwnTracks UI
# OwnTracks Frontend
> A modern web interface for OwnTracks made with Vue.js
![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)
[![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="docs/images/owntracks-ui.png" alt="OwnTracks UI">
</p>
![Screenshot](https://raw.githubusercontent.com/owntracks/frontend/main/docs/images/screenshot.png)
## Introduction
This is a web interface for [OwnTracks](https://github.com/owntracks/recorder), intended to replace the various web pages shipping with the recorder. OwnTracks UI uses Vue.js under the hood.
This is a web interface for [OwnTracks](https://github.com/owntracks/recorder) built as
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.
*This is not an official OwnTracks project.*
## Installation
Clone the repository and copy `index.html` and the `static/` directory to your server's webroot. The API is expected to be reachable under the same domain as the web interface.
![Map features](https://raw.githubusercontent.com/owntracks/frontend/main/docs/images/map-features.png)
## Features
- Enable or disable multiple layers:
- 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
- Last known (i.e. live) locations:
## Installation
- Accuracy visualization (circle)
- Device friendly name and icon
- Detailed information (if available): time, lat, lon, height, battery and speed
### Docker
- Location history (data points, line or both)
- Location heatmap
- Button to quickly fit all shown objects on the map into view
A pre-built Docker image is available on Docker Hub as [`owntracks/frontend`](https://hub.docker.com/r/owntracks/frontend).
- Display data in a specific date range
- Filter by user and device
- Customizable:
You can start a container directly via `docker run`:
- UI color
- Default start and end date
- Map:
```console
$ docker run -d -p 80:80 -e SERVER_HOST=otrecorder-host -e SERVER_PORT=8083 owntracks/frontend
```
- Tile server
- Max zoom
- Default position and zoom
- Heatmap colors, radius and blur
Or you can use `docker-compose` (if you also run the OwnTracks Recorder with the default
compose config, and the service is named `otrecorder`):
## Screenshots
```yaml
version: "3"
_Click to enlarge._
services:
owntracks-frontend:
image: owntracks/frontend
ports:
- 80:80
volumes:
- ./path/to/custom/config.js:/usr/share/nginx/html/config/config.js
environment:
- SERVER_HOST=otrecorder
- SERVER_PORT=8083
restart: unless-stopped
```
<a href="docs/images/live.png" target="_blank"><img src="docs/images/live.png" alt="Live" height="200"></a>
<a href="docs/images/multiple.png" target="_blank"><img src="docs/images/multiple.png" alt="Multiple" height="200"></a>
<a href="docs/images/date-selection.png" target="_blank"><img src="docs/images/date-selection.png" alt="Date selection" height="200"></a>
<a href="docs/images/heatmap.png" target="_blank"><img src="docs/images/heatmap.png" alt="Heatmap" height="200"></a>
<a href="docs/images/customized.png" target="_blank"><img src="docs/images/customized.png" alt="Customized" height="200"></a>
To change the port on which the nginx server will listen on, set the
`LISTEN_PORT` enviroment variable - default is 80.
## ToDo
To build the image from source replace `image:` with:
- Node.js based development workflow:
```yaml
build:
context: ./owntracks-frontend
dockerfile: docker/Dockerfile
```
- Webpack
- Vue SFCs
- Sass
- Dependency management with yarn instead of a local copy or unpkg.com
(assuming you have this repository cloned to `owntracks-frontend` in the same
directory as `docker-compose.yml`)
- Add documentation, at least for the config file
- Docker support
- Download data for selected date range, user and device as JSON
### 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
OwnTracks API is reachable at the root of the same host as the frontend.
Copy [`public/config/config.example.js`](public/config/config.example.js) to
`public/config/config.js` and make changes as you wish.
See [`docs/config.md`](docs/config.md) for all available options.
## Development
- 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
$ 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:
```text
OT_BASIC_AUTH_USERNAME=user
OT_BASIC_AUTH_PASSWORD='P@$$w0rd'
```
Then run:
```console
$ 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`
and `OT_PROXY_PORT` environment variables.
Finally update `api.baseUrl` in your config to `"http://0.0.0.0:8888/https://owntracks.example.com"`.
### I18n
This project uses [Vue I18n](https://kazupon.github.io/vue-i18n/). To see missing and
unused i18n entries, run:
```console
$ npm run i18n:report
```
To add a new locale, copy `en-US.json` to `<locale>.json` in [`src/locales`](src/locales)
and start translating the individual strings. Make sure to [mention the new locale to the docs](docs/config.md#locale)!
For a specific example see commit [`b2edda4`](https://github.com/owntracks/frontend/commit/b2edda410f16633aa6fd9cd4e5250f2031536c7d)
where German translations were added.
## Contributing
Please feel free to open an issue and discuss your ideas and report bugs. If you think you can help out with something, open a PR!
Please feel free to open an issue and discuss your ideas and report bugs. If you think
you can help out with something, open a PR!

24
docker/Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM node:20 as build
WORKDIR /usr/src/app
COPY package.json package-lock.json ./
RUN npm install
COPY . ./
RUN npm run build
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
COPY ./docker/nginx.tmpl /etc/nginx/nginx.tmpl
COPY --from=build /usr/src/app/dist /usr/share/nginx/html
EXPOSE 80
CMD /bin/sh -c " \
envsubst '\${SERVER_HOST} \${SERVER_PORT} \${LISTEN_PORT}' \
< /etc/nginx/nginx.tmpl \
> /etc/nginx/nginx.conf \
&& nginx -g 'daemon off;' \
|| ( env; cat /etc/nginx/nginx.conf ) \
"

37
docker/nginx.tmpl Normal file
View File

@@ -0,0 +1,37 @@
worker_processes 1;
events {
worker_connections 1024;
}
http {
upstream otrecorder {
server ${SERVER_HOST}:${SERVER_PORT};
}
server {
listen ${LISTEN_PORT};
listen [::]:${LISTEN_PORT};
root /usr/share/nginx/html;
location /api/ {
proxy_pass http://otrecorder/api/;
}
location /ws/ {
proxy_pass http://otrecorder/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection upgrade;
}
location / {
include /etc/nginx/mime.types;
try_files $uri $uri/index.html;
}
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
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;
}
}

581
docs/config.md Normal file
View File

@@ -0,0 +1,581 @@
# Configuration
## Overview
All _custom_ configuation is stored in `window.owntracks.config`,
which is a regular JavaScript object - so you can use template strings, spread syntax,
comments and other JS features.
Some of the application state is synced to the URL's query parameters. If a parameter
exists in the URL query, it takes precedence over the configured value - otherwise the
configured value will be used and appended to the URL query.
Start with this:
```js
window.owntracks = window.owntracks || {};
window.owntracks.config = {};
```
**WARNING: if your configuration contains private data (most commonly your tile server**
**access key), make sure to protect access to it properly, e.g. with basic authentication.**
## Options
- `api`
- [`baseUrl`](#apibaseurl)
- [`fetchOptions`](#apifetchoptions)
- [`endDateTime`](#enddatetime)
- `filters`
- [`minAccuracy`](#filtersminaccuracy)
- [`ignorePingLocation`](#ignorepinglocation)
- [`locale`](#locale)
- `map`
- [`attribution`](#mapattribution)
- [`circle`](#mapcircle)
- [`circleMarker`](#mapcirclemarker)
- `controls`
- `scale`
- [`display`](#mapcontrolsscaledisplay)
- [`imperial`](#mapcontrolsscaleimperial)
- [`maxWidth`](#mapcontrolsscalemaxwidth)
- [`metric`](#mapcontrolsscalemetric)
- [`position`](#mapcontrolsscaleposition)
- `zoom`
- [`display`](#mapcontrolszoomdisplay)
- [`position`](#mapcontrolszoomposition)
- `heatmap`
- [`blur`](#mapheatmapblur)
- [`gradient`](#mapheatmapgradient)
- [`max`](#mapheatmapmax)
- [`radius`](#mapheatmapradius)
- `layers`
- [`heatmap`](#maplayersheatmap)
- [`last`](#maplayerslast)
- [`line`](#maplayersline)
- [`poi`](#maplayerspoi)
- [`points`](#maplayerspoints)
- [`maxNativeZoom`](#mapmaxnativezoom)
- [`maxPointDistance`](#mapmaxpointdistance)
- [`maxZoom`](#mapmaxzoom)
- [`poiMarker`](#mappoimarker)
- [`polyline`](#mappolyline)
- [`url`](#mapurl)
- `onLocationChange`
- [`fitView`](#onlocationchangefitview)
- [`reloadHistory`](#onlocationchangereloadhistory)
- [`primaryColor`](#primarycolor)
- `router`
- [`basePath`](#routerbasepath)
- [`selectedDevice`](#selecteddevice)
- [`selectedUser`](#selecteduser)
- [`showDistanceTravelled`](#showdistancetravelled)
- [`startDateTime`](#startdatetime)
- [`verbose`](#verbose)
### `api.baseUrl`
Base URL for the recorder's HTTP and WebSocket API. Keep CORS in mind.
- Type: [`String`]
- Default: current protocol and host
- Examples:
```js
// API requests will be made to https://owntracks.example.com/api/0/...
window.owntracks.config = {
api: {
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/",
},
};
```
### `api.fetchOptions`
Options for API requests (made with `fetch()`). See [`fetch()` docs on MDN] for details.
You can use this for example to send custom HTTP headers or to include cookies in the request.
- Type: [`Object`]
- Default: `{}`
- Example:
```js
// Include credentials (e.g. cookies)
window.owntracks.config = {
api: {
fetchOptions: {
credentials: "include",
},
},
};
```
### `endDateTime`
Initial end date and time (browser timezone) for fetched data.
- Type: [`Date`]
- Default: today, 23:59:59
- Example:
```js
// Data will be fetched up to 1970-01-01
window.owntracks.config = {
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,
},
};
```
### `ignorePingLocation`
Remove the `ping/ping` location from the fetched data. This is useful when using the
`owntracks/recorder` Docker image which has it [enabled for health checks by default](https://github.com/owntracks/recorder/issues/195#issuecomment-304004436).
- Type: [`Boolean`]
- Default: `true`
- Example:
```js
// Don't ignore ping/ping location. Not sure why you'd do this :)
window.owntracks.config = {
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.
- Type: [`String`] (may contain HTML)
- Default: `"&copy; <a href="https://osm.org/copyright">OpenStreetMap</a> contributors"`
- Example:
```js
// Make sure to add proper attribution!
window.owntracks.config = {
map: {
attribution: "Map tiles &copy; MyTileServerProvider",
},
};
```
### `map.circle`
Location accuracy indicator configuation. `color` and `fillColor` default to
`primaryColor` if `null`. See [Vue2Leaflet `l-circle` documentation](https://vue2-leaflet.netlify.app/components/LCircle.html)
for all possible values.
- Type: [`Object`]
- Default:
```js
{
color: null,
fillColor: null,
fillOpacity: 0.2
}
```
### `map.circleMarker`
Location point marker configuation. `color` defaults to `primaryColor` if `null`. See
[Vue2Leaflet `l-circle-marker` documentation](https://vue2-leaflet.netlify.app/components/LCircleMarker.html)
for all possible values.
- Type: [`Object`]
- Default:
```js
{
color: null,
fillColor: "#fff",
fillOpacity: 1,
radius: 4
}
```
### `map.controls.scale.display`
Whether to show scale control or not.
- Type: [`Boolean`]
- Default: `true`
### `map.controls.scale.imperial`
Whether to show an imperial scale (ft) or not.
- Type: [`Boolean`]
- Default: `true`
### `map.controls.scale.maxWidth`
Maximum width of the scale control in pixels.
- Type: [`Number`]
- Default: `200`
### `map.controls.scale.metric`
Whether to show an metric scale (m) or not.
- Type: [`Boolean`]
- Default: `true`
### `map.controls.scale.position`
Scale control position on the map. See [Leaflet control position documentation](https://leafletjs.com/reference.html#control)
for all possible values.
- Type: [`String`]
- Default: `"bottomleft"`
### `map.controls.zoom.display`
Whether to show zoom control or not.
- Type: [`Boolean`]
- Default: `true`
### `map.controls.zoom.position`
Zoom control position on the map. See [Leaflet control position documentation](https://leafletjs.com/reference.html#control)
for all possible values.
- Type: [`String`]
- Default: `"topleft"`
### `map.heatmap.blur`
Heatmap blur radius.
- Type: [`Number`]
- Default: `15`
### `map.heatmap.gradient`
Mapping of values between 0 and 1 to different colors. Defaults to [`simpleheat`'s default gradient](https://github.com/mourner/simpleheat/blob/c1998c36fa2f9a31350371fd42ee30eafcc78f9c/simpleheat.js#L22-L28)
if `null`.
- Type: [`Object`] or `null`
- Default: `null`
### `map.heatmap.max`
Heatmap max data value.
- Type: [`Number`]
- Default: `20`
### `map.heatmap.radius`
Heatmap point radius.
- Type: [`Number`]
- Default: `25`
### `map.layers.heatmap`
Initial visibility of the heatmap layer.
- Type: [`Boolean`]
- Default: `false`
### `map.layers.last`
Initial visibility of the last locations layer.
- Type: [`Boolean`]
- Default: `true`
### `map.layers.line`
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 location points layer.
- Type: [`Boolean`]
- Default: `false`
### `map.maxNativeZoom`
This is being used to fetch tiles in different resolutions - set to the highest value
the configured tileserver supports.
- Type: [`Number`]
- Default: `19`
### `map.maxPointDistance`
Maximum distance (in meters) between points for them to be part of the the same line.
This avoids straight lines going across the map when there's a ceartain distance between
two points (which often indicates that they're not related). Set to `null` to disable
splitting into separate lines.
- Type: [`Number`] or `null`
- Default: `null`
- Example:
```js
// Don't connect points with a distance of more than 1km
window.owntracks.config = {
map: {
maxPointDistance: 1000,
},
};
```
### `map.maxZoom`
Allow zooming closer than the tile server supports, which will result in (slightly)
blurry tiles on higher zoom levels. Set to the same value as [`map.maxNativeZoom`](#map.maxNativeZoom)
to disable.
- Type: [`Number`]
- Default: `21`
### `map.poiMarker`
POI marker configuration. See [Vue2Leaflet `l-circle-marker` documentation](https://vue2-leaflet.netlify.app/components/LCircleMarker.html)
for all possible values.
- Type: [`Object`]
- Default:
```js
{
color: "red",
fillColor: "red",
fillOpacity: 0.2,
radius: 12
}
```
### `map.polyline`
Location point marker configuration. `color` defaults to `primaryColor` if `null`. See
[Vue2Leaflet `l-polyline` documentation](https://vue2-leaflet.netlify.app/components/LPolyline.html)
for all possible values.
- Type: [`Object`]
- Default:
```js
{
color: null,
fillColor: "transparent"
}
```
### `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.html#tilelayer)
and [this Wikipedia article](https://en.wikipedia.org/wiki/Tiled_web_map).
- Type: [`String`]
- Default: `"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"`
- Example:
```js
// Use dark HDPI tiles from Mapbox
window.owntracks.config = {
map: {
url: "https://api.mapbox.com/styles/v1/mapbox/dark-v10/tiles/{z}/{x}/{y}@2x?access_token=xxxxxxxxxxxxxxxx",
},
};
```
### `map.zoomOffset`
Offset the zoom level to account for different tile sizes. For example tiles with a
size of 512x512 need an offset of -1 and for 1024x1024 an offset of -2.
- Type: [`Number`]
- Default: `0`
### `onLocationChange.fitView`
Whether to re-fit the map's content into view or not when a location update is received.
This can be useful if you're showing live locations and don't want them to "leave" the map.
- Type: [`Boolean`]
- Default: `false`
### `onLocationChange.reloadHistory`
Whether to reload the location history (of selected date range) or not when a location
update is received.
- Type: [`Boolean`]
- Default: `false`
### `primaryColor`
Primary color for the user interface (navigation bar and various map elements).
- Type: [`String`] ([CSS `<color>`])
- Default: `"#3f51b5"` (primary color from the OwnTracks Android app)
- Example:
```js
// Set the UI's primary color to 'rebeccapurple'
window.owntracks.config = {
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`.
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.
- Type: [`String`] or `null`
- Default: `null`
- Example:
```js
// Select the device 'phone' from user 'foo' by default
window.owntracks.config = {
selectedUser: "foo",
selectedDevice: "phone",
};
```
### `selectedUser`
Initial selected user. All users will be shown by default if `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.
- Type: [`String`] or `null`
- Default: `null`
- Example:
```js
// Select all devices from user 'foo' by default
window.owntracks.config = {
selectedUser: "foo",
};
```
### `showDistanceTravelled`
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, 00:00:00
- Example:
```js
// Data will be fetched from the first day of the current month
const startDateTime = new Date();
startDateTime.setHours(0, 0, 0, 0);
startDateTime.setDate(1);
window.owntracks.config = {
startDateTime,
};
```
### `verbose`
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
[`fetch()` docs on mdn]: https://developer.mozilla.org/en-US/docs/Web/API/Window/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,224 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, height=device-height, user-scalable=no, initial-scale=1.0" />
<title>OwnTracks</title>
<link href="static/style.css" rel="stylesheet">
<link href="https://unpkg.com/leaflet@1.4.0/dist/leaflet.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css?family=Noto+Sans" rel="stylesheet">
<link href="https://unpkg.com/@mdi/font@3.5.95/css/materialdesignicons.min.css" rel="stylesheet">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="/favicon.ico">
<link rel="manifest" crossorigin="use-credentials" href="/manifest.json">
<title>OwnTracks Frontend</title>
</head>
<body>
<div id="app">
<header>
<nav>
<div class="nav-item">
<button
class="button button-outline"
title="Automatically center the map view and zoom in to relevant data"
@click="centerView"
>
Center View
</button>
</div>
<div class="nav-item">
<span class="mdi mdi-24px mdi-layers"></span>
<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" v-model="showLastLocations">
Show last known locations
</label>
<label tabindex="0">
<input type="checkbox" v-model="showLocationHistoryLine">
Show location history (line)
</label>
<label tabindex="0">
<input type="checkbox" v-model="showLocationHistoryPoints">
Show location history (points)
</label>
<label tabindex="0">
<input type="checkbox" v-model="showLocationHeatmap">
Show location heatmap
</label>
</div>
</div>
</div>
<div class="nav-item">
<span class="mdi mdi-24px mdi-calendar-range"></span>
<vuejs-datepicker
v-model="startDate"
:use-utc="true"
:disabled-dates="startDateDisabledDates"
title="Select start date"
></vuejs-datepicker>
to
<vuejs-datepicker
v-model="endDate"
:use-utc="true"
:disabled-dates="endDateDisabledDates"
title="Select end date"
></vuejs-datepicker>
</div>
<div class="nav-item">
<span class="mdi mdi-24px mdi-account"></span>
<select v-model="selectedUser" class="dropdown-button button" title="Select user">
<option value="">
Show All
</option>
<option v-for="user in users" :value="user">
{{ user }}
</option>
</select>
</div>
<div v-if="selectedUser" class="nav-item">
<span class="mdi mdi-24px mdi-cellphone-link"></span>
<select v-model="selectedDevice" class="dropdown-button button" title="Select device">
<option value="">
Show All
</option>
<option v-for="device in devices[selectedUser]" :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="showDownloadModal = !showDownloadModal"
>
<span class="mdi mdi-24px mdi-download"></span>
</button>
</div>
<div class="nav-item">
<button
class="button button-flat button-icon"
title="Information"
@click="showInformationModal = !showInformationModal"
>
<span class="mdi mdi-24px mdi-information-outline"></span>
</button>
</div>
</nav>
</header>
<main>
<l-map ref="map" :zoom="map.zoom" :center="map.center">
<l-tile-layer
:url="map.url"
:attribution="map.attribution"
:options="{maxNativeZoom: map.maxNativeZoom, maxZoom: map.maxZoom}"
></l-tile-layer>
<l-circle
v-if="showLastLocations"
v-for="l in lastLocations"
:key="`${l.topic}-circle`"
:lat-lng="{lat: l.lat, lng: l.lon}"
:radius="l.acc"
:color="map.circle.color"
:fill-color="map.circle.fillColor"
:fill-opacity="map.circle.fillOpacity"
></l-circle>
<l-marker
v-if="showLastLocations"
v-for="l in lastLocations"
:key="`${l.topic}-marker`"
:lat-lng="[l.lat, l.lon]"
>
<location-popup
:user="l.username"
:device="l.device"
:name="l.name"
:face="l.face"
:timestamp="l.tst"
:lat="l.lat"
:lon="l.lon"
:alt="l.alt"
:battery="l.batt"
:speed="l.vel"
></location-popup>
</l-marker>
<l-polyline
v-if="showLocationHistoryLine"
:lat-lngs="locationHistoryLatLngs"
:color="map.polyline.color"
:fill-color="map.polyline.fillColor"
></l-polyline>
<template v-if="showLocationHistoryPoints">
<template v-for="(userDevices, user) in locationHistory">
<template v-for="(deviceLocations, device) in userDevices">
<l-circle-marker
v-for="(l, n) in deviceLocations"
:key="`${user}-${device}-${n}`"
:lat-lng="[l.lat, l.lon]"
:radius="map.circleMarker.radius"
:color="map.circleMarker.color"
:fill-color="map.circleMarker.fillColor"
:fill-opacity="map.circleMarker.fillOpacity"
>
<location-popup
:user="user"
:device="device"
:timestamp="l.tst"
:lat="l.lat"
:lon="l.lon"
:alt="l.alt"
:battery="l.batt"
:speed="l.vel"
></location-popup>
</l-circle-marker>
</template>
</template>
</template>
<template v-if="showLocationHeatmap">
<l-heatmap
v-if="locationHistoryLatLngs.length"
:lat-lng="locationHistoryLatLngs"
:max="map.heatmap.max"
:radius="map.heatmap.radius"
:blur="map.heatmap.blur"
:gradient="map.heatmap.gradient"
></l-heatmap>
</template>
</l-map>
</main>
<modal :visible="showDownloadModal" @close="showDownloadModal = false">
Not implemented.
</modal>
<modal :visible="showInformationModal" @close="showInformationModal = false">
<b>OwnTracks {{ information.ownTracks.version }}</b>
<ul>
<li><a :href="information.ownTracksUi.sourceCodeUrl">OwnTracks UI Source Code</a></li>
<li><a :href="information.ownTracks.documentationUrl">OwnTracks Recorder Documentation</a></li>
<li><a :href="information.ownTracks.sourceCodeUrl">OwnTracks Recorder Source Code</a></li>
<li><a :href="information.ownTracks.twitterUrl">OwnTracks Twitter</a></li>
</ul>
</modal>
</div>
<script src="https://unpkg.com/vue@2.5.22/dist/vue.min.js"></script>
<script src="https://unpkg.com/vuejs-datepicker@1.5.4/dist/vuejs-datepicker.min.js"></script>
<script src="https://unpkg.com/leaflet@1.4.0/dist/leaflet.js"></script>
<script src="https://unpkg.com/vue2-leaflet@1.2.3/dist/vue2-leaflet.min.js"></script>
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
<script src="https://unpkg.com/deepmerge@3.2.0/dist/umd.js"></script>
<script src="static/components/vue-leaflet-heatmap.js"></script>
<script src="static/components/location-popup.js"></script>
<script src="static/components/modal.js"></script>
<script src="static/config/default.js"></script>
<script src="static/config/custom.js"></script>
<script src="static/main.js"></script>
<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="./config/config.js"></script>
<script type="module" src="/src/main.js"></script>
</body>
</html>

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

62
package.json Normal file
View File

@@ -0,0 +1,62 @@
{
"name": "owntracks-frontend",
"version": "2.15.3",
"license": "MIT",
"author": {
"name": "Linus Groh",
"email": "mail@linusgroh.de"
},
"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"
}
}

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

@@ -0,0 +1,3 @@
// Here you can overwrite the default configuration values
window.owntracks = window.owntracks || {};
window.owntracks.config = {};

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

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"
}

25
scripts/corsProxy.js Normal file
View File

@@ -0,0 +1,25 @@
import { createServer } from "cors-anywhere";
const host = process.env.OT_PROXY_HOST || "0.0.0.0";
const port = process.env.OT_PROXY_PORT || 8888;
const username = process.env.OT_BASIC_AUTH_USERNAME || null;
const password = process.env.OT_BASIC_AUTH_PASSWORD || null;
const options = {
httpProxyOptions: {
ws: true,
},
};
if (username !== null && password !== null) {
console.log(`Basic auth for user ${username} enabled`);
options.setHeaders = {
Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString(
"base64"
)}`,
};
}
createServer(options).listen(port, host, () => {
console.log(`Running CORS Anywhere on http://${host}:${port}`);
});

95
src/App.vue Normal file
View File

@@ -0,0 +1,95 @@
<template>
<div id="app">
<AppHeader />
<main>
<router-view />
</main>
<InformationModal />
<LoadingModal />
</div>
</template>
<script>
import { mapActions } from "vuex";
import * as types from "@/store/mutation-types";
import { log } from "@/logging";
import AppHeader from "@/components/AppHeader.vue";
import InformationModal from "@/components/modals/InformationModal.vue";
import LoadingModal from "@/components/modals/LoadingModal.vue";
export default {
components: { AppHeader, InformationModal, LoadingModal },
created() {
document.documentElement.style.setProperty(
"--color-primary",
this.$config.primaryColor
);
this.populateStateFromQuery(this.$route.query);
this.loadData();
// Update URL query params when relevant values changes
this.$store.subscribe((mutation) => {
if (
[
types.SET_SELECTED_USER,
types.SET_SELECTED_DEVICE,
types.SET_START_DATE_TIME,
types.SET_END_DATE_TIME,
types.SET_MAP_CENTER,
types.SET_MAP_ZOOM,
types.SET_MAP_LAYER_VISIBILITY,
].includes(mutation.type)
) {
this.updateUrlQuery();
}
if (mutation.type === types.SET_IS_LOADING) {
this.$store.state.isLoading
? this.$modal.show("loading")
: this.$modal.hide("loading");
}
});
// Initially update URL query params from state
this.updateUrlQuery();
},
methods: {
...mapActions(["populateStateFromQuery", "loadData"]),
/**
* Update all URL query parameters. This is called whenever any
* of the relevant values change in the Vuex store.
*/
updateUrlQuery() {
const {
map,
startDateTime: start,
endDateTime: end,
selectedUser: user,
selectedDevice: device,
} = this.$store.state;
const activeLayers = Object.keys(map.layers).filter(
(key) => map.layers[key] === true
);
const query = {
lat: map.center.lat,
lng: map.center.lng,
zoom: map.zoom,
start,
end,
...(user !== null && { user }),
...(user !== null && device !== null && { device }),
...(activeLayers.length > 0 && { layers: activeLayers.join(",") }),
};
log("STATE", "Updating URL query from state");
log(
"STATE",
JSON.parse(JSON.stringify({ map, start, end, user, device }))
);
this.$router.replace({ query }).catch(() => {}); // https://github.com/vuejs/vue-router/issues/2872#issuecomment-519073998
},
},
};
</script>
<style lang="scss">
@import "styles/main";
</style>

235
src/api.js Normal file
View File

@@ -0,0 +1,235 @@
import config from "@/config";
import { log, logLevels } from "@/logging";
import { getApiUrl, getLocationHistoryCount } from "@/util";
/**
* Fetch an API resource.
*
* @param {String} path API resource path
* @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 = {}, fetchOptions = {}) => {
const url = getApiUrl(path);
Object.keys(params).forEach((key) => url.searchParams.set(key, params[key]));
log("HTTP", `GET ${url.href}`);
return fetch(url.href, {
...fetchOptions,
...config.api.fetchOptions,
}).catch((error) => {
if (error.name === "AbortError") {
log("HTTP", `GET ${url.href} - Request was aborted`, logLevels.WARNING);
} else {
log("HTTP", error, logLevels.ERROR);
}
});
};
/**
* Get the recorder's version.
*
* @returns {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.
*
* @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 {User[]} users Array of usernames
* @returns {Promise<{User: Device[]}>}
* Object mapping each username to an array of device names
*/
export const getDevices = async (users) => {
const devices = {};
await Promise.all(
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;
};
/**
* Get last locations for a specific or all user/device.
*
* @param {User} [user] Get last locations of all devices from this user
* @param {Device} [device] Get last location of specific device
* @returns {Promise<OTLocation[]>} Array of last location objects
*/
export const getLastLocations = async (user, device) => {
const params = {};
if (user) {
params["user"] = user;
if (device) {
params["device"] = device;
}
}
const response = await fetchApi("/api/0/last", params);
const json = await response.json();
const lastLocations = json;
log(
"API",
() => `[getLastLocations] Fetched ${lastLocations.length} last locations`
);
return lastLocations;
};
/**
* Get the location history of a specific user/device.
*
* @param {User} user Username
* @param {Device} device Device name
* @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,
fetchOptions
) => {
const response = await fetchApi(
"/api/0/locations",
{
from: start,
to: end,
user,
device,
format: "json",
},
fetchOptions
);
const json = await response.json();
// 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 {{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, fetchOptions) => {
const locationHistory = {};
await Promise.all(
Object.keys(devices).map(async (user) => {
locationHistory[user] = {};
await Promise.all(
devices[user].map(async (device) => {
locationHistory[user][device] = await getUserDeviceLocationHistory(
user,
device,
start,
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.
*
* @param {WebSocketLocationCallback} [callback] Callback for location messages
*/
export const connectWebsocket = async (callback) => {
let url = getApiUrl("/ws/last");
url.protocol = url.protocol.replace("http", "ws");
url = url.href;
const ws = new WebSocket(url);
log("WS", `Connecting to ${url}`);
ws.onopen = () => {
log("WS", "Connected");
ws.send("LAST");
};
ws.onclose = (event) => {
log(
"WS",
`Disconnected unexpectedly (reason: ${
event.reason || "unknown"
}). Reconnecting in one second.`,
logLevels.WARNING
);
setTimeout(connectWebsocket, 1000);
};
ws.onmessage = async (msg) => {
if (msg.data) {
try {
const data = JSON.parse(msg.data);
if (data._type === "location") {
log("WS", "Location update received");
callback && (await callback());
}
} catch (err) {
if (msg.data !== "LAST") {
log("WS", err, logLevels.ERROR);
}
}
} else {
log("WS", "Ping");
}
};
};

View File

@@ -0,0 +1,378 @@
<template>
<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="
$t('Automatically center the map view and zoom in to relevant data')
"
@click="$root.$emit('fitView')"
>
{{ $t("Fit view") }}
</button>
</div>
<div class="nav-item">
<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" 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" aria-hidden="true" role="img" />
<select
v-model="selectedUser"
class="dropdown-button button"
:title="$t('Select user')"
>
<option :value="null">
{{ $t("Show all") }}
</option>
<option v-for="user in users" :key="user" :value="user">
{{ user }}
</option>
</select>
</div>
<div v-if="selectedUser" class="nav-item">
<SmartphoneIcon size="1x" aria-hidden="true" role="img" />
<select
v-model="selectedDevice"
class="dropdown-button button"
:title="$t('Select device')"
>
<option :value="null">
{{ $t("Show all") }}
</option>
<option
v-for="device in devices[selectedUser]"
:key="`${selectedUser}-${device}`"
:value="device"
>
{{ device }}
</option>
</select>
</div>
</nav>
<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="$t('Information')"
@click="$modal.show('information')"
>
<InfoIcon size="1x" :aria-label="$t('Information')" role="img" />
</button>
</div>
</nav>
</header>
</template>
<script>
import moment from "moment";
import { mapActions, mapGetters, mapMutations, mapState } from "vuex";
import {
ArrowDownIcon,
ArrowUpIcon,
CalendarIcon,
CrosshairIcon,
InfoIcon,
LayersIcon,
MenuIcon,
SmartphoneIcon,
UserIcon,
} from "vue-feather-icons";
import DatePicker from "vue2-datepicker";
import "vue2-datepicker/index.css";
import DropdownButton from "@/components/DropdownButton.vue";
import { DATE_TIME_FORMAT } from "@/constants";
import * as types from "@/store/mutation-types";
import { humanReadableDistance } from "@/util";
export default {
components: {
ArrowDownIcon,
ArrowUpIcon,
CalendarIcon,
CrosshairIcon,
DatePicker,
InfoIcon,
LayersIcon,
MenuIcon,
SmartphoneIcon,
UserIcon,
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",
"distanceTravelled",
"elevationGain",
"elevationLoss",
]),
selectedUser: {
get() {
return this.$store.state.selectedUser;
},
set(value) {
this.setSelectedUser(value);
},
},
selectedDevice: {
get() {
return this.$store.state.selectedDevice;
},
set(value) {
this.setSelectedDevice(value);
},
},
dateTimeRange: {
get() {
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([startDateTime, endDateTime]) {
this.setStartDateTime(
moment(startDateTime).utc().format(DATE_TIME_FORMAT)
);
this.setEndDateTime(
moment(endDateTime).set("seconds", 59).utc().format(DATE_TIME_FORMAT)
);
},
},
},
methods: {
...mapMutations({
setMapLayerVisibility: types.SET_MAP_LAYER_VISIBILITY,
}),
...mapActions([
"setSelectedUser",
"setSelectedDevice",
"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

@@ -0,0 +1,190 @@
<template>
<LPopup :options="options">
<div class="device">{{ deviceName }}</div>
<div class="wrapper">
<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 :title="$t('Timestamp')">
<ClockIcon size="1x" aria-hidden="true" role="img" />
{{ new Date(timestamp * 1000).toLocaleString($config.locale) }}
<span v-if="isoLocal && timeZone">
<br />
<code style="font-size: 0.7rem">
{{ isoLocal }}[{{ timeZone }}]
</code>
</span>
</li>
<li :title="$t('Location')">
<MapPinIcon size="1x" aria-hidden="true" role="img" />
{{ lat }}
<br />
{{ lon }}
<br />
{{ alt }}m
</li>
<li v-if="address" :title="$t('Address')">
<HomeIcon size="1x" aria-hidden="true" role="img" />
{{ address }}
</li>
<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'" :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>
<script>
import {
BatteryIcon,
ClockIcon,
HomeIcon,
MapPinIcon,
WifiIcon,
ZapIcon,
} from "vue-feather-icons";
import { LPopup } from "vue2-leaflet";
export default {
name: "LDeviceLocationPopup",
components: {
BatteryIcon,
ClockIcon,
HomeIcon,
MapPinIcon,
WifiIcon,
ZapIcon,
LPopup,
},
props: {
user: {
type: String,
default: "",
},
device: {
type: String,
default: "",
},
name: {
type: String,
default: "",
},
face: {
type: String,
default: null,
},
timestamp: {
type: Number,
default: 0,
},
isoLocal: {
type: String,
default: "",
},
timeZone: {
type: String,
default: "",
},
lat: {
type: Number,
default: 0,
},
lon: {
type: Number,
default: 0,
},
alt: {
type: Number,
default: 0,
},
address: {
type: String,
default: null,
},
battery: {
type: Number,
default: null,
},
speed: {
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.
*
* @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>

138
src/components/LHeatmap.vue Normal file
View File

@@ -0,0 +1,138 @@
<template>
<div />
</template>
<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>`
// methods are being used and why `mapObject` has to be named `mapObject`.
import { findRealParent, propsBinder } from "vue2-leaflet";
import L, { DomEvent } from "leaflet";
import "leaflet.heat";
const props = {
latLng: {
type: Array,
default: () => [],
custom: false,
},
minOpacity: {
type: Number,
custom: true,
default: 0.05,
},
maxZoom: {
type: Number,
custom: true,
default: 18,
},
radius: {
type: Number,
custom: true,
default: 25,
},
blur: {
type: Number,
custom: true,
default: 15,
},
max: {
type: Number,
custom: true,
default: 1.0,
},
gradient: {
type: Object,
custom: true,
default: null,
},
visible: {
type: Boolean,
custom: true,
default: true,
},
activated: {
type: Boolean,
custom: true,
default: true,
},
};
export default {
props,
mounted() {
const options = {};
if (this.minOpacity) {
options.minOpacity = this.minOpacity;
}
if (this.maxZoom) {
options.maxZoom = this.maxZoom;
}
if (this.radius) {
options.radius = this.radius;
}
if (this.blur) {
options.blur = this.blur;
}
if (this.max) {
options.max = this.max;
}
if (this.gradient) {
options.gradient = this.gradient;
}
this.mapObject = L.heatLayer(this.latLng, options);
DomEvent.on(this.mapObject, this.$listeners);
propsBinder(this, this.mapObject, props);
this.parentContainer = findRealParent(this.$parent);
this.parentContainer.addLayer(this, !this.visible);
this.$watch(
"latLng",
(newVal) => {
this.mapObject.setLatLngs(newVal);
},
{ deep: true }
);
},
beforeDestroy() {
this.parentContainer.removeLayer(this);
},
methods: {
setMinOpacity(minOpacity) {
this.mapObject.setOptions({ minOpacity });
},
setMaxZoom(maxZoom) {
this.mapObject.setOptions({ maxZoom });
},
setRadius(radius) {
this.mapObject.setOptions({ radius });
},
setBlur(blur) {
this.mapObject.setOptions({ blur });
},
setMax(max) {
this.mapObject.setOptions({ max });
},
setGradient(gradient) {
this.mapObject.setOptions({ gradient });
},
setVisible(newVal, oldVal) {
if (newVal === oldVal) return;
if (newVal) {
this.parentContainer.addLayer(this);
} else {
this.parentContainer.removeLayer(this);
}
},
addLatLng(value) {
this.mapObject.addLatLng(value);
},
},
};
</script>
<style scoped>
div {
display: none;
}
</style>

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

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

98
src/config.js Normal file
View File

@@ -0,0 +1,98 @@
import deepmerge from "deepmerge";
const endDateTime = new Date();
endDateTime.setHours(23, 59, 59, 0);
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,
},
ignorePingLocation: true,
locale: "en-US",
map: {
attribution:
'&copy; <a href="https://osm.org/copyright">OpenStreetMap</a> contributors',
circle: {
color: null,
fillColor: null,
fillOpacity: 0.2,
},
circleMarker: {
color: null,
fillColor: "#fff",
fillOpacity: 1,
radius: 4,
},
controls: {
scale: {
display: true,
imperial: true,
maxWidth: 200,
metric: true,
position: "bottomleft",
},
zoom: {
display: true,
position: "topleft",
},
},
heatmap: {
blur: 15,
gradient: null,
max: 20,
radius: 25,
},
layers: {
heatmap: false,
last: true,
line: true,
poi: true,
points: false,
},
maxNativeZoom: 19,
maxPointDistance: null,
maxZoom: 21,
poiMarker: {
color: "red",
fillColor: "red",
fillOpacity: 0.2,
radius: 12,
},
polyline: {
color: null,
fillColor: "transparent",
},
tileSize: 256,
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
zoomOffset: 0,
},
onLocationChange: {
fitView: false,
reloadHistory: false,
},
primaryColor: "#3f51b5",
router: {
basePath: "/",
},
selectedDevice: null,
selectedUser: null,
showDistanceTravelled: true,
startDateTime,
verbose: false,
};
// Use deepmerge to combine the default and user-defined configuration.
// This enables the user to use a fairly small config object which only
// needs to contain actual changes, not all default values - and these
// stay up-to-date automatically.
// There might not be a user-defined config, default to an empty object.
export default deepmerge(DEFAULT_CONFIG, (window.owntracks || {}).config || {});

8
src/constants.js Normal file
View File

@@ -0,0 +1,8 @@
// 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.
export const EARTH_RADIUS_IN_KM = 6371;

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"
}

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

@@ -0,0 +1,48 @@
{
"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",
"Show points of interest": "Zeige Sehenswürdigkeiten",
"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:",
"Select date": "Datum auswählen",
"Select time": "Uhrzeit auswählen",
"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"
}

48
src/locales/tr-TR.json Normal file
View File

@@ -0,0 +1,48 @@
{
"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",
"Show points of interest": "İlgi çekici noktaları 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",
"Select date": "Tarih seç",
"Select time": "Saat seç",
"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"
}

53
src/logging.js Normal file
View File

@@ -0,0 +1,53 @@
import config from "@/config";
export const logLevels = {
INFO: "INFO",
WARNING: "WARNING",
ERROR: "ERROR",
};
/* eslint-disable no-console */
const logFunctions = {
[logLevels.INFO]: console.info,
[logLevels.WARNING]: console.warn,
[logLevels.ERROR]: console.error,
};
/* eslint-enable no-console */
const logColors = {
[logLevels.INFO]: "#0d66ba",
[logLevels.WARNING]: "#cf8429",
[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);
return;
}
if (level !== logLevels.ERROR && !config.verbose) {
return;
}
const css = `
background: ${logColors[level]};
border-radius: 5px;
color: #fff;
padding: 3px;
`;
const logFunc = logFunctions[level];
logFunc(
`%c${label}`,
css,
typeof message === "function" ? message() : message
);
};

33
src/main.js Normal file
View File

@@ -0,0 +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";
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),
}).$mount("#app");

18
src/router.js Normal file
View File

@@ -0,0 +1,18 @@
import Vue from "vue";
import Router from "vue-router";
import config from "@/config";
import Map from "@/views/Map.vue";
Vue.use(Router);
export default new Router({
mode: "history",
base: config.router.basePath,
routes: [
{
path: "/",
name: "map",
component: Map,
},
],
});

274
src/store/actions.js Normal file
View File

@@ -0,0 +1,274 @@
import * as types from "@/store/mutation-types";
import * as api from "@/api";
import config from "@/config";
import { log } from "@/logging";
import {
distanceBetweenCoordinates,
isIsoDateTime,
getLocationHistoryCount,
} from "@/util";
/**
* Populate the state from URL query parameters.
*
* @param {QueryParams} query URL query parameters
*/
const populateStateFromQuery = ({ state, commit }, query) => {
if (query.lat && !isNaN(parseFloat(query.lat))) {
commit(types.SET_MAP_CENTER, {
lat: query.lat,
lng: parseFloat(state.map.center.lng),
});
}
if (query.lng && !isNaN(parseFloat(query.lng))) {
commit(types.SET_MAP_CENTER, {
lat: parseFloat(state.map.center.lat),
lng: query.lng,
});
}
if (query.zoom && !isNaN(parseInt(query.zoom))) {
commit(types.SET_MAP_ZOOM, parseInt(query.zoom));
}
if (query.start && isIsoDateTime(query.start)) {
commit(types.SET_START_DATE_TIME, query.start);
}
if (query.end && isIsoDateTime(query.end)) {
commit(types.SET_END_DATE_TIME, query.end);
}
if (query.user) {
commit(types.SET_SELECTED_USER, query.user);
}
if (query.device) {
commit(types.SET_SELECTED_DEVICE, query.device);
}
if (query.layers) {
const activeLayers = query.layers.split(",");
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 });
}
});
}
};
/**
* Trigger loading of all required data: users, devices, last locations,
* location history, version and initiate WebSocket connection.
*/
const loadData = async ({ dispatch }) => {
await dispatch("getUsers");
await dispatch("getDevices");
await dispatch("getLastLocations");
await dispatch("getLocationHistory");
await dispatch("getRecorderVersion");
await dispatch("connectWebsocket");
};
/**
* Reload last locations and location history. Will be called when
* start date, end date, selected user or selected device changes.
*/
const reloadData = async ({ dispatch }) => {
await dispatch("getLastLocations");
await dispatch("getLocationHistory");
};
/**
* Connect to WebSocket to receive live location updates. When an update is
* received, reload last locations and location history depending on config.
*/
const connectWebsocket = async ({ dispatch }) => {
api.connectWebsocket(async () => {
// TODO: keep cards from HTTP API response in the Vuex store so we
// can use the data from the WebSocket location update (which does
// not contain card information) and don't have to poll the API.
await dispatch("getLastLocations");
if (config.onLocationChange.reloadHistory) {
await dispatch("getLocationHistory");
}
});
};
/**
* Load user names.
*/
const getUsers = async ({ commit }) => {
commit(types.SET_USERS, await api.getUsers());
};
/**
* Load devices names of all users.
*/
const getDevices = async ({ commit, state }) => {
commit(types.SET_DEVICES, await api.getDevices(state.users));
};
/**
* Load last location of the selected user/device.
*/
const getLastLocations = async ({ commit, state }) => {
let lastLocations = await api.getLastLocations(
state.selectedUser,
state.selectedDevice
);
if (config.ignorePingLocation) {
// 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")
);
}
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.
*/
const getLocationHistory = async ({ commit, state }) => {
commit(types.SET_IS_LOADING, true);
let devices;
if (state.selectedUser) {
if (state.selectedDevice) {
devices = { [state.selectedUser]: [state.selectedDevice] };
} else {
devices = { [state.selectedUser]: state.devices[state.selectedUser] };
}
} else {
devices = state.devices;
}
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);
}
};
/**
* Load the OwnTracks recorder version.
*/
const getRecorderVersion = async ({ commit }) => {
commit(types.SET_RECORDER_VERSION, await api.getVersion());
};
/**
* Set the selected user and reload the location history.
*
* @param {User} user Name of the new selected user
*/
const setSelectedUser = async ({ commit, dispatch }, user) => {
commit(types.SET_SELECTED_DEVICE, null);
commit(types.SET_SELECTED_USER, user);
await dispatch("reloadData");
};
/**
* Set the selected device and reload the location history.
*
* @param {Device} device Name of the new selected device
*/
const setSelectedDevice = async ({ commit, dispatch }, device) => {
commit(types.SET_SELECTED_DEVICE, device);
await dispatch("reloadData");
};
/**
* Set the start date and time for loading data and reload the location history.
*
* @param {String} startDateTime Start date and time in UTC for loading data
*/
const setStartDateTime = async ({ commit, dispatch }, startDateTime) => {
commit(types.SET_START_DATE_TIME, startDateTime);
await dispatch("reloadData");
};
/**
* Set the end date and time for loading data and reload the location history.
*
* @param {String} endDateTime End date and time in UTC for loading data
*/
const setEndDateTime = async ({ commit, dispatch }, endDateTime) => {
commit(types.SET_END_DATE_TIME, endDateTime);
await dispatch("reloadData");
};
export default {
populateStateFromQuery,
loadData,
reloadData,
connectWebsocket,
getUsers,
getDevices,
getLastLocations,
getLocationHistory,
getRecorderVersion,
setSelectedUser,
setSelectedDevice,
setStartDateTime,
setEndDateTime,
};

99
src/store/getters.js Normal file
View File

@@ -0,0 +1,99 @@
import L from "leaflet";
import config from "@/config";
import { distanceBetweenCoordinates } from "@/util";
/**
* Apply filters to the selected users' and devices' location histories.
*
* @param {State} state
* @param {LocationHistory} state.locationHistory
* Location history of selected users and devices
* @returns {LocationHistory} Filtered location history
*/
const filteredLocationHistory = (state) => {
const locationHistory = {};
Object.keys(state.locationHistory).forEach((user) => {
locationHistory[user] = {};
Object.keys(state.locationHistory[user]).forEach((device) => {
locationHistory[user][device] = [];
state.locationHistory[user][device].forEach((location) => {
if (
config.filters.minAccuracy !== null &&
location.acc > config.filters.minAccuracy
)
return;
locationHistory[user][device].push(location);
});
});
});
return locationHistory;
};
/**
* From the selected users' and devices' location histories, create an
* array of all coordinates.
*
* @param {State} state
* @returns {L.LatLng[]} All coordinates
*/
const filteredLocationHistoryLatLngs = (state) => {
const latLngs = [];
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));
});
});
});
return latLngs;
};
/**
* From the selected users' and devices' location histories, create an
* array of coordinate groups where the distance between two subsequent
* coordinates does not exceed `config.map.maxPointDistance`.
*
* @param {State} state
* @returns {L.LatLng[][]} Groups of coherent coordinates
*/
const filteredLocationHistoryLatLngGroups = (state) => {
const groups = [];
const locationHistory = filteredLocationHistory(state);
Object.keys(locationHistory).forEach((user) => {
Object.keys(locationHistory[user]).forEach((device) => {
let latLngs = [];
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 (
typeof config.map.maxPointDistance === "number" &&
config.map.maxPointDistance > 0 &&
latLngs.length > 0
) {
const lastLatLng = latLngs.slice(-1)[0];
if (
distanceBetweenCoordinates(lastLatLng, latLng) >
config.map.maxPointDistance
) {
// Distance is too far, start new group of coordinate
groups.push(latLngs);
latLngs = [];
}
}
// Add coordinate to current active group
latLngs.push(latLng);
});
groups.push(latLngs);
});
});
return groups;
};
export default {
filteredLocationHistory,
filteredLocationHistoryLatLngs,
filteredLocationHistoryLatLngGroups,
};

41
src/store/index.js Normal file
View File

@@ -0,0 +1,41 @@
import Vue from "vue";
import Vuex from "vuex";
import getters from "@/store/getters";
import mutations from "@/store/mutations";
import actions from "@/store/actions";
import config from "@/config";
Vue.use(Vuex);
export default new Vuex.Store({
state: {
isLoading: false,
frontendVersion: import.meta.env.PACKAGE_VERSION,
recorderVersion: "",
users: [],
devices: {},
lastLocations: [],
locationHistory: {},
selectedUser: config.selectedUser,
selectedDevice: config.selectedUser !== null ? config.selectedDevice : null,
// Convert to UTC and get rid of milliseconds
startDateTime: config.startDateTime.toISOString().slice(0, 19),
endDateTime: config.endDateTime.toISOString().slice(0, 19),
map: {
center: {
lat: 0,
lng: 0,
},
zoom: 19,
layers: config.map.layers,
},
distanceTravelled: 0,
elevationGain: 0,
elevationLoss: 0,
requestAbortController: null,
},
getters,
mutations,
actions,
});

View File

@@ -0,0 +1,17 @@
export const SET_IS_LOADING = "SET_IS_LOADING";
export const SET_RECORDER_VERSION = "SET_RECORDER_VERSION";
export const SET_USERS = "SET_USERS";
export const SET_DEVICES = "SET_DEVICES";
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_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";

55
src/store/mutations.js Normal file
View File

@@ -0,0 +1,55 @@
import * as types from "@/store/mutation-types";
export default {
[types.SET_IS_LOADING](state, isLoading) {
state.isLoading = isLoading;
},
[types.SET_RECORDER_VERSION](state, version) {
state.recorderVersion = version;
},
[types.SET_USERS](state, users) {
state.users = users;
},
[types.SET_DEVICES](state, devices) {
state.devices = devices;
},
[types.SET_LAST_LOCATIONS](state, lastLocations) {
state.lastLocations = lastLocations;
},
[types.SET_LOCATION_HISTORY](state, locationHistory) {
state.locationHistory = locationHistory;
},
[types.SET_SELECTED_USER](state, selectedUser) {
state.selectedUser = selectedUser;
},
[types.SET_SELECTED_DEVICE](state, selectedDevice) {
state.selectedDevice = selectedDevice;
},
[types.SET_START_DATE_TIME](state, startDateTime) {
state.startDateTime = startDateTime;
},
[types.SET_END_DATE_TIME](state, endDateTime) {
state.endDateTime = endDateTime;
},
[types.SET_MAP_CENTER](state, center) {
state.map.center = center;
},
[types.SET_MAP_ZOOM](state, zoom) {
state.map.zoom = zoom;
},
[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;
},
};

371
src/styles/_base.scss Normal file
View File

@@ -0,0 +1,371 @@
* {
padding: 0;
margin: 0;
box-sizing: border-box;
}
:root {
--color-text: #333;
--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 {
height: 100%;
}
body {
font-family: "Noto Sans", sans-serif;
font-size: 14px;
color: var(--color-text);
}
a {
color: var(--color-primary);
}
ul {
list-style: inside;
}
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: top;
&:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.2);
}
&::before {
border: 2px solid var(--color-primary);
border-radius: 4px;
content: "";
display: block;
height: 16px;
width: 16px;
}
&:checked::before {
background: var(--color-primary);
}
&:checked::after {
border-bottom: 2px solid var(--color-primary-text);
border-right: 2px solid var(--color-primary-text);
content: "";
display: inline-block;
height: 10px;
left: 7px;
position: absolute;
top: 3px;
transform: rotate(45deg);
width: 4px;
}
& + label {
cursor: pointer;
}
}
pre {
background: #000;
border-radius: 3px;
color: #ddd;
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;
overflow-x: auto;
code {
display: block;
margin: 20px;
}
}
#app {
display: flex;
min-height: 100%;
flex-direction: column;
header {
display: flex;
padding: 20px;
white-space: nowrap;
color: var(--color-primary-text);
background: var(--color-primary);
&.header-sm {
padding: 10px;
.header-item:not(.nav-sm) .nav-item:not(:first-child) {
margin-left: 10px;
}
}
.header-item {
display: flex;
align-items: center;
&:not(:first-child) {
margin-left: 20px;
}
&-grow {
flex: 1;
}
&-right {
margin-left: auto !important;
}
.feather {
font-size: 20px;
}
.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;
}
}
}
}
}
main {
flex: 1;
position: relative;
}
ul.info-list {
list-style: none;
li {
// https://stackoverflow.com/a/17158366/5952681
margin-left: 25px;
text-indent: -25px;
.feather {
font-size: 16px;
margin-right: 8px;
}
a {
font-weight: bold;
text-decoration: none;
}
& + li {
margin-top: 15px;
}
}
}
}
.button {
cursor: pointer;
color: var(--color-text);
background: var(--color-background);
border: 0;
border-radius: 18px;
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);
}
&.button-outline {
border: 1px solid var(--color-background);
color: var(--color-primary-text);
background: transparent;
&.button-primary {
border-color: var(--color-primary);
color: var(--color-text);
}
}
&.button-flat {
color: var(--color-primary-text);
background: transparent;
&.button-primary {
color: var(--color-text);
}
}
&.button-outline,
&.button-flat {
transition:
background-color 0.2s,
box-shadow 0.2s;
&:hover,
&:focus {
background: rgba(0, 0, 0, 0.2);
}
}
&.button-icon {
padding: 8px;
}
}
.dropdown {
display: inline-block;
position: relative;
}
// Not nested so it works as the button alone
.dropdown-button {
appearance: none;
background-image: var(--dropdown-arrow);
background-repeat: no-repeat;
background-position-x: right;
background-position-y: center;
padding-right: 30px;
}
.dropdown-body {
position: absolute;
margin-top: 12px;
padding: 8px 0;
border-radius: 3px;
color: var(--color-text);
background: var(--color-background);
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.2);
}
}
}
.feather {
vertical-align: middle;
}

View File

@@ -0,0 +1,45 @@
.mx-datepicker {
width: 280px;
.mx-input {
border: 0;
border-radius: 18px;
text-align: center;
height: 33px;
padding-right: 0px;
padding-left: 0px;
}
}
@media screen and (max-width: 400px) {
.mx-datepicker-main {
display: flex;
top: 0 !important;
left: 0 !important;
width: 100%;
height: 100%;
.mx-datepicker-sidebar {
flex: 0.7;
}
.mx-datepicker-content {
display: flex;
flex-direction: column;
flex: 1;
margin-left: 0px;
}
}
}
.mx-time {
width: 100%;
}
.toggle-date-btn {
margin-right: 10px;
}
.mx-icon-calendar {
display: none;
}

91
src/styles/_map.scss Normal file
View File

@@ -0,0 +1,91 @@
.leaflet-container {
position: absolute;
z-index: 0;
.leaflet-popup {
filter: var(--drop-shadow);
&--for-pin {
margin-bottom: calc(var(--pin-width) * 1.5 + 20px);
}
.leaflet-popup-content-wrapper {
border-radius: 3px;
box-shadow: none;
.leaflet-popup-content {
margin: 30px;
.face {
width: 40px;
border-radius: 3px;
}
}
}
a.leaflet-popup-close-button {
color: inherit;
display: flex;
justify-content: center;
align-items: center;
width: 30px;
height: 30px;
margin-top: 15px;
margin-right: 15px;
border-radius: 100%;
transition: background-color 0.2s;
&:hover,
&:focus {
background: rgba(0, 0, 0, 0.2);
color: inherit;
}
}
}
.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);
}
}
}
}
}

20
src/styles/_modal.scss Normal file
View File

@@ -0,0 +1,20 @@
.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);
}
}

4
src/styles/main.scss Normal file
View File

@@ -0,0 +1,4 @@
@import "base";
@import "datepicker";
@import "map";
@import "modal";

100
src/util.js Normal file
View File

@@ -0,0 +1,100 @@
import moment from "moment";
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
* @returns {URL} Final API URL
*/
export const getApiUrl = (path) => {
const normalizedBaseUrl = config.api.baseUrl.endsWith("/")
? 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-DDTHH:MM:SS datetime.
*
* @param {String} s Input value to be tested
* @returns {Boolean} Whether the input matches the expected format
*/
export const isIsoDateTime = (s) => moment(s, DATE_TIME_FORMAT, true).isValid();
/**
* Convert degrees to radians.
*
* @param {Number} degrees Angle in degrees
* @returns {Number} Angle in radians
*/
export const degreesToRadians = (degrees) => (degrees * Math.PI) / 180;
/**
* Calculate the distance between two coordinates. Uses the haversine formula,
* which is not 100% accurate - but that's not the goal here.
*
* https://en.wikipedia.org/wiki/Haversine_formula
*
* @param {Coordinate} c1 First coordinate
* @param {Coordinate} c2 Second coordinate
* @returns {Number} Distance in meters
*/
export const distanceBetweenCoordinates = (c1, c2) => {
const r = EARTH_RADIUS_IN_KM * 1000;
const phi1 = degreesToRadians(c1.lat);
const phi2 = degreesToRadians(c2.lat);
const lambda1 = degreesToRadians(c1.lng);
const lambda2 = degreesToRadians(c2.lng);
const d =
2 *
r *
Math.asin(
Math.sqrt(
Math.sin((phi2 - phi1) / 2) ** 2 +
Math.cos(phi1) *
Math.cos(phi2) *
Math.sin((lambda2 - lambda1) / 2) ** 2
)
);
return d;
};
/**
* Format a distance in meters into a human-readable string with unit.
*
* 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 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);

271
src/views/Map.vue Normal file
View File

@@ -0,0 +1,271 @@
<template>
<LMap
ref="map"
:center="map.center"
:zoom="map.zoom"
:options="{ zoomControl: false }"
@update:center="setMapCenter"
@update:zoom="setMapZoom"
>
<LControlZoom
v-if="controls.zoom.display"
:position="controls.zoom.position"
/>
<LControlScale
v-if="controls.scale.display"
:position="controls.scale.position"
:max-width="controls.scale.maxWidth"
:metric="controls.scale.metric"
:imperial="controls.scale.imperial"
/>
<LTileLayer
:url="url"
:attribution="attribution"
: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"
:key="`${l.topic}-circle`"
:lat-lng="[l.lat, l.lon]"
:radius="l.acc"
v-bind="circle"
/>
<LMarker
v-for="l in lastLocations"
:key="`${l.topic}-marker`"
:lat-lng="[l.lat, l.lon]"
:icon="markerIcon"
>
<LDeviceLocationPopup
:user="l.username"
:device="l.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 }"
:options="{ className: 'leaflet-popup--for-pin', maxWidth: 400 }"
:address="l.addr"
/>
</LMarker>
</template>
<template v-if="map.layers.heatmap">
<LHeatmap
v-if="filteredLocationHistoryLatLngs.length"
:lat-lng="filteredLocationHistoryLatLngs"
:max="heatmap.max"
:radius="heatmap.radius"
:blur="heatmap.blur"
:gradient="heatmap.gradient"
/>
</template>
</LMap>
</template>
<script>
import { mapGetters, mapState, mapMutations } from "vuex";
import L from "leaflet";
import {
LMap,
LTileLayer,
LControlScale,
LControlZoom,
LMarker,
LCircleMarker,
LCircle,
LPolyline,
LTooltip,
} from "vue2-leaflet";
import "leaflet/dist/leaflet.css";
import * as types from "@/store/mutation-types";
import LCustomMarker from "@/components/LCustomMarker";
import LHeatmap from "@/components/LHeatmap.vue";
import LDeviceLocationPopup from "@/components/LDeviceLocationPopup.vue";
export default {
components: {
LMap,
LTileLayer,
LControlScale,
LControlZoom,
LMarker,
LCircleMarker,
LCircle,
LPolyline,
LDeviceLocationPopup,
LHeatmap,
LTooltip,
},
data() {
return {
attribution: this.$config.map.attribution,
center: this.$store.state.map.center,
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: {
...this.$config.map.circle,
color: this.$config.map.circle.color || this.$config.primaryColor,
fillColor:
this.$config.map.circle.fillColor || this.$config.primaryColor,
},
circleMarker: {
...this.$config.map.circleMarker,
color: this.$config.map.circleMarker.color || this.$config.primaryColor,
},
poiMarker: this.$config.map.poiMarker,
polyline: {
...this.$config.map.polyline,
color: this.$config.map.polyline.color || this.$config.primaryColor,
},
};
},
computed: {
...mapGetters([
"filteredLocationHistory",
"filteredLocationHistoryLatLngs",
"filteredLocationHistoryLatLngGroups",
]),
...mapState(["lastLocations", "map"]),
},
watch: {
lastLocations() {
if (this.$config.onLocationChange.fitView) {
this.fitView();
}
},
filteredLocationHistory() {
this.fitView();
},
},
mounted() {
this.$root.$on("fitView", () => {
this.fitView();
});
},
methods: {
...mapMutations({
setMapCenter: types.SET_MAP_CENTER,
setMapZoom: types.SET_MAP_ZOOM,
}),
/**
* Fit all objects on the map into view.
*/
fitView() {
if (
(this.map.layers.line ||
this.map.layers.points ||
this.map.layers.poi ||
this.map.layers.heatmap) &&
this.filteredLocationHistoryLatLngs.length > 0
) {
this.$refs.map.mapObject.fitBounds(
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));
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,80 +0,0 @@
(() => {
const props = {
user: {
type: String,
default: '',
},
device: {
type: String,
default: '',
},
name: {
type: String,
default: '',
},
face: {
type: String,
default: null,
},
timestamp: {
type: Number,
default: 0,
},
lat: {
type: Number,
default: 0,
},
lon: {
type: Number,
default: 0,
},
alt: {
type: Number,
default: 0,
},
address: {
type: String,
default: null,
},
battery: {
type: Number,
default: null,
},
speed: {
type: Number,
default: null,
},
};
const { LPopup } = Vue2Leaflet;
Vue.component('location-popup', {
template: `
<l-popup>
<img v-if="face" class="location-popup-face" :src="faceImageDataURI">
<b v-if="name">{{ name }}</b>
<b v-else>{{ user }}/{{ device }}</b>
<div class="location-popup-detail">
<span class="mdi mdi-16px mdi-calendar-clock"></span> {{ new Date(timestamp * 1000).toLocaleString() }}
</div>
<div class="location-popup-detail">
<span class="mdi mdi-16px mdi-crosshairs-gps"></span> {{ lat }}, {{ lon }}, {{ alt }}m
</div class="location-popup-detail">
<div v-if="address" class="location-popup-detail">
<span class="mdi mdi-16px mdi-map-marker"></span> {{ address }}
</div>
<div v-if="typeof battery === 'number'" class="location-popup-detail">
<span class="mdi mdi-16px mdi-battery"></span> {{ battery }} %
</div>
<div v-if="typeof battery === 'number'" class="location-popup-detail">
<span class="mdi mdi-16px mdi-speedometer"></span> {{ speed }} km/h
</div>
</l-popup>
`,
components: { LPopup },
props,
computed: {
faceImageDataURI() {
return `data:image/png;base64,${this.face}`;
},
},
});
})();

View File

@@ -1,18 +0,0 @@
Vue.component('modal', {
template: `
<div class="modal" v-show="visible" @click.self="$emit('close')">
<div class="modal-container">
<button class="modal-close-button" title="Close" @click="$emit('close')">
&times;
</button>
<slot></slot>
</div>
</div>
`,
props: {
visible: {
type: Boolean,
default: false,
},
},
});

View File

@@ -1,152 +0,0 @@
(() => {
const capitalizeFirstLetter = (string) => {
return string.charAt(0).toUpperCase() + string.slice(1);
}
const propsBinder = (vueElement, leafletElement, props) => {
for (const key in props) {
const setMethodName = 'set' + capitalizeFirstLetter(key);
const deepValue = (props[key].type === Object) ||
(props[key].type === Array) ||
(Array.isArray(props[key].type));
if (props[key].custom && vueElement[setMethodName]) {
vueElement.$watch(key, (newVal, oldVal) => {
vueElement[setMethodName](newVal, oldVal);
}, {
deep: deepValue
});
} else if (setMethodName === 'setOptions') {
vueElement.$watch(key, (newVal, oldVal) => {
L.setOptions(leafletElement, newVal);
}, {
deep: deepValue
});
} else if (leafletElement[setMethodName]) {
vueElement.$watch(key, (newVal, oldVal) => {
leafletElement[setMethodName](newVal);
}, {
deep: deepValue
});
}
}
};
const { findRealParent, L } = Vue2Leaflet;
const props = {
latLng: {
type: Array,
custom: false,
default: () => []
},
minOpacity: {
type: Number,
custom: true,
default: 0.05
},
maxZoom: {
type: Number,
custom: true,
default: 18
},
radius: {
type: Number,
custom: true,
default: 25
},
blur: {
type: Number,
custom: true,
default: 15
},
max: {
type: Number,
custom: true,
default: 1.0
},
gradient: {
type: Object,
custom: true,
default: () => ({
0.4: 'blue',
0.6: 'cyan',
0.7: 'lime',
0.8: 'yellow',
1.0: 'red'
})
},
visible: {
type: Boolean,
custom: true,
default: true
}
};
Vue.component('l-heatmap', {
props,
template: '<div></div>',
mounted() {
const options = {};
if (this.minOpacity) {
options.minOpacity = this.minOpacity;
}
if (this.maxZoom) {
options.maxZoom = this.maxZoom;
}
if (this.radius) {
options.radius = this.radius;
}
if (this.blur) {
options.blur = this.blur;
}
if (this.max) {
options.max = this.max;
}
if (this.gradient) {
options.gradient = this.gradient;
}
this.mapObject = L.heatLayer(this.latLng, options);
L.DomEvent.on(this.mapObject, this.$listeners);
propsBinder(this, this.mapObject, props);
this.$watch('latLng', (newVal, _) => {
this.mapObject.setLatLngs(newVal);
}, { deep: true });
this.parentContainer = findRealParent(this.$parent);
this.parentContainer.addLayer(this, !this.visible);
},
beforeDestroy() {
this.parentContainer.removeLayer(this);
},
methods: {
setMinOpacity(newVal) {
this.mapObject.setOptions({ minOpacity: newVal });
},
setMaxZoom(newVal) {
this.mapObject.setOptions({ maxZoom: newVal });
},
setRadius(newVal) {
this.mapObject.setOptions({ radius: newVal });
},
setBlur(newVal) {
this.mapObject.setOptions({ blur: newVal });
},
setMax(newVal) {
this.mapObject.setOptions({ max: newVal });
},
setGradient(newVal) {
this.mapObject.setOptions({ gradient: newVal });
},
setVisible(newVal, oldVal) {
if (newVal === oldVal) return;
if (newVal) {
this.parentContainer.addLayer(this);
} else {
this.parentContainer.removeLayer(this);
}
},
addLatLng(value) {
this.mapObject.addLatLng(value);
}
}
});
})();

View File

@@ -1,2 +0,0 @@
// Here you can overwite values from default.js
window.config = {};

View File

@@ -1,27 +0,0 @@
(() => {
const endDate = new Date();
endDate.setUTCHours(0);
endDate.setUTCMinutes(0);
endDate.setUTCSeconds(0);
const startDate = new Date(endDate);
startDate.setUTCMonth(startDate.getMonth()-1);
window.defaultConfig = {
accentColor: '#3388ff',
startDate,
endDate,
map: {
center: L.latLng(0, 0),
zoom: 19,
maxNativeZoom: 19,
maxZoom: 21,
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '&copy; <a href="https://osm.org/copyright">OpenStreetMap</a> contributors',
heatmap: {
max: 20,
radius: 25,
blur: 15,
gradient: null, // https://github.com/mourner/simpleheat/blob/gh-pages/simpleheat.js#L22
},
},
};
})();

View File

@@ -1,218 +0,0 @@
(() => {
const { LMap, LTileLayer, LMarker, LCircleMarker, LCircle, LPolyline } = Vue2Leaflet;
const config = deepmerge(window.defaultConfig, window.config);
new Vue({
el: '#app',
components: { vuejsDatepicker, LMap, LTileLayer, LMarker, LCircleMarker, LPolyline, LCircle },
data: {
users: [],
devices: {},
lastLocations: [],
locationHistory: {},
showLastLocations: true,
showLocationHistoryPoints: false,
showLocationHistoryLine: false,
showLocationHeatmap: false,
selectedUser: '',
selectedDevice: '',
startDate: config.startDate,
endDate: config.endDate,
showDownloadModal: false,
showInformationModal: false,
map: {
center: config.map.center,
zoom: config.map.zoom,
maxNativeZoom: config.map.maxNativeZoom,
maxZoom: config.map.maxZoom,
url: config.map.url,
attribution: config.map.attribution,
polyline: {
color: config.accentColor,
fillColor: 'transparent',
},
circle: {
color: config.accentColor,
fillColor: config.accentColor,
fillOpacity: 0.2,
},
circleMarker: {
radius: 4,
color: config.accentColor,
fillColor: '#fff',
fillOpacity: 1,
},
heatmap: {
max: config.map.heatmap.max,
radius: config.map.heatmap.radius,
blur: config.map.heatmap.radius,
gradient: config.map.heatmap.gradient,
},
},
information: {
ownTracks: {
version: '',
documentationUrl: 'https://owntracks.org/booklet/',
sourceCodeUrl: 'https://github.com/owntracks/recorder',
twitterUrl: 'https://twitter.com/OwnTracks',
},
ownTracksUi: {
sourceCodeUrl: 'https://github.com/linusg/owntracks-ui',
},
}
},
watch: {
selectedUser: async function () {
this.selectedDevice = '';
this.lastLocations = await this.getLastLocations();
this.locationHistory = await this.getLocationHistory();
},
selectedDevice: async function () {
this.lastLocations = await this.getLastLocations();
this.locationHistory = await this.getLocationHistory();
},
startDate: async function () {
this.locationHistory = await this.getLocationHistory();
},
endDate: async function () {
this.locationHistory = await this.getLocationHistory();
},
},
computed: {
locationHistoryLatLngs() {
const latLngs = [];
Object.keys(this.locationHistory).forEach((user) => {
Object.keys(this.locationHistory[user]).forEach((device) => {
this.locationHistory[user][device].forEach((l) => {
latLngs.push(L.latLng(l.lat, l.lon));
});
});
});
return latLngs;
},
startDateDisabledDates() {
return {
customPredictor: (date) => (date > this.endDate) || (date > new Date())
};
},
endDateDisabledDates() {
return {
customPredictor: (date) => (date < this.startDate) || (date > new Date())
};
},
},
methods: {
init: async function () {
const root = document.documentElement;
root.style.setProperty('--color-accent', config.accentColor);
this.users = await this.getUsers();
this.devices = await this.getDevices();
this.lastLocations = await this.getLastLocations();
this.locationHistory = await this.getLocationHistory();
this.centerView();
await this.connectWebsocket();
this.information.ownTracks.version = await this.getVersion();
},
connectWebsocket: async function () {
const wsUrl = `${document.location.protocol.replace('http', 'ws')}//${document.location.host}/ws/last`;
const ws = new WebSocket(wsUrl);
console.log(`[WS] Connecting to ${wsUrl}...`);
ws.onopen = (e) => {
console.log('[WS] Connected');
ws.send('LAST');
};
ws.onclose = () => {
console.log('[WS] Disconnected. Reconnecting in one second...')
setTimeout(this.connectWebsocket, 1000);
};
ws.onmessage = async (msg) => {
if (msg.data) {
try {
const data = JSON.parse(msg.data);
if (data._type === 'location') {
console.log('[WS] Location update received');
this.lastLocations = await this.getLastLocations();
this.locationHistory = await this.getLocationHistory();
}
} catch (err) {}
} else {
console.log('[WS] Ping');
}
};
},
getVersion: async function () {
const response = await fetch('/api/0/version');
const json = await response.json();
const version = json.version;
return version;
},
getUsers: async function () {
const response = await fetch('/api/0/list');
const json = await response.json();
const users = json.results;
return users;
},
getDevices: async function () {
const devices = {};
await Promise.all(this.users.map(async (user) => {
const response = await fetch(`/api/0/list?user=${user}`);
const json = await response.json();
const userDevices = json.results;
devices[user] = userDevices;
}));
return devices;
},
getLastLocations: async function () {
let url = '/api/0/last';
if (this.selectedUser !== '') {
url += `?&user=${this.selectedUser}`;
if (this.selectedDevice !== '') {
url += `&device=${this.selectedDevice}`;
}
}
const response = await fetch(url);
const json = await response.json();
return json;
},
getLocationHistory: async function () {
let users;
let devices;
if (this.selectedUser === '') {
users = this.users;
devices = { ...this.devices };
} else {
users = [this.selectedUser];
if (this.selectedDevice === '') {
devices = { [this.selectedUser]: this.devices[this.selectedUser] };
} else {
devices = { [this.selectedUser]: [this.selectedDevice] };
}
}
const locations = {};
await Promise.all(users.map(async (user) => {
locations[user] = {};
await Promise.all(devices[user].map(async (device) => {
const startDateString = `${this.startDate.toISOString().split('T')[0]}T00:00:00`;
const endDateString = `${this.endDate.toISOString().split('T')[0]}T23:59:59`;
const url = `/api/0/locations?from=${startDateString}&to=${endDateString}&format=json&user=${user}&device=${device}`;
const response = await fetch(url);
const json = await response.json();
const userDeviceLocations = json.data;
locations[user][device] = userDeviceLocations;
}));
}));
return locations;
},
centerView() {
if ((this.showLocationHistoryPoints || this.showLocationHistoryLine || this.showLocationHeatmap) && this.locationHistoryLatLngs.length > 0) {
this.$refs.map.mapObject.fitBounds(new L.LatLngBounds(this.locationHistoryLatLngs));
} else if (this.showLastLocations && this.lastLocations.length > 0) {
const locations = this.lastLocations.map((l) => L.latLng(l.lat, l.lon));
this.$refs.map.mapObject.fitBounds(new L.LatLngBounds(locations), {maxZoom: this.map.maxNativeZoom});
}
},
},
mounted() {
this.init();
},
});
})();

View File

@@ -1,270 +0,0 @@
* {
padding: 0;
margin: 0;
box-sizing: border-box;
}
:root {
--color-text: #333;
--color-background: #fff;
--color-accent: #3388ff;
--color-accent-text: #fff;
--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");
}
html, body {
height: 100%;
}
body {
font-family: "Noto Sans", sans-serif;
font-size: 13px;
color: var(--color-text);
}
a {
color: var(--color-accent);
}
ul {
list-style: inside;
}
#app {
display: flex;
min-height: 100%;
flex-direction: column;
}
#app > header {
display: flex;
padding: 20px;
white-space: nowrap;
overflow-x: auto;
color: var(--color-accent-text);
background: var(--color-accent);
}
#app > header > nav {
display: flex;
flex: 1;
}
#app > header > nav:not(:first-child) {
margin-left: 20px;
}
#app > header > nav.nav-shrink {
flex: 0 1 auto;
}
#app > header > nav .nav-item {
display: inline-block;
}
#app > header > nav .nav-item:not(:first-child) {
margin-left: 20px;
}
#app > main {
flex: 1;
/* https://github.com/linusg/owntracks-ui/issues/6 */
display: flex;
}
.button,
.vdp-datepicker input {
cursor: pointer;
color: var(--color-text);
background: var(--color-background);
border: 0;
border-radius: 18px;
padding: 8px 16px;
}
.button-outline {
border: 1px solid var(--color-background);
color: var(--color-accent-text);
background: transparent;
}
.button-flat {
color: var(--color-accent-text);
background: transparent;
}
.button-icon {
padding: 8px 0;
}
.dropdown {
display: inline-block;
}
.dropdown-button,
.vdp-datepicker input {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-image: var(--dropdown-arrow);
background-repeat: no-repeat;
background-position-x: right;
background-position-y: center;
padding-right: 30px;
}
.dropdown-body {
display: none;
position: absolute;
margin-top: 12px;
padding: 8px 0;
border-radius: 3px;
color: var(--color-text);
background: var(--color-background);
filter: var(--drop-shadow);
z-index: 2000;
}
.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;
}
.dropdown-body label {
cursor: pointer;
display: block;
padding: 8px 15px;
}
.dropdown-body label:hover {
background: rgba(0, 0, 0, 0.1);
}
.dropdown-body label input[type=checkbox] {
position: relative;
top: 2px;
}
.modal {
position: absolute;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.4);
filter: var(--drop-shadow);
z-index: 4000;
}
.modal .modal-container {
min-width: 300px;
padding: 20px;
border-radius: 3px;
background: var(--color-background);
}
.modal .modal-container .modal-close-button {
display: block;
border: none;
float: right;
font-size: 24px;
line-height: 16px;
background: transparent;
cursor: pointer;
}
.location-popup-face {
border-radius: 50%;
border: 2px solid var(--color-background);
position: absolute;
top: -12px;
left: 50%;
transform: translateX(-50%);
}
.location-popup-detail {
white-space: nowrap;
}
.leaflet-container {
/* https://github.com/linusg/owntracks-ui/issues/6 */
height: auto !important;
}
.leaflet-container .leaflet-popup {
filter: var(--drop-shadow);
}
.leaflet-container .leaflet-popup .leaflet-popup-content-wrapper {
border-radius: 3px;
box-shadow: none;
}
.leaflet-container .leaflet-popup a.leaflet-popup-close-button {
padding: 5px 5px 0 0;
}
.leaflet-popup-tip-container .leaflet-popup-tip {
box-shadow: none;
}
.vdp-datepicker {
position: static !important;
display: inline-block;
white-space: initial;
overflow: initial;
z-index: 3000;
}
.vdp-datepicker input {
width: 120px;
}
.vdp-datepicker .vdp-datepicker__calendar {
color: var(--color-text);
border: 0;
border-radius: 3px;
z-index: 4000;
margin-top: 12px;
filter: var(--drop-shadow);
}
.vdp-datepicker .vdp-datepicker__calendar .cell:not(.blank):not(.disabled).day:hover,
.vdp-datepicker .vdp-datepicker__calendar .cell:not(.blank):not(.disabled).month:hover,
.vdp-datepicker .vdp-datepicker__calendar .cell:not(.blank):not(.disabled).year:hover {
border-color: var(--color-accent);
}
.vdp-datepicker .vdp-datepicker__calendar .cell.selected,
.vdp-datepicker .vdp-datepicker__calendar .cell.selected:hover {
background: var(--color-accent);
color: var(--color-accent-text);
}
header .mdi {
position: relative;
top: 5px;
margin-right: 3px;
}
header .button .mdi {
line-height: 0;
}
.mdi-16px.mdi-set,
.mdi-16px.mdi::before {
font-size: 16px;
}

229
tests/api.test.js Normal file
View File

@@ -0,0 +1,229 @@
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(() => {
fetchMocker.enableMocks();
fetchMocker.resetMocks();
});
test("getVersion", async () => {
fetchMocker.mockResponse(JSON.stringify({ version: "1.2.3" }));
const version = await api.getVersion();
expect(version).toBe("1.2.3");
expect(fetchMocker.mock.calls.length).toEqual(1);
expect(fetchMocker.mock.calls[0][0]).toEqual(
"http://localhost:3000/api/0/version"
);
});
test("getUsers", async () => {
fetchMocker.mockResponse(JSON.stringify({ results: ["foo", "bar"] }));
const users = await api.getUsers();
expect(users).toEqual(["foo", "bar"]);
expect(fetchMocker.mock.calls.length).toEqual(1);
expect(fetchMocker.mock.calls[0][0]).toEqual(
"http://localhost:3000/api/0/list"
);
});
test("getDevices", async () => {
fetchMocker.mockResponses(
[JSON.stringify({ results: ["phone", "tablet"] })],
[JSON.stringify({ results: ["laptop"] })]
);
const devices = await api.getDevices(["foo", "bar"]);
expect(devices).toEqual({ foo: ["phone", "tablet"], bar: ["laptop"] });
expect(fetchMocker.mock.calls.length).toEqual(2);
expect(fetchMocker.mock.calls[0][0]).toEqual(
"http://localhost:3000/api/0/list?user=foo"
);
expect(fetchMocker.mock.calls[1][0]).toEqual(
"http://localhost:3000/api/0/list?user=bar"
);
});
test("getLastLocations", async () => {
const response = [
{
_type: "location",
tid: "pp",
lat: 51.47879,
lon: -0.010677,
tst: 0,
_http: true,
topic: "owntracks/ping/ping",
username: "ping",
device: "ping",
ghash: "gcpuzg2",
isotst: "1970-01-01T00:00:00Z",
disptst: "1970-01-01 00:00:00",
},
];
fetchMocker.mockResponse(JSON.stringify(response));
const lastLocation = await api.getLastLocations();
expect(lastLocation).toEqual(response);
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 () => {
const response = [
// Other properties not relevant for testing
{
username: "foo",
device: "phone",
},
{
username: "foo",
device: "tablet",
},
];
fetchMocker.mockResponse(JSON.stringify(response));
const lastLocation = await api.getLastLocations("foo");
expect(lastLocation).toEqual(response);
expect(fetchMocker.mock.calls.length).toEqual(1);
expect(fetchMocker.mock.calls[0][0]).toEqual(
"http://localhost:3000/api/0/last?user=foo"
);
});
test("getLastLocations with user and device", async () => {
const response = [
// Other properties not relevant for testing
{
username: "foo",
device: "phone",
},
];
fetchMocker.mockResponse(JSON.stringify(response));
const lastLocation = await api.getLastLocations("foo", "phone");
expect(lastLocation).toEqual(response);
expect(fetchMocker.mock.calls.length).toEqual(1);
expect(fetchMocker.mock.calls[0][0]).toEqual(
"http://localhost:3000/api/0/last?user=foo&device=phone"
);
});
test("getUserDeviceLocationHistory", async () => {
const response = {
count: 1,
data: [
{
batt: 100,
lon: -0.010677,
acc: 20,
bs: 1,
vac: 10,
topic: "owntracks/foo/phone",
lat: 51.47879,
conn: "w",
tst: 1568841029,
alt: 31,
_type: "location",
tid: "AD",
_http: true,
ghash: "gcpv4k9",
isorcv: "2019-09-18T21:10:29Z",
isotst: "2019-09-18T21:10:29Z",
disptst: "2019-09-18 21:10:29",
},
],
status: 200,
};
fetchMocker.mockResponse(JSON.stringify(response));
const locationHistory = await api.getUserDeviceLocationHistory(
"foo",
"phone",
"1970-01-01T00:00:00",
"1970-12-31T23:59:59"
);
expect(locationHistory).toEqual(response.data);
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 () => {
fetchMocker.mockResponses(
[
JSON.stringify({
count: 1,
data: [
{
topic: "owntracks/foo/phone",
},
],
status: 200,
}),
],
[
JSON.stringify({
count: 1,
data: [
{
topic: "owntracks/foo/tablet",
},
],
status: 200,
}),
],
[
JSON.stringify({
count: 1,
data: [
{
topic: "owntracks/bar/laptop",
},
],
status: 200,
}),
]
);
const locationHistory = await api.getLocationHistory(
{ foo: ["phone", "tablet"], bar: ["laptop"] },
"1970-01-01T00:00:00",
"1970-12-31T23:59:59"
);
expect(locationHistory).toEqual({
foo: {
phone: [{ topic: "owntracks/foo/phone" }],
tablet: [{ topic: "owntracks/foo/tablet" }],
},
bar: { laptop: [{ topic: "owntracks/bar/laptop" }] },
});
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(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(fetchMocker.mock.calls[2][0]).toEqual(
"http://localhost:3000/api/0/locations?from=1970-01-01T00%3A00%3A00&to=1970-12-31T23%3A59%3A59&user=bar&device=laptop&format=json"
);
});
});

1
tests/setup.js Normal file
View File

@@ -0,0 +1 @@
require("jest-fetch-mock").enableMocks();

133
tests/util.test.js Normal file
View File

@@ -0,0 +1,133 @@
import { describe, expect, test } from "vitest";
import config from "@/config";
import {
getApiUrl,
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: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", () => {
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"
);
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("isIsoDateTime", () => {
test("no match", () => {
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(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);
});
});
describe("degreesToRadians", () => {
test("expected results", () => {
expect(degreesToRadians(0)).toBe(0);
expect(degreesToRadians(45)).toBe(0.7853981633974483);
expect(degreesToRadians(90)).toBe(1.5707963267948966);
expect(degreesToRadians(180)).toBe(3.141592653589793);
expect(degreesToRadians(360)).toBe(6.283185307179586);
expect(degreesToRadians(-180)).toBe(-3.141592653589793);
});
});
describe("distanceBetweenCoordinates", () => {
test("expected results", () => {
expect(
distanceBetweenCoordinates({ lat: 0, lng: 0 }, { lat: 0, lng: 0 })
).toBe(0);
// The Shard - Victoria Memorial
expect(
distanceBetweenCoordinates(
{ lat: 51.5046678, lng: -0.0870769 },
{ lat: 51.501752, lng: -0.1408258 }
)
// 3.74km according to Google Maps
).toBe(3734.3632679046705);
// Gatwick Airport - Heathrow Airport
expect(
distanceBetweenCoordinates(
{ lat: 51.1526929, lng: -0.1752475 },
{ lat: 51.4720694, lng: -0.4499871 }
)
// 40km according to Google Maps
).toBe(40321.457586930104);
// Berlin - San Francisco
expect(
distanceBetweenCoordinates(
{ lat: 52.5067614, lng: 13.284651 },
{ lat: 37.7576948, lng: -122.4726193 }
)
// 9,102.73km according to Google Maps
).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",
},
});