Merge branch 'main' into feat/pwa-favicon-support
This commit is contained in:
258
web/package-lock.json
generated
258
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wanderer",
|
||||
"version": "0.19.1",
|
||||
"version": "0.19.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wanderer",
|
||||
"version": "0.19.1",
|
||||
"version": "0.19.2",
|
||||
"dependencies": {
|
||||
"@felte/validator-zod": "^1.0.18",
|
||||
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||
@@ -26,6 +26,7 @@
|
||||
"@turf/destination": "^7.3.3",
|
||||
"@turf/distance": "^7.3.3",
|
||||
"@types/chart.js": "^4.0.1",
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"@types/three": "^0.183.1",
|
||||
"@xmldom/xmldom": "^0.8.12",
|
||||
"activitypub-types": "^1.1.0",
|
||||
@@ -53,6 +54,7 @@
|
||||
"photoswipe": "^5.4.3",
|
||||
"pocketbase": "^0.26.8",
|
||||
"qrcode": "^1.4.4",
|
||||
"supercluster": "^8.0.1",
|
||||
"svelte-i18n": "^4.0.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"three": "^0.183.1",
|
||||
@@ -63,18 +65,18 @@
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@sveltejs/adapter-auto": "^7.0.1",
|
||||
"@sveltejs/enhanced-img": "^0.10.4",
|
||||
"@sveltejs/kit": "^2.57.1",
|
||||
"@sveltejs/kit": "^2.60.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^25.3.3",
|
||||
"postcss": "^8.5.6",
|
||||
"svelte": "^5.53.6",
|
||||
"svelte": "^5.55.7",
|
||||
"svelte-check": "^4.3.6",
|
||||
"sveltekit-openapi-generator": "^0.1.5",
|
||||
"sveltekit-openapi-generator": "^0.1.6",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.9"
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@apidevtools/json-schema-ref-parser": {
|
||||
@@ -141,9 +143,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
|
||||
"integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==",
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -935,9 +937,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.126.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.126.0.tgz",
|
||||
"integrity": "sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==",
|
||||
"version": "0.133.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
|
||||
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
@@ -983,9 +985,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -999,9 +1001,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1015,9 +1017,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz",
|
||||
"integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1031,9 +1033,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz",
|
||||
"integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1047,9 +1049,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz",
|
||||
"integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1063,12 +1065,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1079,12 +1084,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz",
|
||||
"integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1095,12 +1103,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1111,12 +1122,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1127,12 +1141,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1143,12 +1160,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz",
|
||||
"integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1159,9 +1179,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1175,37 +1195,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz",
|
||||
"integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "1.9.2",
|
||||
"@emnapi/runtime": "1.9.2",
|
||||
"@emnapi/core": "1.10.0",
|
||||
"@emnapi/runtime": "1.10.0",
|
||||
"@napi-rs/wasm-runtime": "^1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz",
|
||||
"integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
|
||||
"integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1219,9 +1229,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz",
|
||||
"integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1235,9 +1245,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/plugin-commonjs": {
|
||||
@@ -1717,9 +1727,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/kit": {
|
||||
"version": "2.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.57.1.tgz",
|
||||
"integrity": "sha512-VRdSbB96cI1EnRh09CqmnQqP/YJvET5buj8S6k7CxaJqBJD4bw4fRKDjcarAj/eX9k2eHifQfDH8NtOh+ZxxPw==",
|
||||
"version": "2.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.60.1.tgz",
|
||||
"integrity": "sha512-mQjlkNo+rJvpln7V2IGY2j99BqhcFbS4UN0AQNKNYfhBAFZTuCDAdW3a1sgf330mvtNvsBXn3HpAhcmvdJTcIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
@@ -1727,7 +1737,7 @@
|
||||
"@types/cookie": "^0.6.0",
|
||||
"acorn": "^8.14.1",
|
||||
"cookie": "^0.6.0",
|
||||
"devalue": "^5.6.4",
|
||||
"devalue": "^5.8.1",
|
||||
"esm-env": "^1.2.2",
|
||||
"kleur": "^4.1.5",
|
||||
"magic-string": "^0.30.5",
|
||||
@@ -3612,9 +3622,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/devalue": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz",
|
||||
"integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==",
|
||||
"version": "5.8.1",
|
||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
|
||||
"integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dfa": {
|
||||
@@ -5009,9 +5019,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -5321,9 +5331,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -5340,7 +5350,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -5727,13 +5737,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0-rc.16",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.16.tgz",
|
||||
"integrity": "sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
|
||||
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.126.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.16"
|
||||
"@oxc-project/types": "=0.133.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
@@ -5742,21 +5752,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.16",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.16",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.16",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.16",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.16",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.16",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.16",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.16",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.16",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.16",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.16",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.16",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.16",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.16",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.16"
|
||||
"@rolldown/binding-android-arm64": "1.0.3",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.3",
|
||||
"@rolldown/binding-darwin-x64": "1.0.3",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.3",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.3",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.3",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.3",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.3",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.3",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.3",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
@@ -6139,9 +6149,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/svelte": {
|
||||
"version": "5.55.4",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.4.tgz",
|
||||
"integrity": "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ==",
|
||||
"version": "5.55.7",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.7.tgz",
|
||||
"integrity": "sha512-ymI5ykLPwIHW839E053FQbI1G+jnRFJEw3Kv5Y4njixVWywQBx+NUFpkkKyk5LIb36Fg9DVXSYpqiGekLD0hyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
@@ -6153,7 +6163,7 @@
|
||||
"aria-query": "5.3.1",
|
||||
"axobject-query": "^4.1.0",
|
||||
"clsx": "^2.1.1",
|
||||
"devalue": "^5.6.4",
|
||||
"devalue": "^5.8.1",
|
||||
"esm-env": "^1.2.1",
|
||||
"esrap": "^2.2.4",
|
||||
"is-reference": "^3.0.3",
|
||||
@@ -6672,9 +6682,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/sveltekit-openapi-generator": {
|
||||
"version": "0.1.5",
|
||||
"resolved": "https://registry.npmjs.org/sveltekit-openapi-generator/-/sveltekit-openapi-generator-0.1.5.tgz",
|
||||
"integrity": "sha512-98EiU64AicZRIbHHRSOQcaaX/YEVk64mC+3GvVwgBesIe2cmen4B+YNHkSLijAZSntIfFSz2LUBfHMGhXJXcXQ==",
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/sveltekit-openapi-generator/-/sveltekit-openapi-generator-0.1.6.tgz",
|
||||
"integrity": "sha512-y4GHsC6Whe5PROJ+n0yVBNbIc0kjc5DtznneYIEUQQ33QEnmEddikXqwXwQRkEkp4qdfxb83vvOcen6ovpulVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -6687,7 +6697,7 @@
|
||||
"peerDependencies": {
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"svelte": "^4.0.0 || ^5.0.0",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svg-pathdata": {
|
||||
@@ -6912,9 +6922,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
@@ -7137,16 +7147,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.9",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz",
|
||||
"integrity": "sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==",
|
||||
"version": "8.0.16",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
||||
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.10",
|
||||
"rolldown": "1.0.0-rc.16",
|
||||
"tinyglobby": "^0.2.16"
|
||||
"postcss": "^8.5.15",
|
||||
"rolldown": "1.0.3",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
@@ -7162,7 +7172,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.1.0",
|
||||
"@vitejs/devtools": "^0.1.18",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wanderer",
|
||||
"version": "0.19.1",
|
||||
"version": "0.19.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -18,18 +18,18 @@
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@sveltejs/adapter-auto": "^7.0.1",
|
||||
"@sveltejs/enhanced-img": "^0.10.4",
|
||||
"@sveltejs/kit": "^2.57.1",
|
||||
"@sveltejs/kit": "^2.60.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^25.3.3",
|
||||
"postcss": "^8.5.6",
|
||||
"svelte": "^5.53.6",
|
||||
"svelte": "^5.55.7",
|
||||
"svelte-check": "^4.3.6",
|
||||
"sveltekit-openapi-generator": "^0.1.5",
|
||||
"sveltekit-openapi-generator": "^0.1.6",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.9"
|
||||
"vite": "^8.0.16"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -51,6 +51,7 @@
|
||||
"@turf/destination": "^7.3.3",
|
||||
"@turf/distance": "^7.3.3",
|
||||
"@types/chart.js": "^4.0.1",
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"@types/three": "^0.183.1",
|
||||
"@xmldom/xmldom": "^0.8.12",
|
||||
"activitypub-types": "^1.1.0",
|
||||
@@ -78,6 +79,7 @@
|
||||
"photoswipe": "^5.4.3",
|
||||
"pocketbase": "^0.26.8",
|
||||
"qrcode": "^1.4.4",
|
||||
"supercluster": "^8.0.1",
|
||||
"svelte-i18n": "^4.0.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"three": "^0.183.1",
|
||||
|
||||
@@ -10,15 +10,15 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light">
|
||||
<script>
|
||||
localStorage.theme === 'dark' ||
|
||||
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
? document.documentElement.classList.add('dark')
|
||||
: document.documentElement.classList.add('light');
|
||||
|
||||
localStorage.theme === 'dark' ||
|
||||
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
? document.querySelector("meta[name='color-scheme'" )?.setAttribute("content", 'dark')
|
||||
: document.querySelector("meta[name='color-scheme'" )?.setAttribute("content", 'light')
|
||||
const storedTheme = localStorage.theme;
|
||||
const theme =
|
||||
storedTheme === 'dark' || storedTheme === 'light'
|
||||
? storedTheme
|
||||
: window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
document.documentElement.classList.add(theme);
|
||||
document.querySelector("meta[name='color-scheme']")?.setAttribute("content", theme);
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
@@ -27,4 +27,4 @@
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -129,4 +129,4 @@
|
||||
.mention {
|
||||
@apply bg-blue-100 dark:bg-slate-700 rounded-md text-sm;
|
||||
padding: 0.1rem 0.3rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { Actor } from '$lib/models/activitypub/actor'
|
||||
import { normalizeLocale } from '$lib/i18n/locales'
|
||||
import { handleError } from '$lib/util/api_util'
|
||||
|
||||
const SEARCH_TOKEN_VERSION = 1;
|
||||
|
||||
function csrf(allowedPaths: string[]): Handle {
|
||||
return async ({ event, resolve }) => {
|
||||
@@ -85,12 +86,12 @@ const auth: Handle = async ({ event, resolve }) => {
|
||||
const currentUserId = pb.authStore.record?.id || 'public';
|
||||
|
||||
if (meiliCookie) {
|
||||
const [token, ownerId] = meiliCookie.split('|');
|
||||
const [token, ownerId, version] = meiliCookie.split('|');
|
||||
|
||||
if (ownerId === currentUserId) {
|
||||
if (ownerId === currentUserId && Number(version) === SEARCH_TOKEN_VERSION) {
|
||||
meilisearchToken = token;
|
||||
} else {
|
||||
// Identity mismatch (e.g. just logged in/out)
|
||||
// Identity mismatch (e.g. just logged in/out) or stale token version
|
||||
event.cookies.delete('meilisearch_token', { path: '/' });
|
||||
}
|
||||
}
|
||||
@@ -99,7 +100,7 @@ const auth: Handle = async ({ event, resolve }) => {
|
||||
try {
|
||||
const tokenResponse = await pb.send("/search/token", { method: "GET", fetch: event.fetch });
|
||||
meilisearchToken = tokenResponse.token
|
||||
event.cookies.set('meilisearch_token', `${meilisearchToken}|${currentUserId}`, {
|
||||
event.cookies.set('meilisearch_token', `${meilisearchToken}|${currentUserId}|${SEARCH_TOKEN_VERSION}`, {
|
||||
path: '/',
|
||||
httpOnly: false,
|
||||
maxAge: 60 * 60 * 24,
|
||||
@@ -143,7 +144,7 @@ const auth: Handle = async ({ event, resolve }) => {
|
||||
|
||||
if (pb.authStore.record) {
|
||||
settings = await pb.collection('settings').getFirstListItem<Settings>(`user="${pb.authStore.record.id}"`, { requestKey: null })
|
||||
actor = await pb.collection("activitypub_actors").getFirstListItem(`isLocal=1&&user='${pb.authStore.record.id}'`)
|
||||
actor = await pb.collection("activitypub_actors").getFirstListItem(`is_local=1&&user='${pb.authStore.record.id}'`)
|
||||
}
|
||||
const meiliHost = env.MEILI_URL;
|
||||
if (!meiliHost) {
|
||||
@@ -190,4 +191,4 @@ const removeLinkFromHeaders: Handle =
|
||||
}
|
||||
|
||||
|
||||
export const handle = sequence(csrf(['/api/v1']), auth, removeLinkFromHeaders)
|
||||
export const handle = sequence(csrf(['/api/v1']), auth, removeLinkFromHeaders)
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { Actor } from "$lib/models/activitypub/actor";
|
||||
import type { Actor, ActorSearchResult } from "$lib/models/activitypub/actor";
|
||||
import { searchActors } from "$lib/stores/search_store";
|
||||
import { show_toast } from "$lib/stores/toast_store.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
@@ -32,10 +32,10 @@
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const actors: Actor[] = await searchActors(q, includeSelf);
|
||||
const actors: ActorSearchResult[] = await searchActors(q, includeSelf);
|
||||
searchItems = actors.map((a) => ({
|
||||
text: a.username,
|
||||
description: `@${a.preferred_username}${a.isLocal ? "" : "@" + a.domain}`,
|
||||
description: `@${a.preferred_username}${a.is_local ? "" : "@" + a.domain}`,
|
||||
value: a,
|
||||
icon: "user",
|
||||
}));
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
value: any;
|
||||
icon?: string;
|
||||
separator?: boolean;
|
||||
danger?: boolean;
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -138,6 +139,8 @@
|
||||
{:else}
|
||||
<li
|
||||
class="menu-item flex items-center px-4 py-3 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
|
||||
class:hover:text-red-500={item.danger}
|
||||
class:focus:text-red-500={item.danger}
|
||||
role="presentation"
|
||||
onclick={(e) => handleItemClick(e, item as { text: string; value: any })}
|
||||
>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import Toggle from "./toggle.svelte";
|
||||
import type { SearchItem } from "./search.svelte";
|
||||
import type { SuggestionProps } from "@tiptap/suggestion";
|
||||
import type { Actor } from "$lib/models/activitypub/actor";
|
||||
import type { Actor, ActorSearchResult } from "$lib/models/activitypub/actor";
|
||||
import { searchActors } from "$lib/stores/search_store";
|
||||
import { show_toast } from "$lib/stores/toast_store.svelte";
|
||||
|
||||
@@ -103,13 +103,13 @@
|
||||
allowToIncludeChar: true,
|
||||
items: async ({ query }) => {
|
||||
try {
|
||||
const actors: Actor[] = await searchActors(
|
||||
const actors: ActorSearchResult[] = await searchActors(
|
||||
query,
|
||||
true,
|
||||
);
|
||||
return actors.map((a) => ({
|
||||
text: a.username!,
|
||||
description: `@${a.preferred_username}${a.isLocal ? "" : "@" + a.domain}`,
|
||||
description: `@${a.preferred_username}${a.is_local ? "" : "@" + a.domain}`,
|
||||
value: a,
|
||||
icon:
|
||||
a.icon ||
|
||||
@@ -155,7 +155,7 @@
|
||||
return (_: Event, item: SearchItem) => {
|
||||
props.command({
|
||||
id: item.value.iri,
|
||||
label: `${item.value.preferred_username}${item.value.isLocal ? "" : "@" + item.value.domain}`,
|
||||
label: `${item.value.preferred_username}${item.value.is_local ? "" : "@" + item.value.domain}`,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,11 +6,14 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
interface Props {
|
||||
name?: string;
|
||||
items?: SelectItem[];
|
||||
value?: any;
|
||||
label?: string;
|
||||
error?: string | string[] | null;
|
||||
disabled?: boolean;
|
||||
onchange?: (value: any) => void
|
||||
}
|
||||
@@ -20,6 +23,7 @@
|
||||
items = [],
|
||||
value = $bindable(items.at(0)?.value ?? ""),
|
||||
label = "",
|
||||
error = "",
|
||||
disabled = false,
|
||||
onchange
|
||||
}: Props = $props();
|
||||
@@ -38,6 +42,8 @@
|
||||
<select
|
||||
{name}
|
||||
class="block bg-input-background h-10 px-4 border-r-8 border-transparent outline-1 outline-input-border rounded-md focus:outline-input-border-focus transition-colors"
|
||||
class:outline-red-400={(error?.length ?? 0) > 0}
|
||||
class:bg-input-background-error={(error?.length ?? 0) > 0}
|
||||
class:text-gray-500={disabled}
|
||||
{disabled}
|
||||
bind:value
|
||||
@@ -47,4 +53,10 @@
|
||||
<option value={item.value}>{item.text}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
{#if error}
|
||||
<span class="select-error text-xs text-red-400">
|
||||
{error instanceof Array ? $_(error[0]) : error}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
257
web/src/lib/components/base/single_select.svelte
Normal file
257
web/src/lib/components/base/single_select.svelte
Normal file
@@ -0,0 +1,257 @@
|
||||
<script module lang="ts">
|
||||
export type SingleSelectItem = {
|
||||
text: string;
|
||||
value: any;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from "svelte";
|
||||
|
||||
interface Props {
|
||||
name?: string;
|
||||
items?: SingleSelectItem[];
|
||||
value?: any;
|
||||
label?: string;
|
||||
ariaLabel?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
onchange?: (value: any) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
name = "",
|
||||
items = [],
|
||||
value = $bindable(""),
|
||||
label = "",
|
||||
ariaLabel = "",
|
||||
placeholder = "",
|
||||
disabled = false,
|
||||
onchange,
|
||||
}: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let trigger: HTMLButtonElement | undefined = $state();
|
||||
let menu: HTMLUListElement | undefined = $state();
|
||||
let menuStyle = $state("");
|
||||
let activeIndex = $state(-1);
|
||||
|
||||
let selectedItem = $derived(items.find((item) => item.value === value));
|
||||
let selectedIndex = $derived(items.findIndex((item) => item.value === value));
|
||||
|
||||
async function positionMenu() {
|
||||
await tick();
|
||||
if (!trigger) return;
|
||||
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const gap = 4;
|
||||
const viewportPadding = 12;
|
||||
const maxHeight = 256;
|
||||
const spaceBelow = window.innerHeight - rect.bottom - viewportPadding;
|
||||
const spaceAbove = rect.top - viewportPadding;
|
||||
const openAbove = spaceBelow < 160 && spaceAbove > spaceBelow;
|
||||
const availableHeight = Math.max(
|
||||
80,
|
||||
Math.min(maxHeight, openAbove ? spaceAbove - gap : spaceBelow - gap),
|
||||
);
|
||||
const top = openAbove ? rect.top - gap - availableHeight : rect.bottom + gap;
|
||||
|
||||
menuStyle = [
|
||||
`left: ${rect.left}px`,
|
||||
`top: ${top}px`,
|
||||
`width: ${rect.width}px`,
|
||||
`max-height: ${availableHeight}px`,
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
async function scrollActiveItemIntoView() {
|
||||
await tick();
|
||||
menu?.querySelector<HTMLElement>(`[data-single-select-index="${activeIndex}"]`)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
function selectItem(item: SingleSelectItem) {
|
||||
if (disabled) return;
|
||||
value = item.value;
|
||||
open = false;
|
||||
onchange?.(item.value);
|
||||
}
|
||||
|
||||
async function openMenu(index = selectedIndex >= 0 ? selectedIndex : 0) {
|
||||
if (disabled) return;
|
||||
activeIndex = items.length ? Math.max(0, Math.min(index, items.length - 1)) : -1;
|
||||
open = true;
|
||||
await positionMenu();
|
||||
await scrollActiveItemIntoView();
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
open = false;
|
||||
activeIndex = -1;
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
if (disabled) return;
|
||||
if (open) {
|
||||
closeMenu();
|
||||
} else {
|
||||
await openMenu();
|
||||
}
|
||||
}
|
||||
|
||||
async function setActiveIndex(index: number) {
|
||||
if (!items.length) return;
|
||||
activeIndex = (index + items.length) % items.length;
|
||||
await scrollActiveItemIntoView();
|
||||
}
|
||||
|
||||
async function handleKeydown(event: KeyboardEvent) {
|
||||
if (disabled) return;
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
if (!open) {
|
||||
await openMenu(selectedIndex >= 0 ? selectedIndex : 0);
|
||||
} else {
|
||||
await setActiveIndex(activeIndex + 1);
|
||||
}
|
||||
break;
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
if (!open) {
|
||||
await openMenu(selectedIndex >= 0 ? selectedIndex : items.length - 1);
|
||||
} else {
|
||||
await setActiveIndex(activeIndex - 1);
|
||||
}
|
||||
break;
|
||||
case "Home":
|
||||
if (!open) return;
|
||||
event.preventDefault();
|
||||
await setActiveIndex(0);
|
||||
break;
|
||||
case "End":
|
||||
if (!open) return;
|
||||
event.preventDefault();
|
||||
await setActiveIndex(items.length - 1);
|
||||
break;
|
||||
case "Enter":
|
||||
case " ":
|
||||
event.preventDefault();
|
||||
if (!open) {
|
||||
await openMenu();
|
||||
} else if (activeIndex >= 0 && items[activeIndex]) {
|
||||
selectItem(items[activeIndex]);
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
if (!open) return;
|
||||
event.preventDefault();
|
||||
closeMenu();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
function handleDocumentMouseDown(event: MouseEvent) {
|
||||
const target = event.target as Node | null;
|
||||
if (!target || trigger?.contains(target) || menu?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
open = false;
|
||||
}
|
||||
|
||||
function handleDocumentKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
closeMenu();
|
||||
}
|
||||
}
|
||||
|
||||
function handleDocumentScroll(event: Event) {
|
||||
if (!open) return;
|
||||
const target = event.target as Node | null;
|
||||
if (target && menu?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
closeMenu();
|
||||
}
|
||||
|
||||
function handleWindowResize() {
|
||||
if (!open) return;
|
||||
positionMenu();
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleDocumentMouseDown);
|
||||
document.addEventListener("keydown", handleDocumentKeyDown);
|
||||
document.addEventListener("scroll", handleDocumentScroll, true);
|
||||
window.addEventListener("resize", handleWindowResize);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleDocumentMouseDown);
|
||||
document.removeEventListener("keydown", handleDocumentKeyDown);
|
||||
document.removeEventListener("scroll", handleDocumentScroll, true);
|
||||
window.removeEventListener("resize", handleWindowResize);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
{#if label.length}
|
||||
<label for={name} class="text-sm font-medium pb-1">
|
||||
{label}
|
||||
</label>
|
||||
{/if}
|
||||
<button
|
||||
bind:this={trigger}
|
||||
id={name}
|
||||
type="button"
|
||||
aria-label={ariaLabel || label || name}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
class="relative flex h-10 w-full items-center rounded-md border border-input-border bg-input-background px-3 text-left transition-colors focus:border-input-border-focus focus:outline-none focus:ring-0"
|
||||
class:opacity-75={disabled}
|
||||
class:cursor-default={disabled}
|
||||
disabled={disabled}
|
||||
onclick={toggle}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
{#if selectedItem}
|
||||
<span class="truncate">{selectedItem.text}</span>
|
||||
{:else}
|
||||
<span class="truncate text-gray-400">{placeholder}</span>
|
||||
{/if}
|
||||
<i
|
||||
class="fa fa-caret-down absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 transition-transform"
|
||||
class:rotate-180={open}
|
||||
></i>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<ul
|
||||
bind:this={menu}
|
||||
role="listbox"
|
||||
class="fixed z-[1001] overflow-y-auto rounded-md border border-input-border bg-menu-background shadow-lg"
|
||||
style={menuStyle}
|
||||
>
|
||||
{#each items as item, i}
|
||||
<li
|
||||
role="option"
|
||||
aria-selected={item.value === value}
|
||||
tabindex="-1"
|
||||
class="flex cursor-pointer items-center justify-between px-3 py-2 hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus"
|
||||
class:bg-menu-item-background-focus={activeIndex === i}
|
||||
data-single-select-index={i}
|
||||
onmousedown={(event) => {
|
||||
event.preventDefault();
|
||||
selectItem(item);
|
||||
}}
|
||||
>
|
||||
<span class="truncate">{item.text}</span>
|
||||
{#if item.value === value}
|
||||
<i class="fa fa-check ml-3"></i>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -11,7 +11,7 @@
|
||||
error?: string | string[] | null;
|
||||
icon?: string;
|
||||
extraClasses?: string;
|
||||
type?: "text" | "password" | "search";
|
||||
type?: "text" | "password" | "search" | "url";
|
||||
autocomplete?: "on" | "off";
|
||||
onchange?: ChangeEventHandler<HTMLInputElement>;
|
||||
oninput?: FormEventHandler<HTMLInputElement>;
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
text: string;
|
||||
action?: string;
|
||||
deny?: string;
|
||||
alternative?: string;
|
||||
id?: string;
|
||||
onconfirm?: () => void
|
||||
oncancel?: () => void
|
||||
onalternative?: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -17,9 +19,11 @@
|
||||
text,
|
||||
action = "delete",
|
||||
deny ="cancel",
|
||||
alternative,
|
||||
id = "confirm-modal",
|
||||
onconfirm,
|
||||
oncancel
|
||||
oncancel,
|
||||
onalternative
|
||||
}: Props = $props();
|
||||
|
||||
let modal: Modal;
|
||||
@@ -29,13 +33,18 @@
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
oncancel?.();
|
||||
modal.closeModal!();
|
||||
oncancel?.();
|
||||
}
|
||||
|
||||
function alternativeAction() {
|
||||
modal.closeModal!();
|
||||
onalternative?.();
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
onconfirm?.()
|
||||
modal.closeModal!();
|
||||
onconfirm?.()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -48,6 +57,11 @@
|
||||
<button class="btn-secondary" onclick={cancel}
|
||||
>{$_(deny)}</button
|
||||
>
|
||||
{#if alternative}
|
||||
<button class="btn-secondary" type="button" onclick={alternativeAction}
|
||||
>{$_(alternative)}</button
|
||||
>
|
||||
{/if}
|
||||
<button
|
||||
id="confirm"
|
||||
class={action === "delete" ? "btn-danger" : "btn-primary"}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
|
||||
<div class="self-start min-w-0 basis-full transition-transform">
|
||||
<div class="flex items-center gap-3">
|
||||
<h5 class="text-xl font-semibold overflow-hidden overflow-ellipsis shrink-0 max-w-3/4 whitespace-nowrap">
|
||||
<h5 class="text-xl font-semibold overflow-hidden text-ellipsis shrink-0 max-w-3/4 whitespace-nowrap">
|
||||
{list.name}
|
||||
</h5>
|
||||
<div class="basis-full"></div>
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
}
|
||||
|
||||
async function shareList(item: SelectItem) {
|
||||
if (!item.value.isLocal && !list.public) {
|
||||
if (!item.value.is_local && !list.public) {
|
||||
displayShareError = true;
|
||||
return;
|
||||
}
|
||||
@@ -162,7 +162,7 @@
|
||||
alt="avatar"
|
||||
/>
|
||||
<p>
|
||||
{`@${share.expand.actor.preferred_username}${share.expand.actor.isLocal ? "" : "@" + share.expand.actor.domain}`}
|
||||
{`@${share.expand.actor.preferred_username}${share.expand.actor.is_local ? "" : "@" + share.expand.actor.domain}`}
|
||||
</p>
|
||||
<span
|
||||
class="basis-full text-sm text-center text-gray-500"
|
||||
@@ -170,13 +170,13 @@
|
||||
>
|
||||
<div
|
||||
class="shrink-0"
|
||||
class:tooltip={!share.expand.actor.isLocal}
|
||||
class:tooltip={!share.expand.actor.is_local}
|
||||
data-title={$_("remote-users-cannot-edit")}
|
||||
>
|
||||
<Select
|
||||
bind:value={share.permission}
|
||||
items={permissionSelectItems}
|
||||
disabled={!share.expand.actor.isLocal}
|
||||
disabled={!share.expand.actor.is_local}
|
||||
onchange={(value) =>
|
||||
updateSharePermission(share, value)}
|
||||
></Select>
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Datepicker from "$lib/components/base/datepicker.svelte";
|
||||
import IntegrationMergeSettings from "$lib/components/settings/integrations/integration_merge_settings.svelte";
|
||||
import Modal from "$lib/components/base/modal.svelte";
|
||||
import TextField from "$lib/components/base/text_field.svelte";
|
||||
import Toggle from "$lib/components/base/toggle.svelte";
|
||||
import { HammerheadSchema } from "$lib/models/api/integration_schema";
|
||||
import type {
|
||||
Integration,
|
||||
HammerheadIntegration,
|
||||
} from "$lib/models/integration";
|
||||
import { validator } from "@felte/validator-zod";
|
||||
import { createForm } from "felte";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
interface Props {
|
||||
integration?: Integration;
|
||||
onsave?: (hammerheadIntegration: HammerheadIntegration) => void;
|
||||
}
|
||||
|
||||
let { integration, onsave }: Props = $props();
|
||||
|
||||
let modal: Modal;
|
||||
|
||||
export function openModal() {
|
||||
errors.set({});
|
||||
modal.openModal();
|
||||
}
|
||||
|
||||
const getInitialFormValues = () => ({
|
||||
email: integration?.hammerhead?.email ?? "",
|
||||
password: integration?.hammerhead?.password ?? "",
|
||||
completed: integration?.hammerhead?.completed ?? true,
|
||||
planned: integration?.hammerhead?.planned ?? true,
|
||||
active: integration?.hammerhead?.active ?? false,
|
||||
after: integration?.hammerhead?.after,
|
||||
merge: integration?.hammerhead?.merge ?? {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
form,
|
||||
errors,
|
||||
data: formData,
|
||||
} = createForm({
|
||||
initialValues: getInitialFormValues(),
|
||||
extend: validator({
|
||||
schema: HammerheadSchema,
|
||||
}),
|
||||
onSubmit: async (form) => {
|
||||
form.active = integration?.hammerhead?.active ?? form.active;
|
||||
onsave?.(form);
|
||||
modal.closeModal();
|
||||
},
|
||||
});
|
||||
|
||||
function clearAfterDate() {
|
||||
($formData as any).after = undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
id="hammerhead-settings-modal"
|
||||
size="md:max-w-lg"
|
||||
title={"Hammerhead " + $_("settings")}
|
||||
bind:this={modal}
|
||||
>
|
||||
{#snippet content()}
|
||||
<form id="hammerhead-settings-form" class="space-y-2" use:form>
|
||||
<TextField
|
||||
label={$_("email")}
|
||||
placeholder="user@example.com"
|
||||
name="email"
|
||||
error={$errors.email}
|
||||
></TextField>
|
||||
<TextField
|
||||
label={$_("password")}
|
||||
placeholder={integration?.hammerhead ? `(${$_("unchanged")})` : ""}
|
||||
name="password"
|
||||
type="password"
|
||||
error={$errors.password}
|
||||
></TextField>
|
||||
<div class="flex flex-wrap gap-x-4">
|
||||
<Toggle name="planned" label={$_("planned-tours", { values: { n: 2 } })}
|
||||
></Toggle>
|
||||
<Toggle
|
||||
name="completed"
|
||||
label={$_("completed-tours", { values: { n: 2 } })}
|
||||
></Toggle>
|
||||
</div>
|
||||
<p
|
||||
class="text-xs text-gray-500 max-w-lg pt-4 pb-1 border-t border-input-border"
|
||||
>
|
||||
{$_("hammerhead-integration-after-date-hint")}
|
||||
</p>
|
||||
<div class="flex items-end relative gap-x-2">
|
||||
<Datepicker
|
||||
error={$errors.after}
|
||||
label={$_("ignore-trails-before-date")}
|
||||
bind:value={$formData.after}
|
||||
></Datepicker>
|
||||
<button
|
||||
class="btn-icon mb-[10px]"
|
||||
type="button"
|
||||
onclick={clearAfterDate}
|
||||
aria-label="Clear 'after' date"
|
||||
><i class="fa fa-close"></i></button
|
||||
>
|
||||
</div>
|
||||
|
||||
<IntegrationMergeSettings prefix="merge" />
|
||||
</form>
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="btn-secondary" onclick={() => modal.closeModal()}
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
<button
|
||||
class="btn-primary"
|
||||
form="hammerhead-settings-form"
|
||||
type="submit"
|
||||
name="save">{$_("save")}</button
|
||||
>
|
||||
</div>
|
||||
{/snippet}</Modal
|
||||
>
|
||||
@@ -1,39 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Toggle from "$lib/components/base/toggle.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
interface Props {
|
||||
onclick: () => void;
|
||||
ontoggle: (value: boolean) => void;
|
||||
active: boolean;
|
||||
disabled: boolean;
|
||||
img: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
let {
|
||||
onclick,
|
||||
ontoggle,
|
||||
active = $bindable(),
|
||||
disabled,
|
||||
img,
|
||||
title,
|
||||
description,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
<div class="border border-input-border rounded-lg p-4 space-y-4 flex flex-col justify-between">
|
||||
<img class="h-20" src={img} alt="integration logo"/>
|
||||
<div>
|
||||
<h5 class="text-xl font-semibold">{title}</h5>
|
||||
<p class="text-sm text-gray-500">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<button class="btn-secondary" {onclick}
|
||||
><i class="fa fa-cogs mr-2"></i>{$_("settings")}</button
|
||||
>
|
||||
<Toggle bind:value={active} onchange={ontoggle} {disabled}></Toggle>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,130 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Modal from "$lib/components/base/modal.svelte";
|
||||
import IntegrationMergeSettings from "$lib/components/settings/integrations/integration_merge_settings.svelte";
|
||||
import Select, {
|
||||
type SelectItem,
|
||||
} from "$lib/components/base/select.svelte";
|
||||
import TextField from "$lib/components/base/text_field.svelte";
|
||||
import Toggle from "$lib/components/base/toggle.svelte";
|
||||
import { KomootSchema } from "$lib/models/api/integration_schema";
|
||||
import type {
|
||||
Integration,
|
||||
KomootIntegration,
|
||||
} from "$lib/models/integration";
|
||||
import { validator } from "@felte/validator-zod";
|
||||
import { createForm } from "felte";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
interface Props {
|
||||
integration?: Integration;
|
||||
onsave?: (komootIntegration: KomootIntegration) => void;
|
||||
}
|
||||
|
||||
let { integration, onsave }: Props = $props();
|
||||
|
||||
let modal: Modal;
|
||||
|
||||
const privacySelectItems: SelectItem[] = [
|
||||
{
|
||||
text: $_("keep-original"),
|
||||
value: "original",
|
||||
},
|
||||
{ text: $_("apply-user-settings"), value: "settings" },
|
||||
];
|
||||
|
||||
export function openModal() {
|
||||
errors.set({});
|
||||
modal.openModal();
|
||||
}
|
||||
|
||||
const getInitialFormValues = () => ({
|
||||
email: integration?.komoot?.email ?? "",
|
||||
password: integration?.komoot?.password ?? "",
|
||||
completed: integration?.komoot?.completed ?? true,
|
||||
planned: integration?.komoot?.planned ?? true,
|
||||
active: integration?.komoot?.active ?? false,
|
||||
privacy: integration?.komoot?.privacy ?? "original",
|
||||
merge: integration?.komoot?.merge ?? {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
form,
|
||||
errors,
|
||||
data: d,
|
||||
} = createForm({
|
||||
initialValues: getInitialFormValues(),
|
||||
extend: validator({
|
||||
schema: KomootSchema,
|
||||
}),
|
||||
onSubmit: async (form) => {
|
||||
form.active = integration?.komoot?.active ?? form.active;
|
||||
onsave?.(form);
|
||||
modal.closeModal();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
id="komoot-settings-modal"
|
||||
size="md:max-w-lg"
|
||||
title={"komoot " + $_("settings")}
|
||||
bind:this={modal}
|
||||
>
|
||||
{#snippet content()}
|
||||
<form id="komoot-settings-form" class="space-y-2" use:form>
|
||||
<TextField
|
||||
label={$_("email")}
|
||||
placeholder="user@example.com"
|
||||
name="email"
|
||||
error={$errors.email}
|
||||
></TextField>
|
||||
<TextField
|
||||
label={$_("password")}
|
||||
placeholder={integration?.komoot ? `(${$_("unchanged")})` : ""}
|
||||
name="password"
|
||||
type="password"
|
||||
error={$errors.password}
|
||||
></TextField>
|
||||
<div class="flex flex-wrap gap-x-4">
|
||||
<Toggle
|
||||
name="planned"
|
||||
label={$_("planned-tours", { values: { n: 2 } })}
|
||||
></Toggle>
|
||||
<Toggle
|
||||
name="completed"
|
||||
label={$_("completed-tours", { values: { n: 2 } })}
|
||||
></Toggle>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label={$_("privacy")}
|
||||
items={privacySelectItems}
|
||||
name="privacy"
|
||||
></Select>
|
||||
<p class="text-xs text-gray-500 max-w-lg">
|
||||
{#if $d.privacy == "original"}
|
||||
{$_("integration-privacy-hint-original")}
|
||||
{:else}
|
||||
{$_("integration-privacy-hint-user")}
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<IntegrationMergeSettings prefix="merge" />
|
||||
</form>
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="btn-secondary" onclick={() => modal.closeModal()}
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
<button
|
||||
class="btn-primary"
|
||||
form="komoot-settings-form"
|
||||
type="submit"
|
||||
name="save">{$_("save")}</button
|
||||
>
|
||||
</div>
|
||||
{/snippet}</Modal
|
||||
>
|
||||
@@ -1,154 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Datepicker from "$lib/components/base/datepicker.svelte";
|
||||
import IntegrationMergeSettings from "$lib/components/settings/integrations/integration_merge_settings.svelte";
|
||||
import Modal from "$lib/components/base/modal.svelte";
|
||||
import type { SelectItem } from "$lib/components/base/select.svelte";
|
||||
import Select from "$lib/components/base/select.svelte";
|
||||
import TextField from "$lib/components/base/text_field.svelte";
|
||||
import Toggle from "$lib/components/base/toggle.svelte";
|
||||
import { StravaSchema } from "$lib/models/api/integration_schema";
|
||||
import type {
|
||||
Integration,
|
||||
StravaIntegration,
|
||||
} from "$lib/models/integration";
|
||||
import { validator } from "@felte/validator-zod";
|
||||
import { createForm } from "felte";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
interface Props {
|
||||
integration?: Integration;
|
||||
onsave?: (stravaIntegration: StravaIntegration) => void;
|
||||
}
|
||||
|
||||
let { integration, onsave }: Props = $props();
|
||||
|
||||
let modal: Modal;
|
||||
|
||||
const privacySelectItems: SelectItem[] = [
|
||||
{
|
||||
text: $_("keep-original"),
|
||||
value: "original",
|
||||
},
|
||||
{ text: $_("apply-user-settings"), value: "settings" },
|
||||
];
|
||||
|
||||
export function openModal() {
|
||||
errors.set({});
|
||||
modal.openModal();
|
||||
}
|
||||
|
||||
const getInitialFormValues = () => ({
|
||||
clientId: integration?.strava?.clientId ?? "",
|
||||
clientSecret: integration?.strava?.clientSecret ?? "",
|
||||
routes: integration?.strava?.routes ?? true,
|
||||
activities: integration?.strava?.activities ?? true,
|
||||
active: integration?.strava?.active ?? false,
|
||||
after: integration?.strava?.after,
|
||||
privacy: integration?.strava?.privacy ?? "original",
|
||||
merge: integration?.strava?.merge ?? {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
form,
|
||||
errors,
|
||||
data: formData,
|
||||
} = createForm({
|
||||
initialValues: getInitialFormValues(),
|
||||
extend: validator({
|
||||
schema: StravaSchema,
|
||||
}),
|
||||
onSubmit: async (form) => {
|
||||
form.active = integration?.strava?.active ?? form.active;
|
||||
onsave?.(form);
|
||||
modal.closeModal();
|
||||
},
|
||||
});
|
||||
|
||||
function clearAfterDate() {
|
||||
($formData as any).after = undefined;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
id="strava-settings-modal"
|
||||
size="md:min-w-lg"
|
||||
title={"Strava " + $_("settings")}
|
||||
bind:this={modal}
|
||||
>
|
||||
{#snippet content()}
|
||||
<form id="strava-settings-form" class="space-y-2" use:form>
|
||||
<TextField
|
||||
label="Client-ID"
|
||||
placeholder="000000"
|
||||
name="clientId"
|
||||
error={$errors.clientId}
|
||||
></TextField>
|
||||
<TextField
|
||||
label="Client Secret"
|
||||
placeholder={integration?.strava
|
||||
? `(${$_("unchanged")})`
|
||||
: "de8b3789bd7116d..."}
|
||||
name="clientSecret"
|
||||
type="password"
|
||||
error={$errors.clientSecret}
|
||||
></TextField>
|
||||
<div class="flex gap-x-4">
|
||||
<Toggle name="routes" label={$_("route", { values: { n: 2 } })}
|
||||
></Toggle>
|
||||
<Toggle
|
||||
name="activities"
|
||||
label={$_("activity", { values: { n: 2 } })}
|
||||
></Toggle>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label={$_("privacy")}
|
||||
items={privacySelectItems}
|
||||
name="privacy"
|
||||
></Select>
|
||||
<p class="text-xs text-gray-500 max-w-lg">
|
||||
{#if $formData.privacy == "original"}
|
||||
{$_("integration-privacy-hint-original")}
|
||||
{:else}
|
||||
{$_("integration-privacy-hint-user")}
|
||||
{/if}
|
||||
</p>
|
||||
<div class="flex items-end relative gap-x-2 pt-2 border-t border-input-border">
|
||||
<Datepicker
|
||||
error={$errors.after}
|
||||
label={$_("ignore-trails-before-date")}
|
||||
bind:value={$formData.after}
|
||||
></Datepicker>
|
||||
<button
|
||||
class="btn-icon mb-[10px]"
|
||||
type="button"
|
||||
onclick={clearAfterDate}
|
||||
aria-label="Clear 'after' date"
|
||||
><i class="fa fa-close"></i></button
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
class="text-xs text-gray-500 max-w-lg"
|
||||
>
|
||||
{$_("strava-integration-after-date-hint")}
|
||||
</p>
|
||||
|
||||
<IntegrationMergeSettings prefix="merge" />
|
||||
</form>
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="btn-secondary" onclick={() => modal.closeModal()}
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
<button
|
||||
class="btn-primary"
|
||||
form="strava-settings-form"
|
||||
type="submit"
|
||||
name="save">{$_("save")}</button
|
||||
>
|
||||
</div>
|
||||
{/snippet}</Modal
|
||||
>
|
||||
111
web/src/lib/components/settings/plugins/plugin_card.svelte
Normal file
111
web/src/lib/components/settings/plugins/plugin_card.svelte
Normal file
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import Toggle from "$lib/components/base/toggle.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
interface Props {
|
||||
onclick: () => void;
|
||||
ontoggle: (value: boolean) => void;
|
||||
active: boolean;
|
||||
toggleDisabled?: boolean;
|
||||
settingsDisabled?: boolean;
|
||||
img?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
lastSyncAt?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
onclick,
|
||||
ontoggle,
|
||||
active = $bindable(),
|
||||
toggleDisabled = false,
|
||||
settingsDisabled = false,
|
||||
img,
|
||||
title,
|
||||
description = "",
|
||||
lastSyncAt = "",
|
||||
error = "",
|
||||
}: Props = $props();
|
||||
|
||||
function formatLastSyncAt(value: string) {
|
||||
return new Date(value).toLocaleString(undefined, {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-4 rounded-xl border border-input-border p-4 transition-colors hover:bg-secondary-hover md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div class="flex min-w-0 items-start gap-6">
|
||||
{#if img}
|
||||
<img
|
||||
class="h-16 w-24 shrink-0 object-contain object-left"
|
||||
src={img}
|
||||
alt="plugin logo"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-16 w-24 shrink-0 items-center justify-center rounded border border-input-border bg-input-background text-sm font-semibold"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{title.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="min-w-0">
|
||||
<h5 class="truncate text-lg font-semibold">{title}</h5>
|
||||
{#if description}
|
||||
<p class="line-clamp-2 text-sm text-gray-500">{description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-col gap-2 md:items-start">
|
||||
<div class="flex items-center justify-between gap-4 md:justify-end">
|
||||
<button
|
||||
class="btn-secondary"
|
||||
class:btn-disabled={settingsDisabled}
|
||||
{onclick}
|
||||
disabled={settingsDisabled}
|
||||
><i class="fa fa-cogs mr-2"></i>{$_("settings")}</button
|
||||
>
|
||||
<div class="plugin-card-toggle">
|
||||
<Toggle bind:value={active} onchange={ontoggle} disabled={toggleDisabled}></Toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-h-5 max-w-56 text-xs text-gray-500">
|
||||
{#if lastSyncAt}
|
||||
<span
|
||||
class:text-red-400={error}
|
||||
class="inline-flex min-w-0 items-center gap-2"
|
||||
title={error
|
||||
? $_("plugin-setup-error")
|
||||
: `${$_("last-sync")}: ${formatLastSyncAt(lastSyncAt)}`}
|
||||
>
|
||||
{#if error}
|
||||
<i
|
||||
class="fa fa-triangle-exclamation shrink-0 text-[0.8rem]"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
{:else}
|
||||
<i
|
||||
class="fa fa-clock shrink-0 text-[0.8rem]"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
{/if}
|
||||
<span class="truncate">{formatLastSyncAt(lastSyncAt)}</span>
|
||||
</span>
|
||||
{:else if error}
|
||||
<span class="inline-flex items-center gap-2 text-red-400" title={$_("plugin-setup-error")}>
|
||||
<i
|
||||
class="fa fa-triangle-exclamation shrink-0 text-[0.8rem]"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="truncate">{$_("plugin-setup-error")}</span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,753 @@
|
||||
<script lang="ts">
|
||||
import Datepicker from "$lib/components/base/datepicker.svelte";
|
||||
import Modal from "$lib/components/base/modal.svelte";
|
||||
import Select, { type SelectItem } from "$lib/components/base/select.svelte";
|
||||
import SingleSelect from "$lib/components/base/single_select.svelte";
|
||||
import TextField from "$lib/components/base/text_field.svelte";
|
||||
import Toggle from "$lib/components/base/toggle.svelte";
|
||||
import PluginMergeSettings from "$lib/components/settings/plugins/plugin_merge_settings.svelte";
|
||||
import type { Category } from "$lib/models/category";
|
||||
import type { PluginInstance } from "$lib/models/plugin_instance";
|
||||
import type { ConfigField, PluginProvider } from "$lib/models/plugin_provider";
|
||||
import { plugin_auth_validate, plugin_oauth_start } from "$lib/stores/plugin_instance_store";
|
||||
import { show_toast } from "$lib/stores/toast_store.svelte";
|
||||
import { translatePluginAPIError } from "$lib/util/plugin_error_i18n";
|
||||
import {
|
||||
configFieldDescription,
|
||||
configFieldLabel,
|
||||
configFieldOptionLabel,
|
||||
pluginTitle as localizedPluginTitle,
|
||||
providerCategoryLabel,
|
||||
} from "$lib/util/plugin_i18n";
|
||||
import { tick } from "svelte";
|
||||
import { _, locale } from "svelte-i18n";
|
||||
|
||||
interface CategoryMappingRow {
|
||||
providerCategory: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
type PluginInstanceForm = Partial<PluginInstance> & {
|
||||
mappingChanged?: boolean;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
plugin: PluginProvider;
|
||||
categories?: Category[];
|
||||
instance?: PluginInstance;
|
||||
onbeforecategorymappingsave?: (instance: PluginInstanceForm) => Promise<boolean> | boolean;
|
||||
onsave?: (instance: Partial<PluginInstance>) => Promise<PluginInstance | void> | PluginInstance | void;
|
||||
}
|
||||
|
||||
let { plugin, categories = [], instance, onbeforecategorymappingsave, onsave }: Props = $props();
|
||||
|
||||
let modal: Modal;
|
||||
let auth: Record<string, string> = $state(initialAuth());
|
||||
let planned = $state(true);
|
||||
let completed = $state(true);
|
||||
let mergeEnabled = $state(false);
|
||||
let privacy = $state("original");
|
||||
let categoryMappingRows: CategoryMappingRow[] = $state(initialCategoryMappingRows());
|
||||
let categoryMappingList: HTMLDivElement | undefined = $state();
|
||||
let authFields = $derived(plugin.auth.fields ?? []);
|
||||
let secretFields = $derived(new Set(plugin.auth.secretFields ?? plugin.auth.fields ?? []));
|
||||
let isOAuthPlugin = $derived(plugin.auth.type === "oauth2");
|
||||
let isSessionPlugin = $derived(plugin.auth.type === "session");
|
||||
let isConnected = $derived(instance?.status === "configured");
|
||||
let isSaving = $state(false);
|
||||
let needsOAuthConnect = $derived(isOAuthPlugin && (!isConnected || authChanged()));
|
||||
let plugin_id = $derived(plugin.id);
|
||||
let configSchema = $derived(plugin.configSchema ?? []);
|
||||
let visibleConfigSchema = $derived(configSchema.filter((field) => !field.hidden));
|
||||
let extraConfig: Record<string, any> = $state(initialExtraConfig());
|
||||
let configErrors: Record<string, string> = $state({});
|
||||
let supportsPlanned = $derived(
|
||||
plugin.capabilities?.includes("list_routes.v1") ?? false,
|
||||
);
|
||||
let supportsCompleted = $derived(
|
||||
plugin.capabilities?.includes("list_activities.v1") ?? false,
|
||||
);
|
||||
let hasTourKindChoice = $derived(supportsPlanned && supportsCompleted);
|
||||
let supportsSourcePrivacy = $derived(
|
||||
plugin.capabilities?.includes("source_privacy") ?? false,
|
||||
);
|
||||
let mergeAvailable = $derived((hostConfig().merge as any)?.available !== false);
|
||||
let supportsCategoryMapping = $derived(supportsPlanned || supportsCompleted);
|
||||
let categorySelectItems: SelectItem[] = $derived(
|
||||
categories
|
||||
.map((category) => ({
|
||||
text: $_(category.name),
|
||||
value: category.id,
|
||||
}))
|
||||
.sort((a, b) => a.text.localeCompare(b.text, $locale ?? undefined)),
|
||||
);
|
||||
let providerCategorySelectItems: SelectItem[] = $derived(providerCategoryItems());
|
||||
let canAddCategoryMappingRow = $derived(
|
||||
categoryMappingRows.every((row) => row.providerCategory && row.category) &&
|
||||
providerCategorySelectItems.some(
|
||||
(item) => !categoryMappingRows.some((row) => row.providerCategory === item.value),
|
||||
),
|
||||
);
|
||||
|
||||
const configLabels: Record<string, string> = {
|
||||
after: "ignore-trails-before-date",
|
||||
};
|
||||
const configHints: Record<string, string> = {
|
||||
after: "plugin-after-date-hint",
|
||||
};
|
||||
const privacySelectItems: SelectItem[] = [
|
||||
{ text: $_("keep-original"), value: "original" },
|
||||
{ text: $_("apply-user-settings"), value: "settings" },
|
||||
];
|
||||
const authLabels: Record<string, string> = {
|
||||
email: $_("email"),
|
||||
password: $_("password"),
|
||||
clientId: "Client ID",
|
||||
clientSecret: "Client Secret",
|
||||
};
|
||||
|
||||
function initialAuth() {
|
||||
return Object.fromEntries(
|
||||
(plugin.auth.fields ?? []).map((field) => [
|
||||
field,
|
||||
instance?.auth?.[field] == null ? "" : String(instance.auth[field]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function initialExtraConfig(): Record<string, any> {
|
||||
const config = pluginConfig();
|
||||
return Object.fromEntries(
|
||||
configSchema.map((field) => {
|
||||
const saved = config[field.key];
|
||||
if (saved !== undefined) return [field.key, saved];
|
||||
if (field.type === "boolean") {
|
||||
return [field.key, booleanDefault(field.default, false)];
|
||||
}
|
||||
if (field.default !== undefined && field.default !== null) {
|
||||
return [field.key, String(field.default)];
|
||||
}
|
||||
if (field.type === "select" && field.options?.length) {
|
||||
return [field.key, field.options[0].value];
|
||||
}
|
||||
if (field.type === "select") {
|
||||
return [field.key, ""];
|
||||
}
|
||||
if (field.type === "text" || field.type === "url") {
|
||||
return [field.key, ""];
|
||||
}
|
||||
return [field.key, undefined];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function initialCategoryMappingRows(): CategoryMappingRow[] {
|
||||
return Object.entries(categoryMapping())
|
||||
.filter(([, category]) => category !== "")
|
||||
.map(([providerCategory, category]) => ({
|
||||
providerCategory,
|
||||
category: categoryTargetValue(category),
|
||||
}));
|
||||
}
|
||||
|
||||
function manifestCategoryMapping(): Record<string, string> {
|
||||
const raw = plugin.hostConfig?.categoryMapping;
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return {};
|
||||
}
|
||||
return stringMapping(raw as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function categoryMapping(): Record<string, string> {
|
||||
const raw = hostConfig().categoryMapping;
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return {};
|
||||
}
|
||||
return stringMapping(raw as Record<string, unknown>);
|
||||
}
|
||||
|
||||
function categoryMappingsEqual(
|
||||
left: Record<string, string>,
|
||||
right: Record<string, string>,
|
||||
) {
|
||||
const leftKeys = Object.keys(left).sort();
|
||||
const rightKeys = Object.keys(right).sort();
|
||||
if (leftKeys.length !== rightKeys.length) {
|
||||
return false;
|
||||
}
|
||||
return leftKeys.every((key, index) => key === rightKeys[index] && left[key] === right[key]);
|
||||
}
|
||||
|
||||
function stringMapping(raw: Record<string, unknown>): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(raw)
|
||||
.filter(([, value]) => typeof value === "string")
|
||||
.map(([key, value]) => [key, value as string]),
|
||||
);
|
||||
}
|
||||
|
||||
function categoryTargetValue(value: string): string {
|
||||
const match = categories.find((category) => category.id === value || category.name === value);
|
||||
return match?.id ?? value;
|
||||
}
|
||||
|
||||
function providerCategoryItems(): SelectItem[] {
|
||||
const values = new Set<string>([
|
||||
...Object.keys(manifestCategoryMapping()),
|
||||
...Object.keys(categoryMapping()),
|
||||
...categoryMappingRows.map((row) => row.providerCategory).filter(Boolean),
|
||||
]);
|
||||
return [...values]
|
||||
.map((value) => ({
|
||||
text: providerCategoryLabel(plugin, value, $locale),
|
||||
value,
|
||||
}))
|
||||
.sort((a, b) => a.text.localeCompare(b.text, $locale ?? undefined));
|
||||
}
|
||||
|
||||
function providerCategoryItemsForRow(index: number): SelectItem[] {
|
||||
const currentValue = categoryMappingRows[index]?.providerCategory;
|
||||
const assignedValues = new Set(
|
||||
categoryMappingRows
|
||||
.filter((_, i) => i !== index)
|
||||
.map((row) => row.providerCategory)
|
||||
.filter(Boolean),
|
||||
);
|
||||
return providerCategorySelectItems.filter(
|
||||
(item) => item.value === currentValue || !assignedValues.has(item.value),
|
||||
);
|
||||
}
|
||||
|
||||
function booleanDefault(value: unknown, fallback: boolean): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "string") return value.trim().toLowerCase() === "true";
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function selectItems(field: ConfigField): SelectItem[] {
|
||||
return (field.options ?? []).map((o) => ({
|
||||
text: configFieldOptionLabel(o, $locale, $_(o.value)),
|
||||
value: o.value,
|
||||
}));
|
||||
}
|
||||
|
||||
function fieldLabel(field: ConfigField): string {
|
||||
return configFieldLabel(field, $locale, $_(configLabels[field.key] ?? field.key));
|
||||
}
|
||||
|
||||
function fieldHint(field: ConfigField): string | undefined {
|
||||
const description = configFieldDescription(field, $locale);
|
||||
if (description) return description;
|
||||
const hint = configHints[field.key];
|
||||
return hint ? $_(hint) : undefined;
|
||||
}
|
||||
|
||||
function fieldError(field: ConfigField): string {
|
||||
return configErrors[field.key] ?? "";
|
||||
}
|
||||
|
||||
async function addCategoryMappingRow() {
|
||||
const newIndex = categoryMappingRows.length;
|
||||
categoryMappingRows = [
|
||||
...categoryMappingRows,
|
||||
{
|
||||
providerCategory: "",
|
||||
category: "",
|
||||
},
|
||||
];
|
||||
await tick();
|
||||
categoryMappingList?.querySelector<HTMLElement>(`[data-category-mapping-row="${newIndex}"]`)
|
||||
?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
|
||||
function removeCategoryMappingRow(index: number) {
|
||||
categoryMappingRows = categoryMappingRows.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function validateConfig(): boolean {
|
||||
const errors: Record<string, string> = {};
|
||||
const hiddenMissing: ConfigField[] = [];
|
||||
for (const field of configSchema) {
|
||||
if (!field.required) continue;
|
||||
const value = extraConfig[field.key];
|
||||
if (value === undefined || value === null || value === "") {
|
||||
if (field.hidden) {
|
||||
hiddenMissing.push(field);
|
||||
continue;
|
||||
}
|
||||
errors[field.key] = $_("required");
|
||||
}
|
||||
}
|
||||
configErrors = errors;
|
||||
if (hiddenMissing.length > 0) {
|
||||
show_toast({
|
||||
text: `${hiddenMissing.map((field) => fieldLabel(field)).join(", ")}: ${$_("required")}`,
|
||||
icon: "close",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
return Object.keys(errors).length === 0 && hiddenMissing.length === 0;
|
||||
}
|
||||
|
||||
function authLabel(field: string): string {
|
||||
return authLabels[field] ?? field;
|
||||
}
|
||||
|
||||
function authFieldType(field: string): "password" | "text" {
|
||||
return secretFields.has(field) ? "password" : "text";
|
||||
}
|
||||
|
||||
function configSection(key: string): Record<string, any> {
|
||||
const section = instance?.config?.[key];
|
||||
if (section && typeof section === "object" && !Array.isArray(section)) {
|
||||
return section as Record<string, any>;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function pluginConfig(): Record<string, any> {
|
||||
return configSection("plugin");
|
||||
}
|
||||
|
||||
function hostConfig(): Record<string, any> {
|
||||
const manifestConfig = plugin.hostConfig ?? {};
|
||||
const instanceConfig = configSection("host");
|
||||
const manifestMerge =
|
||||
manifestConfig.merge && typeof manifestConfig.merge === "object"
|
||||
? (manifestConfig.merge as Record<string, unknown>)
|
||||
: {};
|
||||
const instanceMerge =
|
||||
instanceConfig.merge && typeof instanceConfig.merge === "object"
|
||||
? (instanceConfig.merge as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
...manifestConfig,
|
||||
...instanceConfig,
|
||||
merge: {
|
||||
...manifestMerge,
|
||||
...instanceMerge,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function authChanged() {
|
||||
for (const field of authFields) {
|
||||
const value = auth[field] ?? "";
|
||||
if (secretFields.has(field)) {
|
||||
if (value !== "") {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (value !== ((instance?.auth?.[field] as string | undefined) ?? "")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function openModal() {
|
||||
auth = initialAuth();
|
||||
const config = hostConfig();
|
||||
planned = supportsPlanned && (!hasTourKindChoice || ((config.planned as boolean | undefined) ?? true));
|
||||
completed =
|
||||
supportsCompleted && (!hasTourKindChoice || ((config.completed as boolean | undefined) ?? true));
|
||||
mergeEnabled = mergeAvailable && Boolean((config.merge as any)?.enabled);
|
||||
privacy = (config.privacy as string | undefined) ?? "original";
|
||||
extraConfig = initialExtraConfig();
|
||||
categoryMappingRows = initialCategoryMappingRows();
|
||||
configErrors = {};
|
||||
modal.openModal();
|
||||
}
|
||||
|
||||
function pluginInstanceFromForm(): PluginInstanceForm {
|
||||
const submittedAuth: Record<string, string> = {};
|
||||
for (const field of authFields) {
|
||||
submittedAuth[field] = auth[field] ?? "";
|
||||
}
|
||||
|
||||
const pluginRuntimeConfig: Record<string, unknown> = { ...pluginConfig() };
|
||||
const pluginHostConfig: Record<string, unknown> = { ...hostConfig() };
|
||||
if (supportsPlanned) {
|
||||
pluginHostConfig.planned = hasTourKindChoice ? planned : true;
|
||||
} else {
|
||||
delete pluginHostConfig.planned;
|
||||
}
|
||||
if (supportsCompleted) {
|
||||
pluginHostConfig.completed = hasTourKindChoice ? completed : true;
|
||||
} else {
|
||||
delete pluginHostConfig.completed;
|
||||
}
|
||||
if (supportsSourcePrivacy) {
|
||||
pluginHostConfig.privacy = privacy;
|
||||
}
|
||||
const currentMergeConfig =
|
||||
pluginHostConfig.merge && typeof pluginHostConfig.merge === "object"
|
||||
? (pluginHostConfig.merge as Record<string, unknown>)
|
||||
: {};
|
||||
pluginHostConfig.merge = {
|
||||
...currentMergeConfig,
|
||||
enabled: mergeAvailable && mergeEnabled,
|
||||
};
|
||||
const categoryMappingConfig: Record<string, string> = {};
|
||||
const assignedProviderCategories = new Set<string>();
|
||||
for (const row of categoryMappingRows) {
|
||||
const providerCategory = row.providerCategory.trim();
|
||||
if (!providerCategory || !row.category) {
|
||||
continue;
|
||||
}
|
||||
assignedProviderCategories.add(providerCategory);
|
||||
categoryMappingConfig[providerCategory] = row.category;
|
||||
}
|
||||
for (const providerCategory of [
|
||||
...Object.keys(manifestCategoryMapping()),
|
||||
...Object.keys(categoryMapping()),
|
||||
]) {
|
||||
if (!assignedProviderCategories.has(providerCategory)) {
|
||||
categoryMappingConfig[providerCategory] = "";
|
||||
}
|
||||
}
|
||||
const mappingChanged = !categoryMappingsEqual(categoryMapping(), categoryMappingConfig);
|
||||
if (mappingChanged) {
|
||||
pluginHostConfig.categoryMappingUpdatedAt = new Date().toISOString();
|
||||
}
|
||||
pluginHostConfig.categoryMapping = categoryMappingConfig;
|
||||
for (const field of configSchema) {
|
||||
const val = extraConfig[field.key];
|
||||
if (val !== undefined && val !== "") {
|
||||
pluginRuntimeConfig[field.key] = val;
|
||||
} else {
|
||||
delete pluginRuntimeConfig[field.key];
|
||||
}
|
||||
}
|
||||
const config: Record<string, unknown> = {
|
||||
plugin: pluginRuntimeConfig,
|
||||
host: pluginHostConfig,
|
||||
};
|
||||
|
||||
const status = isOAuthPlugin
|
||||
? !instance?.id
|
||||
? "needs_auth"
|
||||
: authChanged()
|
||||
? isConnected
|
||||
? "needs_reauth"
|
||||
: "needs_auth"
|
||||
: instance.status
|
||||
: instance?.enabled
|
||||
? "configured"
|
||||
: "disabled";
|
||||
|
||||
return {
|
||||
...instance,
|
||||
plugin_id,
|
||||
enabled: isOAuthPlugin && status !== "configured" ? false : (instance?.enabled ?? false),
|
||||
auth: submittedAuth,
|
||||
config,
|
||||
status,
|
||||
mappingChanged,
|
||||
};
|
||||
}
|
||||
|
||||
async function validateAuthIfNeeded() {
|
||||
if (!isSessionPlugin || !authChanged()) {
|
||||
return;
|
||||
}
|
||||
await plugin_auth_validate({
|
||||
pluginId: plugin.id,
|
||||
instanceId: instance?.id,
|
||||
auth,
|
||||
});
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!validateConfig()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
isSaving = true;
|
||||
await validateAuthIfNeeded();
|
||||
} catch (e) {
|
||||
show_toast({
|
||||
text: translatePluginAPIError(e, $_("error-setting-up-plugin", { values: { provider: pluginTitle() } })),
|
||||
icon: "close",
|
||||
type: "error",
|
||||
});
|
||||
isSaving = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const candidate = pluginInstanceFromForm();
|
||||
if (candidate.mappingChanged) {
|
||||
const shouldSave = await onbeforecategorymappingsave?.(candidate);
|
||||
if (shouldSave === false) {
|
||||
return;
|
||||
}
|
||||
delete candidate.mappingChanged;
|
||||
}
|
||||
await onsave?.(candidate as Partial<PluginInstance>);
|
||||
modal.closeModal();
|
||||
} catch {
|
||||
// onsave owns persistence error reporting so the modal does not
|
||||
// duplicate toasts.
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function pluginTitle() {
|
||||
return localizedPluginTitle(plugin, $locale);
|
||||
}
|
||||
|
||||
async function startOAuth() {
|
||||
if (!validateConfig()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const candidate = pluginInstanceFromForm();
|
||||
delete candidate.mappingChanged;
|
||||
const saved = await onsave?.(candidate);
|
||||
const instanceId = saved?.id ?? instance?.id;
|
||||
if (!instanceId) {
|
||||
return;
|
||||
}
|
||||
const redirectUri = `${window.location.origin}/settings/plugins/oauth/callback`;
|
||||
const result = await plugin_oauth_start({
|
||||
pluginId: plugin.id,
|
||||
instanceId,
|
||||
redirectUri,
|
||||
});
|
||||
sessionStorage.setItem(`wanderer_plugin_oauth_${result.state}`, result.instanceId);
|
||||
window.location.href = result.url;
|
||||
} catch (e) {
|
||||
show_toast({
|
||||
text: e instanceof Error ? e.message : $_("error-starting-oauth"),
|
||||
icon: "close",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
id="{plugin_id}-plugin-settings-modal"
|
||||
size="md:min-w-xl lg:min-w-2xl"
|
||||
title={pluginTitle() + " " + $_("settings")}
|
||||
bind:this={modal}
|
||||
>
|
||||
{#snippet content()}
|
||||
<form
|
||||
id="{plugin_id}-plugin-settings-form"
|
||||
class="space-y-3"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
{#each authFields as field}
|
||||
<TextField
|
||||
label={authLabel(field)}
|
||||
placeholder={secretFields.has(field)
|
||||
? instance?.id
|
||||
? `(${$_("unchanged")})`
|
||||
: ""
|
||||
: field == "email"
|
||||
? "user@example.com"
|
||||
: ""}
|
||||
bind:value={auth[field]}
|
||||
name={field}
|
||||
type={authFieldType(field)}
|
||||
></TextField>
|
||||
{/each}
|
||||
|
||||
{#if isOAuthPlugin}
|
||||
<p class="text-xs text-gray-500 max-w-lg">
|
||||
{#if isConnected}
|
||||
{$_("plugin-oauth-connected-hint")}
|
||||
{:else}
|
||||
{$_("plugin-oauth-needs-connect-hint")}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if hasTourKindChoice}
|
||||
<div class="flex flex-wrap gap-x-4">
|
||||
<Toggle
|
||||
bind:value={planned}
|
||||
label={$_("planned-tours", { values: { n: 2 } })}
|
||||
></Toggle>
|
||||
<Toggle
|
||||
bind:value={completed}
|
||||
label={$_("completed-tours", { values: { n: 2 } })}
|
||||
></Toggle>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if supportsSourcePrivacy}
|
||||
<Select
|
||||
label={$_("privacy")}
|
||||
items={privacySelectItems}
|
||||
bind:value={privacy}
|
||||
></Select>
|
||||
<p class="text-xs text-gray-500 max-w-lg">
|
||||
{#if privacy == "original"}
|
||||
{$_("plugin-privacy-hint-original")}
|
||||
{:else}
|
||||
{$_("plugin-privacy-hint-user")}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#each visibleConfigSchema as field}
|
||||
{#if field.type === "select"}
|
||||
<Select
|
||||
label={fieldLabel(field)}
|
||||
items={selectItems(field)}
|
||||
bind:value={extraConfig[field.key] as string}
|
||||
error={fieldError(field)}
|
||||
></Select>
|
||||
{:else if field.type === "boolean"}
|
||||
<Toggle
|
||||
bind:value={extraConfig[field.key]}
|
||||
label={fieldLabel(field)}
|
||||
error={fieldError(field)}
|
||||
></Toggle>
|
||||
{@const hint = fieldHint(field)}
|
||||
{#if hint}
|
||||
<p class="text-xs text-gray-500 max-w-lg">{hint}</p>
|
||||
{/if}
|
||||
{:else if field.type === "date"}
|
||||
{@const hint = fieldHint(field)}
|
||||
{#if hint}
|
||||
<p
|
||||
class="text-xs text-gray-500 max-w-lg pt-4 pb-1 border-t border-input-border"
|
||||
>
|
||||
{hint}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="flex items-end relative gap-x-2">
|
||||
<Datepicker
|
||||
label={fieldLabel(field)}
|
||||
bind:value={extraConfig[field.key]}
|
||||
error={fieldError(field)}
|
||||
></Datepicker>
|
||||
<button
|
||||
class="btn-icon mb-[10px]"
|
||||
type="button"
|
||||
onclick={() => {
|
||||
extraConfig[field.key] = undefined;
|
||||
}}
|
||||
aria-label={$_("clear")}
|
||||
><i class="fa fa-close"></i></button>
|
||||
</div>
|
||||
{:else if field.type === "text" || field.type === "url"}
|
||||
<TextField
|
||||
label={fieldLabel(field)}
|
||||
bind:value={extraConfig[field.key]}
|
||||
name={field.key}
|
||||
type={field.type === "url" ? "url" : "text"}
|
||||
error={fieldError(field)}
|
||||
></TextField>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if supportsCategoryMapping && categorySelectItems.length > 0}
|
||||
<div class="space-y-2 pt-4 border-t border-input-border">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium">{$_("category-mapping")}</h4>
|
||||
<p class="text-xs text-secondary mt-1">
|
||||
{$_("category-mapping-help")}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn-primary text-sm"
|
||||
class:btn-disabled={!canAddCategoryMappingRow}
|
||||
type="button"
|
||||
disabled={!canAddCategoryMappingRow}
|
||||
onclick={addCategoryMappingRow}
|
||||
>{$_("add-entry")}</button>
|
||||
</div>
|
||||
{#if categoryMappingRows.length > 0}
|
||||
<div class="hidden md:grid grid-cols-[minmax(0,1.2fr)_minmax(14rem,1fr)_2.75rem] gap-3 text-sm font-medium">
|
||||
<span>{$_("provider-category")}</span>
|
||||
<span>{$_("category")}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div
|
||||
bind:this={categoryMappingList}
|
||||
class="space-y-3 pr-1 scroll-smooth"
|
||||
class:max-h-[300px]={categoryMappingRows.length > 6}
|
||||
class:overflow-y-auto={categoryMappingRows.length > 6}
|
||||
class:overflow-y-visible={categoryMappingRows.length <= 6}
|
||||
>
|
||||
{#each categoryMappingRows as row, i}
|
||||
{@const providerItems = providerCategoryItemsForRow(i)}
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-[minmax(0,1.2fr)_minmax(14rem,1fr)_2.75rem] items-end gap-3"
|
||||
data-category-mapping-row={i}
|
||||
>
|
||||
<SingleSelect
|
||||
ariaLabel={$_("provider-category")}
|
||||
placeholder={$_("select-provider-category")}
|
||||
items={providerItems}
|
||||
bind:value={row.providerCategory}
|
||||
disabled={row.providerCategory !== "" && providerItems.length <= 1}
|
||||
></SingleSelect>
|
||||
<SingleSelect
|
||||
ariaLabel={$_("category")}
|
||||
placeholder={$_("select-category")}
|
||||
items={categorySelectItems}
|
||||
bind:value={row.category}
|
||||
disabled={row.category !== "" && categorySelectItems.length <= 1}
|
||||
></SingleSelect>
|
||||
<button
|
||||
class="btn-icon h-10"
|
||||
type="button"
|
||||
onclick={() => removeCategoryMappingRow(i)}
|
||||
aria-label={$_("remove")}
|
||||
><i class="fa fa-close"></i></button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mergeAvailable}
|
||||
<PluginMergeSettings prefix="merge" bind:value={mergeEnabled} />
|
||||
{/if}
|
||||
</form>
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="btn-secondary" onclick={() => modal.closeModal()} disabled={isSaving}
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
{#if isOAuthPlugin}
|
||||
{#if needsOAuthConnect}
|
||||
<button class="btn-primary" type="button" onclick={startOAuth} disabled={isSaving}
|
||||
>{isConnected ? $_("save-and-reconnect") : $_("save-and-connect")}</button
|
||||
>
|
||||
{:else}
|
||||
<button
|
||||
class="btn-primary"
|
||||
form="{plugin_id}-plugin-settings-form"
|
||||
type="submit"
|
||||
name="save"
|
||||
disabled={isSaving}>{$_("save")}</button
|
||||
>
|
||||
{/if}
|
||||
{:else}
|
||||
<button
|
||||
class="btn-primary"
|
||||
form="{plugin_id}-plugin-settings-form"
|
||||
type="submit"
|
||||
name="save"
|
||||
disabled={isSaving}
|
||||
>{isSessionPlugin && authChanged() ? $_("save-and-validate") : $_("save")}</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
@@ -4,17 +4,19 @@
|
||||
|
||||
interface Props {
|
||||
prefix: string;
|
||||
value?: boolean;
|
||||
}
|
||||
|
||||
let { prefix }: Props = $props();
|
||||
let { prefix, value = $bindable(false) }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="pt-4 border-t border-input-border space-y-2">
|
||||
<Toggle
|
||||
name={`${prefix}.enabled`}
|
||||
label={$_("integration-auto-merge-label")}
|
||||
bind:value
|
||||
label={$_("plugin-auto-merge-label")}
|
||||
/>
|
||||
<p class="text-xs text-gray-500 max-w-lg">
|
||||
{$_("integration-auto-merge-hint")}
|
||||
{$_("plugin-auto-merge-hint")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -55,7 +55,7 @@
|
||||
alt="avatar"
|
||||
/>
|
||||
<p class="font-semibold text-base">
|
||||
{`@${share.expand.actor.username}${share.expand.actor.isLocal ? "" : "@" + share.expand.actor.domain}`}
|
||||
{`@${share.expand.actor.username}${share.expand.actor.is_local ? "" : "@" + share.expand.actor.domain}`}
|
||||
</p>
|
||||
<span class="mx-2 text-sm text-gray-500"
|
||||
>{$_("can")}</span
|
||||
|
||||
@@ -190,13 +190,13 @@
|
||||
<p
|
||||
class="tooltip flex justify-center"
|
||||
data-title="@{log.expand.author.preferred_username}{log.expand.author
|
||||
.isLocal
|
||||
.is_local
|
||||
? ''
|
||||
: '@' + log.expand.author.domain}"
|
||||
>
|
||||
<a
|
||||
href="/profile/@{log.expand.author.preferred_username?.toLowerCase()}{log
|
||||
.expand.author.isLocal
|
||||
.expand.author.is_local
|
||||
? ''
|
||||
: '@' + log.expand.author.domain}"
|
||||
>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import { ClusterLayer } from "$lib/vendor/maplibre-layer-manager/cluster-layer";
|
||||
import { baseMapStyles } from "$lib/vendor/maplibre-layer-manager/layers";
|
||||
import { LayerManager } from "$lib/vendor/maplibre-layer-manager/maplibre-layer-manager";
|
||||
import type { OverpassPopupActionFactory } from "$lib/vendor/maplibre-layer-manager/overpass-layer";
|
||||
import { PreviewLayer } from "$lib/vendor/maplibre-layer-manager/preview-layer";
|
||||
import { TerrainLayer } from "$lib/vendor/maplibre-layer-manager/terrain-layer";
|
||||
import { TrailLayer } from "$lib/vendor/maplibre-layer-manager/trail-layer";
|
||||
@@ -30,11 +31,13 @@
|
||||
|
||||
interface Props {
|
||||
trails?: Trail[];
|
||||
serverClusters?: GeoJSON.FeatureCollection;
|
||||
gpx?: GPX;
|
||||
waypoints?: Waypoint[];
|
||||
markers?: M.Marker[];
|
||||
map?: M.Map | null;
|
||||
drawing?: boolean;
|
||||
displayWaypoints?: boolean;
|
||||
showElevation?: boolean;
|
||||
showInfoPopup?: boolean;
|
||||
showGrid?: boolean;
|
||||
@@ -63,20 +66,24 @@
|
||||
onmoveend?: (map: M.Map) => void;
|
||||
onzoom?: (map: M.Map) => void;
|
||||
onclick?: (event: M.MapMouseEvent & Object) => void;
|
||||
oncontextmenu?: (event: M.MapMouseEvent & Object) => void;
|
||||
onUnclusteredClick?: (
|
||||
event: M.MapMouseEvent & Object,
|
||||
trail: Trail,
|
||||
) => void;
|
||||
oninit?: (map: M.Map) => void;
|
||||
autoGeolocateOnDrawing?: boolean;
|
||||
buildPoiAnchorAction?: OverpassPopupActionFactory;
|
||||
}
|
||||
|
||||
let {
|
||||
trails = [],
|
||||
serverClusters = undefined,
|
||||
waypoints = [],
|
||||
markers = $bindable([]),
|
||||
map = $bindable(),
|
||||
drawing = false,
|
||||
displayWaypoints = true,
|
||||
showElevation = true,
|
||||
showInfoPopup = false,
|
||||
showGrid = false,
|
||||
@@ -97,9 +104,11 @@
|
||||
onmoveend,
|
||||
onzoom,
|
||||
onclick,
|
||||
oncontextmenu,
|
||||
onUnclusteredClick,
|
||||
oninit,
|
||||
autoGeolocateOnDrawing = false,
|
||||
buildPoiAnchorAction = undefined,
|
||||
}: Props = $props();
|
||||
|
||||
let mapContainer: HTMLDivElement;
|
||||
@@ -114,7 +123,7 @@
|
||||
|
||||
let hoveringTrail: boolean = false;
|
||||
|
||||
let mapLoaded: boolean = false;
|
||||
let mapLoaded: boolean = $state(false);
|
||||
let terrainEnabled: boolean | null = null;
|
||||
|
||||
const trailColors = [
|
||||
@@ -132,9 +141,16 @@
|
||||
|
||||
let clusterPopup: M.Popup | null = null;
|
||||
|
||||
let [data, clusterData, previewData] = $derived(getData(trails));
|
||||
let mapData = $derived(getData(trails, serverClusters));
|
||||
let gpxDataMap = $derived(mapData[0]);
|
||||
let clusterData = $derived(mapData[1]);
|
||||
let previewData = $derived(mapData[2]);
|
||||
|
||||
$effect(() => {
|
||||
if (data && map && mapLoaded) {
|
||||
// Track dependencies for Svelte 5
|
||||
mapData;
|
||||
|
||||
if (map && mapLoaded) {
|
||||
untrack(() => initMap(map?.loaded() ?? false));
|
||||
}
|
||||
});
|
||||
@@ -178,16 +194,22 @@
|
||||
});
|
||||
$effect(() => {
|
||||
waypoints;
|
||||
displayWaypoints;
|
||||
untrack(() => {
|
||||
showWaypoints();
|
||||
syncWaypointMarkers();
|
||||
refreshElevationProfile();
|
||||
});
|
||||
});
|
||||
|
||||
function getData(
|
||||
trails: Trail[],
|
||||
): [FeatureCollection[], FeatureCollection, FeatureCollection] {
|
||||
let clusterData: FeatureCollection = {
|
||||
serverClusters?: GeoJSON.FeatureCollection
|
||||
): [
|
||||
Record<string, FeatureCollection>,
|
||||
FeatureCollection,
|
||||
FeatureCollection,
|
||||
] {
|
||||
let clusterData: FeatureCollection = serverClusters ?? {
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
};
|
||||
@@ -195,21 +217,36 @@
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
};
|
||||
let r: FeatureCollection[] = [];
|
||||
let gpxDataMap: Record<string, FeatureCollection> = {};
|
||||
|
||||
trails.forEach((t, i) => {
|
||||
if (t.expand?.gpx) {
|
||||
r.push(t.expand.gpx.toGeoJSON());
|
||||
} else if (t.expand?.gpx_data) {
|
||||
r.push(GPX.parse(t.expand.gpx_data).toGeoJSON());
|
||||
trails.forEach((t) => {
|
||||
if (t.id) {
|
||||
let fc: FeatureCollection | null = null;
|
||||
if (t.expand?.gpx) {
|
||||
fc = t.expand.gpx.toGeoJSON();
|
||||
} else if (t.expand?.gpx_data) {
|
||||
fc = GPX.parse(t.expand.gpx_data).toGeoJSON();
|
||||
}
|
||||
|
||||
if (fc) {
|
||||
fc.features.forEach((f) => {
|
||||
if (f.properties) {
|
||||
f.properties.bounding_box_diagonal =
|
||||
t.bounding_box_diagonal;
|
||||
}
|
||||
});
|
||||
gpxDataMap[t.id] = fc;
|
||||
}
|
||||
}
|
||||
|
||||
if (clusterTrails) {
|
||||
if (t.lat !== null && t.lon !== null) {
|
||||
if (!serverClusters && t.lat !== undefined && t.lon !== undefined) {
|
||||
clusterData.features.push({
|
||||
id: t.id,
|
||||
type: "Feature",
|
||||
properties: {
|
||||
trail: t.id,
|
||||
bounding_box_diagonal: t.bounding_box_diagonal,
|
||||
},
|
||||
geometry: {
|
||||
type: "Point",
|
||||
@@ -224,6 +261,7 @@
|
||||
type: "Feature",
|
||||
properties: {
|
||||
trail: t.id,
|
||||
bounding_box_diagonal: t.bounding_box_diagonal,
|
||||
color: trailColors[
|
||||
hashStringToIndex(
|
||||
t.id ?? "",
|
||||
@@ -240,7 +278,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
return [r, clusterData, previewData];
|
||||
return [gpxDataMap, clusterData, previewData];
|
||||
}
|
||||
|
||||
function initMap(mapLoaded: boolean) {
|
||||
@@ -249,20 +287,20 @@
|
||||
}
|
||||
|
||||
refreshElevationProfile();
|
||||
if (showElevation && data.length && activeTrail !== null) {
|
||||
if (
|
||||
showElevation &&
|
||||
Object.keys(gpxDataMap).length &&
|
||||
activeTrail !== null
|
||||
) {
|
||||
epc?.showProfile();
|
||||
} else {
|
||||
epc?.hideProfile();
|
||||
}
|
||||
|
||||
trails.forEach((t, i) => {
|
||||
trails.forEach((t) => {
|
||||
const layerId = t.id!;
|
||||
addTrailLayer(t, layerId, i, data[i]);
|
||||
addTrailLayer(t, layerId, 0, gpxDataMap[layerId]);
|
||||
});
|
||||
if (clusterTrails) {
|
||||
addClusterLayer(clusterData);
|
||||
addPreviewLayer(previewData);
|
||||
}
|
||||
|
||||
Object.entries(layerManager.layers).forEach(([id, layer]) => {
|
||||
if (!(layer instanceof TrailLayer)) {
|
||||
@@ -275,18 +313,31 @@
|
||||
}
|
||||
});
|
||||
|
||||
if (
|
||||
!drawing &&
|
||||
fitBounds !== "off" &&
|
||||
data.some((d) => d.bbox !== undefined)
|
||||
) {
|
||||
if (activeTrail !== null && trails[activeTrail] && mapLoaded) {
|
||||
if (clusterTrails) {
|
||||
addPreviewLayer(previewData);
|
||||
addClusterLayer(clusterData);
|
||||
}
|
||||
|
||||
if (!drawing && fitBounds !== "off") {
|
||||
const currentBboxes = Object.values(gpxDataMap)
|
||||
.map((d) => d.bbox)
|
||||
.filter((b) => b !== undefined);
|
||||
|
||||
if (
|
||||
activeTrail !== null &&
|
||||
trails[activeTrail] &&
|
||||
mapLoaded &&
|
||||
gpxDataMap[trails[activeTrail].id!]
|
||||
) {
|
||||
focusTrail(trails[activeTrail]);
|
||||
} else {
|
||||
} else if (currentBboxes.length > 0) {
|
||||
flyToBounds();
|
||||
}
|
||||
} else if (drawing && activeTrail !== null && mapLoaded) {
|
||||
addCaretLayer(data[activeTrail]);
|
||||
const activeId = trails[activeTrail]?.id;
|
||||
if (activeId && gpxDataMap[activeId]) {
|
||||
addCaretLayer(gpxDataMap[activeId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,8 +361,9 @@
|
||||
}
|
||||
|
||||
export function refreshElevationProfile() {
|
||||
if (activeTrail !== null && data[activeTrail]) {
|
||||
epc?.setData(data[activeTrail]!, waypoints);
|
||||
const activeId = activeTrail !== null ? trails[activeTrail]?.id : null;
|
||||
if (activeId && gpxDataMap[activeId]) {
|
||||
epc?.setData(gpxDataMap[activeId]!, waypoints);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,7 +373,7 @@
|
||||
maxX = -Infinity,
|
||||
maxY = -Infinity;
|
||||
|
||||
for (const [xMin, yMin, xMax, yMax] of data
|
||||
for (const [xMin, yMin, xMax, yMax] of Object.values(gpxDataMap)
|
||||
.filter((d) => d.bbox !== undefined)
|
||||
.map((d) => d.bbox!)) {
|
||||
minX = Math.min(minX, xMin);
|
||||
@@ -343,9 +395,10 @@
|
||||
}
|
||||
|
||||
function flyToBounds() {
|
||||
const activeId = activeTrail !== null ? trails[activeTrail]?.id : null;
|
||||
const bounds =
|
||||
activeTrail !== null && data[activeTrail]
|
||||
? (data[activeTrail].bbox as M.LngLatBoundsLike)
|
||||
activeId && gpxDataMap[activeId]
|
||||
? (gpxDataMap[activeId].bbox as M.LngLatBoundsLike)
|
||||
: getBounds();
|
||||
|
||||
if (!bounds || !map) {
|
||||
@@ -386,18 +439,20 @@
|
||||
trailColors[
|
||||
clusterTrails
|
||||
? hashStringToIndex(id ?? "", trailColors.length)
|
||||
: 0
|
||||
: index % trailColors.length
|
||||
],
|
||||
{
|
||||
onEnter: (e) =>
|
||||
highlightTrail(id, trails[activeTrail ?? -1]?.id == id),
|
||||
listeners: {
|
||||
onEnter: (e) =>
|
||||
highlightTrail(id, trails[activeTrail ?? -1]?.id == id),
|
||||
|
||||
onLeave: (e) => unHighlightTrail(id),
|
||||
onMouseUp: (e) => {
|
||||
activeTrail = trails.findIndex((t) => t.id == trail.id);
|
||||
onLeave: (e) => unHighlightTrail(id),
|
||||
onMouseUp: (e) => {
|
||||
activeTrail = trails.findIndex((t) => t.id == trail.id);
|
||||
},
|
||||
onMouseMove: moveCrosshairToCursorPosition,
|
||||
onMouseDown: (e) => handleDragStart(e, id),
|
||||
},
|
||||
onMouseMove: moveCrosshairToCursorPosition,
|
||||
onMouseDown: (e) => handleDragStart(e, id),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -412,7 +467,20 @@
|
||||
if (!geojson || !map || !map.style) {
|
||||
return;
|
||||
}
|
||||
layerManager.addLayer("clusters", new ClusterLayer(map, geojson));
|
||||
layerManager.addLayer(
|
||||
"clusters",
|
||||
new ClusterLayer(map, geojson, {
|
||||
"unclustered-point": {
|
||||
onEnter: (e) => {
|
||||
if (map) map.getCanvas().style.cursor = "pointer";
|
||||
const id = (e as any).features[0].properties.id;
|
||||
const trail = trails.find((t) => t.id === id);
|
||||
if (!hasTrailDetails(trail)) return;
|
||||
highlightCluster(trail, e.lngLat);
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function addPreviewLayer(geojson: FeatureCollection) {
|
||||
@@ -422,18 +490,19 @@
|
||||
layerManager.addLayer(
|
||||
"preview",
|
||||
new PreviewLayer(map, geojson, {
|
||||
preview: {
|
||||
onEnter: (e) => {
|
||||
const trail = trails.find(
|
||||
(t) =>
|
||||
t.id ===
|
||||
(e as any).features[0].properties.trail,
|
||||
);
|
||||
if (!trail) return;
|
||||
highlightCluster(trail, e.lngLat);
|
||||
},
|
||||
onLeave: (e) => {
|
||||
// unHighlightCluster();
|
||||
showStartMarker: page.data.settings?.behavior?.showTrailStartMarker ?? false,
|
||||
listeners: {
|
||||
preview: {
|
||||
onEnter: (e) => {
|
||||
if (map) map.getCanvas().style.cursor = "pointer";
|
||||
const trail = trails.find(
|
||||
(t) =>
|
||||
t.id ===
|
||||
(e as any).features[0].properties.trail,
|
||||
);
|
||||
if (!hasTrailDetails(trail)) return;
|
||||
highlightCluster(trail, e.lngLat);
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -534,11 +603,15 @@
|
||||
// map?.setPaintProperty(id, "line-color", "#648ad5");
|
||||
}
|
||||
|
||||
function hasTrailDetails(trail: Trail | undefined): trail is Trail {
|
||||
return Boolean(trail?.name?.trim());
|
||||
}
|
||||
|
||||
export async function highlightCluster(
|
||||
trail: Trail,
|
||||
lnglat?: M.LngLatLike,
|
||||
) {
|
||||
if (!map || !map.style) {
|
||||
if (!map || !map.style || !hasTrailDetails(trail)) {
|
||||
return;
|
||||
}
|
||||
clusterPopup?.remove();
|
||||
@@ -578,7 +651,7 @@
|
||||
if (
|
||||
!drawing &&
|
||||
fitBounds !== "off" &&
|
||||
data.some((d) => d.bbox !== undefined)
|
||||
Object.values(gpxDataMap).some((d) => d.bbox !== undefined)
|
||||
) {
|
||||
untrack(() => focusTrail(trails[activeTrail]));
|
||||
}
|
||||
@@ -600,8 +673,10 @@
|
||||
if (showElevation) {
|
||||
epc?.showProfile();
|
||||
}
|
||||
showWaypoints();
|
||||
addCaretLayer(data[activeTrail]);
|
||||
syncWaypointMarkers();
|
||||
if (trail.id && gpxDataMap[trail.id]) {
|
||||
addCaretLayer(gpxDataMap[trail.id]);
|
||||
}
|
||||
flyToBounds();
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
@@ -628,7 +703,6 @@
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
hideWaypoints();
|
||||
activeTrail ??= 0;
|
||||
map.getCanvas().style.cursor = "crosshair";
|
||||
if (trails[activeTrail]) {
|
||||
@@ -644,14 +718,15 @@
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
showWaypoints();
|
||||
syncWaypointMarkers();
|
||||
map.getCanvas().style.cursor = "inherit";
|
||||
|
||||
if (activeTrail !== null && trails[activeTrail] && !clusterTrails) {
|
||||
const activeId = trails[activeTrail].id;
|
||||
addStartEndMarkers(
|
||||
trails[activeTrail],
|
||||
trails[activeTrail].id,
|
||||
data[activeTrail],
|
||||
activeId,
|
||||
activeId ? gpxDataMap[activeId] : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -721,7 +796,7 @@
|
||||
}
|
||||
|
||||
function showWaypoints() {
|
||||
if (!map || drawing) {
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -745,6 +820,14 @@
|
||||
});
|
||||
}
|
||||
|
||||
function syncWaypointMarkers() {
|
||||
if (displayWaypoints) {
|
||||
showWaypoints();
|
||||
} else {
|
||||
hideWaypoints();
|
||||
}
|
||||
}
|
||||
|
||||
function hideWaypoints() {
|
||||
if (!map) {
|
||||
return;
|
||||
@@ -809,7 +892,7 @@
|
||||
};
|
||||
map = new M.Map(finalMapOptions);
|
||||
|
||||
layerManager = new LayerManager(map);
|
||||
layerManager = new LayerManager(map, { overpassActionFactory: buildPoiAnchorAction });
|
||||
|
||||
elevationMarker = new FontawesomeMarker(
|
||||
{
|
||||
@@ -944,6 +1027,10 @@
|
||||
onclick?.(e);
|
||||
});
|
||||
|
||||
map.on("contextmenu", (e) => {
|
||||
oncontextmenu?.(e);
|
||||
});
|
||||
|
||||
map.on("load", () => {
|
||||
layerManager.init();
|
||||
initMap(true);
|
||||
@@ -951,7 +1038,7 @@
|
||||
mapLoaded = true;
|
||||
});
|
||||
|
||||
showWaypoints();
|
||||
syncWaypointMarkers();
|
||||
});
|
||||
|
||||
function geolocate() {
|
||||
@@ -1003,8 +1090,9 @@
|
||||
|
||||
if (e.key == "m") {
|
||||
if (trails.length === 1) {
|
||||
addTrailLayer(trails[0], trails[0].id!, 0, data[0]);
|
||||
addCaretLayer(data[0]);
|
||||
const trailId = trails[0].id!;
|
||||
addTrailLayer(trails[0], trailId, 0, gpxDataMap[trailId]);
|
||||
addCaretLayer(gpxDataMap[trailId]);
|
||||
}
|
||||
} else if (e.key == "p") {
|
||||
if (showElevation) {
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
onRecalculateElevationData: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
showWaypoints?: boolean;
|
||||
resetLabel?: string;
|
||||
resetAriaLabel?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -35,6 +38,9 @@
|
||||
onRecalculateElevationData,
|
||||
onUndo,
|
||||
onRedo,
|
||||
showWaypoints = $bindable(true),
|
||||
resetLabel = "reset",
|
||||
resetAriaLabel = "reset-route",
|
||||
}: Props = $props();
|
||||
|
||||
const modesOfTransport: SelectItem[] = [
|
||||
@@ -145,6 +151,22 @@
|
||||
aria-label="recalculate elevation data"
|
||||
onclick={async () => await togglePanels(false, false, !recalculateElevationData)}><i class="fa fa-mountain text-sm"></i></button
|
||||
>
|
||||
<button
|
||||
class="btn-icon tooltip"
|
||||
class:bg-secondary-hover={showWaypoints}
|
||||
type="button"
|
||||
aria-label={$_("waypoints", { values: { n: 2 } })}
|
||||
data-title={$_("waypoints", { values: { n: 2 } })}
|
||||
onclick={() => (showWaypoints = !showWaypoints)}
|
||||
><i class="fa fa-location-dot text-sm"></i></button
|
||||
>
|
||||
<button
|
||||
class="btn-icon tooltip hover:text-red-500"
|
||||
type="button"
|
||||
onclick={() => onReset()}
|
||||
aria-label={$_(resetAriaLabel)}
|
||||
data-title={$_(resetLabel)}><i class="fa fa-trash text-sm"></i></button
|
||||
>
|
||||
<button
|
||||
class="btn-icon"
|
||||
class:text-gray-500={valhallaStore.undoStack.length == 0}
|
||||
@@ -182,13 +204,6 @@
|
||||
data-title={$_("reverse-direction")}
|
||||
><i class="fa fa-arrow-right-arrow-left"></i></button
|
||||
>
|
||||
<button
|
||||
class="btn-icon tooltip"
|
||||
type="button"
|
||||
onclick={() => onReset()}
|
||||
aria-label="Reset route"
|
||||
data-title={$_("reset")}><i class="fa fa-trash"></i></button
|
||||
>
|
||||
<button
|
||||
class="btn-icon tooltip"
|
||||
type="button"
|
||||
|
||||
702
web/src/lib/components/trail/trail_anchor_list.svelte
Normal file
702
web/src/lib/components/trail/trail_anchor_list.svelte
Normal file
@@ -0,0 +1,702 @@
|
||||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import GpxMetricsComputation from "$lib/models/gpx/gpx-metrics-computation";
|
||||
import type TrackSegment from "$lib/models/gpx/track-segment";
|
||||
import type { ValhallaAnchor } from "$lib/models/valhalla";
|
||||
import {
|
||||
searchLocationReverseStructured,
|
||||
type ReverseLocationResult,
|
||||
} from "$lib/stores/search_store";
|
||||
import {
|
||||
formatDistance,
|
||||
formatElevation,
|
||||
} from "$lib/util/format_util";
|
||||
import { valhallaAnchorDisplay, valhallaAnchorTitle } from "$lib/util/valhalla_anchor_util";
|
||||
|
||||
interface Props {
|
||||
anchors: ValhallaAnchor[];
|
||||
segments?: TrackSegment[];
|
||||
disabled?: boolean;
|
||||
onMove: (fromIndex: number, toIndex: number) => void | Promise<void>;
|
||||
onDelete: (index: number) => void;
|
||||
onHover?: (index: number | null) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
anchors,
|
||||
segments = [],
|
||||
disabled = false,
|
||||
onMove,
|
||||
onDelete,
|
||||
onHover,
|
||||
}: Props = $props();
|
||||
|
||||
const anchorCoordinates = (anchor: ValhallaAnchor) =>
|
||||
`${anchor.lat.toFixed(4)}, ${anchor.lon.toFixed(4)}`;
|
||||
|
||||
function fallbackAnchorTitle(index: number) {
|
||||
return valhallaAnchorTitle(index, anchors.length, $_);
|
||||
}
|
||||
|
||||
const locationCache = new Map<string, ReverseLocationResult>();
|
||||
const pendingLocationRequests = new Set<string>();
|
||||
let locations = $state<Record<string, ReverseLocationResult>>({});
|
||||
let locationAbortController: AbortController | null = null;
|
||||
|
||||
const commonAnchorCountry = $derived.by(() => {
|
||||
const countries = anchors
|
||||
.map((anchor) => locations[locationCacheKey(anchor)]?.country)
|
||||
.filter((country): country is string => Boolean(country));
|
||||
|
||||
if (countries.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [country] = countries;
|
||||
return countries.every((nextCountry) => nextCountry === country) ? country : null;
|
||||
});
|
||||
|
||||
function locationCacheKey(anchor: ValhallaAnchor) {
|
||||
return `${anchor.lat.toFixed(5)},${anchor.lon.toFixed(5)}`;
|
||||
}
|
||||
|
||||
async function loadAnchorLocation(anchor: ValhallaAnchor, signal: AbortSignal) {
|
||||
const key = locationCacheKey(anchor);
|
||||
if (locations[key] || pendingLocationRequests.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = locationCache.get(key);
|
||||
if (cached) {
|
||||
locations = { ...locations, [key]: cached };
|
||||
return;
|
||||
}
|
||||
|
||||
pendingLocationRequests.add(key);
|
||||
try {
|
||||
const location = await searchLocationReverseStructured(anchor.lat, anchor.lon, {
|
||||
includeRoad: true,
|
||||
signal,
|
||||
});
|
||||
if (location) {
|
||||
locationCache.set(key, location);
|
||||
locations = { ...locations, [key]: location };
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof DOMException && error.name === "AbortError")) {
|
||||
console.error("Failed to resolve anchor location", error);
|
||||
}
|
||||
} finally {
|
||||
pendingLocationRequests.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAnchorLocations(nextAnchors: ValhallaAnchor[]) {
|
||||
locationAbortController?.abort();
|
||||
const controller = new AbortController();
|
||||
locationAbortController = controller;
|
||||
|
||||
for (const anchor of nextAnchors) {
|
||||
if (controller.signal.aborted) return;
|
||||
await loadAnchorLocation(anchor, controller.signal);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void loadAnchorLocations(anchors);
|
||||
return () => locationAbortController?.abort();
|
||||
});
|
||||
|
||||
function anchorTitle(anchor: ValhallaAnchor, index: number) {
|
||||
const location = locations[locationCacheKey(anchor)];
|
||||
if (!location) {
|
||||
return fallbackAnchorTitle(index);
|
||||
}
|
||||
|
||||
return commonAnchorCountry && location.country === commonAnchorCountry
|
||||
? location.label
|
||||
: location.fullLabel;
|
||||
}
|
||||
|
||||
function updateOverflowingStats(element: HTMLElement) {
|
||||
requestAnimationFrame(() => {
|
||||
const stats = element.querySelectorAll<HTMLElement>(".stats-viewport");
|
||||
for (const stat of stats) {
|
||||
const content = stat.querySelector<HTMLElement>(".stats-content");
|
||||
if (!content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const overflow = Math.max(0, content.scrollWidth - stat.clientWidth);
|
||||
if (overflow <= 0) {
|
||||
stat.classList.remove("is-overflowing");
|
||||
stat.style.removeProperty("--stats-scroll-distance");
|
||||
stat.style.removeProperty("--stats-scroll-duration");
|
||||
continue;
|
||||
}
|
||||
|
||||
const duration = Math.min(5, Math.max(1.6, overflow / 28));
|
||||
stat.style.setProperty("--stats-scroll-distance", `-${overflow}px`);
|
||||
stat.style.setProperty("--stats-scroll-duration", `${duration}s`);
|
||||
stat.classList.add("is-overflowing");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleItemMouseEnter(e: MouseEvent, index: number) {
|
||||
onHover?.(index);
|
||||
const element = e.currentTarget as HTMLElement;
|
||||
const title = element.querySelector<HTMLElement>(".anchor-title-viewport");
|
||||
const text = element.querySelector<HTMLElement>(".anchor-title-text");
|
||||
if (!title || !text) {
|
||||
updateOverflowingStats(element);
|
||||
return;
|
||||
}
|
||||
|
||||
text.style.maxWidth = "none";
|
||||
text.style.overflow = "visible";
|
||||
text.style.textOverflow = "clip";
|
||||
|
||||
const overflow = Math.max(0, text.scrollWidth - title.clientWidth);
|
||||
|
||||
text.style.removeProperty("max-width");
|
||||
text.style.removeProperty("overflow");
|
||||
text.style.removeProperty("text-overflow");
|
||||
|
||||
if (overflow <= 0) {
|
||||
title.classList.remove("is-overflowing");
|
||||
title.style.removeProperty("--title-scroll-distance");
|
||||
title.style.removeProperty("--title-scroll-duration");
|
||||
updateOverflowingStats(element);
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = Math.min(7, Math.max(1.8, overflow / 24));
|
||||
|
||||
title.style.setProperty("--title-scroll-distance", `-${overflow}px`);
|
||||
title.style.setProperty("--title-scroll-duration", `${duration}s`);
|
||||
title.classList.add("is-overflowing");
|
||||
updateOverflowingStats(element);
|
||||
}
|
||||
|
||||
function clearItemHoverState(e: Event) {
|
||||
onHover?.(null);
|
||||
const element = e.currentTarget as HTMLElement;
|
||||
const stats = element.querySelectorAll<HTMLElement>(".stats-viewport");
|
||||
for (const stat of stats) {
|
||||
stat.classList.remove("is-overflowing");
|
||||
stat.style.removeProperty("--stats-scroll-distance");
|
||||
stat.style.removeProperty("--stats-scroll-duration");
|
||||
}
|
||||
|
||||
const title = element.querySelector<HTMLElement>(
|
||||
".anchor-title-viewport",
|
||||
);
|
||||
if (!title) {
|
||||
return;
|
||||
}
|
||||
|
||||
title.classList.remove("is-overflowing");
|
||||
title.style.removeProperty("--title-scroll-distance");
|
||||
title.style.removeProperty("--title-scroll-duration");
|
||||
}
|
||||
|
||||
function handleItemMouseLeave(e: MouseEvent) {
|
||||
clearItemHoverState(e);
|
||||
}
|
||||
|
||||
function anchorIcon(index: number) {
|
||||
return valhallaAnchorDisplay(index, anchors.length).icon;
|
||||
}
|
||||
|
||||
interface SegmentMetrics {
|
||||
distance: number;
|
||||
elevationGain: number;
|
||||
elevationLoss: number;
|
||||
}
|
||||
|
||||
function snapshotMetrics(metrics: GpxMetricsComputation): SegmentMetrics {
|
||||
return {
|
||||
distance: metrics.totalDistance,
|
||||
elevationGain: metrics.totalElevationGainSmoothed,
|
||||
elevationLoss: metrics.totalElevationLossSmoothed,
|
||||
};
|
||||
}
|
||||
|
||||
function subtractMetrics(metrics: SegmentMetrics, previous: SegmentMetrics): SegmentMetrics {
|
||||
return {
|
||||
distance: metrics.distance - previous.distance,
|
||||
elevationGain: metrics.elevationGain - previous.elevationGain,
|
||||
elevationLoss: metrics.elevationLoss - previous.elevationLoss,
|
||||
};
|
||||
}
|
||||
|
||||
const routeMetrics = $derived.by(() => {
|
||||
const metrics = new GpxMetricsComputation(5, 5);
|
||||
const segmentMetrics: SegmentMetrics[] = [];
|
||||
const cumulativeMetrics: SegmentMetrics[] = [];
|
||||
let previous = snapshotMetrics(metrics);
|
||||
|
||||
for (const segment of segments) {
|
||||
const points = segment.trkpt ?? [];
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
metrics.addAndFilter(points[i]);
|
||||
}
|
||||
|
||||
const cumulative = snapshotMetrics(metrics);
|
||||
cumulativeMetrics.push(cumulative);
|
||||
segmentMetrics.push(subtractMetrics(cumulative, previous));
|
||||
previous = cumulative;
|
||||
}
|
||||
|
||||
return {
|
||||
segmentMetrics,
|
||||
cumulativeMetrics,
|
||||
};
|
||||
});
|
||||
|
||||
const allSegmentMetrics = $derived(routeMetrics.segmentMetrics);
|
||||
const allCumulativeMetrics = $derived(routeMetrics.cumulativeMetrics);
|
||||
|
||||
function segmentMetrics(index: number) {
|
||||
if (index === 0) return null;
|
||||
return allSegmentMetrics[index - 1] ?? null;
|
||||
}
|
||||
|
||||
function cumulativeMetrics(index: number) {
|
||||
if (index === 0) return null;
|
||||
return allCumulativeMetrics[index - 1] ?? null;
|
||||
}
|
||||
|
||||
let listElement: HTMLOListElement;
|
||||
let hasVerticalOverflow = $state(false);
|
||||
let dragIndex = $state<number | null>(null);
|
||||
let insertBefore = $state<number | null>(null);
|
||||
let pointerId: number | null = null;
|
||||
|
||||
function updateListOverflow() {
|
||||
if (!listElement) {
|
||||
hasVerticalOverflow = false;
|
||||
return;
|
||||
}
|
||||
|
||||
hasVerticalOverflow = listElement.scrollHeight > listElement.clientHeight + 1;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
anchors.length;
|
||||
segments.length;
|
||||
void tick().then(updateListOverflow);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const element = listElement;
|
||||
if (!element || typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(updateListOverflow);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
function isValidInsert(pos: number): boolean {
|
||||
return (
|
||||
dragIndex !== null &&
|
||||
insertBefore === pos &&
|
||||
pos !== dragIndex &&
|
||||
pos !== dragIndex + 1
|
||||
);
|
||||
}
|
||||
|
||||
function getInsertPosition(clientY: number): number {
|
||||
const items = Array.from(
|
||||
listElement.querySelectorAll<HTMLElement>("li[data-anchor-index]"),
|
||||
);
|
||||
|
||||
for (const item of items) {
|
||||
const itemIndex = Number(item.dataset.anchorIndex);
|
||||
const rect = item.getBoundingClientRect();
|
||||
if (clientY < rect.top + rect.height / 2) {
|
||||
return itemIndex;
|
||||
}
|
||||
}
|
||||
|
||||
return anchors.length;
|
||||
}
|
||||
|
||||
function clearDragState() {
|
||||
dragIndex = null;
|
||||
insertBefore = null;
|
||||
pointerId = null;
|
||||
}
|
||||
|
||||
async function handleKeyDown(e: KeyboardEvent, index: number) {
|
||||
if (disabled) return;
|
||||
let toIndex: number | null = null;
|
||||
if (e.key === "ArrowUp" && index > 0) {
|
||||
e.preventDefault();
|
||||
toIndex = index - 1;
|
||||
} else if (e.key === "ArrowDown" && index < anchors.length - 1) {
|
||||
e.preventDefault();
|
||||
toIndex = index + 1;
|
||||
}
|
||||
if (toIndex === null) return;
|
||||
await onMove(index, toIndex);
|
||||
await tick();
|
||||
const handles = listElement.querySelectorAll<HTMLElement>("li[data-anchor-index] button.drag-handle");
|
||||
handles[toIndex]?.focus();
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent, index: number) {
|
||||
if (disabled || e.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
dragIndex = index;
|
||||
insertBefore = index;
|
||||
pointerId = e.pointerId;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
if (pointerId !== e.pointerId || dragIndex === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
insertBefore = getInsertPosition(e.clientY);
|
||||
}
|
||||
|
||||
function handlePointerUp(e: PointerEvent) {
|
||||
if (pointerId !== e.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
if (dragIndex !== null && insertBefore !== null) {
|
||||
const toIndex =
|
||||
dragIndex < insertBefore ? insertBefore - 1 : insertBefore;
|
||||
if (toIndex !== dragIndex) {
|
||||
onMove(dragIndex, toIndex);
|
||||
}
|
||||
}
|
||||
|
||||
clearDragState();
|
||||
}
|
||||
</script>
|
||||
|
||||
<ol
|
||||
bind:this={listElement}
|
||||
class="anchor-list flex max-h-96 shrink-0 flex-col gap-2 overflow-y-auto py-2"
|
||||
class:has-scrollbar={hasVerticalOverflow}
|
||||
class:pr-3={hasVerticalOverflow}
|
||||
>
|
||||
{#each anchors as anchor, i (anchor.id)}
|
||||
{@const metrics = segmentMetrics(i)}
|
||||
{@const cumulative = cumulativeMetrics(i)}
|
||||
{@const showCumulative = i > 1 && cumulative != null}
|
||||
<li
|
||||
data-anchor-index={i}
|
||||
class="rounded-lg border border-input-border transition-colors hover:bg-secondary-hover"
|
||||
class:p-3={i !== 0}
|
||||
class:px-3={i === 0}
|
||||
class:pt-2={i === 0}
|
||||
class:pb-2={i === 0}
|
||||
class:has-cumulative={showCumulative}
|
||||
class:opacity-50={dragIndex === i}
|
||||
class:drop-above={isValidInsert(i)}
|
||||
class:drop-below={i === anchors.length - 1 && isValidInsert(anchors.length)}
|
||||
onmouseenter={(e) => handleItemMouseEnter(e, i)}
|
||||
onmouseleave={handleItemMouseLeave}
|
||||
onfocusin={(e) => {
|
||||
onHover?.(i);
|
||||
updateOverflowingStats(e.currentTarget as HTMLElement);
|
||||
}}
|
||||
onfocusout={clearItemHoverState}
|
||||
>
|
||||
<div class="grid grid-cols-[2rem_minmax(0,1fr)] gap-x-2 gap-y-1">
|
||||
<button
|
||||
class="drag-handle absolute inset-y-0 left-0 w-12 rounded-md p-0 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label={$_("move-route-point")}
|
||||
aria-keyshortcuts="ArrowUp ArrowDown"
|
||||
onkeydown={(e) => handleKeyDown(e, i)}
|
||||
onpointerdown={(e) => handlePointerDown(e, i)}
|
||||
onpointermove={handlePointerMove}
|
||||
onpointerup={handlePointerUp}
|
||||
onpointercancel={clearDragState}
|
||||
onlostpointercapture={clearDragState}
|
||||
></button>
|
||||
|
||||
<span
|
||||
class="anchor-icon pointer-events-none relative col-start-1 row-start-1 flex h-8 w-8 -translate-x-0.5 items-center justify-center text-xl text-content"
|
||||
>
|
||||
<i class="fa {anchorIcon(i)}"></i>
|
||||
{#if i > 0 && i < anchors.length - 1}
|
||||
<span
|
||||
class="absolute -bottom-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-background px-1 text-[0.65rem] font-semibold leading-none text-gray-500"
|
||||
>
|
||||
{i}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<div class="anchor-title col-start-2 min-w-0">
|
||||
<div
|
||||
class="anchor-title-viewport"
|
||||
role="presentation"
|
||||
title={anchorTitle(anchor, i)}
|
||||
>
|
||||
<span class="anchor-title-text font-medium leading-5">
|
||||
{anchorTitle(anchor, i)}
|
||||
</span>
|
||||
</div>
|
||||
<p class="truncate text-xs leading-4 text-gray-500">
|
||||
{anchorCoordinates(anchor)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if metrics}
|
||||
{#if showCumulative}
|
||||
<span
|
||||
class="cumulative-indicator pointer-events-none relative z-10 col-start-1 row-start-2 flex h-5 w-5 items-center justify-center justify-self-center rounded-full bg-gray-500 text-xs font-semibold leading-none text-background"
|
||||
title={$_("cumulative")}
|
||||
aria-label={$_("cumulative")}
|
||||
>
|
||||
Σ
|
||||
</span>
|
||||
{/if}
|
||||
<div class="col-start-2 row-start-2 min-w-0">
|
||||
<div
|
||||
class="segment-stats stats-viewport h-5 min-w-0 overflow-hidden text-sm leading-5 text-gray-500"
|
||||
>
|
||||
<span class="stats-content flex w-max items-center gap-x-3">
|
||||
<span class="flex shrink-0 items-center whitespace-nowrap">
|
||||
<i class="fa fa-left-right mr-1 w-4 text-center"></i>{formatDistance(metrics.distance, { compact: true })}
|
||||
</span>
|
||||
<span class="flex shrink-0 items-center whitespace-nowrap">
|
||||
<i class="fa fa-arrow-trend-up mr-1 w-4 text-center"></i>{formatElevation(metrics.elevationGain)}
|
||||
</span>
|
||||
<span class="flex shrink-0 items-center whitespace-nowrap">
|
||||
<i class="fa fa-arrow-trend-down mr-1 w-4 text-center"></i>{formatElevation(metrics.elevationLoss)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{#if showCumulative}
|
||||
<div
|
||||
class="cumulative-stats stats-viewport h-5 min-w-0 overflow-hidden text-sm leading-5 text-gray-500"
|
||||
>
|
||||
<span class="stats-content flex w-max items-center gap-x-3">
|
||||
<span class="flex shrink-0 items-center whitespace-nowrap">
|
||||
<i class="fa fa-left-right mr-1 w-4 text-center"></i>{formatDistance(cumulative.distance, { compact: true })}
|
||||
</span>
|
||||
<span class="flex shrink-0 items-center whitespace-nowrap">
|
||||
<i class="fa fa-arrow-trend-up mr-1 w-4 text-center"></i>{formatElevation(cumulative.elevationGain)}
|
||||
</span>
|
||||
<span class="flex shrink-0 items-center whitespace-nowrap">
|
||||
<i class="fa fa-arrow-trend-down mr-1 w-4 text-center"></i>{formatElevation(cumulative.elevationLoss)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="delete-button btn-icon text-xs text-gray-400 hover:text-red-500"
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
title={$_("delete")}
|
||||
aria-label={$_("delete-route-point")}
|
||||
onclick={() => onDelete(i)}
|
||||
>
|
||||
<i class="fa fa-trash"></i>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
<style>
|
||||
.anchor-list.has-scrollbar {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
}
|
||||
.delete-button {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
height: 1.75rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.anchor-title {
|
||||
--delete-button-space: 2rem;
|
||||
--title-end-space: var(--delete-button-space);
|
||||
}
|
||||
.anchor-title-viewport {
|
||||
width: calc(100% - var(--title-end-space));
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.anchor-title-text {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: bottom;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.drag-handle {
|
||||
background-image:
|
||||
radial-gradient(
|
||||
circle at 0.25rem 0.4375rem,
|
||||
rgba(var(--content), 0.5) 1.35px,
|
||||
transparent 1.65px
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 0.75rem 0.125rem,
|
||||
rgba(var(--content), 0.38) 1.35px,
|
||||
transparent 1.65px
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 1.25rem 0.4375rem,
|
||||
rgba(var(--content), 0.27) 1.35px,
|
||||
transparent 1.65px
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 1.75rem 0.125rem,
|
||||
rgba(var(--content), 0.17) 1.35px,
|
||||
transparent 1.65px
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 2.25rem 0.4375rem,
|
||||
rgba(var(--content), 0.1) 1.35px,
|
||||
transparent 1.65px
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 2.75rem 0.125rem,
|
||||
rgba(var(--content), 0.05) 1.35px,
|
||||
transparent 1.65px
|
||||
);
|
||||
background-repeat: repeat-y;
|
||||
background-size: 3rem 0.75rem;
|
||||
cursor: grab;
|
||||
opacity: 0.82;
|
||||
touch-action: none;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
}
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.cumulative-indicator,
|
||||
.cumulative-stats {
|
||||
display: none;
|
||||
}
|
||||
li.drop-above::before,
|
||||
li.drop-below::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(var(--content));
|
||||
}
|
||||
li.drop-above::before {
|
||||
top: -6px;
|
||||
}
|
||||
li.drop-below::after {
|
||||
bottom: -6px;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.anchor-title {
|
||||
--title-end-space: 0rem;
|
||||
}
|
||||
|
||||
li:hover .anchor-title,
|
||||
li:focus-within .anchor-title {
|
||||
--title-end-space: var(--delete-button-space);
|
||||
}
|
||||
|
||||
:global(.anchor-title-viewport.is-overflowing) .anchor-title-text {
|
||||
max-width: none;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
animation: anchor-title-marquee var(--title-scroll-duration, 3s)
|
||||
ease-in-out 0.15s infinite alternate;
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
li:hover .delete-button,
|
||||
li:focus-within .delete-button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
li:hover .drag-handle,
|
||||
li:focus-within .drag-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
li.has-cumulative:hover .segment-stats,
|
||||
li.has-cumulative:focus-within .segment-stats {
|
||||
display: none;
|
||||
}
|
||||
|
||||
li.has-cumulative:hover .cumulative-indicator,
|
||||
li.has-cumulative:focus-within .cumulative-indicator,
|
||||
li.has-cumulative:hover .cumulative-stats,
|
||||
li.has-cumulative:focus-within .cumulative-stats {
|
||||
display: block;
|
||||
}
|
||||
|
||||
li.has-cumulative:hover .cumulative-indicator,
|
||||
li.has-cumulative:focus-within .cumulative-indicator {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
:global(.stats-viewport.is-overflowing) .stats-content {
|
||||
animation: anchor-stats-marquee var(--stats-scroll-duration, 2.5s)
|
||||
ease-in-out 0.15s infinite alternate;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes anchor-title-marquee {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(var(--title-scroll-distance, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes anchor-stats-marquee {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(var(--stats-scroll-distance, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:global(.anchor-title-viewport.is-overflowing) .anchor-title-text,
|
||||
:global(.stats-viewport.is-overflowing) .stats-content {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -173,7 +173,7 @@
|
||||
alt="avatar"
|
||||
/>
|
||||
{trail.expand.author.preferred_username}{trail.expand.author
|
||||
.isLocal
|
||||
.is_local
|
||||
? ""
|
||||
: "@" + trail.expand.author.domain}
|
||||
</p>
|
||||
|
||||
@@ -3,11 +3,6 @@
|
||||
import { page } from "$app/state";
|
||||
import type { List } from "$lib/models/list";
|
||||
import type { Trail } from "$lib/models/trail";
|
||||
import {
|
||||
integrations,
|
||||
integrations_index,
|
||||
uploadGpx,
|
||||
} from "$lib/stores/integration_store";
|
||||
import {
|
||||
lists_add_trail,
|
||||
lists_index,
|
||||
@@ -25,9 +20,8 @@
|
||||
import { trail2gpx } from "$lib/util/gpx_util";
|
||||
import { gpx } from "$lib/vendor/toGeoJSON/toGeoJSON";
|
||||
import JSZip from "jszip";
|
||||
import { onMount, type Snippet } from "svelte";
|
||||
import { type Snippet } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { get } from "svelte/store";
|
||||
import Dropdown, { type DropdownItem } from "../base/dropdown.svelte";
|
||||
import ConfirmModal from "../confirm_modal.svelte";
|
||||
import ListSearchModal from "../list/list_search_modal.svelte";
|
||||
@@ -43,6 +37,7 @@
|
||||
import type { MergeSelection, MergeSettings } from "./trail_merge_modal.svelte";
|
||||
import MergeDialog from "$lib/components/trail/trail_merge_dialog.svelte";
|
||||
import { trail_merge } from "$lib/stores/trail_merge_api";
|
||||
import { hasSendCapablePlugin } from "$lib/stores/plugin_store";
|
||||
|
||||
export interface MergeResult {
|
||||
targetTrail: Trail;
|
||||
@@ -65,58 +60,11 @@
|
||||
let confirmModal: ConfirmModal;
|
||||
let listSelectModal: ListSearchModal;
|
||||
let trailExportModal: TrailExportModal;
|
||||
let trailSendModal: TrailSendModal;
|
||||
let trailShareModal: TrailShareModal;
|
||||
let trailMergeModal: TrailMergeModal;
|
||||
let trailSendModal: TrailSendModal;
|
||||
|
||||
const hammerheadIntegration = $derived(
|
||||
$integrations.find((integration) =>
|
||||
Boolean(integration.hammerhead?.active)
|
||||
)
|
||||
);
|
||||
|
||||
let lists: List[] = $state([]);
|
||||
let integrationsLoading = false;
|
||||
let integrationsLoadedForUser: string | undefined;
|
||||
|
||||
onMount(() => {
|
||||
const unsubscribe = currentUser.subscribe(async (user) => {
|
||||
if (!user) {
|
||||
integrationsLoadedForUser = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = get(integrations);
|
||||
if (
|
||||
existing.length &&
|
||||
existing[0]?.user === user.id
|
||||
) {
|
||||
integrationsLoadedForUser = user.id;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
integrationsLoading ||
|
||||
integrationsLoadedForUser === user.id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
integrationsLoading = true;
|
||||
try {
|
||||
await integrations_index();
|
||||
integrationsLoadedForUser = user.id;
|
||||
} catch (error) {
|
||||
console.error("Failed to load integrations", error);
|
||||
} finally {
|
||||
integrationsLoading = false;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
let loading: boolean = $state(false);
|
||||
|
||||
@@ -214,6 +162,16 @@
|
||||
return !isMultiselectMode();
|
||||
}
|
||||
|
||||
function allowSend(): boolean {
|
||||
return (
|
||||
hasTrail() &&
|
||||
!isMultiselectMode() &&
|
||||
hasGpx() &&
|
||||
Boolean($currentUser) &&
|
||||
$hasSendCapablePlugin
|
||||
);
|
||||
}
|
||||
|
||||
function allowPublish(): boolean {
|
||||
if (mode !== "multi-select") return false;
|
||||
|
||||
@@ -240,7 +198,7 @@
|
||||
});
|
||||
const allowListManagement = isFromCurrentUser();
|
||||
const allowShareSingleTrail = !isMultiselectMode() && isFromCurrentUser();
|
||||
const allowSingleOutput = canExport() || (!isMultiselectMode() && hammerheadIntegration && canExport());
|
||||
const allowSingleOutput = canExport();
|
||||
|
||||
if (isMultiselectMode()) {
|
||||
return [
|
||||
@@ -289,6 +247,7 @@
|
||||
text: $_("delete"),
|
||||
value: "delete",
|
||||
icon: "trash",
|
||||
danger: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -388,7 +347,7 @@
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isMultiselectMode() && hammerheadIntegration && canExport()
|
||||
...(allowSend()
|
||||
? [
|
||||
{
|
||||
text: $_("send-to"),
|
||||
@@ -413,6 +372,7 @@
|
||||
text: $_("delete"),
|
||||
value: "delete",
|
||||
icon: "trash",
|
||||
danger: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -526,6 +486,8 @@
|
||||
}
|
||||
} else if (ddVal == "share") {
|
||||
trailShareModal.openModal();
|
||||
} else if (ddVal == "send-to") {
|
||||
trailSendModal.openModal();
|
||||
} else if (ddVal == "download") {
|
||||
trailExportModal.openModal();
|
||||
} else if (ddVal == "edit") {
|
||||
@@ -546,8 +508,6 @@
|
||||
if (trail()) {
|
||||
await trailMergeModal.openSimilarTrailsModal(trail()!);
|
||||
}
|
||||
} else if (item.value == "send-to") {
|
||||
trailSendModal.openModal();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -603,49 +563,6 @@
|
||||
await trail_merge(trailSource.id, trailTarget.id, settings);
|
||||
onProgress?.(1);
|
||||
}
|
||||
async function uploadToHammerhead() {
|
||||
if (!hammerheadIntegration || !hasTrail()) {
|
||||
console.error("No Hammerhead integration found.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const uTrail of trails!) {
|
||||
try {
|
||||
if (uTrail.gpx) {
|
||||
const gpxData = await trail2gpx(uTrail, $currentUser);
|
||||
const formData = new FormData();
|
||||
const gpxFile = new File(
|
||||
[gpxData],
|
||||
`${uTrail.name || "trail"}.gpx`,
|
||||
{ type: "application/gpx+xml" },
|
||||
);
|
||||
formData.append("file", gpxFile);
|
||||
|
||||
await uploadGpx("hammerhead", gpxFile);
|
||||
|
||||
show_toast({
|
||||
type: "success",
|
||||
icon: "check",
|
||||
text: $_("uploaded-trail-to-hammerhead"),
|
||||
});
|
||||
} else {
|
||||
show_toast({
|
||||
type: "error",
|
||||
icon: "close",
|
||||
text: $_("trail-has-no-gpx"),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
show_toast({
|
||||
type: "error",
|
||||
icon: "close",
|
||||
text: $_("error-uploading-trail-to-hammerhead"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTrailsVisibility() {
|
||||
const newVisibility = !majorityOfSelectedTrailsArePublic();
|
||||
|
||||
@@ -924,17 +841,14 @@
|
||||
onsave={handleShareUpdate}
|
||||
bind:this={trailShareModal}
|
||||
></TrailShareModal>
|
||||
<TrailSendModal
|
||||
trail={trail()}
|
||||
share={page.url.searchParams.get("share") ?? undefined}
|
||||
bind:this={trailSendModal}
|
||||
></TrailSendModal>
|
||||
<TrailMergeModal
|
||||
bind:this={trailMergeModal}
|
||||
onmerge={(settings, selection) => mergeTrails(settings, selection)}
|
||||
></TrailMergeModal>
|
||||
|
||||
<MergeDialog/>
|
||||
<TrailSendModal
|
||||
bind:this={trailSendModal}
|
||||
onsend={async (settings) => {
|
||||
if (settings.integrationName === "hammerhead") {
|
||||
await uploadToHammerhead();
|
||||
}
|
||||
}}
|
||||
></TrailSendModal>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import Tabs from "$lib/components/base/tabs.svelte";
|
||||
import TrailDropdown, { type MergeResult } from "$lib/components/trail/trail_dropdown.svelte";
|
||||
import { Comment } from "$lib/models/comment";
|
||||
import { Tag } from "$lib/models/tag";
|
||||
import type { Trail } from "$lib/models/trail";
|
||||
|
||||
import {
|
||||
@@ -57,7 +58,12 @@
|
||||
import { handleFromRecordWithIRI } from "$lib/util/activitypub_util";
|
||||
import LikeButton from "./like_button.svelte";
|
||||
import Editor from "../base/editor.svelte";
|
||||
import { trails_update } from "$lib/stores/trail_store";
|
||||
import {
|
||||
trails_update,
|
||||
trails_update_metadata,
|
||||
} from "$lib/stores/trail_store";
|
||||
import Combobox, { type ComboboxItem } from "../base/combobox.svelte";
|
||||
import { tags_index } from "$lib/stores/tag_store";
|
||||
|
||||
interface Props {
|
||||
initTrail: Trail;
|
||||
@@ -87,15 +93,16 @@
|
||||
...($currentUser ? [$_("comment", { values: { n: 2 } })] : []),
|
||||
];
|
||||
|
||||
const trailIsShared =
|
||||
(trail.expand?.trail_share_via_trail?.length ?? 0) > 0;
|
||||
const trailIsShared = $derived(
|
||||
(trail.expand?.trail_share_via_trail?.length ?? 0) > 0,
|
||||
);
|
||||
|
||||
let gallery: PhotoGallery;
|
||||
|
||||
let newComment: Comment = $state({
|
||||
text: "",
|
||||
author: "",
|
||||
trail: untrack(() => handle) + "/" + (trail.id ?? ""),
|
||||
trail: untrack(() => `${handle}/${trail.id ?? ""}`),
|
||||
});
|
||||
|
||||
let commentsLoading: boolean = $state(untrack(() => activeTab == 2));
|
||||
@@ -106,6 +113,26 @@
|
||||
let summitLogCreateLoading: boolean = $state(false);
|
||||
|
||||
let fullDescription: boolean = $state(false);
|
||||
let metadataSaving: boolean = $state(false);
|
||||
let editingName: boolean = $state(false);
|
||||
let editingDescription: boolean = $state(false);
|
||||
let editingTags: boolean = $state(false);
|
||||
let nameDraft: string = $state("");
|
||||
let descriptionDraft: string = $state("");
|
||||
let tagDraftItems: ComboboxItem[] = $state([]);
|
||||
let tagItems: ComboboxItem[] = $state([]);
|
||||
|
||||
const canEditTrail = $derived(
|
||||
Boolean(
|
||||
$currentUser &&
|
||||
(trail.author === $currentUser.actor ||
|
||||
trail.expand?.trail_share_via_trail?.some(
|
||||
(share) =>
|
||||
share.permission === "edit" &&
|
||||
share.actor === $currentUser.actor,
|
||||
)),
|
||||
),
|
||||
);
|
||||
|
||||
onMount(async () => {});
|
||||
|
||||
@@ -289,6 +316,133 @@
|
||||
const updatedTrail: Trail = { ...trail };
|
||||
await trails_update(trail, updatedTrail);
|
||||
}
|
||||
|
||||
function cloneTrail(value: Trail): Trail {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function mergeTrailUpdate(previousTrail: Trail, updatedTrail: Trail): Trail {
|
||||
return {
|
||||
...previousTrail,
|
||||
...updatedTrail,
|
||||
expand: {
|
||||
...previousTrail.expand,
|
||||
...updatedTrail.expand,
|
||||
author: previousTrail.expand?.author,
|
||||
trail_like_via_trail:
|
||||
previousTrail.expand?.trail_like_via_trail,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function saveTrailMetadata(update: (nextTrail: Trail) => void) {
|
||||
if (!canEditTrail || metadataSaving) {
|
||||
return false;
|
||||
}
|
||||
|
||||
metadataSaving = true;
|
||||
const oldTrail = cloneTrail(trail);
|
||||
const nextTrail = cloneTrail(trail);
|
||||
nextTrail.expand ??= {};
|
||||
nextTrail.tags = [...(trail.tags ?? [])];
|
||||
update(nextTrail);
|
||||
const tagsChanged =
|
||||
JSON.stringify(nextTrail.expand?.tags ?? []) !==
|
||||
JSON.stringify(oldTrail.expand?.tags ?? []);
|
||||
|
||||
try {
|
||||
const updatedTrail = await trails_update_metadata(oldTrail, {
|
||||
name:
|
||||
nextTrail.name !== oldTrail.name
|
||||
? nextTrail.name
|
||||
: undefined,
|
||||
description:
|
||||
nextTrail.description !== oldTrail.description
|
||||
? nextTrail.description
|
||||
: undefined,
|
||||
expand: tagsChanged ? { tags: nextTrail.expand?.tags } : undefined,
|
||||
});
|
||||
trail = mergeTrailUpdate(trail, updatedTrail);
|
||||
show_toast({
|
||||
icon: "check",
|
||||
type: "success",
|
||||
text: $_("trail-saved-successfully"),
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
show_toast({
|
||||
icon: "close",
|
||||
type: "error",
|
||||
text: $_("error-saving-trail"),
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
metadataSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startNameEdit() {
|
||||
nameDraft = trail.name;
|
||||
editingName = true;
|
||||
}
|
||||
|
||||
async function saveNameEdit() {
|
||||
const name = nameDraft.trim();
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
const saved = await saveTrailMetadata((nextTrail) => {
|
||||
nextTrail.name = name;
|
||||
});
|
||||
editingName = !saved;
|
||||
}
|
||||
|
||||
function startDescriptionEdit() {
|
||||
descriptionDraft = trail.description ?? "";
|
||||
editingDescription = true;
|
||||
}
|
||||
|
||||
async function saveDescriptionEdit() {
|
||||
const saved = await saveTrailMetadata((nextTrail) => {
|
||||
nextTrail.description = descriptionDraft;
|
||||
});
|
||||
editingDescription = !saved;
|
||||
if (saved) {
|
||||
fullDescription = true;
|
||||
}
|
||||
}
|
||||
|
||||
function getTrailTagItems() {
|
||||
return (
|
||||
trail.expand?.tags?.map((tag) => ({
|
||||
text: tag.name,
|
||||
value: tag,
|
||||
})) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
function startTagsEdit() {
|
||||
tagDraftItems = getTrailTagItems();
|
||||
editingTags = true;
|
||||
}
|
||||
|
||||
async function searchTags(q: string) {
|
||||
const result = await tags_index(q);
|
||||
tagItems = result.items.map((tag) => ({
|
||||
text: tag.name,
|
||||
value: tag,
|
||||
}));
|
||||
}
|
||||
|
||||
async function saveTagsEdit() {
|
||||
const saved = await saveTrailMetadata((nextTrail) => {
|
||||
nextTrail.expand!.tags = tagDraftItems.map((item) =>
|
||||
item.value ? item.value : new Tag(item.text),
|
||||
);
|
||||
});
|
||||
editingTags = !saved;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -348,12 +502,57 @@
|
||||
</section>
|
||||
<section class="border-b border-input-border p-8">
|
||||
<div class="flex justify-between items-center gap-x-4">
|
||||
{#if trail.expand?.tags && trail.expand.tags.length > 0}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#if editingTags}
|
||||
<div class="flex-1">
|
||||
<Combobox
|
||||
bind:value={tagDraftItems}
|
||||
onupdate={searchTags}
|
||||
items={tagItems}
|
||||
placeholder={`${$_("tags")}...`}
|
||||
multiple
|
||||
chips
|
||||
></Combobox>
|
||||
<div class="flex gap-2 mt-3">
|
||||
<button
|
||||
class="btn-secondary"
|
||||
type="button"
|
||||
disabled={metadataSaving}
|
||||
onclick={() => (editingTags = false)}
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
<Button
|
||||
primary
|
||||
type="button"
|
||||
loading={metadataSaving}
|
||||
onclick={saveTagsEdit}>{$_("save")}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{:else if trail.expand?.tags && trail.expand.tags.length > 0}
|
||||
<div class="group flex flex-wrap items-center gap-2">
|
||||
{#each trail.expand.tags as tag}
|
||||
<Chip text={tag.name} primary={false}></Chip>
|
||||
{/each}
|
||||
{#if canEditTrail}
|
||||
<button
|
||||
class="btn-icon tooltip opacity-0 hover:opacity-100 focus:opacity-100 group-hover:opacity-100 transition-opacity"
|
||||
type="button"
|
||||
aria-label={$_("tags")}
|
||||
data-title={$_("tags")}
|
||||
onclick={startTagsEdit}
|
||||
><i class="fa fa-pen text-sm"></i></button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if canEditTrail}
|
||||
<button
|
||||
class="btn-icon tooltip opacity-0 hover:opacity-100 focus:opacity-100 group-hover:opacity-100 transition-opacity"
|
||||
type="button"
|
||||
aria-label={$_("tags")}
|
||||
data-title={$_("tags")}
|
||||
onclick={startTagsEdit}
|
||||
><i class="fa fa-tags text-sm"></i></button
|
||||
>
|
||||
{/if}
|
||||
{#if (trail.public || trailIsShared) && $currentUser}
|
||||
<div
|
||||
@@ -378,15 +577,63 @@
|
||||
</div>
|
||||
<div class="flex justify-between items-end w-full gap-y-4">
|
||||
<div class=" overflow-hidden">
|
||||
<h4
|
||||
title={trail.name}
|
||||
class="{mode == 'map'
|
||||
? 'text-4xl'
|
||||
: 'text-5xl'} font-bold line-clamp-3 mb-1 wrap-anywhere"
|
||||
style="line-height: 1.18"
|
||||
>
|
||||
{trail.name}
|
||||
</h4>
|
||||
{#if editingName}
|
||||
<div class="flex flex-col gap-3 mb-3">
|
||||
<input
|
||||
class="{mode == 'map'
|
||||
? 'text-4xl'
|
||||
: 'text-5xl'} font-bold bg-input-background border border-input-border rounded-md px-3 py-2 focus:outline-none focus:border-input-border-focus"
|
||||
bind:value={nameDraft}
|
||||
disabled={metadataSaving}
|
||||
aria-label={$_("name")}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
void saveNameEdit();
|
||||
} else if (e.key === "Escape") {
|
||||
editingName = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="btn-secondary"
|
||||
type="button"
|
||||
disabled={metadataSaving}
|
||||
onclick={() => (editingName = false)}
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
<Button
|
||||
primary
|
||||
type="button"
|
||||
loading={metadataSaving}
|
||||
disabled={!nameDraft.trim()}
|
||||
onclick={saveNameEdit}>{$_("save")}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="group flex items-end gap-2">
|
||||
<h4
|
||||
title={trail.name}
|
||||
class="{mode == 'map'
|
||||
? 'text-4xl'
|
||||
: 'text-5xl'} font-bold line-clamp-3 mb-1 wrap-anywhere"
|
||||
style="line-height: 1.18"
|
||||
>
|
||||
{trail.name}
|
||||
</h4>
|
||||
{#if canEditTrail}
|
||||
<button
|
||||
class="btn-icon tooltip shrink-0 mb-2 opacity-0 hover:opacity-100 focus:opacity-100 group-hover:opacity-100 transition-opacity"
|
||||
type="button"
|
||||
aria-label={$_("name")}
|
||||
data-title={$_("name")}
|
||||
onclick={startNameEdit}
|
||||
><i class="fa fa-pen text-sm"></i></button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if trail.date}
|
||||
<h5 class="text-sm text-gray-500">
|
||||
{new Date(trail.date).toLocaleDateString(
|
||||
@@ -514,10 +761,44 @@
|
||||
class:xl:grid-cols-[1fr_18rem]={mode == "overview"}
|
||||
>
|
||||
<div class="order-1 xl:-order-1">
|
||||
<h4 class="text-2xl font-semibold my-4">
|
||||
{$_("description")}
|
||||
</h4>
|
||||
{#if trail.description?.length}
|
||||
<div class="group flex items-center gap-2 my-4">
|
||||
<h4 class="text-2xl font-semibold">
|
||||
{$_("description")}
|
||||
</h4>
|
||||
{#if canEditTrail && !editingDescription}
|
||||
<button
|
||||
class="btn-icon tooltip opacity-0 hover:opacity-100 focus:opacity-100 group-hover:opacity-100 transition-opacity"
|
||||
type="button"
|
||||
aria-label={$_("description")}
|
||||
data-title={$_("description")}
|
||||
onclick={startDescriptionEdit}
|
||||
><i class="fa fa-pen text-sm"></i></button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{#if editingDescription}
|
||||
<div class="mb-6">
|
||||
<Editor
|
||||
extraClasses="min-h-24"
|
||||
bind:value={descriptionDraft}
|
||||
></Editor>
|
||||
<div class="flex gap-2 mt-3">
|
||||
<button
|
||||
class="btn-secondary"
|
||||
type="button"
|
||||
disabled={metadataSaving}
|
||||
onclick={() => (editingDescription = false)}
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
<Button
|
||||
primary
|
||||
type="button"
|
||||
loading={metadataSaving}
|
||||
onclick={saveDescriptionEdit}>{$_("save")}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{:else if trail.description?.length}
|
||||
<article
|
||||
class="text-justify whitespace-pre-line text-sm prose dark:prose-invert"
|
||||
>
|
||||
|
||||
@@ -1,42 +1,145 @@
|
||||
<script lang="ts">
|
||||
import Modal from "$lib/components/base/modal.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import hammerheadLogoWhite from "$lib/assets/svgs/logos/hammerhead_white.svg";
|
||||
import hammerheadLogoDark from "$lib/assets/svgs/logos/hammerhead_dark.svg";
|
||||
import type { PluginInstance } from "$lib/models/plugin_instance";
|
||||
import type { PluginProvider } from "$lib/models/plugin_provider";
|
||||
import type { Trail } from "$lib/models/trail";
|
||||
import { plugin_instances_index } from "$lib/stores/plugin_instance_store";
|
||||
import { plugins_index } from "$lib/stores/plugin_store";
|
||||
import { theme } from "$lib/stores/theme_store";
|
||||
import { show_toast } from "$lib/stores/toast_store.svelte";
|
||||
import { pluginTitle as localizedPluginTitle } from "$lib/util/plugin_i18n";
|
||||
import { onMount } from "svelte";
|
||||
import { _, locale } from "svelte-i18n";
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
onsend?: (settings: typeof sendSettings) => void;
|
||||
trail?: Trail;
|
||||
share?: string;
|
||||
}
|
||||
|
||||
let { title = $_("send-to"), onsend: onexport }: Props = $props();
|
||||
let { trail, share }: Props = $props();
|
||||
|
||||
let modal: Modal;
|
||||
let loading = $state(false);
|
||||
let sending = $state("");
|
||||
let eligible: PluginProvider[] = $state([]);
|
||||
let currentTheme: "dark" | "light" = $state("light");
|
||||
|
||||
export function openModal() {
|
||||
modal.openModal();
|
||||
}
|
||||
|
||||
const sendSettings: {
|
||||
integrationName: string;
|
||||
} = $state({
|
||||
integrationName: "hammerhead",
|
||||
onMount(() => {
|
||||
currentTheme = document.documentElement.classList.contains("dark") ? "dark" : "light";
|
||||
return theme.subscribe((value) => {
|
||||
currentTheme = value;
|
||||
});
|
||||
});
|
||||
|
||||
function sendTrail() {
|
||||
onexport?.(sendSettings);
|
||||
modal.closeModal();
|
||||
export async function openModal() {
|
||||
modal.openModal();
|
||||
await loadEligible();
|
||||
}
|
||||
|
||||
async function loadEligible() {
|
||||
loading = true;
|
||||
eligible = [];
|
||||
try {
|
||||
const [plugins, instances] = await Promise.all([
|
||||
plugins_index(),
|
||||
plugin_instances_index(),
|
||||
]);
|
||||
const enabledProviders = new Set(
|
||||
instances
|
||||
.filter((a: PluginInstance) => a.enabled)
|
||||
.map((a) => a.plugin_id),
|
||||
);
|
||||
eligible = plugins.filter(
|
||||
(p) =>
|
||||
p.status === "available" &&
|
||||
(p.capabilities ?? []).includes("prepare_trail_send.v1") &&
|
||||
enabledProviders.has(p.id),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function pluginLogo(plugin: PluginProvider): string | undefined {
|
||||
if (currentTheme === "dark" && plugin.iconDark) {
|
||||
return plugin.iconDark;
|
||||
}
|
||||
return plugin.icon || undefined;
|
||||
}
|
||||
|
||||
function pluginTitle(plugin: PluginProvider): string {
|
||||
return localizedPluginTitle(plugin, $locale);
|
||||
}
|
||||
|
||||
async function send(plugin: PluginProvider) {
|
||||
if (!trail?.id) {
|
||||
return;
|
||||
}
|
||||
sending = plugin.id;
|
||||
try {
|
||||
const response = await fetch("/api/v1/plugin-system/trail-send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ pluginId: plugin.id, trailId: trail.id, share }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
show_toast({
|
||||
type: "success",
|
||||
icon: "check",
|
||||
text: $_("trail-sent"),
|
||||
});
|
||||
modal.closeModal();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
show_toast({
|
||||
type: "error",
|
||||
icon: "close",
|
||||
text: $_("error-sending-trail"),
|
||||
});
|
||||
} finally {
|
||||
sending = "";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal id="send-modal" {title} size="md:min-w-sm" bind:this={modal}>
|
||||
<Modal id="send-modal" title={$_("send-to")} size="md:min-w-sm" bind:this={modal}>
|
||||
{#snippet content()}
|
||||
<div>
|
||||
<button class="btn-secondary" onclick={sendTrail}>
|
||||
<img class="h-20" src={$theme == "light" ? hammerheadLogoDark : hammerheadLogoWhite} alt="integration logo"/>
|
||||
</button>
|
||||
</div>
|
||||
{#if loading}
|
||||
<div class="flex justify-center p-4">
|
||||
<div class="spinner light:spinner-dark"></div>
|
||||
</div>
|
||||
{:else if eligible.length === 0}
|
||||
<p class="text-sm text-gray-500">{$_("no-send-plugins")}</p>
|
||||
<a class="btn-secondary mt-4 inline-block" href="/settings/plugins"
|
||||
>{$_("plugins")}</a
|
||||
>
|
||||
{:else}
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
{#each eligible as plugin (plugin.id)}
|
||||
<button
|
||||
class="btn-secondary"
|
||||
disabled={sending !== ""}
|
||||
onclick={() => send(plugin)}
|
||||
>
|
||||
{#if sending === plugin.id}
|
||||
<div class="spinner light:spinner-dark h-20"></div>
|
||||
{:else if pluginLogo(plugin)}
|
||||
<img
|
||||
class="h-20"
|
||||
src={pluginLogo(plugin)}
|
||||
alt={pluginTitle(plugin)}
|
||||
/>
|
||||
{:else}
|
||||
<span>{pluginTitle(plugin)}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -44,5 +147,5 @@
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
</div>
|
||||
{/snippet}</Modal
|
||||
>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (!item.value.isLocal && !trail.public) {
|
||||
if (!item.value.is_local && !trail.public) {
|
||||
displayShareError = true;
|
||||
return;
|
||||
}
|
||||
@@ -167,11 +167,11 @@
|
||||
<img
|
||||
class="rounded-full w-8 aspect-square mr-2"
|
||||
src={share.expand.actor.icon ||
|
||||
`https://api.dicebear.com/7.x/initials/svg?seed=${share.expand.actor.username}&backgroundType=gradientLinear`}
|
||||
`https://api.dicebear.com/7.x/initials/svg?seed=${share.expand.actor.preferred_username}&backgroundType=gradientLinear`}
|
||||
alt="avatar"
|
||||
/>
|
||||
<p>
|
||||
{`@${share.expand.actor.username}${share.expand.actor.isLocal ? "" : "@" + share.expand.actor.domain}`}
|
||||
{`@${share.expand.actor.username}${share.expand.actor.is_local ? "" : "@" + share.expand.actor.domain}`}
|
||||
</p>
|
||||
<span
|
||||
class="basis-full text-sm text-gray-500 text-end"
|
||||
@@ -179,13 +179,13 @@
|
||||
>
|
||||
<div
|
||||
class="shrink-0"
|
||||
class:tooltip={!share.expand.actor.isLocal}
|
||||
class:tooltip={!share.expand.actor.is_local}
|
||||
data-title={$_("remote-users-cannot-edit")}
|
||||
>
|
||||
<Select
|
||||
value={share.permission}
|
||||
items={permissionSelectItems}
|
||||
disabled={!share.expand.actor.isLocal}
|
||||
disabled={!share.expand.actor.is_local}
|
||||
onchange={(value) =>
|
||||
updateSharePermission(share, value)}
|
||||
></Select>
|
||||
|
||||
@@ -176,7 +176,7 @@
|
||||
{#if trail.expand && trail.expand.author}
|
||||
<div class="author-icon">
|
||||
<img
|
||||
title={`${trail.public ? $_("public") + " " : ""}${$_("by")} @${trail.expand.author.preferred_username}${trail.expand.author.isLocal ? "" : "@" + trail.expand.author.domain}`}
|
||||
title={`${trail.public ? $_("public") + " " : ""}${$_("by")} @${trail.expand.author.preferred_username}${trail.expand.author.is_local ? "" : "@" + trail.expand.author.domain}`}
|
||||
class="rounded-full w-5 aspect-square mx-1 inline"
|
||||
src={trail.expand.author.icon ||
|
||||
`https://api.dicebear.com/7.x/initials/svg?seed=${trail.expand.author.preferred_username}&backgroundType=gradientLinear`}
|
||||
|
||||
3
web/src/lib/config/map.ts
Normal file
3
web/src/lib/config/map.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { env } from "$env/dynamic/public";
|
||||
|
||||
export const MAP_MAX_POLYLINES = Number(env.PUBLIC_MAP_MAX_POLYLINES || 100);
|
||||
@@ -3,6 +3,7 @@
|
||||
"Canoeing": "Kanoistika",
|
||||
"Climbing": "Horolezectví",
|
||||
"Hiking": "Turistika",
|
||||
"Other": "Ostatní",
|
||||
"Skiing": "",
|
||||
"Walking": "Chůze",
|
||||
"about": "O aplikaci",
|
||||
@@ -144,7 +145,7 @@
|
||||
"error-copying-trail": "",
|
||||
"error-creating-user": "Chyba při vytváření uživatele",
|
||||
"error-deleting-token": "",
|
||||
"error-disabling-strava-integration": "Chyba při deaktivaci integrace strava",
|
||||
"error-disabling-strava-plugin": "Chyba při deaktivaci pluginu strava",
|
||||
"error-during-login": "Během přihlášení došlo k chybě",
|
||||
"error-during-password-reset": "Nelze odeslat e-mail pro obnovu hesla",
|
||||
"error-exporting-trail": "Chyba při exportování trasy",
|
||||
@@ -157,11 +158,11 @@
|
||||
"error-reading-file": "Soubor nelze načíst",
|
||||
"error-saving-list": "Chyba při ukládání seznamu",
|
||||
"error-saving-trail": "Chyba při ukládání trasy",
|
||||
"error-setting-up-integration": "Chyba během nastavování {provider} integrace",
|
||||
"error-updating-hammerhead-integration": "",
|
||||
"error-updating-komoot-integration": "",
|
||||
"error-setting-up-plugin": "Chyba během nastavování pluginu {provider}",
|
||||
"error-updating-hammerhead-plugin": "",
|
||||
"error-updating-komoot-plugin": "",
|
||||
"error-updating-password": "Chyba během aktualizace hesla",
|
||||
"error-updating-strava-integration": "Chyba při aktualizaci Komoot integrace",
|
||||
"error-updating-strava-plugin": "Chyba při aktualizaci pluginu Strava",
|
||||
"error-uploading-trail-to-hammerhead": "",
|
||||
"est-duration": "Odhadovaná doba trvání",
|
||||
"everyone-with-the-link": "Každý, kdo má odkaz",
|
||||
@@ -202,7 +203,6 @@
|
||||
"get-started": "Začněte",
|
||||
"grid": "Mřížka",
|
||||
"grocery-store": "Potraviny",
|
||||
"hammerhead-integration-after-date-hint": "",
|
||||
"heading": "Nadpis",
|
||||
"height": "Výška",
|
||||
"help": "Nápověda",
|
||||
@@ -224,14 +224,11 @@
|
||||
"import-hint": "Vyberte nebo sem přetáhněte soubory GPX, FIT, KML, či TCX...",
|
||||
"include-description": "Zahrnout popis",
|
||||
"include-waypoints": "Zahrnout body trasy",
|
||||
"integration-description-hammerhead": "",
|
||||
"integration-description-komoot": "Synchronizuje vaše trasy z aplikace Komoot s Wandererem v pravidelných intervalech.",
|
||||
"integration-description-strava": "Synchronizuje vaše trasy a aktivity z aplikace Strava s Wandererem v pravidelných intervalech.",
|
||||
"integration-disabled": "integrace zakázána",
|
||||
"integration-enabled": "integrace povolena",
|
||||
"integration-privacy-hint-original": "",
|
||||
"integration-privacy-hint-user": "",
|
||||
"integrations": "Integrace",
|
||||
"plugin-disabled": "plugin zakázán",
|
||||
"plugin-enabled": "plugin povolen",
|
||||
"plugin-privacy-hint-original": "",
|
||||
"plugin-privacy-hint-user": "",
|
||||
"plugins": "Pluginy",
|
||||
"invalid-date": "Neplatné datum",
|
||||
"invalid-username": "Neplatné uživatelské jméno",
|
||||
"italian": "Italština",
|
||||
@@ -264,6 +261,8 @@
|
||||
"make-one": "Vytvořte si vlastní!",
|
||||
"make-thumbnail": "Vytvořit náhled",
|
||||
"map": "Mapa",
|
||||
"map-trail-preview-zoom-level": "Zobrazit náhledy tras od úrovně přiblížení",
|
||||
"show-trail-start-marker": "Zobrazit značku na začátku trasy",
|
||||
"map-style": "Styl mapy",
|
||||
"mark-trail-as-completed": "",
|
||||
"mark-trail-as-completed-modal-text": "",
|
||||
@@ -373,6 +372,7 @@
|
||||
"road": "Silnice",
|
||||
"route": "{n, plural, =1 {Trasa} few {Trasy} other {Tras}}",
|
||||
"route-point": "Bod trasy",
|
||||
"add-as-endpoint": "Add as endpoint",
|
||||
"russian": "Ruština",
|
||||
"save": "Uložit",
|
||||
"save-list": "Uložit seznam",
|
||||
@@ -431,7 +431,6 @@
|
||||
"statistics": "Statistiky",
|
||||
"stop-drawing": "Ukončit kreslení",
|
||||
"stop-editing": "Ukončit úpravy",
|
||||
"strava-integration-after-date-hint": "Pokud váš účet obsahuje velké množství aktivit, můžete narazit na limit API služby Strava, což znemožní synchronizaci všech aktivit najednou. Tomuto problému předejdete nastavením data \"Od\" a dále - synchronizují se tak pouze aktivity zaznamenané po tomto datu.",
|
||||
"subway-stop": "Vstup do metra",
|
||||
"summit": "Vrchol",
|
||||
"summit-book": "Vrcholová kniha",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"Canoeing": "Kanufahren",
|
||||
"Climbing": "Klettern",
|
||||
"Hiking": "Wandern",
|
||||
"Other": "Sonstiges",
|
||||
"Skiing": "Skifahren",
|
||||
"Walking": "Laufen",
|
||||
"about": "Über",
|
||||
@@ -30,7 +31,7 @@
|
||||
"append-waypoint-description": "Kommentar anhängen",
|
||||
"append-waypoint-photos": "Fotos hinzufügen",
|
||||
"append-waypoint-title": "Titel anhängen",
|
||||
"apply-user-settings": "",
|
||||
"apply-user-settings": "Benutzereinstellungen anwenden",
|
||||
"attraction": "Sehenswürdigkeit",
|
||||
"author": "Autor",
|
||||
"avatar": "Avatar",
|
||||
@@ -69,11 +70,14 @@
|
||||
"card": "{n, plural, =1 {Karte} other {Karten}}",
|
||||
"categories": "Kategorien",
|
||||
"category": "Kategorie",
|
||||
"category-mapping": "Kategorie-Zuordnung",
|
||||
"category-mapping-help": "Entfernte Provider-Kategorien werden bewusst nicht zugeordnet und erhalten beim Import keine Kategorie.",
|
||||
"change": "Ändern",
|
||||
"change-email": "Email ändern",
|
||||
"change-password": "Passwort ändern",
|
||||
"changelog": "Änderungshistorie",
|
||||
"chinese": "Chinesisch (vereinfacht)",
|
||||
"clear": "Leeren",
|
||||
"clear-all": "Alle ausblenden",
|
||||
"dismiss": "Schließen",
|
||||
"climbing": "Klettern",
|
||||
@@ -102,6 +106,7 @@
|
||||
"creation-date": "Erstellungsdatum",
|
||||
"crop": "Zuschneiden",
|
||||
"cross": "Querfeldein",
|
||||
"cumulative": "Kumulativ",
|
||||
"current-password": "Aktuelles Passwort",
|
||||
"cycling": "Radfahren",
|
||||
"cycling-speed": "Radfahrgeschwindigkeit",
|
||||
@@ -116,6 +121,7 @@
|
||||
"delete-linked-trails": "Verknüpfte Routen löschen",
|
||||
"delete-list-confirm": "Möchtest Du diese Liste wirklich löschen? Die Routen in der Liste sind danach weiterhin verfügbar.",
|
||||
"delete-summit-log-confirm": "Möchtest du diesen Gipfelbuch-Eintrag wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"delete-route-point": "Routenpunkt löschen",
|
||||
"delete-trail-confirm": "Möchtest Du diese Route wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"describe-your-trail": "Beschreibe deine Route",
|
||||
"description": "Beschreibung",
|
||||
@@ -156,7 +162,7 @@
|
||||
"error-copying-trail": "Fehler beim Kopieren der Route",
|
||||
"error-creating-user": "Fehler beim Erstellen des Nutzers",
|
||||
"error-deleting-token": "",
|
||||
"error-disabling-strava-integration": "Fehler beim Deaktivieren der Stravaintegration",
|
||||
"error-disabling-strava-plugin": "Fehler beim Deaktivieren des Strava-Plugins",
|
||||
"error-during-login": "Fehler beim Login",
|
||||
"error-during-password-reset": "Email zum Zurücksetzen des Passworts konnte nicht versandt werden",
|
||||
"error-exporting-trail": "Fehler beim Exportieren der Route",
|
||||
@@ -170,11 +176,12 @@
|
||||
"error-reading-file": "Fehler beim Lesen der Datei",
|
||||
"error-saving-list": "Fehler beim Speichern der Liste",
|
||||
"error-saving-trail": "Fehler beim Speichern der Route",
|
||||
"error-setting-up-integration": "Fehler beim Einrichten der {provider}-Integration",
|
||||
"error-updating-hammerhead-integration": "Fehler bei Aktualisierung der Hammerhead-Integration",
|
||||
"error-updating-komoot-integration": "Fehler bei Aktualisierung der komoot-Integration",
|
||||
"error-setting-up-plugin": "Fehler beim Einrichten des {provider}-Plugins",
|
||||
"error-starting-oauth": "Fehler beim Starten der OAuth-Verbindung",
|
||||
"error-updating-hammerhead-plugin": "Fehler bei Aktualisierung des Hammerhead-Plugins",
|
||||
"error-updating-komoot-plugin": "Fehler bei Aktualisierung des komoot-Plugins",
|
||||
"error-updating-password": "Fehler beim Aktualisieren des Passworts",
|
||||
"error-updating-strava-integration": "Fehler bei Aktualisierung der Strava-Integration",
|
||||
"error-updating-strava-plugin": "Fehler bei Aktualisierung des Strava-Plugins",
|
||||
"error-uploading-trail-to-hammerhead": "Fehler beim Hochladen der Route zu Hammerhead",
|
||||
"est-duration": "Gesch. Dauer",
|
||||
"everyone-with-the-link": "Jeder mit dem Link",
|
||||
@@ -216,7 +223,6 @@
|
||||
"get-started": "Los geht’s",
|
||||
"grid": "Gitter",
|
||||
"grocery-store": "Lebensmittelgeschäft",
|
||||
"hammerhead-integration-after-date-hint": "Wenn Ihr Hammerhead Konto bereits mit anderen Trail-Datenbanken wie komoot oder Strava synchronisiert ist, kann die zusätzliche Synchronisierung Ihrer Hammerhead-Daten zu Duplikaten führen. Um dies zu vermeiden, können Sie unten ein Startdatum festlegen, sodass nur Aktivitäten synchronisiert werden, die nach diesem Datum aufgezeichnet wurden.",
|
||||
"heading": "Überschrift",
|
||||
"height": "Höhe",
|
||||
"help": "Hilfe",
|
||||
@@ -238,23 +244,46 @@
|
||||
"import-hint": "GPX, FIT, KML oder TCX Dateien auswählen oder hierher ziehen...",
|
||||
"include-description": "Beschreibung übernehmen",
|
||||
"include-waypoints": "Wegpunkte einbeziehen",
|
||||
"integration-description-hammerhead": "Synchronisiert Deine Hammerhead-Touren regelmäßig mit wanderer.",
|
||||
"integration-description-komoot": "Synchronisiert Deine komoot-Touren regelmäßig mit wanderer.",
|
||||
"integration-description-strava": "Synchronisiert Deine Strava-Routen und -Aktivitäten regelmäßig mit wanderer.",
|
||||
"integration-auto-merge-label": "Automatisch mergen",
|
||||
"integration-auto-merge-hint": "Nach dem Import wird nur dann automatisch gemergt, wenn ein eindeutiger Treffer gefunden wird. Der importierte Trail wird in diesem Fall nicht separat gespeichert.",
|
||||
"integration-disabled": "Integration deaktiviert",
|
||||
"integration-enabled": "Integration aktiviert",
|
||||
"integration-privacy-hint-original": "",
|
||||
"integration-privacy-hint-user": "",
|
||||
"integrations": "Integrationen",
|
||||
"plugin-auto-merge-label": "Automatisch mergen",
|
||||
"plugin-auto-merge-hint": "Nach dem Import wird nur dann automatisch gemergt, wenn ein eindeutiger Treffer gefunden wird. Der importierte Trail wird in diesem Fall nicht separat gespeichert.",
|
||||
"plugin-after-date-hint": "Lege ein Startdatum fest, um nur Routen zu synchronisieren, die nach diesem Datum aufgezeichnet wurden. Das kann helfen, Duplikate zu vermeiden, wenn ältere Routen bereits in wanderer vorhanden sind.",
|
||||
"plugin-category-remap-action": "Kategorien aktualisieren",
|
||||
"plugin-category-remap-back-to-settings": "Zurück zu den Einstellungen",
|
||||
"plugin-category-remap-backfilled-confirm": "Für {count} importierte Routen wurde die Provider-Kategorie erst nach deiner letzten Änderung der wanderer Kategorie-Zuordnung ermittelt. Soll die Zuordnung für diese Routen jetzt nachgeholt werden? Andere Routendaten bleiben unverändert.",
|
||||
"plugin-category-remap-confirm": "{count} Routen sind von den Zuordnungsanpassungen betroffen. Soll die aktualisierte Zuordnung jetzt auf diese Routen angewendet werden? Andere Routendaten bleiben unverändert.",
|
||||
"plugin-category-remap-confirm-unspecified": "Die Kategorie-Zuordnung wurde geändert. Aktuell sind keine importierten Routen bekannt, die sofort aktualisiert werden können. Änderungen speichern?",
|
||||
"plugin-category-remap-continue-without-remap": "Ohne Remapping fortfahren",
|
||||
"plugin-category-remap-dismiss-error": "Remap-Erinnerung konnte nicht gespeichert werden",
|
||||
"plugin-category-remap-error": "Kategorien importierter Routen konnten nicht aktualisiert werden",
|
||||
"plugin-category-remap-ignore": "Ignorieren",
|
||||
"plugin-category-remap-preview-error": "Importierte Routen konnten nicht auf Kategorie-Updates geprüft werden",
|
||||
"plugin-category-remap-success": "Kategorien für {count} importierte Routen aktualisiert",
|
||||
"plugin-category-remap-sync-hint": "Einige dieser Treffer wurden erst nach der letzten Änderung der Zuordnung durch eine Synchronisierung gefunden.",
|
||||
"plugin-category-remap-title": "Kategorien importierter Routen aktualisieren?",
|
||||
"plugin-connect-before-enabling": "Verbinde dieses Plugin, bevor du es aktivierst.",
|
||||
"plugin-disabled": "Plugin deaktiviert",
|
||||
"plugin-enabled": "Plugin aktiviert",
|
||||
"plugins-empty-title": "Keine Plugins installiert",
|
||||
"plugins-empty-description": "Auf diesem Server sind noch keine Plugins verfügbar. Kontaktiere deinen Administrator oder lies die Installationsanleitung.",
|
||||
"plugins-empty-docs-link": "Plugins installieren",
|
||||
"plugin-oauth-connected-hint": "Dieses Plugin ist verbunden. Wenn du die OAuth-Zugangsdaten änderst, verbinde es erneut, bevor du die Synchronisierung wieder aktivierst.",
|
||||
"plugin-oauth-needs-connect-hint": "Speichere und verbinde dieses Plugin, bevor du die Synchronisierung aktivierst.",
|
||||
"plugin-privacy-hint-original": "Importierte Routen behalten dieselbe Sichtbarkeit wie auf der externen Plattform. Wenn die ursprüngliche Route öffentlich war, ist sie auch in wanderer öffentlich, selbst wenn Routen laut deinen Privatsphäre-Einstellungen standardmäßig privat sind.",
|
||||
"plugin-privacy-hint-user": "Die ursprüngliche Sichtbarkeit der Route wird verworfen. Stattdessen werden die lokalen Privatsphäre-Einstellungen für Routen auf alle importierten Routen angewendet.",
|
||||
"plugin-setup-error": "Setup-Fehler",
|
||||
"plugin-error-rate-limited": "Der Anbieter begrenzt gerade Anfragen. Versuche es später erneut.",
|
||||
"plugin-error-provider-unavailable": "Der Anbieter ist momentan nicht erreichbar. Versuche es später erneut.",
|
||||
"plugin-error-invalid-request": "Das Plugin hat eine ungültige Anfrage gesendet.",
|
||||
"plugin-error-internal": "Das Plugin ist unerwartet fehlgeschlagen.",
|
||||
"plugin-error-reconnect-required": "Die Plugin-Autorisierung ist abgelaufen. Verbinde das Plugin erneut.",
|
||||
"invalid-date": "Ungültiges Datum",
|
||||
"invalid-username": "Ungültiger Nutzername",
|
||||
"italian": "Italienisch",
|
||||
"joined": "Beigetreten",
|
||||
"keep-original": "",
|
||||
"keep-original": "Original beibehalten",
|
||||
"keep-private": "Ohne Veröffentlichung fortfahren",
|
||||
"language": "Sprache",
|
||||
"last-sync": "Letzte Synchronisierung",
|
||||
"last-used": "",
|
||||
"latitude": "Breitengrad",
|
||||
"layer": "{n, plural, =1 {Ebene} other {Ebenen}}",
|
||||
@@ -284,6 +313,8 @@
|
||||
"make-one": "Neues erstellen!",
|
||||
"make-thumbnail": "Thumbnail festlegen",
|
||||
"map": "Karte",
|
||||
"map-trail-preview-zoom-level": "Routenlinien anzeigen ab Zoomstufe",
|
||||
"show-trail-start-marker": "Marker am Start der Route anzeigen",
|
||||
"map-style": "Kartenstil",
|
||||
"mark-trail-as-completed": "Route als abgeschlossen markieren",
|
||||
"mark-trail-as-completed-modal-text": "Möchtest du diese Route als abgeschlossen markieren? Du kannst diesen Status jederzeit wieder ändern.",
|
||||
@@ -292,6 +323,7 @@
|
||||
"moderate": "Mittel",
|
||||
"more": "weitere",
|
||||
"more-route-settings": "Weitere Routen-Einstellungen",
|
||||
"move-route-point": "Routenpunkt verschieben",
|
||||
"mountain": "Berg",
|
||||
"mountain-pass": "Bergpass",
|
||||
"must-be-at-least-n-characters-long": "Muss mindestens {n} Zeichen lang sein",
|
||||
@@ -373,6 +405,7 @@
|
||||
"privacy": "Privatsphäre",
|
||||
"private": "Privat",
|
||||
"profile": "Profil",
|
||||
"provider-category": "Provider-Kategorie",
|
||||
"public": "Öffentlich",
|
||||
"public-access": "Öffentlicher Zugriff",
|
||||
"public-share-everyone": "Jeder im Internet mit dem Link kann diese Route sehen",
|
||||
@@ -388,13 +421,17 @@
|
||||
"remote-users-cannot-edit": "Remote-Benutzer können nicht bearbeiten",
|
||||
"removed-trail-from": "Route entfernt aus",
|
||||
"removed-trails-from": "Routen entfernt aus",
|
||||
"remove": "Entfernen",
|
||||
"required": "Pflichtfeld",
|
||||
"reset": "Zurücksetzen",
|
||||
"reset-password": "Passwort zurücksetzen",
|
||||
"reset-route": "Route zurücksetzen",
|
||||
"reverse-direction": "Richtung umkehren",
|
||||
"road": "Straße",
|
||||
"route": "{n, plural, =1 {Route} other {Routen}}",
|
||||
"route-point": "Punkt auf Route",
|
||||
"reset-route-confirm": "Die aktuelle Route wird entfernt. Trail-Details, Fotos, Listen und andere Metadaten bleiben erhalten.",
|
||||
"add-as-endpoint": "Als Endpunkt hinzufügen",
|
||||
"russian": "Russisch",
|
||||
"save": "Speichern",
|
||||
"save-list": "Liste speichern",
|
||||
@@ -406,8 +443,16 @@
|
||||
"search-places": "Orte suchen",
|
||||
"search-trails": "Route suchen",
|
||||
"select-list": "Liste auswählen",
|
||||
"select-category": "Kategorie auswählen",
|
||||
"select-provider-category": "Provider-Kategorie auswählen",
|
||||
"selected": "ausgewählt",
|
||||
"send-to": "Senden an...",
|
||||
"trail-sent": "Trail gesendet",
|
||||
"error-sending-trail": "Fehler beim Senden des Trails",
|
||||
"no-send-plugins": "Kein verbundenes Plugin unterstützt das Senden von Trails. Verbinde zuerst eines in den Plugin-Einstellungen.",
|
||||
"plugins": "Plugins",
|
||||
"plugin-type-trails": "Trails",
|
||||
"plugin-type-trails-description": "Trail-Plugins verbinden wanderer mit externen Diensten und synchronisieren Routen oder Aktivitäten automatisch im Hintergrund.",
|
||||
"set-private": "Verbergen",
|
||||
"set-public": "Veröffentlichen",
|
||||
"settings": "Einstellungen",
|
||||
@@ -429,6 +474,9 @@
|
||||
"settings-privacy-trails-private": "Deine Routen sind standardmäßig privat. Niemand außer Dir kann sie sehen. Du kannst diese Einstellung jederzeit für einzelne Routen ändern.",
|
||||
"settings-privacy-trails-public": "Deine Trails sind standardmäßig öffentlich. Jeder kann sie sehen. Du kannst diese Einstellung jederzeit für einzelne Routen ändern.",
|
||||
"settings-saved": "Einstellungen gespeichert",
|
||||
"save-and-connect": "Speichern & verbinden",
|
||||
"save-and-reconnect": "Speichern & neu verbinden",
|
||||
"save-and-validate": "Speichern & prüfen",
|
||||
"share": "Teilen",
|
||||
"share-profile": "Profil teilen",
|
||||
"share-this-list": "Diese Liste teilen",
|
||||
@@ -454,7 +502,6 @@
|
||||
"statistics": "Statistiken",
|
||||
"stop-drawing": "Zeichnen beenden",
|
||||
"stop-editing": "Bearbeiten beenden",
|
||||
"strava-integration-after-date-hint": "Wenn Ihr Konto eine große Anzahl von Aktivitäten enthält, kann es vorkommen, dass Sie aufgrund der API-Restriktionen von Strava nicht alle Aktivitäten auf einmal synchronisieren können. Um dieses Problem zu umgehen, können Sie unten ein Startdatum festlegen, sodass nur Aktivitäten synchronisiert werden, die nach diesem Datum aufgezeichnet wurden.",
|
||||
"subway-stop": "U-Bahn Eingang",
|
||||
"summit": "Gipfel",
|
||||
"summit-book": "Gipfelbuch",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"Canoeing": "Canoeing",
|
||||
"Climbing": "Climbing",
|
||||
"Hiking": "Hiking",
|
||||
"Other": "Other",
|
||||
"Skiing": "",
|
||||
"Walking": "Walking",
|
||||
"about": "About",
|
||||
@@ -69,11 +70,14 @@
|
||||
"card": "{n, plural, =1 {Card} other {Cards}}",
|
||||
"categories": "Categories",
|
||||
"category": "Category",
|
||||
"category-mapping": "Category mapping",
|
||||
"category-mapping-help": "Removed provider categories are intentionally left unmapped and will be imported without a category.",
|
||||
"change": "Change",
|
||||
"change-email": "Change email",
|
||||
"change-password": "Change password",
|
||||
"changelog": "Changelog",
|
||||
"chinese": "Chinese (simplified)",
|
||||
"clear": "Clear",
|
||||
"clear-all": "Clear all",
|
||||
"dismiss": "Dismiss",
|
||||
"climbing": "Climbing",
|
||||
@@ -102,6 +106,7 @@
|
||||
"creation-date": "Creation date",
|
||||
"crop": "Crop",
|
||||
"cross": "Cross",
|
||||
"cumulative": "Cumulative",
|
||||
"current-password": "Current password",
|
||||
"cycling": "Cycling",
|
||||
"cycling-speed": "Cycling Speed",
|
||||
@@ -116,6 +121,7 @@
|
||||
"delete-linked-trails": "Delete linked trails",
|
||||
"delete-list-confirm": "Do you really want to delete this list? The trails in the list will still be available.",
|
||||
"delete-summit-log-confirm": "Do you really want to delete this summit log? This action cannot be undone.",
|
||||
"delete-route-point": "Delete route point",
|
||||
"delete-trail-confirm": "Do you really want to delete this trail? This action cannot be undone.",
|
||||
"describe-your-trail": "Describe your trail",
|
||||
"description": "Description",
|
||||
@@ -156,7 +162,7 @@
|
||||
"error-copying-trail": "Error copying trail",
|
||||
"error-creating-user": "Error creating user",
|
||||
"error-deleting-token": "Error deleting token",
|
||||
"error-disabling-strava-integration": "Error disabling strava integration",
|
||||
"error-disabling-strava-plugin": "Error disabling strava plugin",
|
||||
"error-during-login": "Error during login",
|
||||
"error-during-password-reset": "Unable to send password reset email",
|
||||
"error-exporting-trail": "Error exporting trail",
|
||||
@@ -170,11 +176,12 @@
|
||||
"error-reading-file": "Error reading file",
|
||||
"error-saving-list": "Error saving list",
|
||||
"error-saving-trail": "Error saving trail",
|
||||
"error-setting-up-integration": "Error setting up {provider} integration",
|
||||
"error-updating-hammerhead-integration": "Error updating Hammerhead integration",
|
||||
"error-updating-komoot-integration": "Error updating komoot integration",
|
||||
"error-setting-up-plugin": "Error setting up {provider} plugin",
|
||||
"error-starting-oauth": "Error starting OAuth connection",
|
||||
"error-updating-hammerhead-plugin": "Error updating Hammerhead plugin",
|
||||
"error-updating-komoot-plugin": "Error updating komoot plugin",
|
||||
"error-updating-password": "Error updating password",
|
||||
"error-updating-strava-integration": "Error updating Strava integration",
|
||||
"error-updating-strava-plugin": "Error updating Strava plugin",
|
||||
"error-uploading-trail-to-hammerhead": "Error uploading trail to Hammerhead",
|
||||
"est-duration": "Est. duration",
|
||||
"everyone-with-the-link": "Everyone with the link",
|
||||
@@ -216,7 +223,6 @@
|
||||
"get-started": "Get started",
|
||||
"grid": "Grid",
|
||||
"grocery-store": "Grocery store",
|
||||
"hammerhead-integration-after-date-hint": "If your hammerhead account is already synced with other trail databases, such as komoot or Strava, start syncing your Hammerhead data may result in duplicates. To avoid this, you can set an start date below, meaning only activities recorded after this date will be synced.",
|
||||
"heading": "Heading",
|
||||
"height": "Height",
|
||||
"help": "Help",
|
||||
@@ -238,16 +244,38 @@
|
||||
"import-hint": "Select or drag GPX, FIT, KML or TCX files here...",
|
||||
"include-description": "Include description",
|
||||
"include-waypoints": "Include waypoints",
|
||||
"integration-description-hammerhead": "Syncs your Hammerhead tours with wanderer in regular intervals.",
|
||||
"integration-description-komoot": "Syncs your komoot tours with wanderer in regular intervals.",
|
||||
"integration-description-strava": "Syncs your Strava routes & activities with wanderer in regular intervals.",
|
||||
"integration-auto-merge-label": "Auto-merge",
|
||||
"integration-auto-merge-hint": "Imported trails are only merged automatically when exactly one clear match is found. The imported trail is always deleted afterwards.",
|
||||
"integration-disabled": "integration disabled",
|
||||
"integration-enabled": "integration enabled",
|
||||
"integration-privacy-hint-original": "Imported trails will maintain the same visibility they have on the external platform. For example, if the original trail was public, it will be public in wanderer, even if trails are private by default according to your privacy settings.",
|
||||
"integration-privacy-hint-user": "The original trail's visibility is discarded. Instead, the local privacy settings for trails are applied to all imported trails.",
|
||||
"integrations": "Integrations",
|
||||
"plugin-auto-merge-label": "Auto-merge",
|
||||
"plugin-auto-merge-hint": "Imported trails are only merged automatically when exactly one clear match is found. The imported trail is always deleted afterwards.",
|
||||
"plugin-after-date-hint": "Set a start date to sync only trails recorded after that date. This can help avoid duplicates when older trails are already available in wanderer.",
|
||||
"plugin-category-remap-action": "Update categories",
|
||||
"plugin-category-remap-back-to-settings": "Back to settings",
|
||||
"plugin-category-remap-backfilled-confirm": "For {count} imported trails, the provider category was detected only after you last changed the wanderer category mapping. Apply the mapping to these trails now? Other trail data stays unchanged.",
|
||||
"plugin-category-remap-confirm": "{count} trails are affected by the category mapping changes. Apply the updated mapping to these trails now? Other trail data stays unchanged.",
|
||||
"plugin-category-remap-confirm-unspecified": "The category mapping was changed. There are currently no imported trails that can be updated immediately. Save changes?",
|
||||
"plugin-category-remap-continue-without-remap": "Continue without remapping",
|
||||
"plugin-category-remap-dismiss-error": "Could not save remap reminder preference",
|
||||
"plugin-category-remap-error": "Could not update imported trail categories",
|
||||
"plugin-category-remap-ignore": "Ignore",
|
||||
"plugin-category-remap-preview-error": "Could not check imported trails for category updates",
|
||||
"plugin-category-remap-success": "Updated categories for {count} imported trails",
|
||||
"plugin-category-remap-sync-hint": "Some of these matches were found during a sync after the mapping was last changed.",
|
||||
"plugin-category-remap-title": "Update imported trail categories?",
|
||||
"plugin-connect-before-enabling": "Connect this plugin before enabling it.",
|
||||
"plugin-disabled": "plugin disabled",
|
||||
"plugin-enabled": "plugin enabled",
|
||||
"plugins-empty-title": "No plugins installed",
|
||||
"plugins-empty-description": "No plugins are available on this server yet. Contact your administrator or read the installation guide.",
|
||||
"plugins-empty-docs-link": "How to install plugins",
|
||||
"plugin-oauth-connected-hint": "This plugin is connected. If you change the OAuth credentials, reconnect it before enabling sync again.",
|
||||
"plugin-oauth-needs-connect-hint": "Save and connect this plugin before enabling sync.",
|
||||
"plugin-privacy-hint-original": "Imported trails will maintain the same visibility they have on the external platform. For example, if the original trail was public, it will be public in wanderer, even if trails are private by default according to your privacy settings.",
|
||||
"plugin-privacy-hint-user": "The original trail's visibility is discarded. Instead, the local privacy settings for trails are applied to all imported trails.",
|
||||
"plugin-setup-error": "Setup error",
|
||||
"plugin-error-rate-limited": "Provider rate limit reached. Try again later.",
|
||||
"plugin-error-provider-unavailable": "Provider is currently unavailable. Try again later.",
|
||||
"plugin-error-invalid-request": "Plugin sent an invalid request.",
|
||||
"plugin-error-internal": "Plugin failed unexpectedly.",
|
||||
"plugin-error-reconnect-required": "Plugin authorization expired. Reconnect the plugin.",
|
||||
"invalid-date": "Invalid Date",
|
||||
"invalid-username": "Invalid username",
|
||||
"italian": "Italian",
|
||||
@@ -255,6 +283,7 @@
|
||||
"keep-original": "Keep original",
|
||||
"keep-private": "Keep private",
|
||||
"language": "Language",
|
||||
"last-sync": "Last sync",
|
||||
"last-used": "Last used",
|
||||
"latitude": "Latitude",
|
||||
"layer": "{n, plural, =1 {Layer} other {Layers}}",
|
||||
@@ -284,6 +313,8 @@
|
||||
"make-one": "Make one!",
|
||||
"make-thumbnail": "Make thumbnail",
|
||||
"map": "Map",
|
||||
"map-trail-preview-zoom-level": "Show trail previews from zoom level",
|
||||
"show-trail-start-marker": "Show marker at start of trail",
|
||||
"map-style": "Map style",
|
||||
"mark-trail-as-completed": "Mark trail as completed",
|
||||
"mark-trail-as-completed-modal-text": "Would you like to mark this trail as completed? You can change this status again at any time.",
|
||||
@@ -292,6 +323,7 @@
|
||||
"moderate": "Moderate",
|
||||
"more": "More",
|
||||
"more-route-settings": "More route settings",
|
||||
"move-route-point": "Move route point",
|
||||
"mountain": "Mountain",
|
||||
"mountain-pass": "Mountain pass",
|
||||
"must-be-at-least-n-characters-long": "Must be at least {n} characters long",
|
||||
@@ -373,6 +405,7 @@
|
||||
"privacy": "Privacy",
|
||||
"private": "Private",
|
||||
"profile": "Profile",
|
||||
"provider-category": "Provider category",
|
||||
"public": "Public",
|
||||
"public-access": "Public access",
|
||||
"public-share-everyone": "Everyone on the internet with the link can see this trail",
|
||||
@@ -388,13 +421,17 @@
|
||||
"remote-users-cannot-edit": "Remote users cannot edit",
|
||||
"removed-trail-from": "Removed trail from",
|
||||
"removed-trails-from": "Removed trails from",
|
||||
"remove": "Remove",
|
||||
"required": "Required",
|
||||
"reset": "Reset",
|
||||
"reset-password": "Reset Password",
|
||||
"reset-route": "Reset route",
|
||||
"reverse-direction": "Reverse direction",
|
||||
"road": "Road",
|
||||
"route": "{n, plural, =1 {Route} other {Routes}}",
|
||||
"route-point": "Route Point",
|
||||
"reset-route-confirm": "The current route will be removed. Trail details, photos, lists and other metadata will be kept.",
|
||||
"add-as-endpoint": "Add as endpoint",
|
||||
"russian": "Russian",
|
||||
"save": "Save",
|
||||
"save-list": "Save List",
|
||||
@@ -406,8 +443,16 @@
|
||||
"search-places": "Search places",
|
||||
"search-trails": "Search trails",
|
||||
"select-list": "Select List",
|
||||
"select-category": "Select category",
|
||||
"select-provider-category": "Select provider category",
|
||||
"selected": "selected",
|
||||
"send-to": "Send to...",
|
||||
"trail-sent": "Trail sent",
|
||||
"error-sending-trail": "Error sending trail",
|
||||
"no-send-plugins": "No connected plugin supports sending trails. Connect one in the plugin settings first.",
|
||||
"plugins": "Plugins",
|
||||
"plugin-type-trails": "Trails",
|
||||
"plugin-type-trails-description": "Trail plugins connect wanderer with external services and automatically sync routes or activities in the background.",
|
||||
"set-private": "Set private",
|
||||
"set-public": "Set public",
|
||||
"settings": "Settings",
|
||||
@@ -429,6 +474,9 @@
|
||||
"settings-privacy-trails-private": "Your trails are private by default. No one except you will be able to see them. You can change this setting at any point for individual trails.",
|
||||
"settings-privacy-trails-public": "Your trails are public by default. Everyone will be able to see them. You can change this setting at any point for individual trails.",
|
||||
"settings-saved": "Settings saved",
|
||||
"save-and-connect": "Save & connect",
|
||||
"save-and-reconnect": "Save & reconnect",
|
||||
"save-and-validate": "Save & validate",
|
||||
"share": "Share",
|
||||
"share-profile": "Share profile",
|
||||
"share-this-list": "Share this list",
|
||||
@@ -454,7 +502,6 @@
|
||||
"statistics": "Statistics",
|
||||
"stop-drawing": "Stop drawing",
|
||||
"stop-editing": "Stop editing",
|
||||
"strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an start date below so that only activities that were recorded after this date are synced.",
|
||||
"subway-stop": "Subway entrance",
|
||||
"summit": "Summit",
|
||||
"summit-book": "Summit Book",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"Canoeing": "Remo",
|
||||
"Climbing": "Escalada",
|
||||
"Hiking": "Senderismo",
|
||||
"Other": "Otro",
|
||||
"Skiing": "",
|
||||
"Walking": "Paseo",
|
||||
"about": "Sobre",
|
||||
@@ -144,7 +145,7 @@
|
||||
"error-copying-trail": "",
|
||||
"error-creating-user": "Error creando el usuario",
|
||||
"error-deleting-token": "",
|
||||
"error-disabling-strava-integration": "Error al desactivar la integración de strava",
|
||||
"error-disabling-strava-plugin": "Error al desactivar el plugin de Strava",
|
||||
"error-during-login": "Error durante el acceso",
|
||||
"error-during-password-reset": "Imposible enviar la contraseña de restablecimiento al correo electrónico",
|
||||
"error-exporting-trail": "Error exportando la ruta",
|
||||
@@ -157,11 +158,11 @@
|
||||
"error-reading-file": "Error leyendo el archivo",
|
||||
"error-saving-list": "Error guardando la lista",
|
||||
"error-saving-trail": "Error guardando la ruta",
|
||||
"error-setting-up-integration": "Error al configurar la integración con {provider}",
|
||||
"error-updating-hammerhead-integration": "",
|
||||
"error-updating-komoot-integration": "",
|
||||
"error-setting-up-plugin": "Error al configurar el plugin {provider}",
|
||||
"error-updating-hammerhead-plugin": "",
|
||||
"error-updating-komoot-plugin": "",
|
||||
"error-updating-password": "Error actualizando la contraseña",
|
||||
"error-updating-strava-integration": "Error al actualizar la integración con komoot",
|
||||
"error-updating-strava-plugin": "Error al actualizar el plugin Strava",
|
||||
"error-uploading-trail-to-hammerhead": "",
|
||||
"est-duration": "Duración estimada",
|
||||
"everyone-with-the-link": "Cualquier persona con el enlace",
|
||||
@@ -202,7 +203,6 @@
|
||||
"get-started": "Iniciar",
|
||||
"grid": "Cuadricula",
|
||||
"grocery-store": "Supermercado",
|
||||
"hammerhead-integration-after-date-hint": "",
|
||||
"heading": "Título",
|
||||
"height": "Altura",
|
||||
"help": "Ayuda",
|
||||
@@ -224,14 +224,11 @@
|
||||
"import-hint": "Selecciona o arrastra aquí archivos GPX, FIT, KML o TCX...",
|
||||
"include-description": "Incluir descripción",
|
||||
"include-waypoints": "Incluir puntos de interés",
|
||||
"integration-description-hammerhead": "",
|
||||
"integration-description-komoot": "Sincroniza tus recorridos de Komoot con Wanderer en intervalos regulares.",
|
||||
"integration-description-strava": "Sincroniza tus recorridos y actividades de Strava con Wanderer en intervalos regulares.",
|
||||
"integration-disabled": "integración desactivada",
|
||||
"integration-enabled": "integración activada",
|
||||
"integration-privacy-hint-original": "",
|
||||
"integration-privacy-hint-user": "",
|
||||
"integrations": "Integraciones",
|
||||
"plugin-disabled": "plugin desactivado",
|
||||
"plugin-enabled": "plugin activado",
|
||||
"plugin-privacy-hint-original": "",
|
||||
"plugin-privacy-hint-user": "",
|
||||
"plugins": "Plugins",
|
||||
"invalid-date": "Fecha no válida",
|
||||
"invalid-username": "Usuario no válido",
|
||||
"italian": "Italiano",
|
||||
@@ -264,6 +261,8 @@
|
||||
"make-one": "¡Crea uno!",
|
||||
"make-thumbnail": "Generar miniaturas",
|
||||
"map": "Mapa",
|
||||
"map-trail-preview-zoom-level": "Mostrar vistas previas de rutas a partir del nivel de zoom",
|
||||
"show-trail-start-marker": "Mostrar marcador al inicio de la ruta",
|
||||
"map-style": "Estilo de mapa",
|
||||
"mark-trail-as-completed": "",
|
||||
"mark-trail-as-completed-modal-text": "",
|
||||
@@ -373,6 +372,7 @@
|
||||
"road": "Carretera",
|
||||
"route": "{n, plural, one {}=1 {Ruta} other {Rutas}}",
|
||||
"route-point": "Punto de ruta",
|
||||
"add-as-endpoint": "Add as endpoint",
|
||||
"russian": "Ruso",
|
||||
"save": "Guardar",
|
||||
"save-list": "Guardar Lista",
|
||||
@@ -431,7 +431,6 @@
|
||||
"statistics": "Estadísticas",
|
||||
"stop-drawing": "Parar de diseñar",
|
||||
"stop-editing": "Parar de editar",
|
||||
"strava-integration-after-date-hint": "Si tu cuenta tiene una gran cantidad de actividades, es posible que alcances el límite de peticiones de la API de Strava, lo que impedirá la sincronización de todas las actividades a la vez. Para mitigar este problema, puedes establecer una fecha \"Posterior a\" a continuación, de modo que solo se sincronicen las actividades que se registraron después de esa fecha.",
|
||||
"subway-stop": "Entrada de metro",
|
||||
"summit": "Cumbre",
|
||||
"summit-book": "Libro de ascensos",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"Canoeing": "Kanoa",
|
||||
"Climbing": "Eskalada",
|
||||
"Hiking": "Mendi-ibilaldia",
|
||||
"Other": "Besteak",
|
||||
"Skiing": "",
|
||||
"Walking": "Oinez",
|
||||
"about": "Honi buruz",
|
||||
@@ -144,7 +145,7 @@
|
||||
"error-copying-trail": "",
|
||||
"error-creating-user": "Errorea erabiltzailea sortzen",
|
||||
"error-deleting-token": "",
|
||||
"error-disabling-strava-integration": "Errorea stravarekin integrazioa desaktibatzean",
|
||||
"error-disabling-strava-plugin": "Errorea strava plugina desaktibatzean",
|
||||
"error-during-login": "Errorea sartzean",
|
||||
"error-during-password-reset": "Ezin izan da pasahitza berrezartzeko mezua bidali",
|
||||
"error-exporting-trail": "Errorea ibilbidea esportatzean",
|
||||
@@ -157,11 +158,11 @@
|
||||
"error-reading-file": "Errorea fitxategia irakurtzean",
|
||||
"error-saving-list": "Errorea zerrenda gordetzean",
|
||||
"error-saving-trail": "Errorea ibilbidea gordetzean",
|
||||
"error-setting-up-integration": "Errorea {provider} integrazioa egitean",
|
||||
"error-updating-hammerhead-integration": "",
|
||||
"error-updating-komoot-integration": "",
|
||||
"error-setting-up-plugin": "Errorea {provider} plugina konfiguratzean",
|
||||
"error-updating-hammerhead-plugin": "",
|
||||
"error-updating-komoot-plugin": "",
|
||||
"error-updating-password": "Errorea pasahitza eguneratzean",
|
||||
"error-updating-strava-integration": "Errorea komoot integrazioa eguneratzean",
|
||||
"error-updating-strava-plugin": "Errorea Strava plugina eguneratzean",
|
||||
"error-uploading-trail-to-hammerhead": "",
|
||||
"est-duration": "Ustezko iraupena",
|
||||
"everyone-with-the-link": "Esteka duen edonor",
|
||||
@@ -202,7 +203,6 @@
|
||||
"get-started": "Hasi",
|
||||
"grid": "Sareta",
|
||||
"grocery-store": "Janari-denda",
|
||||
"hammerhead-integration-after-date-hint": "",
|
||||
"heading": "Goiburukoa",
|
||||
"height": "Altuera",
|
||||
"help": "Laguntza",
|
||||
@@ -224,14 +224,11 @@
|
||||
"import-hint": "Aukeratu edo arrastatu hona GPX, FIT, KML edo TCX fitxategiak...",
|
||||
"include-description": "Gehitu deskribapena",
|
||||
"include-waypoints": "Gehitu bidepuntuak",
|
||||
"integration-description-hammerhead": "",
|
||||
"integration-description-komoot": "Zure komooteko ibilbideak wandererekin sinkronizatzen ditu aldian behin.",
|
||||
"integration-description-strava": "Zure stravako ibilbideak wandererekin sinkronizatzen ditu aldian behin.",
|
||||
"integration-disabled": "integrazioa desaktibatuta",
|
||||
"integration-enabled": "integrazioa aktibatuta",
|
||||
"integration-privacy-hint-original": "",
|
||||
"integration-privacy-hint-user": "",
|
||||
"integrations": "Integrazioak",
|
||||
"plugin-disabled": "plugina desaktibatuta",
|
||||
"plugin-enabled": "plugina aktibatuta",
|
||||
"plugin-privacy-hint-original": "",
|
||||
"plugin-privacy-hint-user": "",
|
||||
"plugins": "Pluginak",
|
||||
"invalid-date": "Data ez da zuzena",
|
||||
"invalid-username": "Erabiltzailea ez da zuzena",
|
||||
"italian": "Italiera",
|
||||
@@ -264,6 +261,8 @@
|
||||
"make-one": "Egin bat!",
|
||||
"make-thumbnail": "Egin iruditxoa",
|
||||
"map": "Mapa",
|
||||
"map-trail-preview-zoom-level": "Erakutsi ibilbideen aurrebistak zoom-mailatik aurrera",
|
||||
"show-trail-start-marker": "Erakutsi markatzailea ibilbidearen hasieran",
|
||||
"map-style": "Maparen estiloa",
|
||||
"mark-trail-as-completed": "",
|
||||
"mark-trail-as-completed-modal-text": "",
|
||||
@@ -373,6 +372,7 @@
|
||||
"road": "Errepidea",
|
||||
"route": "{n, plural, one {}=1 {ibilbide} other {ibilbide}}",
|
||||
"route-point": "Ibilbideko puntua",
|
||||
"add-as-endpoint": "Add as endpoint",
|
||||
"russian": "Errusiera",
|
||||
"save": "Gorde",
|
||||
"save-list": "Gorde zerrenda",
|
||||
@@ -431,7 +431,6 @@
|
||||
"statistics": "Estatistikak",
|
||||
"stop-drawing": "Utzi marrazteari",
|
||||
"stop-editing": "Utzi editatzeari",
|
||||
"strava-integration-after-date-hint": "Zure kontuak ekintza esko baditu Stravaren APIaren mugekin topo egin dezakezu eta agian ezingo dituzu zure ekintza guztiak aldi berean inportatu. Horretarako data jakin batetik aurrerako ekintzak sinkronizatzeko aukera duzu.",
|
||||
"subway-stop": "Metro sarbidea",
|
||||
"summit": "Gailurra",
|
||||
"summit-book": "Igoeren liburua",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"Canoeing": "Canoë",
|
||||
"Climbing": "Escalade",
|
||||
"Hiking": "Randonnée",
|
||||
"Other": "Autre",
|
||||
"Skiing": "",
|
||||
"Walking": "Marche",
|
||||
"about": "Informations",
|
||||
@@ -144,7 +145,7 @@
|
||||
"error-copying-trail": "",
|
||||
"error-creating-user": "Erreur durant la création de l'utilisateur",
|
||||
"error-deleting-token": "",
|
||||
"error-disabling-strava-integration": "Erreur lors de la désactivation de l'intégration Strava",
|
||||
"error-disabling-strava-plugin": "Erreur lors de la désactivation du plugin Strava",
|
||||
"error-during-login": "Erreur durant la connexion",
|
||||
"error-during-password-reset": "Impossible d'envoyer l'e-mail de réinitialisation du mot de passe",
|
||||
"error-exporting-trail": "Erreur lors de l'export de l'itinéraire",
|
||||
@@ -157,11 +158,11 @@
|
||||
"error-reading-file": "Erreur de lecture du fichier",
|
||||
"error-saving-list": "Erreur lors de l'enregistrement de la liste",
|
||||
"error-saving-trail": "Erreur lors de l'enregistrement de l'itinéraire",
|
||||
"error-setting-up-integration": "Error setting up strava integration",
|
||||
"error-updating-hammerhead-integration": "",
|
||||
"error-updating-komoot-integration": "",
|
||||
"error-setting-up-plugin": "Erreur lors de la configuration du plugin {provider}",
|
||||
"error-updating-hammerhead-plugin": "",
|
||||
"error-updating-komoot-plugin": "",
|
||||
"error-updating-password": "Erreur lors de la mise à jour du mot de passe",
|
||||
"error-updating-strava-integration": "Erreur lors de la mise à jour de l'intégration Komoot",
|
||||
"error-updating-strava-plugin": "Erreur lors de la mise à jour du plugin Strava",
|
||||
"error-uploading-trail-to-hammerhead": "",
|
||||
"est-duration": "Temps estimé",
|
||||
"everyone-with-the-link": "Tout le monde avec ce lien",
|
||||
@@ -202,7 +203,6 @@
|
||||
"get-started": "C'est parti",
|
||||
"grid": "Grille",
|
||||
"grocery-store": "Épicerie",
|
||||
"hammerhead-integration-after-date-hint": "",
|
||||
"heading": "Titre",
|
||||
"height": "Hauteur",
|
||||
"help": "Aide",
|
||||
@@ -224,14 +224,11 @@
|
||||
"import-hint": "Sélectionnez ou glissez des fichiers GPX, FIT, KML ou TCX ici...",
|
||||
"include-description": "Inclure la description",
|
||||
"include-waypoints": "Inclure les points de passage",
|
||||
"integration-description-hammerhead": "",
|
||||
"integration-description-komoot": "Synchronisez vos Tours Komoot avec wanderer à intervalles réguliers.",
|
||||
"integration-description-strava": "Synchronisez vos itinéraires et vos activités Strava avec wanderer à intervalles réguliers.",
|
||||
"integration-disabled": "Intégration désactivée",
|
||||
"integration-enabled": "Intégration activée",
|
||||
"integration-privacy-hint-original": "",
|
||||
"integration-privacy-hint-user": "",
|
||||
"integrations": "Intégrations",
|
||||
"plugin-disabled": "Plugin désactivé",
|
||||
"plugin-enabled": "Plugin activé",
|
||||
"plugin-privacy-hint-original": "",
|
||||
"plugin-privacy-hint-user": "",
|
||||
"plugins": "Plugins",
|
||||
"invalid-date": "Date invalide",
|
||||
"invalid-username": "Nom d'utilisateur invalide",
|
||||
"italian": "Italien",
|
||||
@@ -264,6 +261,8 @@
|
||||
"make-one": "Faites-en un !",
|
||||
"make-thumbnail": "Créer une miniature",
|
||||
"map": "Carte",
|
||||
"map-trail-preview-zoom-level": "Afficher les aperçus d'itinéraires à partir du niveau de zoom",
|
||||
"show-trail-start-marker": "Afficher un marqueur au début de l'itinéraire",
|
||||
"map-style": "Style de carte",
|
||||
"mark-trail-as-completed": "",
|
||||
"mark-trail-as-completed-modal-text": "",
|
||||
@@ -373,6 +372,7 @@
|
||||
"road": "Route",
|
||||
"route": "{n, plural, =1 {Itinéraire} other {Itinéraires}}",
|
||||
"route-point": "Étape",
|
||||
"add-as-endpoint": "Add as endpoint",
|
||||
"russian": "Russe",
|
||||
"save": "Sauvegarder",
|
||||
"save-list": "Sauvegarder la liste",
|
||||
@@ -431,7 +431,6 @@
|
||||
"statistics": "Statistiques",
|
||||
"stop-drawing": "Arrêter de tracer",
|
||||
"stop-editing": "Arrêter la modification",
|
||||
"strava-integration-after-date-hint": "Si votre compte a une grande quantité d'activités, vous pouvez rencontrer la limite d'utilisation de l'API de Strava vous empêchant de synchroniser toutes les activités en même temps. Pour atténuer ce problème, vous pouvez définir une date \"Après-\" ci-dessous afin que seules les activités qui ont été enregistrées après cette date soient synchronisées.",
|
||||
"subway-stop": "Bouche de métro",
|
||||
"summit": "Sommet",
|
||||
"summit-book": "Liste des ascensions",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"Canoeing": "Canoeing",
|
||||
"Climbing": "Climbing",
|
||||
"Hiking": "Hiking",
|
||||
"Other": "Egyéb",
|
||||
"Skiing": "",
|
||||
"Walking": "Walking",
|
||||
"about": "A programról",
|
||||
@@ -144,7 +145,7 @@
|
||||
"error-copying-trail": "",
|
||||
"error-creating-user": "Hiba felhasználó hozzáadása közben",
|
||||
"error-deleting-token": "",
|
||||
"error-disabling-strava-integration": "Error disabling strava integration",
|
||||
"error-disabling-strava-plugin": "Error disabling strava plugin",
|
||||
"error-during-login": "Hiba bejelentkezés közben",
|
||||
"error-during-password-reset": "Unable to send password reset email",
|
||||
"error-exporting-trail": "Error exporting trail",
|
||||
@@ -157,11 +158,11 @@
|
||||
"error-reading-file": "Hiba a fájl olvasása közben",
|
||||
"error-saving-list": "Error saving list",
|
||||
"error-saving-trail": "Error saving trail",
|
||||
"error-setting-up-integration": "Error setting up strava integration",
|
||||
"error-updating-hammerhead-integration": "",
|
||||
"error-updating-komoot-integration": "",
|
||||
"error-setting-up-plugin": "Error setting up {provider} plugin",
|
||||
"error-updating-hammerhead-plugin": "",
|
||||
"error-updating-komoot-plugin": "",
|
||||
"error-updating-password": "Error updating password",
|
||||
"error-updating-strava-integration": "Error updating komoot integration",
|
||||
"error-updating-strava-plugin": "Error updating Strava plugin",
|
||||
"error-uploading-trail-to-hammerhead": "",
|
||||
"est-duration": "Becsült időtartam",
|
||||
"everyone-with-the-link": "Everyone with the link",
|
||||
@@ -202,7 +203,6 @@
|
||||
"get-started": "Get started",
|
||||
"grid": "Grid",
|
||||
"grocery-store": "Grocery store",
|
||||
"hammerhead-integration-after-date-hint": "",
|
||||
"heading": "Heading",
|
||||
"height": "Height",
|
||||
"help": "Help",
|
||||
@@ -224,14 +224,11 @@
|
||||
"import-hint": "Select or drag GPX, FIT, KML or TCX files here...",
|
||||
"include-description": "Include description",
|
||||
"include-waypoints": "Útpontok hozzáadása",
|
||||
"integration-description-hammerhead": "",
|
||||
"integration-description-komoot": "Syncs your komoot tours with wanderer in regular intervals.",
|
||||
"integration-description-strava": "Syncs your strava routes & activities with wanderer in regular intervals.",
|
||||
"integration-disabled": "integration disabled",
|
||||
"integration-enabled": "integration enabled",
|
||||
"integration-privacy-hint-original": "",
|
||||
"integration-privacy-hint-user": "",
|
||||
"integrations": "Integrations",
|
||||
"plugin-disabled": "plugin disabled",
|
||||
"plugin-enabled": "plugin enabled",
|
||||
"plugin-privacy-hint-original": "",
|
||||
"plugin-privacy-hint-user": "",
|
||||
"plugins": "Bővítmények",
|
||||
"invalid-date": "Érvénytelen dátum",
|
||||
"invalid-username": "Érvénytelen felhasználó",
|
||||
"italian": "Olasz",
|
||||
@@ -264,6 +261,8 @@
|
||||
"make-one": "Készítsen egyet!",
|
||||
"make-thumbnail": "Készítsen miniatűrképet",
|
||||
"map": "Térkép",
|
||||
"map-trail-preview-zoom-level": "Útvonal-előnézetek megjelenítése ettől a nagyítási szinttől",
|
||||
"show-trail-start-marker": "Jelölő megjelenítése az útvonal elején",
|
||||
"map-style": "Map style",
|
||||
"mark-trail-as-completed": "",
|
||||
"mark-trail-as-completed-modal-text": "",
|
||||
@@ -373,6 +372,7 @@
|
||||
"road": "Road",
|
||||
"route": "{n, plural, =1 {Route} other {Routes}}",
|
||||
"route-point": "Route Point",
|
||||
"add-as-endpoint": "Add as endpoint",
|
||||
"russian": "Russian",
|
||||
"save": "Mentés",
|
||||
"save-list": "Save List",
|
||||
@@ -431,7 +431,6 @@
|
||||
"statistics": "Statistics",
|
||||
"stop-drawing": "Stop drawing",
|
||||
"stop-editing": "Stop editing",
|
||||
"strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.",
|
||||
"subway-stop": "Subway entrance",
|
||||
"summit": "Summit",
|
||||
"summit-book": "Csúcspont könyv",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"Canoeing": "Canoa",
|
||||
"Climbing": "Arrampicata",
|
||||
"Hiking": "Escursionismo",
|
||||
"Other": "Altro",
|
||||
"Skiing": "",
|
||||
"Walking": "Camminare",
|
||||
"about": "Su di noi",
|
||||
@@ -144,7 +145,7 @@
|
||||
"error-copying-trail": "",
|
||||
"error-creating-user": "Errore nella creazione dell'utente",
|
||||
"error-deleting-token": "",
|
||||
"error-disabling-strava-integration": "Error disabling strava integration",
|
||||
"error-disabling-strava-plugin": "Error disabling strava plugin",
|
||||
"error-during-login": "Errore durante il login",
|
||||
"error-during-password-reset": "Impossibile inviare email per ripristinare la password",
|
||||
"error-exporting-trail": "Errore durante l'esportazione del percorso",
|
||||
@@ -157,11 +158,11 @@
|
||||
"error-reading-file": "Errore durante la lettura del file",
|
||||
"error-saving-list": "Errore salvando la lista",
|
||||
"error-saving-trail": "Errore nel salvataggio del percorso",
|
||||
"error-setting-up-integration": "Error setting up strava integration",
|
||||
"error-updating-hammerhead-integration": "",
|
||||
"error-updating-komoot-integration": "",
|
||||
"error-setting-up-plugin": "Errore durante la configurazione del plugin {provider}",
|
||||
"error-updating-hammerhead-plugin": "",
|
||||
"error-updating-komoot-plugin": "",
|
||||
"error-updating-password": "Errore nell'aggiornamento della password",
|
||||
"error-updating-strava-integration": "Error updating komoot integration",
|
||||
"error-updating-strava-plugin": "Errore durante l'aggiornamento del plugin Strava",
|
||||
"error-uploading-trail-to-hammerhead": "",
|
||||
"est-duration": "Durata stimata",
|
||||
"everyone-with-the-link": "Everyone with the link",
|
||||
@@ -202,7 +203,6 @@
|
||||
"get-started": "Get started",
|
||||
"grid": "Griglia",
|
||||
"grocery-store": "Grocery store",
|
||||
"hammerhead-integration-after-date-hint": "",
|
||||
"heading": "Heading",
|
||||
"height": "Height",
|
||||
"help": "Aiuto",
|
||||
@@ -224,14 +224,11 @@
|
||||
"import-hint": "Seleziona o trascina qui i file GPX, FIT, KML o TCX...",
|
||||
"include-description": "Adotta descrizione",
|
||||
"include-waypoints": "Includi waypoint",
|
||||
"integration-description-hammerhead": "",
|
||||
"integration-description-komoot": "Syncs your komoot tours with wanderer in regular intervals.",
|
||||
"integration-description-strava": "Syncs your strava routes & activities with wanderer in regular intervals.",
|
||||
"integration-disabled": "integration disabled",
|
||||
"integration-enabled": "integration enabled",
|
||||
"integration-privacy-hint-original": "",
|
||||
"integration-privacy-hint-user": "",
|
||||
"integrations": "Integrations",
|
||||
"plugin-disabled": "plugin disattivato",
|
||||
"plugin-enabled": "plugin attivato",
|
||||
"plugin-privacy-hint-original": "",
|
||||
"plugin-privacy-hint-user": "",
|
||||
"plugins": "Plugin",
|
||||
"invalid-date": "Data non valida",
|
||||
"invalid-username": "Nome utente non valido",
|
||||
"italian": "Italiano",
|
||||
@@ -264,6 +261,8 @@
|
||||
"make-one": "Creane uno!",
|
||||
"make-thumbnail": "Imposta miniatura",
|
||||
"map": "Mappa",
|
||||
"map-trail-preview-zoom-level": "Mostra le anteprime dei percorsi dal livello di zoom",
|
||||
"show-trail-start-marker": "Mostra un indicatore all'inizio del percorso",
|
||||
"map-style": "Map style",
|
||||
"mark-trail-as-completed": "",
|
||||
"mark-trail-as-completed-modal-text": "",
|
||||
@@ -373,6 +372,7 @@
|
||||
"road": "Road",
|
||||
"route": "{n, plural, =1 {Route} other {Routes}}",
|
||||
"route-point": "Route Point",
|
||||
"add-as-endpoint": "Add as endpoint",
|
||||
"russian": "Russian",
|
||||
"save": "Salva",
|
||||
"save-list": "Salta Lista",
|
||||
@@ -431,7 +431,6 @@
|
||||
"statistics": "Statistiche",
|
||||
"stop-drawing": "Smettere di disegnare",
|
||||
"stop-editing": "Stop editing",
|
||||
"strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.",
|
||||
"subway-stop": "Subway entrance",
|
||||
"summit": "Summit",
|
||||
"summit-book": "Libro di vetta",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"Canoeing": "Kanoën",
|
||||
"Climbing": "Klimmen",
|
||||
"Hiking": "Hiken",
|
||||
"Other": "Overig",
|
||||
"Skiing": "",
|
||||
"Walking": "Wandelen",
|
||||
"about": "Over",
|
||||
@@ -144,7 +145,7 @@
|
||||
"error-copying-trail": "",
|
||||
"error-creating-user": "Fout bij aanmaken gebruiker",
|
||||
"error-deleting-token": "",
|
||||
"error-disabling-strava-integration": "Fout bij het uitschakelen van Strava-integratie",
|
||||
"error-disabling-strava-plugin": "Fout bij het uitschakelen van Strava-plug-in",
|
||||
"error-during-login": "Het inloggen is mislukt",
|
||||
"error-during-password-reset": "Kan geen e-mail voor wachtwoordherstel verzenden",
|
||||
"error-exporting-trail": "Fout bij exporteren van parcours",
|
||||
@@ -157,11 +158,11 @@
|
||||
"error-reading-file": "Fout bij inlezen bestand",
|
||||
"error-saving-list": "Fout bij bewaren van lijst",
|
||||
"error-saving-trail": "Fout bij bewaren van route",
|
||||
"error-setting-up-integration": "Fout bij opzetten {provider} integratie",
|
||||
"error-updating-hammerhead-integration": "",
|
||||
"error-updating-komoot-integration": "",
|
||||
"error-setting-up-plugin": "Fout bij instellen van {provider}-plug-in",
|
||||
"error-updating-hammerhead-plugin": "",
|
||||
"error-updating-komoot-plugin": "",
|
||||
"error-updating-password": "Fout bij bijwerken van wachtwoord",
|
||||
"error-updating-strava-integration": "Fout bij bijwerken van Komoot integratie",
|
||||
"error-updating-strava-plugin": "Fout bij bijwerken van Strava-plug-in",
|
||||
"error-uploading-trail-to-hammerhead": "",
|
||||
"est-duration": "Geschatte duur",
|
||||
"everyone-with-the-link": "Iedereen met de link",
|
||||
@@ -202,7 +203,6 @@
|
||||
"get-started": "Aan de slag",
|
||||
"grid": "Rooster",
|
||||
"grocery-store": "Kruidenier",
|
||||
"hammerhead-integration-after-date-hint": "",
|
||||
"heading": "Titel",
|
||||
"height": "Hoogte",
|
||||
"help": "Help",
|
||||
@@ -224,14 +224,11 @@
|
||||
"import-hint": "Selecteer of sleep GPX-, FIT-, KML- of TCX-bestanden hierheen...",
|
||||
"include-description": "Inclusief beschrijving",
|
||||
"include-waypoints": "Waypoints toevoegen",
|
||||
"integration-description-hammerhead": "",
|
||||
"integration-description-komoot": "Synchroniseert je Komoot-tochten met Wanderer op regelmatige tijdstippen.",
|
||||
"integration-description-strava": "Synchroniseert je Strava-routes en -activiteiten met Wanderer op regelmatige tijdstippen.",
|
||||
"integration-disabled": "Integratie uitgeschakeld",
|
||||
"integration-enabled": "Integratie ingeschakeld",
|
||||
"integration-privacy-hint-original": "",
|
||||
"integration-privacy-hint-user": "",
|
||||
"integrations": "Integraties",
|
||||
"plugin-disabled": "Plug-in uitgeschakeld",
|
||||
"plugin-enabled": "Plug-in ingeschakeld",
|
||||
"plugin-privacy-hint-original": "",
|
||||
"plugin-privacy-hint-user": "",
|
||||
"plugins": "Plug-ins",
|
||||
"invalid-date": "Ongeldige datum",
|
||||
"invalid-username": "Ongeldige gebruikersnaam",
|
||||
"italian": "Italiaans",
|
||||
@@ -264,6 +261,8 @@
|
||||
"make-one": "Maak er een aan!",
|
||||
"make-thumbnail": "Miniatuur maken",
|
||||
"map": "Kaart",
|
||||
"map-trail-preview-zoom-level": "Routevoorbeelden tonen vanaf zoomniveau",
|
||||
"show-trail-start-marker": "Markering aan het begin van de route tonen",
|
||||
"map-style": "Kaartstijl",
|
||||
"mark-trail-as-completed": "",
|
||||
"mark-trail-as-completed-modal-text": "",
|
||||
@@ -373,6 +372,7 @@
|
||||
"road": "Weg",
|
||||
"route": "{n, plural,=1 {Tocht} other {Tochten}}",
|
||||
"route-point": "Routepunt",
|
||||
"add-as-endpoint": "Add as endpoint",
|
||||
"russian": "Russisch",
|
||||
"save": "Bewaren",
|
||||
"save-list": "Bewaar lijst",
|
||||
@@ -431,7 +431,6 @@
|
||||
"statistics": "Statistieken",
|
||||
"stop-drawing": "Stop met tekenen",
|
||||
"stop-editing": "Stop met bewerken",
|
||||
"strava-integration-after-date-hint": "Als uw account een grote hoeveelheid activiteiten heeft, kunt u op de API-limiet van Strava botsen voorkomend dat u alle activiteiten tegelijk synchroniseert. Om dit probleem te omzeilen kunt u een \"Later\" datum hieronder instellen, zodat alleen activiteiten die na deze datum werden opgenomen worden gesynchroniseerd.",
|
||||
"subway-stop": "Metro toegang",
|
||||
"summit": "Top",
|
||||
"summit-book": "Bergtopboek",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"Canoeing": "Padling",
|
||||
"Climbing": "Klatring",
|
||||
"Hiking": "Vandring",
|
||||
"Other": "Annet",
|
||||
"Skiing": "Skisport",
|
||||
"Walking": "Gåtur",
|
||||
"about": "Om",
|
||||
@@ -144,7 +145,7 @@
|
||||
"error-copying-trail": "Feil ved kopiering av sti",
|
||||
"error-creating-user": "Feil ved oppretting av bruker",
|
||||
"error-deleting-token": "Feil ved sletting av token",
|
||||
"error-disabling-strava-integration": "Feil ved deaktivering av Strava-integrasjon",
|
||||
"error-disabling-strava-plugin": "Feil ved deaktivering av Strava-programtillegg",
|
||||
"error-during-login": "Feil under innlogging",
|
||||
"error-during-password-reset": "Kunne ikke sende e-post for tilbakestilling av passord",
|
||||
"error-exporting-trail": "Feil ved eksport av sti",
|
||||
@@ -157,11 +158,11 @@
|
||||
"error-reading-file": "Feil ved lesing av fil",
|
||||
"error-saving-list": "Feil ved lagring av liste",
|
||||
"error-saving-trail": "Feil ved lagring av sti",
|
||||
"error-setting-up-integration": "Feil ved oppsett av {provider}-integrasjon",
|
||||
"error-updating-hammerhead-integration": "Feil ved oppdatering av Hammerhead-integrasjon",
|
||||
"error-updating-komoot-integration": "Feil ved oppdatering av Komoot-integrasjon",
|
||||
"error-setting-up-plugin": "Feil ved oppsett av {provider}-programtillegg",
|
||||
"error-updating-hammerhead-plugin": "Feil ved oppdatering av Hammerhead-programtillegg",
|
||||
"error-updating-komoot-plugin": "Feil ved oppdatering av Komoot-programtillegg",
|
||||
"error-updating-password": "Feil ved oppdatering av passord",
|
||||
"error-updating-strava-integration": "Feil ved oppdatering av Komoot-integrasjon",
|
||||
"error-updating-strava-plugin": "Feil ved oppdatering av Strava-programtillegg",
|
||||
"error-uploading-trail-to-hammerhead": "Feil ved opplasting av sti til Hammerhead",
|
||||
"est-duration": "Est. varighet",
|
||||
"everyone-with-the-link": "Alle med lenken",
|
||||
@@ -202,7 +203,6 @@
|
||||
"get-started": "Kom i gang",
|
||||
"grid": "Rutenett",
|
||||
"grocery-store": "Dagligvarebutikk",
|
||||
"hammerhead-integration-after-date-hint": "Hvis Hammerhead-kontoen din allerede er synkronisert med andre stidatabaser, som Komoot eller Strava, kan synkronisering av Hammerhead-data føre til duplikater. For å unngå dette kan du angi en startdato nedenfor, slik at bare aktiviteter registrert etter denne datoen vil bli synkronisert.",
|
||||
"heading": "Overskrift",
|
||||
"height": "Høyde",
|
||||
"help": "Hjelp",
|
||||
@@ -224,14 +224,13 @@
|
||||
"import-hint": "Velg eller dra GPX, FIT, KML eller TCX-filer hit...",
|
||||
"include-description": "Inkluder beskrivelse",
|
||||
"include-waypoints": "Inkluder veipunkter",
|
||||
"integration-description-hammerhead": "Synkroniserer Hammerhead-turene dine med Wanderer med jevne mellomrom.",
|
||||
"integration-description-komoot": "Synkroniserer dine Komoot-turer med Wanderer med jevne mellomrom.",
|
||||
"integration-description-strava": "Synkroniserer dine Strava-ruter og aktiviteter med Wanderer med jevne mellomrom.",
|
||||
"integration-disabled": "integrasjon deaktivert",
|
||||
"integration-enabled": "integrasjon aktivert",
|
||||
"integration-privacy-hint-original": "Importerte stier vil beholde samme synlighet som de har på den eksterne plattformen. For eksempel, hvis den opprinnelige stien var offentlig, vil den være offentlig i Wanderer, selv om stier er private som standard i henhold til personverninnstillingene dine.",
|
||||
"integration-privacy-hint-user": "Den opprinnelige stiens synlighet blir forkastet. I stedet blir de lokale personverninnstillingene for stier brukt på alle importerte stier.",
|
||||
"integrations": "Integrasjoner",
|
||||
"plugin-disabled": "programtillegg deaktivert",
|
||||
"plugin-enabled": "programtillegg aktivert",
|
||||
"plugin-privacy-hint-original": "Importerte stier vil beholde samme synlighet som de har på den eksterne plattformen. For eksempel, hvis den opprinnelige stien var offentlig, vil den være offentlig i Wanderer, selv om stier er private som standard i henhold til personverninnstillingene dine.",
|
||||
"plugin-privacy-hint-user": "Den opprinnelige stiens synlighet blir forkastet. I stedet blir de lokale personverninnstillingene for stier brukt på alle importerte stier.",
|
||||
"plugins": "Programtillegg",
|
||||
"plugin-type-trails": "Trails",
|
||||
"plugin-type-trails-description": "Trail-programtillegg kobler Wanderer til eksterne tjenester og synkroniserer ruter eller aktiviteter automatisk i bakgrunnen.",
|
||||
"invalid-date": "Ugyldig dato",
|
||||
"invalid-username": "Ugyldig brukernavn",
|
||||
"italian": "Italiensk",
|
||||
@@ -264,6 +263,8 @@
|
||||
"make-one": "Lag en!",
|
||||
"make-thumbnail": "Lag miniatyrbilde",
|
||||
"map": "Kart",
|
||||
"map-trail-preview-zoom-level": "Vis ruteforhåndsvisninger fra zoomnivå",
|
||||
"show-trail-start-marker": "Vis markør ved starten av stien",
|
||||
"map-style": "Kartstil",
|
||||
"mark-trail-as-completed": "",
|
||||
"mark-trail-as-completed-modal-text": "",
|
||||
@@ -373,6 +374,7 @@
|
||||
"road": "Vei",
|
||||
"route": "{n, plural, =1 {Rute} other {Ruter}}",
|
||||
"route-point": "Rutepunkt",
|
||||
"add-as-endpoint": "Add as endpoint",
|
||||
"russian": "Russisk",
|
||||
"save": "Lagre",
|
||||
"save-list": "Lagre liste",
|
||||
@@ -431,7 +433,6 @@
|
||||
"statistics": "Statistikk",
|
||||
"stop-drawing": "Stopp tegning",
|
||||
"stop-editing": "Stopp redigering",
|
||||
"strava-integration-after-date-hint": "Hvis kontoen din har en stor mengde aktiviteter kan du støte på Stravas API-hastighetsgrense som hindrer deg i å synkronisere alle aktiviteter samtidig. For å redusere dette problemet kan du sette en \"Etter\" dato nedenfor slik at bare aktiviteter som ble registrert etter denne datoen blir synkronisert.",
|
||||
"subway-stop": "T-baneinngang",
|
||||
"summit": "Topp",
|
||||
"summit-book": "Toppbok",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"Canoeing": "Kajak",
|
||||
"Climbing": "Wspinaczka",
|
||||
"Hiking": "Wędrówka",
|
||||
"Other": "Inne",
|
||||
"Skiing": "",
|
||||
"Walking": "Spacer",
|
||||
"about": "Na temat",
|
||||
@@ -144,7 +145,7 @@
|
||||
"error-copying-trail": "",
|
||||
"error-creating-user": "Błąd tworzenia użytkownika",
|
||||
"error-deleting-token": "",
|
||||
"error-disabling-strava-integration": "Błąd przy wyłączaniu integracji strava",
|
||||
"error-disabling-strava-plugin": "Błąd przy wyłączaniu wtyczki strava",
|
||||
"error-during-login": "Błąd podczas logowania",
|
||||
"error-during-password-reset": "Nie udało się wysłać e-maila z resetowaniem hasła",
|
||||
"error-exporting-trail": "Błąd podczas eksportowania szlaku",
|
||||
@@ -157,11 +158,11 @@
|
||||
"error-reading-file": "Błąd wczytywania pliku",
|
||||
"error-saving-list": "Błąd przy zapisywaniu listy",
|
||||
"error-saving-trail": "Błąd podczas zapisywania szlaku",
|
||||
"error-setting-up-integration": "Error setting up strava integration",
|
||||
"error-updating-hammerhead-integration": "",
|
||||
"error-updating-komoot-integration": "",
|
||||
"error-setting-up-plugin": "Błąd konfiguracji wtyczki {provider}",
|
||||
"error-updating-hammerhead-plugin": "",
|
||||
"error-updating-komoot-plugin": "",
|
||||
"error-updating-password": "Błąd podczas aktualizacji hasła",
|
||||
"error-updating-strava-integration": "Błąd aktualizacji integracji kamoot",
|
||||
"error-updating-strava-plugin": "Błąd aktualizacji wtyczki Strava",
|
||||
"error-uploading-trail-to-hammerhead": "",
|
||||
"est-duration": "Szacowany czas",
|
||||
"everyone-with-the-link": "Everyone with the link",
|
||||
@@ -202,7 +203,6 @@
|
||||
"get-started": "Get started",
|
||||
"grid": "Siatka",
|
||||
"grocery-store": "Grocery store",
|
||||
"hammerhead-integration-after-date-hint": "",
|
||||
"heading": "Heading",
|
||||
"height": "Wysokość",
|
||||
"help": "Pomoc",
|
||||
@@ -224,14 +224,11 @@
|
||||
"import-hint": "Wybierz lub przeciągnij tutaj plik GPX, FIT, KML lub TCX...",
|
||||
"include-description": "Dołącz opis",
|
||||
"include-waypoints": "Uwzględnij punkty trasy",
|
||||
"integration-description-hammerhead": "",
|
||||
"integration-description-komoot": "Synchronizuje trasy kamoot z wanderer w równych odstępach.",
|
||||
"integration-description-strava": "Synchronizuje trasy i aktywność z wanderer w równych odstępach.",
|
||||
"integration-disabled": "integracja wyłączona",
|
||||
"integration-enabled": "integracja włączona",
|
||||
"integration-privacy-hint-original": "",
|
||||
"integration-privacy-hint-user": "",
|
||||
"integrations": "Integracje",
|
||||
"plugin-disabled": "wtyczka wyłączona",
|
||||
"plugin-enabled": "wtyczka włączona",
|
||||
"plugin-privacy-hint-original": "",
|
||||
"plugin-privacy-hint-user": "",
|
||||
"plugins": "Wtyczki",
|
||||
"invalid-date": "Nieprawidłowa data",
|
||||
"invalid-username": "Błędna nazwa użytkownika",
|
||||
"italian": "Włoski",
|
||||
@@ -264,6 +261,8 @@
|
||||
"make-one": "Stwórz ją!",
|
||||
"make-thumbnail": "Zrób miniaturkę",
|
||||
"map": "Mapa",
|
||||
"map-trail-preview-zoom-level": "Pokazuj podglądy tras od poziomu powiększenia",
|
||||
"show-trail-start-marker": "Pokaż znacznik na początku trasy",
|
||||
"map-style": "Map style",
|
||||
"mark-trail-as-completed": "",
|
||||
"mark-trail-as-completed-modal-text": "",
|
||||
@@ -373,6 +372,7 @@
|
||||
"road": "Droga",
|
||||
"route": "{n, plural,=1 {Trasa} other {Trasy}}",
|
||||
"route-point": "Punkt trasy",
|
||||
"add-as-endpoint": "Add as endpoint",
|
||||
"russian": "Russian",
|
||||
"save": "Zapisz",
|
||||
"save-list": "Zapisz listę",
|
||||
@@ -431,7 +431,6 @@
|
||||
"statistics": "Statystyki",
|
||||
"stop-drawing": "Przestań rysować",
|
||||
"stop-editing": "Zakończ edycję",
|
||||
"strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.",
|
||||
"subway-stop": "Subway entrance",
|
||||
"summit": "Summit",
|
||||
"summit-book": "Logbook",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"Canoeing": "Canoagem",
|
||||
"Climbing": "Escalada",
|
||||
"Hiking": "Montanhismo",
|
||||
"Other": "Outro",
|
||||
"Skiing": "",
|
||||
"Walking": "Caminhada",
|
||||
"about": "Sobre",
|
||||
@@ -144,7 +145,7 @@
|
||||
"error-copying-trail": "",
|
||||
"error-creating-user": "Erro ao criar utilizador",
|
||||
"error-deleting-token": "",
|
||||
"error-disabling-strava-integration": "Error disabling strava integration",
|
||||
"error-disabling-strava-plugin": "Error disabling strava plugin",
|
||||
"error-during-login": "Erro durante o ‘login’",
|
||||
"error-during-password-reset": "Unable to send password reset email",
|
||||
"error-exporting-trail": "Erro na exportação do percurso",
|
||||
@@ -157,11 +158,11 @@
|
||||
"error-reading-file": "Erro ao ler o arquivo",
|
||||
"error-saving-list": "Erro ao gravar lista",
|
||||
"error-saving-trail": "Erro ao gravar percurso",
|
||||
"error-setting-up-integration": "Error setting up strava integration",
|
||||
"error-updating-hammerhead-integration": "",
|
||||
"error-updating-komoot-integration": "",
|
||||
"error-setting-up-plugin": "Error setting up {provider} plugin",
|
||||
"error-updating-hammerhead-plugin": "",
|
||||
"error-updating-komoot-plugin": "",
|
||||
"error-updating-password": "Erro ao atualizar password",
|
||||
"error-updating-strava-integration": "Error updating komoot integration",
|
||||
"error-updating-strava-plugin": "Error updating Strava plugin",
|
||||
"error-uploading-trail-to-hammerhead": "",
|
||||
"est-duration": "Duração prevista",
|
||||
"everyone-with-the-link": "Everyone with the link",
|
||||
@@ -202,7 +203,6 @@
|
||||
"get-started": "Get started",
|
||||
"grid": "Grelha",
|
||||
"grocery-store": "Grocery store",
|
||||
"hammerhead-integration-after-date-hint": "",
|
||||
"heading": "Heading",
|
||||
"height": "Height",
|
||||
"help": "Ajuda",
|
||||
@@ -224,14 +224,11 @@
|
||||
"import-hint": "Selecionar ou arrastar ficheiros GPX, FIT, KML ou TCX para aqui...",
|
||||
"include-description": "Incluir descrição",
|
||||
"include-waypoints": "Incluir pontos de passagem",
|
||||
"integration-description-hammerhead": "",
|
||||
"integration-description-komoot": "Syncs your komoot tours with wanderer in regular intervals.",
|
||||
"integration-description-strava": "Syncs your strava routes & activities with wanderer in regular intervals.",
|
||||
"integration-disabled": "integration disabled",
|
||||
"integration-enabled": "integration enabled",
|
||||
"integration-privacy-hint-original": "",
|
||||
"integration-privacy-hint-user": "",
|
||||
"integrations": "Integrations",
|
||||
"plugin-disabled": "plugin disabled",
|
||||
"plugin-enabled": "plugin enabled",
|
||||
"plugin-privacy-hint-original": "",
|
||||
"plugin-privacy-hint-user": "",
|
||||
"plugins": "Plugins",
|
||||
"invalid-date": "Data inválida",
|
||||
"invalid-username": "Nome de usuário inválido",
|
||||
"italian": "Italiano",
|
||||
@@ -264,6 +261,8 @@
|
||||
"make-one": "Faz um!",
|
||||
"make-thumbnail": "Faça miniatura",
|
||||
"map": "Mapa",
|
||||
"map-trail-preview-zoom-level": "Mostrar pré-visualizações de rotas a partir do nível de zoom",
|
||||
"show-trail-start-marker": "Mostrar marcador no início da rota",
|
||||
"map-style": "Map style",
|
||||
"mark-trail-as-completed": "",
|
||||
"mark-trail-as-completed-modal-text": "",
|
||||
@@ -373,6 +372,7 @@
|
||||
"road": "Road",
|
||||
"route": "{n, plural, =1 {Route} other {Routes}}",
|
||||
"route-point": "Route Point",
|
||||
"add-as-endpoint": "Add as endpoint",
|
||||
"russian": "Russian",
|
||||
"save": "Guardar",
|
||||
"save-list": "Gravar lista",
|
||||
@@ -431,7 +431,6 @@
|
||||
"statistics": "Statistics",
|
||||
"stop-drawing": "Parar desenho",
|
||||
"stop-editing": "Stop editing",
|
||||
"strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.",
|
||||
"subway-stop": "Subway entrance",
|
||||
"summit": "Summit",
|
||||
"summit-book": "Livro da cimeira",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"Canoeing": "Каякинг",
|
||||
"Climbing": "Скалолазание",
|
||||
"Hiking": "Пеший туризм",
|
||||
"Other": "Другое",
|
||||
"Skiing": "",
|
||||
"Walking": "Прогулка",
|
||||
"about": "О",
|
||||
@@ -144,7 +145,7 @@
|
||||
"error-copying-trail": "",
|
||||
"error-creating-user": "Ошибка создания пользователя",
|
||||
"error-deleting-token": "",
|
||||
"error-disabling-strava-integration": "Ошибка отключения Strava",
|
||||
"error-disabling-strava-plugin": "Ошибка отключения Strava",
|
||||
"error-during-login": "Ошибка входа",
|
||||
"error-during-password-reset": "Не удалось отправить email сброса пароля",
|
||||
"error-exporting-trail": "Ошибка экспорта трека",
|
||||
@@ -157,11 +158,11 @@
|
||||
"error-reading-file": "Ошибка чтения файла",
|
||||
"error-saving-list": "Ошибка сохранения списка",
|
||||
"error-saving-trail": "Ошибка сохранения трека",
|
||||
"error-setting-up-integration": "Ошибка настройки {provider}",
|
||||
"error-updating-hammerhead-integration": "",
|
||||
"error-updating-komoot-integration": "",
|
||||
"error-setting-up-plugin": "Ошибка настройки плагина {provider}",
|
||||
"error-updating-hammerhead-plugin": "",
|
||||
"error-updating-komoot-plugin": "",
|
||||
"error-updating-password": "Ошибка изменения пароля",
|
||||
"error-updating-strava-integration": "Ошибка обновления Strava",
|
||||
"error-updating-strava-plugin": "Ошибка обновления плагина Strava",
|
||||
"error-uploading-trail-to-hammerhead": "",
|
||||
"est-duration": "Продолжительность",
|
||||
"everyone-with-the-link": "Everyone with the link",
|
||||
@@ -202,7 +203,6 @@
|
||||
"get-started": "Get started",
|
||||
"grid": "Сетка",
|
||||
"grocery-store": "Продуктовый магазин",
|
||||
"hammerhead-integration-after-date-hint": "",
|
||||
"heading": "Heading",
|
||||
"height": "Высота",
|
||||
"help": "Помощь",
|
||||
@@ -224,14 +224,11 @@
|
||||
"import-hint": "Перетащите GPX, FIT, KML или TCX файлы сюда...",
|
||||
"include-description": "Добавить описание",
|
||||
"include-waypoints": "Include waypoints",
|
||||
"integration-description-hammerhead": "",
|
||||
"integration-description-komoot": "Синхронизирует ваши данные с Komoot.",
|
||||
"integration-description-strava": "Синхронизирует ваши данные со Strava.",
|
||||
"integration-disabled": "интеграция отключена",
|
||||
"integration-enabled": "интеграция включена",
|
||||
"integration-privacy-hint-original": "",
|
||||
"integration-privacy-hint-user": "",
|
||||
"integrations": "Интеграции",
|
||||
"plugin-disabled": "плагин отключен",
|
||||
"plugin-enabled": "плагин включен",
|
||||
"plugin-privacy-hint-original": "",
|
||||
"plugin-privacy-hint-user": "",
|
||||
"plugins": "Плагины",
|
||||
"invalid-date": "Неверная дата",
|
||||
"invalid-username": "Некорректное имя пользователя",
|
||||
"italian": "Итальянский",
|
||||
@@ -264,6 +261,8 @@
|
||||
"make-one": "Создайте!",
|
||||
"make-thumbnail": "Сделать миниатюру",
|
||||
"map": "Карта",
|
||||
"map-trail-preview-zoom-level": "Показывать предпросмотр маршрутов с уровня масштабирования",
|
||||
"show-trail-start-marker": "Показывать маркер в начале маршрута",
|
||||
"map-style": "Стиль карты",
|
||||
"mark-trail-as-completed": "",
|
||||
"mark-trail-as-completed-modal-text": "",
|
||||
@@ -373,6 +372,7 @@
|
||||
"road": "Шоссе",
|
||||
"route": "{n, plural, =1 {Маршрут} other {Маршрутов}}",
|
||||
"route-point": "Точка маршрута",
|
||||
"add-as-endpoint": "Add as endpoint",
|
||||
"russian": "Русский",
|
||||
"save": "Сохранить",
|
||||
"save-list": "Сохранить список",
|
||||
@@ -431,7 +431,6 @@
|
||||
"statistics": "Статистика",
|
||||
"stop-drawing": "Закончить рисование",
|
||||
"stop-editing": "Закончить редактирование",
|
||||
"strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.",
|
||||
"subway-stop": "Вход в метро",
|
||||
"summit": "Вершина",
|
||||
"summit-book": "История поездок",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"Canoeing": "划艇",
|
||||
"Climbing": "攀岩",
|
||||
"Hiking": "徒步",
|
||||
"Other": "其他",
|
||||
"Skiing": "",
|
||||
"Walking": "步行",
|
||||
"about": "关于",
|
||||
@@ -144,7 +145,7 @@
|
||||
"error-copying-trail": "",
|
||||
"error-creating-user": "创建用户错误",
|
||||
"error-deleting-token": "",
|
||||
"error-disabling-strava-integration": "禁用strava集成时出错",
|
||||
"error-disabling-strava-plugin": "禁用strava 插件时出错",
|
||||
"error-during-login": "登录错误",
|
||||
"error-during-password-reset": "无法发送密码重置邮件",
|
||||
"error-exporting-trail": "导出路线失败",
|
||||
@@ -157,11 +158,11 @@
|
||||
"error-reading-file": "读取文件错误",
|
||||
"error-saving-list": "保存列表失败",
|
||||
"error-saving-trail": "保存路线失败",
|
||||
"error-setting-up-integration": "Error setting up strava integration",
|
||||
"error-updating-hammerhead-integration": "",
|
||||
"error-updating-komoot-integration": "",
|
||||
"error-setting-up-plugin": "设置 {provider} 插件时出错",
|
||||
"error-updating-hammerhead-plugin": "",
|
||||
"error-updating-komoot-plugin": "",
|
||||
"error-updating-password": "更新密码失败",
|
||||
"error-updating-strava-integration": "更新 komoot 集成出错",
|
||||
"error-updating-strava-plugin": "更新 Strava 插件时出错",
|
||||
"error-uploading-trail-to-hammerhead": "",
|
||||
"est-duration": "预计时长",
|
||||
"everyone-with-the-link": "Everyone with the link",
|
||||
@@ -202,7 +203,6 @@
|
||||
"get-started": "Get started",
|
||||
"grid": "网格",
|
||||
"grocery-store": "杂货店",
|
||||
"hammerhead-integration-after-date-hint": "",
|
||||
"heading": "标题",
|
||||
"height": "高度",
|
||||
"help": "帮助",
|
||||
@@ -224,14 +224,11 @@
|
||||
"import-hint": "在此选择或拖拽GPX、FIT、KML或TCX文件...",
|
||||
"include-description": "包含描述",
|
||||
"include-waypoints": "包括途径点",
|
||||
"integration-description-hammerhead": "",
|
||||
"integration-description-komoot": "定期与komoot同步您的wanderer。",
|
||||
"integration-description-strava": "定期与strava同步您的wanderer路线和活动。",
|
||||
"integration-disabled": "整合已停用",
|
||||
"integration-enabled": "整合已启用",
|
||||
"integration-privacy-hint-original": "",
|
||||
"integration-privacy-hint-user": "",
|
||||
"integrations": "整合",
|
||||
"plugin-disabled": "插件已停用",
|
||||
"plugin-enabled": "插件已启用",
|
||||
"plugin-privacy-hint-original": "",
|
||||
"plugin-privacy-hint-user": "",
|
||||
"plugins": "插件",
|
||||
"invalid-date": "无效日期",
|
||||
"invalid-username": "无效用户名",
|
||||
"italian": "意大利语",
|
||||
@@ -264,6 +261,8 @@
|
||||
"make-one": "立刻注册!",
|
||||
"make-thumbnail": "生成缩略图",
|
||||
"map": "地图",
|
||||
"map-trail-preview-zoom-level": "从该缩放级别开始显示路线预览",
|
||||
"show-trail-start-marker": "在路线起点显示标记",
|
||||
"map-style": "地图样式",
|
||||
"mark-trail-as-completed": "",
|
||||
"mark-trail-as-completed-modal-text": "",
|
||||
@@ -373,6 +372,7 @@
|
||||
"road": "道路",
|
||||
"route": "{n, plural, =1 {Route} other {Routes}}",
|
||||
"route-point": "路线点",
|
||||
"add-as-endpoint": "Add as endpoint",
|
||||
"russian": "Russian",
|
||||
"save": "保存",
|
||||
"save-list": "保存列表",
|
||||
@@ -431,7 +431,6 @@
|
||||
"statistics": "统计",
|
||||
"stop-drawing": "停止绘制",
|
||||
"stop-editing": "停止编辑",
|
||||
"strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.",
|
||||
"subway-stop": "地铁入口",
|
||||
"summit": "山峰",
|
||||
"summit-book": "详细日程",
|
||||
|
||||
@@ -15,11 +15,21 @@ export interface Actor {
|
||||
icon?: string;
|
||||
followers?: string;
|
||||
following?: string;
|
||||
isLocal: boolean;
|
||||
is_local: boolean;
|
||||
public_key: string;
|
||||
last_fetched: string;
|
||||
user?: string
|
||||
expand?: {
|
||||
user?: User
|
||||
}
|
||||
}
|
||||
|
||||
export interface ActorSearchResult {
|
||||
id: string;
|
||||
username: string;
|
||||
preferred_username: string;
|
||||
domain: string;
|
||||
is_local: boolean;
|
||||
iri: string;
|
||||
icon?: string;
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { z, ZodType } from "zod";
|
||||
import type { Integration } from "../integration";
|
||||
|
||||
const IntegrationMergeSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
|
||||
const StravaSchema = z.object({
|
||||
clientId: z.number({ coerce: true }).int().nonnegative(),
|
||||
clientSecret: z.string().length(40).optional().or(z.literal('')),
|
||||
routes: z.boolean(),
|
||||
activities: z.boolean(),
|
||||
active: z.boolean(),
|
||||
after: z.string().date().optional(),
|
||||
privacy: z.enum(["original", "settings"]),
|
||||
merge: IntegrationMergeSchema,
|
||||
})
|
||||
|
||||
const KomootSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string(),
|
||||
completed: z.boolean(),
|
||||
planned: z.boolean(),
|
||||
active: z.boolean(),
|
||||
privacy: z.enum(["original", "settings"]),
|
||||
merge: IntegrationMergeSchema,
|
||||
})
|
||||
|
||||
const HammerheadSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string(),
|
||||
completed: z.boolean(),
|
||||
planned: z.boolean(),
|
||||
active: z.boolean(),
|
||||
after: z.string().date().optional(),
|
||||
merge: IntegrationMergeSchema,
|
||||
})
|
||||
|
||||
const IntegrationCreateSchema = z.object({
|
||||
user: z.string().length(15),
|
||||
strava: StravaSchema.optional(),
|
||||
komoot: KomootSchema.optional(),
|
||||
hammerhead: HammerheadSchema.optional(),
|
||||
|
||||
}) satisfies ZodType<Integration>
|
||||
|
||||
const IntegrationUpdateSchema = z.object({
|
||||
strava: StravaSchema.optional().nullable(),
|
||||
komoot: KomootSchema.optional().nullable(),
|
||||
hammerhead: HammerheadSchema.optional().nullable(),
|
||||
}) satisfies ZodType<Partial<Integration>>
|
||||
|
||||
export { StravaSchema, IntegrationCreateSchema, IntegrationUpdateSchema, KomootSchema, HammerheadSchema };
|
||||
@@ -918,106 +918,6 @@
|
||||
* type: string
|
||||
* description: Followee user ID (15 chars)
|
||||
*
|
||||
* Integration:
|
||||
* type: object
|
||||
* required:
|
||||
* - id
|
||||
* - user
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* description: Integration ID (15 chars)
|
||||
* user:
|
||||
* type: string
|
||||
* description: User ID (15 chars)
|
||||
* strava:
|
||||
* type: object
|
||||
* properties:
|
||||
* clientId:
|
||||
* type: integer
|
||||
* clientSecret:
|
||||
* type: string
|
||||
* routes:
|
||||
* type: boolean
|
||||
* activities:
|
||||
* type: boolean
|
||||
* active:
|
||||
* type: boolean
|
||||
* after:
|
||||
* type: string
|
||||
* format: date
|
||||
* privacy:
|
||||
* type: string
|
||||
* enum: [original, settings]
|
||||
* komoot:
|
||||
* type: object
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* password:
|
||||
* type: string
|
||||
* completed:
|
||||
* type: boolean
|
||||
* planned:
|
||||
* type: boolean
|
||||
* active:
|
||||
* type: boolean
|
||||
* privacy:
|
||||
* type: string
|
||||
* enum: [original, settings]
|
||||
* hammerhead:
|
||||
* type: object
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* format: email
|
||||
* password:
|
||||
* type: string
|
||||
* completed:
|
||||
* type: boolean
|
||||
* planned:
|
||||
* type: boolean
|
||||
* active:
|
||||
* type: boolean
|
||||
* after:
|
||||
* type: string
|
||||
* format: date
|
||||
* created:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* updated:
|
||||
* type: string
|
||||
* format: date-time
|
||||
*
|
||||
* IntegrationInput:
|
||||
* type: object
|
||||
* required:
|
||||
* - user
|
||||
* properties:
|
||||
* user:
|
||||
* type: string
|
||||
* description: User ID (15 chars)
|
||||
* strava:
|
||||
* type: object
|
||||
* komoot:
|
||||
* type: object
|
||||
* hammerhead:
|
||||
* type: object
|
||||
*
|
||||
* IntegrationUpdateInput:
|
||||
* type: object
|
||||
* properties:
|
||||
* strava:
|
||||
* type: object
|
||||
* nullable: true
|
||||
* komoot:
|
||||
* type: object
|
||||
* nullable: true
|
||||
* hammerhead:
|
||||
* type: object
|
||||
* nullable: true
|
||||
*
|
||||
* Notification:
|
||||
* type: object
|
||||
* required:
|
||||
|
||||
59
web/src/lib/models/api/plugin_instance_schema.ts
Normal file
59
web/src/lib/models/api/plugin_instance_schema.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const PluginInstanceStatusSchema = z.enum([
|
||||
"configured",
|
||||
"needs_auth",
|
||||
"needs_reauth",
|
||||
"syncing",
|
||||
"rate_limited",
|
||||
"unavailable",
|
||||
"unsupported_protocol",
|
||||
"error",
|
||||
"disabled",
|
||||
]);
|
||||
|
||||
const OptionalJsonRecordSchema = z.preprocess(
|
||||
(value) => (value === null ? undefined : value),
|
||||
z.record(z.string(), z.unknown()).optional(),
|
||||
);
|
||||
const OptionalAuthSchema = z.preprocess(
|
||||
(value) => {
|
||||
if (value === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, fieldValue]) => [
|
||||
key,
|
||||
fieldValue == null ? "" : String(fieldValue),
|
||||
]),
|
||||
);
|
||||
},
|
||||
z.record(z.string(), z.string()).optional(),
|
||||
);
|
||||
|
||||
const PluginInstanceCreateSchema = z.object({
|
||||
user: z.string().length(15),
|
||||
plugin_id: z.string().min(1).max(64).regex(/^[a-z0-9][a-z0-9_-]*$/),
|
||||
enabled: z.boolean().default(false),
|
||||
auth: OptionalAuthSchema,
|
||||
config: OptionalJsonRecordSchema,
|
||||
state: OptionalJsonRecordSchema,
|
||||
status: PluginInstanceStatusSchema.optional(),
|
||||
});
|
||||
|
||||
const PluginInstanceUpdateSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
auth: OptionalAuthSchema,
|
||||
config: OptionalJsonRecordSchema,
|
||||
state: OptionalJsonRecordSchema,
|
||||
status: PluginInstanceStatusSchema.optional(),
|
||||
});
|
||||
|
||||
export {
|
||||
PluginInstanceCreateSchema,
|
||||
PluginInstanceUpdateSchema,
|
||||
PluginInstanceStatusSchema,
|
||||
};
|
||||
@@ -22,8 +22,11 @@ const SettingsCreateSchema = z.object({
|
||||
lists: z.enum(["public", "private"])
|
||||
}).optional().nullable(),
|
||||
notifications: z.record(z.enum(Object.values(NotificationType) as [string, ...string[]]), z.object({ web: z.boolean(), email: z.boolean() })).optional().nullable(),
|
||||
behavior: z.object({ allowAutoGeolocate: z.boolean() }).optional().nullable(),
|
||||
behavior: z.object({
|
||||
allowAutoGeolocate: z.boolean(),
|
||||
mapClusteringMaxZoom: z.number().optional(),
|
||||
showTrailStartMarker: z.boolean().optional()
|
||||
}).optional().nullable(),
|
||||
}) satisfies ZodType<Settings>
|
||||
ZodType<Partial<Comment>>
|
||||
|
||||
export { SettingsCreateSchema };
|
||||
|
||||
@@ -45,7 +45,7 @@ const TrailUpdateSchema = z.object({
|
||||
"photos-": z.string().optional(),
|
||||
"photos+": z.string().optional(),
|
||||
thumbnail: z.number().int().nonnegative().optional(),
|
||||
like_count: z.number().int().min(0).optional().default(0),
|
||||
like_count: z.number().int().min(0).optional(),
|
||||
category: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
gpx: z.string().optional(),
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
|
||||
export interface BaseIntegration {
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export interface IntegrationMergeSettings {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface StravaIntegration extends BaseIntegration {
|
||||
clientId: string | number;
|
||||
clientSecret?: string;
|
||||
routes: boolean;
|
||||
activities: boolean;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: number;
|
||||
after?: string
|
||||
privacy: "original" | "settings"
|
||||
merge: IntegrationMergeSettings;
|
||||
}
|
||||
|
||||
export interface KomootIntegration extends BaseIntegration {
|
||||
email: string,
|
||||
password: string,
|
||||
completed: boolean,
|
||||
planned: boolean
|
||||
privacy: "original" | "settings"
|
||||
merge: IntegrationMergeSettings;
|
||||
}
|
||||
|
||||
export interface HammerheadIntegration extends BaseIntegration {
|
||||
email: string,
|
||||
password: string,
|
||||
completed: boolean,
|
||||
planned: boolean,
|
||||
after?: string
|
||||
merge: IntegrationMergeSettings;
|
||||
}
|
||||
|
||||
|
||||
export class Integration {
|
||||
id?: string;
|
||||
user: string;
|
||||
strava?: StravaIntegration | null;
|
||||
komoot?: KomootIntegration | null;
|
||||
hammerhead?: HammerheadIntegration | null;
|
||||
|
||||
constructor(user: string, strava?: StravaIntegration, komoot?: KomootIntegration, hammerhead?: HammerheadIntegration) {
|
||||
this.user = user;
|
||||
this.strava = strava;
|
||||
this.komoot = komoot;
|
||||
this.hammerhead = hammerhead;
|
||||
}
|
||||
}
|
||||
25
web/src/lib/models/plugin_instance.ts
Normal file
25
web/src/lib/models/plugin_instance.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export interface PluginInstance {
|
||||
id?: string;
|
||||
user: string;
|
||||
plugin_id: string;
|
||||
enabled: boolean;
|
||||
auth?: Record<string, string>;
|
||||
config?: Record<string, unknown>;
|
||||
state?: Record<string, unknown>;
|
||||
status:
|
||||
| "configured"
|
||||
| "needs_auth"
|
||||
| "needs_reauth"
|
||||
| "syncing"
|
||||
| "rate_limited"
|
||||
| "unavailable"
|
||||
| "unsupported_protocol"
|
||||
| "error"
|
||||
| "disabled";
|
||||
last_error?: {
|
||||
code?: string;
|
||||
message?: string;
|
||||
};
|
||||
last_sync_at?: string;
|
||||
retry_not_before?: string;
|
||||
}
|
||||
57
web/src/lib/models/plugin_provider.ts
Normal file
57
web/src/lib/models/plugin_provider.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export type LocalizedTextMap = Record<string, string>;
|
||||
|
||||
export interface ConfigFieldOption {
|
||||
value: string;
|
||||
label?: string;
|
||||
labels?: LocalizedTextMap;
|
||||
}
|
||||
|
||||
export interface ConfigField {
|
||||
key: string;
|
||||
type: "boolean" | "date" | "select" | "text" | "url";
|
||||
label?: string;
|
||||
labels?: LocalizedTextMap;
|
||||
description?: string;
|
||||
descriptions?: LocalizedTextMap;
|
||||
options?: ConfigFieldOption[];
|
||||
default?: unknown;
|
||||
required?: boolean;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export interface PluginProvider {
|
||||
id: string;
|
||||
type: "trails";
|
||||
name: string;
|
||||
displayName?: string;
|
||||
displayNames?: LocalizedTextMap;
|
||||
description?: string;
|
||||
descriptions?: LocalizedTextMap;
|
||||
icon?: string;
|
||||
iconDark?: string;
|
||||
version?: string;
|
||||
protocolVersion?: string;
|
||||
risk?: string;
|
||||
auth: {
|
||||
type: string;
|
||||
fields?: string[];
|
||||
secretFields?: string[];
|
||||
authorizationUrl?: string;
|
||||
tokenUrl?: string;
|
||||
tokenRequestFormat?: "json" | "form";
|
||||
scopes?: string[];
|
||||
scopeSeparator?: string;
|
||||
authorizationParams?: Record<string, string>;
|
||||
pkce?: boolean;
|
||||
tokenAuth?: string;
|
||||
};
|
||||
configSchema?: ConfigField[];
|
||||
hostConfig?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
capabilities?: string[];
|
||||
limits?: {
|
||||
recommendedBatchSize?: number;
|
||||
};
|
||||
status: "available" | "disabled" | "error";
|
||||
error?: string;
|
||||
}
|
||||
45
web/src/lib/models/plugin_system.ts
Normal file
45
web/src/lib/models/plugin_system.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export interface PluginSystemCapability {
|
||||
name: string;
|
||||
version: string;
|
||||
export: string;
|
||||
requiredHostFunctions?: string[];
|
||||
job?: string;
|
||||
}
|
||||
|
||||
export interface PluginSystemManifest {
|
||||
manifestVersion: string;
|
||||
id: string;
|
||||
type: "trails";
|
||||
name: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
iconDark?: string;
|
||||
version: string;
|
||||
runtime: {
|
||||
type: string;
|
||||
entrypoint: string;
|
||||
};
|
||||
capabilities: PluginSystemCapability[];
|
||||
auth?: Record<string, unknown>;
|
||||
permissions?: Record<string, unknown>;
|
||||
configSchema?: unknown[];
|
||||
hostConfig?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PluginSystemPlugin {
|
||||
id: string;
|
||||
type: "trails";
|
||||
name: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
iconDark?: string;
|
||||
version: string;
|
||||
runtime: string;
|
||||
capabilities: string[];
|
||||
status: "available" | "disabled" | "error";
|
||||
error?: string;
|
||||
manifest: PluginSystemManifest;
|
||||
}
|
||||
@@ -57,6 +57,8 @@ class Settings {
|
||||
|
||||
export type Behavior = {
|
||||
allowAutoGeolocate: boolean;
|
||||
mapClusteringMaxZoom?: number;
|
||||
showTrailStartMarker?: boolean;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ class Trail {
|
||||
domain?: string;
|
||||
iri?: string;
|
||||
like_count: number;
|
||||
bounding_box_diagonal?: number;
|
||||
expand?: {
|
||||
tags?: Tag[]
|
||||
category?: Category;
|
||||
@@ -74,8 +75,9 @@ class Trail {
|
||||
comments?: Comment[],
|
||||
shares?: TrailShare[],
|
||||
tags?: Tag[],
|
||||
description?: string
|
||||
created?: string
|
||||
description?: string,
|
||||
created?: string,
|
||||
bounding_box_diagonal?: number
|
||||
}
|
||||
|
||||
) {
|
||||
@@ -96,6 +98,7 @@ class Trail {
|
||||
this.photos = params?.photos ?? [];
|
||||
this.tags = [];
|
||||
this.gpx = params?.gpx;
|
||||
this.bounding_box_diagonal = params?.bounding_box_diagonal ?? 0;
|
||||
this.like_count = 0
|
||||
this.expand = {
|
||||
category: params?.category,
|
||||
@@ -214,6 +217,7 @@ interface TrailSearchResult {
|
||||
domain?: string;
|
||||
iri?: string;
|
||||
gpx: string;
|
||||
bounding_box_diagonal: number;
|
||||
_geo: {
|
||||
lat: number,
|
||||
lng: number
|
||||
@@ -245,10 +249,10 @@ export const defaultTrailSearchAttributes = [
|
||||
"like_count",
|
||||
"shares",
|
||||
"iri",
|
||||
"bounding_box_diagonal",
|
||||
"_geo",]
|
||||
|
||||
|
||||
export { Trail };
|
||||
|
||||
export type { TrailBoundingBox, TrailFilter, TrailFilterValues, TrailSearchResult };
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { writable, type Writable } from "svelte/store";
|
||||
export const categories: Writable<Category[]> = writable([])
|
||||
|
||||
export async function categories_index(f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
const r = await f('/api/v1/category', {
|
||||
const r = await f('/api/v1/category?perPage=-1&sort=name', {
|
||||
method: 'GET',
|
||||
})
|
||||
if (!r.ok) {
|
||||
@@ -19,4 +19,4 @@ export async function categories_index(f: (url: RequestInfo | URL, config?: Requ
|
||||
categories.set(response.items);
|
||||
|
||||
return response.items as Category[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { Integration } from "$lib/models/integration";
|
||||
import { APIError } from "$lib/util/api_util";
|
||||
import { type ListResult } from "pocketbase";
|
||||
import { get, writable, type Writable } from "svelte/store";
|
||||
import { currentUser } from "./user_store";
|
||||
|
||||
export const integrations: Writable<Integration[]> = writable([])
|
||||
|
||||
export async function integrations_index(f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
let r = await f('/api/v1/integration' + new URLSearchParams({
|
||||
}), {
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
}
|
||||
|
||||
const fetchedIntegrations: ListResult<Integration> = await r.json();
|
||||
|
||||
integrations.set(fetchedIntegrations.items);
|
||||
|
||||
return fetchedIntegrations.items;
|
||||
}
|
||||
|
||||
export async function integrations_create(integration: Integration) {
|
||||
const user = get(currentUser)
|
||||
if (!user) {
|
||||
throw Error("Unauthenticated")
|
||||
}
|
||||
|
||||
integration.user = user.id;
|
||||
|
||||
let r = await fetch('/api/v1/integration', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(integration),
|
||||
})
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
}
|
||||
|
||||
const model: Integration = await r.json();
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
export async function uploadGpx(integrationName: string, file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
let r = await fetch(`/api/v1/integration/${integrationName}/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
}
|
||||
}
|
||||
|
||||
export async function integrations_update(integration: Integration, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
let r = await f('/api/v1/integration/' + integration.id, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(integration),
|
||||
})
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
}
|
||||
|
||||
const model: Integration = await r.json();
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
export async function integrations_delete(integration: Integration) {
|
||||
const r = await fetch('/api/v1/integration/' + integration.id, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
}
|
||||
}
|
||||
183
web/src/lib/stores/plugin_instance_store.ts
Normal file
183
web/src/lib/stores/plugin_instance_store.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import type { PluginInstance } from "$lib/models/plugin_instance";
|
||||
import { APIError } from "$lib/util/api_util";
|
||||
import type { ListResult } from "pocketbase";
|
||||
import { get, writable, type Writable } from "svelte/store";
|
||||
import { currentUser } from "./user_store";
|
||||
|
||||
export const pluginInstances: Writable<PluginInstance[]> = writable([]);
|
||||
|
||||
export async function plugin_instances_index(
|
||||
f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch,
|
||||
) {
|
||||
const r = await f("/api/v1/plugin-instance?perPage=-1", {
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail);
|
||||
}
|
||||
|
||||
const fetchedInstances: ListResult<PluginInstance> = await r.json();
|
||||
pluginInstances.set(fetchedInstances.items);
|
||||
|
||||
return fetchedInstances.items;
|
||||
}
|
||||
|
||||
export async function plugin_instances_create(
|
||||
instance: Partial<PluginInstance>,
|
||||
) {
|
||||
const user = get(currentUser);
|
||||
if (!user) {
|
||||
throw Error("Unauthenticated");
|
||||
}
|
||||
|
||||
const r = await fetch("/api/v1/plugin-instance", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
...instance,
|
||||
user: user.id,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail);
|
||||
}
|
||||
|
||||
return (await r.json()) as PluginInstance;
|
||||
}
|
||||
|
||||
export async function plugin_instances_update(
|
||||
instance: PluginInstance,
|
||||
f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch,
|
||||
) {
|
||||
if (!instance.id) {
|
||||
throw Error("Plugin instance has no id");
|
||||
}
|
||||
|
||||
const r = await f("/api/v1/plugin-instance/" + instance.id, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(instance),
|
||||
});
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail);
|
||||
}
|
||||
|
||||
return (await r.json()) as PluginInstance;
|
||||
}
|
||||
|
||||
export async function plugin_oauth_start(
|
||||
data: {
|
||||
pluginId: string;
|
||||
instanceId?: string;
|
||||
authContext?: string;
|
||||
redirectUri: string;
|
||||
},
|
||||
f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch,
|
||||
) {
|
||||
const r = await f("/api/v1/plugin-system/oauth/start", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail);
|
||||
}
|
||||
|
||||
return (await r.json()) as { url: string; state: string; instanceId: string };
|
||||
}
|
||||
|
||||
export async function plugin_auth_validate(
|
||||
data: {
|
||||
pluginId: string;
|
||||
instanceId?: string;
|
||||
authContext?: string;
|
||||
auth: Record<string, string>;
|
||||
},
|
||||
f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch,
|
||||
) {
|
||||
const r = await f("/api/v1/plugin-system/auth/validate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail);
|
||||
}
|
||||
|
||||
return (await r.json()) as { ok: boolean; authContext?: string };
|
||||
}
|
||||
|
||||
export async function plugin_oauth_callback(
|
||||
data: { instanceId: string; code: string; state: string },
|
||||
f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch,
|
||||
) {
|
||||
const r = await f("/api/v1/plugin-system/oauth/callback", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail);
|
||||
}
|
||||
|
||||
return (await r.json()) as { ok: boolean };
|
||||
}
|
||||
|
||||
export async function plugin_oauth_revoke(
|
||||
instanceId: string,
|
||||
f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch,
|
||||
) {
|
||||
const r = await f("/api/v1/plugin-system/oauth/revoke", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ instanceId }),
|
||||
});
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail);
|
||||
}
|
||||
|
||||
return (await r.json()) as { ok: boolean };
|
||||
}
|
||||
|
||||
export async function plugin_category_remap_preview(
|
||||
instanceId: string,
|
||||
config?: Record<string, unknown>,
|
||||
f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch,
|
||||
) {
|
||||
const r = await f("/api/v1/plugin-system/category-remap/preview", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ instanceId, config }),
|
||||
});
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail);
|
||||
}
|
||||
|
||||
return (await r.json()) as { count: number; backfilledSinceMapping?: number };
|
||||
}
|
||||
|
||||
export async function plugin_category_remap_apply(
|
||||
instanceId: string,
|
||||
f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch,
|
||||
) {
|
||||
const r = await f("/api/v1/plugin-system/category-remap/apply", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ instanceId }),
|
||||
});
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail);
|
||||
}
|
||||
|
||||
return (await r.json()) as { count: number; remapped?: number };
|
||||
}
|
||||
125
web/src/lib/stores/plugin_store.ts
Normal file
125
web/src/lib/stores/plugin_store.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type { LocalizedTextMap, PluginProvider } from "$lib/models/plugin_provider";
|
||||
import type { PluginSystemPlugin } from "$lib/models/plugin_system";
|
||||
import { APIError } from "$lib/util/api_util";
|
||||
import { derived, writable, type Readable, type Writable } from "svelte/store";
|
||||
import {
|
||||
pluginInstances,
|
||||
plugin_instances_index,
|
||||
} from "./plugin_instance_store";
|
||||
|
||||
export const pluginProviders: Writable<PluginProvider[]> = writable([]);
|
||||
|
||||
// True when the user has at least one enabled plugin instance whose plugin
|
||||
// advertises the trail send capability. Used to gate the trail "send to" action.
|
||||
export const hasSendCapablePlugin: Readable<boolean> = derived(
|
||||
[pluginProviders, pluginInstances],
|
||||
([$plugins, $instances]) => {
|
||||
const enabledProviders = new Set(
|
||||
$instances.filter((a) => a.enabled).map((a) => a.plugin_id),
|
||||
);
|
||||
return $plugins.some(
|
||||
(p) =>
|
||||
p.status === "available" &&
|
||||
(p.capabilities ?? []).includes("prepare_trail_send.v1") &&
|
||||
enabledProviders.has(p.id),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
let pluginDataLoaded = false;
|
||||
|
||||
// Loads plugins and instances once per session so the derived gating
|
||||
// stores are populated (e.g. for the trail "send to" action). Safe to call
|
||||
// repeatedly; only the first call performs the requests.
|
||||
export async function load_plugin_data_once() {
|
||||
if (pluginDataLoaded) {
|
||||
return;
|
||||
}
|
||||
pluginDataLoaded = true;
|
||||
try {
|
||||
await Promise.all([
|
||||
plugins_index(),
|
||||
plugin_instances_index(),
|
||||
]);
|
||||
} catch (e) {
|
||||
pluginDataLoaded = false;
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function plugins_index(
|
||||
f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch,
|
||||
) {
|
||||
const r = await f("/api/v1/plugin-system/plugins", {
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail);
|
||||
}
|
||||
|
||||
const data: { items: PluginSystemPlugin[] } = await r.json();
|
||||
const items = data.items.map(pluginSystemToPluginProvider);
|
||||
pluginProviders.set(items);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function pluginSystemToPluginProvider(plugin: PluginSystemPlugin): PluginProvider {
|
||||
const contexts = Object.entries(
|
||||
(plugin.manifest.auth?.contexts ?? {}) as Record<string, any>,
|
||||
);
|
||||
const primaryAuth = contexts[0]?.[1] ?? {};
|
||||
const fields =
|
||||
primaryAuth.fields ??
|
||||
primaryAuth.secretFields ??
|
||||
(primaryAuth.secretField ? [primaryAuth.secretField] : []);
|
||||
const metadata = plugin.manifest.metadata ?? {};
|
||||
|
||||
return {
|
||||
id: plugin.id,
|
||||
type: plugin.type ?? plugin.manifest.type ?? "trails",
|
||||
name: plugin.name,
|
||||
displayName: plugin.displayName,
|
||||
displayNames: localizedMetadata(metadata, "displayNames"),
|
||||
description: plugin.description,
|
||||
descriptions: localizedMetadata(metadata, "descriptions"),
|
||||
icon: plugin.icon,
|
||||
iconDark: plugin.iconDark,
|
||||
version: plugin.version,
|
||||
auth: {
|
||||
type: primaryAuth.type ?? "none",
|
||||
fields,
|
||||
secretFields: primaryAuth.secretFields ?? fields,
|
||||
authorizationUrl: primaryAuth.authorizationUrl,
|
||||
tokenUrl: primaryAuth.tokenUrl,
|
||||
tokenRequestFormat: primaryAuth.tokenRequestFormat,
|
||||
scopes: primaryAuth.scopes,
|
||||
scopeSeparator: primaryAuth.scopeSeparator,
|
||||
authorizationParams: primaryAuth.authorizationParams,
|
||||
pkce: primaryAuth.pkce,
|
||||
tokenAuth: primaryAuth.tokenAuth,
|
||||
},
|
||||
configSchema: plugin.manifest.configSchema as PluginProvider["configSchema"],
|
||||
hostConfig: plugin.manifest.hostConfig,
|
||||
metadata,
|
||||
capabilities: plugin.capabilities,
|
||||
status: plugin.status,
|
||||
error: plugin.error,
|
||||
};
|
||||
}
|
||||
|
||||
function localizedMetadata(
|
||||
metadata: Record<string, unknown>,
|
||||
key: string,
|
||||
): LocalizedTextMap | undefined {
|
||||
const value = metadata[key];
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const entries = Object.entries(value as Record<string, unknown>).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
);
|
||||
return entries.length ? Object.fromEntries(entries) : undefined;
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { Actor } from "$lib/models/activitypub/actor";
|
||||
import type { ActorSearchResult } from "$lib/models/activitypub/actor";
|
||||
import { defaultTrailSearchAttributes, type TrailSearchResult } from "$lib/models/trail";
|
||||
import { APIError } from "$lib/util/api_util";
|
||||
import type { Hits, MultiSearchParams, MultiSearchResponse, MultiSearchResult, SearchParams, SearchResponse } from "meilisearch";
|
||||
import type { ListResult } from "pocketbase";
|
||||
|
||||
export type LocationSearchResult = {
|
||||
name: string;
|
||||
@@ -99,7 +98,7 @@ export async function searchTrails(q: string, options: SearchParams): Promise<Hi
|
||||
|
||||
const response: SearchResponse<TrailSearchResult> = await r.json();
|
||||
|
||||
return response.hits
|
||||
return response.hits || []
|
||||
}
|
||||
|
||||
export async function searchLocations(q: string, limit?: number, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch): Promise<Hits<LocationSearchResult>> {
|
||||
@@ -128,20 +127,46 @@ export async function searchLocations(q: string, limit?: number, f: (url: Reques
|
||||
}))
|
||||
}
|
||||
|
||||
async function fetchGeocoding(path: string, params: URLSearchParams, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch): Promise<Response> {
|
||||
async function fetchGeocoding(path: string, params: URLSearchParams, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch, signal?: AbortSignal): Promise<Response> {
|
||||
const query = params.toString();
|
||||
const url = query.length ? `/api/v1/geocoding/${path}?${query}` : `/api/v1/geocoding/${path}`;
|
||||
return await f(url);
|
||||
return await f(url, signal ? { signal } : undefined);
|
||||
}
|
||||
|
||||
export async function searchLocationReverse(lat: number, lon: number, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
type ReverseGeocodingOptions = {
|
||||
includeRoad?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export type ReverseLocationResult = {
|
||||
label: string;
|
||||
fullLabel: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
export type FetchFunction = (url: RequestInfo | URL, config?: RequestInit) => Promise<Response>;
|
||||
|
||||
export async function searchLocationReverse(
|
||||
lat: number,
|
||||
lon: number,
|
||||
options: ReverseGeocodingOptions = {},
|
||||
f: FetchFunction = fetch,
|
||||
) {
|
||||
const location = await searchLocationReverseStructured(lat, lon, options, f);
|
||||
return location?.fullLabel ?? "";
|
||||
}
|
||||
|
||||
export async function searchLocationReverseStructured(
|
||||
lat: number,
|
||||
lon: number,
|
||||
options: ReverseGeocodingOptions = {},
|
||||
f: FetchFunction = fetch,
|
||||
): Promise<ReverseLocationResult | null> {
|
||||
const params = new URLSearchParams({
|
||||
lat: String(lat),
|
||||
lon: String(lon),
|
||||
format: "geojson",
|
||||
addressdetails: "1",
|
||||
});
|
||||
const r = await fetchGeocoding("reverse", params, f);
|
||||
lat: String(lat),
|
||||
lon: String(lon),
|
||||
});
|
||||
const r = await fetchGeocoding("reverse", params, f, options.signal);
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail)
|
||||
@@ -149,30 +174,52 @@ export async function searchLocationReverse(lat: number, lon: number, f: (url: R
|
||||
const response: NominatimResponse = await r.json();
|
||||
|
||||
if (response.features?.at(0)?.properties.address) {
|
||||
return getLocationDescription(response.features[0].properties.address)
|
||||
return getReverseLocationResult(response.features[0].properties.address, options);
|
||||
}
|
||||
return ""
|
||||
return null
|
||||
}
|
||||
|
||||
function getLocationDescription(address: Address) {
|
||||
let description = ""
|
||||
function getReverseLocationResult(
|
||||
address: Address,
|
||||
options: ReverseGeocodingOptions = {},
|
||||
): ReverseLocationResult {
|
||||
const country = address.country ?? "";
|
||||
const label = getLocationDescription(address, { ...options, includeCountry: false });
|
||||
const fullLabel = getLocationDescription(address, options);
|
||||
|
||||
if (address.country) {
|
||||
description += address.country;
|
||||
}
|
||||
if (address.state) {
|
||||
description = `${address.state}, ` + description
|
||||
return {
|
||||
label: label || fullLabel,
|
||||
fullLabel,
|
||||
country,
|
||||
};
|
||||
}
|
||||
|
||||
function getLocationDescription(
|
||||
address: Address,
|
||||
options: ReverseGeocodingOptions & { includeCountry?: boolean } = {},
|
||||
) {
|
||||
const parts = [];
|
||||
|
||||
if (options.includeRoad && address.road) {
|
||||
parts.push(address.road);
|
||||
}
|
||||
if (address.city) {
|
||||
description = `${address.city}, ` + description
|
||||
parts.push(address.city);
|
||||
} else if (address.town) {
|
||||
description = `${address.town}, ` + description
|
||||
parts.push(address.town);
|
||||
} else if (address.hamlet) {
|
||||
description = `${address.hamlet}, ` + description
|
||||
parts.push(address.hamlet);
|
||||
} else if (address.village) {
|
||||
description = `${address.village}, ` + description
|
||||
parts.push(address.village);
|
||||
}
|
||||
return description;
|
||||
if (address.state) {
|
||||
parts.push(address.state);
|
||||
}
|
||||
if (options.includeCountry !== false && address.country) {
|
||||
parts.push(address.country);
|
||||
}
|
||||
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
export async function searchMulti(options: MultiSearchParams): Promise<MultiSearchResult<any>[]> {
|
||||
@@ -197,6 +244,10 @@ export async function searchMulti(options: MultiSearchParams): Promise<MultiSear
|
||||
|
||||
const response: MultiSearchResponse<any> = await r.json();
|
||||
|
||||
if (!response.results) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
if (locationQuery && locationQuery.q !== undefined && locationQuery.q !== null) {
|
||||
const locationsResults = await searchLocations(locationQuery.q, locationQuery.limit)
|
||||
@@ -210,16 +261,16 @@ export async function searchMulti(options: MultiSearchParams): Promise<MultiSear
|
||||
return response.results
|
||||
}
|
||||
|
||||
export async function searchActors(q: string, includeSelf: boolean = true): Promise<Actor[]> {
|
||||
export async function searchActors(q: string, includeSelf: boolean = true): Promise<ActorSearchResult[]> {
|
||||
try {
|
||||
const r = await fetch(`/api/v1/search/actor?q=${q}&includeSelf=${includeSelf}`,)
|
||||
|
||||
if (!r.ok) {
|
||||
return []
|
||||
}
|
||||
const response: ListResult<Actor> = await r.json()
|
||||
const response: SearchResponse<ActorSearchResult> = await r.json()
|
||||
|
||||
return response.items
|
||||
return response.hits
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
|
||||
|
||||
@@ -1,31 +1,34 @@
|
||||
import { browser } from "$app/environment";
|
||||
import { get, writable, type Writable } from "svelte/store";
|
||||
|
||||
type Theme = "dark" | "light"
|
||||
type Theme = "dark" | "light";
|
||||
|
||||
export const theme: Writable<Theme> = writable(getDefaultTheme());
|
||||
|
||||
function getDefaultTheme(): Theme {
|
||||
if (browser) {
|
||||
if (localStorage.getItem("theme")) {
|
||||
return localStorage.getItem("theme") as Theme;
|
||||
} else if (document.documentElement.classList.contains("light")) {
|
||||
return "light"
|
||||
} else if (document.documentElement.classList.contains("dark")) {
|
||||
return "dark";
|
||||
}
|
||||
if (!browser) {
|
||||
return "light";
|
||||
}
|
||||
|
||||
return "light";
|
||||
|
||||
const stored = localStorage.getItem("theme");
|
||||
if (stored === "dark" || stored === "light") {
|
||||
return stored;
|
||||
}
|
||||
if (document.documentElement.classList.contains("dark")) {
|
||||
return "dark";
|
||||
}
|
||||
if (document.documentElement.classList.contains("light")) {
|
||||
return "light";
|
||||
}
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
export function toggleTheme() {
|
||||
const currentTheme = get(theme);
|
||||
const newTheme = currentTheme === "light" ? "dark" : "light";
|
||||
document.documentElement.classList.remove(currentTheme)
|
||||
document.documentElement.classList.add(newTheme)
|
||||
document.querySelector("meta[name='color-scheme']" )?.setAttribute("content", newTheme)
|
||||
theme.set(newTheme)
|
||||
document.documentElement.classList.remove(currentTheme);
|
||||
document.documentElement.classList.add(newTheme);
|
||||
document.querySelector("meta[name='color-scheme']")?.setAttribute("content", newTheme);
|
||||
theme.set(newTheme);
|
||||
localStorage.setItem("theme", newTheme);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SummitLog } from "$lib/models/summit_log";
|
||||
import type { Tag } from "$lib/models/tag";
|
||||
import { MAP_MAX_POLYLINES } from "$lib/config/map";
|
||||
import { defaultTrailSearchAttributes, Trail, type TrailFilter, type TrailFilterValues, type TrailSearchResult } from "$lib/models/trail";
|
||||
import type { Waypoint } from "$lib/models/waypoint";
|
||||
import { APIError } from "$lib/util/api_util";
|
||||
@@ -14,11 +15,6 @@ import { tags_create } from "./tag_store";
|
||||
import { currentUser } from "./user_store";
|
||||
import { waypoints_create, waypoints_delete, waypoints_update } from "./waypoint_store";
|
||||
|
||||
let trails: Trail[] = []
|
||||
export const trail: Writable<Trail> = writable(new Trail(""));
|
||||
|
||||
export const editTrail: Writable<Trail> = writable(new Trail(""));
|
||||
|
||||
export async function trails_index(perPage: number = 21, random: boolean = false, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
const r = await f('/api/v1/trail?' + new URLSearchParams({
|
||||
"perPage": perPage.toString(),
|
||||
@@ -93,7 +89,50 @@ export async function trails_search_filter(filter: TrailFilter, page: number = 1
|
||||
|
||||
}
|
||||
|
||||
export async function trails_search_bounding_box(northEast: M.LngLat, southWest: M.LngLat, filter: TrailFilter, page: number = 1, includePolyline: boolean = true) {
|
||||
const DETAILED_CACHE_MAX_SIZE = Math.max(200, MAP_MAX_POLYLINES * 10);
|
||||
|
||||
let trails: Trail[] = []
|
||||
const detailedCache = new Map<string, Trail>();
|
||||
let detailedCacheKey = "";
|
||||
|
||||
function getDetailedCache(id: string): Trail | undefined {
|
||||
const cached = detailedCache.get(id);
|
||||
if (!cached) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
detailedCache.delete(id);
|
||||
// Reinsert the entry so Map iteration order tracks recent usage for LRU eviction.
|
||||
detailedCache.set(id, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
function setDetailedCache(id: string, trail: Trail) {
|
||||
detailedCache.delete(id);
|
||||
detailedCache.set(id, trail);
|
||||
|
||||
while (detailedCache.size > DETAILED_CACHE_MAX_SIZE) {
|
||||
const oldestKey = detailedCache.keys().next().value;
|
||||
if (!oldestKey) {
|
||||
break;
|
||||
}
|
||||
detailedCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
export const trail: Writable<Trail> = writable(new Trail(""));
|
||||
|
||||
export const editTrail: Writable<Trail> = writable(new Trail(""));
|
||||
|
||||
export async function trails_search_bounding_box(
|
||||
northEast: M.LngLat,
|
||||
southWest: M.LngLat,
|
||||
filter: TrailFilter,
|
||||
page: number = 1,
|
||||
zoom: number = 11,
|
||||
perPage: number = 50,
|
||||
loadMapData: boolean = true
|
||||
) {
|
||||
const user = get(currentUser)
|
||||
|
||||
let filterText: string = "";
|
||||
@@ -102,36 +141,182 @@ export async function trails_search_bounding_box(northEast: M.LngLat, southWest:
|
||||
filterText = buildFilterText(user, filter, false);
|
||||
}
|
||||
|
||||
let r = await fetch("/api/v1/search/trails", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
q: "",
|
||||
options: {
|
||||
filter: [
|
||||
`_geoBoundingBox([${northEast.lat}, ${northEast.lng}], [${southWest.lat}, ${southWest.lng}])`,
|
||||
filterText
|
||||
],
|
||||
sort: [`${filter.sort}:${filter.sortOrder == "+" ? "asc" : "desc"}`,],
|
||||
attributesToRetrieve: [...defaultTrailSearchAttributes, ...(includePolyline ? ["polyline"] : [])],
|
||||
hitsPerPage: 500,
|
||||
page: page
|
||||
}
|
||||
}),
|
||||
});
|
||||
const result: { page: number, totalPages: number, hits: Hits<TrailSearchResult> } = await r.json();
|
||||
|
||||
if (result.hits.length == 0) {
|
||||
trails = [];
|
||||
return { trails: [], ...result }
|
||||
let lonFilter = `max_lon >= ${southWest.lng} AND min_lon <= ${northEast.lng}`;
|
||||
if (southWest.lng > northEast.lng) {
|
||||
lonFilter = `(max_lon >= ${southWest.lng} OR min_lon <= ${northEast.lng})`;
|
||||
}
|
||||
|
||||
const resultTrails: Trail[] = await searchResultToTrailList(result.hits)
|
||||
const geoFilter = `max_lat >= ${southWest.lat} AND min_lat <= ${northEast.lat} AND ${lonFilter}`;
|
||||
const listFilter = [filterText, geoFilter].filter(Boolean).join(" AND ");
|
||||
const cacheKey = JSON.stringify({
|
||||
q: filter.q,
|
||||
filterText,
|
||||
sort: filter.sort,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
if (cacheKey !== detailedCacheKey) {
|
||||
detailedCache.clear();
|
||||
detailedCacheKey = cacheKey;
|
||||
}
|
||||
|
||||
trails = page > 1 ? trails.concat(resultTrails) : resultTrails
|
||||
// Step 1: Fetch paginated trails for the side list.
|
||||
const listResponse = await fetch("/api/v1/search/trails", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
q: filter.q,
|
||||
options: {
|
||||
filter: listFilter,
|
||||
attributesToRetrieve: defaultTrailSearchAttributes,
|
||||
sort: [`${filter.sort}:${filter.sortOrder == "+" ? "asc" : "desc"}`],
|
||||
hitsPerPage: perPage,
|
||||
page,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return { trails, ...result };
|
||||
if (!listResponse.ok) {
|
||||
const response = await listResponse.json();
|
||||
throw new APIError(listResponse.status, response.message, response.detail)
|
||||
}
|
||||
|
||||
const listResult: { page: number, totalPages: number, totalHits?: number, estimatedTotalHits?: number, hits: Hits<TrailSearchResult> } = await listResponse.json();
|
||||
const listTrails = listResult.hits.length > 0
|
||||
? await searchResultToTrailList(listResult.hits)
|
||||
: [];
|
||||
|
||||
trails = page > 1 ? trails.concat(listTrails) : listTrails;
|
||||
|
||||
if (!loadMapData) {
|
||||
return {
|
||||
trails,
|
||||
mapTrails: [],
|
||||
clusters: undefined,
|
||||
estimatedTotalHits: listResult.estimatedTotalHits,
|
||||
totalHits: listResult.totalHits ?? listResult.estimatedTotalHits,
|
||||
totalPages: listResult.totalPages
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: Fetch server-side clusters and unclustered points for the map.
|
||||
let cr = await fetch("/api/v1/search/trails/cluster", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
southWest: { lat: southWest.lat, lng: southWest.lng },
|
||||
northEast: { lat: northEast.lat, lng: northEast.lng },
|
||||
zoom,
|
||||
q: filter.q,
|
||||
filterText
|
||||
})
|
||||
});
|
||||
|
||||
if (!cr.ok) {
|
||||
const response = await cr.json();
|
||||
throw new APIError(cr.status, response.message, response.detail)
|
||||
}
|
||||
|
||||
const clusterResult = await cr.json();
|
||||
const clusterFeatureCollection = clusterResult;
|
||||
|
||||
const unclusteredFeatures = clusterFeatureCollection.features
|
||||
.filter((f: any) => !f.properties.cluster);
|
||||
|
||||
// Extract IDs of visible unclustered points that are large enough to show details for
|
||||
const unclusteredIds = unclusteredFeatures
|
||||
.filter((f: any) => f.properties.is_large)
|
||||
.map((f: any) => f.properties.id);
|
||||
|
||||
// Step 3: Identify which visible trails are MISSING from the local cache
|
||||
const missingIds = unclusteredIds.filter((id: string) => !detailedCache.has(id));
|
||||
|
||||
// Step 4: Only fetch details for missing trails
|
||||
if (missingIds.length > 0) {
|
||||
const batchSize = 100; // Meilisearch filter length safety
|
||||
for (let i = 0; i < missingIds.length; i += batchSize) {
|
||||
const batch = missingIds.slice(i, i + batchSize);
|
||||
const detailBatchQuery = {
|
||||
indexUid: "trails",
|
||||
q: "",
|
||||
filter: [`id IN [${batch.map((id: string) => `'${id}'`).join(",")}]`],
|
||||
attributesToRetrieve: [...defaultTrailSearchAttributes, "polyline"],
|
||||
hitsPerPage: batchSize,
|
||||
};
|
||||
|
||||
const dr = await fetch("/api/v1/search/multi", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ queries: [detailBatchQuery] }),
|
||||
});
|
||||
|
||||
if (dr.ok) {
|
||||
const detailResult = await dr.json();
|
||||
const newTrails = await searchResultToTrailList(detailResult.results[0].hits);
|
||||
// Populate cache
|
||||
newTrails.forEach(t => {
|
||||
if (t.id) setDetailedCache(t.id, t)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Convert unclustered hits to lightweight Trail objects for map popups/previews.
|
||||
const mapTrails: Trail[] = unclusteredFeatures
|
||||
.map((f: any) => {
|
||||
const s = f.properties;
|
||||
const lat = f.geometry.coordinates[1];
|
||||
const lng = f.geometry.coordinates[0];
|
||||
|
||||
const cached = getDetailedCache(s.id);
|
||||
if (cached) {
|
||||
return {
|
||||
...cached,
|
||||
lat,
|
||||
lon: lng,
|
||||
bounding_box_diagonal: s.bounding_box_diagonal ?? cached.bounding_box_diagonal,
|
||||
// Strip polyline for small trails so they don't linger as lines when zoomed out
|
||||
polyline: s.is_large ? cached.polyline : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Lightweight fallback for map markers
|
||||
const t: Trail & RecordModel = {
|
||||
id: s.id,
|
||||
lat: lat,
|
||||
lon: lng,
|
||||
name: "",
|
||||
author: "",
|
||||
photos: [],
|
||||
public: true,
|
||||
completed: false,
|
||||
summit_logs: [],
|
||||
waypoints: [],
|
||||
tags: [],
|
||||
category: "",
|
||||
created: new Date(0).toISOString(),
|
||||
date: new Date(0).toISOString(),
|
||||
updated: new Date(0).toISOString(),
|
||||
description: "",
|
||||
difficulty: "easy",
|
||||
distance: 0,
|
||||
duration: 0,
|
||||
elevation_gain: 0,
|
||||
elevation_loss: 0,
|
||||
location: "",
|
||||
bounding_box_diagonal: s.bounding_box_diagonal ?? 0,
|
||||
like_count: 0,
|
||||
collectionId: "trails",
|
||||
collectionName: "trails",
|
||||
expand: { author: {} as any }
|
||||
};
|
||||
return t;
|
||||
});
|
||||
|
||||
return {
|
||||
trails,
|
||||
mapTrails,
|
||||
clusters: clusterFeatureCollection,
|
||||
estimatedTotalHits: listResult.estimatedTotalHits ?? clusterResult.totalHits,
|
||||
totalHits: listResult.totalHits ?? listResult.estimatedTotalHits ?? clusterResult.totalHits,
|
||||
totalPages: listResult.totalPages
|
||||
};
|
||||
}
|
||||
|
||||
export async function trails_show(id: string, handle?: string, share?: string, loadGPX?: boolean, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
@@ -334,9 +519,11 @@ export async function trails_update(oldTrail: Trail, newTrail: Trail, photos?: F
|
||||
}
|
||||
|
||||
|
||||
let r = await fetch(`/api/v1/trail/form/${newTrail.id}?` + new URLSearchParams({
|
||||
const updateUrl = `/api/v1/trail/form/${newTrail.id}?` + new URLSearchParams({
|
||||
expand: "category,waypoints_via_trail,summit_logs_via_trail,trail_share_via_trail,tags",
|
||||
}), {
|
||||
});
|
||||
|
||||
let r = await fetch(updateUrl, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
@@ -360,6 +547,54 @@ export async function trails_update(oldTrail: Trail, newTrail: Trail, photos?: F
|
||||
return model;
|
||||
}
|
||||
|
||||
export async function trails_update_metadata(
|
||||
currentTrail: Trail,
|
||||
patch: Pick<Partial<Trail>, "name" | "description" | "tags"> & {
|
||||
expand?: Pick<NonNullable<Trail["expand"]>, "tags">;
|
||||
},
|
||||
) {
|
||||
const tagIds: string[] | undefined = patch.expand?.tags
|
||||
? []
|
||||
: patch.tags;
|
||||
|
||||
for (const tag of patch.expand?.tags ?? []) {
|
||||
if (!tag.id) {
|
||||
const model = await tags_create(tag);
|
||||
tagIds!.push(model.id!);
|
||||
} else {
|
||||
tagIds!.push(tag.id);
|
||||
}
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams(
|
||||
tagIds !== undefined ? { expand: "tags" } : {},
|
||||
);
|
||||
const query = searchParams.toString();
|
||||
const url = `/api/v1/trail/${currentTrail.id}${query ? `?${query}` : ""}`;
|
||||
const payload = {
|
||||
name: patch.name ?? currentTrail.name,
|
||||
...(patch.description !== undefined
|
||||
? { description: patch.description }
|
||||
: {}),
|
||||
...(tagIds !== undefined ? { tags: tagIds } : {}),
|
||||
};
|
||||
|
||||
const r = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!r.ok) {
|
||||
const response = await r.json();
|
||||
throw new APIError(r.status, response.message, response.detail);
|
||||
}
|
||||
|
||||
const model: Trail = await r.json();
|
||||
trail.set(model);
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
export async function trails_delete(trail: Trail) {
|
||||
const r = await fetch('/api/v1/trail/' + trail.id, {
|
||||
@@ -453,10 +688,12 @@ export async function fetchGPX(trail: { gpx?: string } & Record<string, any>, f:
|
||||
export async function searchResultToTrailList(hits: Hits<TrailSearchResult>): Promise<Trail[]> {
|
||||
const trails: Trail[] = []
|
||||
for (const h of hits) {
|
||||
const created = Number(h.created || 0);
|
||||
const date = Number(h.date || 0);
|
||||
const t: Trail & RecordModel = {
|
||||
collectionId: "trails",
|
||||
collectionName: "trails",
|
||||
updated: new Date(h.created * 1000).toISOString(),
|
||||
updated: new Date(created * 1000).toISOString(),
|
||||
author: h.author_name,
|
||||
name: h.name,
|
||||
photos: h.thumbnail ? [h.thumbnail] : [],
|
||||
@@ -466,8 +703,8 @@ export async function searchResultToTrailList(hits: Hits<TrailSearchResult>): Pr
|
||||
waypoints: [],
|
||||
tags: h.tags ?? [],
|
||||
category: h.category,
|
||||
created: new Date(h.created * 1000).toISOString(),
|
||||
date: new Date(h.date * 1000).toISOString(),
|
||||
created: new Date(created * 1000).toISOString(),
|
||||
date: new Date(date * 1000).toISOString(),
|
||||
description: h.description,
|
||||
difficulty: h.difficulty == 0 ? "easy" : h.difficulty == 1 ? "moderate" : "difficult",
|
||||
distance: h.distance,
|
||||
@@ -480,6 +717,7 @@ export async function searchResultToTrailList(hits: Hits<TrailSearchResult>): Pr
|
||||
location: h.location,
|
||||
gpx: h.gpx,
|
||||
polyline: h.polyline,
|
||||
bounding_box_diagonal: h.bounding_box_diagonal ?? 0,
|
||||
domain: h.domain,
|
||||
iri: h.iri,
|
||||
thumbnail: 0,
|
||||
@@ -487,7 +725,7 @@ export async function searchResultToTrailList(hits: Hits<TrailSearchResult>): Pr
|
||||
expand: {
|
||||
author: {
|
||||
collectionId: "activitypub_actors",
|
||||
isLocal: (h.domain?.length ?? 0) == 0,
|
||||
is_local: (h.domain?.length ?? 0) == 0,
|
||||
id: h.author,
|
||||
icon: h.author_avatar,
|
||||
preferred_username: h.author_name,
|
||||
|
||||
@@ -6,6 +6,7 @@ import Waypoint from "$lib/models/gpx/waypoint";
|
||||
import { type RoutingOptions, type ValhallaAnchor, type ValhallaHeightResponse, type ValhallaRouteResponse } from "$lib/models/valhalla";
|
||||
import { APIError } from "$lib/util/api_util";
|
||||
import { decodePolyline, encodePolyline } from "$lib/util/polyline_util";
|
||||
import { renderValhallaAnchorMarker, valhallaAnchorTitle } from "$lib/util/valhalla_anchor_util";
|
||||
import { applyChangeset, diff, revertChangeset, type Changeset } from 'json-diff-ts';
|
||||
import type { LngLat } from "maplibre-gl";
|
||||
import { _ } from "svelte-i18n";
|
||||
@@ -16,8 +17,8 @@ const emtpyTrack = new Track({ trkseg: [] })
|
||||
class ValhallaStore {
|
||||
route: GPX = $state(new GPX({ trk: [emtpyTrack] }));
|
||||
anchors: ValhallaAnchor[] = $state([]);
|
||||
undoStack: { delta: Changeset, reverseDelta: Changeset }[] = $state([]);
|
||||
redoStack: { delta: Changeset, reverseDelta: Changeset }[] = $state([]);
|
||||
undoStack: { delta: Changeset, reverseDelta: Changeset, anchorsBefore?: ValhallaAnchor[], anchorsAfter?: ValhallaAnchor[] }[] = $state([]);
|
||||
redoStack: { delta: Changeset, reverseDelta: Changeset, anchorsBefore?: ValhallaAnchor[], anchorsAfter?: ValhallaAnchor[] }[] = $state([]);
|
||||
}
|
||||
|
||||
export const valhallaStore = new ValhallaStore();
|
||||
@@ -179,14 +180,21 @@ export function reverseRoute() {
|
||||
if (!a.marker) {
|
||||
return;
|
||||
}
|
||||
a.marker.getElement().textContent = "" + (i + 1);
|
||||
renderValhallaAnchorMarker(
|
||||
a.marker.getElement(),
|
||||
i,
|
||||
valhallaStore.anchors.length,
|
||||
);
|
||||
|
||||
const anchorPopupHeading = a.marker
|
||||
.getPopup()
|
||||
._content.getElementsByTagName("h5")[0];
|
||||
if (anchorPopupHeading) {
|
||||
anchorPopupHeading.textContent =
|
||||
get(_)("route-point") + " #" + (i + 1);
|
||||
anchorPopupHeading.textContent = valhallaAnchorTitle(
|
||||
i,
|
||||
valhallaStore.anchors.length,
|
||||
get(_),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -235,8 +243,8 @@ export async function splitSegment(index: number, pos: LngLat) {
|
||||
const firstSegmentPoints = [...points.slice(0, bestSplitIndex), intersectionPoint];
|
||||
const secondSegmentPoints = [intersectionPoint, ...points.slice(bestSplitIndex)];
|
||||
|
||||
editRoute(index, firstSegmentPoints)
|
||||
insertIntoRoute(secondSegmentPoints, index + 1)
|
||||
await editRoute(index, firstSegmentPoints)
|
||||
await insertIntoRoute(secondSegmentPoints, index + 1)
|
||||
|
||||
}
|
||||
|
||||
@@ -263,21 +271,30 @@ export function normalizeRouteTime() {
|
||||
export function undo() {
|
||||
const historyItem = valhallaStore.undoStack.pop()
|
||||
if (!historyItem) {
|
||||
return
|
||||
return undefined
|
||||
}
|
||||
valhallaStore.redoStack.push(historyItem)
|
||||
|
||||
valhallaStore.route = applyChangeset(valhallaStore.route, historyItem.reverseDelta);
|
||||
valhallaStore.route.features = valhallaStore.route.getTotals();
|
||||
return historyItem;
|
||||
}
|
||||
|
||||
export function revertRouteChange() {
|
||||
const historyItem = valhallaStore.undoStack.pop();
|
||||
if (!historyItem) return;
|
||||
valhallaStore.route = applyChangeset(valhallaStore.route, historyItem.reverseDelta);
|
||||
valhallaStore.route.features = valhallaStore.route.getTotals();
|
||||
}
|
||||
|
||||
export function redo() {
|
||||
const historyItem = valhallaStore.redoStack.pop()
|
||||
if (!historyItem) {
|
||||
return
|
||||
return undefined
|
||||
}
|
||||
valhallaStore.undoStack.push(historyItem)
|
||||
|
||||
valhallaStore.route = applyChangeset(valhallaStore.route, historyItem.delta);
|
||||
valhallaStore.route.features = valhallaStore.route.getTotals();
|
||||
return historyItem;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,18 @@ export function splitUsername(handle: string, localDomain?: string) {
|
||||
return [user, domain]
|
||||
}
|
||||
|
||||
export function isValidPubHandle(handle: string): boolean {
|
||||
// Regex breakdown:
|
||||
// ^@? - Optional leading '@'
|
||||
// [a-zA-Z0-9_.-]+ - Username: alphanumeric, underscores, dots, hyphens (minimum 1 char)
|
||||
// @ - Literal '@' separator
|
||||
// ([a-zA-Z0-9-]+\.)+ - Domain segments (e.g., "mastodon.")
|
||||
// [a-zA-Z]{2,}$ - TLD (e.g., "social", "com" - minimum 2 chars)
|
||||
const activityPubRegex = /^@?[a-zA-Z0-9_.-]+@([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/;
|
||||
|
||||
return activityPubRegex.test(handle);
|
||||
}
|
||||
|
||||
export function isRemoteHandle(handle: string, origin: string) {
|
||||
const [, domain] = splitUsername(handle, origin);
|
||||
if (!domain) {
|
||||
@@ -38,7 +50,7 @@ export function handleFromRecordWithIRI(record: any) {
|
||||
throw new Error("object has no author info")
|
||||
}
|
||||
|
||||
if (!record.iri) {
|
||||
if (!record.iri || record.iri.length == 0) {
|
||||
return `@${record.expand.author.preferred_username}`
|
||||
}
|
||||
const url = new URL(record.iri ?? "")
|
||||
|
||||
@@ -25,7 +25,7 @@ export enum Collection {
|
||||
comments = "comments",
|
||||
feed = "feed",
|
||||
follows = "follows",
|
||||
integrations = "integrations",
|
||||
plugin_instances = "plugin_instances",
|
||||
list_share = "list_share",
|
||||
lists = "lists",
|
||||
notifications = "notifications",
|
||||
|
||||
@@ -13,7 +13,10 @@ export function formatTimeHHMM(seconds?: number) {
|
||||
return (h < 10 ? "0" : "") + h.toString() + "h " + (m < 10 ? "0" : "") + m.toString() + "m";
|
||||
}
|
||||
|
||||
export function formatDistance(meters?: number) {
|
||||
export function formatDistance(
|
||||
meters?: number,
|
||||
options: { compact?: boolean } = {},
|
||||
) {
|
||||
if (meters === undefined) {
|
||||
return "-";
|
||||
}
|
||||
@@ -22,7 +25,14 @@ export function formatDistance(meters?: number) {
|
||||
|
||||
if (unit == "metric") {
|
||||
if (meters >= 1000) {
|
||||
return `${(meters / 1000).toFixed(2)} km`
|
||||
const kilometers = meters / 1000;
|
||||
if (options.compact && kilometers >= 100) {
|
||||
return `${kilometers.toFixed(0)} km`;
|
||||
}
|
||||
if (options.compact && kilometers >= 10) {
|
||||
return `${kilometers.toFixed(1)} km`;
|
||||
}
|
||||
return `${kilometers.toFixed(2)} km`
|
||||
} else {
|
||||
return meters % 1 == 0 ? `${meters} m` : `${Math.round(meters)} m`;
|
||||
}
|
||||
@@ -148,4 +158,4 @@ export function formatHTMLAsText(html?: string) {
|
||||
|
||||
// Trim the result
|
||||
return text.trim();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,16 +73,20 @@ export function createMarkerFromWaypoint(waypoint: Waypoint, onDragEnd?: (marker
|
||||
marker.on("dragend", () => onDragEnd(marker, waypoint.id,));
|
||||
}
|
||||
|
||||
marker.getElement().addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
marker.togglePopup();
|
||||
});
|
||||
|
||||
return marker;
|
||||
}
|
||||
|
||||
export function createAnchorMarker(lat: number, lon: number, index: number,
|
||||
export function createAnchorMarker(lat: number, lon: number,
|
||||
onDeleteClick: () => void, onLoopClick: () => void,
|
||||
onDragStart: (event: Event) => void, onDragEnd: (event: Event) => void): FontawesomeMarker {
|
||||
|
||||
const anchorElement = document.createElement("span")
|
||||
anchorElement.className = "route-anchor cursor-pointer rounded-full w-6 h-6 border border-black text-center bg-primary text-white"
|
||||
anchorElement.textContent = "" + index
|
||||
anchorElement.className = "route-anchor cursor-pointer flex items-center justify-center rounded-full w-6 h-6 border border-black bg-primary text-white"
|
||||
const marker = new M.Marker(
|
||||
{
|
||||
draggable: true,
|
||||
@@ -96,7 +100,7 @@ export function createAnchorMarker(lat: number, lon: number, index: number,
|
||||
popupContent.className = "py-3 pl-3"
|
||||
const anchorH = document.createElement("h5")
|
||||
anchorH.classList.add("text-base", "font-medium");
|
||||
anchorH.textContent = get(_)("route-point") + " #" + index;
|
||||
anchorH.textContent = get(_)("route-point");
|
||||
|
||||
const deleteButton = document.createElement("button");
|
||||
deleteButton.className = "btn-secondary w-full mt-2 text-sm";
|
||||
@@ -260,19 +264,33 @@ export function createPopupFromTrail(trail: Trail) {
|
||||
return popup;
|
||||
}
|
||||
|
||||
export function createOverpassPopup(feature: GeoJSON.Feature, coordinates: GeoJSON.Position) {
|
||||
export type OverpassPopupAction = {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
helperText?: string;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
export function createOverpassPopup(
|
||||
feature: GeoJSON.Feature,
|
||||
coordinates: GeoJSON.Position,
|
||||
action?: OverpassPopupAction,
|
||||
) {
|
||||
const tags: Record<string, string> = JSON.parse(feature.properties?.tags);
|
||||
const name = tags.name ?? get(_)(feature.properties?.query) ?? "?"
|
||||
|
||||
const popupContainer = document.createElement("div");
|
||||
popupContainer.className = "p-4"
|
||||
popupContainer.className = "p-4 relative"
|
||||
|
||||
const indent = action ? "pl-12 " : "";
|
||||
|
||||
const popupHeading = document.createElement("h1");
|
||||
popupHeading.className = "font-medium text-lg"
|
||||
popupHeading.className = indent + "font-medium text-lg"
|
||||
popupHeading.textContent = name;
|
||||
|
||||
const coordinateSubtitle = document.createElement("p")
|
||||
coordinateSubtitle.className = "text-gray-500"
|
||||
coordinateSubtitle.className = indent + "text-gray-500"
|
||||
coordinateSubtitle.textContent = `${coordinates[0].toFixed(6)}, ${coordinates[1].toFixed(6)}`
|
||||
|
||||
popupContainer.appendChild(popupHeading)
|
||||
@@ -295,6 +313,36 @@ export function createOverpassPopup(feature: GeoJSON.Feature, coordinates: GeoJS
|
||||
|
||||
popupContainer.appendChild(tagsGrid)
|
||||
|
||||
if (action) {
|
||||
const actionButton = document.createElement("button");
|
||||
actionButton.type = "button";
|
||||
actionButton.className =
|
||||
"flex h-9 w-9 items-center justify-center absolute top-4 left-4 rounded-full p-0 text-xl text-content hover:bg-secondary-hover disabled:opacity-40 disabled:cursor-not-allowed";
|
||||
actionButton.disabled = action.disabled ?? false;
|
||||
actionButton.setAttribute("aria-label", action.label);
|
||||
actionButton.setAttribute("title", action.label);
|
||||
|
||||
const iconElement = document.createElement("i");
|
||||
iconElement.className = (action.icon ?? "fa fa-flag-checkered");
|
||||
iconElement.setAttribute("aria-hidden", "true");
|
||||
actionButton.appendChild(iconElement);
|
||||
|
||||
actionButton.addEventListener("click", () => {
|
||||
if (!actionButton.disabled) {
|
||||
action.onClick();
|
||||
}
|
||||
});
|
||||
|
||||
popupContainer.appendChild(actionButton);
|
||||
|
||||
if (action.helperText) {
|
||||
const helper = document.createElement("p");
|
||||
helper.className = "text-xs text-gray-500 mt-2";
|
||||
helper.textContent = action.helperText;
|
||||
popupContainer.appendChild(helper);
|
||||
}
|
||||
}
|
||||
|
||||
return popupContainer;
|
||||
}
|
||||
|
||||
@@ -336,4 +384,4 @@ export function calculateScaleFactor(map: M.Map) {
|
||||
const scaleFactor = realWorldMetersPer100Pixels / screenMetersPer100Pixels
|
||||
|
||||
return scaleFactor
|
||||
}
|
||||
}
|
||||
|
||||
112
web/src/lib/util/plugin_error_i18n.ts
Normal file
112
web/src/lib/util/plugin_error_i18n.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { get } from "svelte/store";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { APIError } from "$lib/util/api_util";
|
||||
|
||||
type PluginErrorLike = {
|
||||
code?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
|
||||
const credentialErrorCodes = new Set(["auth_failed"]);
|
||||
const reconnectErrorCodes = new Set(["invalid_grant", "unauthorized"]);
|
||||
const unavailableErrorCodes = new Set(["provider_unavailable", "temporary_unavailable"]);
|
||||
const internalErrorCodes = new Set(["internal_error", "plugin_error"]);
|
||||
|
||||
const authErrorHints = [
|
||||
"auth_failed",
|
||||
"login failed",
|
||||
"email and password are required",
|
||||
];
|
||||
|
||||
export function translatePluginError(code?: string, message?: string): string {
|
||||
const normalizedCode = code?.trim();
|
||||
|
||||
if (normalizedCode && credentialErrorCodes.has(normalizedCode)) {
|
||||
return get(_)("wrong-username-or-password");
|
||||
}
|
||||
if (normalizedCode && reconnectErrorCodes.has(normalizedCode)) {
|
||||
return get(_)("plugin-error-reconnect-required");
|
||||
}
|
||||
if (normalizedCode === "rate_limited") {
|
||||
return get(_)("plugin-error-rate-limited");
|
||||
}
|
||||
if (normalizedCode && unavailableErrorCodes.has(normalizedCode)) {
|
||||
return get(_)("plugin-error-provider-unavailable");
|
||||
}
|
||||
if (normalizedCode === "invalid_request") {
|
||||
return get(_)("plugin-error-invalid-request");
|
||||
}
|
||||
if (normalizedCode && internalErrorCodes.has(normalizedCode)) {
|
||||
return get(_)("plugin-error-internal");
|
||||
}
|
||||
|
||||
return message?.trim() || get(_)("plugin-setup-error");
|
||||
}
|
||||
|
||||
export function translatePluginAPIError(error: unknown, fallback: string): string {
|
||||
if (error instanceof APIError) {
|
||||
const pluginError = extractPluginError(error.detail);
|
||||
if (pluginError?.code) {
|
||||
return translatePluginError(String(pluginError.code), stringValue(pluginError.message));
|
||||
}
|
||||
|
||||
const raw = `${error.message} ${stringValue(error.detail)}`.toLowerCase();
|
||||
if (authErrorHints.some((hint) => raw.includes(hint))) {
|
||||
return get(_)("wrong-username-or-password");
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function extractPluginError(value: unknown): PluginErrorLike | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return extractPluginErrorFromString(value);
|
||||
}
|
||||
|
||||
if (typeof value !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.code === "string") {
|
||||
return record;
|
||||
}
|
||||
|
||||
for (const nested of Object.values(record)) {
|
||||
const parsed = extractPluginError(nested);
|
||||
if (parsed) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractPluginErrorFromString(value: string): PluginErrorLike | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return extractPluginError(parsed);
|
||||
} catch {
|
||||
const match = value.match(/"code"\s*:\s*"([^"]+)"/);
|
||||
return match ? { code: match[1] } : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (value == null) {
|
||||
return undefined;
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
86
web/src/lib/util/plugin_i18n.ts
Normal file
86
web/src/lib/util/plugin_i18n.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { ConfigField, ConfigFieldOption, LocalizedTextMap, PluginProvider } from "$lib/models/plugin_provider";
|
||||
|
||||
export function localizedText(
|
||||
texts: LocalizedTextMap | undefined,
|
||||
currentLocale: string | null | undefined,
|
||||
fallback = "",
|
||||
): string {
|
||||
if (!texts) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const locale = normalizeLocale(currentLocale);
|
||||
const language = locale.split("-")[0];
|
||||
const candidates = [locale, language, "en"];
|
||||
for (const candidate of candidates) {
|
||||
const value = texts[candidate]?.trim();
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
const trimmedFallback = fallback.trim();
|
||||
if (trimmedFallback) {
|
||||
return trimmedFallback;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export function pluginTitle(plugin: PluginProvider, currentLocale: string | null | undefined): string {
|
||||
return localizedText(plugin.displayNames, currentLocale, plugin.displayName || plugin.name);
|
||||
}
|
||||
|
||||
export function pluginDescription(plugin: PluginProvider, currentLocale: string | null | undefined): string {
|
||||
return localizedText(plugin.descriptions, currentLocale, plugin.description ?? "");
|
||||
}
|
||||
|
||||
export function configFieldLabel(
|
||||
field: ConfigField,
|
||||
currentLocale: string | null | undefined,
|
||||
fallback: string,
|
||||
): string {
|
||||
return localizedText(field.labels, currentLocale, field.label || fallback);
|
||||
}
|
||||
|
||||
export function configFieldDescription(
|
||||
field: ConfigField,
|
||||
currentLocale: string | null | undefined,
|
||||
): string | undefined {
|
||||
return localizedText(field.descriptions, currentLocale, field.description ?? "") || undefined;
|
||||
}
|
||||
|
||||
export function configFieldOptionLabel(
|
||||
option: ConfigFieldOption,
|
||||
currentLocale: string | null | undefined,
|
||||
fallback: string,
|
||||
): string {
|
||||
return localizedText(option.labels, currentLocale, option.label || fallback);
|
||||
}
|
||||
|
||||
export function providerCategoryLabel(
|
||||
plugin: PluginProvider,
|
||||
providerCategory: string,
|
||||
currentLocale: string | null | undefined,
|
||||
): string {
|
||||
const providerCategories = plugin.metadata?.providerCategories;
|
||||
if (!providerCategories || typeof providerCategories !== "object" || Array.isArray(providerCategories)) {
|
||||
return providerCategory;
|
||||
}
|
||||
|
||||
const category = (providerCategories as Record<string, unknown>)[providerCategory];
|
||||
if (!category || typeof category !== "object" || Array.isArray(category)) {
|
||||
return providerCategory;
|
||||
}
|
||||
|
||||
const labels = (category as Record<string, unknown>).labels;
|
||||
if (!labels || typeof labels !== "object" || Array.isArray(labels)) {
|
||||
return providerCategory;
|
||||
}
|
||||
|
||||
return localizedText(labels as LocalizedTextMap, currentLocale, providerCategory);
|
||||
}
|
||||
|
||||
function normalizeLocale(value: string | null | undefined): string {
|
||||
return (value || "en").trim().toLowerCase().replace("_", "-");
|
||||
}
|
||||
60
web/src/lib/util/valhalla_anchor_util.ts
Normal file
60
web/src/lib/util/valhalla_anchor_util.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
interface ValhallaAnchorDisplay {
|
||||
icon: string;
|
||||
number: number | null;
|
||||
titleKey: "start" | "finish" | "route-point";
|
||||
}
|
||||
|
||||
export function valhallaAnchorDisplay(index: number, total: number): ValhallaAnchorDisplay {
|
||||
if (index === 0) {
|
||||
return {
|
||||
icon: "fa-bullseye",
|
||||
number: null,
|
||||
titleKey: "start",
|
||||
};
|
||||
}
|
||||
|
||||
if (index === total - 1) {
|
||||
return {
|
||||
icon: "fa-flag-checkered",
|
||||
number: null,
|
||||
titleKey: "finish",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
icon: "fa-location-dot",
|
||||
number: index,
|
||||
titleKey: "route-point",
|
||||
};
|
||||
}
|
||||
|
||||
export function valhallaAnchorTitle(
|
||||
index: number,
|
||||
total: number,
|
||||
translate: (key: string) => string,
|
||||
) {
|
||||
const display = valhallaAnchorDisplay(index, total);
|
||||
if (display.number === null) {
|
||||
return translate(display.titleKey);
|
||||
}
|
||||
|
||||
return `${translate(display.titleKey)} #${display.number}`;
|
||||
}
|
||||
|
||||
export function renderValhallaAnchorMarker(
|
||||
element: HTMLElement,
|
||||
index: number,
|
||||
total: number,
|
||||
) {
|
||||
const display = valhallaAnchorDisplay(index, total);
|
||||
element.replaceChildren();
|
||||
|
||||
if (display.number !== null) {
|
||||
element.textContent = `${display.number}`;
|
||||
return;
|
||||
}
|
||||
|
||||
const icon = document.createElement("i");
|
||||
icon.classList.add("fa", display.icon);
|
||||
element.appendChild(icon);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export class ClusterLayer implements BaseLayer {
|
||||
"clusters": { ...this.listeners["clusters"], ...listeners?.["clusters"] },
|
||||
"unclustered-point": { ...this.listeners["unclustered-point"], ...listeners?.["unclustered-point"] }
|
||||
}
|
||||
|
||||
this.spec = {
|
||||
version: 8,
|
||||
name: "clusters",
|
||||
@@ -36,8 +37,6 @@ export class ClusterLayer implements BaseLayer {
|
||||
"cluster-trails": {
|
||||
type: "geojson",
|
||||
data: geojson,
|
||||
cluster: true,
|
||||
clusterRadius: 50,
|
||||
}
|
||||
},
|
||||
layers: [
|
||||
@@ -45,26 +44,37 @@ export class ClusterLayer implements BaseLayer {
|
||||
id: "clusters",
|
||||
type: "circle",
|
||||
source: "cluster-trails",
|
||||
filter: ["has", "point_count"],
|
||||
maxzoom: 10,
|
||||
filter: ["all", ["!=", ["get", "is_large"], true], [">", ["get", "point_count"], 1]],
|
||||
paint: {
|
||||
"circle-color": "#242734",
|
||||
"circle-radius": [
|
||||
"step",
|
||||
["get", "point_count"],
|
||||
10,
|
||||
5,
|
||||
12,
|
||||
10,
|
||||
15,
|
||||
20,
|
||||
20,
|
||||
50,
|
||||
25,
|
||||
18,
|
||||
100,
|
||||
30,
|
||||
200,
|
||||
35,
|
||||
22,
|
||||
500,
|
||||
25,
|
||||
],
|
||||
"circle-stroke-width": 3,
|
||||
"circle-stroke-width": 2,
|
||||
"circle-stroke-color": "#fff",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "unclustered-point",
|
||||
type: "circle",
|
||||
source: "cluster-trails",
|
||||
filter: ["all", ["!=", ["get", "is_large"], true], ["==", ["get", "point_count"], 1]],
|
||||
paint: {
|
||||
"circle-color": "#242734",
|
||||
"circle-radius": 5,
|
||||
"circle-stroke-width": 2,
|
||||
"circle-stroke-color": "#fff",
|
||||
},
|
||||
},
|
||||
@@ -72,29 +82,17 @@ export class ClusterLayer implements BaseLayer {
|
||||
id: "cluster-count",
|
||||
type: "symbol",
|
||||
source: "cluster-trails",
|
||||
filter: ["has", "point_count"],
|
||||
maxzoom: 10,
|
||||
filter: ["all", ["!=", ["get", "is_large"], true], [">", ["get", "point_count"], 1]],
|
||||
layout: {
|
||||
"text-field": ["get", "point_count_abbreviated"],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": 11,
|
||||
"text-allow-overlap": true,
|
||||
"text-ignore-placement": true,
|
||||
},
|
||||
paint: {
|
||||
"text-color": "#fff",
|
||||
},
|
||||
layout: {
|
||||
"text-field": "{point_count_abbreviated}",
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": 12,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "unclustered-point",
|
||||
type: "circle",
|
||||
source: "cluster-trails",
|
||||
maxzoom: 10,
|
||||
filter: ["!", ["has", "point_count"]],
|
||||
paint: {
|
||||
"circle-color": "#242734",
|
||||
"circle-radius": 7,
|
||||
"circle-stroke-width": 2,
|
||||
"circle-stroke-color": "#fff",
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
@@ -105,19 +103,26 @@ export class ClusterLayer implements BaseLayer {
|
||||
const features = this.map.queryRenderedFeatures(e.point, {
|
||||
layers: ["clusters"],
|
||||
});
|
||||
const clusterId = features[0].properties.cluster_id;
|
||||
const zoom = await (
|
||||
this.map.getSource("cluster-trails") as M.GeoJSONSource
|
||||
).getClusterExpansionZoom(clusterId);
|
||||
const feature = features[0];
|
||||
if (!feature) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentZoom = this.map.getZoom();
|
||||
this.map.flyTo({
|
||||
center: (features[0].geometry as any).coordinates,
|
||||
zoom,
|
||||
center: (feature.geometry as any).coordinates,
|
||||
zoom: currentZoom + 2,
|
||||
maxDuration: 3000
|
||||
});
|
||||
}
|
||||
|
||||
private zoomOnUnclusteredPoint(e: MapMouseEvent) {
|
||||
const coordinates = (e as any).features[0].geometry.coordinates.slice();
|
||||
const feature = (e as any).features?.[0];
|
||||
if (!feature) {
|
||||
return;
|
||||
}
|
||||
|
||||
const coordinates = feature.geometry.coordinates.slice();
|
||||
|
||||
this.map.flyTo({
|
||||
center: coordinates,
|
||||
@@ -125,4 +130,4 @@ export class ClusterLayer implements BaseLayer {
|
||||
maxDuration: 3000
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ import * as M from "maplibre-gl";
|
||||
import { DebugLayer } from "./debug-layer";
|
||||
import { baseMapStyles, defaultMapState, type BaseLayer, type MapState } from "./layers";
|
||||
import { OverlayLayer } from "./overlay-layer";
|
||||
import { OverpassLayer } from "./overpass-layer";
|
||||
import { OverpassLayer, type OverpassPopupActionFactory } from "./overpass-layer";
|
||||
|
||||
const DEFAULT_GLYPHS = "https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf";
|
||||
|
||||
|
||||
export class LayerManager {
|
||||
@@ -11,9 +12,11 @@ export class LayerManager {
|
||||
state!: MapState;
|
||||
layers: Record<string, BaseLayer> = {};
|
||||
private addedListeners: Set<string> = new Set();
|
||||
private overpassActionFactory?: OverpassPopupActionFactory;
|
||||
|
||||
constructor(map: M.Map) {
|
||||
constructor(map: M.Map, options?: { overpassActionFactory?: OverpassPopupActionFactory }) {
|
||||
this.map = map;
|
||||
this.overpassActionFactory = options?.overpassActionFactory;
|
||||
|
||||
const storedMapState = localStorage.getItem("map-state")
|
||||
if (storedMapState) {
|
||||
@@ -40,7 +43,7 @@ export class LayerManager {
|
||||
try {
|
||||
this.update(this.state, true);
|
||||
|
||||
const overpassLayer = new OverpassLayer(this.map)
|
||||
const overpassLayer = new OverpassLayer(this.map, this.overpassActionFactory)
|
||||
const debugLayer = new DebugLayer()
|
||||
|
||||
this.addLayer("overpass", overpassLayer)
|
||||
@@ -118,6 +121,14 @@ export class LayerManager {
|
||||
}
|
||||
}
|
||||
|
||||
const style = this.map.getStyle();
|
||||
if (
|
||||
!style.glyphs &&
|
||||
layer.spec.layers.some((l) => l.type === "symbol" && l.layout && "text-field" in l.layout)
|
||||
) {
|
||||
this.map.setGlyphs(layer.spec.glyphs ?? DEFAULT_GLYPHS);
|
||||
}
|
||||
|
||||
for (const l of layer.spec.layers) {
|
||||
if (!this.map.getLayer(l.id)) {
|
||||
this.map.addLayer(l)
|
||||
@@ -208,4 +219,4 @@ export class LayerManager {
|
||||
this.addLayer(id, layer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,18 @@
|
||||
* License: MIT
|
||||
*/
|
||||
|
||||
import { createOverpassPopup } from "$lib/util/maplibre_util";
|
||||
import { createOverpassPopup, type OverpassPopupAction } from "$lib/util/maplibre_util";
|
||||
import * as M from "maplibre-gl";
|
||||
import { type LngLatBounds, type MapMouseEvent, type StyleSpecification } from "maplibre-gl";
|
||||
import { pois, type BaseLayer, type MapState } from "./layers";
|
||||
import type { OverpassResponse } from "./types";
|
||||
import { env } from '$env/dynamic/public'
|
||||
|
||||
export type OverpassPopupActionFactory = (
|
||||
feature: GeoJSON.Feature,
|
||||
coordinates: GeoJSON.Position,
|
||||
) => OverpassPopupAction | null | undefined;
|
||||
|
||||
export class OverpassLayer implements BaseLayer {
|
||||
private overpassApiURL: string = "/api/v1/overpass/interpreter";
|
||||
|
||||
@@ -61,9 +66,11 @@ export class OverpassLayer implements BaseLayer {
|
||||
private popup: M.Popup;
|
||||
private map: M.Map;
|
||||
private currentPopupCoordinates: GeoJSON.Position | null = null
|
||||
private popupActionFactory?: OverpassPopupActionFactory;
|
||||
|
||||
constructor(map: M.Map) {
|
||||
constructor(map: M.Map, popupActionFactory?: OverpassPopupActionFactory) {
|
||||
this.map = map;
|
||||
this.popupActionFactory = popupActionFactory;
|
||||
this.popup = new M.Popup()
|
||||
.setMaxWidth("420px")
|
||||
}
|
||||
@@ -71,7 +78,8 @@ export class OverpassLayer implements BaseLayer {
|
||||
private openPopup(e: MapMouseEvent) {
|
||||
const features = (e as any).features as GeoJSON.Feature[];
|
||||
const point = features[0].geometry as GeoJSON.Point;
|
||||
const content = createOverpassPopup(features[0], point.coordinates);
|
||||
const action = this.popupActionFactory?.(features[0], point.coordinates);
|
||||
const content = createOverpassPopup(features[0], point.coordinates, action ?? undefined);
|
||||
|
||||
this.currentPopupCoordinates = point.coordinates;
|
||||
this.popup
|
||||
|
||||
@@ -18,9 +18,10 @@ export class PreviewLayer implements BaseLayer {
|
||||
}
|
||||
};
|
||||
|
||||
constructor(map: M.Map, geojson: GeoJSON.FeatureCollection, listeners?: Record<string, { onMouseUp?: (e: MapMouseEvent) => void; onMouseDown?: (e: MapMouseEvent) => void; onEnter?: (e: MapMouseEvent) => void; onLeave?: (e: MapMouseEvent) => void; onMouseMove?: (e: MapMouseEvent) => void; }>) {
|
||||
constructor(map: M.Map, geojson: GeoJSON.FeatureCollection, options?: { showStartMarker?: boolean, listeners?: Record<string, { onMouseUp?: (e: MapMouseEvent) => void; onMouseDown?: (e: MapMouseEvent) => void; onEnter?: (e: MapMouseEvent) => void; onLeave?: (e: MapMouseEvent) => void; onMouseMove?: (e: MapMouseEvent) => void; }> }) {
|
||||
|
||||
this.map = map;
|
||||
const listeners = options?.listeners;
|
||||
this.listeners = {
|
||||
"preview": { ...this.listeners["preview"], ...listeners?.["preview"] },
|
||||
"preview-start-points": { ...this.listeners["preview-start-points"], ...listeners?.["preview-start-points"] }
|
||||
@@ -40,9 +41,11 @@ export class PreviewLayer implements BaseLayer {
|
||||
}
|
||||
}))
|
||||
};
|
||||
|
||||
this.spec = {
|
||||
version: 8,
|
||||
name: "preview",
|
||||
glyphs: "https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf",
|
||||
sources: {
|
||||
"preview": {
|
||||
type: "geojson",
|
||||
@@ -58,7 +61,6 @@ export class PreviewLayer implements BaseLayer {
|
||||
id: "preview",
|
||||
type: "line",
|
||||
source: "preview",
|
||||
minzoom: 10,
|
||||
paint: {
|
||||
"line-color": ["get", "color"],
|
||||
"line-width": 5,
|
||||
@@ -68,10 +70,10 @@ export class PreviewLayer implements BaseLayer {
|
||||
id: "preview-start-points",
|
||||
type: "circle",
|
||||
source: "preview-start-points",
|
||||
minzoom: 10,
|
||||
filter: ["literal", options?.showStartMarker ?? false],
|
||||
paint: {
|
||||
"circle-color": "#242734",
|
||||
"circle-radius": 6,
|
||||
"circle-radius": 5,
|
||||
"circle-stroke-width": 2,
|
||||
"circle-stroke-color": "#fff",
|
||||
},
|
||||
@@ -80,7 +82,6 @@ export class PreviewLayer implements BaseLayer {
|
||||
id: "preview-direction-carets",
|
||||
type: "symbol",
|
||||
source: "preview",
|
||||
minzoom: 10,
|
||||
layout: {
|
||||
"symbol-placement": "line",
|
||||
"symbol-spacing": [
|
||||
@@ -108,4 +109,4 @@ export class PreviewLayer implements BaseLayer {
|
||||
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MapMouseEvent, Marker, StyleSpecification } from "maplibre-gl";
|
||||
import type { FilterSpecification, MapMouseEvent, Marker, StyleSpecification } from "maplibre-gl";
|
||||
import * as M from "maplibre-gl";
|
||||
import type { BaseLayer } from "./layers";
|
||||
|
||||
export class TrailLayer implements BaseLayer {
|
||||
@@ -7,7 +8,19 @@ export class TrailLayer implements BaseLayer {
|
||||
listeners: Record<string, { onMouseUp?: (e: MapMouseEvent) => void; onMouseDown?: (e: MapMouseEvent) => void; onEnter?: (e: MapMouseEvent) => void; onLeave?: (e: MapMouseEvent) => void; onMouseMove?: (e: MapMouseEvent) => void; }>
|
||||
markers: Record<string, Marker> = {};
|
||||
|
||||
constructor(id: string, geojson: GeoJSON.FeatureCollection, color: string, listerners?: { onMouseUp?: (e: MapMouseEvent) => void; onMouseDown?: (e: MapMouseEvent) => void; onEnter?: (e: MapMouseEvent) => void; onLeave?: (e: MapMouseEvent) => void; onMouseMove?: (e: MapMouseEvent) => void; }) {
|
||||
constructor(id: string, geojson: GeoJSON.FeatureCollection, color: string, options?: {
|
||||
listeners?: { onMouseUp?: (e: MapMouseEvent) => void; onMouseDown?: (e: MapMouseEvent) => void; onEnter?: (e: MapMouseEvent) => void; onLeave?: (e: MapMouseEvent) => void; onMouseMove?: (e: MapMouseEvent) => void; }
|
||||
}) {
|
||||
const layer: M.LineLayerSpecification = {
|
||||
id: id,
|
||||
type: "line",
|
||||
source: id,
|
||||
paint: {
|
||||
"line-color": color,
|
||||
"line-width": 5,
|
||||
},
|
||||
};
|
||||
|
||||
this.spec = {
|
||||
version: 8,
|
||||
name: id,
|
||||
@@ -17,18 +30,10 @@ export class TrailLayer implements BaseLayer {
|
||||
data: geojson,
|
||||
}
|
||||
},
|
||||
layers: [{
|
||||
id: id,
|
||||
type: "line",
|
||||
source: id,
|
||||
paint: {
|
||||
"line-color": color,
|
||||
"line-width": 5,
|
||||
},
|
||||
}]
|
||||
layers: [layer]
|
||||
|
||||
};
|
||||
|
||||
this.listeners = { [id]: listerners ?? {} }
|
||||
this.listeners = { [id]: options?.listeners ?? {} }
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
import PageLoadingBar from "$lib/components/page_loading_bar.svelte";
|
||||
import UploadDialog from "$lib/components/settings/upload_dialog.svelte";
|
||||
import { currentUser } from "$lib/stores/user_store";
|
||||
import { load_plugin_data_once } from "$lib/stores/plugin_store";
|
||||
import { isRouteProtected } from "$lib/util/authorization_util";
|
||||
import { onMount, type Snippet } from "svelte";
|
||||
import { slide } from "svelte/transition";
|
||||
@@ -42,6 +43,9 @@
|
||||
if (page.data.origin != location.origin) {
|
||||
showWarning = true;
|
||||
}
|
||||
if (data.user) {
|
||||
load_plugin_data_once();
|
||||
}
|
||||
});
|
||||
|
||||
let hideDemoHint = $state(false);
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
const actors = await searchActors(q);
|
||||
searchDropdownItems = actors.map((a) => ({
|
||||
text: a.username,
|
||||
description: `@${a.preferred_username}${a.isLocal ? "" : "@" + a.domain}`,
|
||||
description: `@${a.preferred_username}${a.is_local ? "" : "@" + a.domain}`,
|
||||
value: a,
|
||||
icon:
|
||||
a.icon ||
|
||||
@@ -119,7 +119,7 @@
|
||||
goto(`/lists/${item.value}`);
|
||||
} else if (item.value.preferred_username) {
|
||||
goto(
|
||||
`/profile/@${item.value.preferred_username}${item.value.isLocal ? "" : "@" + item.value.domain}`,
|
||||
`/profile/@${item.value.preferred_username}${item.value.is_local ? "" : "@" + item.value.domain}`,
|
||||
);
|
||||
} else {
|
||||
goto(`/map/?lat=${item.value.lat}&lon=${item.value.lon}`);
|
||||
|
||||
@@ -46,7 +46,7 @@ export async function GET(event: RequestEvent) {
|
||||
|
||||
const [username, domain] = splitUsername(fullUsername, env.ORIGIN)
|
||||
|
||||
const actor: Actor = await event.locals.pb.collection("activitypub_actors").getFirstListItem(`preferred_username:lower='${username?.toLowerCase()}'&&isLocal=1`)
|
||||
const actor: Actor = await event.locals.pb.collection("activitypub_actors").getFirstListItem(`preferred_username:lower='${username?.toLowerCase()}'&&is_local=1`)
|
||||
const user: UserAnonymous = await event.locals.pb.collection("users_anonymous").getOne(actor.user!)
|
||||
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ export async function GET(event: RequestEvent) {
|
||||
|
||||
const [username, domain] = splitUsername(fullUsername, env.ORIGIN)
|
||||
|
||||
const actor: Actor = await event.locals.pb.collection("activitypub_actors").getFirstListItem(`preferred_username:lower='${username?.toLowerCase()}'&&isLocal=true`)
|
||||
const actor: Actor = await event.locals.pb.collection("activitypub_actors").getFirstListItem(`preferred_username:lower='${username?.toLowerCase()}'&&is_local=true`)
|
||||
|
||||
const followers: ListResult<Follow> = await event.locals.pb.collection("follows").getList(intPage, 10, { sort: "-created", filter: `followee='${actor.id}'&&status='accepted'`, expand: "follower" })
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ export async function GET(event: RequestEvent) {
|
||||
|
||||
const [username, domain] = splitUsername(fullUsername, env.ORIGIN)
|
||||
|
||||
const actor: Actor = await event.locals.pb.collection("activitypub_actors").getFirstListItem(`preferred_username:lower='${username?.toLowerCase()}'&&isLocal=true`)
|
||||
const actor: Actor = await event.locals.pb.collection("activitypub_actors").getFirstListItem(`preferred_username:lower='${username?.toLowerCase()}'&&is_local=true`)
|
||||
|
||||
const followers: ListResult<Follow> = await event.locals.pb.collection("follows").getList(intPage, 10, { sort: "-created", filter: `follower='${actor.id}'&&status='accepted'`, expand: "followee" })
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ export async function GET(event: RequestEvent) {
|
||||
const [username, domain] = splitUsername(fullUsername, env.ORIGIN)
|
||||
|
||||
|
||||
const actor: Actor = await event.locals.pb.collection("activitypub_actors").getFirstListItem(`preferred_username:lower='${username?.toLowerCase()}'&&isLocal=true`)
|
||||
const actor: Actor = await event.locals.pb.collection("activitypub_actors").getFirstListItem(`preferred_username:lower='${username?.toLowerCase()}'&&is_local=true`)
|
||||
|
||||
const filter = `actor='${actor.iri}'&&type='Create'${safeSearchParams.filter ? '&&' + safeSearchParams.filter : ''}`
|
||||
const activities: ListResult<Activity> = await event.locals.pb.collection("activitypub_activities").getList(page, perPage, { sort: safeSearchParams.sort ?? "-created", filter })
|
||||
|
||||
@@ -61,7 +61,7 @@ export async function PUT(event: RequestEvent) {
|
||||
const followerActor: Actor = await event.locals.pb.collection("activitypub_actors").getFirstListItem(`user = '${event.locals.user.id}'`)
|
||||
const followeeActor: Actor = await event.locals.pb.collection("activitypub_actors").getOne(safeData.followee);
|
||||
|
||||
const follow = await event.locals.pb.collection("follows").create({ follower: followerActor.id, followee: followeeActor.id, status: followeeActor.isLocal ? "accepted" : "pending" })
|
||||
const follow = await event.locals.pb.collection("follows").create({ follower: followerActor.id, followee: followeeActor.id, status: followeeActor.is_local ? "accepted" : "pending" })
|
||||
|
||||
return json(follow);
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { IntegrationCreateSchema } from "$lib/models/api/integration_schema";
|
||||
import type { Integration } from "$lib/models/integration";
|
||||
import { Collection, create, handleError, list } from "$lib/util/api_util";
|
||||
import { json, type RequestEvent } from "@sveltejs/kit";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/integration:
|
||||
* get:
|
||||
* summary: List integrations
|
||||
* tags:
|
||||
* - Integrations
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: page
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: query
|
||||
* name: perPage
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: query
|
||||
* name: sort
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: filter
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: expand
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of integrations
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/ListResult'
|
||||
* 400:
|
||||
* description: Bad Request
|
||||
* 500:
|
||||
* description: Internal Server Error
|
||||
*/
|
||||
export async function GET(event: RequestEvent) {
|
||||
try {
|
||||
const r = await list<Integration>(event, Collection.integrations);
|
||||
|
||||
return json(r)
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/integration:
|
||||
* put:
|
||||
* summary: Create integration
|
||||
* tags:
|
||||
* - Integrations
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/IntegrationInput'
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Integration created
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Integration'
|
||||
* 400:
|
||||
* description: Bad Request
|
||||
* 500:
|
||||
* description: Internal Server Error
|
||||
*/
|
||||
export async function PUT(event: RequestEvent) {
|
||||
try {
|
||||
const r = await create<Integration>(event, IntegrationCreateSchema, Collection.integrations)
|
||||
return json(r);
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { IntegrationUpdateSchema } from "$lib/models/api/integration_schema";
|
||||
import type { Integration } from "$lib/models/integration";
|
||||
import { Collection, handleError, remove, show, update } from "$lib/util/api_util";
|
||||
import { json, type RequestEvent } from "@sveltejs/kit";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/integration/{id}:
|
||||
* get:
|
||||
* summary: Get integration
|
||||
* tags:
|
||||
* - Integrations
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: expand
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Integration details
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Integration'
|
||||
* 404:
|
||||
* description: Not Found
|
||||
* 500:
|
||||
* description: Internal Server Error
|
||||
*/
|
||||
export async function GET(event: RequestEvent) {
|
||||
try {
|
||||
const r = await show<Integration>(event, Collection.integrations)
|
||||
return json(r)
|
||||
} catch (e: any) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/integration/{id}:
|
||||
* post:
|
||||
* summary: Update integration
|
||||
* tags:
|
||||
* - Integrations
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/IntegrationUpdateInput'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Integration updated
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Integration'
|
||||
* 400:
|
||||
* description: Bad Request
|
||||
* 404:
|
||||
* description: Not Found
|
||||
* 500:
|
||||
* description: Internal Server Error
|
||||
*/
|
||||
export async function POST(event: RequestEvent) {
|
||||
try {
|
||||
const r = await update<Integration>(event, IntegrationUpdateSchema, Collection.integrations)
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/integration/{id}:
|
||||
* delete:
|
||||
* summary: Delete integration
|
||||
* tags:
|
||||
* - Integrations
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Integration deleted
|
||||
* 404:
|
||||
* description: Not Found
|
||||
* 500:
|
||||
* description: Internal Server Error
|
||||
*/
|
||||
export async function DELETE(event: RequestEvent) {
|
||||
try {
|
||||
const r = await remove(event, Collection.integrations)
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { handleError } from "$lib/util/api_util";
|
||||
import { json, type RequestEvent } from "@sveltejs/kit";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/integration/hammerhead/login:
|
||||
* get:
|
||||
* summary: Get Hammerhead login endpoint
|
||||
* description: Proxies to backend to get Hammerhead login configuration
|
||||
* tags:
|
||||
* - Integrations
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Hammerhead login endpoint
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* 500:
|
||||
* description: Internal Server Error
|
||||
*/
|
||||
export async function GET(event: RequestEvent) {
|
||||
try {
|
||||
const r = await event.locals.pb.send("/integration/hammerhead/login", {
|
||||
method: "GET",
|
||||
});
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { handleError } from "$lib/util/api_util";
|
||||
import { json, type RequestEvent } from "@sveltejs/kit";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/integration/komoot/login:
|
||||
* get:
|
||||
* summary: Get Komoot login endpoint
|
||||
* description: Proxies to backend to get Komoot login configuration
|
||||
* tags:
|
||||
* - Integrations
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Komoot login endpoint
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* 500:
|
||||
* description: Internal Server Error
|
||||
*/
|
||||
export async function GET(event: RequestEvent) {
|
||||
try {
|
||||
const r = await event.locals.pb.send("/integration/komoot/login", {
|
||||
method: "GET",
|
||||
});
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
return handleError(e)
|
||||
}
|
||||
}
|
||||
111
web/src/routes/api/v1/plugin-instance/+server.ts
Normal file
111
web/src/routes/api/v1/plugin-instance/+server.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
PluginInstanceCreateSchema,
|
||||
} from "$lib/models/api/plugin_instance_schema";
|
||||
import type { PluginInstance } from "$lib/models/plugin_instance";
|
||||
import { Collection, create, handleError, list } from "$lib/util/api_util";
|
||||
import { json, type RequestEvent } from "@sveltejs/kit";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/plugin-instance:
|
||||
* get:
|
||||
* summary: List plugin instances
|
||||
* description: Retrieves the authenticated user's configured plugin instances.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: page
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: query
|
||||
* name: perPage
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: query
|
||||
* name: sort
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: filter
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: ListResult<PluginInstance>
|
||||
* 400:
|
||||
* description: Bad Request
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 500:
|
||||
* description: Internal Server Error
|
||||
*/
|
||||
export async function GET(event: RequestEvent) {
|
||||
try {
|
||||
const r = await list<PluginInstance>(event, Collection.plugin_instances);
|
||||
return json(r);
|
||||
} catch (e) {
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/plugin-instance:
|
||||
* put:
|
||||
* summary: Create plugin instance
|
||||
* description: Creates a plugin instance for the authenticated user.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - user
|
||||
* - plugin_id
|
||||
* properties:
|
||||
* user:
|
||||
* type: string
|
||||
* description: User record ID
|
||||
* plugin_id:
|
||||
* type: string
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* auth:
|
||||
* type: object
|
||||
* additionalProperties:
|
||||
* type: string
|
||||
* config:
|
||||
* type: object
|
||||
* additionalProperties: true
|
||||
* state:
|
||||
* type: object
|
||||
* additionalProperties: true
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [configured, needs_auth, needs_reauth, syncing, rate_limited, unavailable, unsupported_protocol, error, disabled]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Plugin instance created
|
||||
* 400:
|
||||
* description: Bad Request
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 500:
|
||||
* description: Internal Server Error
|
||||
*/
|
||||
export async function PUT(event: RequestEvent) {
|
||||
try {
|
||||
const r = await create<PluginInstance>(
|
||||
event,
|
||||
PluginInstanceCreateSchema,
|
||||
Collection.plugin_instances,
|
||||
);
|
||||
return json(r);
|
||||
} catch (e) {
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
138
web/src/routes/api/v1/plugin-instance/[id]/+server.ts
Normal file
138
web/src/routes/api/v1/plugin-instance/[id]/+server.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
PluginInstanceUpdateSchema,
|
||||
} from "$lib/models/api/plugin_instance_schema";
|
||||
import type { PluginInstance } from "$lib/models/plugin_instance";
|
||||
import { Collection, handleError, remove, show, update } from "$lib/util/api_util";
|
||||
import { json, type RequestEvent } from "@sveltejs/kit";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/plugin-instance/{id}:
|
||||
* get:
|
||||
* summary: Get plugin instance
|
||||
* description: Retrieves a plugin instance by ID.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Plugin instance record ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Plugin instance details
|
||||
* 400:
|
||||
* description: Bad Request
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 404:
|
||||
* description: Not Found
|
||||
* 500:
|
||||
* description: Internal Server Error
|
||||
*/
|
||||
export async function GET(event: RequestEvent) {
|
||||
try {
|
||||
const r = await show<PluginInstance>(event, Collection.plugin_instances);
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/plugin-instance/{id}:
|
||||
* post:
|
||||
* summary: Update plugin instance
|
||||
* description: Updates plugin instance settings, auth, state, or status.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Plugin instance record ID
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* auth:
|
||||
* type: object
|
||||
* additionalProperties:
|
||||
* type: string
|
||||
* config:
|
||||
* type: object
|
||||
* additionalProperties: true
|
||||
* state:
|
||||
* type: object
|
||||
* additionalProperties: true
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [configured, needs_auth, needs_reauth, syncing, rate_limited, unavailable, unsupported_protocol, error, disabled]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Plugin instance updated
|
||||
* 400:
|
||||
* description: Bad Request
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 404:
|
||||
* description: Not Found
|
||||
* 500:
|
||||
* description: Internal Server Error
|
||||
*/
|
||||
export async function POST(event: RequestEvent) {
|
||||
try {
|
||||
const r = await update<PluginInstance>(
|
||||
event,
|
||||
PluginInstanceUpdateSchema,
|
||||
Collection.plugin_instances,
|
||||
);
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/plugin-instance/{id}:
|
||||
* delete:
|
||||
* summary: Delete plugin instance
|
||||
* description: Deletes a configured plugin instance.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Plugin instance record ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Success
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 404:
|
||||
* description: Not Found
|
||||
* 500:
|
||||
* description: Internal Server Error
|
||||
*/
|
||||
export async function DELETE(event: RequestEvent) {
|
||||
try {
|
||||
const r = await remove(event, Collection.plugin_instances);
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
63
web/src/routes/api/v1/plugin-system/auth/validate/+server.ts
Normal file
63
web/src/routes/api/v1/plugin-system/auth/validate/+server.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { handleError } from "$lib/util/api_util";
|
||||
import { json, type RequestEvent } from "@sveltejs/kit";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/plugin-system/auth/validate:
|
||||
* post:
|
||||
* summary: Validate plugin session auth
|
||||
* description: Validates session-style plugin credentials before saving them.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - pluginId
|
||||
* - auth
|
||||
* properties:
|
||||
* pluginId:
|
||||
* type: string
|
||||
* instanceId:
|
||||
* type: string
|
||||
* authContext:
|
||||
* type: string
|
||||
* auth:
|
||||
* type: object
|
||||
* additionalProperties:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Auth validation result
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* ok:
|
||||
* type: boolean
|
||||
* authContext:
|
||||
* type: string
|
||||
* 400:
|
||||
* description: Bad Request
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 500:
|
||||
* description: Internal Server Error
|
||||
*/
|
||||
export async function POST(event: RequestEvent) {
|
||||
try {
|
||||
const body = await event.request.json();
|
||||
const r = await event.locals.pb.send("/plugins/auth/validate", {
|
||||
method: "POST",
|
||||
body,
|
||||
fetch: event.fetch,
|
||||
});
|
||||
return json(r);
|
||||
} catch (e) {
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { handleError } from "$lib/util/api_util";
|
||||
import { json, type RequestEvent } from "@sveltejs/kit";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/plugin-system/category-remap/apply:
|
||||
* post:
|
||||
* summary: Apply plugin category remap
|
||||
* description: Updates imported trails to the category configured for their stored provider category in the current plugin instance mapping.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - instanceId
|
||||
* properties:
|
||||
* instanceId:
|
||||
* type: string
|
||||
* description: Plugin instance record ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Remap result
|
||||
* 400:
|
||||
* description: Bad Request
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 404:
|
||||
* description: Not Found
|
||||
* 500:
|
||||
* description: Internal Server Error
|
||||
*/
|
||||
export async function POST(event: RequestEvent) {
|
||||
try {
|
||||
const body = await event.request.json();
|
||||
const r = await event.locals.pb.send("/plugins/category-remap/apply", {
|
||||
method: "POST",
|
||||
body,
|
||||
fetch: event.fetch,
|
||||
});
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { handleError } from "$lib/util/api_util";
|
||||
import { json, type RequestEvent } from "@sveltejs/kit";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/plugin-system/category-remap/preview:
|
||||
* post:
|
||||
* summary: Preview plugin category remap
|
||||
* description: Counts imported trails whose stored provider category can be mapped by the current plugin instance category mapping.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - instanceId
|
||||
* properties:
|
||||
* instanceId:
|
||||
* type: string
|
||||
* description: Plugin instance record ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Remap preview
|
||||
* 400:
|
||||
* description: Bad Request
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 404:
|
||||
* description: Not Found
|
||||
* 500:
|
||||
* description: Internal Server Error
|
||||
*/
|
||||
export async function POST(event: RequestEvent) {
|
||||
try {
|
||||
const body = await event.request.json();
|
||||
const r = await event.locals.pb.send("/plugins/category-remap/preview", {
|
||||
method: "POST",
|
||||
body,
|
||||
fetch: event.fetch,
|
||||
});
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { handleError } from "$lib/util/api_util";
|
||||
import { json, type RequestEvent } from "@sveltejs/kit";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/plugin-system/oauth/callback:
|
||||
* post:
|
||||
* summary: Complete plugin OAuth flow
|
||||
* description: Exchanges an OAuth authorization code and stores the resulting plugin credentials.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - instanceId
|
||||
* - code
|
||||
* - state
|
||||
* properties:
|
||||
* instanceId:
|
||||
* type: string
|
||||
* code:
|
||||
* type: string
|
||||
* state:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OAuth callback handled
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* ok:
|
||||
* type: boolean
|
||||
* 400:
|
||||
* description: Bad Request
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 500:
|
||||
* description: Internal Server Error
|
||||
*/
|
||||
export async function POST(event: RequestEvent) {
|
||||
try {
|
||||
const body = await event.request.json();
|
||||
const r = await event.locals.pb.send("/plugins/oauth/callback", {
|
||||
method: "POST",
|
||||
body,
|
||||
fetch: event.fetch,
|
||||
});
|
||||
return json(r);
|
||||
} catch (e) {
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
@@ -3,52 +3,52 @@ import { json, type RequestEvent } from "@sveltejs/kit";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/integration/hammerhead/upload:
|
||||
* /api/v1/plugin-system/oauth/revoke:
|
||||
* post:
|
||||
* summary: Upload via Hammerhead integration
|
||||
* description: Proxies file upload to backend Hammerhead integration
|
||||
* summary: Revoke plugin OAuth credentials
|
||||
* description: Revokes OAuth credentials stored for a plugin instance.
|
||||
* tags:
|
||||
* - Integrations
|
||||
* - Plugins
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* multipart/form-data:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - file
|
||||
* - instanceId
|
||||
* properties:
|
||||
* file:
|
||||
* instanceId:
|
||||
* type: string
|
||||
* authContext:
|
||||
* type: string
|
||||
* format: binary
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Upload result
|
||||
* description: OAuth credentials revoked
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* ok:
|
||||
* type: boolean
|
||||
* 400:
|
||||
* description: Bad Request
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 500:
|
||||
* description: Internal Server Error
|
||||
*/
|
||||
export async function POST(event: RequestEvent) {
|
||||
try {
|
||||
const formData = await event.request.formData();
|
||||
const file = formData.get("file");
|
||||
|
||||
if (!(file instanceof Blob)) {
|
||||
return json({ message: "missing_file" }, { status: 400 });
|
||||
}
|
||||
|
||||
const r = await event.locals.pb.send("/integration/hammerhead/upload", {
|
||||
const body = await event.request.json();
|
||||
const r = await event.locals.pb.send("/plugins/oauth/revoke", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
body,
|
||||
fetch: event.fetch,
|
||||
});
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
return handleError(e)
|
||||
} catch (e) {
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
66
web/src/routes/api/v1/plugin-system/oauth/start/+server.ts
Normal file
66
web/src/routes/api/v1/plugin-system/oauth/start/+server.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { handleError } from "$lib/util/api_util";
|
||||
import { json, type RequestEvent } from "@sveltejs/kit";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/plugin-system/oauth/start:
|
||||
* post:
|
||||
* summary: Start plugin OAuth flow
|
||||
* description: Creates an OAuth authorization URL for a plugin instance.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - pluginId
|
||||
* - instanceId
|
||||
* - redirectUri
|
||||
* properties:
|
||||
* pluginId:
|
||||
* type: string
|
||||
* instanceId:
|
||||
* type: string
|
||||
* authContext:
|
||||
* type: string
|
||||
* redirectUri:
|
||||
* type: string
|
||||
* format: uri
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OAuth authorization URL
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* url:
|
||||
* type: string
|
||||
* format: uri
|
||||
* state:
|
||||
* type: string
|
||||
* instanceId:
|
||||
* type: string
|
||||
* 400:
|
||||
* description: Bad Request
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 500:
|
||||
* description: Internal Server Error
|
||||
*/
|
||||
export async function POST(event: RequestEvent) {
|
||||
try {
|
||||
const body = await event.request.json();
|
||||
const r = await event.locals.pb.send("/plugins/oauth/start", {
|
||||
method: "POST",
|
||||
body,
|
||||
fetch: event.fetch,
|
||||
});
|
||||
return json(r);
|
||||
} catch (e) {
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
60
web/src/routes/api/v1/plugin-system/plugins/+server.ts
Normal file
60
web/src/routes/api/v1/plugin-system/plugins/+server.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { handleError } from "$lib/util/api_util";
|
||||
import { json, type RequestEvent } from "@sveltejs/kit";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/plugin-system/plugins:
|
||||
* get:
|
||||
* summary: List installed plugins
|
||||
* description: Refreshes the plugin cache and lists locally installed plugin providers with runtime status and manifest metadata.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Installed plugin providers
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* items:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* type:
|
||||
* type: string
|
||||
* name:
|
||||
* type: string
|
||||
* version:
|
||||
* type: string
|
||||
* runtime:
|
||||
* type: string
|
||||
* capabilities:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [available, disabled, error]
|
||||
* error:
|
||||
* type: string
|
||||
* manifest:
|
||||
* type: object
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 500:
|
||||
* description: Internal Server Error
|
||||
*/
|
||||
export async function GET(event: RequestEvent) {
|
||||
try {
|
||||
const r = await event.locals.pb.send("/plugins", {
|
||||
method: "GET",
|
||||
});
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
62
web/src/routes/api/v1/plugin-system/trail-send/+server.ts
Normal file
62
web/src/routes/api/v1/plugin-system/trail-send/+server.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { handleError } from "$lib/util/api_util";
|
||||
import { json, type RequestEvent } from "@sveltejs/kit";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/plugin-system/trail-send:
|
||||
* post:
|
||||
* summary: Send trail through plugin
|
||||
* description: Sends an existing trail to an enabled plugin provider that supports trail transfer.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - pluginId
|
||||
* - trailId
|
||||
* properties:
|
||||
* pluginId:
|
||||
* type: string
|
||||
* trailId:
|
||||
* type: string
|
||||
* share:
|
||||
* type: string
|
||||
* description: Optional share token used to authorize sending a shared trail.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Trail sent
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* ok:
|
||||
* type: boolean
|
||||
* 400:
|
||||
* description: Bad Request
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Forbidden
|
||||
* 404:
|
||||
* description: Not Found
|
||||
* 500:
|
||||
* description: Internal Server Error
|
||||
*/
|
||||
export async function POST(event: RequestEvent) {
|
||||
try {
|
||||
const body = await event.request.json();
|
||||
const r = await event.locals.pb.send("/plugins/trail-send", {
|
||||
method: "POST",
|
||||
body,
|
||||
fetch: event.fetch,
|
||||
});
|
||||
return json(r);
|
||||
} catch (e: any) {
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user