full settings overhaul

This commit is contained in:
Christian Beutel
2024-07-05 18:14:31 +02:00
parent 6522b06e96
commit d0a408983b
47 changed files with 4355 additions and 551 deletions

View File

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

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

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

View File

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

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

View File

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

View File

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

View File

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

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

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

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

View File

@@ -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";
@@ -93,33 +94,39 @@
);
const topoLayer = L.tileLayer(
"https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png ",
"https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png",
{
attribution: "© OpenStreetMap contributors",
},
);
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 = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 ?? [];
}
}

View File

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

View File

@@ -107,20 +107,20 @@ export async function users_update(user: User | { [K in keyof User]?: User[K] },
throw new ClientResponseError(await r.json())
}
const formData = new FormData();
if (avatar) {
const formData = new FormData();
formData.append("avatar", avatar);
}
r = await fetch(`/api/v1/user/${user.id!}/file`, {
method: 'POST',
body: formData,
})
if (!r.ok) {
throw new ClientResponseError(await r.json())
r = await fetch(`/api/v1/user/${user.id!}/file`, {
method: 'POST',
body: formData,
})
if (!r.ok) {
throw new ClientResponseError(await r.json())
}
}
const model: User = await r.json();

View File

@@ -1,5 +1,5 @@
const privateRoutes = [
"/profile",
"/settings",
"/lists",
"/trail/edit/new",
]

View File

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

File diff suppressed because it is too large Load Diff

16
web/src/lib/vendor/easy-fit/messages.ts vendored Normal file
View 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;
}

View File

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

View File

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

View File

@@ -134,7 +134,9 @@ export const createForm = (config) => {
return clearErrorsAndSubmit(values);
} else {
errors.set(error);
onError(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),
);
onError(updatedErrors)
if(onError) {
onError(updatedErrors)
}
errors.set(updatedErrors);
}
isSubmitting.set(false);

View File

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

View File

@@ -10,8 +10,17 @@ export async function GET(event: RequestEvent) {
const filter = event.url.searchParams.get("filter") ?? "";
try {
const r = await pb.collection('trails')
.getList<Trail>(parseInt(page), parseInt(perPage), { expand: expand ?? "", sort: sort ?? "", filter: filter ?? "" })
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);

View File

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

View File

@@ -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)
return json(r);
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)
}

View File

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

View File

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

View File

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

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

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

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

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

View File

@@ -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 = "";
const fileContent = await file.text();
const fileBuffer = await file.arrayBuffer();
if (fileExtension !== "fit") {
const fileContent = await file.text();
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}

View File

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