full settings overhaul
This commit is contained in:
15
db/main.go
15
db/main.go
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/pocketbase/pocketbase/models"
|
||||
"github.com/pocketbase/pocketbase/plugins/migratecmd"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
|
||||
_ "pocketbase/migrations"
|
||||
"pocketbase/util"
|
||||
@@ -122,6 +123,20 @@ func main() {
|
||||
return nil
|
||||
})
|
||||
|
||||
app.OnRecordBeforeRequestEmailChangeRequest("users").Add(func(e *core.RecordRequestEmailChangeEvent) error {
|
||||
form := forms.NewRecordEmailChangeRequest(app, e.Record)
|
||||
if err := e.HttpContext.Bind(form); err != nil {
|
||||
return err
|
||||
}
|
||||
e.Record.Set("email", form.NewEmail)
|
||||
|
||||
if err := app.Dao().SaveRecord(e.Record); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return hook.StopPropagation
|
||||
})
|
||||
|
||||
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
|
||||
e.Router.GET("/public/search/token", func(c echo.Context) error {
|
||||
searchRules := map[string]interface{}{
|
||||
|
||||
57
db/migrations/1720090768_updated_settings.go
Normal file
57
db/migrations/1720090768_updated_settings.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(db dbx.Builder) error {
|
||||
dao := daos.New(db);
|
||||
|
||||
collection, err := dao.FindCollectionByNameOrId("uavt73rsqcn1n13")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// add
|
||||
new_category := &schema.SchemaField{}
|
||||
if err := json.Unmarshal([]byte(`{
|
||||
"system": false,
|
||||
"id": "owlyzl1x",
|
||||
"name": "category",
|
||||
"type": "relation",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "kjxvi8asj2igqwf",
|
||||
"cascadeDelete": false,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
}`), new_category); err != nil {
|
||||
return err
|
||||
}
|
||||
collection.Schema.AddField(new_category)
|
||||
|
||||
return dao.SaveCollection(collection)
|
||||
}, func(db dbx.Builder) error {
|
||||
dao := daos.New(db);
|
||||
|
||||
collection, err := dao.FindCollectionByNameOrId("uavt73rsqcn1n13")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// remove
|
||||
collection.Schema.RemoveField("owlyzl1x")
|
||||
|
||||
return dao.SaveCollection(collection)
|
||||
})
|
||||
}
|
||||
53
db/migrations/1720094290_updated_settings.go
Normal file
53
db/migrations/1720094290_updated_settings.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/daos"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
"github.com/pocketbase/pocketbase/models/schema"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(db dbx.Builder) error {
|
||||
dao := daos.New(db);
|
||||
|
||||
collection, err := dao.FindCollectionByNameOrId("uavt73rsqcn1n13")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// add
|
||||
new_tilesets := &schema.SchemaField{}
|
||||
if err := json.Unmarshal([]byte(`{
|
||||
"system": false,
|
||||
"id": "xdbayoqg",
|
||||
"name": "tilesets",
|
||||
"type": "json",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"maxSize": 2000000
|
||||
}
|
||||
}`), new_tilesets); err != nil {
|
||||
return err
|
||||
}
|
||||
collection.Schema.AddField(new_tilesets)
|
||||
|
||||
return dao.SaveCollection(collection)
|
||||
}, func(db dbx.Builder) error {
|
||||
dao := daos.New(db);
|
||||
|
||||
collection, err := dao.FindCollectionByNameOrId("uavt73rsqcn1n13")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// remove
|
||||
collection.Schema.RemoveField("xdbayoqg")
|
||||
|
||||
return dao.SaveCollection(collection)
|
||||
})
|
||||
}
|
||||
@@ -15,7 +15,7 @@ A route is the GPS data of a trail. There are two main ways that a user can prov
|
||||
|
||||
#### Upload a file
|
||||
|
||||
Click the `Upload file` button and choose a file. The file must be either in GPX, TCX, or KML format. Once you have selected a file you will see a couple of things happen:
|
||||
Click the `Upload file` button and choose a file. The file must be either in GPX, FIT, TCX, or KML format. Once you have selected a file you will see a couple of things happen:
|
||||
- the map will display and focus on your track.
|
||||
- the elevation profile and speed charts (if your file contains that information) will be updated accordingly.
|
||||
- in the left-hand panel information like the trail name, location, and distance will be displayed.
|
||||
|
||||
65
web/package-lock.json
generated
65
web/package-lock.json
generated
@@ -14,9 +14,9 @@
|
||||
"@threlte/extras": "^8.7.5",
|
||||
"@types/leaflet.awesome-markers": "^2.0.28",
|
||||
"@types/three": "^0.161.2",
|
||||
"@types/xmldom": "^0.1.34",
|
||||
"canvg": "^4.0.1",
|
||||
"crypto-random-string": "^5.0.0",
|
||||
"easy-fit": "^0.0.8",
|
||||
"instead": "^1.0.3",
|
||||
"isomorphic-xml2js": "^0.1.3",
|
||||
"jspdf": "^2.5.1",
|
||||
@@ -32,6 +32,7 @@
|
||||
"qrcode": "^1.4.4",
|
||||
"svelte-i18n": "^4.0.0",
|
||||
"three": "^0.161.0",
|
||||
"xmldom": "^0.6.0",
|
||||
"yup": "^1.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1158,6 +1159,11 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/xmldom": {
|
||||
"version": "0.1.34",
|
||||
"resolved": "https://registry.npmjs.org/@types/xmldom/-/xmldom-0.1.34.tgz",
|
||||
"integrity": "sha512-7eZFfxI9XHYjJJuugddV6N5YNeXgQE1lArWOcd1eCOKWb/FGs5SIjacSYuEJuwhsGS3gy4RuZ5EUIcqYscuPDA=="
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.2.1.tgz",
|
||||
@@ -2235,11 +2241,6 @@
|
||||
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/easy-fit": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/easy-fit/-/easy-fit-0.0.8.tgz",
|
||||
"integrity": "sha512-59BXYGsiteuFG7qfkOccPz2ioXH9RTWcO4ffY/m3niYdAyzYSsSkgqOB6vKncLcbIBZU9cC8V12dCbeAlPBeew=="
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.4.647",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.647.tgz",
|
||||
@@ -3334,9 +3335,9 @@
|
||||
"integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ=="
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "24.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.0.0.tgz",
|
||||
"integrity": "sha512-UDS2NayCvmXSXVP6mpTj+73JnNQadZlr9N68189xib2tx5Mls7swlTNao26IoHv46BZJFvXygyRtyXd1feAk1A==",
|
||||
"version": "24.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.0.tgz",
|
||||
"integrity": "sha512-6gpM7pRXCwIOKxX47cgOyvyQDN/Eh0f1MeKySBV2xGdKtqJBLj8P25eY3EVCWo2mglDDzozR2r2MW4T+JiNUZA==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
@@ -3346,21 +3347,21 @@
|
||||
"decimal.js": "^10.4.3",
|
||||
"form-data": "^4.0.0",
|
||||
"html-encoding-sniffer": "^4.0.0",
|
||||
"http-proxy-agent": "^7.0.0",
|
||||
"https-proxy-agent": "^7.0.2",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.4",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"nwsapi": "^2.2.7",
|
||||
"nwsapi": "^2.2.10",
|
||||
"parse5": "^7.1.2",
|
||||
"rrweb-cssom": "^0.6.0",
|
||||
"rrweb-cssom": "^0.7.0",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^4.1.3",
|
||||
"tough-cookie": "^4.1.4",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^7.0.0",
|
||||
"whatwg-encoding": "^3.1.1",
|
||||
"whatwg-mimetype": "^4.0.0",
|
||||
"whatwg-url": "^14.0.0",
|
||||
"ws": "^8.16.0",
|
||||
"ws": "^8.17.0",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3375,6 +3376,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jsdom/node_modules/rrweb-cssom": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz",
|
||||
"integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/jsdom/node_modules/tr46": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz",
|
||||
@@ -3912,9 +3921,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nwsapi": {
|
||||
"version": "2.2.7",
|
||||
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.7.tgz",
|
||||
"integrity": "sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==",
|
||||
"version": "2.2.10",
|
||||
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.10.tgz",
|
||||
"integrity": "sha512-QK0sRs7MKv0tKe1+5uZIQk/C8XGza4DAnztJG8iD+TpJIORARrCxczA738awHrZoHeTjSSoHqao2teO0dC/gFQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true
|
||||
@@ -5521,9 +5530,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz",
|
||||
"integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==",
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
|
||||
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
@@ -6135,9 +6144,9 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz",
|
||||
"integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==",
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
|
||||
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
@@ -6196,6 +6205,14 @@
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/xmldom": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.6.0.tgz",
|
||||
"integrity": "sha512-iAcin401y58LckRZ0TkI4k0VSM1Qg0KGSc3i8rU+xrxe19A/BN1zHyVSJY7uoutVlaTSzYyk/v5AmkewAP7jtg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
|
||||
@@ -38,9 +38,9 @@
|
||||
"@threlte/extras": "^8.7.5",
|
||||
"@types/leaflet.awesome-markers": "^2.0.28",
|
||||
"@types/three": "^0.161.2",
|
||||
"@types/xmldom": "^0.1.34",
|
||||
"canvg": "^4.0.1",
|
||||
"crypto-random-string": "^5.0.0",
|
||||
"easy-fit": "^0.0.8",
|
||||
"instead": "^1.0.3",
|
||||
"isomorphic-xml2js": "^0.1.3",
|
||||
"jspdf": "^2.5.1",
|
||||
@@ -56,6 +56,7 @@
|
||||
"qrcode": "^1.4.4",
|
||||
"svelte-i18n": "^4.0.0",
|
||||
"three": "^0.161.0",
|
||||
"xmldom": "^0.6.0",
|
||||
"yup": "^1.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
export let name: string = "";
|
||||
export let value: string | number = "";
|
||||
export let placeholder: string = "";
|
||||
export let disabled: boolean = false;
|
||||
export let label: string = "";
|
||||
export let error: string = "";
|
||||
export let icon: string = "";
|
||||
@@ -28,6 +29,8 @@
|
||||
class="bg-input-background border border-input-border rounded-md p-3 transition-colors focus:border-input-border-focus focus:outline-none focus:ring-0 w-full {extraClasses}"
|
||||
class:border-red-400={error.length > 0}
|
||||
class:bg-input-background-error={error.length > 0}
|
||||
class:text-gray-500={disabled}
|
||||
{disabled}
|
||||
{autocomplete}
|
||||
use:typeAction
|
||||
bind:value
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
];
|
||||
|
||||
const dropdownItems = [
|
||||
{ text: $_("profile"), value: "profile", icon: "user" },
|
||||
{ text: $_("settings"), value: "settings", icon: "cog" },
|
||||
{ text: $_("logout"), value: "logout", icon: "right-from-bracket" },
|
||||
];
|
||||
|
||||
@@ -79,8 +79,8 @@
|
||||
if (item.value == "logout") {
|
||||
logout();
|
||||
window.location.href = "/";
|
||||
} else if (item.value == "profile") {
|
||||
goto("/profile");
|
||||
} else if (item.value == "settings") {
|
||||
goto("/settings/account");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -203,11 +203,11 @@
|
||||
let:toggleMenu={openDropdown}
|
||||
>
|
||||
<button
|
||||
class="rounded-full bg-white text-black hover:bg-gray-200 focus:ring-4 ring-gray-100/50 transition-colors h-10 w-10"
|
||||
class="rounded-full bg-white text-black hover:bg-gray-200 focus:ring-4 ring-gray-100/50 transition-colors max-h-11 aspect-square"
|
||||
on:click={openDropdown}
|
||||
>
|
||||
<img
|
||||
class="rounded-full"
|
||||
class="rounded-full w-full h-full"
|
||||
src={getFileURL($currentUser, $currentUser.avatar) ||
|
||||
`https://api.dicebear.com/7.x/initials/svg?seed=${$currentUser.username}&backgroundType=gradientLinear`}
|
||||
alt="avatar"
|
||||
|
||||
60
web/src/lib/components/profile/email_modal.svelte
Normal file
60
web/src/lib/components/profile/email_modal.svelte
Normal file
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import Modal from "$lib/components/base/modal.svelte";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import TextField from "../base/text_field.svelte";
|
||||
import { createForm } from "$lib/vendor/svelte-form-lib";
|
||||
import { object, string } from "yup";
|
||||
|
||||
let _openModal: (() => void) | undefined = undefined;
|
||||
export let closeModal: (() => void) | undefined = undefined;
|
||||
export function openModal() {
|
||||
$form.email = email
|
||||
$errors.email = "";
|
||||
_openModal!();
|
||||
}
|
||||
|
||||
export let email = "";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const { form, errors, handleChange, handleSubmit } = createForm<{
|
||||
email: string;
|
||||
}>({
|
||||
initialValues: { email: email },
|
||||
validationSchema: object({
|
||||
email: string()
|
||||
.email($_("not-a-valid-email-address"))
|
||||
.required($_("required")),
|
||||
}),
|
||||
onSubmit: async (_) => {
|
||||
dispatch("save", $form.email);
|
||||
closeModal!();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
id="email-modal"
|
||||
size="max-w-xl"
|
||||
title={$_("change-email")}
|
||||
bind:openModal={_openModal}
|
||||
bind:closeModal
|
||||
>
|
||||
<form id="email-form" slot="content" on:submit={handleSubmit}>
|
||||
<TextField
|
||||
name="email"
|
||||
bind:value={$form.email}
|
||||
error={$errors.email}
|
||||
on:change={handleChange}
|
||||
></TextField>
|
||||
</form>
|
||||
<div slot="footer" class="flex items-center gap-4">
|
||||
<button class="btn-secondary" on:click={closeModal}
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
<button class="btn-primary" type="submit" form="email-form" name="save"
|
||||
>{$_("save")}</button
|
||||
>
|
||||
</div></Modal
|
||||
>
|
||||
84
web/src/lib/components/profile/password_modal.svelte
Normal file
84
web/src/lib/components/profile/password_modal.svelte
Normal file
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import Modal from "$lib/components/base/modal.svelte";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import TextField from "../base/text_field.svelte";
|
||||
import { createForm } from "$lib/vendor/svelte-form-lib";
|
||||
import { object, string } from "yup";
|
||||
|
||||
let _openModal: (() => void) | undefined = undefined;
|
||||
export let closeModal: (() => void) | undefined = undefined;
|
||||
export function openModal() {
|
||||
$form.password = ""
|
||||
$errors.password = "";
|
||||
$form.oldPassword = ""
|
||||
$errors.oldPassword = "";
|
||||
_openModal!();
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const { form, errors, handleChange, handleSubmit } = createForm<{
|
||||
oldPassword: string;
|
||||
password: string;
|
||||
}>({
|
||||
initialValues: { oldPassword: "", password: "" },
|
||||
validationSchema: object({
|
||||
oldPassword: string()
|
||||
.min(
|
||||
8,
|
||||
$_("must-be-at-least-n-characters-long", {
|
||||
values: { n: 8 },
|
||||
}),
|
||||
)
|
||||
.required($_("required")),
|
||||
password: string()
|
||||
.min(
|
||||
8,
|
||||
$_("must-be-at-least-n-characters-long", {
|
||||
values: { n: 8 },
|
||||
}),
|
||||
)
|
||||
.required($_("required")),
|
||||
}),
|
||||
onSubmit: async (_) => {
|
||||
dispatch("save", {oldPassword: $form.oldPassword, password: $form.password, passwordConfirm: $form.password});
|
||||
closeModal!();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
id="password-modal"
|
||||
size="max-w-xl"
|
||||
title={$_("change-password")}
|
||||
bind:openModal={_openModal}
|
||||
bind:closeModal
|
||||
>
|
||||
<form id="password-form" slot="content" on:submit={handleSubmit}>
|
||||
<TextField
|
||||
name="oldPassword"
|
||||
label={$_("current-password")}
|
||||
type="password"
|
||||
bind:value={$form.oldPassword}
|
||||
error={$errors.oldPassword}
|
||||
on:change={handleChange}
|
||||
></TextField>
|
||||
<TextField
|
||||
name="password"
|
||||
label={$_("new-password")}
|
||||
type="password"
|
||||
bind:value={$form.password}
|
||||
error={$errors.password}
|
||||
on:change={handleChange}
|
||||
></TextField>
|
||||
</form>
|
||||
<div slot="footer" class="flex items-center gap-4">
|
||||
<button class="btn-secondary" on:click={closeModal}
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
<button class="btn-primary" type="submit" form="password-form" name="save"
|
||||
>{$_("save")}</button
|
||||
>
|
||||
</div></Modal
|
||||
>
|
||||
41
web/src/lib/components/profile/settings.svelte
Normal file
41
web/src/lib/components/profile/settings.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import type { DropdownItem } from "../base/dropdown.svelte";
|
||||
|
||||
export let selected: number;
|
||||
|
||||
const items: DropdownItem[] = [
|
||||
{ text: "My Account", value: "/settings/account" },
|
||||
{ text: "Display", value: "/settings/display" },
|
||||
{ text: "Import/Export", value: "/settings/export" },
|
||||
{ text: "Help", value: "https://wanderer.to" },
|
||||
];
|
||||
|
||||
function handleItemClick(item: DropdownItem) {
|
||||
if(item.text != "Help") {
|
||||
goto(item.value);
|
||||
}else {
|
||||
window.open(item.value, '_blank');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-1 md:grid-cols-[256px_1fr] max-w-4xl mx-4 md:mx-auto gap-x-12 items-start"
|
||||
>
|
||||
<menu class="p-4 border border-input-border rounded-xl mb-4">
|
||||
{#each items as item, i}
|
||||
<li
|
||||
class="menu-item flex items-center px-4 py-3 my-1 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors rounded-md"
|
||||
class:bg-menu-item-background-hover={i == selected}
|
||||
role="presentation"
|
||||
on:mousedown|stopPropagation={() => handleItemClick(item)}
|
||||
>
|
||||
{item.text}
|
||||
</li>
|
||||
{/each}
|
||||
</menu>
|
||||
<div class="settings-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/stores";
|
||||
import { Settings } from "$lib/models/settings";
|
||||
import type { Trail } from "$lib/models/trail";
|
||||
import { createMarkerFromWaypoint } from "$lib/util/leaflet_util";
|
||||
import "$lib/vendor/leaflet-elevation/src/index.css";
|
||||
@@ -99,27 +100,33 @@
|
||||
},
|
||||
);
|
||||
|
||||
const baseMaps = {
|
||||
const baseMaps: Record<string, L.TileLayer> = {
|
||||
OpenStreetMaps: baseLayer,
|
||||
OpenTopoMaps: topoLayer,
|
||||
...($page.data.settings as Settings).tilesets?.reduce<
|
||||
Record<string, string>
|
||||
>((t, current) => {
|
||||
t[current.name] = L.tileLayer(current.url);
|
||||
return t;
|
||||
}, {}),
|
||||
};
|
||||
|
||||
L.control.layers(baseMaps).addTo(map);
|
||||
|
||||
switch (localStorage.getItem("layer")) {
|
||||
case "OpenTopoMaps":
|
||||
topoLayer.addTo(map);
|
||||
break;
|
||||
default:
|
||||
baseLayer.addTo(map);
|
||||
break;
|
||||
const layerPreference = localStorage.getItem("layer")
|
||||
|
||||
if (layerPreference && Object.keys(baseMaps).includes(layerPreference)) {
|
||||
baseMaps[layerPreference].addTo(map!)
|
||||
}else {
|
||||
baseLayer.addTo(map)
|
||||
}
|
||||
|
||||
map!.on("baselayerchange", function (e) {
|
||||
localStorage.setItem("layer", e.name);
|
||||
});
|
||||
|
||||
const localMetric = localStorage.getItem("gradient") as any ?? "altitude";
|
||||
const localMetric =
|
||||
(localStorage.getItem("gradient") as any) ?? "altitude";
|
||||
selectedMetric = localMetric === "false" ? false : localMetric;
|
||||
|
||||
const default_elevation_options = {
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="trail-info-panel mx-auto border border-input-border rounded-3xl h-full" style="max-width: min(100%, 64rem)"
|
||||
class="trail-info-panel mx-auto border border-input-border rounded-3xl h-full" style="max-width: min(100%, 64rem);"
|
||||
>
|
||||
<div class="trail-info-panel-header">
|
||||
<section class="relative h-80">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"Biking": "Radfahren",
|
||||
"Canoeing": "Kanufahren",
|
||||
"Climbing": "Klettern",
|
||||
"export-all-trails": "",
|
||||
"Hiking": "Wandern",
|
||||
"Skiing": "Skifahren",
|
||||
"Walking": "Laufen",
|
||||
@@ -23,6 +24,8 @@
|
||||
"categories": "Kategorien",
|
||||
"category": "Kategorie",
|
||||
"change": "Ändern",
|
||||
"change-email": "",
|
||||
"change-password": "",
|
||||
"changelog": "Changelog",
|
||||
"chinese": "Chinesisch (vereinfacht)",
|
||||
"close": "",
|
||||
@@ -33,9 +36,11 @@
|
||||
"copy-link": "Link kopieren",
|
||||
"create-new-list": "Neue Liste erstellen",
|
||||
"creation-date": "Erstellungsdatum",
|
||||
"current-password": "",
|
||||
"cycling": "Radfahren",
|
||||
"danger-zone": "Gefahrenzone",
|
||||
"date": "Datum",
|
||||
"default-category": "",
|
||||
"default-location": "Standort",
|
||||
"delete": "Löschen",
|
||||
"delete-account": "Konto löschen",
|
||||
@@ -46,6 +51,7 @@
|
||||
"difficult": "Schwer",
|
||||
"difficulty": "Schwierigkeit",
|
||||
"directions": "Wegbeschreibung",
|
||||
"display": "",
|
||||
"display-as": "Anzeigen als",
|
||||
"distance": "Distanz",
|
||||
"documentation": "Dokumentation",
|
||||
@@ -66,12 +72,14 @@
|
||||
"error-exporting-trail": "Fehler beim Exportieren des Trails",
|
||||
"error-printing-map": "Fehler beim Drucken der Karte",
|
||||
"error-reading-file": "Fehler beim Lesen der Datei",
|
||||
"error-updating-password": "",
|
||||
"est-duration": "Gesch. Dauer",
|
||||
"explore": "Erkunden",
|
||||
"explore-some-trails": "Erkunden Sie einige Routen",
|
||||
"export": "Exportieren",
|
||||
"features": "Features",
|
||||
"file-format": "Dateiformat",
|
||||
"focus-map-on": "",
|
||||
"french": "Französisch",
|
||||
"german": "Deutsch",
|
||||
"get-position-from-exif": "Koordinaten aus EXIF Daten",
|
||||
@@ -85,6 +93,7 @@
|
||||
"hungarian": "Ungarisch",
|
||||
"icon": "Icon",
|
||||
"imperial": "Amerikanisch",
|
||||
"import": "",
|
||||
"include-description": "Beschreibung übernehmen",
|
||||
"invalid-date": "Ungültiges Datum",
|
||||
"invalid-username": "Ungültiger Nutzername",
|
||||
@@ -96,6 +105,7 @@
|
||||
"list": "{n, plural, =1 {Liste} other {Listen}}",
|
||||
"location": "Standort",
|
||||
"login": "Login",
|
||||
"login-details": "",
|
||||
"logout": "Logout",
|
||||
"longitude": "Längengrad",
|
||||
"make-one": "Neues erstellen!",
|
||||
@@ -113,6 +123,7 @@
|
||||
"name": "Name",
|
||||
"near": "Nahe",
|
||||
"new-list": "Neue Liste",
|
||||
"new-password": "",
|
||||
"new-trail": "Neue Route",
|
||||
"no-account": "Du hast noch kein Konto?",
|
||||
"no-preference": "Keine Präferenz",
|
||||
@@ -124,6 +135,7 @@
|
||||
"orientation": "Ausrichtung",
|
||||
"paper-size": "Papierformat",
|
||||
"password": "Passwort",
|
||||
"password-updated": "",
|
||||
"photos": "Fotos",
|
||||
"pick-a-trail": "Route auswählen",
|
||||
"polish": "Polnisch",
|
||||
@@ -156,12 +168,14 @@
|
||||
"stop-drawing": "Zeichnen beenden",
|
||||
"summit-book": "Gipfelbuch",
|
||||
"text": "Text",
|
||||
"tilesets": "",
|
||||
"trail": "{n, plural, =1 {Route} other {Routen}}",
|
||||
"trail-not-shared": "Route mit niemandem geteilt",
|
||||
"trails-for-you": "Routen für dich",
|
||||
"units": "Einheiten",
|
||||
"upload-file": "Datei hochladen",
|
||||
"upload-gpx": "GPX hochladen",
|
||||
"uploaded": "",
|
||||
"username": "Nutzername",
|
||||
"view": "Ansehen",
|
||||
"waypoints": "{n, plural, =1 {Wegpunkt} other {Wegpunkte}}",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"Biking": "Biking",
|
||||
"Canoeing": "Canoeing",
|
||||
"Climbing": "Climbing",
|
||||
"export-all-trails": "Export all trails",
|
||||
"Hiking": "Hiking",
|
||||
"Skiing": "Skiing",
|
||||
"Walking": "Walking",
|
||||
@@ -23,6 +24,8 @@
|
||||
"categories": "Categories",
|
||||
"category": "Category",
|
||||
"change": "Change",
|
||||
"change-email": "Change email",
|
||||
"change-password": "Change password",
|
||||
"changelog": "Changelog",
|
||||
"chinese": "Chinese (simplified)",
|
||||
"close": "Close",
|
||||
@@ -33,9 +36,11 @@
|
||||
"copy-link": "Copy Link",
|
||||
"create-new-list": "Create new list",
|
||||
"creation-date": "Creation date",
|
||||
"current-password": "Current password",
|
||||
"cycling": "Cycling",
|
||||
"danger-zone": "Danger zone",
|
||||
"date": "Date",
|
||||
"default-category": "Default category",
|
||||
"default-location": "Default Location",
|
||||
"delete": "Delete",
|
||||
"delete-account": "Delete Account",
|
||||
@@ -46,6 +51,7 @@
|
||||
"difficult": "Difficult",
|
||||
"difficulty": "Difficulty",
|
||||
"directions": "Directions",
|
||||
"display": "Display",
|
||||
"display-as": "Display as",
|
||||
"distance": "Distance",
|
||||
"documentation": "Documentation",
|
||||
@@ -66,12 +72,14 @@
|
||||
"error-exporting-trail": "Error exporting trail",
|
||||
"error-printing-map": "Error printing map",
|
||||
"error-reading-file": "Error reading file",
|
||||
"error-updating-password": "Error updating password",
|
||||
"est-duration": "Est. duration",
|
||||
"explore": "Explore",
|
||||
"explore-some-trails": "Explore some trails",
|
||||
"export": "Export",
|
||||
"features": "Features",
|
||||
"file-format": "File format",
|
||||
"focus-map-on": "Focus map on",
|
||||
"french": "French",
|
||||
"german": "German",
|
||||
"get-position-from-exif": "Get coordinates from EXIF data",
|
||||
@@ -85,6 +93,7 @@
|
||||
"hungarian": "Hungarian",
|
||||
"icon": "Icon",
|
||||
"imperial": "Imperial",
|
||||
"import": "Import",
|
||||
"include-description": "Include description",
|
||||
"invalid-date": "Invalid Date",
|
||||
"invalid-username": "Invalid username",
|
||||
@@ -96,6 +105,7 @@
|
||||
"list": "{n, plural, =1 {List} other {Lists}}",
|
||||
"location": "Location",
|
||||
"login": "Login",
|
||||
"login-details": "Login details",
|
||||
"logout": "Logout",
|
||||
"longitude": "Longitude",
|
||||
"make-one": "Make one!",
|
||||
@@ -113,6 +123,7 @@
|
||||
"name": "Name",
|
||||
"near": "Near",
|
||||
"new-list": "New List",
|
||||
"new-password": "New password",
|
||||
"new-trail": "New Trail",
|
||||
"no-account": "Don't have an account?",
|
||||
"no-preference": "No preference",
|
||||
@@ -124,6 +135,7 @@
|
||||
"orientation": "Orientation",
|
||||
"paper-size": "Paper size",
|
||||
"password": "Password",
|
||||
"password-updated": "Password updated",
|
||||
"photos": "Photos",
|
||||
"pick-a-trail": "Pick a trail",
|
||||
"polish": "Polish",
|
||||
@@ -156,12 +168,14 @@
|
||||
"stop-drawing": "Stop drawing",
|
||||
"summit-book": "Summit Book",
|
||||
"text": "Text",
|
||||
"tilesets": "Custom tilesets",
|
||||
"trail": "{n, plural, =1 {Trail} other {Trails}}",
|
||||
"trail-not-shared": "Not shared with anyone",
|
||||
"trails-for-you": "Trails for you",
|
||||
"units": "Units",
|
||||
"upload-file": "Upload file",
|
||||
"upload-gpx": "Upload GPX",
|
||||
"uploaded": "uploaded",
|
||||
"username": "Username",
|
||||
"view": "View",
|
||||
"waypoints": "{n, plural, =1 {Waypoint} other {Waypoints}}",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"Biking": "",
|
||||
"Canoeing": "",
|
||||
"Climbing": "",
|
||||
"export-all-trails": "",
|
||||
"Hiking": "",
|
||||
"Skiing": "",
|
||||
"Walking": "",
|
||||
@@ -23,6 +24,8 @@
|
||||
"categories": "Catégories",
|
||||
"category": "Catégorie",
|
||||
"change": "Changement",
|
||||
"change-email": "",
|
||||
"change-password": "",
|
||||
"changelog": "Journal des modifications",
|
||||
"chinese": "Chinois (simplifié)",
|
||||
"close": "",
|
||||
@@ -33,9 +36,11 @@
|
||||
"copy-link": "",
|
||||
"create-new-list": "Créer une nouvelle liste",
|
||||
"creation-date": "Date de création",
|
||||
"current-password": "",
|
||||
"cycling": "",
|
||||
"danger-zone": "Zone de danger",
|
||||
"date": "Date",
|
||||
"default-category": "",
|
||||
"default-location": "Endroit pas défaut",
|
||||
"delete": "Supprimer",
|
||||
"delete-account": "Supprimer le compte",
|
||||
@@ -46,6 +51,7 @@
|
||||
"difficult": "Difficile",
|
||||
"difficulty": "Difficulté",
|
||||
"directions": "Directions",
|
||||
"display": "",
|
||||
"display-as": "Afficher en tant que",
|
||||
"distance": "Distance",
|
||||
"documentation": "Documentation",
|
||||
@@ -66,12 +72,14 @@
|
||||
"error-exporting-trail": "",
|
||||
"error-printing-map": "Erreur d'impression de la carte",
|
||||
"error-reading-file": "Erreur de lecture du fichier",
|
||||
"error-updating-password": "",
|
||||
"est-duration": "Temps estimé",
|
||||
"explore": "Explorer",
|
||||
"explore-some-trails": "Explorer certains itinéraires",
|
||||
"export": "",
|
||||
"features": "Fonctionnalités",
|
||||
"file-format": "",
|
||||
"focus-map-on": "",
|
||||
"french": "Français",
|
||||
"german": "Allemand",
|
||||
"get-position-from-exif": "",
|
||||
@@ -85,6 +93,7 @@
|
||||
"hungarian": "Hongrois",
|
||||
"icon": "Icône",
|
||||
"imperial": "Impérial",
|
||||
"import": "",
|
||||
"include-description": "Inclure la description",
|
||||
"invalid-date": "Date invalide",
|
||||
"invalid-username": "Nom d'utilisateur invalide",
|
||||
@@ -96,6 +105,7 @@
|
||||
"list": "{n, plural, =1 {Liste} other {Listes}}",
|
||||
"location": "Localisation",
|
||||
"login": "Connexion",
|
||||
"login-details": "",
|
||||
"logout": "Déconnexion",
|
||||
"longitude": "Longitude",
|
||||
"make-one": "Faites-en un !",
|
||||
@@ -113,6 +123,7 @@
|
||||
"name": "Nom",
|
||||
"near": "Proche",
|
||||
"new-list": "Nouvelle liste",
|
||||
"new-password": "",
|
||||
"new-trail": "Nouvel itinéraire",
|
||||
"no-account": "Pas encore de compte ?",
|
||||
"no-preference": "Pas de préférence",
|
||||
@@ -124,6 +135,7 @@
|
||||
"orientation": "Orientation",
|
||||
"paper-size": "Taille de papier",
|
||||
"password": "Mot de passe",
|
||||
"password-updated": "",
|
||||
"photos": "Photos",
|
||||
"pick-a-trail": "Choisir un itinéraire",
|
||||
"polish": "polonais",
|
||||
@@ -156,12 +168,14 @@
|
||||
"stop-drawing": "",
|
||||
"summit-book": "Livre du sommet",
|
||||
"text": "Texte",
|
||||
"tilesets": "",
|
||||
"trail": "{n, plural, =1 {Itinéraire} other {Itinéraires}}",
|
||||
"trail-not-shared": "",
|
||||
"trails-for-you": "Itinéraire pour vous",
|
||||
"units": "Unités",
|
||||
"upload-file": "Téléverser un fichier",
|
||||
"upload-gpx": "Envoyer un GPX",
|
||||
"uploaded": "",
|
||||
"username": "Nom d'utilisteur",
|
||||
"view": "",
|
||||
"waypoints": "Point de repère",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"Biking": "",
|
||||
"Canoeing": "",
|
||||
"Climbing": "",
|
||||
"export-all-trails": "",
|
||||
"Hiking": "",
|
||||
"Skiing": "",
|
||||
"Walking": "",
|
||||
@@ -23,6 +24,8 @@
|
||||
"categories": "Kategóriák",
|
||||
"category": "Kategória",
|
||||
"change": "Változás",
|
||||
"change-email": "",
|
||||
"change-password": "",
|
||||
"changelog": "Változás napló",
|
||||
"chinese": "Kínai (egyszerűsítve)",
|
||||
"close": "",
|
||||
@@ -33,9 +36,11 @@
|
||||
"copy-link": "",
|
||||
"create-new-list": "Új lista létrehozása",
|
||||
"creation-date": "létrehozás dátuma",
|
||||
"current-password": "",
|
||||
"cycling": "",
|
||||
"danger-zone": "Veszélyes zóna",
|
||||
"date": "Dátum",
|
||||
"default-category": "",
|
||||
"default-location": "Alapértelmezett hely",
|
||||
"delete": "Törlés",
|
||||
"delete-account": "Fiók törlése",
|
||||
@@ -46,6 +51,7 @@
|
||||
"difficult": "Nehéz",
|
||||
"difficulty": "Nehézség",
|
||||
"directions": "Irányok",
|
||||
"display": "",
|
||||
"display-as": "Megjelenítés mint",
|
||||
"distance": "Távolság",
|
||||
"documentation": "Dokumentáció",
|
||||
@@ -66,12 +72,14 @@
|
||||
"error-exporting-trail": "",
|
||||
"error-printing-map": "",
|
||||
"error-reading-file": "Hiba a fájl olvasása közben",
|
||||
"error-updating-password": "",
|
||||
"est-duration": "Becsült időtartam",
|
||||
"explore": "Felfedezés",
|
||||
"explore-some-trails": "Fedezzen fel néhány ösvényt",
|
||||
"export": "",
|
||||
"features": "Jellemzők",
|
||||
"file-format": "",
|
||||
"focus-map-on": "",
|
||||
"french": "Francia",
|
||||
"german": "Német",
|
||||
"get-position-from-exif": "",
|
||||
@@ -85,6 +93,7 @@
|
||||
"hungarian": "Magyar",
|
||||
"icon": "Ikon",
|
||||
"imperial": "Angolszász",
|
||||
"import": "",
|
||||
"include-description": "",
|
||||
"invalid-date": "Érvénytelen dátum",
|
||||
"invalid-username": "Érvénytelen felhasználó",
|
||||
@@ -96,6 +105,7 @@
|
||||
"list": "{n, plural, =1 {Lista} other {Listák}}",
|
||||
"location": "Helyszín",
|
||||
"login": "Bejelentkezés",
|
||||
"login-details": "",
|
||||
"logout": "Kijelentkezés",
|
||||
"longitude": "Hosszúság",
|
||||
"make-one": "Készítsen egyet!",
|
||||
@@ -113,6 +123,7 @@
|
||||
"name": "Név",
|
||||
"near": "Közelben",
|
||||
"new-list": "Új lista",
|
||||
"new-password": "",
|
||||
"new-trail": "Új útvonal",
|
||||
"no-account": "Nincs még fiókja?",
|
||||
"no-preference": "Nincs preferált",
|
||||
@@ -124,6 +135,7 @@
|
||||
"orientation": "",
|
||||
"paper-size": "",
|
||||
"password": "Jelszó",
|
||||
"password-updated": "",
|
||||
"photos": "Képek",
|
||||
"pick-a-trail": "Válasszon útvonalat",
|
||||
"polish": "Lengyel",
|
||||
@@ -156,12 +168,14 @@
|
||||
"stop-drawing": "",
|
||||
"summit-book": "Csúcspont könyv",
|
||||
"text": "Szöveg",
|
||||
"tilesets": "",
|
||||
"trail": "{n, plural, =1 {Útvonal} other {Útvonalak}}",
|
||||
"trail-not-shared": "",
|
||||
"trails-for-you": "Útvonalak önnek",
|
||||
"units": "Mértékegységek",
|
||||
"upload-file": "Fájl feltöltése",
|
||||
"upload-gpx": "GPX feltöltése",
|
||||
"uploaded": "",
|
||||
"username": "Felhasználónév",
|
||||
"view": "",
|
||||
"waypoints": "Útvonalpontok",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"Biking": "Ciclismo",
|
||||
"Canoeing": "Canoa",
|
||||
"Climbing": "Arrampicata",
|
||||
"export-all-trails": "",
|
||||
"Hiking": "Escursionismo",
|
||||
"Skiing": "Sciare",
|
||||
"Walking": "Camminare",
|
||||
@@ -23,6 +24,8 @@
|
||||
"categories": "Categorie",
|
||||
"category": "Categoria",
|
||||
"change": "Modifica",
|
||||
"change-email": "",
|
||||
"change-password": "",
|
||||
"changelog": "Changelog",
|
||||
"chinese": "Cinese (semplificato)",
|
||||
"close": "Chiudi",
|
||||
@@ -33,9 +36,11 @@
|
||||
"copy-link": "Copia link",
|
||||
"create-new-list": "Crea nuova lista",
|
||||
"creation-date": "Data di creazione",
|
||||
"current-password": "",
|
||||
"cycling": "Ciclismo",
|
||||
"danger-zone": "Zona di pericolo",
|
||||
"date": "Data",
|
||||
"default-category": "",
|
||||
"default-location": "Posizione predefinita",
|
||||
"delete": "Elimina",
|
||||
"delete-account": "Elimina account",
|
||||
@@ -46,6 +51,7 @@
|
||||
"difficult": "Difficile",
|
||||
"difficulty": "Difficoltà",
|
||||
"directions": "Indicazioni",
|
||||
"display": "",
|
||||
"display-as": "Visualizza come",
|
||||
"distance": "Distanza",
|
||||
"documentation": "Documentazione",
|
||||
@@ -66,12 +72,14 @@
|
||||
"error-exporting-trail": "Errore durante l'esportazione del percorso",
|
||||
"error-printing-map": "Errore durante la stampa della mappa",
|
||||
"error-reading-file": "Errore durante la lettura del file",
|
||||
"error-updating-password": "",
|
||||
"est-duration": "Durata stimata",
|
||||
"explore": "Esplora",
|
||||
"explore-some-trails": "Esplora alcuni percorsi",
|
||||
"export": "Esporta",
|
||||
"features": "Caratteristiche",
|
||||
"file-format": "Formato del file",
|
||||
"focus-map-on": "",
|
||||
"french": "Francese",
|
||||
"german": "Tedesco",
|
||||
"get-position-from-exif": "Ottieni posizione da dati EXIF",
|
||||
@@ -85,9 +93,11 @@
|
||||
"hungarian": "Ungherese",
|
||||
"icon": "Icona",
|
||||
"imperial": "Imperiale",
|
||||
"import": "",
|
||||
"include-description": "Adotta descrizione",
|
||||
"invalid-date": "Data non valida",
|
||||
"invalid-username": "Nome utente non valido",
|
||||
"italian": "",
|
||||
"language": "Lingua",
|
||||
"latitude": "Latitudine",
|
||||
"license": "Licenza",
|
||||
@@ -95,6 +105,7 @@
|
||||
"list": "{n, plural, =1 {Lista} other {Liste}}",
|
||||
"location": "Posizione",
|
||||
"login": "Login",
|
||||
"login-details": "",
|
||||
"logout": "Logout",
|
||||
"longitude": "Longitudine",
|
||||
"make-one": "Creane uno!",
|
||||
@@ -112,6 +123,7 @@
|
||||
"name": "Nome",
|
||||
"near": "Vicino",
|
||||
"new-list": "Nuova lista",
|
||||
"new-password": "",
|
||||
"new-trail": "Nuovo percorso",
|
||||
"no-account": "Non hai ancora un account?",
|
||||
"no-preference": "Nessuna preferenza",
|
||||
@@ -123,6 +135,7 @@
|
||||
"orientation": "Orientamento",
|
||||
"paper-size": "Formato carta",
|
||||
"password": "Password",
|
||||
"password-updated": "",
|
||||
"photos": "Foto",
|
||||
"pick-a-trail": "Scegli un percorso",
|
||||
"polish": "Polacco",
|
||||
@@ -155,12 +168,14 @@
|
||||
"stop-drawing": "Smettere di disegnare",
|
||||
"summit-book": "Libro di vetta",
|
||||
"text": "Testo",
|
||||
"tilesets": "",
|
||||
"trail": "{n, plural, =1 {Percorso} other {Percorsi}}",
|
||||
"trail-not-shared": "Percorso non condiviso con nessuno",
|
||||
"trails-for-you": "Percorsi per te",
|
||||
"units": "Unità",
|
||||
"upload-file": "Carica file",
|
||||
"upload-gpx": "Carica file GPX",
|
||||
"uploaded": "",
|
||||
"username": "Nome utente",
|
||||
"view": "Visualizza",
|
||||
"waypoints": "{n, plural, =1 {Waypoint} other {Waypoint}}",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"Biking": "",
|
||||
"Canoeing": "",
|
||||
"Climbing": "",
|
||||
"export-all-trails": "",
|
||||
"Hiking": "",
|
||||
"Skiing": "",
|
||||
"Walking": "",
|
||||
@@ -23,6 +24,8 @@
|
||||
"categories": "Categorieën",
|
||||
"category": "Categorie",
|
||||
"change": "Wijzigen",
|
||||
"change-email": "",
|
||||
"change-password": "",
|
||||
"changelog": "Wijzigingslog",
|
||||
"chinese": "Versimpeld Chinees",
|
||||
"close": "",
|
||||
@@ -33,9 +36,11 @@
|
||||
"copy-link": "",
|
||||
"create-new-list": "Nieuwe lijst",
|
||||
"creation-date": "Aanmaakdatum",
|
||||
"current-password": "",
|
||||
"cycling": "",
|
||||
"danger-zone": "Gevarenzone",
|
||||
"date": "Datum",
|
||||
"default-category": "",
|
||||
"default-location": "Standaardlocatie",
|
||||
"delete": "Verwijderen",
|
||||
"delete-account": "Account verwijderen",
|
||||
@@ -46,6 +51,7 @@
|
||||
"difficult": "Moeilijk",
|
||||
"difficulty": "Moeilijkheidsgraad",
|
||||
"directions": "Routebeschrijving",
|
||||
"display": "",
|
||||
"display-as": "Tonen als",
|
||||
"distance": "Afstand",
|
||||
"documentation": "Documentatie",
|
||||
@@ -66,12 +72,14 @@
|
||||
"error-exporting-trail": "",
|
||||
"error-printing-map": "Fout bij afdrukken van kaart",
|
||||
"error-reading-file": "Het bestand kan niet worden ingelezen",
|
||||
"error-updating-password": "",
|
||||
"est-duration": "Geschatte duur",
|
||||
"explore": "Verkennen",
|
||||
"explore-some-trails": "Verken enkele wandelroutes",
|
||||
"export": "",
|
||||
"features": "Kenmerken",
|
||||
"file-format": "",
|
||||
"focus-map-on": "",
|
||||
"french": "Frans",
|
||||
"german": "Duits",
|
||||
"get-position-from-exif": "",
|
||||
@@ -85,6 +93,7 @@
|
||||
"hungarian": "Hongaars",
|
||||
"icon": "Pictogram",
|
||||
"imperial": "Imperiaal",
|
||||
"import": "",
|
||||
"include-description": "Inclusief beschrijving",
|
||||
"invalid-date": "Ongeldige datum",
|
||||
"invalid-username": "Ongeldige gebruikersnaam",
|
||||
@@ -96,6 +105,7 @@
|
||||
"list": "{n, plural, =1 {Lijst} other {Lijsten}}",
|
||||
"location": "Locatie",
|
||||
"login": "Inloggen",
|
||||
"login-details": "",
|
||||
"logout": "Uitloggen",
|
||||
"longitude": "Lengtegraad",
|
||||
"make-one": "Maak er een aan!",
|
||||
@@ -113,6 +123,7 @@
|
||||
"name": "Naam",
|
||||
"near": "Nabij",
|
||||
"new-list": "Nieuwe lijst",
|
||||
"new-password": "",
|
||||
"new-trail": "Nieuwe wandelroute",
|
||||
"no-account": "Heb je nog geen account?",
|
||||
"no-preference": "Geen voorkeur",
|
||||
@@ -124,6 +135,7 @@
|
||||
"orientation": "Oriëntatie",
|
||||
"paper-size": "Papierformaat",
|
||||
"password": "Wachtwoord",
|
||||
"password-updated": "",
|
||||
"photos": "Foto's",
|
||||
"pick-a-trail": "Kies een wandelroute",
|
||||
"polish": "Pools",
|
||||
@@ -156,12 +168,14 @@
|
||||
"stop-drawing": "",
|
||||
"summit-book": "Bergtopboek",
|
||||
"text": "Tekst",
|
||||
"tilesets": "",
|
||||
"trail": "{n, plural, =1 {Wandelroute} other {Wandelroutes}}",
|
||||
"trail-not-shared": "",
|
||||
"trails-for-you": "Wandelroutes voor jou",
|
||||
"units": "Eenheden",
|
||||
"upload-file": "Bestand uploaden",
|
||||
"upload-gpx": "GPX-bestand uploaden",
|
||||
"uploaded": "",
|
||||
"username": "Gebruikersnaam",
|
||||
"view": "",
|
||||
"waypoints": "Routepunten",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"Biking": "",
|
||||
"Canoeing": "",
|
||||
"Climbing": "",
|
||||
"export-all-trails": "",
|
||||
"Hiking": "",
|
||||
"Skiing": "",
|
||||
"Walking": "",
|
||||
@@ -23,6 +24,8 @@
|
||||
"categories": "Kategorie",
|
||||
"category": "Kategoria",
|
||||
"change": "Zmień",
|
||||
"change-email": "",
|
||||
"change-password": "",
|
||||
"changelog": "Dziennik zmian",
|
||||
"chinese": "Uproszczony chiński",
|
||||
"close": "",
|
||||
@@ -33,9 +36,11 @@
|
||||
"copy-link": "",
|
||||
"create-new-list": "Stwórz nową listę",
|
||||
"creation-date": "Data dodania",
|
||||
"current-password": "",
|
||||
"cycling": "",
|
||||
"danger-zone": "Strefa niebezpieczna",
|
||||
"date": "Data",
|
||||
"default-category": "",
|
||||
"default-location": "Domyślna Lokalizacja",
|
||||
"delete": "Usuń",
|
||||
"delete-account": "Usuń Konto",
|
||||
@@ -46,6 +51,7 @@
|
||||
"difficult": "Trudna",
|
||||
"difficulty": "Trudność",
|
||||
"directions": "Kierunki",
|
||||
"display": "",
|
||||
"display-as": "Wyświetl jako",
|
||||
"distance": "Dystans",
|
||||
"documentation": "Dokumentacja",
|
||||
@@ -66,12 +72,14 @@
|
||||
"error-exporting-trail": "",
|
||||
"error-printing-map": "",
|
||||
"error-reading-file": "Błąd wczytywania pliku",
|
||||
"error-updating-password": "",
|
||||
"est-duration": "Szacowany czas",
|
||||
"explore": "Eksploruj",
|
||||
"explore-some-trails": "Eksploruj różne ścieżki",
|
||||
"export": "",
|
||||
"features": "Funkcje",
|
||||
"file-format": "",
|
||||
"focus-map-on": "",
|
||||
"french": "Francuski",
|
||||
"german": "Niemiecki",
|
||||
"get-position-from-exif": "",
|
||||
@@ -85,6 +93,7 @@
|
||||
"hungarian": "Język węgierski",
|
||||
"icon": "Ikona",
|
||||
"imperial": "Anglosaskie",
|
||||
"import": "",
|
||||
"include-description": "",
|
||||
"invalid-date": "Nieprawidłowa data",
|
||||
"invalid-username": "Błędna nazwa użytkownika",
|
||||
@@ -96,6 +105,7 @@
|
||||
"list": "{n, plural, =1 {Lista} other {Listy}}",
|
||||
"location": "Lokalizacja",
|
||||
"login": "Zaloguj się",
|
||||
"login-details": "",
|
||||
"logout": "Wyloguj",
|
||||
"longitude": "Wysokość",
|
||||
"make-one": "Stwórz ją!",
|
||||
@@ -113,6 +123,7 @@
|
||||
"name": "Nazwa",
|
||||
"near": "Blisko",
|
||||
"new-list": "Nowa Lista",
|
||||
"new-password": "",
|
||||
"new-trail": "Nowa ścieżka",
|
||||
"no-account": "Nie masz konta?",
|
||||
"no-preference": "Brak preferencji",
|
||||
@@ -124,6 +135,7 @@
|
||||
"orientation": "",
|
||||
"paper-size": "",
|
||||
"password": "Hasło",
|
||||
"password-updated": "",
|
||||
"photos": "Zdjęcia",
|
||||
"pick-a-trail": "Wybierz ścieżkę",
|
||||
"polish": "Polski",
|
||||
@@ -156,12 +168,14 @@
|
||||
"stop-drawing": "",
|
||||
"summit-book": "Logbook",
|
||||
"text": "Tekst",
|
||||
"tilesets": "",
|
||||
"trail": "{n, plural, =1 {Szlak} other {Szlaki}}",
|
||||
"trail-not-shared": "",
|
||||
"trails-for-you": "Ścieżki dla ciebie",
|
||||
"units": "Jednostki",
|
||||
"upload-file": "Przesyłanie pliku",
|
||||
"upload-gpx": "Importuj GPX",
|
||||
"uploaded": "",
|
||||
"username": "Nazwa Użytkownika",
|
||||
"view": "",
|
||||
"waypoints": "Punkty",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"Biking": "",
|
||||
"Canoeing": "",
|
||||
"Climbing": "",
|
||||
"export-all-trails": "",
|
||||
"Hiking": "",
|
||||
"Skiing": "",
|
||||
"Walking": "",
|
||||
@@ -23,6 +24,8 @@
|
||||
"categories": "Categorias",
|
||||
"category": "Categoria",
|
||||
"change": "Alterar",
|
||||
"change-email": "",
|
||||
"change-password": "",
|
||||
"changelog": "Registo de alterações",
|
||||
"chinese": "Chinês (simplificado)",
|
||||
"close": "",
|
||||
@@ -33,9 +36,11 @@
|
||||
"copy-link": "",
|
||||
"create-new-list": "Criar nova lista",
|
||||
"creation-date": "Data de criação",
|
||||
"current-password": "",
|
||||
"cycling": "",
|
||||
"danger-zone": "Zona de perigo",
|
||||
"date": "Data",
|
||||
"default-category": "",
|
||||
"default-location": "Localização padrão",
|
||||
"delete": "Excluir",
|
||||
"delete-account": "Excluir conta",
|
||||
@@ -46,6 +51,7 @@
|
||||
"difficult": "Difícil",
|
||||
"difficulty": "Dificuldade",
|
||||
"directions": "Instruções",
|
||||
"display": "",
|
||||
"display-as": "Exibir como",
|
||||
"distance": "Distância",
|
||||
"documentation": "Documentação",
|
||||
@@ -66,12 +72,14 @@
|
||||
"error-exporting-trail": "",
|
||||
"error-printing-map": "",
|
||||
"error-reading-file": "Erro ao ler o arquivo",
|
||||
"error-updating-password": "",
|
||||
"est-duration": "Duração prevista",
|
||||
"explore": "Explorar",
|
||||
"explore-some-trails": "Explore algumas trilhas",
|
||||
"export": "",
|
||||
"features": "Características",
|
||||
"file-format": "",
|
||||
"focus-map-on": "",
|
||||
"french": "Francês",
|
||||
"german": "Alemão",
|
||||
"get-position-from-exif": "",
|
||||
@@ -85,6 +93,7 @@
|
||||
"hungarian": "Húngaro",
|
||||
"icon": "Ícone",
|
||||
"imperial": "Imperial",
|
||||
"import": "",
|
||||
"include-description": "",
|
||||
"invalid-date": "Data inválida",
|
||||
"invalid-username": "Nome de usuário inválido",
|
||||
@@ -96,6 +105,7 @@
|
||||
"list": "{n, plural, =1 {Lista} other {Listas}}",
|
||||
"location": "Localização",
|
||||
"login": "Login",
|
||||
"login-details": "",
|
||||
"logout": "Sair",
|
||||
"longitude": "Longitude",
|
||||
"make-one": "Faz um!",
|
||||
@@ -113,6 +123,7 @@
|
||||
"name": "Nome",
|
||||
"near": "Próximo",
|
||||
"new-list": "Nova lista",
|
||||
"new-password": "",
|
||||
"new-trail": "Nova trilha",
|
||||
"no-account": "Não tem uma conta?",
|
||||
"no-preference": "Nenhuma preferência",
|
||||
@@ -124,6 +135,7 @@
|
||||
"orientation": "",
|
||||
"paper-size": "",
|
||||
"password": "Senha",
|
||||
"password-updated": "",
|
||||
"photos": "Fotos",
|
||||
"pick-a-trail": "Escolha uma trilha",
|
||||
"polish": "Polonês",
|
||||
@@ -156,12 +168,14 @@
|
||||
"stop-drawing": "",
|
||||
"summit-book": "Livro da cimeira",
|
||||
"text": "Texto",
|
||||
"tilesets": "",
|
||||
"trail": "{n, plural, =1 {Percurso} other {Percursos}}",
|
||||
"trail-not-shared": "",
|
||||
"trails-for-you": "Trilhos para si",
|
||||
"units": "Unidades",
|
||||
"upload-file": "Subir arquivo",
|
||||
"upload-gpx": "Carregar GPX",
|
||||
"uploaded": "",
|
||||
"username": "Nome de utilizador",
|
||||
"view": "",
|
||||
"waypoints": "Pontos de passagem",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"Biking": "",
|
||||
"Canoeing": "",
|
||||
"Climbing": "",
|
||||
"export-all-trails": "",
|
||||
"Hiking": "",
|
||||
"Skiing": "",
|
||||
"Walking": "",
|
||||
@@ -23,6 +24,8 @@
|
||||
"categories": "分类",
|
||||
"category": "分类",
|
||||
"change": "更换",
|
||||
"change-email": "",
|
||||
"change-password": "",
|
||||
"changelog": "变更日志",
|
||||
"chinese": "华人",
|
||||
"close": "",
|
||||
@@ -33,9 +36,11 @@
|
||||
"copy-link": "",
|
||||
"create-new-list": "创建新列表",
|
||||
"creation-date": "创建日期",
|
||||
"current-password": "",
|
||||
"cycling": "",
|
||||
"danger-zone": "危险区域",
|
||||
"date": "日期",
|
||||
"default-category": "",
|
||||
"default-location": "默认地点",
|
||||
"delete": "删除",
|
||||
"delete-account": "注销账户",
|
||||
@@ -46,6 +51,7 @@
|
||||
"difficult": "困难",
|
||||
"difficulty": "难度",
|
||||
"directions": "导航",
|
||||
"display": "",
|
||||
"display-as": "显示方式",
|
||||
"distance": "距离",
|
||||
"documentation": "文档",
|
||||
@@ -66,12 +72,14 @@
|
||||
"error-exporting-trail": "",
|
||||
"error-printing-map": "",
|
||||
"error-reading-file": "读取文件错误",
|
||||
"error-updating-password": "",
|
||||
"est-duration": "预计时长",
|
||||
"explore": "探索",
|
||||
"explore-some-trails": "探索行程",
|
||||
"export": "",
|
||||
"features": "特性",
|
||||
"file-format": "",
|
||||
"focus-map-on": "",
|
||||
"french": "法语",
|
||||
"german": "德语",
|
||||
"get-position-from-exif": "",
|
||||
@@ -85,6 +93,7 @@
|
||||
"hungarian": "匈牙利语",
|
||||
"icon": "图标",
|
||||
"imperial": "英制",
|
||||
"import": "",
|
||||
"include-description": "",
|
||||
"invalid-date": "无效日期",
|
||||
"invalid-username": "无效用户名",
|
||||
@@ -96,6 +105,7 @@
|
||||
"list": "{n, plural, =1 {列表} other {列表}}",
|
||||
"location": "地点",
|
||||
"login": "登录",
|
||||
"login-details": "",
|
||||
"logout": "登出",
|
||||
"longitude": "经度",
|
||||
"make-one": "立刻注册!",
|
||||
@@ -113,6 +123,7 @@
|
||||
"name": "名称",
|
||||
"near": "附近",
|
||||
"new-list": "新列表",
|
||||
"new-password": "",
|
||||
"new-trail": "创建新路线",
|
||||
"no-account": "还未注册?",
|
||||
"no-preference": "尚未规划",
|
||||
@@ -124,6 +135,7 @@
|
||||
"orientation": "",
|
||||
"paper-size": "",
|
||||
"password": "密码",
|
||||
"password-updated": "",
|
||||
"photos": "图片",
|
||||
"pick-a-trail": "选择一个路线",
|
||||
"polish": "波兰语",
|
||||
@@ -156,12 +168,14 @@
|
||||
"stop-drawing": "",
|
||||
"summit-book": "详细日程",
|
||||
"text": "文本",
|
||||
"tilesets": "",
|
||||
"trail": "{n, plural, =1 {路线} other {路线}}",
|
||||
"trail-not-shared": "",
|
||||
"trails-for-you": "推荐路线",
|
||||
"units": "单位",
|
||||
"upload-file": "上传文件",
|
||||
"upload-gpx": "上传 GPX",
|
||||
"uploaded": "",
|
||||
"username": "用户名",
|
||||
"view": "",
|
||||
"waypoints": "坐标",
|
||||
|
||||
@@ -5,6 +5,8 @@ class Settings {
|
||||
language?: "en" | "de" | "fr" | "hu"| "it" | "nl" | "pl" | "pt" | "zh";
|
||||
mapFocus?: "trails" | "location";
|
||||
location?: { name: string, lat: number, lon: number };
|
||||
category?: string;
|
||||
tilesets?: {name: string, url: string}[]
|
||||
user?: string;
|
||||
|
||||
constructor(
|
||||
@@ -13,7 +15,9 @@ class Settings {
|
||||
mapFocus: "trails" | "location",
|
||||
user: string,
|
||||
params?: {
|
||||
location: { name: string, lat: number, lon: number }
|
||||
location?: { name: string, lat: number, lon: number }
|
||||
category?: string
|
||||
tilesets?: {name: string, url: string}[]
|
||||
}
|
||||
) {
|
||||
this.unit = unit;
|
||||
@@ -21,6 +25,8 @@ class Settings {
|
||||
this.mapFocus = mapFocus;
|
||||
this.user = user;
|
||||
this.location = params?.location;
|
||||
this.category = params?.category;
|
||||
this.tilesets = params?.tilesets ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,11 @@ export const trail: Writable<Trail> = writable(new Trail(""));
|
||||
|
||||
export const editTrail: Writable<Trail> = writable(new Trail(""));
|
||||
|
||||
export async function trails_index(data: { perPage: number, random?: boolean, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> } = { perPage: 30, random: false, f: fetch }) {
|
||||
const r = await data.f('/api/v1/trail?' + new URLSearchParams({
|
||||
"per-page": data.perPage.toString(),
|
||||
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({
|
||||
"per-page": perPage.toString(),
|
||||
expand: "category,waypoints,summit_logs",
|
||||
sort: data.random ? "@random" : ""
|
||||
sort: random ? "@random" : ""
|
||||
}), {
|
||||
method: 'GET',
|
||||
})
|
||||
@@ -343,6 +343,22 @@ export async function trails_get_bounding_box(f: (url: RequestInfo | URL, config
|
||||
}
|
||||
}
|
||||
|
||||
export async function trails_upload(file: File, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch): Promise<TrailFilterValues> {
|
||||
const r = await f('/api/v1/trail/upload', {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data"
|
||||
},
|
||||
method: 'PUT',
|
||||
body: file
|
||||
})
|
||||
|
||||
if (r.ok) {
|
||||
return await r.json();
|
||||
} else {
|
||||
throw new ClientResponseError(await r.json())
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGPX(trail: Trail, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
if (!trail.gpx) {
|
||||
return "";
|
||||
|
||||
@@ -107,12 +107,11 @@ export async function users_update(user: User | { [K in keyof User]?: User[K] },
|
||||
throw new ClientResponseError(await r.json())
|
||||
}
|
||||
|
||||
|
||||
if (avatar) {
|
||||
const formData = new FormData();
|
||||
|
||||
if (avatar) {
|
||||
formData.append("avatar", avatar);
|
||||
}
|
||||
|
||||
|
||||
r = await fetch(`/api/v1/user/${user.id!}/file`, {
|
||||
method: 'POST',
|
||||
@@ -122,6 +121,7 @@ export async function users_update(user: User | { [K in keyof User]?: User[K] },
|
||||
if (!r.ok) {
|
||||
throw new ClientResponseError(await r.json())
|
||||
}
|
||||
}
|
||||
|
||||
const model: User = await r.json();
|
||||
currentUser.set(model);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const privateRoutes = [
|
||||
"/profile",
|
||||
"/settings",
|
||||
"/lists",
|
||||
"/trail/edit/new",
|
||||
]
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import GPX from "$lib/models/gpx/gpx";
|
||||
import { Trail } from "$lib/models/trail";
|
||||
import { Waypoint, waypointSchema } from "$lib/models/waypoint";
|
||||
import { Waypoint } from "$lib/models/waypoint";
|
||||
import { currentUser } from "$lib/stores/user_store";
|
||||
import GeoJsonToGpx from "$lib/vendor/geoJSONToGPX";
|
||||
import { kml, tcx } from "$lib/vendor/toGeoJSON/toGeoJSON";
|
||||
import cryptoRandomString from "crypto-random-string";
|
||||
import { get } from "svelte/store";
|
||||
//@ts-ignore
|
||||
import EasyFit from "easy-fit"
|
||||
import EasyFit from "$lib/vendor/easy-fit/easy-fit"
|
||||
import Track from "$lib/models/gpx/track";
|
||||
import TrackSegment from "$lib/models/gpx/track-segment";
|
||||
import GPXWaypoint from "$lib/models/gpx/waypoint";
|
||||
import { browser } from "$app/environment";
|
||||
import * as xmldom from 'xmldom'
|
||||
import type { Feature, FeatureCollection, GeoJsonProperties, Position } from 'geojson';
|
||||
|
||||
|
||||
export async function gpx2trail(gpxString: string) {
|
||||
const gpx = await GPX.parse(gpxString);
|
||||
@@ -60,7 +63,7 @@ export async function gpx2trail(gpxString: string) {
|
||||
}
|
||||
|
||||
export async function trail2gpx(trail: Trail) {
|
||||
if (!trail.expand.gpx_data) {
|
||||
if (!trail.expand?.gpx_data) {
|
||||
throw Error("Trail has no GPX data")
|
||||
}
|
||||
const gpx = await GPX.parse(trail.expand.gpx_data) as GPX;
|
||||
@@ -81,7 +84,7 @@ export async function trail2gpx(trail: Trail) {
|
||||
gpx.wpt = [];
|
||||
}
|
||||
|
||||
for (const wp of trail.expand.waypoints) {
|
||||
for (const wp of trail.expand.waypoints ?? []) {
|
||||
const gpxWpt = gpx.wpt.find((w) => w.$.lat == wp.lat && w.$.lon == wp.lon)
|
||||
if (!gpxWpt) {
|
||||
gpx.wpt.push({
|
||||
@@ -97,50 +100,33 @@ export async function trail2gpx(trail: Trail) {
|
||||
}
|
||||
|
||||
export function fromKML(kmlData: string) {
|
||||
const geojson = kml(new DOMParser().parseFromString(kmlData, "text/xml"))
|
||||
const options = {
|
||||
metadata: {
|
||||
name: '',
|
||||
author: {
|
||||
name: '',
|
||||
link: {
|
||||
href: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const gpx = GeoJsonToGpx(geojson as any, options)
|
||||
const parser = browser ? new DOMParser() : new xmldom.DOMParser();
|
||||
const nodes = parser.parseFromString(kmlData, "text/xml")
|
||||
const geojson = kml(nodes) as Feature | FeatureCollection
|
||||
|
||||
return new XMLSerializer().serializeToString(gpx)
|
||||
const gpx = fromGeoJSON(geojson);
|
||||
|
||||
return gpx
|
||||
}
|
||||
|
||||
export function fromTCX(tcxData: string) {
|
||||
const geojson = tcx(new DOMParser().parseFromString(tcxData, "text/xml"))
|
||||
const options = {
|
||||
metadata: {
|
||||
name: '',
|
||||
author: {
|
||||
name: '',
|
||||
link: {
|
||||
href: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const gpx = GeoJsonToGpx(geojson as any, options)
|
||||
return new XMLSerializer().serializeToString(gpx)
|
||||
const parser = browser ? new DOMParser() : new xmldom.DOMParser();
|
||||
const nodes = parser.parseFromString(tcxData, "text/xml")
|
||||
const geojson = tcx(nodes) as Feature | FeatureCollection
|
||||
|
||||
const gpx = fromGeoJSON(geojson);
|
||||
|
||||
return gpx
|
||||
}
|
||||
|
||||
export async function fromFIT(fitData: ArrayBuffer) {
|
||||
const easyFit = new EasyFit({
|
||||
});
|
||||
return new Promise<string>((resolve, reject) => easyFit.parse(fitData, function (error: string, data: any) {
|
||||
const easyFit = new EasyFit();
|
||||
return new Promise<string>((resolve, reject) => easyFit.parse(fitData, function (error, data) {
|
||||
|
||||
if (error) {
|
||||
console.log(error);
|
||||
reject(error)
|
||||
}
|
||||
console.log(data);
|
||||
|
||||
const gpx = new GPX({
|
||||
metadata: {
|
||||
@@ -152,7 +138,7 @@ export async function fromFIT(fitData: ArrayBuffer) {
|
||||
trk: [
|
||||
new Track({
|
||||
trkseg: new TrackSegment({
|
||||
trkpt: data.records.flatMap((d => {
|
||||
trkpt: data.records.flatMap(((d: { position_lat: any; position_long: any; timestamp: any; altitude: any; }) => {
|
||||
return (d.position_lat && d.position_long) ? new GPXWaypoint({
|
||||
$: { lat: d.position_lat, lon: d.position_long },
|
||||
time: d.timestamp,
|
||||
@@ -164,8 +150,133 @@ export async function fromFIT(fitData: ArrayBuffer) {
|
||||
})
|
||||
]
|
||||
});
|
||||
console.log(gpx);
|
||||
|
||||
resolve(gpx.toString());
|
||||
}));
|
||||
}
|
||||
|
||||
export function fromGeoJSON(geoJson: Feature | FeatureCollection) {
|
||||
const gpx = new GPX({
|
||||
metadata: {
|
||||
name: "",
|
||||
desc: "",
|
||||
time: new Date(),
|
||||
keywords: "wanderer"
|
||||
},
|
||||
trk: [],
|
||||
wpt: []
|
||||
});
|
||||
|
||||
const type = geoJson.type;
|
||||
|
||||
function addSupportedPropertiesFromObject(el: any, supports: string[], properties: GeoJsonProperties) {
|
||||
if (properties && typeof properties === 'object') {
|
||||
supports.forEach(function (key: any) {
|
||||
var value = properties[key];
|
||||
if (value && typeof value === 'string' && supports.includes(key)) {
|
||||
el[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
function createTrk(properties: GeoJsonProperties) {
|
||||
var trk = new Track({ trkseg: [] });
|
||||
var supports = ['name', 'desc', 'src', 'type'];
|
||||
addSupportedPropertiesFromObject(trk, supports, properties);
|
||||
return trk;
|
||||
}
|
||||
function createPt(position: Position, properties?: GeoJsonProperties) {
|
||||
const pt = new GPXWaypoint({ $: { lat: position[1], lon: position[0] }, ele: position[2], time: position[3] as any });
|
||||
const supports = ['name', 'desc', 'src', 'type'];
|
||||
if (properties) {
|
||||
addSupportedPropertiesFromObject(pt, supports, properties);
|
||||
}
|
||||
return pt;
|
||||
}
|
||||
function createTrkSeg(coordinates: Position[]) {
|
||||
var trkSeg = new TrackSegment({ trkpt: [] });
|
||||
coordinates.forEach(function (point) {
|
||||
trkSeg.trkpt!.push(createPt(point));
|
||||
});
|
||||
return trkSeg;
|
||||
}
|
||||
function interpretFeature(feature: Feature) {
|
||||
const geometry = feature.geometry
|
||||
if (!geometry) {
|
||||
return
|
||||
}
|
||||
const properties = feature.properties;
|
||||
const type = geometry.type;
|
||||
switch (type) {
|
||||
case 'Polygon':
|
||||
break;
|
||||
case 'Point': {
|
||||
gpx.wpt!.push(createPt(geometry.coordinates, properties));
|
||||
break;
|
||||
}
|
||||
case 'MultiPoint': {
|
||||
geometry.coordinates.forEach(function (coord) {
|
||||
gpx.wpt!.push(createPt(coord, properties));
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'LineString': {
|
||||
var lineTrk = createTrk(properties);
|
||||
var trkseg = createTrkSeg(geometry.coordinates);
|
||||
lineTrk.trkseg!.push(trkseg);
|
||||
gpx.trk!.push(lineTrk);
|
||||
break;
|
||||
}
|
||||
case 'MultiLineString': {
|
||||
var trk_1 = createTrk(properties);
|
||||
geometry.coordinates.forEach(function (pos) {
|
||||
var trkseg = createTrkSeg(pos);
|
||||
trk_1.trkseg!.push(trkseg);
|
||||
});
|
||||
gpx.trk!.push(trk_1);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
switch (type) {
|
||||
case 'Feature': {
|
||||
interpretFeature(geoJson);
|
||||
break;
|
||||
}
|
||||
case 'FeatureCollection': {
|
||||
var features = geoJson.features;
|
||||
features.forEach(function (feature) {
|
||||
interpretFeature(feature);
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return gpx.toString()
|
||||
}
|
||||
|
||||
export function isFITFile(buffer: ArrayBuffer) {
|
||||
var blob = new Uint8Array(buffer);
|
||||
|
||||
if (blob.length < 12) {
|
||||
return false
|
||||
}
|
||||
|
||||
var headerLength = blob[0];
|
||||
if (headerLength !== 14 && headerLength !== 12) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var fileTypeString = '';
|
||||
for (var i = 8; i < 12; i++) {
|
||||
fileTypeString += String.fromCharCode(blob[i]);
|
||||
}
|
||||
if (fileTypeString !== '.FIT') {
|
||||
return false
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
266
web/src/lib/vendor/easy-fit/binary.ts
vendored
Normal file
266
web/src/lib/vendor/easy-fit/binary.ts
vendored
Normal file
@@ -0,0 +1,266 @@
|
||||
import { FIT } from "./fit";
|
||||
import { getFitMessage, getFitMessageBaseType } from "./messages";
|
||||
|
||||
export { addEndian, readRecord, getArrayBuffer, calculateCRC };
|
||||
|
||||
|
||||
function addEndian(littleEndian: boolean, bytes: any[]) {
|
||||
var result = 0;
|
||||
if (!littleEndian) bytes.reverse();
|
||||
for (var i = 0; i < bytes.length; i++) {
|
||||
result += bytes[i] << (i << 3) >>> 0;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function readData(blob: Uint8Array, fDef: { endianAbility: boolean; size: number; littleEndian: boolean; type: string; }, startIndex: number) {
|
||||
if (fDef.endianAbility === true) {
|
||||
var temp = [];
|
||||
for (var i = 0; i < fDef.size; i++) {
|
||||
temp.push(blob[startIndex + i]);
|
||||
}
|
||||
var uint32Rep = addEndian(fDef.littleEndian, temp);
|
||||
|
||||
if (fDef.type === 'sint32') {
|
||||
return uint32Rep >> 0;
|
||||
}
|
||||
|
||||
return uint32Rep;
|
||||
}
|
||||
return blob[startIndex];
|
||||
}
|
||||
|
||||
function formatByType(data: number, type: string | number, scale: number, offset: number) {
|
||||
switch (type) {
|
||||
case 'date_time':
|
||||
return new Date(data * 1000 + 631065600000);
|
||||
case 'sint32':
|
||||
case 'sint16':
|
||||
return data * FIT.scConst;
|
||||
case 'uint32':
|
||||
case 'uint16':
|
||||
return scale ? data / scale + offset : data;
|
||||
default:
|
||||
if (FIT.types[type]) {
|
||||
return FIT.types[type][data];
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
function isInvalidValue(data: number, type: string) {
|
||||
switch (type) {
|
||||
case 'enum':
|
||||
return data === 0xFF;
|
||||
case 'sint8':
|
||||
return data === 0x7F;
|
||||
case 'uint8':
|
||||
return data === 0xFF;
|
||||
case 'sint16':
|
||||
return data === 0x7FFF;
|
||||
case 'unit16':
|
||||
return data === 0xFFFF;
|
||||
case 'sint32':
|
||||
return data === 0x7FFFFFFF;
|
||||
case 'uint32':
|
||||
return data === 0xFFFFFFFF;
|
||||
case 'string':
|
||||
return data === 0x00;
|
||||
case 'float32':
|
||||
return data === 0xFFFFFFFF;
|
||||
case 'float64':
|
||||
return data === 0xFFFFFFFFFFFFFFFF;
|
||||
case 'uint8z':
|
||||
return data === 0x00;
|
||||
case 'uint16z':
|
||||
return data === 0x0000;
|
||||
case 'uint32z':
|
||||
return data === 0x000000;
|
||||
case 'byte':
|
||||
return data === 0xFF;
|
||||
case 'sint64':
|
||||
return data === 0x7FFFFFFFFFFFFFFF;
|
||||
case 'uint64':
|
||||
return data === 0xFFFFFFFFFFFFFFFF;
|
||||
case 'uint64z':
|
||||
return data === 0x0000000000000000;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function convertTo(data: number, unitsList: string, speedUnit: string | number) {
|
||||
var unitObj = FIT.options[unitsList][speedUnit];
|
||||
return unitObj ? data * unitObj.multiplier + unitObj.offset : data;
|
||||
}
|
||||
|
||||
function applyOptions(data: number, field: any, options: { speedUnit?: string | number; lengthUnit?: string | number; temperatureUnit?: string | number; }) {
|
||||
switch (field) {
|
||||
case 'speed':
|
||||
case 'enhanced_speed':
|
||||
case 'vertical_speed':
|
||||
case 'avg_speed':
|
||||
case 'max_speed':
|
||||
case 'speed_1s':
|
||||
case 'ball_speed':
|
||||
case 'enhanced_avg_speed':
|
||||
case 'enhanced_max_speed':
|
||||
case 'avg_pos_vertical_speed':
|
||||
case 'max_pos_vertical_speed':
|
||||
case 'avg_neg_vertical_speed':
|
||||
case 'max_neg_vertical_speed':
|
||||
return convertTo(data, 'speedUnits', options.speedUnit ?? 0);
|
||||
case 'distance':
|
||||
case 'total_distance':
|
||||
case 'enhanced_avg_altitude':
|
||||
case 'enhanced_min_altitude':
|
||||
case 'enhanced_max_altitude':
|
||||
case 'enhanced_altitude':
|
||||
case 'height':
|
||||
case 'odometer':
|
||||
case 'avg_stroke_distance':
|
||||
case 'min_altitude':
|
||||
case 'avg_altitude':
|
||||
case 'max_altitude':
|
||||
case 'total_ascent':
|
||||
case 'total_descent':
|
||||
case 'altitude':
|
||||
case 'cycle_length':
|
||||
case 'auto_wheelsize':
|
||||
case 'custom_wheelsize':
|
||||
case 'gps_accuracy':
|
||||
return convertTo(data, 'lengthUnits', options.lengthUnit ?? 0);
|
||||
case 'temperature':
|
||||
case 'avg_temperature':
|
||||
case 'max_temperature':
|
||||
return convertTo(data, 'temperatureUnits', options.temperatureUnit ?? 0);
|
||||
default:
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
function readRecord(blob: Uint8Array, messageTypes: Record<number, any>, startIndex: number, options: { elapsedRecordField?: any; speedUnit?: string | number; lengthUnit?: string | number; temperatureUnit?: string | number; }, startDate: number | undefined) {
|
||||
var recordHeader = blob[startIndex];
|
||||
var localMessageType = recordHeader & 15;
|
||||
|
||||
if ((recordHeader & 64) === 64) {
|
||||
// is definition message
|
||||
// startIndex + 1 is reserved
|
||||
|
||||
var lEnd = blob[startIndex + 2] === 0;
|
||||
var mTypeDef: { littleEndian: boolean, globalMessageNumber: number, numberOfFields: number, fieldDefs: any[] } = {
|
||||
littleEndian: lEnd,
|
||||
globalMessageNumber: addEndian(lEnd, [blob[startIndex + 3], blob[startIndex + 4]]),
|
||||
numberOfFields: blob[startIndex + 5],
|
||||
fieldDefs: []
|
||||
};
|
||||
|
||||
var _message = getFitMessage(mTypeDef.globalMessageNumber);
|
||||
|
||||
for (var i = 0; i < mTypeDef.numberOfFields; i++) {
|
||||
var fDefIndex = startIndex + 6 + i * 3;
|
||||
var baseType = blob[fDefIndex + 2];
|
||||
|
||||
var _message$getAttribute = _message.getAttributes(blob[fDefIndex]),
|
||||
field = _message$getAttribute.field,
|
||||
type = _message$getAttribute.type;
|
||||
|
||||
var fDef = {
|
||||
type: type,
|
||||
fDefNo: blob[fDefIndex],
|
||||
size: blob[fDefIndex + 1],
|
||||
endianAbility: (baseType & 128) === 128,
|
||||
littleEndian: lEnd,
|
||||
baseTypeNo: baseType & 15,
|
||||
name: field,
|
||||
dataType: getFitMessageBaseType(baseType & 15)
|
||||
};
|
||||
|
||||
mTypeDef.fieldDefs.push(fDef);
|
||||
}
|
||||
messageTypes[localMessageType] = mTypeDef;
|
||||
|
||||
return {
|
||||
messageType: 'fieldDescription',
|
||||
nextIndex: startIndex + 6 + mTypeDef.numberOfFields * 3
|
||||
};
|
||||
}
|
||||
|
||||
var messageType: { littleEndian: boolean, globalMessageNumber: number, numberOfFields: number, fieldDefs: any[] } | undefined = void 0;
|
||||
|
||||
if (messageTypes[localMessageType]) {
|
||||
messageType = messageTypes[localMessageType];
|
||||
} else {
|
||||
messageType = messageTypes[0];
|
||||
}
|
||||
|
||||
// TODO: handle compressed header ((recordHeader & 128) == 128)
|
||||
|
||||
// uncompressed header
|
||||
var messageSize = 0;
|
||||
var readDataFromIndex = startIndex + 1;
|
||||
var fields: any = {};
|
||||
var message = getFitMessage(messageType?.globalMessageNumber);
|
||||
|
||||
for (var _i = 0; _i < (messageType?.fieldDefs.length ?? 0); _i++) {
|
||||
var _fDef = messageType?.fieldDefs[_i];
|
||||
var data = readData(blob, _fDef, readDataFromIndex);
|
||||
|
||||
if (!isInvalidValue(data, _fDef.type)) {
|
||||
var _message$getAttribute2 = message.getAttributes(_fDef.fDefNo),
|
||||
field = _message$getAttribute2.field,
|
||||
type = _message$getAttribute2.type,
|
||||
scale = _message$getAttribute2.scale,
|
||||
offset = _message$getAttribute2.offset;
|
||||
|
||||
if (field !== 'unknown' && field !== '' && field !== undefined) {
|
||||
fields[field] = applyOptions(formatByType(data, type, scale, offset), field, options);
|
||||
}
|
||||
|
||||
if (message.name === 'record' && options.elapsedRecordField) {
|
||||
fields.elapsed_time = (fields.timestamp - (startDate ?? 0)) / 1000;
|
||||
}
|
||||
}
|
||||
|
||||
readDataFromIndex += _fDef.size;
|
||||
messageSize += _fDef.size;
|
||||
}
|
||||
|
||||
var result = {
|
||||
messageType: message.name,
|
||||
nextIndex: startIndex + messageSize + 1,
|
||||
message: fields
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getArrayBuffer(buffer: ArrayBuffer | number[]) {
|
||||
if (buffer instanceof ArrayBuffer) {
|
||||
return buffer;
|
||||
}
|
||||
var ab = new ArrayBuffer(buffer.length);
|
||||
var view = new Uint8Array(ab);
|
||||
for (var i = 0; i < buffer.length; ++i) {
|
||||
view[i] = buffer[i];
|
||||
}
|
||||
return ab;
|
||||
}
|
||||
|
||||
function calculateCRC(blob: Uint8Array, start: number, end: number) {
|
||||
var crcTable = [0x0000, 0xCC01, 0xD801, 0x1400, 0xF001, 0x3C00, 0x2800, 0xE401, 0xA001, 0x6C00, 0x7800, 0xB401, 0x5000, 0x9C01, 0x8801, 0x4400];
|
||||
|
||||
var crc = 0;
|
||||
for (var i = start; i < end; i++) {
|
||||
var byte = blob[i];
|
||||
var tmp = crcTable[crc & 0xF];
|
||||
crc = crc >> 4 & 0x0FFF;
|
||||
crc = crc ^ tmp ^ crcTable[byte & 0xF];
|
||||
tmp = crcTable[crc & 0xF];
|
||||
crc = crc >> 4 & 0x0FFF;
|
||||
crc = crc ^ tmp ^ crcTable[byte >> 4 & 0xF];
|
||||
}
|
||||
|
||||
return crc;
|
||||
}
|
||||
156
web/src/lib/vendor/easy-fit/easy-fit.ts
vendored
Normal file
156
web/src/lib/vendor/easy-fit/easy-fit.ts
vendored
Normal file
@@ -0,0 +1,156 @@
|
||||
import { calculateCRC, getArrayBuffer, readRecord } from "./binary";
|
||||
|
||||
type Options = {
|
||||
force: boolean,
|
||||
speedUnit: 'm/s' | 'km/h' | 'mph',
|
||||
lengthUnit: 'm' | 'km' | 'mi',
|
||||
temperatureUnit: 'celsius' | 'fahrenheit',
|
||||
elapsedRecordField: boolean,
|
||||
mode: 'cascade' | 'list' | 'both'
|
||||
}
|
||||
|
||||
class EasyFit {
|
||||
options: Options;
|
||||
constructor(
|
||||
options?: Options
|
||||
) {
|
||||
this.options = options || {
|
||||
force: true,
|
||||
speedUnit: 'm/s',
|
||||
lengthUnit: 'm',
|
||||
temperatureUnit: 'celsius',
|
||||
elapsedRecordField: false,
|
||||
mode: 'list'
|
||||
};
|
||||
}
|
||||
|
||||
parse(content: ArrayBuffer, callback: { (error: string, data: any): void; (arg0: string | null, arg1: {}): void; }) {
|
||||
var blob = new Uint8Array(getArrayBuffer(content));
|
||||
|
||||
if (blob.length < 12) {
|
||||
callback('File to small to be a FIT file', {});
|
||||
if (!this.options.force) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var headerLength = blob[0];
|
||||
if (headerLength !== 14 && headerLength !== 12) {
|
||||
callback('Incorrect header size', {});
|
||||
if (!this.options.force) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var fileTypeString = '';
|
||||
for (var i = 8; i < 12; i++) {
|
||||
fileTypeString += String.fromCharCode(blob[i]);
|
||||
}
|
||||
if (fileTypeString !== '.FIT') {
|
||||
callback('Missing \'.FIT\' in header', {});
|
||||
if (!this.options.force) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (headerLength === 14) {
|
||||
var crcHeader = blob[12] + (blob[13] << 8);
|
||||
var crcHeaderCalc = calculateCRC(blob, 0, 12);
|
||||
if (crcHeader !== crcHeaderCalc) {
|
||||
// callback('Header CRC mismatch', {});
|
||||
// TODO: fix Header CRC check
|
||||
if (!this.options.force) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
var dataLength = blob[4] + (blob[5] << 8) + (blob[6] << 16) + (blob[7] << 24);
|
||||
var crcStart = dataLength + headerLength;
|
||||
var crcFile = blob[crcStart] + (blob[crcStart + 1] << 8);
|
||||
var crcFileCalc = calculateCRC(blob, headerLength === 12 ? 0 : headerLength, crcStart);
|
||||
|
||||
if (crcFile !== crcFileCalc) {
|
||||
// callback('File CRC mismatch', {});
|
||||
// TODO: fix File CRC check
|
||||
if (!this.options.force) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var fitObj = {};
|
||||
var sessions = [];
|
||||
var laps = [];
|
||||
var records = [];
|
||||
var events = [];
|
||||
|
||||
var tempLaps = [];
|
||||
var tempRecords = [];
|
||||
|
||||
var loopIndex = headerLength;
|
||||
var messageTypes: Record<number, any> = [];
|
||||
|
||||
var isModeCascade = this.options.mode === 'cascade';
|
||||
var isCascadeNeeded = isModeCascade || this.options.mode === 'both';
|
||||
|
||||
var startDate = void 0;
|
||||
|
||||
while (loopIndex < crcStart) {
|
||||
var _readRecord = readRecord(blob, messageTypes, loopIndex, this.options, startDate),
|
||||
nextIndex = _readRecord.nextIndex,
|
||||
messageType = _readRecord.messageType,
|
||||
message = _readRecord.message;
|
||||
|
||||
loopIndex = nextIndex;
|
||||
switch (messageType) {
|
||||
case 'lap':
|
||||
if (isCascadeNeeded) {
|
||||
message.records = tempRecords;
|
||||
tempRecords = [];
|
||||
tempLaps.push(message);
|
||||
}
|
||||
laps.push(message);
|
||||
break;
|
||||
case 'session':
|
||||
if (isCascadeNeeded) {
|
||||
message.laps = tempLaps;
|
||||
tempLaps = [];
|
||||
}
|
||||
sessions.push(message);
|
||||
break;
|
||||
case 'event':
|
||||
events.push(message);
|
||||
break;
|
||||
case 'record':
|
||||
if (!startDate) {
|
||||
startDate = message.timestamp;
|
||||
message.elapsed_time = 0;
|
||||
}
|
||||
records.push(message);
|
||||
if (isCascadeNeeded) {
|
||||
tempRecords.push(message);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (messageType !== '') {
|
||||
fitObj[messageType] = message;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isCascadeNeeded) {
|
||||
fitObj.activity.sessions = sessions;
|
||||
fitObj.activity.events = events;
|
||||
}
|
||||
if (!isModeCascade) {
|
||||
fitObj.sessions = sessions;
|
||||
fitObj.laps = laps;
|
||||
fitObj.records = records;
|
||||
fitObj.events = events;
|
||||
}
|
||||
|
||||
callback(null, fitObj);
|
||||
}
|
||||
}
|
||||
|
||||
export default EasyFit
|
||||
2437
web/src/lib/vendor/easy-fit/fit.ts
vendored
Normal file
2437
web/src/lib/vendor/easy-fit/fit.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
16
web/src/lib/vendor/easy-fit/messages.ts
vendored
Normal file
16
web/src/lib/vendor/easy-fit/messages.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
export { getFitMessage, getFitMessageBaseType }
|
||||
|
||||
import { getFieldObject, getMessageName } from "./fit"
|
||||
|
||||
function getFitMessage(messageNum: any) {
|
||||
return {
|
||||
name: getMessageName(messageNum),
|
||||
getAttributes: function getAttributes(fieldNum: any) {
|
||||
return getFieldObject(fieldNum, messageNum);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getFitMessageBaseType(foo: any) {
|
||||
return foo;
|
||||
}
|
||||
39
web/src/lib/vendor/geoJSONToGPX/index.d.ts
vendored
39
web/src/lib/vendor/geoJSONToGPX/index.d.ts
vendored
@@ -1,39 +0,0 @@
|
||||
import { Feature, FeatureCollection } from 'geojson';
|
||||
declare interface Bounds {
|
||||
minlat: string;
|
||||
minlon: string;
|
||||
maxlat: string;
|
||||
maxlon: string;
|
||||
}
|
||||
declare interface Copyright {
|
||||
author?: string;
|
||||
year?: string;
|
||||
license?: string;
|
||||
}
|
||||
declare interface Link {
|
||||
href: string;
|
||||
text?: string;
|
||||
type?: string;
|
||||
}
|
||||
declare interface Person {
|
||||
name?: string;
|
||||
email?: string;
|
||||
link?: Link;
|
||||
}
|
||||
declare interface MetaData {
|
||||
name?: string;
|
||||
desc?: string;
|
||||
author?: Person;
|
||||
copyright?: Copyright;
|
||||
link?: Link;
|
||||
time?: string;
|
||||
keywords?: string;
|
||||
bounds?: Bounds;
|
||||
}
|
||||
export declare interface Options {
|
||||
creator?: string;
|
||||
version?: string;
|
||||
metadata?: MetaData;
|
||||
}
|
||||
export default function GeoJsonToGpx(geoJson: Feature | FeatureCollection, options?: Options, implementation?: DOMImplementation): XMLDocument;
|
||||
export {};
|
||||
155
web/src/lib/vendor/geoJSONToGPX/index.js
vendored
155
web/src/lib/vendor/geoJSONToGPX/index.js
vendored
@@ -1,155 +0,0 @@
|
||||
export default function GeoJsonToGpx(geoJson, options, implementation) {
|
||||
if (implementation === void 0) { implementation = document.implementation; }
|
||||
var doc = implementation.createDocument('http://www.topografix.com/GPX/1/1', '');
|
||||
var instruct = doc.createProcessingInstruction('xml', 'version="1.0" encoding="UTF-8"');
|
||||
doc.appendChild(instruct);
|
||||
var defaultPackageName = '@dwayneparton/geojson-to-gpx';
|
||||
var creator = (options === null || options === void 0 ? void 0 : options.creator) || defaultPackageName;
|
||||
var createElementWithNS = function (tagName) {
|
||||
return doc.createElementNS('http://www.topografix.com/GPX/1/1', tagName);
|
||||
};
|
||||
var gpx = createElementWithNS('gpx');
|
||||
gpx.setAttribute('version', '1.1');
|
||||
gpx.setAttribute('creator', creator);
|
||||
var wpts = [];
|
||||
var trks = [];
|
||||
function createTagInParentElement(parent, tagName, content) {
|
||||
if (content === undefined) {
|
||||
return;
|
||||
}
|
||||
var element = createElementWithNS(tagName);
|
||||
var contentEl = doc.createTextNode(String(content));
|
||||
element.appendChild(contentEl);
|
||||
parent.appendChild(element);
|
||||
}
|
||||
function addSupportedPropertiesFromObject(el, supports, properties) {
|
||||
if (properties && typeof properties === 'object') {
|
||||
supports.forEach(function (key) {
|
||||
var value = properties[key];
|
||||
if (value && typeof value === 'string' && supports.includes(key)) {
|
||||
createTagInParentElement(el, key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
function createLinkInParentElement(parent, props) {
|
||||
var href = props.href;
|
||||
if (!href) {
|
||||
return;
|
||||
}
|
||||
var el = createElementWithNS('link');
|
||||
el.setAttribute('href', href);
|
||||
addSupportedPropertiesFromObject(el, ['text', 'type'], props);
|
||||
parent.appendChild(el);
|
||||
}
|
||||
function createTrk(properties) {
|
||||
var el = createElementWithNS('trk');
|
||||
var supports = ['name', 'desc', 'src', 'type'];
|
||||
addSupportedPropertiesFromObject(el, supports, properties);
|
||||
return el;
|
||||
}
|
||||
function createPt(type, position, properties) {
|
||||
var lon = position[0], lat = position[1], ele = position[2], time = position[3];
|
||||
var el = createElementWithNS(type);
|
||||
el.setAttribute('lat', String(lat));
|
||||
el.setAttribute('lon', String(lon));
|
||||
createTagInParentElement(el, 'ele', ele);
|
||||
createTagInParentElement(el, 'time', time);
|
||||
var supports = ['name', 'desc', 'src', 'type'];
|
||||
addSupportedPropertiesFromObject(el, supports, properties);
|
||||
return el;
|
||||
}
|
||||
function createTrkSeg(coordinates) {
|
||||
var el = createElementWithNS('trkseg');
|
||||
coordinates.forEach(function (point) {
|
||||
el.appendChild(createPt('trkpt', point));
|
||||
});
|
||||
return el;
|
||||
}
|
||||
function interpretFeature(feature) {
|
||||
var geometry = feature.geometry, properties = feature.properties;
|
||||
var type = geometry.type;
|
||||
switch (type) {
|
||||
case 'Polygon':
|
||||
break;
|
||||
case 'Point': {
|
||||
wpts.push(createPt('wpt', geometry.coordinates, properties));
|
||||
break;
|
||||
}
|
||||
case 'MultiPoint': {
|
||||
geometry.coordinates.forEach(function (coord) {
|
||||
wpts.push(createPt('wpt', coord, properties));
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'LineString': {
|
||||
var lineTrk = createTrk(properties);
|
||||
var trkseg = createTrkSeg(geometry.coordinates);
|
||||
lineTrk.appendChild(trkseg);
|
||||
trks.push(lineTrk);
|
||||
break;
|
||||
}
|
||||
case 'MultiLineString': {
|
||||
var trk_1 = createTrk(properties);
|
||||
geometry.coordinates.forEach(function (pos) {
|
||||
var trkseg = createTrkSeg(pos);
|
||||
trk_1.appendChild(trkseg);
|
||||
});
|
||||
trks.push(trk_1);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (options && typeof options.metadata === 'object') {
|
||||
var meta = options.metadata;
|
||||
var metadata = createElementWithNS('metadata');
|
||||
createTagInParentElement(metadata, 'name', meta.name);
|
||||
createTagInParentElement(metadata, 'desc', meta.desc);
|
||||
if (meta.author && typeof meta.author === 'object') {
|
||||
var author = createElementWithNS('author');
|
||||
createTagInParentElement(author, 'name', meta.author.name);
|
||||
createTagInParentElement(author, 'email', meta.author.email);
|
||||
if (meta.author.link && typeof meta.author.link === 'object') {
|
||||
createLinkInParentElement(author, meta.author.link);
|
||||
}
|
||||
metadata.appendChild(author);
|
||||
}
|
||||
if (typeof meta.copyright === 'object') {
|
||||
var copyright = createElementWithNS('copyright');
|
||||
if (meta.copyright.author) {
|
||||
copyright.setAttribute('author', meta.copyright.author);
|
||||
}
|
||||
createTagInParentElement(copyright, 'year', meta.copyright.year);
|
||||
createTagInParentElement(copyright, 'license', meta.copyright.license);
|
||||
metadata.appendChild(copyright);
|
||||
}
|
||||
if (typeof meta.link === 'object') {
|
||||
createLinkInParentElement(metadata, meta.link);
|
||||
}
|
||||
createTagInParentElement(metadata, 'time', meta.time);
|
||||
createTagInParentElement(metadata, 'keywords', meta.keywords);
|
||||
gpx.appendChild(metadata);
|
||||
}
|
||||
var type = geoJson.type;
|
||||
switch (type) {
|
||||
case 'Feature': {
|
||||
interpretFeature(geoJson);
|
||||
break;
|
||||
}
|
||||
case 'FeatureCollection': {
|
||||
var features = geoJson.features;
|
||||
features.forEach(function (feature) {
|
||||
interpretFeature(feature);
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
wpts.forEach(function (wpt) { return gpx.appendChild(wpt); });
|
||||
trks.forEach(function (trk) { return gpx.appendChild(trk); });
|
||||
doc.appendChild(gpx);
|
||||
return doc;
|
||||
}
|
||||
@@ -134,7 +134,9 @@ export const createForm = (config) => {
|
||||
return clearErrorsAndSubmit(values);
|
||||
} else {
|
||||
errors.set(error);
|
||||
if(onError) {
|
||||
onError(error)
|
||||
}
|
||||
isSubmitting.set(false);
|
||||
}
|
||||
})
|
||||
@@ -156,7 +158,9 @@ export const createForm = (config) => {
|
||||
yupErrors.inner.map((error) =>
|
||||
util.set(updatedErrors, error.path, error.message),
|
||||
);
|
||||
if(onError) {
|
||||
onError(updatedErrors)
|
||||
}
|
||||
errors.set(updatedErrors);
|
||||
}
|
||||
isSubmitting.set(false);
|
||||
|
||||
@@ -3,6 +3,6 @@ import { trails_index } from "$lib/stores/trail_store";
|
||||
import type { ServerLoad } from "@sveltejs/kit";
|
||||
|
||||
export const load: ServerLoad = async ({ params, locals, fetch }) => {
|
||||
await trails_index({perPage: 20, random: true, f: fetch})
|
||||
await trails_index(20, true, fetch)
|
||||
await categories_index(fetch)
|
||||
};
|
||||
@@ -10,8 +10,17 @@ export async function GET(event: RequestEvent) {
|
||||
const filter = event.url.searchParams.get("filter") ?? "";
|
||||
|
||||
try {
|
||||
const r = await pb.collection('trails')
|
||||
let r;
|
||||
if (parseInt(perPage) < 0) {
|
||||
r = {
|
||||
items: await pb.collection('trails')
|
||||
.getFullList<Trail>({ expand: expand, sort: sort, filter: filter })
|
||||
}
|
||||
} else {
|
||||
r = await pb.collection('trails')
|
||||
.getList<Trail>(parseInt(page), parseInt(perPage), { expand: expand ?? "", sort: sort ?? "", filter: filter ?? "" })
|
||||
}
|
||||
|
||||
return json(r)
|
||||
} catch (e: any) {
|
||||
throw error(e.status, e);
|
||||
|
||||
@@ -1,25 +1,48 @@
|
||||
import type { Trail } from "$lib/models/trail";
|
||||
import { trails_create } from "$lib/stores/trail_store";
|
||||
import { gpx2trail } from "$lib/util/gpx_util";
|
||||
import { fromFIT, fromKML, fromTCX, gpx2trail, isFITFile } from "$lib/util/gpx_util";
|
||||
import { error, json, type RequestEvent } from "@sveltejs/kit";
|
||||
import { ClientResponseError } from "pocketbase";
|
||||
|
||||
export async function PUT(event: RequestEvent) {
|
||||
try {
|
||||
const data = await event.request.blob();
|
||||
const gpxString = await data.text();
|
||||
if (!gpxString.length) {
|
||||
const fileBuffer = await data.arrayBuffer();
|
||||
const fileContent = await data.text();
|
||||
let gpxData = ""
|
||||
let gpxFile: Blob;
|
||||
if (isFITFile(fileBuffer)) {
|
||||
gpxData = await fromFIT(fileBuffer);
|
||||
gpxFile = new Blob([gpxData], {
|
||||
type: "application/gpx+xml",
|
||||
});
|
||||
}
|
||||
else if (fileContent.includes("http://www.opengis.net/kml")) {
|
||||
gpxData = fromKML(fileContent);
|
||||
gpxFile = new Blob([gpxData], {
|
||||
type: "application/gpx+xml",
|
||||
});
|
||||
} else if (fileContent.includes("TrainingCenterDatabase")) {
|
||||
gpxData = fromTCX(fileContent);
|
||||
gpxFile = new Blob([gpxData], {
|
||||
type: "application/gpx+xml",
|
||||
});
|
||||
} else {
|
||||
gpxData = fileContent;
|
||||
gpxFile = data
|
||||
}
|
||||
if (!gpxData.length) {
|
||||
throw new ClientResponseError({ status: 400, response: { message: "Empty file" } })
|
||||
}
|
||||
let trail: Trail;
|
||||
try {
|
||||
trail = (await gpx2trail(gpxString)).trail;
|
||||
trail = (await gpx2trail(gpxData)).trail;
|
||||
} catch (e: any) {
|
||||
throw new ClientResponseError({ status: 400, response: { message: "Invalid GPX file" } })
|
||||
throw new ClientResponseError({ status: 400, response: { message: "Invalid file" } })
|
||||
}
|
||||
|
||||
try {
|
||||
trail = await trails_create(trail, [], data, event.fetch);
|
||||
trail = await trails_create(trail, [], gpxFile, event.fetch);
|
||||
} catch (e: any) {
|
||||
console.log(e);
|
||||
|
||||
@@ -28,6 +51,8 @@ export async function PUT(event: RequestEvent) {
|
||||
return json(trail);
|
||||
|
||||
} catch (e: any) {
|
||||
console.log(e);
|
||||
|
||||
throw error(e.status, e)
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,17 @@ export async function GET(event: RequestEvent) {
|
||||
export async function POST(event: RequestEvent) {
|
||||
const data = await event.request.json()
|
||||
try {
|
||||
if (data.email != pb.authStore.model!.email) {
|
||||
const r = await pb.collection('users').requestEmailChange(data.email);
|
||||
pb.authStore.model!.email = data.email;
|
||||
}
|
||||
const r = await pb.collection('users').update<User>(event.params.id as string, data)
|
||||
if (data.password) {
|
||||
const r = await pb.collection('users').authWithPassword(data.email ?? data.username!, data.password);
|
||||
return json(r.record)
|
||||
} else {
|
||||
return json(r);
|
||||
}
|
||||
} catch (e: any) {
|
||||
throw error(e.status, e)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</svelte:head>
|
||||
<main class="grid grid-cols-1 md:grid-cols-[458px_1fr] gap-x-1 gap-y-4">
|
||||
<TrailInfoPanel trail={$trail} {markers}></TrailInfoPanel>
|
||||
<div id="trail-details" class=" sticky top-0">
|
||||
<div id="trail-details" class=" sticky top-0 min-h-[600px]">
|
||||
<MapWithElevation trail={$trail} bind:markers></MapWithElevation>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import RadioGroup, {
|
||||
type RadioItem,
|
||||
} from "$lib/components/base/radio_group.svelte";
|
||||
import Search, {
|
||||
type SearchItem,
|
||||
} from "$lib/components/base/search.svelte";
|
||||
import Select, {
|
||||
type SelectItem,
|
||||
} from "$lib/components/base/select.svelte";
|
||||
import ConfirmModal from "$lib/components/confirm_modal.svelte";
|
||||
import { settings_update } from "$lib/stores/settings_store";
|
||||
import {
|
||||
currentUser,
|
||||
logout,
|
||||
users_delete,
|
||||
users_update,
|
||||
} from "$lib/stores/user_store";
|
||||
import { country_codes } from "$lib/util/country_code_util";
|
||||
import { getFileURL } from "$lib/util/file_util";
|
||||
import { onMount } from "svelte";
|
||||
import { _, locale } from "svelte-i18n";
|
||||
|
||||
const settings = $page.data.settings;
|
||||
|
||||
const languages: SelectItem[] = [
|
||||
{ text: $_("chinese"), value: "zh" },
|
||||
{ text: $_("german"), value: "de" },
|
||||
{ text: $_("english"), value: "en" },
|
||||
{ text: $_("french"), value: "fr" },
|
||||
{ text: $_("hungarian"), value: "hu" },
|
||||
{ text: $_("italian"), value: "it" },
|
||||
{ text: $_("dutch"), value: "nl" },
|
||||
{ text: $_("polish"), value: "pl" },
|
||||
{ text: $_("portuguese"), value: "pt" },
|
||||
];
|
||||
|
||||
const mapFocus: SelectItem[] = [
|
||||
{ text: $_("trail", { values: { n: 2 } }), value: "trails" },
|
||||
{ text: $_("location"), value: "location" },
|
||||
];
|
||||
|
||||
const units: RadioItem[] = [
|
||||
{ text: $_("metric"), value: "metric" },
|
||||
{ text: $_("imperial"), value: "imperial" },
|
||||
];
|
||||
|
||||
let selectedLanguage = "en";
|
||||
let selectedMapFocus = "trails";
|
||||
|
||||
let searchDropdownItems: SearchItem[] = [];
|
||||
let citySearchQuery: string = "";
|
||||
|
||||
let openConfirmModal: () => void;
|
||||
|
||||
onMount(() => {
|
||||
citySearchQuery = settings?.location?.name ?? "";
|
||||
selectedLanguage = settings?.language || "en";
|
||||
selectedMapFocus = settings?.mapFocus ?? "trails";
|
||||
});
|
||||
|
||||
async function searchCities(q: string) {
|
||||
const r = await fetch("/api/v1/search/cities500", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ q: q, options: { limit: 5 } }),
|
||||
});
|
||||
const result = await r.json();
|
||||
searchDropdownItems = result.hits.map((h: Record<string, any>) => ({
|
||||
text: h.name,
|
||||
description: `${h.division ? `${h.division} | ` : ""}${
|
||||
country_codes[h["country code"] as keyof typeof country_codes]
|
||||
}`,
|
||||
value: h,
|
||||
icon: "city",
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleSearchClick(item: SearchItem) {
|
||||
citySearchQuery = item.text;
|
||||
await settings_update({
|
||||
id: settings?.id!,
|
||||
location: {
|
||||
name: item.value.name,
|
||||
lat: item.value.lat,
|
||||
lon: item.value.lon,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAvatarSelection() {
|
||||
const files = (
|
||||
document.getElementById("avatarInput") as HTMLInputElement
|
||||
).files;
|
||||
|
||||
if (!files || files.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await users_update($currentUser!, files[0]);
|
||||
}
|
||||
|
||||
async function handleUnitSelection(e: RadioItem) {
|
||||
await settings_update({
|
||||
id: settings!.id,
|
||||
unit: e.value as "imperial" | "metric",
|
||||
});
|
||||
}
|
||||
|
||||
async function handleLanguageSelection(
|
||||
value: "en" | "de" | "fr" | "hu" | "nl" | "pl" | "pt",
|
||||
) {
|
||||
locale.set(value);
|
||||
await settings_update({
|
||||
id: settings!.id,
|
||||
language: value,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleMapFocusSelection(value: "trails" | "location") {
|
||||
await settings_update({
|
||||
id: settings!.id,
|
||||
mapFocus: value,
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteAccount() {
|
||||
await users_delete($currentUser!);
|
||||
logout();
|
||||
goto("/");
|
||||
}
|
||||
|
||||
function openFileBrowser() {
|
||||
document.getElementById("avatarInput")!.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_("profile")} | wanderer</title>
|
||||
</svelte:head>
|
||||
<main
|
||||
class="grid grid-cols-1 md:grid-cols-[356px_1fr] max-w-4xl mx-4 md:mx-auto gap-x-8 items-start"
|
||||
>
|
||||
{#if $currentUser}
|
||||
<div
|
||||
class="flex flex-col items-center rounded-xl border border-input-border p-4"
|
||||
>
|
||||
<div
|
||||
class="rounded-full w-32 aspect-square overflow-hidden relative group"
|
||||
>
|
||||
<img
|
||||
src={getFileURL($currentUser, $currentUser.avatar) ||
|
||||
`https://api.dicebear.com/7.x/initials/svg?seed=${$currentUser.username}&backgroundType=gradientLinear`}
|
||||
alt="avatar"
|
||||
/>
|
||||
<button
|
||||
class="absolute top-0 w-32 aspect-square opacity-0 group-hover:opacity-100 flex justify-center items-center bg-black/50 focus:bg-black/60 text-white cursor-pointer transition-opacity"
|
||||
on:click={openFileBrowser}
|
||||
>
|
||||
<i class="fa fa-pen"></i>
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
name="avatar"
|
||||
id="avatarInput"
|
||||
accept="image/*"
|
||||
style="display: none;"
|
||||
on:change={handleAvatarSelection}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 class="text-2xl mt-4 font-semibold">{$currentUser.username}</h3>
|
||||
<h5 class="font-medium">{$currentUser.email}</h5>
|
||||
</div>
|
||||
<div class="space-y-6 rounded-xl p-4">
|
||||
<h3 class="text-2xl font-semibold">{$_("settings")}</h3>
|
||||
<div>
|
||||
<h5 class="font-medium mb-1">{$_("language")}</h5>
|
||||
<Select
|
||||
items={languages}
|
||||
bind:value={selectedLanguage}
|
||||
on:change={(e) => handleLanguageSelection(e.detail)}
|
||||
></Select>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="font-medium mb-1">{$_("units")}</h5>
|
||||
<RadioGroup
|
||||
name="unit"
|
||||
items={units}
|
||||
selected={settings?.unit == "metric" ? 0 : 1}
|
||||
on:change={(e) => handleUnitSelection(e.detail)}
|
||||
></RadioGroup>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="font-medium mb-1">Focus map on</h5>
|
||||
|
||||
<Select
|
||||
items={mapFocus}
|
||||
bind:value={selectedMapFocus}
|
||||
on:change={(e) => handleMapFocusSelection(e.detail)}
|
||||
></Select>
|
||||
{#if selectedMapFocus == "location"}
|
||||
<div class="mt-3">
|
||||
<Search
|
||||
items={searchDropdownItems}
|
||||
placeholder="{$_('search-cities')}..."
|
||||
bind:value={citySearchQuery}
|
||||
on:update={(e) => searchCities(e.detail)}
|
||||
on:click={(e) => handleSearchClick(e.detail)}
|
||||
></Search>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<hr class="border-input-border" />
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-xl text-red-400">{$_("danger-zone")}</h4>
|
||||
<button class="btn-danger" on:click={openConfirmModal}
|
||||
>{$_("delete-account")}</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
<ConfirmModal
|
||||
text={$_("account-delete-confirm")}
|
||||
bind:openModal={openConfirmModal}
|
||||
on:confirm={deleteAccount}
|
||||
></ConfirmModal>
|
||||
@@ -4,9 +4,10 @@
|
||||
import TextField from "$lib/components/base/text_field.svelte";
|
||||
import LogoTextTwoLineDark from "$lib/components/logo/logo_text_two_line_dark.svelte";
|
||||
import LogoTextTwoLineLight from "$lib/components/logo/logo_text_two_line_light.svelte";
|
||||
import type { User } from "$lib/models/user";
|
||||
import { theme } from "$lib/stores/theme_store";
|
||||
import { show_toast } from "$lib/stores/toast_store";
|
||||
import { login, users_create, type User } from "$lib/stores/user_store";
|
||||
import { login, users_create } from "$lib/stores/user_store";
|
||||
import { createForm } from "$lib/vendor/svelte-form-lib";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { object, string } from "yup";
|
||||
|
||||
285
web/src/routes/settings/account/+page.svelte
Normal file
285
web/src/routes/settings/account/+page.svelte
Normal file
@@ -0,0 +1,285 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import { type RadioItem } from "$lib/components/base/radio_group.svelte";
|
||||
import { type SearchItem } from "$lib/components/base/search.svelte";
|
||||
import Select, {
|
||||
type SelectItem,
|
||||
} from "$lib/components/base/select.svelte";
|
||||
import ConfirmModal from "$lib/components/confirm_modal.svelte";
|
||||
import EmailModal from "$lib/components/profile/email_modal.svelte";
|
||||
import PasswordModal from "$lib/components/profile/password_modal.svelte";
|
||||
import Settings from "$lib/components/profile/settings.svelte";
|
||||
import { settings_update } from "$lib/stores/settings_store";
|
||||
import { show_toast } from "$lib/stores/toast_store";
|
||||
import {
|
||||
currentUser,
|
||||
logout,
|
||||
users_delete,
|
||||
users_update,
|
||||
} from "$lib/stores/user_store";
|
||||
import { country_codes } from "$lib/util/country_code_util";
|
||||
import { getFileURL } from "$lib/util/file_util";
|
||||
import { onMount } from "svelte";
|
||||
import { _, locale } from "svelte-i18n";
|
||||
|
||||
const settings = $page.data.settings;
|
||||
|
||||
const languages: SelectItem[] = [
|
||||
{ text: $_("chinese"), value: "zh" },
|
||||
{ text: $_("german"), value: "de" },
|
||||
{ text: $_("english"), value: "en" },
|
||||
{ text: $_("french"), value: "fr" },
|
||||
{ text: $_("hungarian"), value: "hu" },
|
||||
{ text: $_("italian"), value: "it" },
|
||||
{ text: $_("dutch"), value: "nl" },
|
||||
{ text: $_("polish"), value: "pl" },
|
||||
{ text: $_("portuguese"), value: "pt" },
|
||||
];
|
||||
|
||||
const mapFocus: SelectItem[] = [
|
||||
{ text: $_("trail", { values: { n: 2 } }), value: "trails" },
|
||||
{ text: $_("location"), value: "location" },
|
||||
];
|
||||
|
||||
const units: RadioItem[] = [
|
||||
{ text: $_("metric"), value: "metric" },
|
||||
{ text: $_("imperial"), value: "imperial" },
|
||||
];
|
||||
|
||||
let selectedLanguage = "en";
|
||||
let selectedMapFocus = "trails";
|
||||
|
||||
let searchDropdownItems: SearchItem[] = [];
|
||||
let citySearchQuery: string = "";
|
||||
|
||||
let openConfirmModal: () => void;
|
||||
let openEmailModal: () => void;
|
||||
let openPasswordModal: () => void;
|
||||
|
||||
onMount(() => {
|
||||
citySearchQuery = settings?.location?.name ?? "";
|
||||
selectedLanguage = settings?.language || "en";
|
||||
selectedMapFocus = settings?.mapFocus ?? "trails";
|
||||
});
|
||||
|
||||
async function searchCities(q: string) {
|
||||
const r = await fetch("/api/v1/search/cities500", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ q: q, options: { limit: 5 } }),
|
||||
});
|
||||
const result = await r.json();
|
||||
searchDropdownItems = result.hits.map((h: Record<string, any>) => ({
|
||||
text: h.name,
|
||||
description: `${h.division ? `${h.division} | ` : ""}${
|
||||
country_codes[h["country code"] as keyof typeof country_codes]
|
||||
}`,
|
||||
value: h,
|
||||
icon: "city",
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleSearchClick(item: SearchItem) {
|
||||
citySearchQuery = item.text;
|
||||
await settings_update({
|
||||
id: settings?.id!,
|
||||
location: {
|
||||
name: item.value.name,
|
||||
lat: item.value.lat,
|
||||
lon: item.value.lon,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAvatarSelection() {
|
||||
const files = (
|
||||
document.getElementById("avatarInput") as HTMLInputElement
|
||||
).files;
|
||||
|
||||
if (!files || files.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await users_update($currentUser!, files[0]);
|
||||
}
|
||||
|
||||
async function handleUnitSelection(e: RadioItem) {
|
||||
await settings_update({
|
||||
id: settings!.id,
|
||||
unit: e.value as "imperial" | "metric",
|
||||
});
|
||||
}
|
||||
|
||||
async function handleLanguageSelection(
|
||||
value: "en" | "de" | "fr" | "hu" | "nl" | "pl" | "pt",
|
||||
) {
|
||||
locale.set(value);
|
||||
await settings_update({
|
||||
id: settings!.id,
|
||||
language: value,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleMapFocusSelection(value: "trails" | "location") {
|
||||
await settings_update({
|
||||
id: settings!.id,
|
||||
mapFocus: value,
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteAccount() {
|
||||
await users_delete($currentUser!);
|
||||
logout();
|
||||
goto("/");
|
||||
}
|
||||
|
||||
async function updateEmail(email: string) {
|
||||
try {
|
||||
await users_update({ ...$currentUser!, email: email });
|
||||
} catch (e) {
|
||||
show_toast({
|
||||
text: "Error updating email",
|
||||
icon: "close",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePassword(data: {
|
||||
oldPassword: string;
|
||||
password: string;
|
||||
passwordConfirm: string;
|
||||
}) {
|
||||
try {
|
||||
await users_update({ ...$currentUser!, ...data });
|
||||
show_toast({
|
||||
text: $_("password-updated"),
|
||||
icon: "check",
|
||||
type: "success",
|
||||
});
|
||||
} catch (e) {
|
||||
show_toast({
|
||||
text: $_("error-updating-password"),
|
||||
icon: "close",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function openFileBrowser() {
|
||||
document.getElementById("avatarInput")!.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_("settings")} | wanderer</title>
|
||||
</svelte:head>
|
||||
<main>
|
||||
{#if $currentUser}
|
||||
<Settings selected={0}>
|
||||
<div class="space-y-6">
|
||||
<h3 class="text-2xl font-semibold">{$_("profile")}</h3>
|
||||
|
||||
<div class="flex gap-6 items-center">
|
||||
<div
|
||||
class="rounded-full w-24 aspect-square overflow-hidden relative group"
|
||||
>
|
||||
<img
|
||||
src={getFileURL(
|
||||
$currentUser,
|
||||
$currentUser.avatar,
|
||||
) ||
|
||||
`https://api.dicebear.com/7.x/initials/svg?seed=${$currentUser.username}&backgroundType=gradientLinear`}
|
||||
alt="avatar"
|
||||
/>
|
||||
<button
|
||||
class="absolute top-0 w-24 aspect-square opacity-0 group-hover:opacity-100 flex justify-center items-center bg-black/50 focus:bg-black/60 text-white cursor-pointer transition-opacity"
|
||||
on:click={openFileBrowser}
|
||||
>
|
||||
<i class="fa fa-pen"></i>
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
name="avatar"
|
||||
id="avatarInput"
|
||||
accept="image/*"
|
||||
style="display: none;"
|
||||
on:change={handleAvatarSelection}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-2xl">{$currentUser.username}</h4>
|
||||
<h5 class="font-medium">{$currentUser.email}</h5>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-2xl font-semibold">{$_("login-details")}</h3>
|
||||
<button class="btn-secondary block" on:click={openEmailModal}
|
||||
>{$_("change-email")}</button
|
||||
>
|
||||
<button class="btn-secondary" on:click={openPasswordModal}
|
||||
>{$_("change-password")}</button
|
||||
>
|
||||
|
||||
<h3 class="text-2xl font-semibold">{$_("language")}</h3>
|
||||
<div class="block">
|
||||
<Select
|
||||
items={languages}
|
||||
bind:value={selectedLanguage}
|
||||
on:change={(e) => handleLanguageSelection(e.detail)}
|
||||
></Select>
|
||||
</div>
|
||||
<!--<div>
|
||||
<h5 class="font-medium mb-1">{$_("units")}</h5>
|
||||
<RadioGroup
|
||||
name="unit"
|
||||
items={units}
|
||||
selected={settings?.unit == "metric" ? 0 : 1}
|
||||
on:change={(e) => handleUnitSelection(e.detail)}
|
||||
></RadioGroup>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="font-medium mb-1">Focus map on</h5>
|
||||
|
||||
<Select
|
||||
items={mapFocus}
|
||||
bind:value={selectedMapFocus}
|
||||
on:change={(e) => handleMapFocusSelection(e.detail)}
|
||||
></Select>
|
||||
{#if selectedMapFocus == "location"}
|
||||
<div class="mt-3">
|
||||
<Search
|
||||
items={searchDropdownItems}
|
||||
placeholder="{$_('search-cities')}..."
|
||||
bind:value={citySearchQuery}
|
||||
on:update={(e) => searchCities(e.detail)}
|
||||
on:click={(e) => handleSearchClick(e.detail)}
|
||||
></Search>
|
||||
</div>
|
||||
{/if}
|
||||
</div> -->
|
||||
<hr class="border-input-border" />
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-xl text-red-400">{$_("danger-zone")}</h4>
|
||||
<button class="btn-danger" on:click={openConfirmModal}
|
||||
>{$_("delete-account")}</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<EmailModal
|
||||
email={$currentUser.email}
|
||||
on:save={(e) => updateEmail(e.detail)}
|
||||
bind:openModal={openEmailModal}
|
||||
></EmailModal>
|
||||
<PasswordModal
|
||||
on:save={(e) => updatePassword(e.detail)}
|
||||
bind:openModal={openPasswordModal}
|
||||
></PasswordModal>
|
||||
</Settings>
|
||||
{/if}
|
||||
</main>
|
||||
<ConfirmModal
|
||||
text={$_("account-delete-confirm")}
|
||||
bind:openModal={openConfirmModal}
|
||||
on:confirm={deleteAccount}
|
||||
></ConfirmModal>
|
||||
212
web/src/routes/settings/display/+page.svelte
Normal file
212
web/src/routes/settings/display/+page.svelte
Normal file
@@ -0,0 +1,212 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/stores";
|
||||
import RadioGroup, {
|
||||
type RadioItem,
|
||||
} from "$lib/components/base/radio_group.svelte";
|
||||
import Search, {
|
||||
type SearchItem,
|
||||
} from "$lib/components/base/search.svelte";
|
||||
import Select, {
|
||||
type SelectItem,
|
||||
} from "$lib/components/base/select.svelte";
|
||||
|
||||
import TextField from "$lib/components/base/text_field.svelte";
|
||||
import Settings from "$lib/components/profile/settings.svelte";
|
||||
import type { Category } from "$lib/models/category.js";
|
||||
import { settings_update } from "$lib/stores/settings_store";
|
||||
import { currentUser } from "$lib/stores/user_store";
|
||||
import { country_codes } from "$lib/util/country_code_util";
|
||||
import { onMount } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
export let data;
|
||||
|
||||
$: settings = $page.data.settings;
|
||||
|
||||
const mapFocus: SelectItem[] = [
|
||||
{ text: $_("trail", { values: { n: 2 } }), value: "trails" },
|
||||
{ text: $_("location"), value: "location" },
|
||||
];
|
||||
|
||||
const categoryItems: SelectItem[] = data.categories.map((c: Category) => ({
|
||||
text: $_(c.name),
|
||||
value: c.id,
|
||||
}));
|
||||
|
||||
const units: RadioItem[] = [
|
||||
{ text: $_("metric"), value: "metric" },
|
||||
{ text: $_("imperial"), value: "imperial" },
|
||||
];
|
||||
|
||||
let selectedLanguage = "en";
|
||||
let selectedMapFocus = "trails";
|
||||
|
||||
let selectedCategory =
|
||||
$page.data.settings?.category || data.categories[0].id;
|
||||
|
||||
let searchDropdownItems: SearchItem[] = [];
|
||||
let citySearchQuery: string = "";
|
||||
|
||||
let customTilesetName: string = "";
|
||||
let customTilesetURL: string = "";
|
||||
|
||||
onMount(() => {
|
||||
citySearchQuery = settings?.location?.name ?? "";
|
||||
selectedLanguage = settings?.language || "en";
|
||||
selectedMapFocus = settings?.mapFocus ?? "trails";
|
||||
});
|
||||
|
||||
async function searchCities(q: string) {
|
||||
const r = await fetch("/api/v1/search/cities500", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ q: q, options: { limit: 5 } }),
|
||||
});
|
||||
const result = await r.json();
|
||||
searchDropdownItems = result.hits.map((h: Record<string, any>) => ({
|
||||
text: h.name,
|
||||
description: `${h.division ? `${h.division} | ` : ""}${
|
||||
country_codes[h["country code"] as keyof typeof country_codes]
|
||||
}`,
|
||||
value: h,
|
||||
icon: "city",
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleSearchClick(item: SearchItem) {
|
||||
citySearchQuery = item.text;
|
||||
await settings_update({
|
||||
id: settings?.id!,
|
||||
location: {
|
||||
name: item.value.name,
|
||||
lat: item.value.lat,
|
||||
lon: item.value.lon,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleUnitSelection(e: RadioItem) {
|
||||
await settings_update({
|
||||
id: settings!.id,
|
||||
unit: e.value as "imperial" | "metric",
|
||||
});
|
||||
}
|
||||
|
||||
async function handleMapFocusSelection(value: "trails" | "location") {
|
||||
await settings_update({
|
||||
id: settings!.id,
|
||||
mapFocus: value,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCategorySelection(value: string) {
|
||||
await settings_update({
|
||||
id: settings!.id,
|
||||
category: value,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleTilesetAdd() {
|
||||
if (!customTilesetName || !customTilesetURL) {
|
||||
return;
|
||||
}
|
||||
await settings_update({
|
||||
id: settings!.id,
|
||||
tilesets: [
|
||||
...(settings.tilesets ?? []),
|
||||
{ name: customTilesetName, url: customTilesetURL },
|
||||
],
|
||||
});
|
||||
customTilesetName = "";
|
||||
customTilesetURL = "";
|
||||
}
|
||||
|
||||
async function handleTilesetDelete(index: number) {
|
||||
settings.tilesets.splice(index, 1);
|
||||
await settings_update({
|
||||
id: settings!.id,
|
||||
tilesets: settings.tilesets,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_("settings")} | wanderer</title>
|
||||
</svelte:head>
|
||||
<main>
|
||||
{#if $currentUser}
|
||||
<Settings selected={1}>
|
||||
<div class="space-y-6">
|
||||
<h3 class="text-2xl font-semibold">{$_("units")}</h3>
|
||||
<RadioGroup
|
||||
name="unit"
|
||||
items={units}
|
||||
selected={settings?.unit == "metric" ? 0 : 1}
|
||||
on:change={(e) => handleUnitSelection(e.detail)}
|
||||
></RadioGroup>
|
||||
<h3 class="text-2xl font-semibold">{$_("focus-map-on")}</h3>
|
||||
|
||||
<Select
|
||||
items={mapFocus}
|
||||
bind:value={selectedMapFocus}
|
||||
on:change={(e) => handleMapFocusSelection(e.detail)}
|
||||
></Select>
|
||||
{#if selectedMapFocus == "location"}
|
||||
<div class="mt-3">
|
||||
<Search
|
||||
items={searchDropdownItems}
|
||||
placeholder="{$_('search-cities')}..."
|
||||
bind:value={citySearchQuery}
|
||||
on:update={(e) => searchCities(e.detail)}
|
||||
on:click={(e) => handleSearchClick(e.detail)}
|
||||
></Search>
|
||||
</div>
|
||||
{/if}
|
||||
<h3 class="text-2xl font-semibold">{$_("default-category")}</h3>
|
||||
<Select
|
||||
items={categoryItems}
|
||||
bind:value={selectedCategory}
|
||||
on:change={(e) => handleCategorySelection(e.detail)}
|
||||
></Select>
|
||||
<h3 class="text-2xl font-semibold">{$_("tilesets")}</h3>
|
||||
|
||||
{#each settings.tilesets ?? [] as tileset, i}
|
||||
<div
|
||||
class="flex items-center justify-between px-4 py-2 border border-input-border rounded-xl"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium">{tileset.name}</p>
|
||||
<p class=" text-sm">{tileset.url}</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn-icon"
|
||||
on:click={() => handleTilesetDelete(i)}
|
||||
><i class="fa fa-trash text-red-500"></i></button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="flex gap-4 items-center">
|
||||
<TextField
|
||||
label={$_("name")}
|
||||
bind:value={customTilesetName}
|
||||
placeholder="Open Street Maps"
|
||||
></TextField>
|
||||
<div class="flex items-center basis-full gap-2">
|
||||
<div class="flex-grow">
|
||||
<TextField
|
||||
label="URL"
|
||||
bind:value={customTilesetURL}
|
||||
placeholder="https://{'{'}s{'}'}.tile.openstreetmap.org/{'{'}z{'}'}/{'{'}x{'}'}/{'{'}y{'}'}.png"
|
||||
></TextField>
|
||||
</div>
|
||||
<button
|
||||
class="btn-icon mt-6"
|
||||
on:click={handleTilesetAdd}
|
||||
><i class="fa fa-plus"></i></button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Settings>
|
||||
{/if}
|
||||
</main>
|
||||
8
web/src/routes/settings/display/+page.ts
Normal file
8
web/src/routes/settings/display/+page.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { categories_index } from "$lib/stores/category_store";
|
||||
import { type Load } from "@sveltejs/kit";
|
||||
|
||||
export const load: Load = async ({ params, fetch }) => {
|
||||
const categories = await categories_index(fetch)
|
||||
|
||||
return { categories: categories }
|
||||
};
|
||||
194
web/src/routes/settings/export/+page.svelte
Normal file
194
web/src/routes/settings/export/+page.svelte
Normal file
@@ -0,0 +1,194 @@
|
||||
<script lang="ts">
|
||||
import Button from "$lib/components/base/button.svelte";
|
||||
import Settings from "$lib/components/profile/settings.svelte";
|
||||
import TrailExportModal from "$lib/components/trail/trail_export_modal.svelte";
|
||||
import { show_toast } from "$lib/stores/toast_store";
|
||||
import { fetchGPX, trails_index, trails_upload } from "$lib/stores/trail_store";
|
||||
import { getFileURL, saveAs } from "$lib/util/file_util";
|
||||
import { trail2gpx } from "$lib/util/gpx_util";
|
||||
import { gpx } from "$lib/vendor/toGeoJSON/toGeoJSON";
|
||||
import JSZip from "jszip";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { linear } from "svelte/easing";
|
||||
import { tweened } from "svelte/motion";
|
||||
|
||||
const uploadProgress = tweened(0, {
|
||||
duration: 300,
|
||||
easing: linear,
|
||||
});
|
||||
|
||||
let openExportModal: () => void;
|
||||
|
||||
function openFileBrowser() {
|
||||
document.getElementById("file-input")!.click();
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
handleFileSelection(e.dataTransfer?.files);
|
||||
}
|
||||
|
||||
async function handleFileSelection(files?: FileList | null) {
|
||||
if (!files) {
|
||||
files = (document.getElementById("file-input") as HTMLInputElement)
|
||||
.files;
|
||||
}
|
||||
|
||||
if (!files) {
|
||||
return;
|
||||
}
|
||||
|
||||
let errorsThrown = 0;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
try {
|
||||
await trails_upload(file);
|
||||
} catch (e) {
|
||||
errorsThrown += 1;
|
||||
show_toast({
|
||||
type: "error",
|
||||
icon: "close",
|
||||
text: `Error uploading file: ${file.name}`,
|
||||
});
|
||||
}
|
||||
$uploadProgress += (files.length / i) * 100;
|
||||
setTimeout(() => {}, 3000);
|
||||
}
|
||||
if (errorsThrown == 0) {
|
||||
show_toast({
|
||||
type: "success",
|
||||
icon: "check",
|
||||
text: `${files.length} ${$_("trail", { values: { n: files.length } })} ${$_("uploaded")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function exportTrails(exportSettings: {
|
||||
fileFormat: "gpx" | "json";
|
||||
photos: boolean;
|
||||
summitLog: boolean;
|
||||
}) {
|
||||
try {
|
||||
const trails = await trails_index(-1);
|
||||
|
||||
const zip = new JSZip();
|
||||
|
||||
for (const trail of trails) {
|
||||
const gpxData: string = await fetchGPX(trail);
|
||||
if (!trail.expand) {
|
||||
trail.expand = {};
|
||||
}
|
||||
trail.expand.gpx_data = gpxData;
|
||||
const trailFolder = zip.folder(`${trail.name}`);
|
||||
let fileData: string = await trail2gpx(trail);
|
||||
if (exportSettings.fileFormat == "json") {
|
||||
fileData = JSON.stringify(
|
||||
gpx(
|
||||
new DOMParser().parseFromString(
|
||||
fileData,
|
||||
"text/xml",
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
trailFolder?.file(
|
||||
`${trail.name}.${exportSettings.fileFormat}`,
|
||||
fileData,
|
||||
);
|
||||
if (exportSettings.photos) {
|
||||
const photoFolder = trailFolder?.folder($_("photos"));
|
||||
for (const photo of trail.photos ?? []) {
|
||||
const photoURL = getFileURL(trail, photo);
|
||||
const photoBlob = await fetch(photoURL).then(
|
||||
(response) => response.blob(),
|
||||
);
|
||||
const photoData = new File([photoBlob], photo);
|
||||
photoFolder?.file(photo, photoData, { base64: true });
|
||||
}
|
||||
}
|
||||
if (exportSettings.summitLog) {
|
||||
let summitLogString = "";
|
||||
for (const summitLog of trail.expand?.summit_logs ?? []) {
|
||||
summitLogString += `${summitLog.date},${summitLog.text}\n`;
|
||||
}
|
||||
trailFolder?.file(
|
||||
`${trail.name} - ${$_("summit-book")}.csv`,
|
||||
summitLogString,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const blob = await zip.generateAsync({ type: "blob" });
|
||||
saveAs(blob, `wanderer-export-${Date.now()}.zip`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
show_toast({
|
||||
type: "error",
|
||||
icon: "close",
|
||||
text: $_("error-exporting-trail"),
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_("settings")} | wanderer</title>
|
||||
</svelte:head>
|
||||
<main>
|
||||
<Settings selected={2}>
|
||||
<div class="space-y-6">
|
||||
<h3 class="text-2xl font-semibold">{$_("import")}</h3>
|
||||
<button
|
||||
class="drop-area relative h-64 w-full border border-content border-dashed rounded-xl flex items-center justify-center text-gray-500 bg-background cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
|
||||
style="--progress: {$uploadProgress}%"
|
||||
on:click={openFileBrowser}
|
||||
on:dragover={handleDragOver}
|
||||
on:drop={handleDrop}
|
||||
>
|
||||
Select or drag GPX, FIT, KML or TCX files here...
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
id="file-input"
|
||||
accept=".gpx,.GPX,.tcx,.TCX,.kml,.KML,.fit,.FIT"
|
||||
multiple={true}
|
||||
style="display: none;"
|
||||
on:change={() => handleFileSelection()}
|
||||
/>
|
||||
<h3 class="text-2xl font-semibold">{$_("export")}</h3>
|
||||
<Button secondary={true} on:click={() => openExportModal()}
|
||||
>{$_("export-all-trails")}</Button
|
||||
>
|
||||
</div>
|
||||
</Settings>
|
||||
</main>
|
||||
|
||||
<TrailExportModal
|
||||
bind:openModal={openExportModal}
|
||||
on:export={(e) => exportTrails(e.detail)}
|
||||
></TrailExportModal>
|
||||
|
||||
<style>
|
||||
.drop-area::after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: -4px;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
bottom: -4px;
|
||||
background-color: transparent;
|
||||
z-index: -100;
|
||||
border-radius: 0.75rem;
|
||||
background-image: conic-gradient(
|
||||
rgba(var(--content)),
|
||||
rgba(var(--content)) var(--progress),
|
||||
transparent var(--progress)
|
||||
);
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/stores";
|
||||
import { PUBLIC_VALHALLA_URL } from "$env/static/public";
|
||||
import Button from "$lib/components/base/button.svelte";
|
||||
import Datepicker from "$lib/components/base/datepicker.svelte";
|
||||
@@ -50,18 +51,18 @@
|
||||
formatElevation,
|
||||
formatTimeHHMM,
|
||||
} from "$lib/util/format_util";
|
||||
import { fromFIT, fromKML, fromTCX, gpx2trail } from "$lib/util/gpx_util";
|
||||
import { fromFIT, fromKML, fromTCX, gpx2trail, isFITFile } from "$lib/util/gpx_util";
|
||||
import {
|
||||
createAnchorMarker,
|
||||
createMarkerFromWaypoint,
|
||||
} from "$lib/util/leaflet_util";
|
||||
import { createForm } from "$lib/vendor/svelte-form-lib";
|
||||
import cryptoRandomString from "crypto-random-string";
|
||||
import type { DivIcon, LatLng, LeafletMouseEvent, Map } from "leaflet";
|
||||
import type { DivIcon, LeafletMouseEvent, Map } from "leaflet";
|
||||
import { onMount } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { backInOut } from "svelte/easing";
|
||||
import { fade, scale } from "svelte/transition";
|
||||
import { scale } from "svelte/transition";
|
||||
import { array, number, object, string } from "yup";
|
||||
|
||||
export let data: { trail: Trail };
|
||||
@@ -110,7 +111,10 @@
|
||||
let autoRouting = true;
|
||||
|
||||
const { form, errors, handleChange, handleSubmit } = createForm<Trail>({
|
||||
initialValues: data.trail,
|
||||
initialValues: {
|
||||
...data.trail,
|
||||
category: $page.data.settings?.category || $categories[0].id,
|
||||
},
|
||||
validationSchema: trailSchema,
|
||||
onError: (errors) => {
|
||||
if (errors.name) {
|
||||
@@ -225,9 +229,10 @@
|
||||
const fileExtension = file.name.split(".").pop()?.toLowerCase();
|
||||
|
||||
let gpxData = "";
|
||||
|
||||
if (fileExtension !== "fit") {
|
||||
const fileContent = await file.text();
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
|
||||
if (!isFITFile(fileBuffer)) {
|
||||
if (fileContent.includes("http://www.opengis.net/kml")) {
|
||||
gpxData = fromKML(fileContent);
|
||||
gpxFile = new Blob([gpxData], {
|
||||
@@ -244,11 +249,13 @@
|
||||
gpxFile = file;
|
||||
}
|
||||
} else {
|
||||
const fileContent = await file.arrayBuffer();
|
||||
gpxData = await fromFIT(fileContent);
|
||||
gpxData = await fromFIT(fileBuffer);
|
||||
gpxFile = new Blob([gpxData], {
|
||||
type: "application/gpx+xml",
|
||||
});
|
||||
}
|
||||
|
||||
return gpxData
|
||||
return gpxData;
|
||||
}
|
||||
|
||||
async function handleFileSelection() {
|
||||
@@ -272,7 +279,7 @@
|
||||
const parseResult = await gpx2trail(gpxData);
|
||||
$form = parseResult.trail;
|
||||
$form.expand.gpx_data = gpxData;
|
||||
$form.category = $categories[0].id;
|
||||
$form.category = $page.data.settings.category || $categories[0].id;
|
||||
|
||||
setRoute(parseResult.gpx);
|
||||
initRouteAnchors(parseResult.gpx);
|
||||
@@ -399,7 +406,7 @@
|
||||
savedWaypoint.marker = marker;
|
||||
}
|
||||
|
||||
function beforSummitLogModalOpen() {
|
||||
function beforeSummitLogModalOpen() {
|
||||
summitLog.set(new SummitLog(new Date().toISOString().split("T")[0]));
|
||||
openSummitLogModal();
|
||||
}
|
||||
@@ -810,7 +817,7 @@
|
||||
<button
|
||||
class="btn-secondary"
|
||||
type="button"
|
||||
on:click={beforSummitLogModalOpen}
|
||||
on:click={beforeSummitLogModalOpen}
|
||||
><i class="fa fa-plus mr-2"></i>{$_("add-entry")}</button
|
||||
>
|
||||
{#if $lists.length}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { lists_index } from "$lib/stores/list_store";
|
||||
import { trails_show } from "$lib/stores/trail_store";
|
||||
import { error, type Load } from "@sveltejs/kit";
|
||||
|
||||
export const load: Load = async ({ params, fetch }) => {
|
||||
export const load: Load = async ({ params, fetch, data }) => {
|
||||
if (!params.id) {
|
||||
return error(400, "Bad Request")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user