Initial import of sotlas-frontend v1.9.2

qrz
Manuel Kasper 2020-08-12 13:16:16 +02:00
commit 9b795bd6fd
156 zmienionych plików z 48426 dodań i 0 usunięć

5
.editorconfig 100644
Wyświetl plik

@ -0,0 +1,5 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true

22
.gitignore vendored 100644
Wyświetl plik

@ -0,0 +1,22 @@
.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?
src/compiled-icons

2
.npmrc 100644
Wyświetl plik

@ -0,0 +1,2 @@
@fortawesome:registry=https://npm.fontawesome.com/
//npm.fontawesome.com/:_authToken=$NPM_FONTAWESOME_TOKEN

29
README.md 100644
Wyświetl plik

@ -0,0 +1,29 @@
# sotlas
## Project setup
```
npm install
```
### Compiles and hot-reloads for development
```
npm run serve
```
### Compiles and minifies for production
```
npm run build
```
### Run your tests
```
npm run test
```
### Lints and fixes files
```
npm run lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

5
babel.config.js 100644
Wyświetl plik

@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/app'
]
}

20607
package-lock.json wygenerowano 100644

Plik diff jest za duży Load Diff

94
package.json 100644
Wyświetl plik

@ -0,0 +1,94 @@
{
"name": "sotlas",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint",
"generate-icons": "vsvg -s ./svg-icons -t ./src/compiled-icons",
"deploy": "gzip -fk9 dist/js/*.js dist/css/*.css && rsync -av dist/* root@vs5.ksys.ch:/data/www",
"deploy:beta": "gzip -fk9 dist/js/*.js dist/css/*.css && rsync -av --delete dist/* root@vs5.ksys.ch:/data/www/beta"
},
"dependencies": {
"@dsb-norge/vue-keycloak-js": "github:manuelkasper/vue-keycloak-js#sotlas",
"@fortawesome/fontawesome-svg-core": "^1.2.25",
"@fortawesome/free-brands-svg-icons": "^5.11.2",
"@fortawesome/free-solid-svg-icons": "^5.11.2",
"@fortawesome/pro-regular-svg-icons": "^5.11.2",
"@fortawesome/pro-solid-svg-icons": "^5.11.2",
"@fortawesome/vue-fontawesome": "^0.1.7",
"@mapbox/mapbox-gl-draw": "github:manuelkasper/mapbox-gl-draw#sotlas",
"@mapbox/togeojson": "^0.16.0",
"@tmcw/togeojson": "^3.2.0",
"axios": "^0.19.0",
"buefy": "^0.8.15",
"cheap-ruler": "^2.5.1",
"core-js": "^2.6.10",
"filepond": "^4.13.1",
"filepond-plugin-file-validate-type": "^1.2.5",
"flagpack": "^1.0.4",
"frappe-charts": "^1.3.0",
"haversine-distance": "^1.1.6",
"maidenhead": "^1.0.7",
"mapbox-gl": "^1.8.1",
"moment": "^2.24.0",
"node-vincenty": "0.0.6",
"photoswipe": "^4.1.3",
"togpx": "^0.5.4",
"vue": "^2.6.10",
"vue-clipboard2": "^0.3.1",
"vue-debounce": "^2.5.0",
"vue-filepond": "^6.0.2",
"vue-infinite-loading": "^2.4.4",
"vue-lazy-youtube-video": "^2.0.0",
"vue-mapbox": "github:manuelkasper/vue-mapbox#fix-layer-remove",
"vue-match-media": "^1.0.3",
"vue-native-websocket": "^2.0.13",
"vue-router": "^3.1.3",
"vuedraggable": "^2.23.2",
"vuex": "^3.1.1"
},
"devDependencies": {
"@vue/cli-plugin-babel": "^3.12.1",
"@vue/cli-plugin-eslint": "^3.12.1",
"@vue/cli-service": "^3.12.1",
"@vue/eslint-config-standard": "^4.0.0",
"babel-eslint": "^10.0.3",
"eslint": "^5.16.0",
"eslint-plugin-vue": "^5.2.3",
"git-revision-webpack-plugin": "^3.0.4",
"node-sass": "^4.13.0",
"sass-loader": "^7.3.1",
"vue-svgicon": "^3.2.6",
"vue-template-compiler": "^2.6.10"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"@vue/standard"
],
"rules": {},
"parserOptions": {
"parser": "babel-eslint"
},
"globals": {
"VERSION": true,
"COMMITHASH": true,
"BRANCH": true
}
},
"postcss": {
"plugins": {
"autoprefixer": {}
}
},
"browserslist": [
"> 1%",
"last 2 versions"
]
}

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 2.4 KiB

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 10 KiB

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 2.4 KiB

Wyświetl plik

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#da532c</TileColor>
</tile>
</msapplication>
</browserconfig>

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 724 B

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 959 B

BIN
public/favicon.ico 100644

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 15 KiB

37
public/index.html 100644
Wyświetl plik

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="apple-touch-icon" sizes="180x180" href="<%= BASE_URL %>apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="<%= BASE_URL %>favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="<%= BASE_URL %>favicon-16x16.png">
<link rel="manifest" href="<%= BASE_URL %>site.webmanifest">
<link rel="mask-icon" href="<%= BASE_URL %>safari-pinned-tab.svg" color="#5bbad5">
<meta name="msapplication-TileColor" content="#da532c">
<meta name="theme-color" content="#ffffff">
<title>SOTLAS</title>
</head>
<body class="has-navbar-fixed-top is-size-6 is-size-7-mobile">
<noscript>
<p>SOTLAS (SOTA Atlas) is an online map of Summits On the Air (SOTA) summits with detailed information about summits, activators, spots and alerts.</p>
<strong>We're sorry but SOTLAS doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="oldbrowser" style="display: none; margin: 1em">You are using a browser that is too old to load this website. Please upgrade to a more modern browser.</div>
<script type="text/javascript">
function checkBrowser() {
try {
eval("var bar = (x) => x+1");
} catch (e) { return false; }
return true;
}
if (!checkBrowser()) {
document.getElementById('oldbrowser').style.display = 'block';
}
</script>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 1.9 KiB

Wyświetl plik

@ -0,0 +1,122 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="1152.000000pt" height="1152.000000pt" viewBox="0 0 1152.000000 1152.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,1152.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M5269 11516 c-2 -2 -49 -7 -104 -11 -55 -3 -111 -8 -125 -9 -14 -2
-56 -7 -95 -11 -38 -4 -77 -8 -85 -10 -8 -1 -40 -6 -70 -10 -30 -4 -64 -9 -75
-11 -35 -7 -69 -13 -110 -20 -22 -3 -44 -7 -50 -9 -13 -3 -76 -15 -95 -19 -8
-2 -26 -6 -40 -9 -14 -4 -36 -9 -50 -12 -44 -9 -103 -23 -185 -44 -44 -12 -93
-24 -110 -27 -16 -3 -38 -10 -47 -15 -10 -5 -18 -7 -18 -3 0 3 -7 1 -15 -6 -8
-7 -15 -10 -15 -7 0 4 -57 -14 -127 -39 -71 -25 -132 -47 -138 -48 -12 -4 -39
-14 -85 -34 -19 -8 -40 -16 -47 -18 -28 -6 -411 -201 -423 -215 -3 -3 -16 -12
-30 -19 -63 -33 -266 -174 -337 -233 -83 -70 -136 -118 -199 -181 -62 -62 -96
-100 -188 -206 -75 -87 -217 -297 -277 -410 -18 -36 -38 -68 -44 -72 -5 -4 -6
-8 -1 -8 5 0 4 -6 -2 -12 -29 -36 -113 -267 -152 -417 -36 -136 -43 -167 -55
-251 -2 -19 -7 -46 -9 -60 -18 -93 -29 -266 -29 -470 1 -197 8 -318 29 -450 3
-19 7 -44 8 -55 3 -22 19 -117 21 -125 1 -3 5 -18 9 -35 23 -97 83 -289 99
-320 6 -11 12 -25 13 -31 1 -7 13 -35 28 -63 14 -28 23 -51 20 -51 -3 0 2 -7
10 -16 9 -8 16 -22 16 -30 0 -8 5 -14 10 -14 6 0 10 -6 10 -14 0 -7 9 -25 21
-40 11 -14 18 -26 14 -26 -3 0 -1 -6 5 -12 6 -7 32 -43 58 -79 51 -72 91 -126
107 -143 6 -6 31 -36 56 -66 83 -98 265 -271 368 -350 18 -14 38 -30 45 -36
23 -21 243 -164 306 -198 92 -51 345 -174 405 -198 27 -11 66 -26 85 -35 139
-60 359 -135 615 -208 92 -26 474 -125 588 -151 9 -2 25 -6 35 -8 28 -7 195
-47 217 -52 22 -6 53 -12 88 -18 12 -2 55 -12 96 -21 40 -9 80 -19 90 -20 9
-2 16 -4 16 -5 0 -1 11 -3 25 -6 27 -5 111 -21 150 -30 14 -3 39 -8 55 -11 46
-8 60 -11 60 -13 0 -1 9 -3 20 -4 11 -1 37 -6 57 -10 21 -4 40 -7 43 -7 2 0
19 -5 37 -10 18 -5 53 -12 77 -15 25 -3 48 -7 52 -10 6 -3 135 -32 224 -49 60
-12 565 -129 645 -150 61 -15 270 -67 290 -71 20 -4 171 -45 215 -58 6 -2 24
-6 40 -11 231 -60 663 -236 888 -362 31 -17 57 -29 57 -25 0 4 4 3 8 -3 4 -5
27 -21 51 -34 131 -71 328 -218 444 -330 37 -36 72 -67 77 -69 6 -2 10 -8 10
-13 0 -6 16 -27 36 -47 21 -20 70 -82 111 -137 40 -55 78 -105 83 -112 31 -39
150 -293 150 -322 0 -9 4 -21 9 -27 19 -21 66 -236 87 -394 13 -106 19 -389
10 -485 -3 -22 -7 -72 -10 -110 -4 -39 -9 -84 -12 -100 -19 -114 -28 -157 -45
-224 -11 -42 -21 -83 -23 -91 -10 -38 -50 -146 -59 -157 -5 -7 -7 -13 -3 -13
4 0 -1 -15 -11 -32 -9 -18 -18 -35 -18 -38 -4 -25 -124 -233 -137 -238 -4 -2
-8 -8 -8 -13 0 -15 -100 -153 -188 -259 -45 -53 -209 -220 -272 -274 -81 -70
-231 -178 -339 -243 -58 -35 -113 -68 -121 -73 -26 -16 -234 -117 -267 -130
-18 -7 -33 -17 -33 -23 0 -6 -2 -8 -5 -5 -3 3 -41 -8 -83 -24 -91 -35 -247
-87 -282 -94 -38 -9 -41 -10 -90 -23 -25 -7 -52 -15 -60 -16 -67 -15 -272 -57
-299 -61 -18 -3 -43 -8 -55 -10 -40 -8 -199 -31 -273 -39 -106 -12 -143 -15
-258 -22 -238 -13 -655 -3 -902 22 -29 3 -79 7 -110 10 -32 3 -76 8 -98 12
-22 3 -53 7 -70 9 -36 3 -97 12 -145 20 -19 3 -44 7 -55 9 -81 13 -102 17
-119 22 -11 3 -34 7 -50 9 -25 2 -211 42 -276 58 -80 20 -232 65 -270 78 -8 3
-51 18 -95 33 -164 57 -448 187 -596 272 -134 78 -364 233 -389 262 -3 3 -27
24 -55 46 -48 38 -277 264 -323 318 -12 15 -22 32 -22 37 0 6 -4 10 -8 10 -17
0 -234 308 -247 351 -4 10 -11 19 -16 19 -6 0 -8 4 -4 9 3 5 1 12 -5 16 -5 3
-33 54 -63 113 -45 90 -72 154 -127 297 -10 26 -50 156 -54 175 -2 8 -6 26 -9
40 -4 14 -9 36 -12 50 -2 14 -8 41 -13 60 -5 19 -9 38 -10 43 -3 17 -12 74
-17 102 -3 17 -8 50 -10 75 -3 25 -7 61 -10 80 -12 86 -18 231 -19 430 l0 216
-339 0 -339 0 4 -223 c1 -123 5 -223 8 -223 3 0 6 -28 8 -63 1 -34 4 -80 7
-102 3 -22 8 -62 11 -90 8 -83 26 -197 48 -320 15 -78 71 -302 86 -340 4 -11
20 -58 35 -105 68 -208 162 -407 293 -620 26 -41 49 -77 52 -80 3 -3 11 -17
19 -33 8 -15 18 -27 23 -27 4 0 8 -5 8 -10 0 -10 37 -67 51 -80 3 -3 18 -23
34 -45 17 -22 32 -42 36 -45 3 -3 12 -15 20 -26 60 -89 359 -393 479 -489 41
-33 81 -66 89 -73 19 -18 266 -183 311 -208 19 -10 37 -21 40 -24 5 -5 32 -20
160 -87 47 -24 89 -49 95 -54 5 -5 15 -9 21 -9 7 0 48 -16 91 -36 176 -82 407
-165 593 -215 41 -11 100 -27 130 -35 30 -8 64 -17 75 -19 11 -3 56 -14 100
-24 72 -18 310 -68 360 -76 17 -2 78 -12 188 -30 50 -9 82 -13 142 -20 32 -4
68 -9 80 -11 11 -2 47 -6 80 -9 33 -2 78 -7 100 -9 43 -6 113 -12 290 -23 237
-15 790 -8 965 12 19 2 60 6 90 9 30 4 66 8 80 10 14 3 45 7 70 10 25 4 56 8
70 11 14 2 68 11 120 20 52 9 97 18 100 20 3 2 21 6 40 9 59 9 339 75 450 106
169 47 317 97 415 140 22 9 60 25 85 36 56 23 406 200 423 214 6 5 62 42 122
81 108 71 255 177 286 208 9 9 38 34 65 56 78 63 272 262 357 365 106 129 291
399 289 420 0 3 5 12 12 20 15 18 71 133 67 139 -1 2 1 7 6 10 16 11 115 282
128 350 2 13 7 31 10 41 3 10 8 27 10 37 3 10 9 36 14 58 6 22 12 56 16 75 3
19 7 46 10 60 12 67 17 99 20 140 2 25 4 45 5 45 1 0 5 38 8 85 14 171 6 622
-12 715 -2 11 -6 41 -10 66 -29 202 -84 406 -154 574 -38 92 -125 251 -195
357 -106 161 -181 252 -322 393 -112 112 -144 140 -230 205 -33 25 -62 47 -65
50 -3 3 -34 24 -70 47 -36 24 -70 47 -77 53 -7 5 -15 10 -18 10 -4 0 -26 14
-50 30 -24 17 -48 30 -53 30 -6 0 -12 4 -14 9 -3 9 -98 63 -208 118 -41 21
-77 41 -78 46 -2 4 -8 5 -13 1 -5 -3 -9 0 -9 5 0 6 -3 10 -7 9 -5 -1 -39 13
-78 31 -38 18 -92 42 -120 53 -27 11 -63 25 -80 33 -88 38 -323 118 -485 164
-58 16 -126 36 -152 44 -27 8 -48 13 -48 11 0 -3 -13 2 -30 11 -16 8 -31 14
-32 13 -3 -2 -92 20 -118 29 -8 3 -22 7 -30 9 -26 5 -144 36 -160 41 -8 4 -21
7 -29 9 -50 10 -120 27 -141 34 -22 7 -106 26 -150 34 -8 2 -22 6 -30 9 -8 3
-49 13 -90 21 -41 9 -106 23 -145 32 -38 8 -81 17 -95 19 -14 2 -70 13 -125
24 -55 12 -111 23 -125 26 -14 3 -45 10 -70 15 -25 5 -56 12 -70 14 -14 2 -59
11 -100 20 -41 8 -86 17 -100 20 -14 2 -32 7 -40 10 -8 3 -31 8 -50 11 -66 11
-76 14 -285 61 -11 2 -33 7 -50 10 -16 2 -46 9 -65 14 -19 5 -46 11 -60 14
-14 2 -119 25 -234 51 -115 26 -216 48 -225 50 -38 8 -217 55 -301 79 -30 9
-62 18 -70 20 -33 8 -307 99 -335 111 -16 7 -86 34 -155 60 -148 57 -296 127
-409 192 -45 27 -92 53 -104 60 -12 7 -31 19 -42 27 -11 8 -56 40 -100 71 -93
66 -193 153 -285 246 -105 106 -200 219 -200 237 0 6 -4 12 -9 14 -16 6 -136
210 -130 220 1 2 -7 21 -18 43 -11 22 -26 58 -32 80 -7 22 -16 49 -21 60 -9
22 -23 72 -25 95 -1 8 -6 26 -10 40 -5 14 -9 34 -11 45 -1 11 -8 46 -14 78 -6
32 -13 85 -14 116 -2 32 -5 62 -8 67 -12 23 0 413 17 534 7 54 18 116 22 135
3 11 6 29 8 40 6 34 14 66 20 85 3 10 8 27 10 37 10 48 62 187 100 268 35 76
139 255 167 290 5 6 32 42 60 80 84 112 257 295 348 365 25 19 50 38 55 43 45
36 129 95 173 121 30 17 75 44 100 59 59 35 279 145 352 175 47 20 266 99 295
106 6 1 30 8 55 15 25 7 52 15 60 17 8 2 51 12 95 23 44 10 97 22 119 25 21 4
42 8 46 11 4 2 27 7 51 9 24 3 54 7 65 9 42 10 161 28 214 32 19 1 55 5 80 9
48 6 106 12 235 22 88 8 489 7 600 -1 97 -6 203 -15 245 -19 19 -3 62 -7 95
-11 33 -3 74 -8 90 -11 45 -8 97 -15 128 -18 15 -2 30 -6 34 -10 3 -3 18 -6
32 -6 14 0 76 -12 136 -26 61 -14 117 -27 126 -29 220 -49 597 -175 754 -252
168 -83 241 -123 345 -192 47 -31 90 -58 95 -60 6 -2 62 -46 125 -97 171 -137
322 -292 438 -448 29 -39 59 -78 65 -86 57 -72 216 -365 271 -500 15 -36 34
-79 42 -97 8 -17 14 -40 14 -50 0 -11 3 -23 8 -27 4 -4 7 -11 7 -14 1 -4 9
-30 18 -58 10 -28 20 -59 22 -70 2 -10 9 -37 15 -59 17 -67 50 -227 55 -270 3
-22 8 -58 11 -80 3 -22 8 -60 10 -85 5 -49 16 -183 18 -215 1 -19 10 -19 336
-19 l335 0 -1 69 c0 66 -10 194 -19 260 -3 17 -7 48 -9 70 -8 68 -16 119 -21
145 -3 14 -7 39 -10 55 -21 123 -88 393 -105 426 -6 10 -8 19 -6 19 6 0 -64
204 -83 245 -5 11 -26 58 -47 105 -20 47 -40 89 -45 94 -5 6 -9 18 -9 28 0 10
-4 18 -9 18 -5 0 -13 10 -17 22 -3 11 -27 58 -53 102 -26 45 -54 92 -62 106
-7 14 -16 27 -19 30 -15 15 -50 71 -50 80 0 5 -4 10 -10 10 -5 0 -10 6 -10 14
0 8 -3 16 -7 18 -10 4 -73 85 -73 92 0 3 -8 14 -17 24 -10 10 -38 43 -62 73
-56 68 -291 303 -349 350 -24 18 -54 42 -65 53 -12 10 -51 39 -87 64 -36 26
-67 49 -70 52 -8 9 -158 110 -163 110 -5 0 -102 62 -122 78 -17 13 -355 182
-355 177 0 -2 -22 7 -49 20 -27 14 -54 25 -60 25 -6 0 -19 5 -29 10 -24 15
-242 90 -242 84 0 -3 -9 0 -21 6 -11 6 -28 13 -37 15 -9 2 -56 15 -104 29 -49
13 -88 22 -88 19 0 -3 -6 -1 -12 4 -11 9 -45 18 -103 29 -5 1 -13 3 -17 4 -5
1 -12 4 -18 5 -5 1 -11 3 -12 5 -4 2 -24 6 -72 14 -21 3 -41 8 -45 10 -3 2
-22 7 -41 10 -19 3 -46 8 -60 11 -34 8 -117 23 -160 29 -19 3 -44 8 -56 10
-12 2 -43 7 -70 10 -27 4 -65 9 -84 11 -19 2 -57 7 -85 11 -27 3 -61 7 -75 9
-14 2 -63 6 -110 10 -47 3 -107 8 -135 11 -59 6 -721 15 -726 10z"/>
</g>
</svg>

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 8.5 KiB

Wyświetl plik

@ -0,0 +1,19 @@
{
"name": "SOTLAS - SOTA Atlas",
"short_name": "SOTLAS",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

File diff suppressed because one or more lines are too long

BIN
public/sprites.png 100644

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 77 KiB

File diff suppressed because one or more lines are too long

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 120 KiB

28
src/App.vue 100644
Wyświetl plik

@ -0,0 +1,28 @@
<template>
<div id="app">
<NavBar />
<keep-alive include="Map">
<router-view />
</keep-alive>
</div>
</template>
<script>
import NavBar from './components/NavBar.vue'
export default {
components: { NavBar }
}
</script>
<style lang="scss">
@import "~bulma/sass/utilities/_all";
$link: $blue;
@import "~bulma";
@import "~buefy/src/scss/buefy";
@import "~flagpack/dist/flagpack.css";
@import '~mapbox-gl/dist/mapbox-gl.css';
@import '~@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css'
</style>

Wyświetl plik

@ -0,0 +1,160 @@
.nowrap {
white-space: nowrap;
}
.section {
padding-top: 1.5rem;
padding-bottom: 1.5rem;
}
.hero-body {
margin-top: 0.5rem;
padding-bottom: 1.5rem;
}
.title.is-6 {
margin-top: 1em;
margin-bottom: 0;
}
.subdued {
font-size: 0.8em;
color: #7a7a7a;
}
.subtitle {
margin-top: 0.2em;
line-height: 1.5em;
}
.clickable {
cursor: pointer;
}
.auto-width .table {
width: auto !important;
}
.th-wrap .is-invisible {
display: none;
}
.message-body .icon svg {
height: 2.5rem;
}
@media (max-width: 768px) {
.message {
font-size: 0.75rem;
}
.message-body {
padding: 0.5em;
}
.message-body .icon {
height: 1.25rem;
width: 1.5rem;
}
.message-body .icon svg {
height: 1rem;
}
.message-body .media-left {
margin-right: 0.3rem;
}
.section {
padding-top: 1rem;
padding-bottom: 1rem;
}
.level-left + .level-right {
margin-top: 1rem;
}
.level:not(:last-child) {
margin-bottom: 1rem !important;
}
.title.is-4 {
font-size: 1.25rem;
}
.title:not(:last-child).is-4 {
margin-bottom: 0.75rem !important;
}
}
@media (max-width: 1023px) {
/* Leave enough space so user can press outside menu to dismiss */
.dropdown.is-mobile-modal .dropdown-menu {
width: calc(100vw - 120px);
}
}
.hero-head {
background-color: #ddd;
}
.hero .navbar-item.is-active {
background-color: whitesmoke !important;
}
.hero-body {
padding-top: 0.75rem;
}
h4 > .icon {
margin-left: 0.3rem;
margin-right: 0.7rem;
opacity: 0.5;
vertical-align: top;
}
.date-small {
font-size: 80%;
color: #777;
margin-right: 0.3em;
min-width: 2em;
display: inline-block;
}
.time-weekday {
font-size: 80%;
color: #777;
margin-left: 0.3em;
}
@media (min-width: 769px) {
.table .tag {
vertical-align: top;
position: relative;
top: 0.15em;
}
}
.fp.unknown {
background-color: #ddd;
}
hr {
margin: 0.75rem 0;
}
.action-button {
font-weight: normal;
}
@media (max-width: 768px) {
.action-button {
margin-left: 0.5rem;
float: right;
}
}
@media (min-width: 768px) {
.action-button {
margin-left: 1rem;
display: inline-block;
}
.action-button button {
vertical-align: baseline;
}
}
.modal-card {
max-height: calc(90vh - 80px);
}
.modal-card-head {
padding: 15px 30px 15px 15px;
}
.modal-card-foot {
justify-content: flex-end;
}
/* Fix to keep Iceland flag from appearing in pagination buttons */
.pagination .icon.is {
background-image: none;
}
/* Fix for scale text selection */
.mapboxgl-ctrl-scale {
user-select: none;
}
/* Fix Buefy bug */
@media screen and (max-width: 1023px) {
.dropdown.is-mobile-modal>.dropdown-menu>.dropdown-content>div>a {
padding: 1rem 1.5rem;
}
}

BIN
src/assets/hikr.png 100644

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 1.3 KiB

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 5.1 KiB

BIN
src/assets/sota.mp3 100644

Plik binarny nie jest wyświetlany.

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 1.5 KiB

Wyświetl plik

@ -0,0 +1,124 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="120.58959mm"
height="19.343578mm"
viewBox="0 0 120.58959 19.343578"
version="1.1"
id="svg8"
inkscape:version="0.92.2 5c3e80d, 2017-08-06"
sodipodi:docname="sotlas.svg">
<defs
id="defs2">
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath5259">
<use
x="0"
y="0"
xlink:href="#g5255"
id="use5261"
width="100%"
height="100%" />
</clipPath>
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1.4"
inkscape:cx="185.55186"
inkscape:cy="66.811867"
inkscape:document-units="mm"
inkscape:current-layer="text12"
showgrid="false"
inkscape:window-width="2560"
inkscape:window-height="1329"
inkscape:window-x="0"
inkscape:window-y="1"
inkscape:window-maximized="1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-78.247876,-11.392425)">
<g
aria-label="SOTLAS"
style="font-style:normal;font-weight:normal;font-size:10.58333302px;line-height:1.25;font-family:sans-serif;letter-spacing:0.63764584px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.26458332"
id="text12">
<path
d="m 120.40386,17.005825 q -0.0508,-1.1684 -0.508,-2.032 -0.4318,-0.8636 -1.1938,-1.4478 -0.762,-0.5842 -1.8288,-0.8636 -1.0414,-0.3048 -2.286,-0.3048 -0.762,0 -1.651,0.1778 -0.8636,0.1778 -1.6256,0.635 -0.7366,0.4318 -1.2192,1.1938 -0.4826,0.7366 -0.4826,1.8542 0,1.0922 0.5334,1.778 0.5334,0.6858 1.397,1.1176 0.8636,0.4064 1.9812,0.6604 1.1176,0.254 2.2606,0.4826 1.1684,0.2286 2.2606,0.5588 1.1176,0.3048 1.9812,0.8636 0.889,0.5334 1.4224,1.4224 0.5334,0.8636 0.5334,2.1844 0,1.4224 -0.6096,2.3876 -0.6096,0.9652 -1.5494,1.5748 -0.9144,0.6096 -2.0574,0.8636 -1.1176,0.2794 -2.159,0.2794 -1.6002,0 -2.9972,-0.3556 -1.397,-0.3302 -2.4384,-1.0922 -1.0414,-0.7874 -1.651,-2.0066 -0.5842,-1.2192 -0.5588,-2.9718 h 1.1176 q -0.0762,1.4986 0.4318,2.54 0.508,1.016 1.397,1.6764 0.9144,0.6604 2.1336,0.9652 1.2192,0.2794 2.5654,0.2794 0.8128,0 1.7272,-0.2032 0.9398,-0.2032 1.7018,-0.6858 0.7874,-0.4826 1.2954,-1.27 0.5334,-0.7874 0.5334,-1.9812 0,-1.143 -0.5334,-1.8542 -0.5334,-0.7366 -1.4224,-1.1684 -0.8636,-0.4572 -1.9812,-0.7112 -1.0922,-0.2794 -2.2606,-0.508 -1.143,-0.2286 -2.2606,-0.5334 -1.1176,-0.3048 -1.9812,-0.8128 -0.8636,-0.5334 -1.397,-1.3462 -0.5334,-0.8382 -0.5334,-2.1336 0,-1.2954 0.5334,-2.2098 0.5588,-0.9398 1.4224,-1.4986 0.889,-0.5842 1.9812,-0.8382 1.0922,-0.2794 2.159,-0.2794 1.4224,0 2.6416,0.3302 1.2446,0.3048 2.159,0.9906 0.9398,0.6604 1.4986,1.7272 0.5588,1.0668 0.635,2.5654 z"
style="fill:#29bdfe;fill-opacity:1"
id="path873"
inkscape:connector-curvature="0" />
<path
d="m 141.02509,20.892025 q 0,1.9812 -0.5842,3.7338 -0.5842,1.7272 -1.7018,3.0226 -1.0922,1.27 -2.6924,2.0066 -1.6002,0.7366 -3.6322,0.7366 -2.032,0 -3.6576,-0.7366 -1.6002,-0.7366 -2.7178,-2.0066 -1.0922,-1.2954 -1.6764,-3.0226 -0.5842,-1.7526 -0.5842,-3.7338 0,-1.9812 0.5842,-3.7084 0.5842,-1.7526 1.6764,-3.0226 1.1176,-1.2954 2.7178,-2.032 1.6256,-0.7366 3.6576,-0.7366 2.032,0 3.6322,0.7366 1.6002,0.7366 2.6924,2.032 1.1176,1.27 1.7018,3.0226 0.5842,1.7272 0.5842,3.7084 z m -16.129,0 q 0,1.7526 0.508,3.302 0.508,1.5494 1.4732,2.7178 0.9652,1.143 2.3622,1.8288 1.397,0.6858 3.175,0.6858 1.778,0 3.1496,-0.6858 1.397,-0.6858 2.3622,-1.8288 0.9652,-1.1684 1.4732,-2.7178 0.508,-1.5494 0.508,-3.302 0,-1.7526 -0.508,-3.302 -0.508,-1.5494 -1.4732,-2.6924 -0.9652,-1.1684 -2.3622,-1.8542 -1.3716,-0.6858 -3.1496,-0.6858 -1.778,0 -3.175,0.6858 -1.397,0.6858 -2.3622,1.8542 -0.9652,1.143 -1.4732,2.6924 -0.508,1.5494 -0.508,3.302 z"
style="fill:#29bdfe;fill-opacity:1"
id="path875"
inkscape:connector-curvature="0" />
<path
d="m 140.42045,12.789425 v -0.9652 h 13.8938 v 0.9652 h -6.4008 v 17.1704 h -1.1176 v -17.1704 z"
style="fill:#29bdfe;fill-opacity:1"
id="path877"
inkscape:connector-curvature="0" />
<path
d="m 155.59715,11.824225 h 1.1176 v 17.1704 h 10.287 v 0.9652 h -11.4046 z"
style="letter-spacing:2.04787493px;fill:#016490;fill-opacity:1"
id="path879"
inkscape:connector-curvature="0" />
<path
d="m 175.72983,11.824225 h 1.2192 l 7.1628,18.1356 h -1.1938 l -2.286,-5.8166 h -8.6868 l -2.3114,5.8166 h -1.1938 z m 4.5466,11.3538 -3.8862,-10.2616 h -0.0508 l -4.0386,10.2616 z"
style="fill:#29bdfe;fill-opacity:1"
id="path881"
inkscape:connector-curvature="0" />
<path
d="m 197.26267,17.005825 q -0.0508,-1.1684 -0.508,-2.032 -0.4318,-0.8636 -1.1938,-1.4478 -0.76199,-0.5842 -1.82879,-0.8636 -1.0414,-0.3048 -2.286,-0.3048 -0.762,0 -1.651,0.1778 -0.8636,0.1778 -1.6256,0.635 -0.7366,0.4318 -1.2192,1.1938 -0.4826,0.7366 -0.4826,1.8542 0,1.0922 0.5334,1.778 0.5334,0.6858 1.397,1.1176 0.8636,0.4064 1.9812,0.6604 1.1176,0.254 2.2606,0.4826 1.1684,0.2286 2.2606,0.5588 1.11759,0.3048 1.98119,0.8636 0.889,0.5334 1.4224,1.4224 0.5334,0.8636 0.5334,2.1844 0,1.4224 -0.6096,2.3876 -0.6096,0.9652 -1.5494,1.5748 -0.9144,0.6096 -2.05739,0.8636 -1.1176,0.2794 -2.159,0.2794 -1.6002,0 -2.9972,-0.3556 -1.397,-0.3302 -2.4384,-1.0922 -1.0414,-0.7874 -1.651,-2.0066 -0.5842,-1.2192 -0.5588,-2.9718 h 1.1176 q -0.0762,1.4986 0.4318,2.54 0.508,1.016 1.397,1.6764 0.9144,0.6604 2.1336,0.9652 1.2192,0.2794 2.5654,0.2794 0.8128,0 1.7272,-0.2032 0.93979,-0.2032 1.70179,-0.6858 0.7874,-0.4826 1.2954,-1.27 0.5334,-0.7874 0.5334,-1.9812 0,-1.143 -0.5334,-1.8542 -0.5334,-0.7366 -1.4224,-1.1684 -0.86359,-0.4572 -1.98119,-0.7112 -1.0922,-0.2794 -2.2606,-0.508 -1.143,-0.2286 -2.2606,-0.5334 -1.1176,-0.3048 -1.9812,-0.8128 -0.8636,-0.5334 -1.397,-1.3462 -0.5334,-0.8382 -0.5334,-2.1336 0,-1.2954 0.5334,-2.2098 0.5588,-0.9398 1.4224,-1.4986 0.889,-0.5842 1.9812,-0.8382 1.0922,-0.2794 2.159,-0.2794 1.4224,0 2.6416,0.3302 1.24459,0.3048 2.15899,0.9906 0.9398,0.6604 1.4986,1.7272 0.5588,1.0668 0.635,2.5654 z"
style="font-style:normal;font-variant:normal;font-weight:200;font-stretch:normal;font-size:25.39999962px;font-family:'Helvetica Neue';-inkscape-font-specification:'Helvetica Neue Ultra-Light';letter-spacing:0px;stroke-width:0.26458332;fill:#016490;fill-opacity:1"
id="path883"
inkscape:connector-curvature="0" />
<g
id="g5255"
inkscape:label="Clip">
<path
inkscape:connector-curvature="0"
style="fill:#29bdfe;fill-opacity:1;stroke-width:0.01132015"
d="M 78.247876,15.233355 V 30.05059 c 0,0.484324 0.48903,0.815479 0.938699,0.635783 l 5.906871,-2.688598 V 11.568408 l -5.98474,2.39381 a 1.3693707,1.3693707 0 0 0 -0.86083,1.271137 z"
id="path3324" />
<path
inkscape:connector-curvature="0"
style="fill:#29bdfe;fill-opacity:1;stroke-width:0.01132015"
d="m 86.46256,27.997775 8.214684,2.738229 V 14.306636 L 86.46256,11.568408 Z"
id="path3322" />
<path
inkscape:connector-curvature="0"
style="fill:#29bdfe;fill-opacity:1;stroke-width:0.01132015"
d="m 101.95323,11.618038 -5.906873,2.688598 v 16.429368 l 5.984743,-2.393811 a 1.3689428,1.3689428 0 0 0 0.86083,-1.271136 V 12.253821 c 0,-0.484324 -0.48903,-0.815479 -0.9387,-0.635783 z"
id="path2" />
</g>
</g>
</g>
</svg>

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 8.2 KiB

2632
src/assets/style.json 100644

Plik diff jest za duży Load Diff

Wyświetl plik

@ -0,0 +1,96 @@
<template>
<div class="card">
<div class="card-content">
<div class="points"><SummitPointsLabel :points="activation.points" :bonus="activation.bonus" /></div>
<div class="qsos" @click="$emit('openQsoList', activation.id)">
{{ activation.qsos }} QSOs
<font-awesome-icon :icon="['far', 'th-list']" class="faicon" />
</div>
<div class="date">{{ activation.date | formatActivationDate }}</div>
<div class="summit"><router-link :class="{ invalid: activation.summit.invalid }" :to="makeSummitLink(activation.summit.code)">{{ activation.summit.name }} ({{ activation.summit.code }})</router-link><font-awesome-icon v-if="hasOwnPhotos(activation.summit)" class="photos-icon" :icon="['far', 'images']" /><font-awesome-icon v-else-if="activation.summit.photoAuthors && activation.summit.photoAuthors.length > 0" class="photos-icon-others" :icon="['far', 'images']" /><AltitudeLabel :altitude="activation.summit.altitude" /><ActivationCount :activationCount="activation.summit.activationCount" /></div>
</div>
</div>
</template>
<script>
import utils from '../mixins/utils.js'
import SummitPointsLabel from '../components/SummitPointsLabel.vue'
import ActivationCount from '../components/ActivationCount.vue'
import AltitudeLabel from '../components/AltitudeLabel.vue'
export default {
name: 'ActivationCard',
components: {
SummitPointsLabel, ActivationCount, AltitudeLabel
},
mixins: [utils],
props: {
activation: {
type: Object,
required: true
},
ownCallsign: String
},
methods: {
hasOwnPhotos (summit) {
if (!summit.photoAuthors || !this.ownCallsign) {
return false
}
return summit.photoAuthors.includes(this.ownCallsign)
}
}
}
</script>
<style scoped>
.card-content {
font-size: 0.9rem;
padding: 0.6rem;
line-height: 1.3;
overflow: auto;
}
.card-content .date {
margin-right: 0.5em;
min-width: 3.5em;
display: inline-block;
}
.card-content .points {
float: right;
}
.card-content .qsos {
float: right;
font-size: 0.75rem;
margin-right: 0.7em;
color: #3273dc;
margin-top: 0.15em;
cursor: pointer;
}
.card-content .summit {
margin-top: 0.1em;
}
.card-content .invalid {
opacity: 0.7;
text-decoration: line-through;
}
.card-content .altitude {
font-size: 0.75rem;
color: #777;
margin-left: 0.3em;
}
.card-content .activation-count {
font-size: 0.75rem;
margin-left: 0.3em;
margin-right: 0.2em;
}
.photos-icon {
margin-left: 0.5em;
margin-right: 0.1em;
color: #777;
}
.photos-icon-others {
margin-left: 0.5em;
margin-right: 0.1em;
color: #aaa;
}
</style>

Wyświetl plik

@ -0,0 +1,247 @@
<template>
<div>
<div class="columns is-5 is-variable">
<div class="column is-half">
<h4 class="title is-4">Activations per year</h4>
<BarChart v-if="activationsPerYear" :data="activationsPerYear" labelField="year" valueField="activations" valueFieldB="bonusActivations" name="Activations" nameB="with Bonus" :xIsSeries="true" />
</div>
<div class="column is-half">
<h4 class="title is-4">Activations per association</h4>
<PieChart v-if="activationsPerAssociation" :data="activationsPerAssociation" labelField="association" valueField="activations" name="Activations" />
</div>
</div>
<div v-if="moreStats" class="columns is-5 is-variable">
<div class="column is-half">
<h4 class="title is-4">Activations by altitude</h4>
<BarChart v-if="activationsPerAltitude" :data="activationsPerAltitude" labelField="altitude" valueField="activations" name="Activations" :xIsSeries="true" />
</div>
<div class="column is-half">
<h4 class="title is-4 no-margin-bottom">Number of QSOs per activation</h4>
<PercentageChart v-if="qsosPerActivation" :data="qsosPerActivation" labelField="qsos" valueField="activations" name="Activations" :maxSlices="11" />
<template v-if="modes">
<h4 class="title is-4 no-margin-bottom">Number of QSOs by mode</h4>
<PercentageChart :data="modes" labelField="mode" valueField="qsos" name="QSOs" />
</template>
<template v-if="bands">
<h4 class="title is-4 no-margin-bottom">Number of QSOs by band</h4>
<PercentageChart :data="bands" labelField="band" valueField="qsos" name="QSOs" />
</template>
<div v-if="!authenticated" class="stats-teaser">
<div>
<font-awesome-icon :icon="['far', 'chart-bar']" /> Log in for more statistics
</div>
</div>
</div>
</div>
<div class="more-button" v-else>
<b-button @click="moreStats = true" type="is-info" icon-left="chart-bar">More stats</b-button>
</div>
</div>
</template>
<script>
import moment from 'moment'
import utils from '../mixins/utils.js'
import BarChart from '../components/BarChart.vue'
import PieChart from '../components/PieChart.vue'
import PercentageChart from '../components/PercentageChart.vue'
export default {
props: {
activations: Array
},
mixins: [utils],
components: {
BarChart, PieChart, PercentageChart
},
computed: {
activationsPerYear () {
if (this.activations.length === 0) {
return null
}
let years = {}
let yearsBonus = {}
this.activations.forEach(activation => {
let year = moment.utc(activation.date).year()
if (!years[year]) {
years[year] = 0
yearsBonus[year] = 0
}
years[year]++
if (activation.bonus > 0) {
yearsBonus[year]++
}
})
return Object.keys(years).sort().map(year => {
return { year, activations: years[year], bonusActivations: yearsBonus[year] }
})
},
activationsPerAssociation () {
if (this.activations.length === 0) {
return null
}
let associations = {}
this.activations.forEach(activation => {
let association = activation.summit.code.substring(0, activation.summit.code.indexOf('/'))
if (!associations[association]) {
associations[association] = 0
}
associations[association]++
})
return Object.keys(associations).sort().map(association => {
return { association, activations: associations[association] }
})
},
activationsPerAltitude () {
return this.makeBands(this.activations.map(activation => { return activation.summit.altitude }), 500, ' m', 'altitude', 'activations')
},
qsosPerActivation () {
return this.makeBands(this.activations.map(activation => { return activation.qsos }), 10, '', 'qsos', 'activations', true, 110)
},
modes () {
if (this.activations.length === 0) {
return null
}
let modes = {}
let totalQsos = 0
this.activations.forEach(activation => {
if (activation.modeQsos) {
Object.keys(activation.modeQsos).forEach(mode => {
if (!modes[mode]) {
modes[mode] = 0
}
modes[mode] += activation.modeQsos[mode]
})
}
totalQsos += activation.qsos
})
let modesArr = []
let modeQsosSum = 0
Object.keys(modes).forEach(mode => {
modesArr.push({ mode: mode.toUpperCase(), qsos: modes[mode] })
modeQsosSum += modes[mode]
})
if (modesArr.length > 0) {
if (modeQsosSum < totalQsos) {
modesArr.push({ mode: 'Other', qsos: (totalQsos - modeQsosSum) })
}
return modesArr
} else {
return null
}
},
bands () {
if (this.activations.length === 0) {
return null
}
let bands = {}
this.activations.forEach(activation => {
if (activation.bandQsos) {
Object.keys(activation.bandQsos).forEach(band => {
if (!bands[band]) {
bands[band] = 0
}
bands[band] += activation.bandQsos[band]
})
}
})
let bandsArr = []
Object.keys(bands).forEach(band => {
bandsArr.push({ band, qsos: bands[band] })
})
if (bandsArr.length > 0) {
bandsArr.sort((a, b) => {
return b.qsos - a.qsos
})
return bandsArr
} else {
return null
}
}
},
methods: {
makeBands (data, interval, suffix, bandKey, valueKey, ranges = false, maxBand) {
if (!data) {
return null
}
let bands = {}
let maxValue = 0
data.forEach(value => {
let band = Math.ceil(value / interval) * interval
if (maxBand && band > maxBand) {
band = maxBand
}
if (!bands[band]) {
bands[band] = 0
}
bands[band]++
if (maxValue < band) {
maxValue = band
}
})
for (let value = interval; value < maxValue; value += interval) {
if (bands[value] === undefined) {
bands[value] = 0
}
}
return Object.keys(bands).sort((a, b) => { return parseInt(a) - parseInt(b) }).map(value => {
value = parseInt(value)
let label = '< ' + value + suffix
if (ranges) {
if (value === maxBand) {
label = '> ' + (value - interval) + suffix
} else {
label = (value - interval + 1) + '-' + value + suffix
}
}
return { [bandKey]: label, [valueKey]: bands[value] }
})
}
},
data () {
return {
moreStats: false
}
}
}
</script>
<style scoped>
.more-button {
text-align: center;
}
.no-margin-bottom {
margin-bottom: 0;
}
.stats-teaser {
text-align: center;
margin-top: 1em;
}
.stats-teaser div {
font-size: 1.5rem;
color: #ccc;
border: 2px dashed #ccc;
display: inline-block;
padding: 0.5em 0.75em;
border-radius: 0.75em;
}
.stats-teaser .fa-chart-bar {
font-size: 2rem;
vertical-align: middle;
margin-right: 0.3em;
}
</style>

Wyświetl plik

@ -0,0 +1,44 @@
<template>
<span :class="activationCountClass" label="activation count" title="activation count">{{ activationCount }}</span>
</template>
<script>
export default {
name: 'ActivationCount',
props: {
activationCount: Number
},
computed: {
activationCountClass () {
if (this.activationCount !== undefined) {
return { 'activation-count': true, ['activations-' + this.activationCount]: true }
} else {
return null
}
}
}
}
</script>
<style scoped>
.activation-count {
color: #ccc;
border-radius: 0.2em;
padding: 0 0.2em;
border: 1px solid #ccc;
margin-left: 0.3em;
}
.activations-0, .activations-1 {
font-weight: bold;
color: #ccc124;
border: 1px solid #ccc124;
}
.activations-2 {
color: #eaa82b;
border: 1px solid #eaa82b;
}
.activations-3 {
color: #a98d59;
border: 1px solid #a98d59;
}
</style>

Wyświetl plik

@ -0,0 +1,141 @@
<template>
<div>
<CardPagination v-if="!$mq.desktop" :data="cardActivations" :paginated="true" :infinite="infinite">
<template v-slot="{ row }">
<ActivationCard v-slot="{ row }" :activation="row" :ownCallsign="ownCallsign" @openQsoList="openQsoList" />
</template>
</CardPagination>
<b-table v-else class="auto-width" :narrowed="true" :paginated="true" :striped="true" :default-sort="['date', 'desc']" :per-page="perPage" :data="data" :row-class="rowClass">
<template slot-scope="props">
<b-table-column field="date" label="Date" sortable>
{{ props.row.date | formatActivationDate }}
</b-table-column>
<b-table-column field="summit.code" label="Summit" class="code" sortable>
<CountryFlag v-if="props.row.summit.isoCode" :country="props.row.summit.isoCode" class="flag" />
<router-link :to="makeSummitLink(props.row.summit.code)">{{ props.row.summit.code }}</router-link>
</b-table-column>
<b-table-column field="summit.name" label="Name" class="name" sortable>
<router-link :to="makeSummitLink(props.row.summit.code)">{{ props.row.summit.name }}</router-link>
<font-awesome-icon v-if="hasOwnPhotos(props.row.summit)" class="photos-icon" :icon="['far', 'images']" />
<font-awesome-icon v-else-if="props.row.summit.photoAuthors && props.row.summit.photoAuthors.length > 0" class="photos-icon-others" :icon="['far', 'images']" />
</b-table-column>
<b-table-column field="summit.altitude" label="Altitude" class="altitude" sortable numeric>
<AltitudeLabel :altitude="props.row.summit.altitude" />
</b-table-column>
<b-table-column field="points" label="Points" sortable>
<SummitPointsLabel :points="props.row.points" :bonus="props.row.bonus" />
</b-table-column>
<b-table-column field="summit.activationCount" label="Activations" sortable numeric>
<ActivationCount :activationCount="props.row.summit.activationCount" />
</b-table-column>
<b-table-column field="callsignUsed" label="Callsign used" sortable>
{{ props.row.callsignUsed.toUpperCase() }}
</b-table-column>
<b-table-column field="qsos" label="QSOs" sortable numeric>
<span class="qsos" @click="openQsoList(props.row.id)">{{ props.row.qsos }}</span>
<font-awesome-icon :icon="['far', 'th-list']" class="faicon qsos" @click="openQsoList(props.row.id)" />
</b-table-column>
</template>
<template v-slot:bottom-left>
<b-select v-model="perPage">
<option v-for="option in perPageOptions" :key="option" :value="option">{{ option }} per page</option>
</b-select>
</template>
</b-table>
<ModalQSOList :activationId="modalActivationId" @modalClosed="modalActivationId = null" />
</div>
</template>
<script>
import utils from '../mixins/utils.js'
import prefs from '../mixins/prefs.js'
import SummitPointsLabel from '../components/SummitPointsLabel.vue'
import CardPagination from '../components/CardPagination.vue'
import ActivationCard from '../components/ActivationCard.vue'
import ActivationCount from '../components/ActivationCount.vue'
import AltitudeLabel from '../components/AltitudeLabel.vue'
import CountryFlag from '../components/CountryFlag.vue'
import ModalQSOList from '../components/ModalQSOList.vue'
export default {
name: 'ActivationsList',
mixins: [utils, prefs],
prefs: {
key: 'activationsListPrefs',
props: ['perPage']
},
props: {
data: Array,
infinite: Boolean,
ownCallsign: String
},
components: {
SummitPointsLabel, CardPagination, ActivationCard, ActivationCount, AltitudeLabel, CountryFlag, ModalQSOList
},
methods: {
openQsoList (activationId) {
if (!this.authenticated) {
this.$buefy.dialog.alert('Please log in to view QSOs.')
return
}
this.modalActivationId = activationId
},
rowClass (row) {
return { invalid: row.summit.invalid }
},
hasOwnPhotos (summit) {
if (!summit.photoAuthors || !this.ownCallsign) {
return false
}
return summit.photoAuthors.includes(this.ownCallsign)
}
},
computed: {
cardActivations () {
return [...this.data].sort((a, b) => {
if (a.date > b.date) {
return -1
} else if (a.date < b.date) {
return 1
} else {
return 0
}
})
}
},
data () {
return {
modalActivationId: null,
perPage: 15,
perPageOptions: [10, 15, 20, 30, 50, 100]
}
}
}
</script>
<style scoped>
.flag {
margin-right: 0.4em;
}
.qsos {
color: #3273dc;
cursor: pointer;
}
.faicon {
margin-left: 0.4em;
}
.photos-icon {
margin-left: 0.5em;
color: #777;
}
.photos-icon-others {
margin-left: 0.5em;
color: #aaa;
}
.invalid .code, .invalid .name {
opacity: 0.7;
text-decoration: line-through;
}
</style>

Wyświetl plik

@ -0,0 +1,126 @@
<template>
<div class="card">
<div v-if="$slots.header" class="card-header"><p class="card-header-title"><slot name="header"></slot></p></div>
<div class="card-content">
<div class="time" v-html="formatTime(alert.dateActivated)" />
<div class="freqmode">{{ alert.frequency }}</div>
<div class="callsign">
<template v-if="callsignLink">
<router-link :to="makeActivatorLink(alert.activatorCallsign)">{{ alert.activatorCallsign }}</router-link>
</template>
<template v-else>{{ alert.activatorCallsign }}</template>
</div>
<div v-if="showSummitInfo" class="summit">
<div class="summit-title" v-if="alert.summit.name">
<CountryFlag v-if="alert.summit.isoCode" :country="alert.summit.isoCode" class="flag" />
<router-link :to="makeSummitLink(alert.summit.code)"><span class="summit-name">{{ alert.summit.name }}</span></router-link>
</div>
<div class="summit-info">{{ alert.summit.code }}<span v-if="alert.summit.altitude">, <AltitudeLabel :altitude="alert.summit.altitude" />, {{ alert.summit.points }}pt<ActivationCount :activationCount="alert.summit.activationCount" /></span></div>
</div>
<div class="poster">{{ alert.posterCallsign }}</div>
<div class="comments">{{ alert.comments }}</div>
<slot name="actions"></slot>
</div>
</div>
</template>
<script>
import utils from '../mixins/utils.js'
import ActivationCount from '../components/ActivationCount.vue'
import CountryFlag from '../components/CountryFlag.vue'
import AltitudeLabel from '../components/AltitudeLabel.vue'
export default {
name: 'AlertCard',
components: {
ActivationCount, CountryFlag, AltitudeLabel
},
mixins: [utils],
props: {
alert: {
type: Object,
required: true
},
showSummitInfo: {
type: Boolean,
default: true
},
callsignLink: {
type: Boolean,
default: true
}
}
}
</script>
<style scoped>
.card-header {
background-color: #eee;
margin-top: 1em;
font-size: 1rem;
}
.card-header-title {
padding: 0.3em 0.5em;
}
.card-content {
font-size: 0.9rem;
padding: 0.6rem;
line-height: 1.3;
overflow: auto;
}
.card-content .time {
margin-right: 0.5em;
min-width: 3.5em;
display: inline-block;
}
.card-content .callsign {
font-weight: bold;
display: inline-block;
}
.card-content .freqmode {
float: right;
font-size: 0.75rem;
line-height: 1.5;
margin-left: 0.3em;
max-width: calc(100vw - 20em);
text-align: right;
}
.card-content .summit {
font-size: 0.75rem;
margin-top: 0.1em;
}
.card-content .summit-name {
font-size: 0.9rem;
}
.card-content .comments {
font-size: 0.75rem;
color: #777;
margin-top: 0.1em;
}
.card-content .poster {
float: right;
font-style: italic;
margin-left: 0.3em;
font-size: 0.75rem;
clear: right;
color: #777;
}
.card-content .flag {
margin-right: 0.5em;
margin-left: 0.1em;
position: relative;
top: -0.05em;
}
.card-content .activation-count {
margin-left: 0.4em;
}
.summit-title {
display: inline-block;
margin-right: 0.3em;
}
@media (min-width: 769px) {
.summit-info {
display: inline-block;
}
}
</style>

Wyświetl plik

@ -0,0 +1,250 @@
<template>
<div>
<CardPagination v-if="!$mq.desktop && cardAlerts.length > 0" :data="cardAlerts" :infinite="infinite" :paginated="paginated">
<template v-slot="{ row, prevRow }">
<AlertCard :alert="row" :callsignLink="callsignLink" :showSummitInfo="showSummitInfo">
<template v-if="needDateHeader(row, prevRow)" v-slot:header>
{{ row.dateActivated | formatAlertDate }}
</template>
<template v-if="canEditAlert(row)" v-slot:actions>
<div class="actions">
<b-button class="control" size="is-small" outlined icon-left="edit" @click="editAlert(row)">Edit</b-button>
<b-button class="control" size="is-small" outlined icon-left="plus" @click="makeSpot(row)">Spot</b-button>
<b-button class="control" size="is-small" type="is-danger" outlined icon-left="trash-alt" @click="deleteAlert(row)">Delete</b-button>
</div>
</template>
</AlertCard>
</template>
</CardPagination>
<p v-else-if="!$mq.desktop && cardAlerts.length === 0">No matching alerts found.</p>
<b-table v-else default-sort="dateActivated" :narrowed="true" :striped="true" :data="data" :paginated="paginated" :per-page="perPage" :row-class="rowClass">
<template slot-scope="props">
<b-table-column field="dateActivated" class="timestamp" label="Date/Time" sortable>
<span v-html="formatDateTimeRelative(props.row.dateActivated)" />
</b-table-column>
<b-table-column v-if="showCallsign" field="activatorCallsign" label="Callsign" sortable>
<template v-if="callsignLink">
<router-link :to="makeActivatorLink(props.row.activatorCallsign)">{{ props.row.activatorCallsign }}</router-link>
</template>
<template v-else>
{{ props.row.activatorCallsign }}
</template>
</b-table-column>
<b-table-column v-if="showSummitInfo" field="summit.code" label="Summit code" class="nowrap" sortable>
<CountryFlag v-if="props.row.summit.isoCode && $mq.fullhd" :country="props.row.summit.isoCode" class="flag" />
<router-link v-if="props.row.summit.name" :to="makeSummitLink(props.row.summit.code)">{{ props.row.summit.code }}</router-link>
<span v-else>{{ props.row.summit.code }}</span>
</b-table-column>
<b-table-column v-if="showSummitInfo" field="summit.name" label="Summit name" sortable>
<router-link :to="makeSummitLink(props.row.summit.code)">{{ props.row.summit.name }}</router-link>
</b-table-column>
<b-table-column v-if="showSummitInfo" field="summit.altitude" label="Altitude" sortable numeric>
<template v-if="props.row.summit.altitude"><AltitudeLabel :altitude="props.row.summit.altitude" /></template>
</b-table-column>
<b-table-column v-if="showSummitInfo" field="summit.points" label="Points" sortable numeric>
<SummitPointsLabel v-if="props.row.summit.points" :points="props.row.summit.points" />
</b-table-column>
<b-table-column v-if="showSummitInfo" field="summit.activationCount" label="Act." sortable numeric>
<ActivationCount :activationCount="props.row.summit.activationCount" />
</b-table-column>
<b-table-column class="comments" label="Frequencies/Comments">
<div class="comments-cell">
<div>
{{ props.row.frequency }}<br />
<span class="comments-text">{{ props.row.comments }} ({{ props.row.posterCallsign }})</span>
</div>
<b-dropdown v-if="canEditAlert(props.row)" class="actions" aria-role="list">
<b-button size="is-small" slot="trigger" icon-pack="fas" icon-right="caret-down" outlined>Actions</b-button>
<b-dropdown-item aria-role="listitem" @click="editAlert(props.row)"><b-icon icon="edit" size="is-small" /><span class="dropdown-label">Edit</span></b-dropdown-item>
<b-dropdown-item aria-role="listitem" @click="makeSpot(props.row)"><b-icon icon="plus" size="is-small" /><span class="dropdown-label">Spot</span></b-dropdown-item>
<b-dropdown-item aria-role="listitem" @click="deleteAlert(props.row)"><b-icon icon="trash-alt" type="is-danger" size="is-small" /><span class="has-text-danger dropdown-label">Delete</span></b-dropdown-item>
</b-dropdown>
</div>
</b-table-column>
</template>
<template v-if="paginated" v-slot:bottom-left>
<b-select v-model="perPage">
<option v-for="option in perPageOptions" :key="option" :value="option">{{ option }} per page</option>
</b-select>
</template>
</b-table>
<b-modal v-if="isEditAlertActive" :active="true" has-modal-card :can-cancel="['escape']" @close="isEditAlertActive = false">
<EditAlert :alert="alertToEdit" />
</b-modal>
<b-modal v-if="isEditSpotActive" :active="true" has-modal-card :can-cancel="['escape']" @close="isEditSpotActive = false">
<EditSpot :spot="spotToMake" />
</b-modal>
</div>
</template>
<script>
import moment from 'moment'
import utils from '../mixins/utils.js'
import prefs from '../mixins/prefs.js'
import nowticker from '../mixins/nowticker.js'
import sotawatch from '../mixins/sotawatch.js'
import SummitPointsLabel from '../components/SummitPointsLabel.vue'
import CardPagination from '../components/CardPagination.vue'
import AlertCard from '../components/AlertCard.vue'
import ActivationCount from '../components/ActivationCount.vue'
import CountryFlag from '../components/CountryFlag.vue'
import AltitudeLabel from '../components/AltitudeLabel.vue'
import EditAlert from '../components/EditAlert.vue'
import EditSpot from '../components/EditSpot.vue'
export default {
name: 'AlertsList',
components: {
SummitPointsLabel, CardPagination, AlertCard, ActivationCount, CountryFlag, AltitudeLabel, EditAlert, EditSpot
},
mixins: [utils, prefs, nowticker, sotawatch],
prefs: {
key: 'alertsListPrefs',
props: ['perPage']
},
props: {
data: Array,
showCallsign: {
type: Boolean,
default: true
},
showSummitInfo: {
type: Boolean,
default: true
},
paginated: {
type: Boolean,
default: true
},
infinite: {
type: Boolean,
default: false
},
callsignLink: {
type: Boolean,
default: true
}
},
filters: {
formatAlertDate (date) {
return moment.utc(date).format('dddd, DD MMM YYYY')
}
},
methods: {
needDateHeader (row, prevRow) {
return (!prevRow || !moment.utc(prevRow.dateActivated).isSame(moment.utc(row.dateActivated), 'day'))
},
rowClass (row, index) {
if (index === 0 || index === this.data.length) {
return ''
}
if (!moment.utc(this.data[index - 1].dateActivated).isSame(moment.utc(row.dateActivated), 'day')) {
return 'date-change'
} else {
return ''
}
},
canEditAlert (alert) {
if (!this.myCallsign) {
return false
}
return (alert.posterCallsign === this.myCallsign)
},
addAlert () {
this.alertToEdit = null
this.isEditAlertActive = true
},
editAlert (alert) {
this.alertToEdit = alert
this.isEditAlertActive = true
},
makeSpot (alert) {
this.spotToMake = {
activatorCallsign: alert.activatorCallsign,
summit: alert.summit,
comments: alert.comments,
mode: ''
}
this.isEditSpotActive = true
},
deleteAlert (alert) {
this.$buefy.dialog.confirm({
message: 'Are you sure you want to delete this alert?',
confirmText: 'Delete',
type: 'is-danger',
onConfirm: () => {
this.deleteSotaWatchAlert(alert.id)
.then(response => {
this.$store.dispatch('reloadAlerts')
})
}
})
}
},
computed: {
cardAlerts () {
return [...this.data].sort((a, b) => {
if (a.dateActivated > b.dateActivated) {
return 1
} else if (a.dateActivated < b.dateActivated) {
return -1
} else {
return 0
}
})
}
},
data () {
return {
perPage: 15,
perPageOptions: [10, 15, 20, 30, 50, 100],
isEditAlertActive: false,
isEditSpotActive: false,
alertToEdit: null,
spotToMake: null
}
}
}
</script>
<style scoped>
@media (min-width: 769px) {
.table .comments {
font-size: 0.8rem;
max-width: 30em;
}
}
.flag {
margin-right: 0.4em;
}
.comments-text {
color: #777;
}
.comments-cell {
display: flex;
}
.actions {
margin-left: auto;
}
.card .actions {
float: right;
clear: right;
}
.actions .button {
margin-left: 1em;
}
.card .actions {
margin-top: 0.5em;
}
.date-change td {
border-top: 3px solid #dbdbdb;
}
>>> .dropdown-item .icon {
vertical-align: middle;
}
.dropdown-item .dropdown-label {
margin-left: 0.5em;
}
</style>

Wyświetl plik

@ -0,0 +1,26 @@
<template>
<span :class="{ altitude: true }">{{ displayAltitude }}</span>
</template>
<script>
export default {
name: 'AltitudeLabel',
props: {
altitude: Number,
altitudeFt: Number
},
computed: {
displayAltitude () {
if (this.$store.state.altitudeUnits === 'ft') {
if (this.altitudeFt) {
return this.altitudeFt + ' ft'
} else {
return Math.round(this.altitude * 3.28084) + ' ft'
}
} else {
return this.altitude + ' m'
}
}
}
}
</script>

Wyświetl plik

@ -0,0 +1,80 @@
<template>
<div ref="chart"></div>
</template>
<script>
import { Chart } from 'frappe-charts/dist/frappe-charts.min.esm'
export default {
props: {
data: Array,
labelField: String,
valueField: String,
valueFieldB: String,
name: String,
nameB: String,
xIsSeries: {
type: Boolean,
default: false
},
stacked: Boolean
},
methods: {
updateChart () {
let labels = []
let values = []
let valuesB = []
this.data.forEach(row => {
labels.push(row[this.labelField])
values.push(row[this.valueField])
})
let datasets = [{
values,
name: this.name
}]
if (this.valueFieldB) {
this.data.forEach(row => {
valuesB.push(row[this.valueFieldB])
})
datasets.push({
values: valuesB,
name: this.nameB
})
}
this.chart = new Chart(this.$refs.chart, {
data: {
labels,
datasets: datasets
},
type: 'bar',
height: 250,
barOptions: {
spaceRatio: 0.3,
stacked: this.stacked
},
axisOptions: {
xAxisMode: 'tick',
xIsSeries: this.xIsSeries
}
})
}
},
watch: {
data () {
this.updateChart()
}
},
mounted () {
this.updateChart()
}
}
</script>
<style scoped>
>>> .graph-svg-tip .title {
color: #fff;
}
</style>

Wyświetl plik

@ -0,0 +1,86 @@
<template>
<span v-if="homeQth" class="wrapper">
<DistanceLabel :distance="distance" />, {{ bearing }}°
</span>
<span v-else-if="homeQth === null" class="has-text-grey">
Loading...
</span>
<span v-else class="has-text-grey">
Set home QTH in <a @click="doAccountManagement">your account</a>
</span>
</template>
<script>
import DistanceLabel from './DistanceLabel.vue'
import vincenty from 'node-vincenty'
export default {
name: 'Bearing',
components: { DistanceLabel },
props: {
latitude: Number,
longitude: Number
},
computed: {
homeQth () {
return this.$store.state.homeQth
}
},
mounted () {
if (this.homeQth === null) {
this.$keycloak.loadUserProfile()
.success(profile => {
if (profile.attributes.Lat && profile.attributes.Lat[0] && profile.attributes.Lon && profile.attributes.Lon[0]) {
this.$store.commit('setHomeQth', {
latitude: parseFloat(profile.attributes.Lat[0]),
longitude: parseFloat(profile.attributes.Lon[0])
})
this.calculate()
} else {
this.$store.commit('setHomeQth', undefined)
}
})
} else {
this.calculate()
}
},
watch: {
latitude () {
this.calculate()
},
longitude () {
this.calculate()
},
homeQth () {
this.calculate()
}
},
methods: {
calculate () {
if (!this.homeQth) {
this.distance = null
this.bearing = null
return
}
let res = vincenty.distVincenty(this.homeQth.latitude, this.homeQth.longitude, this.latitude, this.longitude)
this.distance = res.distance
this.bearing = (Math.round(res.initialBearing) + 360) % 360
},
doAccountManagement () {
this.$keycloak.accountManagement()
}
},
data () {
return {
distance: null,
bearing: null
}
}
}
</script>
<style scoped>
.wrapper {
display: inline-block;
}
</style>

Wyświetl plik

@ -0,0 +1,48 @@
<template>
<nav class="breadcrumb is-small" aria-label="breadcrumbs">
<ul>
<li v-if="association" :class="{'is-active': !region, association: true}"><CountryFlag v-if="association.isoCode" :country="association.isoCode" /> <router-link :to="'/summits/' + association.code">{{ association.code }} <span class="breadcrumb-label">&nbsp;{{ association.name }}</span></router-link></li>
<li v-if="region" :class="{'is-active': !summit}"><router-link :to="'/summits/' + association.code + '/' + region.code">{{ region.code }} <span class="breadcrumb-label">&nbsp;{{ region.name }}</span></router-link></li>
<li v-if="summit &amp;&amp; summit.code" class="is-active summit-number"><router-link :to="'/summits/' + summit.code" aria-current="page">{{ summitNumber }}</router-link></li>
</ul>
</nav>
</template>
<script>
import CountryFlag from '../components/CountryFlag.vue'
export default {
name: 'Breadcrumb',
components: {
CountryFlag
},
props: {
association: Object,
region: Object,
summit: Object
},
computed: {
summitNumber () {
if (!this.summit || !this.summit.code) {
return null
}
return this.summit.code.substring(this.summit.code.indexOf('-') + 1)
}
}
}
</script>
<style scoped>
.breadcrumb {
margin-top: 0.3em;
}
.breadcrumb li.summit-number:before {
content: "–";
}
.breadcrumb .association a {
padding-left: 0.5em;
}
.breadcrumb-label {
color: #777;
}
</style>

Wyświetl plik

@ -0,0 +1,93 @@
<template>
<div class="card-list">
<b-pagination v-if="paginated && !infinite" :total="data.length" :current.sync="currentCardPage" :simple="true" size="is-small" :per-page="perPage" aria-next-label="Next page" aria-previous-label="Previous page" aria-page-label="Page" aria-current-label="Current page" />
<div v-for="(row, index) in pageData" :key="row[rowKey]">
<slot :row="row" :prevRow="(index > 0 ? pageData[index-1] : null)"></slot>
</div>
<infinite-loading v-if="infinite" :identifier="infiniteIdentifier" @infinite="infiniteHandler">
<div slot="no-more"></div>
<div slot="no-results"></div>
</infinite-loading>
</div>
</template>
<script>
import InfiniteLoading from 'vue-infinite-loading'
export default {
name: 'CardPagination',
components: {
InfiniteLoading
},
props: {
data: Array,
paginated: {
type: Boolean,
default: true
},
infinite: {
type: Boolean,
default: false
},
perPage: {
type: Number,
default: 10
},
infiniteBatchSize: {
type: Number,
default: 50
},
rowKey: {
type: String,
default: 'id'
}
},
watch: {
currentCardPage (newCardPage) {
if (newCardPage === 0) {
this.currentCardPage = 1
}
},
data () {
this.infiniteIdentifier++
}
},
methods: {
infiniteHandler ($state) {
if (this.data.length > (this.infiniteBatchCount * this.infiniteBatchSize)) {
this.infiniteBatchCount++
$state.loaded()
} else {
$state.complete()
}
}
},
computed: {
pageData () {
if (this.infinite) {
return this.data.slice(0, this.infiniteBatchCount * this.infiniteBatchSize)
} else if (this.paginated) {
return this.data.slice((this.currentCardPage - 1) * this.perPage, this.currentCardPage * this.perPage)
} else {
return this.data
}
}
},
data () {
return {
currentCardPage: 1,
infiniteBatchCount: 1,
infiniteIdentifier: 1
}
}
}
</script>
<style scoped>
.card-list {
margin-top: 1.5em;
}
.pagination {
margin-bottom: 0.5em;
}
</style>

Wyświetl plik

@ -0,0 +1,46 @@
<template>
<b-table :narrowed="true" :paginated="true" :striped="true" :default-sort="['activationDate', 'desc']" :per-page="15" :data="data" :mobile-cards="false">
<template slot-scope="props">
<b-table-column field="activationDate" label="Date" sortable>
{{ props.row.activationDate | formatActivationDate }}
</b-table-column>
<b-table-column field="otherCallsign" label="Activator" sortable>
<router-link :to="makeActivatorLink(props.row.otherCallsign.toUpperCase())">{{ props.row.otherCallsign.toUpperCase() }}</router-link>
</b-table-column>
<b-table-column field="band" label="Band" :custom-sort="sortBand" sortable numeric>
{{ bandForFrequency(props.row.band.replace('MHz', '')) }}
</b-table-column>
<b-table-column field="mode" label="Mode" sortable>
<ModeLabel :mode="props.row.mode" />
</b-table-column>
</template>
</b-table>
</template>
<script>
import utils from '../mixins/utils.js'
import ModeLabel from '../components/ModeLabel.vue'
export default {
props: {
data: Array
},
mixins: [utils],
components: {
ModeLabel
},
methods: {
sortBand (a, b, isAsc) {
let fa = parseFloat(a.band.replace('MHz', ''))
let fb = parseFloat(b.band.replace('MHz', ''))
if (fa < fb) {
return (isAsc ? -1 : 1)
} else if (fa === fb) {
return 0
} else {
return (isAsc ? 1 : -1)
}
}
}
}
</script>

Wyświetl plik

@ -0,0 +1,199 @@
<template>
<span class="wrapper">
<span class="coordinates">{{ latitude }}, {{ longitude }}</span>
<div class="actions">
<b-field>
<p class="control">
<b-dropdown aria-role="list">
<b-button type="is-info" outlined size="is-small" icon-right="angle-down" slot="trigger">
Open
</b-button>
<b-dropdown-item v-for="action in filteredActions" :key="action.name" :has-link="true" aria-role="listitem"><a :href="action.url()" target="_blank">{{ action.name }}</a></b-dropdown-item>
</b-dropdown>
</p>
<p class="control">
<b-button type="is-info" outlined size="is-small" v-clipboard:copy="latitude + ',' + longitude" v-clipboard:success="onCopySuccess" v-clipboard:error="onCopyError">Copy</b-button>
</p>
</b-field>
</div>
<div v-if="showMaidenhead" class="locator">Locator: {{ maidenhead }}</div>
<div v-if="showElevation" class="elevation">Elevation: <span v-if="elevation"><AltitudeLabel :altitude="elevation" /> (approx.)</span><font-awesome-icon v-else :icon="['far', 'spinner']" spin /></div>
</span>
</template>
<script>
import Maidenhead from 'maidenhead'
import axios from 'axios'
import AltitudeLabel from './AltitudeLabel.vue'
export default {
name: 'Coordinates',
components: { AltitudeLabel },
props: {
latitude: Number,
longitude: Number,
reference: String,
showMaidenhead: Boolean,
showElevation: Boolean
},
computed: {
filteredActions () {
return this.actions.filter(action => {
return (action.url() !== null)
})
},
maidenhead () {
let loc = new Maidenhead(this.latitude, this.longitude, 4)
return loc.locator
}
},
mounted () {
this.loadElevation()
},
watch: {
latitude () {
this.loadElevation()
},
longitude () {
this.loadElevation()
}
},
methods: {
onCopySuccess () {
this.$buefy.toast.open({
message: 'Coordinates copied to clipboard',
type: 'is-info'
})
},
onCopyError () {
this.$buefy.toast.open({
message: 'Could not copy coordinates to clipboard',
type: 'is-danger'
})
},
loadElevation () {
this.elevation = null
if (!this.latitude || !this.longitude || !this.showElevation) {
return
}
axios.post('https://ele.sotl.as/api', [[this.latitude, this.longitude]])
.then(result => {
this.elevation = Math.round(result.data[0])
})
}
},
data () {
return {
elevation: null,
actions: [
{
name: 'swisstopo',
url: () => {
if (this.latitude >= 45.7 && this.latitude <= 47.85 && this.longitude >= 5.9 && this.longitude <= 10.9) {
return `https://map.geo.admin.ch/?swisssearch=${this.latitude},${this.longitude}`
}
return null
}
},
{
name: 'BayernAtlas',
url: () => {
if (this.latitude >= 47.25 && this.latitude <= 50.6 && this.longitude >= 9.6 && this.longitude <= 13.85) {
return `https://geoportal.bayern.de/bayernatlas?lon=${this.longitude}&lat=${this.latitude}&zoom=10`
}
return null
}
},
{
name: 'CalTopo',
url: () => {
if (this.latitude >= 14 && this.longitude >= -169 && this.longitude <= -52) {
return `https://caltopo.com/map.html#ll=${this.latitude},${this.longitude}&z=15&b=t&o=f16a%2Cr&n=1,0.25`
}
return null
}
},
{
name: 'OS Maps',
url: () => {
if (this.latitude >= 49.8 && this.latitude <= 60 && this.longitude >= -9 && this.longitude <= 2) {
return `https://osmaps.ordnancesurvey.co.uk/${this.latitude},${this.longitude},15/pin`
}
return null
}
},
{
name: 'Google Maps',
url: () => {
return `https://www.google.com/maps/search/?api=1&query=${this.latitude},${this.longitude}`
}
},
{
name: 'OpenStreetMap',
url: () => {
return `https://www.openstreetmap.org/?mlat=${this.latitude}&mlon=${this.longitude}&zoom=16`
}
},
{
name: 'OpenTopoMap',
url: () => {
return `https://www.opentopomap.org/#marker=16/${this.latitude}/${this.longitude}`
}
},
{
name: 'SummitPost',
url: () => {
return `https://www.summitpost.org/object_list.php?object_type=1&distance_lat_1=${this.latitude}&distance_lon_1=${this.longitude}&map_1=1`
}
},
{
name: 'SOTA Summits',
url: () => {
if (this.reference) {
return `https://summits.sota.org.uk/summit/${this.reference}`
}
return null
}
},
{
name: 'SOTA Map',
url: () => {
if (this.reference) {
return `https://www.sotamaps.org/summit/${this.reference}`
}
return null
}
},
{
name: 'aprs.fi',
url: () => {
return `https://aprs.fi/#!lat=${this.latitude}&lng=${this.longitude}&z=14`
}
},
{
name: 'APRS Direct',
url: () => {
return `https://www.aprsdirect.com/center/${this.latitude},${this.longitude}/zoom/14`
}
}
]
}
}
}
</script>
<style scoped>
.wrapper {
display: inline-block;
}
.coordinates {
margin-right: 0.75em;
}
.locator {
color: #777;
}
.actions {
display: inline-block;
}
</style>

Wyświetl plik

@ -0,0 +1,283 @@
<template>
<span :class="iconClass" :label="label" :title="label"></span>
</template>
<script>
let countryMap = {
'af': 'Afghanistan',
'ax': 'Åland Islands',
'al': 'Albania',
'dz': 'Algeria',
'as': 'American Samoa',
'ad': 'Andorra',
'ao': 'Angola',
'ai': 'Anguilla',
'aq': 'Antarctica',
'ag': 'Antigua and Barbuda',
'ar': 'Argentina',
'am': 'Armenia',
'aw': 'Aruba',
'au': 'Australia',
'at': 'Austria',
'az': 'Azerbaijan',
'bs': 'Bahamas',
'bh': 'Bahrain',
'bd': 'Bangladesh',
'bb': 'Barbados',
'by': 'Belarus',
'be': 'Belgium',
'bz': 'Belize',
'bj': 'Benin',
'bm': 'Bermuda',
'bt': 'Bhutan',
'bo': 'Bolivia',
'bq': 'Bonaire, Sint Eustatius and Saba',
'ba': 'Bosnia and Herzegovina',
'bw': 'Botswana',
'bv': 'Bouvet Island',
'br': 'Brazil',
'io': 'British Indian Ocean Territory',
'bn': 'Brunei Darussalam',
'bg': 'Bulgaria',
'bf': 'Burkina Faso',
'bi': 'Burundi',
'cv': 'Cabo Verde',
'kh': 'Cambodia',
'cm': 'Cameroon',
'ca': 'Canada',
'ky': 'Cayman Islands',
'cf': 'Central African Republic',
'td': 'Chad',
'cl': 'Chile',
'cn': 'China',
'cx': 'Christmas Island',
'cc': 'Cocos (Keeling) Islands',
'co': 'Colombia',
'km': 'Comoros',
'cg': 'Congo',
'cd': 'Congo, Democratic Republic of the',
'ck': 'Cook Islands',
'cr': 'Costa Rica',
'ci': 'Côte d\'Ivoire',
'hr': 'Croatia',
'cu': 'Cuba',
'cw': 'Curaçao',
'cy': 'Cyprus',
'cz': 'Czechia',
'dk': 'Denmark',
'dj': 'Djibouti',
'dm': 'Dominica',
'do': 'Dominican Republic',
'ec': 'Ecuador',
'eg': 'Egypt',
'sv': 'El Salvador',
'gq': 'Equatorial Guinea',
'er': 'Eritrea',
'ee': 'Estonia',
'sz': 'Eswatini',
'et': 'Ethiopia',
'fk': 'Falkland Islands (Malvinas)',
'fo': 'Faroe Islands',
'fj': 'Fiji',
'fi': 'Finland',
'fr': 'France',
'gf': 'French Guiana',
'pf': 'French Polynesia',
'tf': 'French Southern Territories',
'ga': 'Gabon',
'gm': 'Gambia',
'ge': 'Georgia',
'de': 'Germany',
'gh': 'Ghana',
'gi': 'Gibraltar',
'gr': 'Greece',
'gl': 'Greenland',
'gd': 'Grenada',
'gp': 'Guadeloupe',
'gu': 'Guam',
'gt': 'Guatemala',
'gg': 'Guernsey',
'gn': 'Guinea',
'gw': 'Guinea-Bissau',
'gy': 'Guyana',
'ht': 'Haiti',
'hm': 'Heard Island and McDonald Islands',
'va': 'Holy See',
'hn': 'Honduras',
'hk': 'Hong Kong',
'hu': 'Hungary',
'is': 'Iceland',
'in': 'India',
'id': 'Indonesia',
'ir': 'Iran',
'iq': 'Iraq',
'ie': 'Ireland',
'im': 'Isle of Man',
'il': 'Israel',
'it': 'Italy',
'jm': 'Jamaica',
'jp': 'Japan',
'je': 'Jersey',
'jo': 'Jordan',
'kz': 'Kazakhstan',
'ke': 'Kenya',
'ki': 'Kiribati',
'kp': 'Korea (Democratic People\'s Republic of)',
'kr': 'Korea, Republic of',
'kw': 'Kuwait',
'kg': 'Kyrgyzstan',
'la': 'Laos',
'lv': 'Latvia',
'lb': 'Lebanon',
'ls': 'Lesotho',
'lr': 'Liberia',
'ly': 'Libya',
'li': 'Liechtenstein',
'lt': 'Lithuania',
'lu': 'Luxembourg',
'mo': 'Macao',
'mg': 'Madagascar',
'mw': 'Malawi',
'my': 'Malaysia',
'mv': 'Maldives',
'ml': 'Mali',
'mt': 'Malta',
'mh': 'Marshall Islands',
'mq': 'Martinique',
'mr': 'Mauritania',
'mu': 'Mauritius',
'yt': 'Mayotte',
'mx': 'Mexico',
'fm': 'Micronesia (Federated States of)',
'md': 'Moldova',
'mc': 'Monaco',
'mn': 'Mongolia',
'me': 'Montenegro',
'ms': 'Montserrat',
'ma': 'Morocco',
'mz': 'Mozambique',
'mm': 'Myanmar',
'na': 'Namibia',
'nr': 'Nauru',
'np': 'Nepal',
'nl': 'Netherlands',
'nc': 'New Caledonia',
'nz': 'New Zealand',
'ni': 'Nicaragua',
'ne': 'Niger',
'ng': 'Nigeria',
'nu': 'Niue',
'nf': 'Norfolk Island',
'mk': 'North Macedonia',
'mp': 'Northern Mariana Islands',
'no': 'Norway',
'om': 'Oman',
'pk': 'Pakistan',
'pw': 'Palau',
'ps': 'Palestine, State of',
'pa': 'Panama',
'pg': 'Papua New Guinea',
'py': 'Paraguay',
'pe': 'Peru',
'ph': 'Philippines',
'pn': 'Pitcairn',
'pl': 'Poland',
'pt': 'Portugal',
'pr': 'Puerto Rico',
'qa': 'Qatar',
're': 'Réunion',
'ro': 'Romania',
'ru': 'Russian Federation',
'rw': 'Rwanda',
'bl': 'Saint Barthélemy',
'sh': 'Saint Helena, Ascension and Tristan da Cunha',
'kn': 'Saint Kitts and Nevis',
'lc': 'Saint Lucia',
'mf': 'Saint Martin',
'pm': 'Saint Pierre and Miquelon',
'vc': 'Saint Vincent and the Grenadines',
'ws': 'Samoa',
'sm': 'San Marino',
'st': 'Sao Tome and Principe',
'sa': 'Saudi Arabia',
'sn': 'Senegal',
'rs': 'Serbia',
'sc': 'Seychelles',
'sl': 'Sierra Leone',
'sg': 'Singapore',
'sx': 'Sint Maarten',
'sk': 'Slovakia',
'si': 'Slovenia',
'sb': 'Solomon Islands',
'so': 'Somalia',
'za': 'South Africa',
'gs': 'South Georgia and the South Sandwich Islands',
'ss': 'South Sudan',
'es': 'Spain',
'lk': 'Sri Lanka',
'sd': 'Sudan',
'sr': 'Suriname',
'sj': 'Svalbard and Jan Mayen',
'se': 'Sweden',
'ch': 'Switzerland',
'sy': 'Syrian Arab Republic',
'tw': 'Taiwan',
'tj': 'Tajikistan',
'tz': 'Tanzania',
'th': 'Thailand',
'tl': 'Timor-Leste',
'tg': 'Togo',
'tk': 'Tokelau',
'to': 'Tonga',
'tt': 'Trinidad and Tobago',
'tn': 'Tunisia',
'tr': 'Turkey',
'tm': 'Turkmenistan',
'tc': 'Turks and Caicos Islands',
'tv': 'Tuvalu',
'ug': 'Uganda',
'ua': 'Ukraine',
'ae': 'United Arab Emirates',
'gb': 'United Kingdom',
'gb-nir': 'Northern Ireland',
'gb-sct': 'Scotland',
'gb-eng': 'England',
'gb-wls': 'Wales',
'us': 'United States of America',
'um': 'United States Minor Outlying Islands',
'uy': 'Uruguay',
'uz': 'Uzbekistan',
'vu': 'Vanuatu',
've': 'Venezuela',
'vn': 'Viet Nam',
'vg': 'Virgin Islands (British)',
'vi': 'Virgin Islands (U.S.)',
'wf': 'Wallis and Futuna',
'eh': 'Western Sahara',
'ye': 'Yemen',
'zm': 'Zambia',
'zw': 'Zimbabwe'
}
export default {
name: 'CountryFlag',
props: {
country: {
type: String,
required: true
},
rounded: {
type: Boolean,
default: true
}
},
computed: {
iconClass () {
return { 'fp': true, 'fp-rounded': this.rounded, [this.country.toLowerCase()]: true }
},
label () {
return countryMap[this.country.toLowerCase()]
}
}
}
</script>

Wyświetl plik

@ -0,0 +1,30 @@
<template>
<span>{{ displayDistance }}</span>
</template>
<script>
export default {
name: 'DistanceLabel',
props: {
distance: Number,
highPrecision: Boolean
},
computed: {
displayDistance () {
if (this.$store.state.altitudeUnits === 'ft') {
if (this.highPrecision && this.distance < (1000 / 3.28084)) {
return Math.round(this.distance * 3.28084) + ' ft'
} else {
return (this.distance * 0.000621371).toFixed(1) + ' mi'
}
} else {
if (this.highPrecision && this.distance < 1000) {
return Math.round(this.distance) + ' m'
} else {
return (this.distance / 1000).toFixed(1) + ' km'
}
}
}
}
}
</script>

Wyświetl plik

@ -0,0 +1,53 @@
<template>
<div class="action-button download-button">
<b-dropdown>
<b-button slot="trigger" type="is-info" size="is-small" outlined icon-left="file-download" icon-right="angle-down">Download</b-button>
<b-dropdown-item has-link><a :href="makeUrlForType('gpx')">GPX file</a></b-dropdown-item>
<b-dropdown-item has-link><a :href="makeUrlForType('kml')">KML file</a></b-dropdown-item>
<b-dropdown-item separator />
<b-dropdown-item custom disabled><b>Label options</b></b-dropdown-item>
<b-dropdown-item custom><b-checkbox v-model="nameopts" native-value="name">Summit name</b-checkbox></b-dropdown-item>
<b-dropdown-item custom><b-checkbox v-model="nameopts" native-value="altitude">Summit altitude</b-checkbox></b-dropdown-item>
<b-dropdown-item custom><b-checkbox v-model="nameopts" native-value="points">Summit points</b-checkbox></b-dropdown-item>
</b-dropdown>
</div>
</template>
<script>
export default {
props: {
exportUrlPrefix: String,
exportUrlParams: {
type: Object
}
},
methods: {
makeUrlForType (type) {
let params = {}
if (this.exportUrlParams !== null) {
params = { ...this.exportUrlParams }
}
if (this.nameopts.length > 0) {
params.nameopts = this.nameopts.join(',')
}
let url = this.exportUrlPrefix + '.' + type
if (Object.keys(params).length > 0) {
url += '?' + Object.entries(params).map(kv => kv.map(encodeURIComponent).join('=')).join('&')
}
return url
}
},
data () {
return {
nameopts: []
}
}
}
</script>
<style scoped>
>>> .checkbox .control-label {
white-space: nowrap;
}
</style>

Wyświetl plik

@ -0,0 +1,381 @@
<template>
<div class="modal-card" style="width: auto">
<header class="modal-card-head">
<p class="modal-card-title">{{ this.alert ? 'Edit' : 'Add' }} Alert</p>
</header>
<section class="modal-card-body">
<b-field label="Callsign" :message="isOwnCallsign ? '' : 'You are posting an alert for someone else\'s callsign'" :type="isOwnCallsign ? '' : 'is-info'">
<b-input type="text" class="callsign" v-model="callsign" pattern="[a-zA-Z0-9/]{3,}" validation-message="Invalid callsign" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" required />
</b-field>
<b-field label="Summit reference" :message="summitDisplay" :type="summitType" :class="summitLabelClass" expanded>
<b-field>
<b-input type="text" ref="summitCode" v-model="summitCode" placeholder="XX/YY-000" :loading="summitLoading" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" required />
<p class="control">
<NearbySummitsList @summitSelected="onSummitSelected" />
</p>
</b-field>
</b-field>
<b-field label="Activation date" message="dd/mm/yyyy" expanded>
<b-datepicker v-model="date" icon="calendar-day" :min-date="minDate" :date-formatter="dateFormatter" :date-parser="dateParser" :mobile-native="false" required />
</b-field>
<b-field label="ETA" message="e.g. 12:15" class="eta" expanded>
<b-field>
<b-input :type="$mq.mobile ? 'time' : 'text'" pattern="([0-1]{1}[0-9]{1}|20|21|22|23):[0-5]{1}[0-9]{1}" class="time-input" v-model="time" icon="clock" required />
<p class="control">
<b-radio-button v-model="timeZone" native-value="local">Local</b-radio-button>
</p>
<p class="control">
<b-radio-button v-model="timeZone" native-value="utc">UTC</b-radio-button>
</p>
</b-field>
</b-field>
<b-field label="Frequency-Mode(s)" :message="freqModeMessage">
<b-taginput v-model="freqMode" ref="freqMode" autocomplete rounded :data="freqModeSuggestions" :confirm-key-codes="[9,13,32,188]" @typing="updateFreqModeSuggestions" @input="onFreqModeInput" @blur="onFreqModeBlur" @keydown.native="onFreqModeKeyDown" append-to-body />
</b-field>
<b-field label="Comments">
<b-input v-model="comments" type="text" maxlength="60" />
</b-field>
</section>
<footer class="modal-card-foot">
<b-button @click="$parent.close()">Cancel</b-button>
<b-button type="is-info" :disabled="!isInputValid" :loading="posting" @click="postAlert">{{ this.alert ? 'Edit' : 'Add' }} Alert</b-button>
</footer>
</div>
</template>
<script>
import axios from 'axios'
import moment from 'moment'
import utils from '../mixins/utils.js'
import prefs from '../mixins/prefs.js'
import sotawatch from '../mixins/sotawatch.js'
import NearbySummitsList from './NearbySummitsList.vue'
export default {
components: {
NearbySummitsList
},
mixins: [utils, prefs, sotawatch],
props: {
defaultSummitCode: String,
alert: Object
},
prefs: {
key: 'editAlertPrefs',
props: ['lastCallsign', 'timeZone', 'defaultComments']
},
mounted () {
if (!this.callsign) {
if (this.lastCallsign) {
this.callsign = this.lastCallsign
} else if (this.myCallsign) {
this.callsign = this.myCallsign
if (!/\/P$/.test(this.callsign)) {
this.callsign += '/P'
}
}
}
if (!this.timeZone) {
this.timeZone = 'utc'
}
if (!this.comments && this.defaultComments) {
this.comments = this.defaultComments
}
},
computed: {
minDate () {
if (this.timeZone === 'local') {
return moment(moment().startOf('day').format('YYYY-MM-DD')).toDate()
} else {
return moment(moment.utc().startOf('day').format('YYYY-MM-DD')).toDate()
}
},
summitDisplay () {
if (this.summit) {
if (this.$store.state.altitudeUnits === 'ft') {
return this.summit.name + ' (' + Math.round(this.summit.altitude * 3.28084) + ' ft)'
} else {
return this.summit.name + ' (' + this.summit.altitude + ' m)'
}
} else if (this.summitInvalid) {
return 'Summit not found'
} else {
return 'You can enter spaces instead of / and -'
}
},
summitType () {
if (this.summitInvalid) {
return 'is-danger'
} else {
return ''
}
},
freqModeMessage () {
return 'Format: <em>freq-mode, ...</em> (e.g. <em>7.030-cw, 14.250-ssb</em>)'
},
isInputValid () {
return /^[a-zA-Z0-9/]{3,}$/.test(this.callsign) && this.summit !== null && this.isSummitValid(this.summit) && this.date && /^\d\d:\d\d$/.test(this.time) && this.freqMode.length > 0 && (this.freqMode.join(', ').length <= 40 || this.freqMode.join(',').length <= 40)
},
summitLabelClass () {
if (!this.summit || this.isSummitValid(this.summit)) {
return { summitref: true }
} else {
return { summitref: true, invalid: true }
}
},
isOwnCallsign () {
return (!this.callsign || !this.myCallsign || (this.homeCallsign(this.callsign) === this.homeCallsign(this.myCallsign)))
}
},
watch: {
defaultSummitCode: {
immediate: true,
handler () {
if (!this.summitCode) {
this.summitCode = this.defaultSummitCode
}
}
},
summitCode: {
immediate: true,
handler () {
if (this.summitCode) {
// Shorthand input
let summitRegex = /^([A-Z0-9]{1,8})[/ ]([A-Z]{2})[- ]?([0-9]{3})$/i
let matches = this.summitCode.match(summitRegex)
if (matches) {
this.summitCode = (matches[1] + '/' + matches[2] + '-' + matches[3]).toUpperCase()
this.summitLoading = true
axios.get('https://api.sotl.as/summits/' + this.summitCode)
.then(response => {
this.summitLoading = false
this.summitInvalid = false
this.summit = response.data
})
.catch(() => {
this.summitLoading = false
this.summitInvalid = true
this.summit = null
})
} else {
this.summit = null
this.summitInvalid = false
}
} else {
this.summit = null
this.summitInvalid = false
}
}
},
alert: {
immediate: true,
handler () {
if (this.alert) {
this.callsign = this.alert.activatorCallsign
this.summitCode = this.alert.summit.code
this.date = moment(this.alert.dateActivated.substring(0, 19)).toDate()
this.time = moment(this.alert.dateActivated.substring(0, 19)).format('HH:mm')
this.freqMode = this.alert.frequency.split(/\s*,\s*/)
this.comments = this.alert.comments
}
}
},
timeZone (newTimeZone) {
if (!this.date || !this.time) {
return
}
if (newTimeZone === 'local') {
let conv = this.utcToLocal(this.date, this.time)
if (conv) {
this.date = conv.date
this.time = conv.time
}
} else {
let conv = this.localToUtc(this.date, this.time)
if (conv) {
this.date = conv.date
this.time = conv.time
}
}
},
time (newTime) {
// Add colon to nnnn style times
let matches = newTime.match(/^(\d\d)(\d\d)$/)
if (matches) {
this.time = matches[1] + ':' + matches[2]
}
}
},
methods: {
postAlert () {
this.lastCallsign = this.callsign.toUpperCase()
let freqMode = this.freqMode.join(', ')
if (freqMode.length > 40) {
freqMode = this.freqMode.join(',')
}
let utcDate = this.date
let utcTime = this.time
if (this.timeZone === 'local') {
let conv = this.localToUtc(this.date, this.time)
utcDate = conv.date
utcTime = conv.time
}
let params = {
activatingCallsign: this.callsign.toUpperCase(),
associationCode: this.summitCode.substring(0, this.summitCode.indexOf('/')),
summitCode: this.summitCode.substring(this.summitCode.indexOf('/') + 1),
dateActivated: moment(utcDate).format('DD/MM/YYYY'),
eta: utcTime.replace(':', ''),
frequency: freqMode,
comments: this.comments,
posterCallsign: this.myCallsign
}
if (this.alert) {
params.id = this.alert.id
}
this.posting = true
this.postSotaWatchAlert(params)
.then(response => {
this.$store.dispatch('reloadAlerts')
this.$parent.close()
})
.finally(() => {
this.posting = false
})
},
dateFormatter (date) {
return moment(date).format('DD/MM/YYYY')
},
dateParser (date) {
return moment(date).toDate()
},
updateFreqModeSuggestions (text) {
let matches = text.match(/^([0-9.]+)/)
if (matches) {
this.freqModeSuggestions = Object.keys(this.allModes()).map(mode => {
return matches[1] + '-' + mode
}).filter(suggestion => {
return suggestion.startsWith(text.toLowerCase())
})
} else {
this.freqModeSuggestions = []
}
},
onFreqModeInput () {
let splitFreqModes = []
this.freqMode.forEach(fm => {
splitFreqModes = splitFreqModes.concat(fm.split(/\s*[, ]\s*/))
})
this.freqMode = splitFreqModes
},
onFreqModeBlur () {
// Delay to avoid double entry when clicking a tag suggestion
setTimeout(() => {
this.$refs.freqMode.addTag()
}, 100)
},
onFreqModeKeyDown () {
// Hack to allow us to get keep-first behavior on autocomplete despite the fact
// that b-taginput sets keepFirst = !allowNew
if (this.$refs.freqMode.confirmKeyCodes.indexOf(event.keyCode) >= 0) {
event.preventDefault()
this.$refs.freqMode.addTag()
}
},
onSummitSelected (summit) {
this.summitCode = summit.code
this.$nextTick(() => {
this.$refs.summitCode.checkHtml5Validity()
})
},
localToUtc (date, time) {
let utc = moment(moment(date).format('YYYY-MM-DD') + ' ' + time).utc()
if (!utc.isValid()) {
return undefined
}
return {
date: moment(moment(utc).startOf('day').format('YYYY-MM-DD')).toDate(),
time: utc.format('HH:mm')
}
},
utcToLocal (date, time) {
let local = moment.utc(moment(date).format('YYYY-MM-DD') + ' ' + time).local()
if (!local.isValid()) {
return undefined
}
return {
date: moment(moment(local).startOf('day').format('YYYY-MM-DD')).toDate(),
time: local.format('HH:mm')
}
}
},
data () {
return {
callsign: '',
lastCallsign: null,
defaultComments: '',
summitCode: '',
date: new Date(),
time: '',
freqMode: [],
freqModeSuggestions: [],
comments: '',
summit: null,
summitInvalid: false,
summitLoading: false,
timeZone: 'utc',
posting: false
}
}
}
</script>
<style scoped>
.callsign >>> input {
text-transform: uppercase;
}
@media (max-width: 1023px) {
>>> .datepicker .dropdown.is-mobile-modal .dropdown-menu {
width: calc(100vw - 40px);
}
}
>>> .datepicker .dropdown.is-active .dropdown-menu {
position: fixed !important;
left: 50% !important;
top: 50% !important;
transform: translate(-50%, -50%);
z-index: 100;
}
.invalid >>> .help {
text-decoration: line-through;
}
.taginput {
max-width: 30em;
}
.eta .field {
margin-bottom: 0;
}
.eta >>> .time-input {
width: 8em;
}
.summitref .field {
margin-bottom: 0;
}
/* Fix from https://github.com/buefy/buefy/issues/1932#issuecomment-551453842 */
>>> .field.has-addons {
flex-wrap: wrap;
}
>>> .field.has-addons .help {
width: 100%;
}
</style>

Wyświetl plik

@ -0,0 +1,202 @@
<template>
<div class="modal-card" style="width: auto">
<header class="modal-card-head">
<p class="modal-card-title">Edit photo</p>
</header>
<section class="modal-card-body">
<div class="thumb-view">
<img class="thumb" :src="photoSrc(photo, 'thumb')" />
<div class="image-info">
<div>Original size: <strong>{{ photo.width }} x {{ photo.height }}</strong></div>
<div v-if="photo.camera">Camera: <strong>{{ photo.camera }}</strong></div>
<div v-if="photo.positioningError">GPS position accuracy: <strong>± {{ photo.positioningError }} m</strong></div>
<b-checkbox v-model="isCover">Use as cover photo</b-checkbox>
</div>
</div>
<b-field label="Description">
<b-input type="textarea" class="title-area" v-model="title" />
</b-field>
<b-field label="Date/Time">
<b-datetimepicker v-model="date" rounded editable placeholder="Click to select..." icon="calendar-day" :max-datetime="new Date()" append-to-body open-on-focus horizontal-time-picker />
</b-field>
<b-field grouped>
<b-field label="Latitude">
<b-input class="coord" v-model="latitude" placeholder="45.6789" />
</b-field>
<b-field label="Longitude">
<b-input class="coord" v-model="longitude" placeholder="-56.789" />
</b-field>
</b-field>
<b-field label="Direction" message="0° = North, 90° = East, 180° = South, 270° = West">
<b-field>
<b-input class="coord" v-model="direction" expanded />
<p class="control">
<span class="button is-static">°</span>
</p>
</b-field>
</b-field>
</section>
<footer class="modal-card-foot">
<button class="button" type="button" @click="$parent.close()">Cancel</button>
<button class="button is-info" :disabled="!isInputValid" :loading="saving" @click="save">Save</button>
</footer>
</div>
</template>
<script>
import photos from '../mixins/photos.js'
import moment from 'moment'
import api from '../mixins/api.js'
export default {
props: {
summitCode: String,
photo: Object
},
mixins: [photos, api],
computed: {
isInputValid () {
if (this.latitude) {
if (!this.longitude) {
return false
}
let lat = parseFloat(this.latitude)
if (lat < -90 || lat > 90) {
return false
}
}
if (this.longitude) {
if (!this.latitude) {
return false
}
let lon = parseFloat(this.longitude)
if (lon < -180 || lon > 180) {
return false
}
}
if (this.direction) {
let dir = parseFloat(this.direction)
if (dir < 0 || dir >= 360) {
return false
}
}
return true
}
},
methods: {
save () {
let newData = {
title: this.title,
date: this.date ? moment(this.date).utcOffset(0, true).toDate() : null,
isCover: this.isCover
}
if (this.latitude && this.longitude) {
newData.coordinates = {
latitude: parseFloat(this.latitude),
longitude: parseFloat(this.longitude)
}
if (!this.photo.coordinates ||
Math.abs(newData.coordinates.latitude - this.photo.coordinates.latitude) > 0.000001 ||
Math.abs(newData.coordinates.longitude - this.photo.coordinates.longitude) > 0.000001) {
// Latitude or longitude changed by user; set positioningError to 0
newData.positioningError = 0
} else {
newData.positioningError = parseFloat(this.photo.positioningError)
}
newData.direction = parseFloat(this.direction)
}
this.saving = true
this.editPhoto(this.summitCode, this.photo.filename, newData)
.then(() => {
this.$emit('photoEdited')
this.$parent.close()
})
.finally(() => {
this.saving = false
})
}
},
watch: {
photo: {
handler (newPhoto) {
this.title = newPhoto.title
if (newPhoto.date) {
this.date = moment(newPhoto.date.replace('Z', '')).toDate()
} else {
this.date = null
}
if (newPhoto.coordinates) {
this.latitude = newPhoto.coordinates.latitude
this.longitude = newPhoto.coordinates.longitude
} else {
this.latitude = null
this.longitude = null
}
this.direction = newPhoto.direction
this.isCover = newPhoto.isCover
},
immediate: true
},
latitude (newLatitude) {
// Catch comma separated lat/lon
if (newLatitude.includes(',')) {
let latlon = newLatitude.split(',')
this.latitude = latlon[0].trim()
this.longitude = latlon[1].trim()
}
}
},
data () {
return {
title: null,
date: null,
latitude: null,
longitude: null,
direction: null,
isCover: null,
saving: false
}
}
}
</script>
<style scoped>
.thumb {
max-height: 96px;
margin-right: 1em;
border: 1px solid #e0e0e0;
}
.thumb-view {
display: flex;
margin-bottom: 1em;
}
.title-area >>> .textarea {
min-height: 5em !important;
}
@media (max-width: 1023px) {
>>> .datepicker .dropdown.is-mobile-modal .dropdown-menu {
width: calc(100vw - 40px);
}
}
@media (min-width: 769px) {
.title-area >>> .textarea {
min-width: 24em;
}
.image-info {
font-size: 0.8em;
}
}
.coord {
max-width: 8em;
}
.image-info .checkbox {
margin-top: 0.5em;
}
</style>

Wyświetl plik

@ -0,0 +1,279 @@
<template>
<div class="modal-card" style="width: auto">
<header class="modal-card-head">
<p class="modal-card-title">{{ this.spot ? 'Edit' : 'Add' }} Spot</p>
</header>
<section class="modal-card-body">
<b-field label="Callsign" :message="isOwnCallsign ? '' : 'You are spotting someone else\'s callsign'" :type="isOwnCallsign ? '' : 'is-info'">
<b-input type="text" class="callsign" v-model="callsign" pattern="[a-zA-Z0-9/]{3,}" validation-message="Invalid callsign" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" required />
</b-field>
<b-field label="Summit reference" :message="summitDisplay" :type="summitType" :class="summitLabelClass" expanded>
<b-field>
<b-input type="text" ref="summitCode" v-model="summitCode" placeholder="XX/YY-000" :loading="summitLoading" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" required />
<p class="control">
<NearbySummitsList @summitSelected="onSummitSelected" />
</p>
</b-field>
</b-field>
<b-field label="Frequency" :message="maybeKhz ? 'Do you really mean ' + frequency + ' MHz, or are you missing a dot?' : ''" :type="maybeKhz ? 'is-warning' : ''">
<b-field :type="maybeKhz ? 'is-warning' : ''">
<b-input v-model="frequency" type="number" step="any" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" required />
<p class="control">
<span class="button is-static">MHz</span>
</p>
</b-field>
</b-field>
<b-field label="Mode">
<b-field>
<b-radio-button v-for="(curModeDisp, curMode) in allModes()" :key="curMode" v-model="mode" :size="$mq.mobile ? 'is-small' : ''" :native-value="curMode">{{ curModeDisp }}</b-radio-button>
</b-field>
</b-field>
<b-field label="Comments">
<b-input v-model="comments" type="text" maxlength="60" />
</b-field>
</section>
<footer class="modal-card-foot">
<b-button @click="$parent.close()">Cancel</b-button>
<b-button type="is-info" :disabled="!isInputValid" :loading="posting" @click="postSpot">{{ (this.spot && this.spot.id) ? 'Edit' : 'Add' }} Spot</b-button>
</footer>
</div>
</template>
<script>
import axios from 'axios'
import utils from '../mixins/utils.js'
import prefs from '../mixins/prefs.js'
import sotawatch from '../mixins/sotawatch.js'
import NearbySummitsList from './NearbySummitsList.vue'
export default {
components: {
NearbySummitsList
},
mixins: [utils, prefs, sotawatch],
props: {
defaultSummitCode: String,
spot: Object
},
prefs: {
key: 'spotPrefs',
props: ['lastCallsign', 'lastSummitCode', 'defaultComments']
},
mounted () {
if (!this.callsign) {
if (this.lastCallsign) {
this.callsign = this.lastCallsign
} else if (this.myCallsign) {
this.callsign = this.myCallsign
if (!/\/P$/.test(this.callsign)) {
this.callsign += '/P'
}
}
}
if (!this.summitCode) {
if (this.lastSummitCode) {
this.summitCode = this.lastSummitCode
}
}
if (!this.comments && this.defaultComments) {
this.comments = this.defaultComments
}
},
computed: {
summitDisplay () {
if (this.summit) {
if (this.$store.state.altitudeUnits === 'ft') {
return this.summit.name + ' (' + Math.round(this.summit.altitude * 3.28084) + ' ft)'
} else {
return this.summit.name + ' (' + this.summit.altitude + ' m)'
}
} else if (this.summitInvalid) {
return 'Summit not found'
} else {
return 'You can enter spaces instead of / and -'
}
},
summitType () {
if (this.summitInvalid) {
return 'is-danger'
} else {
return ''
}
},
isInputValid () {
return /^[a-zA-Z0-9/]{3,}$/.test(this.callsign) && this.summit !== null && this.isSummitValid(this.summit) && this.frequency && this.mode
},
summitLabelClass () {
if (!this.summit || this.isSummitValid(this.summit)) {
return { summitref: true }
} else {
return { summitref: true, invalid: true }
}
},
isOwnCallsign () {
return (!this.callsign || !this.myCallsign || (this.homeCallsign(this.callsign) === this.homeCallsign(this.myCallsign)))
},
maybeKhz () {
return (this.frequency && this.frequency > 1500)
}
},
watch: {
defaultSummitCode: {
immediate: true,
handler () {
if (!this.summitCode) {
this.summitCode = this.defaultSummitCode
}
}
},
summitCode: {
immediate: true,
handler () {
if (this.summitCode) {
// Shorthand input
let summitRegex = /^([A-Z0-9]{1,8})[/ ]([A-Z]{2})[- ]?([0-9]{3})$/i
let matches = this.summitCode.match(summitRegex)
if (matches) {
this.summitCode = (matches[1] + '/' + matches[2] + '-' + matches[3]).toUpperCase()
this.summitLoading = true
axios.get('https://api.sotl.as/summits/' + this.summitCode)
.then(response => {
this.summitLoading = false
this.summitInvalid = false
this.summit = response.data
})
.catch(() => {
this.summitLoading = false
this.summitInvalid = true
this.summit = null
})
} else {
this.summit = null
this.summitInvalid = false
}
} else {
this.summit = null
this.summitInvalid = false
}
}
},
spot: {
immediate: true,
handler () {
if (this.spot) {
this.callsign = this.spot.activatorCallsign
this.summitCode = this.spot.summit.code
this.frequency = this.spot.frequency
this.mode = this.spot.mode.toLowerCase()
this.comments = (this.spot.comments ? this.spot.comments.replace('[sotl.as]', '').trim() : '')
}
}
}
},
methods: {
postSpot () {
this.lastCallsign = this.callsign.toUpperCase()
this.lastSummitCode = this.summitCode
// Advertise in comments :)
let commentsTag = '[sotl.as]'
let comments = this.comments
if ((comments.length + commentsTag.length) < 60 && !comments.endsWith(commentsTag)) {
if (comments.length > 0) {
comments += ' '
}
comments += commentsTag
}
let params = {
callsign: this.myCallsign,
activatorCallsign: this.callsign.toUpperCase(),
associationCode: this.summitCode.substring(0, this.summitCode.indexOf('/')),
summitCode: this.summitCode.substring(this.summitCode.indexOf('/') + 1),
frequency: this.frequency,
mode: this.allModes()[this.mode],
comments
}
if (this.spot && this.spot.id) {
params.id = this.spot.id
}
this.posting = true
this.postSotaWatchSpot(params)
.then(response => {
this.$store.commit('updateSpot', {
id: (this.spot && this.spot.id) ? this.spot.id : response.data.id,
timeStamp: response.data.timeStamp,
frequency: response.data.frequency,
mode: response.data.mode,
summit: this.summit,
activatorCallsign: response.data.activatorCallsign,
callsign: response.data.callsign,
comments: response.data.comments
})
this.$parent.close()
})
.finally(() => {
this.posting = false
})
},
onSummitSelected (summit) {
this.summitCode = summit.code
this.$nextTick(() => {
this.$refs.summitCode.checkHtml5Validity()
})
}
},
data () {
return {
callsign: '',
lastCallsign: null,
defaultComments: '',
summitCode: '',
lastSummitCode: null,
frequency: '',
mode: '',
comments: '',
summit: null,
summitInvalid: false,
summitLoading: false,
posting: false
}
}
}
</script>
<style scoped>
.callsign >>> input {
text-transform: uppercase;
}
.invalid >>> .help {
text-decoration: line-through;
}
>>> input::-webkit-outer-spin-button,
>>> input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
>>> input[type=number] {
-moz-appearance:textfield;
}
.summitref .field {
margin-bottom: 0;
}
>>> .help.is-warning {
color: #cda400;
}
/* Fix from https://github.com/buefy/buefy/issues/1932#issuecomment-551453842 */
>>> .field.has-addons {
flex-wrap: wrap;
}
>>> .field.has-addons .help {
width: 100%;
}
</style>

Wyświetl plik

@ -0,0 +1,49 @@
<template>
<b-input :value="value" ref="filter" :placeholder="placeholder" type="search" icon-pack="far" icon="search" :class="{ filter: true, invalid }" :size="size" rounded @input="updateValue" />
</template>
<script>
export default {
name: 'FilterInput',
props: {
value: String,
size: String,
placeholder: {
type: String,
default: 'Filter'
},
isRegex: Boolean
},
computed: {
invalid () {
if (!this.isRegex) {
return false
}
try {
RegExp(this.value)
return false
} catch (e) {
return true
}
}
},
methods: {
updateValue (value) {
this.$emit('input', value)
},
focus () {
this.$refs.filter.focus()
}
}
}
</script>
<style scoped>
.filter {
max-width: 20em;
}
.filter.invalid >>> input {
background-color: #ffeeee;
}
</style>

Wyświetl plik

@ -0,0 +1,33 @@
<template>
<footer class="footer">
<div class="content has-text-centered">
<p>
<strong>SOTA Atlas</strong> by Manuel HB9DQM. <router-link to="/about">About</router-link>
</p>
<p class="version">
{{ version }}
</p>
</div>
</footer>
</template>
<script>
export default {
name: 'Footer',
computed: {
version () {
return VERSION + '-' + COMMITHASH.substring(0, 7)
}
}
}
</script>
<style scoped>
.version {
font-size: 0.8em;
color: #777;
}
.content p {
margin-bottom: 0;
}
</style>

Wyświetl plik

@ -0,0 +1,97 @@
<template>
<div ref="chart"></div>
</template>
<script>
import { Chart } from 'frappe-charts/dist/frappe-charts.min.esm'
export default {
props: {
data: Array,
labelField: String,
valueField: String,
valueFieldB: String,
name: String,
nameB: String,
xIsSeries: {
type: Boolean,
default: false
},
animate: {
type: Boolean,
default: true
},
suffixX: {
type: String,
default: ''
},
suffixY: {
type: String,
default: ''
}
},
methods: {
updateChart () {
let labels = []
let values = []
let valuesB = []
this.data.forEach(row => {
labels.push(row[this.labelField])
values.push(row[this.valueField])
})
let datasets = [{
values,
name: this.name
}]
if (this.valueFieldB) {
this.data.forEach(row => {
valuesB.push(row[this.valueFieldB])
})
datasets.push({
values: valuesB,
name: this.nameB
})
}
this.chart = new Chart(this.$refs.chart, {
data: {
labels,
datasets: datasets
},
type: 'line',
height: 250,
colors: ['red'],
axisOptions: {
xAxisMode: 'tick',
xIsSeries: this.xIsSeries
},
lineOptions: {
hideDots: true,
regionFill: true
},
tooltipOptions: {
formatTooltipX: d => d + this.suffixX,
formatTooltipY: d => d + this.suffixY
},
animate: this.animate
})
}
},
watch: {
data () {
this.updateChart()
}
},
mounted () {
this.updateChart()
}
}
</script>
<style scoped>
>>> .graph-svg-tip .title {
color: #fff;
}
</style>

Wyświetl plik

@ -0,0 +1,23 @@
<template>
<div v-if="$mq.mobile" class="liveinfo">
LIVE <span :class="{ indicator: true, connected: this.$store.state.socket.isConnected }"></span>
</div>
<b-tooltip v-else class="liveinfo" type="is-info" label="New spots appear immediately, no need to refresh the page" position="is-left">
<b-taglist attached>
<b-tag type="is-dark">Live Feed</b-tag>
<b-tag :type="this.$store.state.socket.isConnected ? 'is-success' : 'is-danger'">{{ this.$store.state.socket.isConnected ? 'CONNECTED' : 'DISCONNECTED' }}</b-tag>
</b-taglist>
</b-tooltip>
</template>
<style scoped>
.liveinfo {
margin-left: 1em;
}
.indicator {
color: #dd0000;
}
.indicator.connected {
color: #00dd00;
}
</style>

Wyświetl plik

@ -0,0 +1,36 @@
<template>
<div class="lds-dual-ring"></div>
</template>
<script>
export default {
name: 'LoadingRing'
}
</script>
<style scoped>
.lds-dual-ring {
display: inline-block;
width: 46px;
height: 46px;
}
.lds-dual-ring:after {
content: " ";
display: block;
width: 46px;
height: 46px;
margin: 1px;
border-radius: 50%;
border: 5px solid #aaa;
border-color: #aaa transparent #aaa transparent;
animation: lds-dual-ring 1.2s linear infinite;
}
@keyframes lds-dual-ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

Wyświetl plik

@ -0,0 +1,30 @@
<template>
<div class="lds-dual-ring"></div>
</template>
<style scoped>
.lds-dual-ring {
display: inline-block;
width: 1em;
height: 1em;
vertical-align: -0.125em;
}
.lds-dual-ring:after {
content: " ";
display: block;
width: 1em;
height: 1em;
border-radius: 50%;
border: 0.1em solid #a0a0a0;
border-color: #a0a0a0 transparent #a0a0a0 transparent;
animation: lds-dual-ring 1.2s linear infinite;
}
@keyframes lds-dual-ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

Wyświetl plik

@ -0,0 +1,58 @@
<template>
<div>
<b-table :narrowed="true" :paginated="true" :striped="true" :default-sort="['activationDate', 'desc']" :per-page="15" :data="data" :mobile-cards="false">
<template slot-scope="props">
<b-table-column field="activationDate" label="Date" sortable>
{{ props.row.activationDate | formatActivationDate }}
</b-table-column>
<b-table-column field="ownCallsign" label="Activator" sortable>
<router-link :to="makeActivatorLink(props.row.ownCallsign.toUpperCase())">{{ props.row.ownCallsign.toUpperCase() }}</router-link>
</b-table-column>
<b-table-column field="qsos" label="QSOs" sortable numeric>
<span class="qsos" @click="openQsoList(props.row.id)">{{ props.row.qsos }}</span>
<font-awesome-icon :icon="['far', 'th-list']" class="faicon qsos" @click="openQsoList(props.row.id)" />
</b-table-column>
</template>
</b-table>
<ModalQSOList :activationId="modalActivationId" @modalClosed="modalActivationId = null" />
</div>
</template>
<script>
import utils from '../mixins/utils.js'
import sotadb from '../mixins/sotadb.js'
import ModalQSOList from '../components/ModalQSOList.vue'
export default {
props: {
data: Array
},
mixins: [utils, sotadb],
components: { ModalQSOList },
data () {
return {
modalActivationId: null
}
},
methods: {
openQsoList (activationId) {
if (!this.authenticated) {
this.$buefy.dialog.alert('Please log in to view QSOs.')
return
}
this.modalActivationId = activationId
}
}
}
</script>
<style scoped>
.faicon {
margin-left: 0.4em;
}
.qsos {
color: #3273dc;
cursor: pointer;
}
</style>

Wyświetl plik

@ -0,0 +1,61 @@
<template>
<b-dropdown v-if="authenticated" position="is-bottom-left" aria-role="menu">
<a class="navbar-item callsign" slot="trigger" role="button"><span>{{ $keycloak.tokenParsed.callsign ? $keycloak.tokenParsed.callsign : $keycloak.userName }}</span><b-icon icon="angle-down"></b-icon></a>
<b-dropdown-item v-if="$keycloak.tokenParsed.callsign" has-link><router-link :to="'/activators/' + $keycloak.tokenParsed.callsign" @click.native="$emit('linkClicked')">My activator page</router-link></b-dropdown-item>
<b-dropdown-item @click="doAccountManagement">Manage account</b-dropdown-item>
<b-dropdown-item @click="doLogout">Logout</b-dropdown-item>
</b-dropdown>
<div v-else class="navbar-item">
<b-button type="is-info" @click="doLogin">Login</b-button>
</div>
</template>
<script>
import utils from '../mixins/utils.js'
export default {
mixins: [utils],
methods: {
doLogin () {
this.$emit('linkClicked')
if (!this.$keycloak) {
sessionStorage.setItem('wantSso', 'true')
sessionStorage.setItem('wantSsoLogin', 'true')
window.location.reload()
} else {
this.$keycloak.login()
}
},
doAccountManagement () {
this.$emit('linkClicked')
this.$keycloak.accountManagement()
},
doLogout () {
this.$emit('linkClicked')
localStorage.removeItem('wantSso')
sessionStorage.removeItem('wantSso')
this.$keycloak.logoutFn()
}
},
watch: {
authenticated: {
immediate: true,
handler (newAuthenticated) {
if (newAuthenticated) {
// Assume that if they have logged on successfully, they will want to do so every time they come back
localStorage.setItem('wantSso', 'true')
}
}
}
}
}
</script>
<style scoped>
.callsign {
font-weight: bold;
}
.dropdown-trigger .icon {
vertical-align: middle;
}
</style>

Wyświetl plik

@ -0,0 +1,28 @@
<template>
<div v-if="!$mq.mobile" class="mapboxgl-ctrl-group mapboxgl-ctrl">
<button :class="{ 'mapboxgl-ctrl-icon': true, 'mapbox-gl-download': true }" type="button" title="Download map" @click="downloadMap" />
</div>
</template>
<script>
export default {
name: 'MapDownloadControl',
inject: ['map'],
methods: {
downloadMap () {
let link = document.createElement('a')
link.download = 'map.png'
link.href = this.map.getCanvas().toDataURL()
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
}
}
</script>
<style scoped>
.mapbox-gl-download {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M15.6 11.676v3.613a.52.52 0 01-.52.52H4.89a.52.52 0 01-.52-.52v-3.613H3.33v3.613c0 .86.7 1.56 1.56 1.56h10.19c.86 0 1.56-.7 1.56-1.56v-3.613z'/%3E%3Cpath d='M13.216 10.402l-.735-.735-1.975 1.975V3.54h-1.04v8.103L7.49 9.667l-.735.735 3.23 3.23z'/%3E%3C/svg%3E");
}
</style>

Wyświetl plik

@ -0,0 +1,477 @@
<template>
<div>
<div v-if="chartData || loading" class="elevation-chart">
<div class="elevation-controls">
<b-button size="is-small" type="is-text" icon-left="window-close" @click="hideElevationProfile" />
</div>
<b-loading :active="loading" :is-full-page="false" />
<LineChart v-if="chartData" :data="chartData" labelField="distance" valueField="elevation" name="Elevation" :xIsSeries="true" :animate="false" :suffixX="' ' + distanceUnits" :suffixY="' ' + $store.state.altitudeUnits" />
</div>
</div>
</template>
<script>
import MapboxDraw from '@mapbox/mapbox-gl-draw'
import haversineDistance from 'haversine-distance'
import cheapRuler from 'cheap-ruler'
import togpx from 'togpx'
import moment from 'moment'
import axios from 'axios'
import utils from '../mixins/utils.js'
import LineChart from './LineChart.vue'
import { gpx, kml } from '@tmcw/togeojson'
export default {
components: { LineChart },
inject: ['map'],
mixins: [utils],
mounted () {
this.setupDraw()
},
watch: {
map () {
this.setupDraw()
}
},
computed: {
distanceUnits () {
return (this.$store.state.altitudeUnits === 'ft' ? 'mi' : 'km')
}
},
methods: {
isDrawing () {
return (this.draw && this.draw.getMode() !== 'simple_select')
},
setupDraw () {
if (!this.map || this.draw) {
return
}
this.draw = new MapboxDraw({
controls: {
point: true,
line_string: true,
trash: true,
open: true,
save: true
},
displayControlsDefault: false,
styles: [
{
'id': 'gl-draw-line-midpoint',
'type': 'circle',
'filter': ['all', ['==', '$type', 'Point'], ['==', 'meta', 'midpoint']],
'paint': {
'circle-radius': 3,
'circle-color': '#d20c0c'
}
},
{
'id': 'gl-draw-line-inactive',
'type': 'line',
'filter': ['all', ['==', 'active', 'false'], ['==', '$type', 'LineString'], ['!=', 'mode', 'static']],
'layout': {
'line-cap': 'round',
'line-join': 'round'
},
'paint': {
'line-color': '#d20c0c',
'line-width': 2
}
},
{
'id': 'gl-draw-line-active',
'type': 'line',
'filter': ['all', ['==', '$type', 'LineString'], ['==', 'active', 'true']],
'layout': {
'line-cap': 'round',
'line-join': 'round'
},
'paint': {
'line-color': '#d20c0c',
'line-dasharray': [0.2, 2],
'line-width': 2
}
},
{
'id': 'gl-draw-polygon-and-line-vertex-stroke-inactive',
'type': 'circle',
'filter': ['all', ['==', 'meta', 'vertex'], ['==', '$type', 'Point'], ['!=', 'mode', 'static']],
'paint': {
'circle-radius': 7,
'circle-color': '#fff'
}
},
{
'id': 'gl-draw-polygon-and-line-vertex-inactive',
'type': 'circle',
'filter': ['all', ['==', 'meta', 'vertex'], ['==', '$type', 'Point'], ['!=', 'mode', 'static']],
'paint': {
'circle-radius': 5,
'circle-color': '#d20c0c'
}
},
{
'id': 'gl-draw-point-point-stroke-inactive',
'type': 'circle',
'filter': ['all', ['==', 'active', 'false'], ['==', '$type', 'Point'], ['==', 'meta', 'feature'], ['!=', 'mode', 'static']],
'paint': {
'circle-radius': 7,
'circle-opacity': 1,
'circle-color': '#fff'
}
},
{
'id': 'gl-draw-point-inactive',
'type': 'circle',
'filter': ['all', ['==', 'active', 'false'], ['==', '$type', 'Point'], ['==', 'meta', 'feature'], ['!=', 'mode', 'static']],
'paint': {
'circle-radius': 5,
'circle-color': '#d20c0c'
}
},
{
'id': 'gl-draw-point-active',
'type': 'circle',
'filter': ['all', ['==', '$type', 'Point'], ['!=', 'meta', 'midpoint'], ['==', 'active', 'true']],
'paint': {
'circle-radius': 9,
'circle-color': '#d20c0c'
}
},
{
'id': 'gl-draw-point-stroke-active',
'type': 'circle',
'filter': ['all', ['==', '$type', 'Point'], ['==', 'active', 'true'], ['!=', 'meta', 'midpoint']],
'paint': {
'circle-radius': 7,
'circle-color': '#fff'
}
}
]
})
this.map.addControl(this.draw, 'top-right')
this.map.addSource('_measurements', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: []
}
})
this.map.addLayer({
id: '_measurements_endpoint',
source: '_measurements',
type: 'symbol',
minzoom: 9,
filter: ['all', ['==', 'measType', 'endpoint']],
paint: {
'text-color': '#d20c0c',
'text-halo-color': '#fff',
'text-halo-width': 2
},
layout: {
'text-font': ['Open Sans Regular'],
'text-field': '{label}',
'text-size': { 'stops': [[9, 9], [12, 14]] },
'text-allow-overlap': true,
'text-ignore-placement': true,
'text-offset': [1.5, 1.5]
}
})
this.map.addLayer({
id: '_measurements_interval',
source: '_measurements',
type: 'symbol',
minzoom: 12.5,
filter: ['all', ['==', 'measType', 'interval']],
paint: {
'text-color': '#d20c0c'
},
layout: {
'text-font': ['Open Sans Regular'],
'text-field': '{label}',
'text-size': 10,
'icon-image': 'us-state_2'
}
})
// Update measurements on render
this.map.on('draw.render', e => {
let labelFeatures = []
let all = this.draw.getAll()
if (all && all.features) {
let selected = this.draw.getSelectedIds()
let ruler = cheapRuler(this.map.getCenter().lat, 'meters')
all.features.forEach(feature => {
if (feature.geometry.type === 'LineString') {
let distance = ruler.lineDistance(feature.geometry.coordinates)
if (distance > 0) {
// 1 km (or 1 mi) interval markers along line, unless selected
if (!selected.includes(feature.id) && distance < 100000) {
let markerOffset = this.$store.state.altitudeUnits === 'ft' ? 1609.3445 : 1000
let i = 1
while (markerOffset < distance) {
if (distance - markerOffset < 200) {
break
}
let intervalCoords = ruler.along(feature.geometry.coordinates, markerOffset)
labelFeatures.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: intervalCoords
},
properties: {
label: i,
measType: 'interval'
}
})
i++
markerOffset = i * (this.$store.state.altitudeUnits === 'ft' ? 1609.3445 : 1000)
}
}
// Total distance at endpoint
labelFeatures.push({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: feature.geometry.coordinates[feature.geometry.coordinates.length - 1]
},
properties: {
label: this.formatDistance(distance),
measType: 'endpoint'
}
})
}
}
})
}
this.map.getSource('_measurements').setData({
type: 'FeatureCollection',
features: labelFeatures
})
this.map.moveLayer('_measurements_interval')
})
this.map.on('draw.open', e => {
this.input = document.createElement('input')
this.input.setAttribute('type', 'file')
this.input.setAttribute('accept', '.gpx,.kml,application/gpx+xml,application/vnd.google-earth.kml+xml')
this.input.addEventListener('change', (e) => {
for (let i = 0; i < e.target.files.length; i++) {
let reader = new FileReader()
reader.onload = e => {
try {
let dom = new DOMParser().parseFromString(e.target.result, 'text/xml')
if (!dom) {
throw new Error('Bad XML document')
}
if (dom.documentElement.tagName === 'kml') {
this.draw.set(kml(dom))
} else {
this.draw.set(gpx(dom))
}
} catch (e) {
console.error(e)
}
}
reader.readAsText(e.target.files[i])
}
}, false)
this.input.click()
})
this.map.on('draw.save', e => {
let all = this.draw.getAll()
if (all && all.features && all.features.length > 0) {
const loadingComponent = this.$buefy.loading.open()
this.addElevations(all)
.then(() => {
loadingComponent.close()
let gpx = togpx(all)
let blob = new Blob([gpx], { type: 'application/gpx+xml' })
let url = window.URL.createObjectURL(blob)
let link = document.createElement('a')
link.download = 'sotlas-' + moment().format('YYYYMMDD-HHmmss') + '.gpx'
link.href = url
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
})
} else {
alert('Draw at least one line or point before saving your drawing.')
}
})
this.map.on('draw.selectionchange', e => {
this.updateElevationProfile()
})
this.map.on('draw.delete', e => {
this.updateElevationProfile()
})
this.map.on('draw.update', e => {
this.updateElevationProfile(true)
})
},
calcDistance (coordinates) {
if (coordinates.length < 2) {
return 0
}
let distance = 0
for (let i = 1; i < coordinates.length; i++) {
distance += haversineDistance(
{ lng: coordinates[i - 1][0], lat: coordinates[i - 1][1] },
{ lng: coordinates[i][0], lat: coordinates[i][1] }
)
}
return distance
},
formatDistance (distance) {
if (distance === 0) {
return ''
}
if (this.$store.state.altitudeUnits === 'ft') {
return (distance * 0.000621371).toFixed(2) + ' mi'
} else {
if (distance > 1000) {
return (distance / 1000).toFixed(2) + ' km'
} else {
return distance.toFixed(0) + ' m'
}
}
},
updateElevationProfile (forceUpdate = false) {
let selectedFeatures = this.draw.getSelected()
if (selectedFeatures.type !== 'FeatureCollection') {
return
}
selectedFeatures = selectedFeatures.features
if (selectedFeatures.length === 1 &&
selectedFeatures[0].type === 'Feature' && selectedFeatures[0].geometry.type === 'LineString') {
if (forceUpdate || this.selectedFeatureId !== selectedFeatures[0].id) {
this.selectedFeatureId = selectedFeatures[0].id
this.showElevationProfile(selectedFeatures[0].geometry.coordinates)
}
} else {
this.selectedFeatureId = null
this.hideElevationProfile()
}
},
showElevationProfile (coordinates) {
if (coordinates.length < 2) {
return
}
// Make an elevation profile by sampling the line described by the coordinates at 100 m intervals,
// or whichever interval size is needed to stay below 300 samples
let ruler = cheapRuler(this.map.getCenter().lat, 'meters')
let distance = ruler.lineDistance(coordinates)
let interval = Math.max(distance / 300, 100)
let eleCoordinates = []
let distances = []
let markerOffset
for (markerOffset = 0; markerOffset < distance; markerOffset += interval) {
let intervalCoords = ruler.along(coordinates, markerOffset)
distances.push(markerOffset)
eleCoordinates.push([intervalCoords[1], intervalCoords[0]])
}
// Ensure final point is added as well
if ((distance - markerOffset + interval) > 5) {
let last = coordinates[coordinates.length - 1]
eleCoordinates.push([last[1], last[0]])
distances.push(distance)
}
this.loading = true
axios.post('https://ele.sotl.as/api', eleCoordinates)
.then(result => {
this.chartData = result.data.map((elevation, i) => {
return {
distance: this.renderDistance(distances[i]),
elevation: this.renderElevation(elevation)
}
})
this.loading = false
})
.finally(() => {
this.loading = false
})
},
hideElevationProfile () {
this.chartData = null
},
renderElevation (elevation) {
if (this.$store.state.altitudeUnits === 'ft') {
return Math.round(elevation * 3.28084)
} else {
return Math.round(elevation)
}
},
renderDistance (distance) {
if (this.$store.state.altitudeUnits === 'ft') {
return (distance * 0.000621371).toFixed(1)
} else {
return (distance / 1000).toFixed(1)
}
},
addElevations (obj) {
if (obj.type !== 'FeatureCollection') {
return
}
return Promise.all(obj.features.map(feature => {
if (feature.type !== 'Feature' || feature.geometry.type !== 'LineString') {
return
}
let coordsSwapped = feature.geometry.coordinates.map(coord => [coord[1], coord[0]])
return axios.post('https://ele.sotl.as/api', coordsSwapped)
.then(result => {
result.data.forEach((elevation, index) => {
if (feature.geometry.coordinates[index].length === 2) {
feature.geometry.coordinates[index].push(Math.round(elevation))
}
})
})
}))
}
},
data () {
return {
chartData: null,
loading: false,
selectedFeatureId: null
}
}
}
</script>
<style scoped>
.elevation-chart {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 1.5rem;
width: 800px;
max-width: 80%;
height: 250px;
background: white;
filter: drop-shadow(10px 10px 16px rgba(0,0,0,0.2));
}
.elevation-controls {
position: absolute;
background: white;
right: 0px;
z-index: 10;
}
</style>

Wyświetl plik

@ -0,0 +1,22 @@
<template>
<div class="mapboxgl-ctrl-group mapboxgl-ctrl">
<button :class="{ 'enlarge-control': true, 'mapboxgl-ctrl-fullscreen': !isEnlarged, 'mapboxgl-ctrl-shrink': isEnlarged }" type="button" :title="isEnlarged ? 'Shrink' : 'Enlarge'" @click="$emit('enlarge')"><span class="mapboxgl-ctrl-icon"></span></button>
</div>
</template>
<script>
export default {
props: {
isEnlarged: Boolean
}
}
</script>
<style scoped>
@media (max-width: 768px) {
.enlarge-control {
width: 40px;
height: 40px;
}
}
</style>

Wyświetl plik

@ -0,0 +1,352 @@
<template>
<div :class="{ 'mapboxgl-ctrl-group': true, 'mapboxgl-ctrl': true, 'mapbox-gl-filter-container': true }">
<button :class="{ 'mapboxgl-ctrl-icon': true, 'mapbox-gl-filter': true, active: active }" type="button" title="Toggle filter" @click="toggleFilter" />
<div v-if="open" class="filter-container">
<div class="filter-criterion">
<b-field>
<b-checkbox v-model="activationsEnabled" size="is-small">Activations</b-checkbox>
</b-field>
<b-field grouped>
<b-input v-model="activationsFrom" class="count" placeholder="min." size="is-small" :disabled="!activationsEnabled" />
<div class="tlabel">to</div>
<b-input v-model="activationsTo" class="count" placeholder="max." size="is-small" :disabled="!activationsEnabled" />
</b-field>
<b-field grouped>
<b-button class="control" size="is-small" @click="setActivations(0, 0)">Never</b-button>
<b-button class="control" size="is-small" @click="setActivations(0, 3)">Rarely</b-button>
</b-field>
</div>
<div class="filter-criterion">
<b-field>
<b-checkbox v-model="pointsEnabled" size="is-small">Points</b-checkbox>
</b-field>
<b-field grouped>
<b-select v-model="pointsFrom" placeholder="min." size="is-small" :disabled="!pointsEnabled">
<option v-for="point in points" :key="point" :value="point">{{ point }}</option>
</b-select>
<div class="tlabel">to</div>
<b-select v-model="pointsTo" placeholder="max." size="is-small" :disabled="!pointsEnabled">
<option v-for="point in points" :key="point" :value="point">{{ point }}</option>
</b-select>
</b-field>
</div>
<div class="filter-criterion">
<b-field>
<b-checkbox v-model="altitudeEnabled" size="is-small">Altitude</b-checkbox>
</b-field>
<b-field grouped>
<b-input v-model="altitudeFrom" class="altitude" placeholder="min." size="is-small" :disabled="!altitudeEnabled" />
<div class="tlabel">to</div>
<b-input v-model="altitudeTo" class="altitude" placeholder="max." size="is-small" :disabled="!altitudeEnabled" />
<div class="tlabel">{{ $store.state.altitudeUnits }}</div>
</b-field>
</div>
<div class="filter-criterion">
<b-field>
<b-checkbox v-model="activatedByEnabled" size="is-small">Activated by</b-checkbox>
</b-field>
<b-field grouped>
<b-input v-model="activatedBy" class="callsign" placeholder="Callsign" size="is-small" :disabled="!activatedByEnabled" />
<b-button v-if="myCallsign" class="control" size="is-small" @click="activatedBy = myCallsign" :disabled="!activatedByEnabled">Me</b-button>
<b-checkbox v-model="activatedByThisYear" size="is-small">This year</b-checkbox>
</b-field>
</div>
<div class="filter-criterion">
<b-field>
<b-checkbox v-model="notActivatedByEnabled" size="is-small">Not activated by</b-checkbox>
</b-field>
<b-field grouped>
<b-input v-model="notActivatedBy" class="callsign" placeholder="Callsign" size="is-small" :disabled="!notActivatedByEnabled" />
<b-button v-if="myCallsign" class="control" size="is-small" @click="notActivatedBy = myCallsign" :disabled="!notActivatedByEnabled">Me</b-button>
<b-checkbox v-model="notActivatedByThisYear" size="is-small">This year</b-checkbox>
</b-field>
</div>
<div class="filter-criterion">
<b-field>
<b-tooltip label="Only available if logged in" type="is-info" :active="!authenticated"><b-checkbox v-model="completeCandidateEnabled" size="is-small" :disabled="!authenticated">Complete candidate for me</b-checkbox></b-tooltip>
</b-field>
</div>
<div class="action-buttons">
<b-button type="is-link" size="is-small" :loading="filterLoadingCount > 0" @click="updateFilter">Update</b-button>
<b-button type="is-danger" size="is-small" @click="clearFilter">Clear</b-button>
</div>
</div>
</div>
</template>
<script>
import moment from 'moment'
import prefs from '../mixins/prefs.js'
import utils from '../mixins/utils.js'
import api from '../mixins/api.js'
import sotadb from '../mixins/sotadb.js'
export default {
name: 'MapFilterControl',
inject: ['map'],
mixins: [prefs, utils, api, sotadb],
prefs: {
key: 'mapFilter',
props: ['activationsEnabled', 'activationsFrom', 'activationsTo', 'pointsEnabled', 'pointsFrom', 'pointsTo',
'altitudeEnabled', 'altitudeFrom', 'altitudeTo', 'activatedByEnabled', 'activatedBy', 'activatedByThisYear',
'notActivatedByEnabled', 'notActivatedBy', 'notActivatedByThisYear', 'completeCandidateEnabled']
},
data () {
return {
open: false,
active: false,
activationsEnabled: false,
activationsFrom: null,
activationsTo: null,
pointsEnabled: false,
pointsFrom: null,
pointsTo: null,
altitudeEnabled: false,
altitudeFrom: null,
altitudeTo: null,
activatedByEnabled: false,
activatedBy: null,
activatedByThisYear: false,
notActivatedByEnabled: false,
notActivatedBy: null,
notActivatedByThisYear: false,
completeCandidateEnabled: false,
points: [1, 2, 4, 6, 8, 10],
filterLoadingCount: 0
}
},
mounted () {
this.updateFilter()
},
watch: {
filterLoadingCount (newFilterLoadingCount) {
if (newFilterLoadingCount > 0) {
this.$emit('startFiltering')
} else {
this.$emit('stopFiltering')
}
}
},
methods: {
close () {
this.open = false
},
toggleFilter () {
this.open = !this.open
},
updateFilter () {
let filterPromises = []
if (this.activationsEnabled) {
if (this.activationsFrom && this.activationsTo && this.activationsTo < this.activationsFrom) {
let tmp = this.activationsFrom
this.activationsFrom = this.activationsTo
this.activationsTo = tmp
}
if (this.activationsFrom !== null && this.activationsFrom !== '') {
filterPromises.push(['>=', 'act', parseInt(this.activationsFrom)])
}
if (this.activationsTo !== null && this.activationsTo !== '') {
filterPromises.push(['<=', 'act', parseInt(this.activationsTo)])
}
}
if (this.pointsEnabled) {
if (this.pointsFrom && this.pointsTo && this.pointsTo < this.pointsFrom) {
let tmp = this.pointsFrom
this.pointsFrom = this.pointsTo
this.pointsTo = tmp
}
if (this.pointsFrom) {
filterPromises.push(['>=', 'points', parseInt(this.pointsFrom)])
}
if (this.pointsTo) {
filterPromises.push(['<=', 'points', parseInt(this.pointsTo)])
}
}
if (this.altitudeEnabled) {
if (this.altitudeFrom) {
this.altitudeFrom = parseInt(this.altitudeFrom)
}
if (this.altitudeTo) {
this.altitudeTo = parseInt(this.altitudeTo)
}
if (this.altitudeFrom && this.altitudeTo && this.altitudeTo < this.altitudeFrom) {
let tmp = this.altitudeFrom
this.altitudeFrom = this.altitudeTo
this.altitudeTo = tmp
}
let mul = 1
if (this.$store.state.altitudeUnits === 'ft') {
mul = 1 / 3.28084
}
if (this.altitudeFrom) {
filterPromises.push(['>=', 'alt', Math.round(parseInt(this.altitudeFrom) * mul)])
}
if (this.altitudeTo) {
filterPromises.push(['<=', 'alt', Math.round(parseInt(this.altitudeTo) * mul)])
}
}
filterPromises.push(this.makeActivationsFilter('activatedBy', ['in', 'code']))
filterPromises.push(this.makeActivationsFilter('notActivatedBy', ['!in', 'code']))
if (this.completeCandidateEnabled) {
filterPromises.push(this.makeCompleteCandidateFilter())
}
Promise.all(filterPromises).then(filters => {
let filtersNoNull = filters.filter(el => {
return el !== null
})
if (filtersNoNull.length > 0) {
this.setSummitFilter(['all'].concat(filtersNoNull))
this.active = true
} else {
this.setSummitFilter(null)
this.active = false
}
})
},
setActivations (from, to) {
this.activationsEnabled = true
this.activationsFrom = from
this.activationsTo = to
this.updateFilter()
},
clearFilter () {
this.$options.prefs.props.forEach(key => {
this[key] = null
})
this.updateFilter()
},
setSummitFilter (filter) {
this.map.setFilter('summits_circles', filter)
this.map.setFilter('summits_names', filter)
this.map.setFilter('summits_activations', filter)
this.map.setFilter('summits_inactive_circles', filter)
this.map.setFilter('summits_inactive_names', filter)
},
makeActivationsFilter (paramField, filterTemplate) {
if (!this[paramField + 'Enabled'] || !this[paramField]) {
return null
}
this.filterLoadingCount++
return this.loadActivations(this[paramField].toUpperCase().trim())
.then(activations => {
this.filterLoadingCount--
let filter = filterTemplate
if (this[paramField + 'ThisYear']) {
let now = moment.utc()
activations = activations.filter(activation => {
return moment.utc(activation.date).isSame(now, 'year')
})
}
activations.forEach(activation => {
filter.push(activation.summit.code)
})
return filter
})
.catch(() => {
this.filterLoadingCount--
})
},
makeCompleteCandidateFilter () {
if (!this.authenticated) {
return null
}
this.filterLoadingCount++
return this.loadMyChaserUniques()
.then(chaserUniques => {
return this.loadMyActivatorUniques()
.then(activatorUniques => {
// Find all summits that have been chased, but not activated
let completeCandidates = new Set()
chaserUniques.forEach(ent => {
completeCandidates.add(ent)
})
activatorUniques.forEach(ent => {
completeCandidates.delete(ent)
})
this.filterLoadingCount--
return ['in', 'code', ...completeCandidates]
})
})
},
isActive () {
return this.active
}
}
}
</script>
<style scoped>
.mapbox-gl-filter {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M16.93 3.62C16.86 3.47 16.71 3.37 16.54 3.37H3.15c-0.17 0-0.32 0.1-0.39 0.25-0.07 0.15-0.05 0.33 0.06 0.46l5.15 6.24v5.76c0 0.15 0.08 0.29 0.2 0.37 0.07 0.04 0.15 0.06 0.23 0.06 0.07 0 0.13-0.01 0.19-0.04l2.89-1.43c0.15-0.07 0.24-0.22 0.24-0.39l0.01-4.32 5.15-6.24c0.11-0.13 0.13-0.31 0.06-0.46zm-5.97 6.27c-0.06 0.08-0.1 0.17-0.1 0.27l-0.01 4.21-2.03 1.01v-5.21c0-0.1-0.03-0.2-0.1-0.27L4.07 4.23H15.62Z' stroke-width='0.06'/%3E%3C/svg%3E%0A");
}
.mapbox-gl-filter.active {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M16.93 3.62C16.86 3.47 16.71 3.37 16.54 3.37H3.15c-0.17 0-0.32 0.1-0.39 0.25-0.07 0.15-0.05 0.33 0.06 0.46l5.15 6.24v5.76c0 0.15 0.08 0.29 0.2 0.37 0.07 0.04 0.15 0.06 0.23 0.06 0.07 0 0.13-0.01 0.19-0.04l2.89-1.43c0.15-0.07 0.24-0.22 0.24-0.39l0.01-4.32 5.15-6.24c0.11-0.13 0.13-0.31 0.06-0.46zm-5.97 6.27c-0.06 0.08-0.1 0.17-0.1 0.27l-0.01 4.21-2.03 1.01v-5.21c0-0.1-0.03-0.2-0.1-0.27L4.07 4.23H15.62Z' stroke-width='0.06' fill='%2333b5e5'/%3E%3C/svg%3E%0A");
}
.filter-criterion {
margin: 0.3em 0;
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 0.2em;
background-color: #eee;
font-size: 0.8rem;
}
.filter-criterion div.tlabel {
margin-right: 0.75rem;
display: inline-block;
}
.filter-criterion .field {
margin: 0.2rem 0.2rem 0.5rem 0.2rem;
line-height: 1;
align-items: center;
}
.filter-criterion .field:last-child {
margin-bottom: 0.2rem;
}
.filter-criterion .callsign >>> input {
text-transform: uppercase;
}
.filter-criterion .count >>> input {
width: 4em;
}
.filter-criterion .altitude >>> input {
width: 5em;
}
.filter-criterion .field input, .filter-criterion .field select {
vertical-align: baseline;
}
/* Fix overrides from mapbox-gl.css */
.filter-container button {
width: auto;
height: auto;
padding-bottom: calc(.375em - 1px);
padding-left: .75em;
padding-right: .75em;
padding-top: calc(.375em - 1px);
}
.filter-criterion button {
border: 1px solid #dbdbdb;
background-color: #fff;
}
.action-buttons button {
float: right;
margin-left: 0.5em;
margin-top: 0.2em;
}
.mapbox-gl-filter-container .filter-container {
display: none;
padding: 0 0.5em 0.5em 0;
display: inline-block;
}
.mapbox-gl-filter-container button {
display: inline-block;
vertical-align: top;
}
</style>

Wyświetl plik

@ -0,0 +1,41 @@
<template>
<MglPopup :coordinates="[coordinates.longitude, coordinates.latitude]" :showed="true" @close="$emit('close')">
<div class="popup-content">
<Coordinates :latitude="latitude" :longitude="longitude" show-maidenhead show-elevation />
</div>
</MglPopup>
</template>
<script>
import { MglPopup } from 'vue-mapbox'
import Coordinates from './Coordinates.vue'
export default {
name: 'MapInfoPopup',
props: {
coordinates: Object
},
components: {
MglPopup, Coordinates
},
computed: {
latitude () {
return parseFloat(this.coordinates.latitude.toFixed(5))
},
longitude () {
return parseFloat(this.coordinates.longitude.toFixed(5))
}
}
}
</script>
<style scoped>
.popup-content {
margin-top: 7px;
}
>>> .coordinates {
vertical-align: middle;
font-weight: bold;
font-size: 1rem;
}
</style>

Wyświetl plik

@ -0,0 +1,185 @@
<template>
<div class="mapboxgl-ctrl-group mapboxgl-ctrl">
<button :class="{ 'mapboxgl-ctrl-icon': true, 'mapbox-gl-areas': true, active: areas }" type="button" title="Toggle areas" @click="toggleAreas" />
<button :class="{ 'mapboxgl-ctrl-icon': true, 'mapbox-gl-contours': true, active: contours }" type="button" title="Toggle contours" @click="toggleContours" />
<button :class="{ 'mapboxgl-ctrl-icon': true, 'mapbox-gl-hillshading': true, active: hillshading }" type="button" title="Toggle hillshading" @click="toggleHillshading" />
<button :class="{ 'mapboxgl-ctrl-icon': true, 'mapbox-gl-difficulty': true, active: difficulty }" type="button" title="Toggle trail difficulty" @click="toggleDifficulty" />
<button :class="{ 'mapboxgl-ctrl-icon': true, 'mapbox-gl-spots': true, active: spots }" type="button" title="Toggle spots" @click="toggleSpots" />
<button :class="{ 'mapboxgl-ctrl-icon': true, 'mapbox-gl-inactive': true, active: inactive }" type="button" title="Toggle inactive summits" @click="toggleInactive" />
</div>
</template>
<script>
import moment from 'moment'
import prefs from '../mixins/prefs.js'
import mapstyle from '../mixins/mapstyle.js'
const RECENT_SPOT_AGE = 30 * 60 * 1000
export default {
name: 'MapOptionsControl',
inject: ['map'],
mixins: [mapstyle, prefs],
prefs: {
key: 'mapOptions',
props: ['areas', 'contours', 'hillshading', 'difficulty', 'spots', 'inactive']
},
data () {
return {
areas: false,
contours: true,
hillshading: true,
difficulty: true,
spots: false,
inactive: false
}
},
mounted () {
this.updateRecentSpots()
if (this.difficulty === undefined) {
this.difficulty = true
}
},
computed: {
recentSpots () {
let now = moment.utc()
return this.$store.state.spots.filter(spot => {
return (now.diff(spot.timeStamp) < RECENT_SPOT_AGE)
}).map(spot => {
return spot.summit.code
})
}
},
watch: {
areas: {
handler () {
this.map.setLayoutProperty('regions_areas', 'visibility', this.areas ? 'visible' : 'none')
this.map.setLayoutProperty('regions_labels', 'visibility', this.areas ? 'visible' : 'none')
},
immediate: true
},
contours: {
handler () {
this.map.setLayoutProperty('contour', 'visibility', this.contours ? 'visible' : 'none')
this.map.setLayoutProperty('contour_index', 'visibility', this.contours ? 'visible' : 'none')
this.map.setLayoutProperty('contour_label', 'visibility', this.contours ? 'visible' : 'none')
},
immediate: true
},
hillshading: {
handler () {
this.map.setLayoutProperty('hillshading', 'visibility', this.hillshading ? 'visible' : 'none')
},
immediate: true
},
difficulty: {
handler () {
this.updateDifficultyLayer()
},
immediate: true
},
mapServer: {
handler () {
this.updateDifficultyLayer()
}
},
inactive: {
handler () {
this.map.setLayoutProperty('summits_inactive_circles', 'visibility', this.inactive ? 'visible' : 'none')
this.map.setLayoutProperty('summits_inactive_names', 'visibility', this.inactive ? 'visible' : 'none')
},
immediate: true
},
recentSpots: {
handler () {
this.updateRecentSpots()
},
immediate: true
},
spots () {
this.updateRecentSpots()
}
},
methods: {
updateDifficultyLayer () {
this.map.setLayoutProperty('road_path_pedestrian_sac', 'visibility', this.difficulty ? 'visible' : 'none')
this.map.setLayoutProperty('road_path_pedestrian_sac_label', 'visibility', this.difficulty ? 'visible' : 'none')
},
toggleAreas () {
this.areas = !this.areas
},
toggleContours () {
this.contours = !this.contours
},
toggleHillshading () {
this.hillshading = !this.hillshading
},
toggleDifficulty () {
this.difficulty = !this.difficulty
},
toggleSpots () {
this.spots = !this.spots
},
toggleInactive () {
this.inactive = !this.inactive
},
showInactive () {
this.inactive = true
},
updateRecentSpots () {
if (this.spots) {
this.map.setFilter('summits_highlight', ['in', 'code', ...this.recentSpots])
} else {
this.map.setFilter('summits_highlight', ['in', 'code'])
}
},
spotsShown () {
return this.spots
}
}
}
</script>
<style scoped>
.mapbox-gl-areas {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' class='undefined'%3E%3Cstyle%3E.a%7Bfill:none;%7D.b%7Bfill:none;stroke-linejoin:round;stroke-width:1;stroke:%23000;%7D%3C/style%3E%3Crect width='16.86' height='9.24' x='4.32' y='2.54' rx='1.1' ry='1.1' class='a'/%3E%3Crect width='9.58' height='6.86' x='2.71' y='3.05' rx='1.1' ry='1.1' class='a'/%3E%3Crect width='9.66' height='7.88' x='3.56' y='3.73' rx='1.02' ry='1.02' class='b'/%3E%3Crect ry='1.02' rx='1.02' y='8.22' x='6.78' height='7.88' width='9.66' class='b'/%3E%3C/svg%3E%0A");
}
.mapbox-gl-areas.active {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' class='undefined'%3E%3Cstyle%3E.a%7Bfill:none;%7D.b%7Bfill:none;stroke-linejoin:round;stroke-width:1;stroke:%2333b5e5;%7D%3C/style%3E%3Crect width='16.86' height='9.24' x='4.32' y='2.54' rx='1.1' ry='1.1' class='a'/%3E%3Crect width='9.58' height='6.86' x='2.71' y='3.05' rx='1.1' ry='1.1' class='a'/%3E%3Crect width='9.66' height='7.88' x='3.56' y='3.73' rx='1.02' ry='1.02' class='b'/%3E%3Crect ry='1.02' rx='1.02' y='8.22' x='6.78' height='7.88' width='9.66' class='b'/%3E%3C/svg%3E%0A");
}
.mapbox-gl-contours {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cstyle%3E.a%7Bfill:none;stroke:%23000;%7D%3C/style%3E%3Crect width='16.864' height='9.237' x='-34.746' y='-4.068' rx='1.102' ry='1.102' fill='none'/%3E%3Cpath d='m3.644 12.203c0 0-0.169-6.186 3.898-7.712 4.068-1.525 7.712-2.881 7.712 0.339 0 3.22 2.881 5.678-0.678 7.712-3.559 2.034-3.644 4.237-6.356 4.576-2.712 0.339-4.915 0.593-4.576-0.932 0.339-1.525 0-3.983 0-3.983z' class='a'/%3E%3Cpath d='m5.903 11.25c0 0-0.106-3.859 2.431-4.81 2.537-0.951 4.81-1.797 4.81 0.211 0 2.009 1.797 3.541-0.423 4.81-2.22 1.269-2.273 2.643-3.964 2.854-1.691 0.211-3.066 0.37-2.854-0.581 0.211-0.951 0-2.484 0-2.484z' class='a'/%3E%3Cpath d='m8.206 10.493c0 0-0.044-1.612 1.016-2.009 1.06-0.397 2.009-0.751 2.009 0.088 0 0.839 0.751 1.48-0.177 2.009-0.927 0.53-0.95 1.104-1.656 1.192-0.707 0.088-1.281 0.155-1.192-0.243 0.088-0.397 0-1.038 0-1.038z' style='fill:none;stroke-width:0.71;stroke:%23000'/%3E%3C/svg%3E%0A");
}
.mapbox-gl-contours.active {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cstyle%3E.a%7Bfill:none;stroke:%2333b5e5;%7D%3C/style%3E%3Crect width='16.864' height='9.237' x='-34.746' y='-4.068' rx='1.102' ry='1.102' fill='none'/%3E%3Cpath d='m3.644 12.203c0 0-0.169-6.186 3.898-7.712 4.068-1.525 7.712-2.881 7.712 0.339 0 3.22 2.881 5.678-0.678 7.712-3.559 2.034-3.644 4.237-6.356 4.576-2.712 0.339-4.915 0.593-4.576-0.932 0.339-1.525 0-3.983 0-3.983z' class='a'/%3E%3Cpath d='m5.903 11.25c0 0-0.106-3.859 2.431-4.81 2.537-0.951 4.81-1.797 4.81 0.211 0 2.009 1.797 3.541-0.423 4.81-2.22 1.269-2.273 2.643-3.964 2.854-1.691 0.211-3.066 0.37-2.854-0.581 0.211-0.951 0-2.484 0-2.484z' class='a'/%3E%3Cpath d='m8.206 10.493c0 0-0.044-1.612 1.016-2.009 1.06-0.397 2.009-0.751 2.009 0.088 0 0.839 0.751 1.48-0.177 2.009-0.927 0.53-0.95 1.104-1.656 1.192-0.707 0.088-1.281 0.155-1.192-0.243 0.088-0.397 0-1.038 0-1.038z' style='fill:none;stroke-width:0.71;stroke:%2333b5e5'/%3E%3C/svg%3E%0A");
}
.mapbox-gl-hillshading {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Crect x='-34.7' y='-4.1' width='16.9' height='9.2' rx='1.1' ry='1.1' fill='none'/%3E%3Cpath d='m3.0561 16.464 3.8951-8.9287 2.0974 1.9176 3.2359-5.2134 4.3745 12.284' fill='none' stroke='%23000' stroke-width='1px'/%3E%3Cpath d='m4.8986 16.645 2.7045-6.043 1.6903 1.6058 2.504-3.9553 3.0319 8.308v0.04226' fill='none' stroke='%23808080' stroke-width='.7052px'/%3E%3C/svg%3E%0A");
}
.mapbox-gl-hillshading.active {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Crect x='-34.7' y='-4.1' width='16.9' height='9.2' rx='1.1' ry='1.1' fill='none'/%3E%3Cpath d='m3.0561 16.464 3.8951-8.9287 2.0974 1.9176 3.2359-5.2134 4.3745 12.284' fill='none' stroke='%2333b5e5' stroke-width='1px'/%3E%3Cpath d='m4.8986 16.645 2.7045-6.043 1.6903 1.6058 2.504-3.9553 3.0319 8.308v0.04226' fill='none' stroke='%2389a9fa' stroke-width='.7052px'/%3E%3C/svg%3E%0A");
}
.mapbox-gl-difficulty {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 7.056 7.056' height='20pt' width='20pt'%3E%3Cpath transform='skewX(.587) scale(1 .99995)' fill='none' stroke='%23000' stroke-width='.178' d='M3.977 1.708h2.178v3.633H3.977z'/%3E%3Cg style='line-height:1.25'%3E%3Cpath d='M1.988 5.38h.603V2.11h.884v-.508H1.088v.508h.9z' /%3E%3C/g%3E%3C/svg%3E");
}
.mapbox-gl-difficulty.active {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 7.056 7.056' height='20pt' width='20pt'%3E%3Cpath transform='skewX(.587) scale(1 .99995)' fill='none' stroke='%2333b5e5' stroke-width='.178' d='M3.977 1.708h2.178v3.633H3.977z'/%3E%3Cg style='line-height:1.25'%3E%3Cpath d='M1.988 5.38h.603V2.11h.884v-.508H1.088v.508h.9z' fill='%2333b5e5' /%3E%3C/g%3E%3C/svg%3E");
}
.mapbox-gl-spots {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M14.6 7.6C15.7 9.5 15.3 12.1 13.7 13.7 12.1 15.4 9.3 15.7 7.3 14.4 5.4 13.3 4.4 10.9 5 8.7 5.5 6.4 7.7 4.7 10.1 4.8 10.9 4.8 11.7 5 12.4 5.4 12.8 5.1 13.1 4.8 13.4 4.5 11.2 3.1 8.2 3.2 6.1 4.8 4.1 6.2 3.1 8.8 3.6 11.2 4.1 13.8 6.3 16 8.8 16.4 11.3 16.9 13.9 15.8 15.3 13.8 16.8 11.7 16.9 8.8 15.5 6.6 15.2 6.9 14.9 7.3 14.6 7.6 14.6 7.6 14.6 7.6 14.6 7.6'/%3E%3Cpath d='M6.1 10C6.1 11.9 7.6 13.6 9.4 13.9 11.2 14.2 13.1 13 13.7 11.3 14 10.4 14 9.4 13.6 8.5 13.3 8.9 12.9 9.2 12.6 9.6 12.9 11.1 11.5 12.7 10 12.6 8.5 12.6 7.2 11.2 7.4 9.7 7.5 8.3 9 7.1 10.4 7.4 10.8 7.1 11.1 6.7 11.5 6.4 9.8 5.6 7.6 6.4 6.7 8 6.3 8.6 6.1 9.3 6.1 10 6.1 10 6.1 10 6.1 10'/%3E%3Cpath d='M16.4 4.5C16.1 4.2 15.8 3.9 15.5 3.6 13.8 5.3 12.1 7 10.3 8.7 9.4 8.5 8.5 9.4 8.7 10.3 8.9 11.2 10.1 11.6 10.8 11 11.4 10.7 11.1 9.9 11.4 9.5 13.1 7.8 14.8 6.2 16.4 4.5 16.4 4.5 16.4 4.5 16.4 4.5'/%3E%3C/svg%3E%0A");
}
.mapbox-gl-spots.active {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M14.6 7.6C15.7 9.5 15.3 12.1 13.7 13.7 12.1 15.4 9.3 15.7 7.3 14.4 5.4 13.3 4.4 10.9 5 8.7 5.5 6.4 7.7 4.7 10.1 4.8 10.9 4.8 11.7 5 12.4 5.4 12.8 5.1 13.1 4.8 13.4 4.5 11.2 3.1 8.2 3.2 6.1 4.8 4.1 6.2 3.1 8.8 3.6 11.2 4.1 13.8 6.3 16 8.8 16.4 11.3 16.9 13.9 15.8 15.3 13.8 16.8 11.7 16.9 8.8 15.5 6.6 15.2 6.9 14.9 7.3 14.6 7.6 14.6 7.6 14.6 7.6 14.6 7.6' fill='%2333b5e5'/%3E%3Cpath d='M6.1 10C6.1 11.9 7.6 13.6 9.4 13.9 11.2 14.2 13.1 13 13.7 11.3 14 10.4 14 9.4 13.6 8.5 13.3 8.9 12.9 9.2 12.6 9.6 12.9 11.1 11.5 12.7 10 12.6 8.5 12.6 7.2 11.2 7.4 9.7 7.5 8.3 9 7.1 10.4 7.4 10.8 7.1 11.1 6.7 11.5 6.4 9.8 5.6 7.6 6.4 6.7 8 6.3 8.6 6.1 9.3 6.1 10 6.1 10 6.1 10 6.1 10' fill='%2333b5e5'/%3E%3Cpath d='M16.4 4.5C16.1 4.2 15.8 3.9 15.5 3.6 13.8 5.3 12.1 7 10.3 8.7 9.4 8.5 8.5 9.4 8.7 10.3 8.9 11.2 10.1 11.6 10.8 11 11.4 10.7 11.1 9.9 11.4 9.5 13.1 7.8 14.8 6.2 16.4 4.5 16.4 4.5 16.4 4.5 16.4 4.5' fill='%2333b5e5' /%3E%3C/svg%3E%0A");
}
.mapbox-gl-inactive {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' aria-hidden='true' focusable='false' role='img' viewBox='0 0 20 20' %3E%3Cpath d='M7.98 5.79C7.98 5.79 7.83 5.85 7.83 5.85 7.83 5.85 7.71 5.96 7.71 5.96 7.71 5.96 7.66 6.03 7.66 6.03 7.66 6.03 6.81 7.37 6.81 7.37 6.81 7.37 7.72 7.94 7.72 7.94 7.72 7.94 8.13 7.31 8.13 7.31 8.13 7.31 8.58 7.99 8.58 7.99 8.58 7.99 9.48 7.39 9.48 7.39 9.48 7.39 8.56 6.01 8.56 6.01 8.56 6.01 8.45 5.89 8.45 5.89 8.45 5.89 8.31 5.81 8.31 5.81 8.31 5.81 8.15 5.78 8.15 5.78 8.15 5.78 7.98 5.79 7.98 5.79'/%3E%3Cpath d='M4.51 11.02C4.51 11.02 5.42 11.6 5.42 11.6 5.42 11.6 7.15 8.86 7.15 8.86 7.15 8.86 6.23 8.28 6.23 8.28 6.23 8.28 4.51 11.02 4.51 11.02'/%3E%3Cpath d='M9.18 8.89C9.18 8.89 10.98 11.58 10.98 11.58 10.98 11.58 11.87 10.98 11.87 10.98 11.87 10.98 10.08 8.29 10.08 8.29 10.08 8.29 9.18 8.89 9.18 8.89'/%3E%3Cpath d='M11.57 12.48C11.57 12.48 11.8 12.82 11.8 12.82 11.8 12.82 11.01 12.82 11.01 12.82 11.01 12.82 11 13.9 11 13.9 11 13.9 12.81 13.91 12.81 13.91 12.81 13.91 12.97 13.88 12.97 13.88 12.97 13.88 13.12 13.81 13.12 13.81 13.12 13.81 13.24 13.7 13.24 13.7 13.24 13.7 13.32 13.55 13.32 13.55 13.32 13.55 13.35 13.39 13.35 13.39 13.35 13.39 13.34 13.23 13.34 13.23 13.34 13.23 13.27 13.08 13.27 13.08 13.27 13.08 13.26 13.07 13.26 13.07 13.26 13.07 12.47 11.88 12.47 11.88 12.47 11.88 11.57 12.48 11.57 12.48'/%3E%3Cpath d='M6.68 13.87C6.68 13.87 9.92 13.89 9.92 13.89 9.92 13.89 9.93 12.81 9.93 12.81 9.93 12.81 6.69 12.79 6.69 12.79 6.69 12.79 6.68 13.87 6.68 13.87'/%3E%3Cpath d='M11.86 6.47C11.86 6.47 11.71 6.53 11.71 6.53 11.71 6.53 11.58 6.64 11.58 6.64 11.58 6.64 11.55 6.67 11.55 6.67 11.55 6.67 10.51 8.07 10.51 8.07 10.51 8.07 11.38 8.72 11.38 8.72 11.38 8.72 11.96 7.93 11.96 7.93 11.96 7.93 12.36 8.53 12.36 8.53 12.36 8.53 13.26 7.93 13.26 7.93 13.26 7.93 12.44 6.69 12.44 6.69 12.44 6.69 12.32 6.57 12.32 6.57 12.32 6.57 12.18 6.49 12.18 6.49 12.18 6.49 12.02 6.46 12.02 6.46 12.02 6.46 11.86 6.47 11.86 6.47M12.96 9.43C12.96 9.43 14.76 12.12 14.76 12.12 14.76 12.12 15.66 11.53 15.66 11.53 15.66 11.53 13.86 8.83 13.86 8.83 13.86 8.83 12.96 9.43 12.96 9.43M15.65 12.83C15.65 12.83 13.79 12.83 13.79 12.83 13.79 12.83 13.79 13.91 13.79 13.91 13.79 13.91 16.24 13.91 16.24 13.91 16.24 13.91 16.41 13.89 16.41 13.89 16.41 13.89 16.55 13.81 16.55 13.81 16.55 13.81 16.67 13.7 16.67 13.7 16.67 13.7 16.75 13.56 16.75 13.56 16.75 13.56 16.78 13.4 16.78 13.4 16.78 13.4 16.77 13.23 16.77 13.23 16.77 13.23 16.7 13.08 16.7 13.08 16.7 13.08 16.69 13.07 16.69 13.07 16.69 13.07 16.26 12.42 16.26 12.42 16.26 12.42 15.65 12.83 15.65 12.83'/%3E%3Cpath d='M4.9 12.44C4.9 12.44 4.67 12.78 4.67 12.78 4.67 12.78 5.47 12.78 5.47 12.78 5.47 12.78 5.48 13.86 5.48 13.86 5.48 13.86 3.66 13.87 3.66 13.87 3.66 13.87 3.5 13.84 3.5 13.84 3.5 13.84 3.35 13.77 3.35 13.77 3.35 13.77 3.23 13.66 3.23 13.66 3.23 13.66 3.16 13.52 3.16 13.52 3.16 13.52 3.12 13.35 3.12 13.35 3.12 13.35 3.14 13.19 3.14 13.19 3.14 13.19 3.21 13.04 3.21 13.04 3.21 13.04 3.21 13.03 3.21 13.03 3.21 13.03 4 11.84 4 11.84 4 11.84 4.9 12.44 4.9 12.44'/%3E%3C/svg%3E%0A");
}
.mapbox-gl-inactive.active {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' aria-hidden='true' focusable='false' role='img' viewBox='0 0 20 20' %3E%3Cpath d='M7.98 5.79C7.98 5.79 7.83 5.85 7.83 5.85 7.83 5.85 7.71 5.96 7.71 5.96 7.71 5.96 7.66 6.03 7.66 6.03 7.66 6.03 6.81 7.37 6.81 7.37 6.81 7.37 7.72 7.94 7.72 7.94 7.72 7.94 8.13 7.31 8.13 7.31 8.13 7.31 8.58 7.99 8.58 7.99 8.58 7.99 9.48 7.39 9.48 7.39 9.48 7.39 8.56 6.01 8.56 6.01 8.56 6.01 8.45 5.89 8.45 5.89 8.45 5.89 8.31 5.81 8.31 5.81 8.31 5.81 8.15 5.78 8.15 5.78 8.15 5.78 7.98 5.79 7.98 5.79' fill='%2333b5e5'/%3E%3Cpath d='M4.51 11.02C4.51 11.02 5.42 11.6 5.42 11.6 5.42 11.6 7.15 8.86 7.15 8.86 7.15 8.86 6.23 8.28 6.23 8.28 6.23 8.28 4.51 11.02 4.51 11.02' fill='%2333b5e5'/%3E%3Cpath d='M9.18 8.89C9.18 8.89 10.98 11.58 10.98 11.58 10.98 11.58 11.87 10.98 11.87 10.98 11.87 10.98 10.08 8.29 10.08 8.29 10.08 8.29 9.18 8.89 9.18 8.89' fill='%2333b5e5'/%3E%3Cpath d='M11.57 12.48C11.57 12.48 11.8 12.82 11.8 12.82 11.8 12.82 11.01 12.82 11.01 12.82 11.01 12.82 11 13.9 11 13.9 11 13.9 12.81 13.91 12.81 13.91 12.81 13.91 12.97 13.88 12.97 13.88 12.97 13.88 13.12 13.81 13.12 13.81 13.12 13.81 13.24 13.7 13.24 13.7 13.24 13.7 13.32 13.55 13.32 13.55 13.32 13.55 13.35 13.39 13.35 13.39 13.35 13.39 13.34 13.23 13.34 13.23 13.34 13.23 13.27 13.08 13.27 13.08 13.27 13.08 13.26 13.07 13.26 13.07 13.26 13.07 12.47 11.88 12.47 11.88 12.47 11.88 11.57 12.48 11.57 12.48' fill='%2333b5e5'/%3E%3Cpath d='M6.68 13.87C6.68 13.87 9.92 13.89 9.92 13.89 9.92 13.89 9.93 12.81 9.93 12.81 9.93 12.81 6.69 12.79 6.69 12.79 6.69 12.79 6.68 13.87 6.68 13.87' fill='%2333b5e5'/%3E%3Cpath d='M11.86 6.47C11.86 6.47 11.71 6.53 11.71 6.53 11.71 6.53 11.58 6.64 11.58 6.64 11.58 6.64 11.55 6.67 11.55 6.67 11.55 6.67 10.51 8.07 10.51 8.07 10.51 8.07 11.38 8.72 11.38 8.72 11.38 8.72 11.96 7.93 11.96 7.93 11.96 7.93 12.36 8.53 12.36 8.53 12.36 8.53 13.26 7.93 13.26 7.93 13.26 7.93 12.44 6.69 12.44 6.69 12.44 6.69 12.32 6.57 12.32 6.57 12.32 6.57 12.18 6.49 12.18 6.49 12.18 6.49 12.02 6.46 12.02 6.46 12.02 6.46 11.86 6.47 11.86 6.47M12.96 9.43C12.96 9.43 14.76 12.12 14.76 12.12 14.76 12.12 15.66 11.53 15.66 11.53 15.66 11.53 13.86 8.83 13.86 8.83 13.86 8.83 12.96 9.43 12.96 9.43M15.65 12.83C15.65 12.83 13.79 12.83 13.79 12.83 13.79 12.83 13.79 13.91 13.79 13.91 13.79 13.91 16.24 13.91 16.24 13.91 16.24 13.91 16.41 13.89 16.41 13.89 16.41 13.89 16.55 13.81 16.55 13.81 16.55 13.81 16.67 13.7 16.67 13.7 16.67 13.7 16.75 13.56 16.75 13.56 16.75 13.56 16.78 13.4 16.78 13.4 16.78 13.4 16.77 13.23 16.77 13.23 16.77 13.23 16.7 13.08 16.7 13.08 16.7 13.08 16.69 13.07 16.69 13.07 16.69 13.07 16.26 12.42 16.26 12.42 16.26 12.42 15.65 12.83 15.65 12.83' fill='%2333b5e5'/%3E%3Cpath d='M4.9 12.44C4.9 12.44 4.67 12.78 4.67 12.78 4.67 12.78 5.47 12.78 5.47 12.78 5.47 12.78 5.48 13.86 5.48 13.86 5.48 13.86 3.66 13.87 3.66 13.87 3.66 13.87 3.5 13.84 3.5 13.84 3.5 13.84 3.35 13.77 3.35 13.77 3.35 13.77 3.23 13.66 3.23 13.66 3.23 13.66 3.16 13.52 3.16 13.52 3.16 13.52 3.12 13.35 3.12 13.35 3.12 13.35 3.14 13.19 3.14 13.19 3.14 13.19 3.21 13.04 3.21 13.04 3.21 13.04 3.21 13.03 3.21 13.03 3.21 13.03 4 11.84 4 11.84 4 11.84 4.9 12.44 4.9 12.44' fill='%2333b5e5'/%3E%3C/svg%3E%0A");
}
</style>

Wyświetl plik

@ -0,0 +1,74 @@
<template>
<div>
<MglMarker :coordinates="photoCoordinates">
<div slot="marker" class="marker-icon" @click="markerClicked">
<font-awesome-layers slot="marker" class="fa-2x fa-fw">
<font-awesome-icon v-if="photo.direction" class="direction" :icon="['fas', 'location-arrow']" :transform="{ rotate: photo.direction - 45 }" :style="{ color: photo.highlight ? '#bb0000' : undefined, display: photo.highlight ? 'block' : undefined }" />
<font-awesome-icon icon="circle" :style="{ color: photo.highlight ? 'red' : undefined }" />
<font-awesome-icon :icon="['fas', 'camera']" transform="shrink-7" :style="{ color: 'white' }" />
</font-awesome-layers>
</div>
<MglPopup :closeButton="false">
<div class="thumbwrapper">
<img class="thumb" :src="photoSrc(photo, 'thumb')" @click="$emit('photoClicked', photo)" />
<div v-if="photo.title" class="caption">{{ photo.title }}</div>
</div>
</MglPopup>
</MglMarker>
</div>
</template>
<script>
import { MglMarker, MglPopup } from 'vue-mapbox'
import photos from '../mixins/photos.js'
export default {
name: 'MapPhoto',
props: {
summit: Object,
photo: Object
},
components: {
MglMarker, MglPopup
},
mixins: [photos],
computed: {
photoCoordinates () {
return [this.photo.coordinates.longitude, this.photo.coordinates.latitude]
}
},
methods: {
markerClicked (e) {
e.hitMarker = true
}
}
}
</script>
<style scoped>
.marker-icon {
padding: 4px;
cursor: pointer;
}
.thumbwrapper {
max-width: 172px;
}
.thumb {
max-height: 128px;
vertical-align: bottom;
}
.caption {
font-size: 0.75rem;
margin-top: 0.3rem;
line-height: 1.4;
color: #555;
}
.direction {
display: none;
color: #777;
transform: scale(1.8);
}
.marker-icon:hover .direction {
display: block;
}
</style>

Wyświetl plik

@ -0,0 +1,202 @@
<template>
<div>
<MglGeojsonLayer v-if="trackSource" :sourceId="sourceId + '_trk'" :source="trackSource" :layerId="sourceId + '_trk'" :layer="trackLayer" before="summits_selected" />
<MglGeojsonLayer v-if="waypointSource" :sourceId="sourceId + '_wpt'" :source="waypointSource" :layerId="sourceId + '_wpt'" :layer="waypointLayer" before="summits_selected" />
<MglMarker v-if="startCoordinates" :coordinates="startCoordinates">
<font-awesome-layers slot="marker" class="fa-2x">
<font-awesome-icon icon="circle" :style="{ color: route.highlight ? '#ee0000' : '#0000ee' }" />
<font-awesome-icon :icon="['fas', 'hiking']" transform="shrink-6" :style="{ color: 'white' }" />
</font-awesome-layers>
</MglMarker>
<MglMarker v-if="parkingCoordinates" :coordinates="parkingCoordinates">
<font-awesome-layers slot="marker" class="fa-2x">
<font-awesome-icon icon="square" :style="{ color: 'white' }" />
<font-awesome-icon :icon="['fas', 'parking']" :style="{ color: route.highlight ? '#ee0000' : '#0000ee' }" />
</font-awesome-layers>
</MglMarker>
<MglMarker v-if="publicTransportCoordinates" :coordinates="publicTransportCoordinates">
<font-awesome-layers slot="marker" class="fa-2x">
<font-awesome-icon icon="square" :style="{ color: route.highlight ? '#ee0000' : '#0000ee' }" />
<font-awesome-icon :icon="['fas', 'bus']" transform="shrink-6" :style="{ color: 'white' }" />
</font-awesome-layers>
</MglMarker>
</div>
</template>
<script>
import axios from 'axios'
import togeojson from '@mapbox/togeojson'
import { MglGeojsonLayer, MglMarker } from 'vue-mapbox'
import haversineDistance from 'haversine-distance'
import tracks from '../mixins/tracks.js'
export default {
name: 'MapRoute',
props: {
route: Object
},
components: {
MglGeojsonLayer, MglMarker
},
mixins: [tracks],
computed: {
trackLayer () {
return {
type: 'line',
layout: {
'line-join': 'round',
'line-cap': 'round'
},
paint: {
'line-color': this.route.highlight ? '#ff0000' : '#245acd',
'line-width': 3,
'line-opacity': 0.75
}
}
},
waypointLayer () {
return {
type: 'symbol',
layout: {
visibility: this.route.highlight ? 'visible' : 'none',
'icon-image': 'information_15',
'text-field': '{name}',
'text-font': ['Open Sans Regular'],
'text-size': {
base: 1,
stops: [
[13, 10],
[20, 12]
]
},
'text-anchor': 'bottom',
'text-offset': {
stops: [
[
13,
[
0,
2.3
]
],
[
20,
[
0,
2.5
]
]
]
}
},
paint: {
'text-color': 'rgba(200, 0, 0, 1)',
'text-halo-color': 'rgba(255, 255, 255, 1)',
'text-halo-width': 1,
'text-halo-blur': 1
}
}
},
startCoordinates () {
if (!this.route.startPoint) {
return null
}
// If start point coordinates are very close to parking or public transport, then don't display them
if ((this.route.parking && this.route.parking.coordinates && haversineDistance(this.route.startPoint.coordinates, this.route.parking.coordinates) < 50) ||
(this.route.publicTransport && this.route.publicTransport.coordinates && haversineDistance(this.route.startPoint.coordinates, this.route.publicTransport.coordinates) < 50)) {
return null
}
return [this.route.startPoint.coordinates.longitude, this.route.startPoint.coordinates.latitude]
},
parkingCoordinates () {
if (!this.route.parking || !this.route.parking.coordinates) {
return null
}
return [this.route.parking.coordinates.longitude, this.route.parking.coordinates.latitude]
},
publicTransportCoordinates () {
if (!this.route.publicTransport || !this.route.publicTransport.coordinates) {
return null
}
return [this.route.publicTransport.coordinates.longitude, this.route.publicTransport.coordinates.latitude]
},
trackSource () {
if (this.geoJsonSource === null || this.geoJsonSource.data.type !== 'FeatureCollection') {
return null
}
return {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: this.geoJsonSource.data.features.filter(feature => feature.geometry.type === 'LineString')
}
}
},
waypointSource () {
if (this.geoJsonSource === null || this.geoJsonSource.data.type !== 'FeatureCollection') {
return null
}
return {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: this.geoJsonSource.data.features.filter(feature => feature.geometry.type === 'Point')
}
}
}
},
watch: {
route: {
immediate: true,
handler () {
if (this.route.track) {
if (this.route.track.filename) {
this.loadGpx()
} else if (this.route.track.points) {
this.convertSmpPoints()
}
}
}
}
},
methods: {
loadGpx () {
this.sourceId = this.route.track.filename
axios.get(this.trackUrl(this.route.track))
.then(response => {
let dom = (new DOMParser()).parseFromString(response.data, 'text/xml')
this.geoJsonSource = {
type: 'geojson',
data: togeojson.gpx(dom)
}
})
},
convertSmpPoints () {
this.sourceId = this.route.id
let geojson = {
type: 'LineString',
coordinates: this.route.track.points.map(point => {
return [point.longitude, point.latitude]
})
}
this.geoJsonSource = {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [{
type: 'Feature',
geometry: geojson
}]
}
}
}
},
data () {
return {
geoJsonSource: null,
sourceId: ''
}
}
}
</script>

Wyświetl plik

@ -0,0 +1,256 @@
<template>
<MglMap v-if="(mapCenter || bounds) && mapStyle" :mapStyle="mapStyle" :bounds="bounds" :fitBoundsOptions="fitBoundsOptions" :center="mapCenter" :zoom="12.5" :dragPan="dragPanEnabled" :dragRotate="false" :attributionControl="false" @load="onMapLoaded" @click="onMapClicked" @contextmenu="onMapRightClicked" @idle="onMapIdle">
<MglGeolocateControl v-if="!$mq.mobile || isEnlarged" :positionOptions="{ enableHighAccuracy: true }" :fitBoundsOptions="{ maxZoom: 12.5 }" :trackUserLocation="true" position="top-right" />
<MglNavigationControl v-if="!$mq.mobile" position="top-right" :showCompass="false" />
<MglScaleControl v-if="!$mq.mobile || isEnlarged" position="bottom-left" />
<div v-if="canEnlarge" class="mapboxgl-ctrl-top-left">
<MapEnlargeControl :isEnlarged="isEnlarged" @enlarge="$emit('enlarge')" />
</div>
<MglAttributionControl :compact="true" position="bottom-right" />
<MapRoute v-for="route in routes" :key="route.id" :route="route" />
<MapPhoto v-for="photo in mapPhotos" :key="photo.filename" :summit="summit" :photo="photo" @photoClicked="photo => $emit('photoClicked', photo)" />
<MapInfoPopup v-if="infoCoordinates !== null" :coordinates="infoCoordinates" @close="infoCoordinates = null" />
<div v-if="zoomWarningVisible" class="zoom-warning">Zoom in to see all activations</div>
</MglMap>
</template>
<script>
import { MglMap, MglGeolocateControl, MglNavigationControl, MglScaleControl, MglAttributionControl } from 'vue-mapbox'
import MapRoute from './MapRoute.vue'
import MapPhoto from './MapPhoto.vue'
import MapInfoPopup from './MapInfoPopup.vue'
import MapEnlargeControl from './MapEnlargeControl.vue'
import mapstyle from '../mixins/mapstyle.js'
import utils from '../mixins/utils.js'
import longtouch from '../mixins/longtouch.js'
export default {
name: 'MiniMap',
props: {
summit: Object,
routes: {
type: Array,
default: () => { return [] }
},
filter: Array,
bounds: Array,
isEnlarged: Boolean,
zoomWarning: Boolean,
showInactiveSummits: Boolean,
canEnlarge: Boolean
},
components: {
MglMap, MglGeolocateControl, MglNavigationControl, MapEnlargeControl, MglScaleControl, MglAttributionControl, MapRoute, MapPhoto, MapInfoPopup
},
mixins: [utils, mapstyle, longtouch],
watch: {
summit: {
immediate: true,
handler () {
this.highlightCurrentSummit()
}
},
showInactiveSummits () {
this.showHideInactiveSummits()
},
dragPanEnabled () {
if (this.dragPanEnabled) {
this.map.dragPan.enable()
} else {
this.map.dragPan.disable()
}
}
},
computed: {
mapCenter () {
if (this.summit && this.summit.coordinates) {
return [this.summit.coordinates.longitude, this.summit.coordinates.latitude]
} else {
return undefined
}
},
mapPhotos () {
if (!this.summit || !this.summit.photos) {
return []
}
return this.summit.photos.filter(photo => {
if (photo.coordinates === undefined) {
return false
}
if (photo.positioningError && photo.positioningError > 100) {
return false
}
return true
})
},
dragPanEnabled () {
return (!this.$mq.mobile || this.isEnlarged)
},
fitBoundsOptions () {
return { padding: { left: 60, top: 40, right: 60, bottom: 40 }, maxZoom: 12 }
}
},
methods: {
showHideInactiveSummits () {
if (!this.map) {
return
}
if (this.showInactiveSummits) {
this.map.setLayoutProperty('summits_inactive_names', 'visibility', 'visible')
this.map.setLayoutProperty('summits_inactive_circles', 'visibility', 'visible')
} else {
this.map.setLayoutProperty('summits_inactive_names', 'visibility', 'none')
this.map.setLayoutProperty('summits_inactive_circles', 'visibility', 'none')
}
},
highlightCurrentSummit () {
if (!this.map || !this.summit || !this.summit.code) {
return
}
this.map.setFilter('summits_selected', ['==', 'code', this.summit.code])
},
onMapLoaded (event) {
this.map = event.map
this.map.touchZoomRotate.disableRotation()
this.$nextTick(() => {
this.map.resize()
});
['summits_circles', 'summits_inactive_circles'].forEach(layer => {
this.map.on('mouseenter', layer, () => {
this.map.getCanvas().style.cursor = 'pointer'
})
this.map.on('mouseleave', layer, () => {
this.map.getCanvas().style.cursor = ''
})
})
this.map.setFilter('summits_circles', this.filter)
this.map.setFilter('summits_names', this.filter)
this.map.setFilter('summits_activations', this.filter)
this.map.setFilter('summits_inactive_circles', this.filter)
this.map.setFilter('summits_inactive_names', this.filter)
this.map.setLayoutProperty('contour', 'visibility', 'visible')
this.map.setLayoutProperty('contour_index', 'visibility', 'visible')
this.map.setLayoutProperty('contour_label', 'visibility', 'visible')
this.map.setLayoutProperty('hillshading', 'visibility', 'visible')
this.updateDifficultyLayer()
this.installLongTouchHandler(this.map, (e) => {
this.infoCoordinates = {
latitude: e.lngLat.lat,
longitude: e.lngLat.lng
}
})
this.showHideInactiveSummits()
this.highlightCurrentSummit()
},
onMapClicked (event) {
if (event.mapboxEvent.originalEvent.hitMarker) {
return
}
// Search for summit circles with some padding/fuzz to make it easier to hit on mobile devices
let point = event.mapboxEvent.point
let bbox = [[point.x - 10, point.y - 10], [point.x + 10, point.y + 10]]
let features = this.map.queryRenderedFeatures(bbox, { layers: ['summits_circles', 'summits_inactive_circles'] })
if (features.length > 0) {
// Find the summit closest to where the user tapped
let minDistance = null
let chosenFeature = null
features.forEach(feature => {
let projected = this.map.project(feature.geometry.coordinates)
let distance = Math.pow(projected.x - point.x, 2) + Math.pow(projected.y - point.y, 2)
if (minDistance === null || distance < minDistance) {
minDistance = distance
chosenFeature = feature
}
})
if (chosenFeature && chosenFeature.properties.code) {
if (this.summit && chosenFeature.properties.code === this.summit.code) {
this.$router.push('/map/summits/' + chosenFeature.properties.code)
} else {
this.$router.push('/summits/' + chosenFeature.properties.code)
}
}
}
},
onMapRightClicked (event) {
this.infoCoordinates = {
latitude: event.mapboxEvent.lngLat.lat,
longitude: event.mapboxEvent.lngLat.lng
}
},
onMapIdle () {
if (this.map) {
this.zoomWarningVisible = (this.map.getZoom() < 3) && this.zoomWarning
}
},
updateDifficultyLayer () {
if (!this.map) {
return
}
// Glean main map difficulty visibility setting
let mapOptions
try {
mapOptions = JSON.parse(localStorage.getItem('mapOptions'))
if (mapOptions && mapOptions.difficulty === false) {
this.map.setLayoutProperty('road_path_pedestrian_sac', 'visibility', 'none')
this.map.setLayoutProperty('road_path_pedestrian_sac_label', 'visibility', 'none')
} else {
this.map.setLayoutProperty('road_path_pedestrian_sac', 'visibility', 'visible')
this.map.setLayoutProperty('road_path_pedestrian_sac_label', 'visibility', 'visible')
}
} catch (e) {}
},
resize () {
this.$nextTick(() => {
if (this.map) {
this.map.resize()
}
})
},
easeTo (coordinates, zoom) {
if (this.map) {
this.map.easeTo({
center: coordinates,
zoom
})
}
}
},
data () {
return {
infoCoordinates: null,
zoomWarningVisible: false
}
}
}
</script>
<style scoped>
>>> .mapboxgl-canvas-container.mapboxgl-interactive {
cursor: auto;
}
.map >>> .mapboxgl-popup {
max-width: 400px !important;
}
.zoom-warning {
position: absolute;
left: 50%;
top: 1.5rem;
transform: translate(-50%, 0);
background-color: #feffd2;
padding: 0.2em 0.5em;
border-radius: 0.5em;
text-align: center;
opacity: 0.9;
}
</style>

Wyświetl plik

@ -0,0 +1,155 @@
<template>
<b-modal :active.sync="modalActive" :can-cancel="['escape', 'outside']" has-modal-card>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title"><span v-if="activationDetails"><router-link :to="makeActivatorLink(activationDetails.OwnCallsign)">{{ activationDetails.OwnCallsign }}</router-link> on <router-link :to="makeSummitLink(summitCode)">{{ activationDetails.Summit }}</router-link>, <span class="activation-date">{{ niceActivationDate }}</span></span></p>
<button class="delete" aria-label="close" @click="modalActive = false"></button>
</header>
<section class="modal-card-body">
<QSOList v-if="activationDetails !== null" :data="activationDetails.ActivatorLogs" />
<b-loading :is-full-page="false" :active="activationLoading" />
</section>
<footer class="modal-card-foot"></footer>
</div>
</b-modal>
</template>
<script>
import moment from 'moment'
import QSOList from '../components/QSOList.vue'
import sotadb from '../mixins/sotadb.js'
import utils from '../mixins/utils.js'
export default {
props: {
activationId: Number
},
mixins: [ sotadb, utils ],
components: { QSOList },
watch: {
activationId: {
immediate: true,
handler () {
if (this.activationId === null) {
this.modalActive = false
return
}
this.activationDetails = null
this.modalActive = true
this.activationLoading = true
this.loadActivationDetails(this.activationId)
.then(activationDetails => {
this.activationDetails = activationDetails
this.activationLoading = false
let matches = activationDetails.ActivationDate.match(/^\d\d\/\d\d\/(\d\d\d\d)$/)
if (matches) {
this.loadS2SLog(activationDetails.UserID, matches[1])
.then(response => {
this.augmentS2S(response)
})
}
})
.catch(() => {
this.activationLoading = false
})
}
},
modalActive (newModalActive) {
if (!newModalActive) {
this.$emit('modalClosed')
this.qsoList = null
}
}
},
computed: {
niceActivationDate () {
return moment(this.activationDetails.ActivationDate, 'DD/MM/YYYY').format('DD MMM YYYY')
},
summitCode () {
return this.activationDetails.Summit.substring(0, this.activationDetails.Summit.indexOf(' '))
}
},
methods: {
augmentS2S (response) {
// Convert date (SOTA API is not consistent with formats)
let matches = this.activationDetails.ActivationDate.match(/^(\d\d)\/(\d\d)\/(\d\d\d\d)$/)
if (!matches) {
return
}
let activationDate = `${matches[3]}-${matches[2]}-${matches[1]}`
// Extract summit ref.
let summitRef = this.activationDetails.Summit.split(' ')[0]
// Augment activation details with S2S info, if available
response.forEach(s2s => {
if (s2s.ActivationDate !== activationDate || s2s.Summit2Code !== summitRef) {
return
}
// Find a QSO in the activator log with matching callsign, band, mode and approximate time
let otherCallsign = this.homeCallsign(s2s.OtherCallsign)
let s2sTime = moment.utc(activationDate + ' ' + s2s.TimeOfDay)
let logEntry = this.activationDetails.ActivatorLogs.find(log => {
if (this.homeCallsign(log.OtherCallsign) !== otherCallsign ||
log.Band !== s2s.Band ||
log.Mode !== s2s.Mode) {
return false
}
let logTime = moment.utc(activationDate + ' ' + log.TimeOfDay)
if (Math.abs(logTime.diff(s2sTime, 'minutes')) > 15) {
return false
}
return true
})
if (logEntry) {
// Add to notes if it does not already contain the summit ref
if (!logEntry.Notes.includes(s2s.SummitCode)) {
if (logEntry.Notes.length > 0 && logEntry.Notes !== 'S2S') {
logEntry.Notes += ', '
}
if (logEntry.Notes !== 'S2S') {
logEntry.Notes += 'S2S'
}
logEntry.Notes += ' ' + s2s.SummitCode
}
}
})
}
},
data () {
return {
modalActive: false,
activationLoading: false,
activationDetails: null
}
}
}
</script>
<style scoped>
.faicon {
color: #3273dc;
float: right;
}
.delete {
left: 10px;
}
.activation-date {
white-space: nowrap;
}
.modal-card-body {
padding: 10px;
min-height: 6rem;
}
.modal-card-title {
font-size: 1rem;
flex-shrink: 1;
line-height: 1.25;
}
</style>

Wyświetl plik

@ -0,0 +1,39 @@
<template>
<b-tag type="is-dark" :class="tagClass">{{ mode.toUpperCase() }}</b-tag>
</template>
<script>
export default {
name: 'ModeLabel',
props: {
mode: {
type: String,
required: true
}
},
computed: {
tagClass () {
return { ['mode-' + this.mode.toLowerCase()]: true }
}
}
}
</script>
<style scoped>
.tag {
min-width: 3em;
padding: 0.4em 0.5em;
color: #fff;
line-height: 1em;
height: auto;
}
.tag.mode-cw {
background-color: #2b4970;
}
.tag.mode-ssb {
background-color: #a19a36;
}
.tag.mode-fm {
background-color: #a7385a;
}
</style>

Wyświetl plik

@ -0,0 +1,170 @@
<template>
<nav class="navbar is-fixed-top">
<div class="container">
<div class="navbar-brand">
<div class="navbar-item">
<router-link to="/about"><img src="../assets/sotlas.svg" alt="Logo"></router-link>
</div>
<div class="navbar-item clock">
<font-awesome-icon :icon="['far', 'clock']" class="faicon" /> {{ clock }}
</div>
<a role="button" :class="{ 'navbar-burger': true, 'burger': true, 'is-active': burgerActive }" aria-label="menu" aria-expanded="false" data-target="navbarMenu" @click="burgerClicked">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="navbarMenu" :class="{ 'navbar-menu': true, 'is-active': burgerActive }">
<div class="navbar-end">
<div class="navbar-item">
<SearchField :query="query" @search="closeBurger" />
</div>
<router-link v-for="link in links" :key="link.target" :to="link.target" :class="linkClass(link)" :title="link.title" @click.native="closeBurger">
<b-icon v-if="link.icon" :pack="link.iconPack" :icon="link.icon" />
{{ link.text }}
<span v-if="!$mq.desktop">{{ link.mobileText }}</span>
</router-link>
<LoginButton @linkClicked="closeBurger" />
</div>
</div>
</div>
</nav>
</template>
<script>
import moment from 'moment'
import SearchField from '../components/SearchField.vue'
import LoginButton from '../components/LoginButton.vue'
import utils from '../mixins/utils.js'
import EventBus from '../event-bus'
export default {
name: 'NavBar',
mixins: [ utils ],
components: {
SearchField, LoginButton
},
props: {
query: {
type: String,
default: ''
}
},
mounted () {
this.updateClock()
this.clockInterval = setInterval(() => {
this.updateClock()
}, 1000)
},
watch: {
burgerActive () {
EventBus.$emit(this.burgerActive ? 'navbarMenuOpened' : 'navbarMenuClosed')
}
},
destroyed () {
clearInterval(this.clockInterval)
},
methods: {
linkClass (link) {
let classes = { 'navbar-item': true }
if (this.$route.path.startsWith(link.target)) {
classes['is-active'] = true
}
return classes
},
burgerClicked () {
this.burgerActive = !this.burgerActive
},
closeBurger () {
this.burgerActive = false
},
updateClock () {
let newClock = this.formatTime(moment.utc())
if (newClock !== this.clock) {
this.clock = newClock
}
}
},
computed: {
links () {
let mapLink = '/map'
if (this.$route.path.match(/^\/summits\/\S+\/\S+-\d+$/)) {
mapLink = this.$route.path.replace('/summits/', '/map/summits/')
}
return [
{
target: mapLink,
text: 'Map'
},
{
target: '/summits',
text: 'Summits'
},
{
target: '/spots',
text: 'Spots'
},
{
target: '/alerts',
text: 'Alerts'
},
{
target: '/activators',
text: 'Activators'
},
{
target: '/settings',
mobileText: 'Settings',
title: 'Settings',
icon: 'cog',
iconPack: 'fas'
}
]
}
},
data () {
return {
burgerActive: false,
clock: ''
}
}
}
</script>
<style scoped>
.navbar {
background-color: #ddd;
border-bottom: 1px solid #ccc;
}
@media print {
.navbar {
display: none;
}
}
@media (max-width: 320px) {
.navbar-brand img {
max-height: 1.5rem;
}
}
@media (max-width: 768px) {
.navbar {
font-size: 1rem !important;
}
}
a.navbar-item.is-active:not(:focus):not(:hover), .navbar-link.is-active:not(:focus):not(:hover) {
background-color: whitesmoke;
}
.clock {
opacity: 0.7;
font-size: 1rem;
}
.clock .faicon {
margin-right: 0.3em;
}
.navbar-brand .navbar-item {
line-height: 1;
}
.navbar-menu .navbar-item span {
vertical-align: middle;
}
</style>

Wyświetl plik

@ -0,0 +1,109 @@
<template>
<b-dropdown ref="dropdown" aria-role="list" position="is-bottom-left" class="nearby-summits">
<b-button slot="trigger" icon-right="angle-down" :loading="loading" @click.stop="clickButton">Nearby</b-button>
<b-dropdown-item v-for="summit in nearbySummits" :key="summit.code" aria-role="listitem" @click="clickSummit(summit)">
<div class="summit-title"><div class="summit-name">{{ summit.name }}</div><div class="summit-alt"><AltitudeLabel :altitude="summit.altitude" /></div></div>
<div class="summit-info"><div class="distance"><font-awesome-icon :icon="['far', 'arrows-h']" class="faicon" /><DistanceLabel :distance="summit.distance" :high-precision="true" /></div>{{ summit.code }}</div>
</b-dropdown-item>
</b-dropdown>
</template>
<script>
import axios from 'axios'
import haversineDistance from 'haversine-distance'
import DistanceLabel from './DistanceLabel.vue'
import AltitudeLabel from './AltitudeLabel.vue'
export default {
name: 'NearbySummitsList',
components: {
DistanceLabel, AltitudeLabel
},
methods: {
clickButton () {
if (this.$refs.dropdown.isActive) {
this.$refs.dropdown.toggle()
} else {
if (navigator.geolocation) {
this.loading = true
navigator.geolocation.getCurrentPosition(
position => {
axios.get('https://api.sotl.as/summits/near', { params: { lat: position.coords.latitude, lon: position.coords.longitude, limit: 5, maxDistance: 100000 } })
.then(response => {
if (response.data.length === 0) {
alert('No summits within 100 km.')
} else {
response.data.forEach(summit => {
summit.distance = haversineDistance(summit.coordinates, position.coords)
})
this.nearbySummits = response.data
this.$refs.dropdown.toggle()
}
})
.finally(() => {
this.loading = false
})
},
error => {
this.loading = false
alert(error.message)
}, {
enableHighAccuracy: true,
timeout: 10000
}
)
} else {
alert('Geolocation is not supported by this browser.')
}
}
},
clickSummit (summit) {
this.$emit('summitSelected', summit)
}
},
data () {
return {
nearbySummits: [],
loading: false
}
}
}
</script>
<style scoped>
.summit-title {
max-width: 100%;
}
.summit-name {
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
max-width: calc(100% - 4em);
vertical-align: bottom;
}
.summit-alt {
display: inline-block;
font-size: 0.9em;
color: #777;
margin-left: 0.5em;
}
@media (min-width: 1024px) {
.summit-name {
max-width: 16em;
}
}
.summit-info {
color: #777;
font-size: 0.9em;
}
.dropdown-item {
padding-right: 1rem;
}
.distance .faicon {
margin-right: 0.3em;
}
.distance {
float: right;
margin-left: 0.5em;
}
</style>

Wyświetl plik

@ -0,0 +1,54 @@
<template>
<div>
<section class="hero is-light">
<div class="hero-body">
<div class="container">
<div class="level is-mobile">
<div class="level-left">
<h1 class="title is-size-1 is-size-3-mobile">
<slot name="title"></slot>
</h1>
</div>
<div class="level-right">
<slot name="title-right"></slot>
</div>
</div>
</div>
<div class="container">
<slot name="subtitle"></slot>
</div>
</div>
</section>
<slot></slot>
<Footer />
</div>
</template>
<script>
import Footer from '../components/Footer.vue'
export default {
name: 'PageLayout',
components: {
Footer
},
computed: {
query () {
return this.$route.query.q
}
}
}
</script>
<style scoped>
.level {
align-items: start;
}
@media (max-width: 768px) {
.level-left + .level-right {
margin-top: 0;
}
}
</style>

Wyświetl plik

@ -0,0 +1,60 @@
<template>
<div ref="chart"></div>
</template>
<script>
import { Chart } from 'frappe-charts/dist/frappe-charts.min.esm'
export default {
props: {
data: Array,
labelField: String,
valueField: String,
name: String,
maxSlices: {
type: Number,
default: 8
}
},
methods: {
updateChart () {
let labels = []
let values = []
this.data.forEach(row => {
labels.push(row[this.labelField])
values.push(row[this.valueField])
})
this.chart = new Chart(this.$refs.chart, {
data: {
labels,
datasets: [{
values,
name: this.name
}]
},
type: 'percentage',
height: 150,
maxSlices: this.maxSlices,
barOptions: {
depth: 1
}
})
}
},
watch: {
data () {
this.updateChart()
}
},
mounted () {
this.updateChart()
}
}
</script>
<style scoped>
>>> .graph-svg-tip .title {
color: #fff;
}
</style>

Wyświetl plik

@ -0,0 +1,97 @@
<template>
<file-pond name="photo" ref="filePond" :label-idle="labelIdle" :allow-multiple="true" :allow-replace="false" :allow-revert="false" :allow-paste="false" accepted-file-types="image/jpeg, image/png, image/heic" :server="uploadServer()" @processfile="onProcessFile" />
</template>
<script>
import vueFilePond from 'vue-filepond'
import prefs from '../mixins/prefs.js'
import { FileStatus } from 'filepond'
import 'filepond/dist/filepond.min.css'
import FilePondPluginFileValidateType from 'filepond-plugin-file-validate-type'
import api from '../mixins/api.js'
import axios from 'axios'
const FilePond = vueFilePond(FilePondPluginFileValidateType)
export default {
props: {
summitCode: String
},
components: {
FilePond
},
prefs: {
key: 'photosUploaderPrefs',
props: ['gpsNotificationShown']
},
mixins: [api, prefs],
computed: {
labelIdle () {
if (this.$mq.mobile) {
return '<div>Tap to upload photos</div><div style="font-size: 0.7em">By uploading your photos, you allow SOTLAS to publish them. You can delete your photos at any time.</div>'
} else {
return '<div>Drag & Drop your photos or <span class="filepond--label-action">Browse</span> to upload</div><div style="font-size: 0.8em">By uploading your photos, you allow SOTLAS to publish them. You can delete your photos at any time.</div>'
}
}
},
methods: {
uploadServer () {
return {
process: (fieldName, file, metadata, load, error, progress, abort, transfer, options) => {
const CancelToken = axios.CancelToken
const source = CancelToken.source()
this.uploadPhoto(this.summitCode, file, e => progress(e.lengthComputable, e.loaded, e.total), source.token)
.then(res => {
load(res)
if (res.data.length > 0 && !res.data[0].coordinates && !this.gpsNotificationShown) {
this.$buefy.dialog.alert({
title: 'No GPS information',
message: '<p>Your photo has been uploaded successfully, but did not contain GPS coordinates in its metadata (Exif header). If possible, try uploading original full-resolution files from your camera. SOTLAS will automatically reduce the resolution if needed, and will use the GPS coordinates to show the photos on the map if the position is accurate enough.</p><p><small>This alert will not appear again for future uploads with missing GPS information.</small></p>',
type: 'is-info',
hasIcon: true,
icon: 'info-circle',
iconPack: 'far'
})
this.gpsNotificationShown = true
}
})
.catch(err => {
error(err)
})
return {
abort () {
source.cancel()
abort()
}
}
},
fetch: null,
revert: null
}
},
onProcessFile () {
// Check if any other uploads are pending
if (this.$refs.filePond.getFiles().every(file => file.status === FileStatus.PROCESSING_COMPLETE)) {
this.$emit('upload')
this.$refs.filePond.removeFiles()
}
}
},
data () {
return {
gpsNotificationShown: false
}
}
}
</script>
<style scoped>
>>> .filepond--root {
margin-bottom: 0;
}
>>> .filepond--panel-root {
background-color: #f7f7f7;
}
</style>

Wyświetl plik

@ -0,0 +1,172 @@
<template>
<div>
<div ref="container">
<draggable v-model="myItems" handle=".handle" @change="dragChange">
<figure v-for="(item, index) in myItems" :key="item.src">
<a :href="item.src" :title="item.thumbTitle" @click.prevent="open(index)" @mouseover="$emit('mouseoverPicture', item, index)" @mouseleave="$emit('mouseleavePicture', item, index)">
<img :src="item.msrc" />
</a>
<div class="move-button" v-if="item.editable">
<b-button class="control handle" size="is-small" icon-left="arrows-alt" title="Drag to reorder"></b-button>
</div>
<div class="edit-buttons" v-if="item.editable">
<b-button class="control" size="is-small" icon-left="edit" @click="$emit('editPicture', item, index)" title="Edit"></b-button>
<b-button class="control" size="is-small" type="is-danger" icon-left="trash-alt" @click="$emit('deletePicture', item, index)" title="Delete"></b-button>
</div>
<font-awesome-icon v-if="item.thumbTitle" class="comment-icon" :icon="['far', 'comment']" />
</figure>
</draggable>
</div>
<div ref="pswp" class="pswp" tabindex="-1" role="dialog" aria-hidden="true">
<div class="pswp__bg"></div>
<div class="pswp__scroll-wrap">
<div class="pswp__container">
<div class="pswp__item"></div>
<div class="pswp__item"></div>
<div class="pswp__item"></div>
</div>
<div class="pswp__ui pswp__ui--hidden">
<div class="pswp__top-bar">
<div class="pswp__counter"></div>
<button class="pswp__button pswp__button--close" title="Close (Esc)"></button>
<button class="pswp__button pswp__button--share" title="Share"></button>
<button class="pswp__button pswp__button--fs" title="Toggle fullscreen"></button>
<button class="pswp__button pswp__button--zoom" title="Zoom in/out"></button>
<div class="pswp__preloader">
<div class="pswp__preloader__icn">
<div class="pswp__preloader__cut">
<div class="pswp__preloader__donut"></div>
</div>
</div>
</div>
</div>
<div class="pswp__share-modal pswp__share-modal--hidden pswp__single-tap">
<div class="pswp__share-tooltip"></div>
</div>
<button class="pswp__button pswp__button--arrow--left" title="Previous (arrow left)">
</button>
<button class="pswp__button pswp__button--arrow--right" title="Next (arrow right)">
</button>
<div class="pswp__caption">
<div class="pswp__caption__center"></div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import PhotoSwipe from 'photoswipe/dist/photoswipe'
import PhotoSwipeUIDefault from 'photoswipe/dist/photoswipe-ui-default'
import 'photoswipe/dist/photoswipe.css'
import 'photoswipe/dist/default-skin/default-skin.css'
import draggable from 'vuedraggable'
export default {
components: {
draggable
},
props: {
items: {
type: Array
},
options: {
default: () => ({}),
type: Object
}
},
methods: {
open (index, disableAnimation = false) {
let that = this
let gallery
let options = {
index,
getThumbBoundsFn (index) {
let thumbnail = Array.from(that.$refs.container.getElementsByTagName('img')).find(tag => tag.src === that.myItems[index].msrc)
let pageYScroll = window.pageYOffset || document.documentElement.scrollTop
let rect = thumbnail.getBoundingClientRect()
return { x: rect.left, y: rect.top + pageYScroll, w: rect.width }
},
shareButtons: [
{ id: 'download', label: 'Download original', url: '{{raw_image_url}}', download: true }
],
getImageURLForShare: () => {
return gallery.currItem.osrc || ''
}
}
if (disableAnimation) {
options.showAnimationDuration = 0
// Hide animation duration not 0 to prevent close click event from bubbling through
options.hideAnimationDuration = 1
}
gallery = new PhotoSwipe(this.$refs.pswp, PhotoSwipeUIDefault, this.myItems, Object.assign(options, this.options))
gallery.init()
},
dragChange (event) {
// Should not get any other type of event
if (event.moved) {
this.$emit('movePicture', event.moved.newIndex, event.moved.oldIndex, event.element)
}
}
},
data () {
return {
myItems: []
}
},
watch: {
items: {
handler (newItems) {
this.myItems = newItems
},
immediate: true
}
}
}
</script>
<style>
.pswp__top-bar {
text-align: right;
}
.pswp__caption__center {
text-align: center
}
figure {
display: inline-block;
margin: 5px;
position: relative;
}
figure img {
vertical-align: middle;
}
.pswp__caption__center {
max-width: 90vw;
}
.move-button {
position: absolute;
left: 0.5em;
top: 0.5em;
}
.edit-buttons {
position: absolute;
right: 0.5em;
bottom: 0.5em;
}
.edit-buttons .button {
margin-left: 0.5em;
}
.comment-icon {
position: absolute;
left: 0.5em;
bottom: 0.5em;
color: white;
filter: drop-shadow(0 0 0.15em #000);
pointer-events: none;
}
</style>

Wyświetl plik

@ -0,0 +1,62 @@
<template>
<div ref="chart"></div>
</template>
<script>
import { Chart } from 'frappe-charts/dist/frappe-charts.min.esm'
export default {
props: {
data: Array,
labelField: String,
valueField: String,
name: String,
maxSlices: {
type: Number,
default: 8
}
},
methods: {
updateChart () {
let labels = []
let values = []
this.data.forEach(row => {
labels.push(row[this.labelField])
values.push(row[this.valueField])
})
this.chart = new Chart(this.$refs.chart, {
data: {
labels,
datasets: [{
values,
name: this.name
}]
},
type: 'pie',
height: 250,
maxSlices: this.maxSlices
})
}
},
watch: {
data () {
this.updateChart()
}
},
mounted () {
this.updateChart()
}
}
</script>
<style scoped>
>>> .graph-svg-tip .title {
color: #fff;
}
@media (max-width: 1216px) {
>>> svg.chart {
height: 300px;
}
}
</style>

Wyświetl plik

@ -0,0 +1,76 @@
<template>
<b-table class="auto-width" :narrowed="true" :striped="true" :data="data" :mobile-cards="false">
<template slot-scope="props">
<b-table-column field="TimeOfDay" label="Time" class="nowrap" sortable>
{{ props.row.TimeOfDay }}
</b-table-column>
<b-table-column field="OtherCallsign" label="Callsign" class="nowrap" sortable>
<CountryFlag :country="isoCodeForCallsign(props.row.OtherCallsign)" class="flag" />
<router-link :to="makeActivatorLink(props.row.OtherCallsign)">{{ props.row.OtherCallsign }}</router-link>
</b-table-column>
<b-table-column field="Band" label="Band" :custom-sort="sortBand" class="nowrap" sortable numeric>
{{ bandForFrequency(props.row.Band.replace('MHz', '')) }}
</b-table-column>
<b-table-column field="Mode" label="Mode" class="mode nowrap" sortable>
<ModeLabel :mode="props.row.Mode" />
</b-table-column>
<b-table-column field="Notes" label="Notes" class="nowrap">
<span v-html="formatNotes(props.row.Notes)" />
</b-table-column>
</template>
</b-table>
</template>
<script>
import utils from '../mixins/utils.js'
import prefix from '../prefix.js'
import ModeLabel from '../components/ModeLabel.vue'
import CountryFlag from '../components/CountryFlag.vue'
export default {
props: {
data: {
type: Array,
required: true
}
},
components: {
ModeLabel, CountryFlag
},
mixins: [utils],
methods: {
sortBand (a, b, isAsc) {
let fa = parseFloat(a.Band.replace('MHz', ''))
let fb = parseFloat(b.Band.replace('MHz', ''))
if (fa < fb) {
return (isAsc ? -1 : 1)
} else if (fa === fb) {
return 0
} else {
return (isAsc ? 1 : -1)
}
},
isoCodeForCallsign (callsign) {
return prefix.isoCodeForCallsign(callsign)
},
formatNotes (notes) {
// Detect summit references and link them
let doc = new DOMParser().parseFromString(notes, 'text/html')
let notesText = doc.body.textContent || ''
return notesText.replace(/[A-Z0-9]{1,8}\/[A-Z]{2}-[0-9]{3}/gi, match => {
return '<a href="/summits/' + match.toUpperCase() + '">' + match.toUpperCase() + '</a>'
})
}
}
}
</script>
<style scoped>
.flag {
margin-right: 0.4em;
}
.mode .tag {
padding-top: 0.3em;
padding-bottom: 0.3em;
}
</style>

Wyświetl plik

@ -0,0 +1,92 @@
<template>
<div class="card">
<div class="card-content">
<div class="freqmode">{{ spot.frequency | formatFrequency }} <ModeLabel :mode="spot.mode" /></div>
<div class="time" v-html="formatTimeDay(spot.timeStamp)" />
<div class="callsign">
<CountryFlag :country="country(spot.callsign)" class="flag" />
<template v-if="callsignLink">
<router-link :to="makeActivatorLink(spot.callsign)">{{ spot.callsign }}</router-link>
</template>
<template v-else>{{ spot.callsign }}</template>
</div>
<div class="details">
<div class="spotter">@{{ spot.spotter }}</div>
<div class="speedsnr">{{ spot.snr }} dB, {{ spot.speed }} wpm</div>
</div>
</div>
</div>
</template>
<script>
import prefix from '../prefix.js'
import utils from '../mixins/utils.js'
import ModeLabel from '../components/ModeLabel.vue'
import CountryFlag from '../components/CountryFlag.vue'
export default {
name: 'RBNSpotCard',
components: {
ModeLabel, CountryFlag
},
mixins: [utils],
props: {
spot: {
type: Object,
required: true
},
callsignLink: {
type: Boolean,
default: true
}
},
methods: {
country (callsign) {
return prefix.isoCodeForCallsign(callsign)
}
}
}
</script>
<style scoped>
.card-content {
font-size: 0.9rem;
padding: 0.6rem;
line-height: 1.3;
overflow: auto;
}
.card-content .time {
margin-right: 0.5em;
min-width: 3.5em;
display: inline-block;
}
.card-content .callsign {
font-weight: bold;
display: inline-block;
}
.card-content .freqmode {
float: right;
margin-bottom: 0.1em;
}
.card-content .tag {
position: relative;
margin-left: 0.3em;
top: -0.1em;
}
.card-content .details {
margin-top: 0.1em;
font-size: 0.75rem;
}
.card-content .spotter {
display: inline-block;
margin-right: 0.5em;
font-weight: bold;
width: 6em;
}
.card-content .speedsnr {
display: inline-block;
}
.card-content .flag {
margin-right: 0.5em;
}
</style>

Wyświetl plik

@ -0,0 +1,126 @@
<template>
<CardPagination v-if="!$mq.desktop" :data="cardSpots" rowKey="_id" :infinite="infinite" :paginated="paginated">
<template v-slot="{ row }">
<RBNSpotCard v-slot="{ row }" :class="recentClass(row.timeStamp)" :spot="row" :callsignLink="callsignLink" />
</template>
</CardPagination>
<b-table v-else :default-sort="['timeStamp', 'desc']" :narrowed="true" :striped="true" :data="data" :paginated="paginated" :per-page="perPage" :row-class="rowClass">
<template slot-scope="props">
<b-table-column field="timeStamp" class="timestamp" label="Time" sortable>
<span v-html="formatTimeDay(props.row.timeStamp)" />
</b-table-column>
<b-table-column field="callsign" label="Callsign" sortable>
<CountryFlag :country="country(props.row.callsign)" class="flag" /><template v-if="callsignLink"><router-link :to="makeActivatorLink(props.row.callsign)">{{ props.row.callsign }}</router-link></template><template v-else>{{ props.row.callsign }}</template>
</b-table-column>
<b-table-column field="frequency" label="Frequency" sortable numeric>
{{ props.row.frequency | formatFrequency }}
</b-table-column>
<b-table-column field="mode" label="Mode" sortable>
<ModeLabel :mode="props.row.mode" />
</b-table-column>
<b-table-column field="snr" label="SNR" sortable numeric>
{{ props.row.snr }} dB
</b-table-column>
<b-table-column field="speed" label="Speed" sortable numeric>
{{ props.row.speed }} wpm
</b-table-column>
<b-table-column field="spotter" label="Spotter" sortable>
{{ props.row.spotter }}
</b-table-column>
</template>
<template v-if="paginated" v-slot:bottom-left>
<b-select v-model="perPage">
<option v-for="option in perPageOptions" :key="option" :value="option">{{ option }} per page</option>
</b-select>
</template>
</b-table>
</template>
<script>
import prefix from '../prefix.js'
import utils from '../mixins/utils.js'
import prefs from '../mixins/prefs.js'
import nowticker from '../mixins/nowticker.js'
import ModeLabel from '../components/ModeLabel.vue'
import CardPagination from '../components/CardPagination.vue'
import RBNSpotCard from '../components/RBNSpotCard.vue'
import CountryFlag from '../components/CountryFlag.vue'
export default {
name: 'SpotsList',
components: {
ModeLabel, CardPagination, RBNSpotCard, CountryFlag
},
mixins: [utils, prefs, nowticker],
prefs: {
key: 'rbnSpotListPrefs',
props: ['perPage']
},
props: {
data: Array,
paginated: {
type: Boolean,
default: true
},
infinite: {
type: Boolean,
default: false
},
callsignLink: {
type: Boolean,
default: true
}
},
computed: {
cardSpots () {
return [...this.data].sort((a, b) => {
if (a.timeStamp > b.timeStamp) {
return -1
} else if (a.timeStamp < b.timeStamp) {
return 1
} else {
return 0
}
})
}
},
methods: {
rowClass (row) {
return this.recentClass(row.timeStamp)
},
country (callsign) {
return prefix.isoCodeForCallsign(callsign)
}
},
data () {
return {
perPage: 15,
perPageOptions: [10, 15, 20, 30, 50, 100]
}
}
}
</script>
<style scoped>
tr .timestamp {
border-left: 3px solid #e0e0e0;
}
tr.recent1 .timestamp {
border-left: 3px solid #f28591;
}
tr.recent2 .timestamp {
border-left: 3px solid #fbaf63;
}
.card {
border-left: 3px solid #e0e0e0;
}
.card.recent1 {
border-left: 3px solid #f28591;
}
.card.recent2 {
border-left: 3px solid #fbaf63;
}
.flag {
margin-right: 0.4em;
}
</style>

Wyświetl plik

@ -0,0 +1,42 @@
<template>
<div class="content">
<ul class="resources">
<li v-for="resource in resources" :key="resource.id"><b-icon v-if="resource.icon" :icon="resource.icon" :pack="resource.iconPack" size="is-small" /><svgicon v-if="resource.svgicon" class="icon" :icon="resource.svgicon" color="#555" /><img v-if="resource.iconImg" class="icon" :src="resource.iconImg" />{{ resource.prefix ? resource.prefix + ': ' : '' }}<a :href="resource.url" target="_blank">{{ resource.title }}</a><span class="subdued author" v-if="resource.author">(by {{ resource.author }} on {{ resource.date | formatSubmittedDate }})</span><span v-if="resource.suffix" class="subdued details">{{ resource.suffix }}</span></li>
</ul>
</div>
</template>
<script>
import moment from 'moment'
export default {
name: 'ResourceList',
props: {
resources: Array
},
filters: {
formatSubmittedDate (date) {
return moment.unix(date).format('DD MMM YYYY')
}
}
}
</script>
<style scoped>
.resources {
margin-top: 0.5em;
list-style: none;
margin-left: 1em;
}
.resources .icon {
width: 1.5em;
height: 1.5em;
vertical-align: bottom;
margin-right: 0.3em;
opacity: 0.5;
}
.author, .details {
display: inline-block;
margin-left: 0.5em;
}
</style>

Wyświetl plik

@ -0,0 +1,77 @@
<template>
<span class="route-attributes">
<b-tooltip v-if="route.parking && route.parking.available" :active="!$mq.mobile" label="Parking available" type="is-info" position="is-bottom">
<font-awesome-icon class="fa-icon clickable" :icon="['fas', 'parking']" @click="$emit('mapReposition', route.parking.coordinates)" />
</b-tooltip>
<b-tooltip v-if="route.publicTransport && route.publicTransport.available" :active="!$mq.mobile" label="Public transport available" type="is-info" position="is-bottom">
<font-awesome-layers class="fa-icon clickable" @click="$emit('mapReposition', route.publicTransport.coordinates)">
<font-awesome-icon class="fa-icon" icon="square" />
<font-awesome-icon class="fa-icon" :icon="['fas', 'bus']" transform="shrink-6" :style="{ color: 'white' }" />
</font-awesome-layers>
</b-tooltip>
<b-tooltip v-if="route.cableCar" :active="!$mq.mobile" label="Cable car/funicular available" type="is-info" position="is-bottom">
<svgicon icon="icons8-cable-car" />
</b-tooltip>
<b-tooltip v-if="route.track" :active="!$mq.mobile" label="GPS track available" type="is-info" position="is-bottom">
<TrackLink :route="route" :summit="summit"><font-awesome-icon class="fa-icon" :icon="['far', 'location']" /></TrackLink>
</b-tooltip>
<b-tooltip v-if="route.accessibleInWinter" :active="!$mq.mobile" label="Accessible in winter" type="is-info" position="is-bottom">
<font-awesome-icon class="fa-icon" :icon="['far', 'snowflake']" />
</b-tooltip>
</span>
</template>
<script>
import '../compiled-icons'
import TrackLink from './TrackLink.vue'
export default {
name: 'RouteAttributes',
props: {
route: Object,
summit: Object
},
components: { TrackLink },
computed: {
icons () {
let icons = []
if (this.route.accessibleInWinter) {
icons.push({
icon: 'snowflake',
text: 'Accessible in winter'
})
}
if (this.route.parking && this.route.parking.available) {
icons.push({
icon: 'parking',
text: 'Parking available'
})
}
if (this.route.publicTransport && this.route.publicTransport.available) {
icons.push({
icon: 'bus',
text: 'Public transport available'
})
}
return this.attributeList.filter((attribute) => this.route[attribute.attribute] === true)
}
}
}
</script>
<style scoped>
.b-tooltip {
margin-right: 0.25em;
display: inline;
}
.fa-icon, .svg-icon {
width: 1.4em;
height: 1.4em;
}
.b-tooltip:last-child {
margin-right: 0;
}
a.track-link {
color: inherit;
}
</style>

Wyświetl plik

@ -0,0 +1,73 @@
<template>
<b-tooltip type="is-info" position="is-bottom" :active="searchTooltipActive" always animated multilined label="Enter a (partial) callsign, summit name, reference, region or coordinates here">
<b-input class="search-input" ref="query" v-model="myQuery" placeholder="Summit, Callsign, Coords..." type="search" icon-pack="far" icon="search" rounded @keydown.native.enter="doSearch" @focus="searchFocus" @blur="searchBlur" />
</b-tooltip>
</template>
<script>
import prefs from '../mixins/prefs.js'
export default {
name: 'NavBar',
props: {
query: {
type: String,
default: ''
}
},
mixins: [prefs],
prefs: {
key: 'searchField',
props: ['searchTooltipShown']
},
data () {
return {
myQuery: this.query,
searchTooltipActive: false,
searchTooltipShown: false
}
},
methods: {
searchFocus () {
if (!this.searchTooltipShown) {
this.searchTooltipActive = true
}
},
searchBlur () {
if (this.searchTooltipActive) {
this.searchTooltipActive = false
this.searchTooltipShown = true
}
},
doSearch () {
if (this.myQuery.length > 0) {
// Coordinates?
let coordMatches = this.myQuery.match(/^\s*(-?[0-9.]+)\s*,\s*(-?[0-9.]+)\s*$/)
if (coordMatches) {
this.$router.push('/map/coordinates/' + coordMatches[1] + ',' + coordMatches[2] + '/16.0?popup=1')
} else {
// Region?
let regionMatches = this.myQuery.match(/^[A-Z0-9]{1,3}\/[A-Z]{2}$/i)
if (regionMatches) {
this.$router.push('/summits/' + this.myQuery.toUpperCase())
} else {
this.$router.push('/search?q=' + encodeURIComponent(this.myQuery))
}
}
this.myQuery = ''
this.$refs.query.$el.querySelector('input').blur()
this.$emit('search')
}
}
}
}
</script>
<style scoped>
@media screen and (min-width: 1024px) and (max-width: 1215px) {
.search-input {
max-width: 13rem;
}
}
</style>

Wyświetl plik

@ -0,0 +1,122 @@
<template>
<div class="card">
<div class="card-content">
<div class="freqmode">{{ spot.frequency | formatFrequency }} <ModeLabel :mode="spot.mode" /></div>
<div class="time" v-html="formatTimeDay(spot.timeStamp)" />
<div class="callsign">
<template v-if="callsignLink">
<router-link :to="makeActivatorLink(spot.activatorCallsign)">{{ spot.activatorCallsign }}</router-link>
</template>
<template v-else>{{ spot.activatorCallsign }}</template>
</div>
<div class="summit" v-if="showSummitInfo">
<div class="summit-title" v-if="spot.summit.name">
<CountryFlag v-if="spot.summit.isoCode" :country="spot.summit.isoCode" class="flag" />
<router-link :to="makeSummitLink(spot.summit.code)"><span class="summit-name">{{ spot.summit.name }}</span></router-link>
</div>
<div class="summit-info">{{ spot.summit.code }}<template v-if="spot.summit.altitude">, <AltitudeLabel :altitude="spot.summit.altitude" />, {{ spot.summit.points }}pt<ActivationCount :activationCount="spot.summit.activationCount" /></template></div>
</div>
<div class="spotter">{{ spot.callsign }}</div>
<div class="comments">{{ spot.comments }}</div>
<slot name="actions"></slot>
</div>
</div>
</template>
<script>
import utils from '../mixins/utils.js'
import ModeLabel from '../components/ModeLabel.vue'
import ActivationCount from '../components/ActivationCount.vue'
import CountryFlag from '../components/CountryFlag.vue'
import AltitudeLabel from '../components/AltitudeLabel.vue'
export default {
name: 'SpotCard',
components: {
ModeLabel, ActivationCount, CountryFlag, AltitudeLabel
},
mixins: [utils],
props: {
spot: {
type: Object,
required: true
},
callsignLink: {
type: Boolean,
default: true
},
showSummitInfo: {
type: Boolean,
default: true
}
}
}
</script>
<style scoped>
.card-content {
font-size: 0.9rem;
padding: 0.6rem;
line-height: 1.3;
overflow: auto;
}
.card-content .time {
margin-right: 0.5em;
min-width: 3.5em;
display: inline-block;
}
.card-content .callsign {
font-weight: bold;
display: inline-block;
}
.card-content .freqmode {
float: right;
margin-left: 0.3em;
}
.card-content .tag {
position: relative;
margin-left: 0.3em;
top: -0.1em;
}
.card-content .summit {
font-size: 0.75rem;
margin-top: 0.1em;
}
.card-content .summit-name {
font-size: 0.9rem;
}
.card-content .comments {
font-size: 0.75rem;
color: #777;
margin-top: 0.1em;
}
.card-content .spotter {
float: right;
font-style: italic;
margin-left: 0.3em;
font-size: 0.75rem;
clear: right;
color: #777;
}
.card-content .flag {
margin-right: 0.5em;
margin-left: 0.1em;
position: relative;
top: -0.05em;
}
.card-content .activation-count {
margin-left: 0.4em;
}
.card-content .actions {
margin-top: 0.5em;
}
.summit-title {
display: inline-block;
margin-right: 0.3em;
}
@media (min-width: 769px) {
.summit-info {
display: inline-block;
}
}
</style>

Wyświetl plik

@ -0,0 +1,266 @@
<template>
<div>
<CardPagination v-if="!$mq.desktop" :data="cardSpots" :infinite="infinite" :paginated="paginated">
<template v-slot="{ row }">
<SpotCard :class="recentClass(row.timeStamp)" :spot="row" :callsignLink="callsignLink" :showSummitInfo="showSummitInfo">
<template v-slot:actions>
<div v-if="canEditSpot(row)" class="actions">
<b-button class="control" size="is-small" outlined icon-left="edit" @click="editSpot(row)">Edit</b-button>
<b-button class="control" size="is-small" outlined icon-left="clone" @click="cloneSpot(row)">Clone</b-button>
<b-button class="control" size="is-small" type="is-danger" outlined icon-left="trash-alt" @click="deleteSpot(row)">Delete</b-button>
</div>
</template>
</SpotCard>
</template>
</CardPagination>
<b-table v-else :default-sort="['timeStamp', 'desc']" :narrowed="true" :striped="true" :data="data" :paginated="paginated" :per-page="perPage" :row-class="rowClass">
<template slot-scope="props">
<b-table-column field="timeStamp" class="timestamp" label="Time" sortable>
<span v-html="formatTimeDay(props.row.timeStamp)" />
</b-table-column>
<b-table-column v-if="showCallsign" field="activatorCallsign" label="Callsign" sortable>
<template v-if="callsignLink">
<router-link :to="makeActivatorLink(props.row.activatorCallsign)">{{ props.row.activatorCallsign }}</router-link>
</template>
<template v-else>
{{ props.row.activatorCallsign }}
</template>
</b-table-column>
<b-table-column field="frequency" label="Frequency" sortable :custom-sort="sortFrequency" numeric>
{{ props.row.frequency | formatFrequency }}
</b-table-column>
<b-table-column field="mode" label="Mode" sortable>
<ModeLabel :mode="props.row.mode" />
</b-table-column>
<b-table-column v-if="showSummitInfo" field="summit.code" label="Summit code" class="nowrap" sortable>
<CountryFlag v-if="props.row.summit.isoCode && $mq.fullhd" :country="props.row.summit.isoCode" class="flag" />
<router-link v-if="props.row.summit.name" :to="makeSummitLink(props.row.summit.code)">{{ props.row.summit.code }}</router-link>
<span v-else>{{ props.row.summit.code }}</span>
</b-table-column>
<b-table-column v-if="showSummitInfo" field="summit.name" label="Summit name" sortable>
<router-link :to="makeSummitLink(props.row.summit.code)">{{ props.row.summit.name }}</router-link>
</b-table-column>
<b-table-column v-if="showSummitInfo" field="summit.altitude" label="Altitude" sortable numeric>
<template v-if="props.row.summit.altitude"><AltitudeLabel :altitude="props.row.summit.altitude" /></template>
</b-table-column>
<b-table-column v-if="showSummitInfo" field="summit.points" label="Points" sortable numeric>
<SummitPointsLabel v-if="props.row.summit.points" :points="props.row.summit.points" />
</b-table-column>
<b-table-column v-if="showSummitInfo" field="summit.activationCount" label="Act." sortable numeric>
<ActivationCount :activationCount="props.row.summit.activationCount" />
</b-table-column>
<b-table-column field="callsign" label="Posted by" sortable>
{{ props.row.callsign }}
</b-table-column>
<b-table-column field="comments" class="comments" label="Comments">
<div class="comments-cell">
<b-tooltip class="comments-tooltip" :label="props.row.comments" position="is-left" multilined :active="!$mq.fullhd"><div>{{ props.row.comments }}</div></b-tooltip>
<b-dropdown v-if="canEditSpot(props.row)" class="actions" aria-role="list">
<b-button size="is-small" slot="trigger" icon-pack="fas" icon-right="caret-down" outlined>Actions</b-button>
<b-dropdown-item aria-role="listitem" @click="editSpot(props.row)"><b-icon icon="edit" size="is-small" /><span class="dropdown-label">Edit</span></b-dropdown-item>
<b-dropdown-item aria-role="listitem" @click="cloneSpot(props.row)"><b-icon icon="clone" size="is-small" /><span class="dropdown-label">Clone</span></b-dropdown-item>
<b-dropdown-item aria-role="listitem" @click="deleteSpot(props.row)"><b-icon icon="trash-alt" type="is-danger" size="is-small" /><span class="has-text-danger dropdown-label">Delete</span></b-dropdown-item>
</b-dropdown>
</div>
</b-table-column>
</template>
<template v-if="paginated" v-slot:bottom-left>
<b-select v-model="perPage">
<option v-for="option in perPageOptions" :key="option" :value="option">{{ option }} per page</option>
</b-select>
</template>
</b-table>
<b-modal v-if="isEditSpotActive" :active="true" has-modal-card :can-cancel="['escape']" @close="isEditSpotActive = false">
<EditSpot :spot="spotToEdit" />
</b-modal>
</div>
</template>
<script>
import utils from '../mixins/utils.js'
import prefs from '../mixins/prefs.js'
import nowticker from '../mixins/nowticker.js'
import sotawatch from '../mixins/sotawatch.js'
import ModeLabel from '../components/ModeLabel.vue'
import SummitPointsLabel from '../components/SummitPointsLabel.vue'
import CardPagination from '../components/CardPagination.vue'
import SpotCard from '../components/SpotCard.vue'
import ActivationCount from '../components/ActivationCount.vue'
import CountryFlag from '../components/CountryFlag.vue'
import AltitudeLabel from '../components/AltitudeLabel.vue'
import EditSpot from '../components/EditSpot.vue'
export default {
name: 'SpotsList',
components: {
ModeLabel, SummitPointsLabel, CardPagination, SpotCard, ActivationCount, CountryFlag, AltitudeLabel, EditSpot
},
mixins: [utils, prefs, nowticker, sotawatch],
prefs: {
key: 'spotsListPrefs',
props: ['perPage']
},
props: {
data: Array,
showCallsign: {
type: Boolean,
default: true
},
showSummitInfo: {
type: Boolean,
default: true
},
paginated: {
type: Boolean,
default: true
},
infinite: {
type: Boolean,
default: false
},
callsignLink: {
type: Boolean,
default: true
}
},
computed: {
cardSpots () {
return [...this.data].sort((a, b) => {
if (a.timeStamp > b.timeStamp) {
return -1
} else if (a.timeStamp < b.timeStamp) {
return 1
} else {
return 0
}
})
}
},
methods: {
rowClass (row) {
return this.recentClass(row.timeStamp)
},
sortFrequency (a, b, isAsc) {
let fa = parseFloat(a.frequency)
let fb = parseFloat(b.frequency)
if (fa < fb) {
return (isAsc ? -1 : 1)
} else if (fa === fb) {
return 0
} else {
return (isAsc ? 1 : -1)
}
},
canEditSpot (spot) {
if (!this.myCallsign) {
return false
}
return (spot.callsign === this.myCallsign)
},
addSpot () {
this.spotToEdit = null
this.isEditSpotActive = true
},
editSpot (spot) {
this.spotToEdit = spot
this.isEditSpotActive = true
},
cloneSpot (spot) {
let newSpot = Object.assign({}, spot)
delete newSpot.id
delete newSpot.frequency
this.spotToEdit = newSpot
this.isEditSpotActive = true
},
deleteSpot (spot) {
this.$buefy.dialog.confirm({
message: 'Are you sure you want to delete this spot?',
confirmText: 'Delete',
type: 'is-danger',
onConfirm: () => {
this.deleteSotaWatchSpot(spot.id)
.then(response => {
this.$store.commit('deleteSpot', spot)
})
}
})
}
},
data () {
return {
perPage: 15,
perPageOptions: [10, 15, 20, 30, 50, 100],
isEditSpotActive: false,
spotToEdit: null
}
}
}
</script>
<style scoped>
tr .timestamp {
border-left: 3px solid #e0e0e0;
}
tr.recent1 .timestamp {
border-left: 3px solid #f28591;
}
tr.recent2 .timestamp {
border-left: 3px solid #fbaf63;
}
@media (min-width: 769px) {
.table .comments-tooltip {
font-size: 0.8rem;
}
.table .comments-tooltip div {
padding-top: 0.15em;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 10em;
}
}
@media (min-width: 1408px) {
.table .comments-tooltip div {
overflow: visible;
white-space: normal;
text-overflow: ellipsis;
max-width: none;
}
}
.card {
border-left: 3px solid #e0e0e0;
}
.card.recent1 {
border-left: 3px solid #f28591;
}
.card.recent2 {
border-left: 3px solid #fbaf63;
}
.flag {
margin-right: 0.4em;
}
.comments-cell {
display: flex;
}
.actions {
margin-left: auto;
}
.card .actions {
float: right;
clear: right;
}
.actions .button {
margin-left: 1em;
}
.card .actions {
margin-top: 0.5em;
}
>>> .dropdown-item .icon {
vertical-align: middle;
}
.dropdown-item .dropdown-label {
margin-left: 0.5em;
}
</style>

Wyświetl plik

@ -0,0 +1,226 @@
<template>
<section class="section" v-if="activations !== null && activations.length > 0">
<div class="container">
<div class="columns is-5 is-variable">
<div class="column is-half">
<template v-if="myActivations && myActivations.length > 0">
<h4 class="title is-4">
My activations
</h4>
<LoggedActivationsList :data="myActivations" />
</template>
<template v-if="myChases && myChases.length > 0">
<h4 class="title is-4">
My chases
</h4>
<ChasesList :data="myChases" />
</template>
<div class="level">
<div class="level-left">
<h4 class="title is-4">
Logged activations
</h4>
</div>
<div class="level-right">
<b-field>
<FilterInput v-model="filter" size="is-small" :is-regex="true" />
</b-field>
</div>
</div>
<LoggedActivationsList :data="filteredActivations" />
</div>
<div class="column stats">
<h4 class="title is-4">
QSOs per band
</h4>
<BarChart v-if="bands" :data="bands" labelField="band" valueField="qsos" name="QSOs" />
<h4 class="title is-4">
Activations per year
</h4>
<BarChart v-if="activationsPerYear" :data="activationsPerYear" labelField="year" valueField="activations" :xIsSeries="true" name="Activations" />
<template v-if="activations.length >= 20">
<h4 class="title is-4">
Activations per month
</h4>
<BarChart v-if="activationsPerMonth" :data="activationsPerMonth" labelField="month" valueField="activations" :xIsSeries="true" name="Activations" />
</template>
</div>
<div class="column" v-if="enableModes">
<h4 class="title is-4">
Modes
</h4>
<b-table :default-sort="['qsos', 'desc']" :narrowed="true" :striped="true" :data="modes" :mobile-cards="false">
<template slot-scope="props">
<b-table-column field="mode" label="Mode" sortable>
{{ props.row.mode.toUpperCase() }}
</b-table-column>
<b-table-column field="qsos" label="QSOs" sortable numeric>
{{ props.row.qsos }}
</b-table-column>
</template>
</b-table>
</div>
</div>
</div>
</section>
</template>
<script>
import axios from 'axios'
import utils from '../mixins/utils.js'
import FilterInput from '../components/FilterInput.vue'
import BarChart from '../components/BarChart.vue'
import LoggedActivationsList from '../components/LoggedActivationsList.vue'
import ChasesList from '../components/ChasesList.vue'
export default {
name: 'Activations',
components: { FilterInput, BarChart, LoggedActivationsList, ChasesList },
props: {
summitCode: String,
activations: Array,
myActivations: Array,
myChases: Array
},
mixins: [utils],
mounted () {
this.loadBandstats()
},
watch: {
summitCode () {
this.loadBandstats()
}
},
methods: {
loadBandstats () {
axios.get('https://api2.sota.org.uk/api/bandstats/' + this.summitCode)
.then(response => {
this.bandstats = response.data
})
}
},
computed: {
filteredActivations () {
if (!this.filter) {
return this.activations
}
try {
let regex = new RegExp(this.filter, 'i')
return this.activations.filter(activation => {
return regex.test(activation.ownCallsign)
})
} catch (e) {
return []
}
},
bands () {
return this.bandstats.map(bandstat => {
return { band: bandstat.wavelength, qsos: bandstat.qsos }
})
},
modes () {
let modeStats = {}
this.bandstats.forEach(bandstat => {
Object.keys(bandstat).forEach(key => {
if (!key.match(/^(ssb|cw|fm|data|am|dv|other)$/)) {
return
}
if (!modeStats[key]) {
modeStats[key] = 0
}
modeStats[key] += bandstat[key]
})
})
let modeStatsArr = []
Object.keys(modeStats).sort().forEach(mode => {
modeStatsArr.push({
mode, qsos: modeStats[mode]
})
})
return modeStatsArr
},
activationsPerYear () {
if (!this.activations) {
return null
}
let years = {}
let firstYear, lastYear
this.activations.forEach(activation => {
let year = activation.activationDate.substring(0, 4)
if (!years[year]) {
years[year] = 0
}
years[year]++
if (!firstYear || year < firstYear) {
firstYear = year
}
if (!lastYear || year > lastYear) {
lastYear = year
}
})
let yearsArr = []
for (let year = firstYear; year <= lastYear; year++) {
yearsArr.push({
year, activations: years[year] || 0
})
}
return yearsArr
},
activationsPerMonth () {
if (!this.activations) {
return null
}
let months = []
this.activations.forEach(activation => {
let month = parseInt(activation.activationDate.substring(5, 7))
if (!months[month]) {
months[month] = 0
}
months[month]++
})
let monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
let monthsArr = []
for (let i = 1; i <= 12; i++) {
monthsArr.push({
month: monthNames[i - 1],
activations: months[i] || 0
})
}
return monthsArr
}
},
data () {
return {
bandstats: [],
filter: '',
enableModes: false // currently no data from SOTA API
}
}
}
</script>
<style scoped>
.filter {
width: 10em
}
.stats >>> .chart-container {
margin-bottom: 1em
}
</style>

Wyświetl plik

@ -0,0 +1,150 @@
<template>
<div v-if="attributes && Object.keys(attributes).length > 0" class="attribute-list">
<ul>
<li v-for="item in summitAttributes" :key="item.attribute"><div><svgicon :icon="item.icon" color="#555" />{{ item.text }}</div></li>
<li v-if="attributes['mobileSignal'] !== undefined"><div><svgicon :icon="this.signalList[attributes['mobileSignal']].icon" :color="attributes['mobileSignal'] > 0 ? '#555' : '#aaa'" />{{ this.signalList[attributes['mobileSignal']].text }}</div></li>
</ul>
<div class="pole-support" v-if="attributes.poleSupport && attributes.poleSupport.length > 0">
Pole support:
<b-tooltip v-for="(val, key) in poleSupport" :key="key" :label="val.text" type="is-info" position="is-bottom"><svgicon :icon="val.icon" color="#555" /></b-tooltip>
</div>
</div>
</template>
<script>
import '../compiled-icons'
export default {
name: 'SummitAttributes',
props: ['attributes'],
computed: {
summitAttributes () {
return this.attributeList.filter((attribute) => this.attributes[attribute.attribute] === true)
},
poleSupport () {
return this.attributes.poleSupport.map(el => this.poleSupportList[el])
}
},
data () {
return {
attributeList: [
{
attribute: 'driveUp',
icon: 'icons8-car',
text: 'Drive up'
},
{
attribute: 'cableCar',
icon: 'icons8-cable-car',
text: 'Cable car/Funicular available'
},
{
attribute: 'seating',
icon: 'icons8-bench-filled',
text: 'Seating/Bench'
},
{
attribute: 'summitCross',
icon: 'icons8-cross',
text: 'Summit cross'
},
{
attribute: 'summitBook',
icon: 'icons8-open-book',
text: 'Summit book'
},
{
attribute: 'shelter',
icon: 'icons8-home',
text: 'Shelter'
},
{
attribute: 'views',
icon: 'icons8-binoculars',
text: 'Views'
}
],
poleSupportList: {
summitCross: {
icon: 'icons8-cross',
text: 'Summit cross'
},
bench: {
icon: 'icons8-bench-filled',
text: 'Bench'
},
signpost: {
icon: 'icons8-signpost',
text: 'Signpost'
},
cairn: {
icon: 'icons8-rock',
text: 'Cairn'
},
fencePost: {
icon: 'icons8-fence',
text: 'Fence post'
}
},
signalList: [
{
icon: 'icons8-signal0',
text: 'No signal'
},
{
icon: 'icons8-signal1',
text: 'Weak signal'
},
{
icon: 'icons8-signal2',
text: 'Average signal'
},
{
icon: 'icons8-signal3',
text: 'Good signal'
}
]
}
}
}
</script>
<style scoped>
.attribute-list {
margin: 0.5em 0;
background: #f7f7f7;
border-radius: 7px;
padding: 0.4em 0.5em;
margin-left: -0.5em;
display: inline-block;
}
.attribute-list ul li {
margin: 0.2em 1em 0.2em 0;
display: inline;
}
.attribute-list ul li:last-child {
margin-right: 0;
}
.attribute-list ul li div {
display: inline-block;
}
.svg-icon {
vertical-align: bottom;
margin-right: 0.4em;
width: 1.5em;
height: 1.5em;
}
.pole-support {
margin-left: 0.2em;
margin-top: 0.5em;
border-top: 1px solid #ccc;
padding-top: 0.5em;
}
.pole-support .svg-icon {
margin-left: 0.3em;
margin-right: 0;
}
.pole-support .b-tooltip {
display: inline;
}
</style>

Wyświetl plik

@ -0,0 +1,54 @@
<template>
<div>
<section class="hero is-light">
<div class="hero-body">
<div class="container">
<div class="level">
<div class="level-left">
<slot name="title"></slot>
</div>
<div class="level-right">
<Breadcrumb :association="association" :region="region" :summit="summit" />
</div>
</div>
</div>
<div class="container">
<slot name="subtitle"></slot>
</div>
</div>
</section>
<slot></slot>
<Footer />
</div>
</template>
<script>
import Breadcrumb from '../components/Breadcrumb.vue'
import Footer from '../components/Footer.vue'
export default {
name: 'SummitDatabasePageLayout',
components: {
Breadcrumb, Footer
},
props: {
association: Object,
region: Object,
summit: Object,
query: String
}
}
</script>
<style scoped>
.level {
align-items: start;
}
@media (max-width: 768px) {
.level-left + .level-right {
margin-top: 0;
}
}
</style>

Wyświetl plik

@ -0,0 +1,184 @@
<template>
<b-table :class="{ 'auto-width': autoWidth, summits: true }" default-sort="code" :narrowed="true" :striped="true" :data="data" :mobile-cards="false" :row-class="(row, index) => !row.isValid && 'is-invalid'">
<template slot-scope="props">
<b-table-column field="code" label="Code" class="nowrap" sortable>
<router-link :to="makeSummitLink(props.row.code)">{{ props.row.code }}</router-link>
</b-table-column>
<b-table-column field="name" label="Name" class="summit-name" sortable>
<router-link :to="makeSummitLink(props.row.code)">{{ props.row.name }}</router-link>
<font-awesome-icon v-if="props.row.hasPhotos" class="photos-icon" :icon="['far', 'images']" />
</b-table-column>
<b-table-column field="altitude" :label="$mq.mobile ? 'Alt.' : 'Altitude'" class="nowrap" sortable numeric>
<AltitudeLabel :altitude="props.row.altitude" />
</b-table-column>
<b-table-column field="points" :label="$mq.mobile ? 'Pts.' : 'Points'" class="nowrap" sortable>
<SummitPointsLabel :points="props.row.points" :bonus="$mq.mobile ? null : props.row.bonusPoints" class="points" />
</b-table-column>
<b-table-column field="activationCount" :label="$mq.mobile ? 'Act.' : 'Activations'" class="nowrap" sortable numeric>
{{ props.row.activationCount }}
<font-awesome-icon v-if="myActivatedSummits" :icon="activationIcon(props.row)" :class="activationIconClass(props.row)" :label="activationIconLabel(props.row)" :title="activationIconLabel(props.row)" />
<font-awesome-icon v-if="myActivatedSummitsThisYear" :icon="['far', 'calendar-check']" :class="calendarIconClass(props.row)" :label="calendarIconLabel(props.row)" :title="calendarIconLabel(props.row)" />
</b-table-column>
</template>
<template v-if="myActivatedSummits" slot="footer">
<ul class="legend">
<li><font-awesome-icon :icon="['far', 'chevron-circle-down']" :class="['activation-icon', 'chased']" /> Chased</li>
<li><font-awesome-icon :icon="['far', 'chevron-circle-up']" :class="['activation-icon', 'activated']" /> Activated</li>
<li><font-awesome-icon :icon="['fas', 'check-circle']" :class="['activation-icon', 'complete']" /> Complete</li>
<li v-if="myActivatedSummitsThisYear"><font-awesome-icon :icon="['far', 'calendar-check']" :class="['calendar-icon', 'active']" /> Activated this year</li>
</ul>
</template>
</b-table>
</template>
<script>
import utils from '../mixins/utils.js'
import SummitPointsLabel from '../components/SummitPointsLabel.vue'
import AltitudeLabel from '../components/AltitudeLabel.vue'
export default {
name: 'SummitList',
props: {
data: Array,
myActivatedSummits: Set,
myActivatedSummitsThisYear: Set,
myChasedSummits: Set,
autoWidth: Boolean
},
mixins: [utils],
components: {
SummitPointsLabel, AltitudeLabel
},
methods: {
activationIcon (row) {
if (this.isComplete(row.code)) {
return ['fas', 'check-circle']
} else if (this.isActivated(row.code)) {
return ['far', 'chevron-circle-up']
} else if (this.isChased(row.code)) {
return ['far', 'chevron-circle-down']
} else {
return ['far', 'check-circle']
}
},
activationIconClass (row) {
if (this.isComplete(row.code)) {
return ['activation-icon', 'complete']
} else if (this.isActivated(row.code)) {
return ['activation-icon', 'activated']
} else if (this.isChased(row.code)) {
return ['activation-icon', 'chased']
} else {
return ['activation-icon']
}
},
activationIconLabel (row) {
if (this.isComplete(row.code)) {
return 'completed by me'
} else if (this.isActivated(row.code)) {
return 'activated by me'
} else if (this.isChased(row.code)) {
return 'chased by me'
} else {
return 'not activated/chased by me'
}
},
calendarIconClass (row) {
if (this.isActivatedThisYear(row.code)) {
return ['calendar-icon', 'active']
} else {
return ['calendar-icon']
}
},
calendarIconLabel (row) {
if (this.isActivatedThisYear(row.code)) {
return 'activated this year'
} else {
return 'not activated this year'
}
},
isActivated (code) {
if (!this.myActivatedSummits) {
return false
}
return this.myActivatedSummits.has(code)
},
isActivatedThisYear (code) {
if (!this.myActivatedSummitsThisYear) {
return null
}
return this.myActivatedSummitsThisYear.has(code)
},
isChased (code) {
if (!this.myChasedSummits) {
return false
}
return this.myChasedSummits.has(code)
},
isComplete (code) {
if (!this.myActivatedSummits || !this.myChasedSummits) {
return false
}
return this.myActivatedSummits.has(code) && this.myChasedSummits.has(code)
}
}
}
</script>
<style scoped>
.summits >>> .is-invalid {
opacity: 0.5;
}
@media (max-width: 414px) {
.table .summit-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
max-width: 10em;
}
}
@media (max-width: 375px) {
.table td, .table th {
padding: 0.25em 0.3em;
}
.table .summit-name {
max-width: 8em;
}
}
.activation-icon {
color: #e7e7e7;
margin-left: 0.1em;
}
.activation-icon.activated, .activation-icon.complete {
color: #406caf;
}
.activation-icon.chased {
color: #2eaa9b;
}
.calendar-icon {
color: #e7e7e7;
margin-left: 0.3em;
}
.calendar-icon.active {
color: #444;
}
.legend {
font-weight: normal;
list-style-type: none;
margin-top: 0.5em;
text-align: right;
}
.legend .calendar-icon, .legend .activation-icon {
margin-left: 0;
margin-right: 0.1em;
}
.legend li {
display: inline-block;
margin-left: 0.75em;
}
.photos-icon {
margin-left: 0.5em;
color: #777;
}
</style>

Wyświetl plik

@ -0,0 +1,142 @@
<template>
<div>
<div v-for="group in groups" :key="group.key">
<SummitPhotosGroup ref="photosGroup" :photos="group.photos" :title="group.title" :titleLink="group.titleLink" @editPhoto="onEditPhoto" @deletePhoto="onDeletePhoto" @reorderPhotos="onReorderPhotos" />
</div>
<b-modal :active.sync="isEditorActive" has-modal-card trap-focus aria-role="dialog" aria-modal>
<EditPhoto v-if="editingPhoto" :photo="editingPhoto" :summitCode="summit.code" @photoEdited="$emit('photoEdited')" />
</b-modal>
</div>
</template>
<script>
import SummitPhotosGroup from './SummitPhotosGroup.vue'
import EditPhoto from './EditPhoto.vue'
import utils from '../mixins/utils.js'
import api from '../mixins/api.js'
import moment from 'moment'
export default {
name: 'SummitPhotos',
props: {
summit: Object
},
components: {
SummitPhotosGroup, EditPhoto
},
mixins: [utils, api],
computed: {
groups () {
if (!this.summit || !this.summit.photos) {
return []
}
// Group photos by author
let authorGroups = new Map()
this.summit.photos.forEach(photo => {
let authorGroup = authorGroups.get(photo.author)
if (!authorGroup) {
authorGroup = {
author: photo.author.toUpperCase(),
title: photo.author.toUpperCase(),
titleLink: '/activators/' + photo.author.toUpperCase(),
photos: []
}
authorGroups.set(photo.author, authorGroup)
}
authorGroup.photos.push(photo)
})
// Sort photos within group by sort order, then by photo date, then by upload date
authorGroups.forEach((authorGroup, author) => {
authorGroup.photos.sort((a, b) => {
// No sort order = place at end
let sa = a.sortOrder || 1000000
let sb = b.sortOrder || 1000000
if (sa < sb) {
return -1
} else if (sa > sb) {
return 1
} else {
if (!a.date && !b.date) {
return moment(a.uploadDate).diff(b.uploadDate)
} else if (a.date && !b.date) {
return 1
} else if (!a.date && b.date) {
return -1
} else {
let ma = moment(a.date)
let mb = moment(b.date)
if (ma.isBefore(mb)) {
return -1
} else if (mb.isBefore(ma)) {
return 1
} else {
return 0
}
}
}
})
})
// Sort groups by latest uploaded photo
return [...authorGroups.values()].sort((a, b) => {
let reduceFunc = (prev, current) => {
return (moment(prev.uploadDate).isAfter(current.uploadDate)) ? prev : current
}
let dateA = a.photos.reduce(reduceFunc).uploadDate
let dateB = b.photos.reduce(reduceFunc).uploadDate
if (!dateA && !dateB) {
return 0
} else if (dateA && !dateB) {
return -1
} else if (dateB && !dateA) {
return 1
} else {
return (moment(dateB).diff(dateA))
}
})
}
},
methods: {
openPhoto (photo) {
this.groups.forEach((group, index) => {
if (group.photos.some(curPhoto => curPhoto === photo)) {
this.$refs.photosGroup[index].openPhoto(photo)
}
})
},
onEditPhoto (photo) {
this.editingPhoto = this.summit.photos.find(curPhoto => curPhoto.filename === photo.filename)
this.isEditorActive = true
},
onDeletePhoto (photo) {
this.$buefy.dialog.confirm({
message: 'Are you sure you want to delete this photo?',
confirmText: 'Delete',
type: 'is-danger',
onConfirm: () => {
this.deletePhoto(this.summit.code, photo.filename)
.then(() => {
this.$emit('photoDeleted')
})
}
})
},
onReorderPhotos (filenames) {
this.reorderPhotos(this.summit.code, filenames)
.then(() => {
this.$emit('photosReordered')
})
}
},
data () {
return {
isEditorActive: false,
editingPhoto: null
}
}
}
</script>

Wyświetl plik

@ -0,0 +1,170 @@
<template>
<div class="photo-group">
<div class="photo-group-title">
<router-link v-if="titleLink" :to="titleLink">{{ title }}</router-link>
<span v-else>{{ title }}</span>
</div>
<PictureSwipe ref="pictureSwipe" class="photos" :items="swipeItems" :options="swipeOptions" @mouseoverPicture="mouseoverPicture" @mouseleavePicture="mouseleavePicture" @editPicture="onEditPicture" @deletePicture="onDeletePicture" @movePicture="onMovePicture" />
</div>
</template>
<script>
import PictureSwipe from './PictureSwipe.vue'
import utils from '../mixins/utils.js'
import photos from '../mixins/photos.js'
import moment from 'moment'
export default {
name: 'SummitPhotosGroup',
props: {
photos: Array,
title: String,
titleLink: String
},
components: {
PictureSwipe
},
mixins: [utils, photos],
computed: {
swipeItems () {
let largeSize = 1600
return this.photos.map(photo => {
let largeW = photo.width
let largeH = photo.height
if (largeW > largeSize) {
largeH = Math.round(largeH * largeSize / largeW)
largeW = largeSize
}
if (largeH > largeSize) {
largeW = Math.round(largeW * largeSize / largeH)
largeH = largeSize
}
return {
src: this.photoSrc(photo, 'large'),
msrc: this.photoSrc(photo, 'thumb'),
osrc: this.photoSrc(photo, 'original'),
w: largeW,
h: largeH,
title: this.makeTitleHtml(photo),
thumbTitle: photo.title,
editable: (this.$keycloak && this.$keycloak.authenticated && this.$keycloak.tokenParsed.callsign === photo.author),
filename: photo.filename
}
})
},
swipeOptions () {
return {
bgOpacity: 0.85,
loop: false,
history: false,
closeOnScroll: false
}
}
},
methods: {
makeTitleHtml (photo) {
let html = '<div class="photo-title">' + this.escapeHtml(photo.title).replace('\n', '<br />')
let authorLine = ''
if (photo.author) {
authorLine = this.escapeHtml(photo.author.toUpperCase())
}
if (photo.date) {
authorLine += ' on ' + moment.utc(photo.date).format('DD MMM YYYY HH:mm:ss')
}
if (photo.camera) {
authorLine += ', ' + this.escapeHtml(photo.camera)
}
if (photo.coordinates) {
authorLine += ', Coordinates: <a href="/map/coordinates/' + photo.coordinates.latitude.toFixed(5) + ',' + photo.coordinates.longitude.toFixed(5) + '/16.0?popup=1">' + photo.coordinates.latitude.toFixed(5) + ', ' + photo.coordinates.longitude.toFixed(5) + '</a>'
if (photo.positioningError) {
if (photo.positioningError >= 1000) {
authorLine += ' (± ' + (photo.positioningError / 1000).toFixed(1) + ' km)'
} else {
authorLine += ' (± ' + photo.positioningError + ' m)'
}
}
}
if (authorLine) {
html += '<div class="author">' + authorLine + '</div>'
}
html += '</div>'
return html
},
openPhoto (photo) {
let photoIndex = this.photos.findIndex(curPhoto => curPhoto.filename === photo.filename)
if (photoIndex !== -1) {
this.$refs.pictureSwipe.open(photoIndex, true)
}
},
mouseoverPicture (picture) {
this.photos.forEach(photo => {
if (photo.filename === picture.filename && !photo.highlight) {
this.$set(photo, 'highlight', true)
} else if (photo.highlight) {
this.$set(photo, 'highlight', false)
}
})
},
mouseleavePicture (picture) {
this.$set(this.photos.find(photo => photo.filename === picture.filename), 'highlight', false)
},
onEditPicture (picture) {
this.$emit('editPhoto', picture)
},
onDeletePicture (picture) {
this.$emit('deletePhoto', picture)
},
onMovePicture (newIndex, oldIndex, picture) {
// Make new array of filenames in desired order
let filenames = this.photos.map(photo => photo.filename)
filenames.splice(newIndex, 0, filenames.splice(oldIndex, 1)[0])
this.$emit('reorderPhotos', filenames)
}
}
}
</script>
<style scoped>
.photos >>> figure {
margin: 0 0.75rem 0.75rem 0;
}
.photos >>> figure img {
max-height: 128px;
max-width: 256px;
}
@media (max-width: 768px) {
.photos >>> figure {
margin: 0 0.5rem 0.5rem 0;
}
.photos >>> figure img {
max-height: 104px;
}
}
>>> .photo-title {
font-size: 1rem;
}
>>> .photo-title .author {
font-size: 0.8rem;
margin-top: 0.2em;
}
.photo-group {
background: whitesmoke;
padding: 0.25rem 0 0 0.75rem;
display: inline-block;
margin-bottom: 0.75rem;
}
.photo-group-title {
color: #777;
font-size: 0.8em;
font-weight: bold;
margin-bottom: 0.2rem;
margin-right: 0.75rem;
}
.photo-group-title a {
color: #777;
}
.photo-group-title a:hover {
color: #3273dc;
}
</style>

Wyświetl plik

@ -0,0 +1,82 @@
<template>
<b-taglist v-if="points !== null && points !== undefined" :class="{ ['points-' + points]: true }" attached>
<b-tag>{{ points }}</b-tag>
<b-tag class="bonus" v-if="bonus">+{{ bonus }}</b-tag>
</b-taglist>
</template>
<script>
export default {
name: 'SummitPointsLabel',
props: {
points: {
type: Number
},
bonus: {
type: Number
}
}
}
</script>
<style scoped>
.tags {
display: inline-block;
margin-bottom: 0 !important;
}
.tag {
min-width: 2.2em;
padding: 0.4em;
color: #fff;
line-height: 1em;
height: auto;
margin-bottom: 0 !important;
}
@media (max-width: 768px) {
.tag {
padding: 0.3em 0.4em;
}
}
.points-0 .tag {
background-color: rgba(160, 160, 160, 1);
}
.points-1 .tag {
background-color: rgba(77, 122, 32, 1);
}
.points-2 .tag {
background-color: rgba(109, 165, 54, 1);
}
.points-4 .tag {
background-color: rgba(174, 167, 39, 1);
}
.points-6 .tag {
background-color: rgba(239, 168, 24, 1);
}
.points-8 .tag {
background-color: rgba(220, 93, 4, 1);
}
.points-10 .tag {
background-color: rgba(200, 16, 30, 1);
}
.points-1 .tag.bonus {
background-color: rgba(51, 81, 21, 1);
}
.points-2 .tag.bonus {
background-color: rgba(77, 116, 37, 1);
}
.points-4 .tag.bonus {
background-color: rgba(125, 120, 28, 1);
}
.points-6 .tag.bonus {
background-color: rgba(167, 115, 12, 1);
}
.points-8 .tag.bonus {
background-color: rgba(150, 64, 3, 1);
}
.points-10 .tag.bonus {
background-color: rgba(118, 10, 19, 1);
}
.tag.bonus {
min-width: 0;
}
</style>

Wyświetl plik

@ -0,0 +1,142 @@
<template>
<MglPopup v-if="summit" key="summitinfo" :coordinates="[summit.coordinates.longitude, summit.coordinates.latitude]" :showed="true" anchor="bottom" :closeButton="false" @close="$emit('close')">
<div :class="{ summitPopup: true, minimize: minimizePopup }">
<div v-if="coverPhoto" class="photo">
<div style="text-align: center"><a :href="coverPhoto.mediaLink" target="_blank"><img :src="coverPhoto.src" /></a></div>
<div v-if="coverPhoto.description" class="description">{{ coverPhoto.description }}</div>
<div v-if="coverPhoto.attribution" class="attribution" v-html="coverPhoto.attribution"></div>
</div>
<h2>{{ summit.name }}<span class="summitCode"><router-link :to="'/summits/' + summit.code">{{ summit.code }}</router-link></span></h2>
<table class="summitinfo">
<tr><th>Altitude</th><td><AltitudeLabel :altitude="summit.altitude" /></td></tr>
<tr class="points"><th>Points</th><td><SummitPointsLabel :points="summit.points" :bonus="summit.bonusPoints" /></td></tr>
<tr><th>Activations</th><td>{{ summit.activationCount }}</td></tr>
<tr v-if="summit.activationDate"><th>Last activation</th><td>{{ summit.activationDate | formatActivationDate }} (<router-link :to="makeActivatorLink(summit.activationCall)">{{ summit.activationCall }}</router-link>)</td></tr>
<tr v-if="lastSpot"><th>Last spot</th><td><span v-html="formatTimeDay(lastSpot.timeStamp)" />: <router-link :to="makeActivatorLink(lastSpot.activatorCallsign)">{{ lastSpot.activatorCallsign }}</router-link>, {{ lastSpot.frequency }} <ModeLabel :mode="lastSpot.mode" /></td></tr>
</table>
<div class="buttons">
<b-button v-if="!minimizePopup" size="is-small" icon-left="window-close" @click="$emit('close')">Close</b-button>
<b-button v-if="!minimizePopup" size="is-small" icon-left="window-minimize" @click="minimizePopup = true">Minimize</b-button>
<b-button v-if="minimizePopup" size="is-small" icon-left="window-maximize" @click="minimizePopup = false"></b-button>
<router-link v-if="!minimizePopup" class="button more is-info is-small" :to="'/summits/' + summit.code"><font-awesome-icon class="fa-icon" :icon="['far', 'expand-arrows']" style="margin-right: 0.5em" /> More</router-link>
</div>
</div>
</MglPopup>
</template>
<script>
import { MglPopup } from 'vue-mapbox'
import ModeLabel from '../components/ModeLabel.vue'
import AltitudeLabel from '../components/AltitudeLabel.vue'
import SummitPointsLabel from '../components/SummitPointsLabel.vue'
import utils from '../mixins/utils.js'
import coverphoto from '../mixins/coverphoto.js'
export default {
name: 'SummitPopup',
props: {
summit: Object,
lastSpot: Object
},
mixins: [utils, coverphoto],
components: {
MglPopup, ModeLabel, AltitudeLabel, SummitPointsLabel
},
data () {
return {
minimizePopup: false
}
}
}
</script>
<style scoped>
.summitPopup {
padding: 0.3rem;
}
.summitPopup h2 {
margin: 0.2em 0 0.5em 0;
font-size: 16pt;
white-space: nowrap;
font-weight: normal;
}
.summitPopup .summitCode {
color: #777;
font-size: 10pt;
padding-left: 0.7em;
}
.summitPopup table {
font-size: 10pt;
width: 100%;
}
.summitPopup .buttons {
margin-top: 0.5em;
float: right;
}
.summitPopup.minimize .buttons {
margin-top: 0;
display: inline;
margin-left: 1em;
}
.summitPopup .points th {
vertical-align: middle;
}
.summitPopup.minimize .photo, .summitPopup.minimize .summitinfo {
display: none;
}
.summitPopup.minimize h2 {
font-size: 11pt;
display: inline;
vertical-align: middle;
}
.summitPopup.minimize .summitCode {
display: none;
}
.photo {
width: 300px;
padding-bottom: 0.5em;
border-bottom: 1px solid #ccc;
margin-bottom: 1em;
}
.photo img {
border: 1px solid #aaa;
vertical-align: top;
text-align: center;
}
.photo .description {
font-size: 9pt;
line-height: 1.4;
color: #777;
margin-top: 0.5em;
}
.photo a {
color: #3f5da7;
}
.photo .attribution {
font-size: 8pt;
line-height: 1.4;
font-style: italic;
color: #777;
text-align: right;
}
.summitinfo {
margin: 0 !important;
border-bottom: none !important;
}
.summitinfo th {
padding-right: 1em;
text-align: left;
color: #444;
}
.summitinfo th, .summitinfo td {
border-top: 1px solid #e0e0e0;
padding-top: 0.3em !important;
padding-bottom: 0.3em !important;
white-space: nowrap;
text-transform: none;
line-height: normal !important;
}
.tag {
padding: 0.2em 0.3em;
}
</style>

Wyświetl plik

@ -0,0 +1,152 @@
<template>
<b-table ref="routesTable" :data="routes" :narrowed="true" :striped="true" detailed @details-open="detailsOpen" @details-close="detailsClose">
<template slot-scope="props">
<b-table-column field="title" label="Title" :sortable="routes.length > 1">
<span><a @click="toggle(props.row)"><strong>{{ props.row.title }}</strong></a> <RouteAttributes class="route-attributes" :route="props.row" :summit="summit" @mapReposition="coordinates => $emit('mapReposition', coordinates)" /></span>
</b-table-column>
<b-table-column field="difficulty" label="Difficulty" :sortable="routes.length > 1">
{{ renderDifficulty(props.row) }}
</b-table-column>
<b-table-column field="ascent" label="Ascent" numeric :sortable="routes.length > 1">
<span><AltitudeLabel v-if="props.row.ascent" :altitude="props.row.ascent" /> <span v-if="props.row.ascentExcludesCounterAscents" class="star"> (*)</span></span>
</b-table-column>
<b-table-column field="distance" label="Distance" numeric :sortable="routes.length > 1">
<DistanceLabel v-if="props.row.distance" :distance="props.row.distance" />
</b-table-column>
<b-table-column field="duration" label="Duration" numeric :sortable="routes.length > 1">
{{ props.row.duration | formatDuration }}
</b-table-column>
</template>
<template slot="detail" slot-scope="props">
<div v-if="(props.row.parking && props.row.parking.description) || (props.row.publicTransport && props.row.publicTransport.description)" class="add-description-wrapper">
<div v-if="props.row.parking && props.row.parking.description" class="add-description">
<font-awesome-icon class="fa-icon clickable" :icon="['fas', 'parking']" @click="$emit('mapReposition', props.row.parking.coordinates)" />
{{ props.row.parking.description }}
</div>
<div v-if="props.row.publicTransport && props.row.publicTransport.description" class="add-description">
<font-awesome-layers class="fa-icon clickable" @click="$emit('mapReposition', props.row.publicTransport.coordinates)">
<font-awesome-icon class="fa-subicon" icon="square" />
<font-awesome-icon class="fa-subicon" :icon="['fas', 'bus']" transform="shrink-6" :style="{ color: 'white' }" />
</font-awesome-layers>
{{ props.row.publicTransport.description }}
</div>
</div>
<article class="routeDescr" v-html="linkifyCoordinates(props.row.description)" />
<div class="author">by {{ props.row.author }}</div>
<div class="track-download" v-if="props.row.track">
<TrackLink :route="props.row" :summit="summit"><font-awesome-icon :icon="['far', 'file-download']" class="fa-icon" /> Download track (.gpx)</TrackLink>
</div>
</template>
<template v-if="anyCounterAscentExcludes" slot="footer">
(*) Difference between highest and lowest elevation, excluding counter-ascents
</template>
</b-table>
</template>
<script>
import RouteAttributes from './RouteAttributes.vue'
import AltitudeLabel from './AltitudeLabel.vue'
import DistanceLabel from './DistanceLabel.vue'
import TrackLink from './TrackLink.vue'
import utils from '../mixins/utils.js'
export default {
name: 'SummitRoutes',
props: {
summit: Object,
routes: Array
},
components: {
RouteAttributes, AltitudeLabel, DistanceLabel, TrackLink
},
mixins: [utils],
computed: {
anyCounterAscentExcludes () {
return this.routes.some(el => el.ascentExcludesCounterAscents)
}
},
methods: {
toggle (row) {
this.$refs.routesTable.toggleDetails(row)
},
detailsOpen (row) {
this.$emit('detailsOpen', row)
},
detailsClose (row) {
this.$emit('detailsClose', row)
},
renderDifficulty (row) {
let fields = ['hikingDifficulty', 'snowshoeDifficulty', 'alpineDifficulty', 'skiDifficulty', 'climbingDifficulty']
let difficulties = []
fields.forEach(field => {
if (row[field]) {
difficulties.push(row[field])
}
})
return difficulties.join(' ')
},
linkifyCoordinates (description) {
return description.replace(/(?:^|\s)([-+]?[1-8]?\d\.\d+),\s*([-+]?(?:(?:1[0-7]\d)|(?:[1-9]?\d))\.\d+)\b/g, '<a href="/map/coordinates/$1,$2/16.0?popup=1">$&</a>')
}
}
}
</script>
<style scoped>
>>> .routeDescr p, >>> .routeDescr ul {
margin-bottom: 1em;
}
>>> .routeDescr ul {
list-style-type: disc;
margin-left: 1.5em;
}
>>> .table-wrapper {
overflow-x: initial;
}
.route-attributes {
display: inline-block;
vertical-align: middle;
margin-left: 0.5em;
}
.add-description-wrapper {
margin-bottom: 1em;
border-bottom: 1px solid #eee;
padding-bottom: 0.5em;
opacity: 0.75;
}
.add-description {
margin-bottom: 0.3em;
font-style: italic;
}
.add-description .fa-icon {
width: 1.25em;
height: 1.25em;
vertical-align: text-bottom;
margin-right: 0.3em;
}
.add-description .fa-subicon {
width: 1.25em;
height: 1.25em;
}
.track-download .fa-icon {
margin-right: 0.3em;
}
.author {
float: right;
color: #7a7a7a;
font-size: 95%;
font-style: italic;
}
>>> .table-footer th {
font-weight: normal;
font-size: 0.9em;
padding-top: 0.5em;
color: #7a7a7a;
text-align: right;
}
.star {
color: #7a7a7a;
}
</style>

Wyświetl plik

@ -0,0 +1,70 @@
<template>
<div>
<div v-for="group in groups" :key="group.key" class="video-group-wrapper">
<SummitVideosGroup :videos="group.videos" :title="group.title" :titleLink="group.titleLink" />
</div>
</div>
</template>
<script>
import SummitVideosGroup from './SummitVideosGroup.vue'
import moment from 'moment'
export default {
name: 'SummitVideos',
props: {
videos: Array
},
components: {
SummitVideosGroup
},
computed: {
groups () {
if (!this.videos) {
return []
}
// Group videos by author
let authorGroups = new Map()
this.videos.forEach(video => {
let authorGroup = authorGroups.get(video.author)
if (!authorGroup) {
authorGroup = {
author: video.author.toUpperCase(),
title: video.author.toUpperCase(),
titleLink: '/activators/' + video.author.toUpperCase(),
videos: []
}
authorGroups.set(video.author, authorGroup)
}
authorGroup.videos.push(video)
})
// Sort groups by latest uploaded video
return [...authorGroups.values()].sort((a, b) => {
let reduceFunc = (prev, current) => {
return (moment(prev.date).isAfter(current.date)) ? prev : current
}
let dateA = a.videos.reduce(reduceFunc).date
let dateB = b.videos.reduce(reduceFunc).date
if (!dateA && !dateB) {
return 0
} else if (dateA && !dateB) {
return -1
} else if (dateB && !dateA) {
return 1
} else {
return (moment(dateB).diff(dateA))
}
})
}
}
}
</script>
<style scoped>
.video-group-wrapper:not(:last-child) {
margin-bottom: 0.75rem;
}
</style>

Wyświetl plik

@ -0,0 +1,66 @@
<template>
<div class="video-group">
<div class="video-group-title">
<router-link v-if="titleLink" :to="titleLink">{{ title }}</router-link>
<span v-else>{{ title }}</span>
</div>
<LazyYoutubeVideo v-for="video in videos" :key="video.src" :src="video.src" />
</div>
</template>
<script>
import LazyYoutubeVideo from 'vue-lazy-youtube-video'
import 'vue-lazy-youtube-video/dist/style.css'
export default {
name: 'SummitVideosGroup',
props: {
videos: Array,
title: String,
titleLink: String
},
components: {
LazyYoutubeVideo
}
}
</script>
<style scoped>
.y-video {
margin: 0 0.75rem 0.75rem 0;
width: 30rem;
display: inline-block;
vertical-align: bottom;
}
@media (max-width: 768px) {
.y-video {
margin: 0 0.5rem 0.5rem 0;
width: 70vw;
}
}
>>> .video-title {
font-size: 1rem;
}
>>> .video-title .author {
font-size: 0.8rem;
margin-top: 0.2em;
}
.video-group {
background: whitesmoke;
padding: 0.25rem 0 0 0.75rem;
display: inline-block;
}
.video-group-title {
color: #777;
font-size: 0.8em;
font-weight: bold;
margin-bottom: 0.2rem;
margin-right: 0.75rem;
}
.video-group-title a {
color: #777;
}
.video-group-title a:hover {
color: #3273dc;
}
</style>

Wyświetl plik

@ -0,0 +1,86 @@
<template>
<a class="track-link" :href="href" :download="filename"><slot></slot></a>
</template>
<script>
import tracks from '../mixins/tracks.js'
export default {
name: 'TrackLink',
mixins: [tracks],
props: {
route: Object,
summit: Object
},
watch: {
route: {
immediate: true,
handler () {
if (this.route.track.points) {
let trkpts = this.route.track.points.map(point => {
if (point.altitude !== undefined) {
return `<trkpt lat="${point.latitude}" lon="${point.longitude}"><ele>${Math.round(point.altitude)}</ele></trkpt>`
} else {
return `<trkpt lat="${point.latitude}" lon="${point.longitude}"></trkpt>`
}
})
let gpx = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<gpx version="1.1"
creator="SOTLAS"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.topografix.com/GPX/1/1"
xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">
<metadata>
<name>Track for SOTA Summit ${this.summit.code} - ${this.summit.name}</name>
<desc>Imported from SMP</desc>
<author>
<name>${this.route.author}</name>
</author>
<link href="https://sotl.as/summits/${this.summit.code}">
<text>SOTLAS</text>
</link>
</metadata>
<trk>
<name>${this.route.title}</name>
<src>${this.route.author}</src>
<trkseg>
${trkpts.join('\n')}
</trkseg>
</trk>
</gpx>`
let blob = new Blob([gpx], { type: 'application/gpx+xml' })
if (this.objectUrl) {
URL.revokeObjectURL(this.objectUrl)
}
this.objectUrl = URL.createObjectURL(blob)
}
}
}
},
computed: {
filename () {
return this.summit.code.replace('/', '_') + '_' + this.route.id.substr(-8) + '.gpx'
},
href () {
if (this.route.track.filename) {
return this.trackUrl(this.route.track)
} else {
return this.objectUrl
}
}
},
destroyed () {
if (this.objectUrl) {
URL.revokeObjectURL(this.objectUrl)
this.objectUrl = null
}
},
data () {
return {
objectUrl: null
}
}
}
</script>

5
src/event-bus.js 100644
Wyświetl plik

@ -0,0 +1,5 @@
import Vue from 'vue'
const EventBus = new Vue()
export default EventBus

64
src/keyzipper.js 100644
Wyświetl plik

@ -0,0 +1,64 @@
const KEY_DECOMPRESSION_MAP = {
a: 'altitude',
ac: 'activatorCallsign',
ao: 'activationCount',
c: 'comments',
d: 'code',
e: 'speed',
f: 'frequency',
hc: 'homeCallsign',
i: 'isActivator',
ic: 'isoCode',
l: 'callsign',
m: 'mode',
n: 'name',
o: 'continent',
p: 'points',
s: 'summit',
t: 'spotter',
ts: 'timeStamp'
}
let KEY_COMPRESSION_MAP = null
function compressKeys (obj) {
// Lazy init
if (KEY_COMPRESSION_MAP === null) {
KEY_COMPRESSION_MAP = {}
Object.keys(KEY_DECOMPRESSION_MAP).forEach(key => {
KEY_COMPRESSION_MAP[KEY_DECOMPRESSION_MAP[key]] = key
})
}
return mapKeys(obj, KEY_COMPRESSION_MAP)
}
function decompressKeys (obj) {
return mapKeys(obj, KEY_DECOMPRESSION_MAP)
}
function mapKeys (obj, map) {
if (obj === null) {
return null
} else if (Array.isArray(obj)) {
return obj.map(el => {
return mapKeys(el, map)
})
} else if (typeof obj === 'object' && !(obj instanceof Date)) {
let ret = {}
Object.keys(obj).forEach(key => {
let val = mapKeys(obj[key], map)
if (map[key]) {
ret[map[key]] = val
} else {
ret[key] = val
}
})
return ret
} else {
return obj
}
}
export { compressKeys, decompressKeys }

110
src/main.js 100644
Wyświetl plik

@ -0,0 +1,110 @@
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import Buefy from 'buefy'
import VueSVGIcon from 'vue-svgicon'
import vueDebounce from 'vue-debounce'
import VueClipboard from 'vue-clipboard2'
import MatchMedia from 'vue-match-media/src'
import VueKeyCloak from '@dsb-norge/vue-keycloak-js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCheck, faCheckCircle, faInfoCircle, faExclamationTriangle, faExclamationCircle, faArrowUp, faPlus, faCheckDouble,
faAngleRight, faAngleLeft, faAngleDown, faEye, faEyeSlash, faCaretUp, faUpload, faLink, faHistory, faThList, faImages,
faQuoteRight, faSearch, faMountains, faUser, faClock, faChevronCircleUp, faChevronCircleDown, faChartBar, faFileDownload,
faExchange, faGlobe, faCalendarDay, faTrashAlt, faEdit, faClone, faCheckCircle as farCheckCircle, faArrowsH, faArrowsAlt,
faSnowflake, faWindowMinimize, faWindowMaximize, faWindowClose, faExpandArrows, faLocation, faCalendarCheck, faComment, faSpinner } from '@fortawesome/pro-regular-svg-icons'
import { faMap, faCheckCircle as fasCheckCircle, faChevronCircleDown as fasChevronCircleDown, faChevronCircleUp as fasChevronCircleUp,
faParking, faSquare, faBus, faHiking, faCircle, faCamera, faVolume, faVolumeMute, faCog, faCaretDown as fasCaretDown, faLocationArrow as fasLocationArrow } from '@fortawesome/pro-solid-svg-icons'
import { faWikipediaW, faGoogle } from '@fortawesome/free-brands-svg-icons'
import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome'
import '@/assets/global.css'
import store from './store'
import axios from 'axios'
import { SnackbarProgrammatic as Snackbar } from 'buefy/dist/components/snackbar'
library.add(faCheck, faCheckCircle, faInfoCircle, faExclamationTriangle, faExclamationCircle, faArrowUp, faPlus, faCheckDouble,
faAngleRight, faAngleLeft, faAngleDown, faEye, faEyeSlash, faCaretUp, faUpload, faLink, faHistory, faThList, faImages,
faQuoteRight, faSearch, faMountains, faUser, faClock, faChevronCircleUp, faChevronCircleDown, faMap, faChartBar, faFileDownload,
faExchange, faGlobe, faCalendarDay, faTrashAlt, faEdit, faClone, farCheckCircle, faArrowsH, faArrowsAlt,
faSnowflake, faWindowMinimize, faWindowMaximize, faWindowClose, faExpandArrows, faLocation, faCalendarCheck, faComment, faSpinner)
library.add(faMap, fasCheckCircle, fasChevronCircleDown, fasChevronCircleUp, faParking, faSquare, faBus, faHiking, faCircle, faCamera, faVolume, faVolumeMute, faCog, fasCaretDown, fasLocationArrow)
library.add(faWikipediaW, faGoogle)
Vue.component('font-awesome-icon', FontAwesomeIcon)
Vue.component('font-awesome-layers', FontAwesomeLayers)
Vue.use(VueSVGIcon)
Vue.use(vueDebounce)
Vue.use(VueClipboard)
Vue.use(Buefy, {
defaultIconComponent: 'font-awesome-icon',
defaultIconPack: 'far'
})
Vue.use(MatchMedia)
if (window.performance && performance.navigation.type === 1) {
// Store last reload timestamp so user reloads can be detected despite SSO redirect
sessionStorage.setItem('lastReload', new Date().getTime())
}
if (sessionStorage.getItem('wantSso') || localStorage.getItem('wantSso')) {
Vue.use(VueKeyCloak, {
config: {
realm: 'SOTA',
url: 'https://sso.sota.org.uk/auth',
clientId: 'sotlas'
},
init: {
onLoad: 'check-sso',
checkLoginIframe: false
},
onReady: keycloak => {
if (sessionStorage.getItem('wantSsoLogin')) {
sessionStorage.removeItem('wantSsoLogin')
keycloak.login()
} else {
startVue()
}
},
onInitError: error => {
console.error('Keycloak error: ' + error)
startVue()
},
autoUpdateToken: false
})
} else {
startVue()
}
Vue.config.productionTip = false
// Axios error handling
let lastError = null
axios.interceptors.response.use(response => {
return response
}, error => {
if ((!lastError || new Date().getTime() - lastError > 9000) && (!error.response || error.response.status !== 404)) {
Snackbar.open({
duration: 9000,
message: 'Network or server error while loading data, try again later',
type: 'is-danger',
position: 'is-bottom-left',
queue: false
})
lastError = new Date().getTime()
}
return Promise.reject(error)
})
function startVue () {
new Vue({
store,
router,
render: h => h(App),
mq: {
mobile: '(max-width: 768px)',
desktop: '(min-width: 1024px)',
fullhd: '(min-width: 1408px)'
}
}).$mount('#app')
}

32
src/mixins/api.js 100644
Wyświetl plik

@ -0,0 +1,32 @@
import axios from 'axios'
import ssoauth from './ssoauth.js'
export default {
mixins: [ssoauth],
methods: {
loadActivations (callsign) {
return axios.get('https://api.sotl.as/activations/' + callsign)
.then(response => {
return response.data
})
},
uploadPhoto (summitCode, file, progress, cancelToken) {
let formData = new FormData()
formData.append('photo', file)
return this.axiosAuth.post('https://api.sotl.as/photos/summits/' + summitCode + '/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: progress,
cancelToken
})
},
deletePhoto (summitCode, filename) {
return this.axiosAuth.delete('https://api.sotl.as/photos/summits/' + summitCode + '/' + filename)
},
editPhoto (summitCode, filename, data) {
return this.axiosAuth.post('https://api.sotl.as/photos/summits/' + summitCode + '/' + filename, data)
},
reorderPhotos (summitCode, filenames) {
return this.axiosAuth.post('https://api.sotl.as/photos/summits/' + summitCode + '/reorder', { filenames })
}
}
}

Wyświetl plik

@ -0,0 +1,66 @@
import photos from '../mixins/photos.js'
import Wikipedia from '../wikipedia.js'
export default {
mixins: [photos],
computed: {
coverPhoto () {
if (this.summit.photos) {
let coverPhoto = this.summit.photos.find(photo => photo.isCover)
if (coverPhoto) {
return {
src: this.photoSrc(coverPhoto, 'thumb'),
mediaLink: this.photoSrc(coverPhoto, 'large'),
attribution: coverPhoto.author
}
}
}
if (this.wikipediaPhoto) {
return this.wikipediaPhoto
}
return null
}
},
watch: {
summit: {
handler (newSummit, oldSummit) {
if (!newSummit.code) {
return
}
if (newSummit && oldSummit && newSummit.code === oldSummit.code && this.wikipediaPhoto !== null) {
return
}
this.wikipediaPhoto = null
if (!this.alwaysLoadWikipedia && this.summit.photos && this.summit.photos.some(photo => photo.isCover)) {
// We have our own photo; no need to load
return
}
let loadingSummit = this.summit
Wikipedia.loadSummitPhoto(this.summit, 320)
.then(photo => {
if (!photo) {
return
}
let preloadImg = new Image()
preloadImg.onload = () => {
if (this.summit && loadingSummit.code === this.summit.code) {
this.wikipediaPhoto = photo
}
}
preloadImg.src = photo.src
})
},
immediate: true
}
},
data () {
return {
wikipediaPhoto: null
}
}
}

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