refactor zo zod and felte

This commit is contained in:
Christian Beutel
2024-12-30 19:52:44 +01:00
parent 75c94a3ce4
commit 871481a84e
45 changed files with 659 additions and 1040 deletions

76
web/package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "wanderer", "name": "wanderer",
"version": "0.12.0", "version": "0.12.0",
"dependencies": { "dependencies": {
"@felte/validator-zod": "^1.0.18",
"@fortawesome/fontawesome-free": "^6.5.1", "@fortawesome/fontawesome-free": "^6.5.1",
"@sveltejs/adapter-node": "^4.0.1", "@sveltejs/adapter-node": "^4.0.1",
"@threlte/core": "^7.1.0", "@threlte/core": "^7.1.0",
@@ -22,6 +23,7 @@
"chartjs-plugin-crosshair": "^2.0.0", "chartjs-plugin-crosshair": "^2.0.0",
"chartjs-plugin-zoom": "^2.1.0", "chartjs-plugin-zoom": "^2.1.0",
"crypto-random-string": "^5.0.0", "crypto-random-string": "^5.0.0",
"felte": "^1.3.0",
"heic2any": "^0.0.4", "heic2any": "^0.0.4",
"instead": "^1.0.3", "instead": "^1.0.3",
"isomorphic-xml2js": "^0.1.3", "isomorphic-xml2js": "^0.1.3",
@@ -38,7 +40,6 @@
"svelte-i18n": "^4.0.0", "svelte-i18n": "^4.0.0",
"three": "^0.161.0", "three": "^0.161.0",
"xmldom": "^0.6.0", "xmldom": "^0.6.0",
"yup": "^1.3.3",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
@@ -438,6 +439,39 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@felte/common": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@felte/common/-/common-1.1.9.tgz",
"integrity": "sha512-d9e9M5FWc7AyaX/fb2I78AvwIAAA6S8ncKoTd3vYdPGQfgryl18oMU+ScM1WgFnbOBSoItdt61oaZAuRqfE4AA==",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/@felte/core": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/@felte/core/-/core-1.4.4.tgz",
"integrity": "sha512-qXEofmfc0tKvd1MOP9T1ixoI4+u0ZcUtC7JpBfb2A/lr/mhc8HrkvVWGatrGBnNovTc4lgIKWOySVVFswa4BYg==",
"dependencies": {
"@felte/common": "1.1.9"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
}
},
"node_modules/@felte/validator-zod": {
"version": "1.0.18",
"resolved": "https://registry.npmjs.org/@felte/validator-zod/-/validator-zod-1.0.18.tgz",
"integrity": "sha512-nLhy3qhCrL14iMxIHXtIH0MckZr/dmpqRd/KcqQ6HJoT2tjvv8gPpgOGJdOR15Wfzd25n/UUr3tyX7ydgXA5xQ==",
"dependencies": {
"@felte/common": "1.1.9"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"peerDependencies": {
"zod": "^3.2.0"
}
},
"node_modules/@formatjs/ecma402-abstract": { "node_modules/@formatjs/ecma402-abstract": {
"version": "1.18.2", "version": "1.18.2",
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.18.2.tgz", "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-1.18.2.tgz",
@@ -2717,6 +2751,20 @@
"reusify": "^1.0.4" "reusify": "^1.0.4"
} }
}, },
"node_modules/felte": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/felte/-/felte-1.3.0.tgz",
"integrity": "sha512-J09vuOC2Hw/d+ajhT4ueVEcSU0af+tlnfwF9QWVTuOWQf286pSGS48cFYhjmS7K7hUNMRUIwXwCnHWpyasadmA==",
"dependencies": {
"@felte/core": "1.4.4"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"peerDependencies": {
"svelte": "^3.31.0 || ^4.0.0 || ^5.0.0"
}
},
"node_modules/fflate": { "node_modules/fflate": {
"version": "0.6.10", "version": "0.6.10",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz",
@@ -4823,11 +4871,6 @@
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
}, },
"node_modules/property-expr": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz",
"integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA=="
},
"node_modules/protocol-buffers-schema": { "node_modules/protocol-buffers-schema": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
@@ -5866,11 +5909,6 @@
"next-tick": "1" "next-tick": "1"
} }
}, },
"node_modules/tiny-case": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
"integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q=="
},
"node_modules/tiny-glob": { "node_modules/tiny-glob": {
"version": "0.2.9", "version": "0.2.9",
"resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
@@ -5926,11 +5964,6 @@
"node": ">=8.0" "node": ">=8.0"
} }
}, },
"node_modules/toposort": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg=="
},
"node_modules/totalist": { "node_modules/totalist": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
@@ -6729,17 +6762,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/yup": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/yup/-/yup-1.3.3.tgz",
"integrity": "sha512-v8QwZSsHH2K3/G9WSkp6mZKO+hugKT1EmnMqLNUcfu51HU9MDyhlETT/JgtzprnrnQHPWsjc6MUDMBp/l9fNnw==",
"dependencies": {
"property-expr": "^2.0.5",
"tiny-case": "^1.0.3",
"toposort": "^2.0.2",
"type-fest": "^2.19.0"
}
},
"node_modules/zod": { "node_modules/zod": {
"version": "3.24.1", "version": "3.24.1",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz",

View File

@@ -30,6 +30,7 @@
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@felte/validator-zod": "^1.0.18",
"@fortawesome/fontawesome-free": "^6.5.1", "@fortawesome/fontawesome-free": "^6.5.1",
"@sveltejs/adapter-node": "^4.0.1", "@sveltejs/adapter-node": "^4.0.1",
"@threlte/core": "^7.1.0", "@threlte/core": "^7.1.0",
@@ -44,6 +45,7 @@
"chartjs-plugin-crosshair": "^2.0.0", "chartjs-plugin-crosshair": "^2.0.0",
"chartjs-plugin-zoom": "^2.1.0", "chartjs-plugin-zoom": "^2.1.0",
"crypto-random-string": "^5.0.0", "crypto-random-string": "^5.0.0",
"felte": "^1.3.0",
"heic2any": "^0.0.4", "heic2any": "^0.0.4",
"instead": "^1.0.3", "instead": "^1.0.3",
"isomorphic-xml2js": "^0.1.3", "isomorphic-xml2js": "^0.1.3",
@@ -60,7 +62,6 @@
"svelte-i18n": "^4.0.0", "svelte-i18n": "^4.0.0",
"three": "^0.161.0", "three": "^0.161.0",
"xmldom": "^0.6.0", "xmldom": "^0.6.0",
"yup": "^1.3.3",
"zod": "^3.24.1" "zod": "^3.24.1"
} }
} }

View File

@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { _ } from "svelte-i18n";
export let name: string = ""; export let name: string = "";
export let value: string | Date = ""; export let value: string | Date = "";
export let label: string = ""; export let label: string = "";
export let error: string = ""; export let error: string | string[] | null = "";
</script> </script>
<div> <div>
@@ -15,15 +17,17 @@
<input <input
{name} {name}
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" 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"
class:border-red-400={error.length > 0} class:border-red-400={(error?.length ?? 0) > 0}
class:bg-red-50={error.length > 0} class:bg-input-background-error={(error?.length ?? 0) > 0}
type="date" type="date"
bind:value bind:value
on:change on:change
/> />
</div> </div>
<span class="textfield-error text-xs text-red-400"> {#if error}
{error} <span class="textfield-error text-xs text-red-400">
</span> {error instanceof Array ? $_(error[0]) : error}
</span>
{/if}
</div> </div>

View File

@@ -9,8 +9,8 @@
import { createEventDispatcher } from "svelte"; import { createEventDispatcher } from "svelte";
export let name: string = ""; export let name: string = "";
export let value: any;
export let items: SelectItem[] = []; export let items: SelectItem[] = [];
export let value: any = items.at(0)?.value ?? "";
export let label: string = ""; export let label: string = "";
export let disabled: boolean = false; export let disabled: boolean = false;

View File

@@ -1,10 +1,12 @@
<script lang="ts"> <script lang="ts">
import { _ } from "svelte-i18n";
export let name: string = ""; export let name: string = "";
export let value: string | number = ""; export let value: string | number = "";
export let placeholder: string = ""; export let placeholder: string = "";
export let disabled: boolean = false; export let disabled: boolean = false;
export let label: string = ""; export let label: string = "";
export let error: string = ""; export let error: string | string[] | null = "";
export let icon: string = ""; export let icon: string = "";
export let extraClasses: string = ""; export let extraClasses: string = "";
export let type: "text" | "password" | "search" = "text"; export let type: "text" | "password" | "search" = "text";
@@ -29,8 +31,8 @@
<input <input
{name} {name}
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="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:border-red-400={(error?.length ?? 0) > 0}
class:bg-input-background-error={error.length > 0} class:bg-input-background-error={(error?.length ?? 0) > 0}
class:text-gray-500={disabled} class:text-gray-500={disabled}
{disabled} {disabled}
{autocomplete} {autocomplete}
@@ -44,7 +46,9 @@
/> />
</div> </div>
<span class="textfield-error text-xs text-red-400"> {#if error}
{error} <span class="textfield-error text-xs text-red-400">
</span> {error instanceof Array ? $_(error[0]) : error}
</span>
{/if}
</div> </div>

View File

@@ -1,10 +1,12 @@
<script lang="ts"> <script lang="ts">
import { _ } from "svelte-i18n";
export let name: string = ""; export let name: string = "";
export let value: string | number = ""; export let value: string | number = "";
export let placeholder: string = ""; export let placeholder: string = "";
export let rows: number = 3; export let rows: number = 3;
export let label: string = ""; export let label: string = "";
export let error: string = ""; export let error: string | string[] | null = "";
export let extraClasses: string = ""; export let extraClasses: string = "";
</script> </script>
@@ -19,12 +21,14 @@
class="bg-input-background border border-input-border rounded-md p-3 resize-none transition-colors focus:border-input-border-focus focus:outline-none focus:ring-0 w-full {extraClasses}" class="bg-input-background border border-input-border rounded-md p-3 resize-none transition-colors focus:border-input-border-focus focus:outline-none focus:ring-0 w-full {extraClasses}"
{rows} {rows}
{placeholder} {placeholder}
class:border-red-400={error.length > 0} class:border-red-400={(error?.length ?? 0) > 0}
class:bg-red-50={error.length > 0} class:bg-input-background-error={(error?.length ?? 0) > 0}
bind:value bind:value
on:change on:change
/> />
<span class="textfield-error text-xs text-red-400"> {#if error}
{error} <span class="textfield-error text-xs text-red-400">
</span> {error instanceof Array ? $_(error[0]) : error}
</span>
{/if}
</div> </div>

View File

@@ -125,13 +125,17 @@
{#each { length: 5 } as _, index} {#each { length: 5 } as _, index}
<SkeletonNotificationCard></SkeletonNotificationCard> <SkeletonNotificationCard></SkeletonNotificationCard>
{/each} {/each}
{:else} {:else if notifications.length}
{#each notifications as notification} {#each notifications as notification}
<NotificationCard <NotificationCard
on:click={handleNotificationClick} on:click={handleNotificationClick}
{notification} {notification}
></NotificationCard> ></NotificationCard>
{/each} {/each}
{:else}
<p class="text-gray-500 text-sm text-center py-6">
No notifications
</p>
{/if} {/if}
</ul> </ul>
{/if} {/if}

View File

@@ -1,16 +1,17 @@
<script lang="ts"> <script lang="ts">
import Modal from "$lib/components/base/modal.svelte"; import Modal from "$lib/components/base/modal.svelte";
import { validator } from "@felte/validator-zod";
import { createForm } from "felte";
import { createEventDispatcher } from "svelte"; import { createEventDispatcher } from "svelte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { z } from "zod";
import TextField from "../base/text_field.svelte"; 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; let _openModal: (() => void) | undefined = undefined;
export let closeModal: (() => void) | undefined = undefined; export let closeModal: (() => void) | undefined = undefined;
export function openModal() { export function openModal() {
$form.email = email setFields("email", email);
$errors.email = ""; setErrors("email", []);
_openModal!(); _openModal!();
} }
@@ -18,17 +19,20 @@
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
const { form, errors, handleChange, handleSubmit } = createForm<{ const { form, errors, setFields, setErrors } = createForm<{
email: string; email: string;
}>({ }>({
initialValues: { email: email }, initialValues: { email: email },
validationSchema: object({ extend: validator({
email: string() schema: z.object({
.email($_("not-a-valid-email-address")) email: z
.required($_("required")), .string()
.min(1, "required")
.email("not-a-valid-email-address"),
}),
}), }),
onSubmit: async (_) => { onSubmit: async (form) => {
dispatch("save", $form.email); dispatch("save", form.email);
closeModal!(); closeModal!();
}, },
}); });
@@ -41,13 +45,8 @@
bind:openModal={_openModal} bind:openModal={_openModal}
bind:closeModal bind:closeModal
> >
<form id="email-form" slot="content" on:submit={handleSubmit}> <form id="email-form" slot="content" use:form>
<TextField <TextField name="email" error={$errors.email}></TextField>
name="email"
bind:value={$form.email}
error={$errors.email}
on:change={handleChange}
></TextField>
</form> </form>
<div slot="footer" class="flex items-center gap-4"> <div slot="footer" class="flex items-center gap-4">
<button class="btn-secondary" on:click={closeModal} <button class="btn-secondary" on:click={closeModal}

View File

@@ -3,46 +3,61 @@
import { createEventDispatcher } from "svelte"; import { createEventDispatcher } from "svelte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import TextField from "../base/text_field.svelte"; import TextField from "../base/text_field.svelte";
import { createForm } from "$lib/vendor/svelte-form-lib"; import { validator } from "@felte/validator-zod";
import { object, string } from "yup"; import { createForm } from "felte";
import { z } from "zod";
let _openModal: (() => void) | undefined = undefined; let _openModal: (() => void) | undefined = undefined;
export let closeModal: (() => void) | undefined = undefined; export let closeModal: (() => void) | undefined = undefined;
export function openModal() { export function openModal() {
$form.password = "" setFields("password", "")
$errors.password = ""; setFields("oldPassword", "")
$form.oldPassword = "" setErrors("password", [])
$errors.oldPassword = ""; setErrors("oldPassword", [])
_openModal!(); _openModal!();
} }
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
const { form, errors, handleChange, handleSubmit } = createForm<{ const { form, errors, setFields, setErrors } = createForm<{
oldPassword: string; oldPassword: string;
password: string; password: string;
passwordConfirm: string;
}>({ }>({
initialValues: { oldPassword: "", password: "" }, initialValues: { oldPassword: "", password: "" },
validationSchema: object({ extend: validator({
oldPassword: string() schema: z
.min( .object({
8, oldPassword: z.string().min(
$_("must-be-at-least-n-characters-long", { 8,
values: { n: 8 }, $_("must-be-at-least-n-characters-long", {
}), values: { n: 8 },
) }),
.required($_("required")), ),
password: string() password: z.string().min(
.min( 8,
8, $_("must-be-at-least-n-characters-long", {
$_("must-be-at-least-n-characters-long", { values: { n: 8 },
values: { n: 8 }, }),
}), ),
) passwordConfirm: z.string().min(
.required($_("required")), 8,
$_("must-be-at-least-n-characters-long", {
values: { n: 8 },
}),
),
})
.refine((d) => d.password === d.passwordConfirm, {
message: "passwords-must-match",
path: ["passwordConfirm"],
}),
}), }),
onSubmit: async (_) => { onSubmit: async (form) => {
dispatch("save", {oldPassword: $form.oldPassword, password: $form.password, passwordConfirm: $form.password}); dispatch("save", {
oldPassword: form.oldPassword,
password: form.password,
passwordConfirm: form.password,
});
closeModal!(); closeModal!();
}, },
}); });
@@ -55,30 +70,35 @@
bind:openModal={_openModal} bind:openModal={_openModal}
bind:closeModal bind:closeModal
> >
<form id="password-form" slot="content" on:submit={handleSubmit}> <form id="password-form" slot="content" use:form>
<TextField <TextField
name="oldPassword" name="oldPassword"
label={$_("current-password")} label={$_("current-password")}
type="password" type="password"
bind:value={$form.oldPassword}
error={$errors.oldPassword} error={$errors.oldPassword}
on:change={handleChange}
></TextField> ></TextField>
<TextField <TextField
name="password" name="password"
label={$_("new-password")} label={$_("new-password")}
type="password" type="password"
bind:value={$form.password}
error={$errors.password} error={$errors.password}
on:change={handleChange} ></TextField>
<TextField
name="passwordConfirm"
label={$_("password-confirm")}
type="password"
error={$errors.passwordConfirm}
></TextField> ></TextField>
</form> </form>
<div slot="footer" class="flex items-center gap-4"> <div slot="footer" class="flex items-center gap-4">
<button class="btn-secondary" on:click={closeModal} <button class="btn-secondary" on:click={closeModal}
>{$_("cancel")}</button >{$_("cancel")}</button
> >
<button class="btn-primary" type="submit" form="password-form" name="save" <button
>{$_("save")}</button class="btn-primary"
type="submit"
form="password-form"
name="save">{$_("save")}</button
> >
</div></Modal </div></Modal
> >

View File

@@ -1,41 +1,50 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from "svelte"; import { createEventDispatcher } from "svelte";
import { type SummitLog } from "$lib/models/summit_log"; import {
SummitLogCreateSchema,
} from "$lib/models/api/summit_log_schema";
import GPX from "$lib/models/gpx/gpx";
import { summitLog } from "$lib/stores/summit_log_store"; import { summitLog } from "$lib/stores/summit_log_store";
import { createForm } from "$lib/vendor/svelte-form-lib/index"; import { createForm } from "felte";
import { util } from "$lib/vendor/svelte-form-lib/util"; import { validator } from "@felte/validator-zod";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { date, object, string } from "yup"; import { z } from "zod";
import Datepicker from "../base/datepicker.svelte"; import Datepicker from "../base/datepicker.svelte";
import Modal from "../base/modal.svelte"; import Modal from "../base/modal.svelte";
import Textarea from "../base/textarea.svelte"; import Textarea from "../base/textarea.svelte";
import TrailPicker from "../trail/trail_picker.svelte";
import GPX from "$lib/models/gpx/gpx";
import PhotoPicker from "../trail/photo_picker.svelte"; import PhotoPicker from "../trail/photo_picker.svelte";
import TrailPicker from "../trail/trail_picker.svelte";
import { cloneDeep } from "$lib/util/deep_util";
export let openModal: (() => void) | undefined = undefined; export let openModal: (() => void) | undefined = undefined;
export let closeModal: (() => void) | undefined = undefined; export let closeModal: (() => void) | undefined = undefined;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
const summitLogSchema = object<SummitLog>({ const ClientSummitLogCreateSchema = SummitLogCreateSchema.extend({
id: string().optional(), _photos: z.array(z.instanceof(File)).optional(),
date: date().required("Required").typeError($_("invalid-date")), _gpx: z.instanceof(File).optional().nullable(),
text: string().optional(), expand: z
.object({
gpx_data: z.string().optional(),
})
.optional(),
}); });
const { form, errors, handleChange, handleSubmit } = createForm<SummitLog>({ const { form, errors, data, setFields } = createForm<
z.infer<typeof ClientSummitLogCreateSchema>
>({
initialValues: $summitLog, initialValues: $summitLog,
validationSchema: summitLogSchema, extend: validator({ schema: ClientSummitLogCreateSchema }),
onSubmit: async (submittedValues) => { onSubmit: async (form) => {
if (!$form.expand.gpx_data) { if (!form.expand?.gpx_data) {
$form.gpx = ""; form.gpx = "";
} }
if ( if (
!$form._photos?.length && !form._photos?.length &&
!$form.photos?.length && !form.photos?.length &&
$form.expand?.gpx_data form.expand?.gpx_data
) { ) {
const canvas = document.querySelector( const canvas = document.querySelector(
"#trail-picker-map .maplibregl-canvas", "#trail-picker-map .maplibregl-canvas",
@@ -44,25 +53,25 @@
const dataURL = canvas.toDataURL(); const dataURL = canvas.toDataURL();
const response = await fetch(dataURL); const response = await fetch(dataURL);
const blob = await response.blob(); const blob = await response.blob();
$form._photos = [new File([blob], "route")]; form._photos = [new File([blob], "route")];
} }
dispatch("save", submittedValues); dispatch("save", form);
closeModal!(); closeModal!();
}, },
}); });
$: form.set(util.cloneDeep($summitLog)); $: setFields(cloneDeep($summitLog));
$: if ($summitLog._gpx) { $: if ($summitLog._gpx) {
$form._gpx = $summitLog._gpx; $data._gpx = $summitLog._gpx;
} }
async function handleTrailSelection(trailData: string | null) { async function handleTrailSelection(trailData: string | null) {
if (!trailData) { if (!trailData) {
$form.duration = undefined; $data.duration = undefined;
$form.elevation_gain = undefined; $data.elevation_gain = undefined;
$form.elevation_loss = undefined; $data.elevation_loss = undefined;
$form.distance = undefined; $data.distance = undefined;
return; return;
} }
const gpxObject = await GPX.parse(trailData); const gpxObject = await GPX.parse(trailData);
@@ -72,16 +81,15 @@
const totals = gpxObject.getTotals(); const totals = gpxObject.getTotals();
$form.duration = totals.duration / 1000; $data.duration = totals.duration / 1000;
$form.elevation_gain = totals.elevationGain; $data.elevation_gain = totals.elevationGain;
$form.elevation_loss = totals.elevationLoss; $data.elevation_loss = totals.elevationLoss;
$form.distance = totals.distance; $data.distance = totals.distance;
} }
</script> </script>
<Modal <Modal
id="summit-log-modal" id="summit-log-modal"
title={$form.id ? $_("edit-entry") : $_("add-entry")} title={$data.id ? $_("edit-entry") : $_("add-entry")}
let:openModal let:openModal
bind:openModal bind:openModal
bind:closeModal bind:closeModal
@@ -91,15 +99,10 @@
id="summit-log-form" id="summit-log-form"
slot="content" slot="content"
class="modal-content space-y-4" class="modal-content space-y-4"
on:submit={handleSubmit} use:form
> >
<div class="flex"> <div class="flex">
<Datepicker <Datepicker name="date" label={$_("date")} error={$errors.date}
name="date"
label={$_("date")}
bind:value={$form.date}
error={$errors.date}
on:change={handleChange}
></Datepicker> ></Datepicker>
</div> </div>
<div> <div>
@@ -108,27 +111,27 @@
</label> </label>
<PhotoPicker <PhotoPicker
id="summitlog-photo-input" id="summitlog-photo-input"
parent={$form} parent={$data}
bind:photos={$form.photos} bind:photos={$data.photos}
bind:photoFiles={$form._photos} bind:photoFiles={$data._photos}
showThumbnailControls={false} showThumbnailControls={false}
></PhotoPicker> ></PhotoPicker>
</div> </div>
<div class="flex gap-4"> <div class="flex gap-4">
<TrailPicker {#if $data.expand}
bind:trailFile={$form._gpx} <TrailPicker
bind:trailData={$form.expand.gpx_data} bind:trailFile={$data._gpx}
label={$_("trail", { values: { n: 1 } })} bind:trailData={$data.expand.gpx_data}
on:change={(e) => handleTrailSelection(e.detail)} label={$_("trail", { values: { n: 1 } })}
></TrailPicker> on:change={(e) => handleTrailSelection(e.detail)}
></TrailPicker>
{/if}
<div class="basis-full"> <div class="basis-full">
<Textarea <Textarea
name="text" name="text"
extraClasses="h-28" extraClasses="h-28"
label={$_("text")} label={$_("text")}
bind:value={$form.text}
error={$errors.text} error={$errors.text}
on:change={handleChange}
></Textarea> ></Textarea>
</div> </div>
</div> </div>

View File

@@ -3,7 +3,7 @@
import { fromFile, toGeoJson } from "$lib/util/gpx_util"; import { fromFile, toGeoJson } from "$lib/util/gpx_util";
import { createEventDispatcher, onMount } from "svelte"; import { createEventDispatcher, onMount } from "svelte";
export let trailFile: File | null; export let trailFile: File | undefined | null;
export let trailData: string | undefined; export let trailData: string | undefined;
export let label: string = ""; export let label: string = "";
@@ -31,7 +31,7 @@
attributionControl: false, attributionControl: false,
dragPan: false, dragPan: false,
scrollZoom: false, scrollZoom: false,
preserveDrawingBuffer: true preserveDrawingBuffer: true,
}); });
} }
@@ -95,9 +95,12 @@
}, },
}); });
layer = map.getLayer(layerId) as M.LineLayerSpecification; layer = map.getLayer(layerId) as M.LineLayerSpecification;
source = map.getSource(sourceId) as M.GeoJSONSource; source = map.getSource(sourceId) as M.GeoJSONSource;
map.resize() map.resize();
map.fitBounds(geojson.bbox as M.LngLatBoundsLike, {animate: false, padding: 8}); map.fitBounds(geojson.bbox as M.LngLatBoundsLike, {
animate: false,
padding: 8,
});
} }
function removeTrailFromMap() { function removeTrailFromMap() {

View File

@@ -1,42 +1,58 @@
<script lang="ts"> <script lang="ts">
import { Waypoint, waypointSchema } from "$lib/models/waypoint";
import { createEventDispatcher } from "svelte"; import { createEventDispatcher } from "svelte";
import { WaypointCreateSchema } from "$lib/models/api/waypoint_schema";
import { convertDMSToDD } from "$lib/models/gpx/utils";
import { show_toast } from "$lib/stores/toast_store";
import { waypoint } from "$lib/stores/waypoint_store"; import { waypoint } from "$lib/stores/waypoint_store";
import { cloneDeep } from "$lib/util/deep_util";
import { icons } from "$lib/util/icon_util"; import { icons } from "$lib/util/icon_util";
import EXIF from "$lib/vendor/exif-js/exif"; import EXIF from "$lib/vendor/exif-js/exif";
import { createForm } from "$lib/vendor/svelte-form-lib/index"; import { validator } from "@felte/validator-zod";
import { util } from "$lib/vendor/svelte-form-lib/util"; import { createForm } from "felte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { z } from "zod";
import Combobox from "../base/combobox.svelte"; import Combobox from "../base/combobox.svelte";
import Modal from "../base/modal.svelte"; import Modal from "../base/modal.svelte";
import TextField from "../base/text_field.svelte"; import TextField from "../base/text_field.svelte";
import Textarea from "../base/textarea.svelte"; import Textarea from "../base/textarea.svelte";
import PhotoPicker from "../trail/photo_picker.svelte"; import PhotoPicker from "../trail/photo_picker.svelte";
import { show_toast } from "$lib/stores/toast_store";
import { convertDMSToDD } from "$lib/models/gpx/utils";
export let openModal: (() => void) | undefined = undefined; export let openModal: (() => void) | undefined = undefined;
export let closeModal: (() => void) | undefined = undefined; export let closeModal: (() => void) | undefined = undefined;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
const { form, errors, handleChange, handleSubmit } = createForm<Waypoint>({ const ClientWaypointCreateSchema = WaypointCreateSchema.extend({
_photos: z.array(z.instanceof(File)).optional(),
});
const { form, errors, data, setFields } = createForm<
z.infer<typeof ClientWaypointCreateSchema>
>({
initialValues: $waypoint, initialValues: $waypoint,
validationSchema: waypointSchema, extend: validator({ schema: ClientWaypointCreateSchema }),
onSubmit: async (submittedWaypoint) => { onSubmit: async (form) => {
dispatch("save", submittedWaypoint); dispatch("save", form);
closeModal!(); closeModal!();
}, },
transform: (values: unknown) => {
const v = values as any;
return {
...v,
lat: parseFloat(v.lat),
lon: parseFloat(v.lon),
};
},
}); });
$: form.set(util.cloneDeep($waypoint)); $: setFields(cloneDeep($waypoint));
$: filteredIcons = $: filteredIcons =
($form.icon?.length ?? 0) > 2 ($data.icon?.length ?? 0) > 2
? icons ? icons
.filter((i) => i.includes($form.icon ?? "")) .filter((i) => i.includes($data.icon ?? ""))
.map((i) => ({ text: i, value: i, icon: i })) .map((i) => ({ text: i, value: i, icon: i }))
: []; : [];
@@ -48,8 +64,8 @@
const lonDir = EXIF.getTag(p, "GPSLongitudeRef"); const lonDir = EXIF.getTag(p, "GPSLongitudeRef");
if (lat && lon) { if (lat && lon) {
$form.lat = convertDMSToDD(lat, latDir); setFields("lat", convertDMSToDD(lat, latDir));
$form.lon = convertDMSToDD(lon, lonDir); setFields("lon", convertDMSToDD(lon, lonDir));
} else { } else {
show_toast({ show_toast({
text: "No GPS data in image", text: "No GPS data in image",
@@ -63,7 +79,7 @@
<Modal <Modal
id="waypoint-modal" id="waypoint-modal"
title={$form.id ? $_("edit-waypoint") : $_("add-waypoint")} title={$data.id ? $_("edit-waypoint") : $_("add-waypoint")}
let:openModal let:openModal
bind:openModal bind:openModal
bind:closeModal bind:closeModal
@@ -73,50 +89,31 @@
id="waypoint-form" id="waypoint-form"
slot="content" slot="content"
class="modal-content space-y-4" class="modal-content space-y-4"
on:submit={handleSubmit} use:form
> >
<div class="flex gap-4"> <div class="flex gap-4">
<div class="basis-2/3"> <div class="basis-2/3">
<TextField <TextField name="name" label={$_("name")} error={$errors.name}
name="name"
label={$_("name")}
bind:value={$form.name}
error={$errors.name}
on:change={handleChange}
></TextField> ></TextField>
</div> </div>
<Combobox <Combobox
name="icon" name="icon"
bind:value={$form.icon} icon={$data.icon}
icon={$form.icon}
items={filteredIcons} items={filteredIcons}
label={$_("icon")} label={$_("icon")}
on:change={handleChange}
></Combobox> ></Combobox>
</div> </div>
<Textarea <Textarea
name="description" name="description"
label={$_("description")} label={$_("description")}
bind:value={$form.description}
error={$errors.description} error={$errors.description}
on:change={handleChange}
></Textarea> ></Textarea>
<div class="flex gap-4"> <div class="flex gap-4">
<TextField <TextField name="lat" label={$_("latitude")} error={$errors.lat}
name="lat"
label={$_("latitude")}
bind:value={$form.lat}
error={$errors.lat}
on:change={handleChange}
></TextField> ></TextField>
<TextField <TextField name="lon" label={$_("longitude")} error={$errors.lat}
name="lon"
label={$_("longitude")}
bind:value={$form.lon}
error={$errors.lat}
on:change={handleChange}
></TextField> ></TextField>
</div> </div>
<div> <div>
@@ -125,10 +122,10 @@
</label> </label>
<PhotoPicker <PhotoPicker
id="waypoint-photo-input" id="waypoint-photo-input"
parent={$form} parent={$data}
on:exif={(e) => getCoordinatesFromPhoto(e.detail)} on:exif={(e) => getCoordinatesFromPhoto(e.detail)}
bind:photos={$form.photos} bind:photos={$data.photos}
bind:photoFiles={$form._photos} bind:photoFiles={$data._photos}
showThumbnailControls={false} showThumbnailControls={false}
showExifControls={true} showExifControls={true}
></PhotoPicker> ></PhotoPicker>

View File

@@ -83,6 +83,7 @@
"elevation-gain": "Höhenunterschied (aufw.)", "elevation-gain": "Höhenunterschied (aufw.)",
"elevation-loss": "Höhenunterschied (abw.)", "elevation-loss": "Höhenunterschied (abw.)",
"email": "Email", "email": "Email",
"email-updated": "",
"empty-activities": "{username} hat noch keine Aktivitäten", "empty-activities": "{username} hat noch keine Aktivitäten",
"empty-bio": "{username} hat noch keine Biographie hinzugefügt", "empty-bio": "{username} hat noch keine Biographie hinzugefügt",
"empty-lists": "{username} hat keine öffentlichen Listen", "empty-lists": "{username} hat keine öffentlichen Listen",
@@ -175,6 +176,7 @@
"no-grid": "Kein Gitter", "no-grid": "Kein Gitter",
"no-preference": "Keine Präferenz", "no-preference": "Keine Präferenz",
"no-results": "Keine Ergebnisse gefunden", "no-results": "Keine Ergebnisse gefunden",
"no-routes-added": "",
"not-a-valid-email-address": "Keine gültige Email-Adresse", "not-a-valid-email-address": "Keine gültige Email-Adresse",
"not-completed": "Nicht abgeschlossen", "not-completed": "Nicht abgeschlossen",
"notification-list-create": "", "notification-list-create": "",

View File

@@ -83,6 +83,7 @@
"elevation-gain": "Elevation Gain", "elevation-gain": "Elevation Gain",
"elevation-loss": "Elevation Loss", "elevation-loss": "Elevation Loss",
"email": "Email", "email": "Email",
"email-updated": "Email updated",
"empty-activities": "{username} has no activity yet", "empty-activities": "{username} has no activity yet",
"empty-bio": "{username} has not added a bio yet", "empty-bio": "{username} has not added a bio yet",
"empty-lists": "{username} has no public lists", "empty-lists": "{username} has no public lists",
@@ -175,6 +176,7 @@
"no-grid": "No Grid", "no-grid": "No Grid",
"no-preference": "No preference", "no-preference": "No preference",
"no-results": "No results found", "no-results": "No results found",
"no-routes-added": "No routes added",
"not-a-valid-email-address": "Not a valid email address", "not-a-valid-email-address": "Not a valid email address",
"not-completed": "Not completed", "not-completed": "Not completed",
"notification-list-create": "{user} created a new list", "notification-list-create": "{user} created a new list",

View File

@@ -83,6 +83,7 @@
"elevation-gain": "Gain d'altitude", "elevation-gain": "Gain d'altitude",
"elevation-loss": "", "elevation-loss": "",
"email": "Email", "email": "Email",
"email-updated": "",
"empty-activities": "", "empty-activities": "",
"empty-bio": "", "empty-bio": "",
"empty-lists": "", "empty-lists": "",
@@ -175,6 +176,7 @@
"no-grid": "", "no-grid": "",
"no-preference": "Pas de préférence", "no-preference": "Pas de préférence",
"no-results": "Pas de résultat", "no-results": "Pas de résultat",
"no-routes-added": "",
"not-a-valid-email-address": "Adresse email invalide", "not-a-valid-email-address": "Adresse email invalide",
"not-completed": "Pas terminé", "not-completed": "Pas terminé",
"notification-list-create": "", "notification-list-create": "",

View File

@@ -83,6 +83,7 @@
"elevation-gain": "Magasságnövekedés", "elevation-gain": "Magasságnövekedés",
"elevation-loss": "", "elevation-loss": "",
"email": "Email", "email": "Email",
"email-updated": "",
"empty-activities": "", "empty-activities": "",
"empty-bio": "", "empty-bio": "",
"empty-lists": "", "empty-lists": "",
@@ -175,6 +176,7 @@
"no-grid": "", "no-grid": "",
"no-preference": "Nincs preferált", "no-preference": "Nincs preferált",
"no-results": "Nincs eredmény", "no-results": "Nincs eredmény",
"no-routes-added": "",
"not-a-valid-email-address": "Érvénytelen e-mail cím", "not-a-valid-email-address": "Érvénytelen e-mail cím",
"not-completed": "Nem teljesített", "not-completed": "Nem teljesített",
"notification-list-create": "", "notification-list-create": "",

View File

@@ -83,6 +83,7 @@
"elevation-gain": "Guadagno di quota", "elevation-gain": "Guadagno di quota",
"elevation-loss": "", "elevation-loss": "",
"email": "Email", "email": "Email",
"email-updated": "",
"empty-activities": "", "empty-activities": "",
"empty-bio": "", "empty-bio": "",
"empty-lists": "", "empty-lists": "",
@@ -175,6 +176,7 @@
"no-grid": "", "no-grid": "",
"no-preference": "Nessuna preferenza", "no-preference": "Nessuna preferenza",
"no-results": "Nessun risultato trovato", "no-results": "Nessun risultato trovato",
"no-routes-added": "",
"not-a-valid-email-address": "Indirizzo email non valido", "not-a-valid-email-address": "Indirizzo email non valido",
"not-completed": "Non completato", "not-completed": "Non completato",
"notification-list-create": "", "notification-list-create": "",

View File

@@ -83,6 +83,7 @@
"elevation-gain": "Hoogteverschil", "elevation-gain": "Hoogteverschil",
"elevation-loss": "", "elevation-loss": "",
"email": "E-mail", "email": "E-mail",
"email-updated": "",
"empty-activities": "", "empty-activities": "",
"empty-bio": "", "empty-bio": "",
"empty-lists": "", "empty-lists": "",
@@ -175,6 +176,7 @@
"no-grid": "", "no-grid": "",
"no-preference": "Geen voorkeur", "no-preference": "Geen voorkeur",
"no-results": "Er zijn geen zoekresultaten", "no-results": "Er zijn geen zoekresultaten",
"no-routes-added": "",
"not-a-valid-email-address": "Het e-mailadres is ongeldig", "not-a-valid-email-address": "Het e-mailadres is ongeldig",
"not-completed": "Niet voltooid", "not-completed": "Niet voltooid",
"notification-list-create": "", "notification-list-create": "",

View File

@@ -83,6 +83,7 @@
"elevation-gain": "Wzrost Wysokości", "elevation-gain": "Wzrost Wysokości",
"elevation-loss": "", "elevation-loss": "",
"email": "Email", "email": "Email",
"email-updated": "",
"empty-activities": "", "empty-activities": "",
"empty-bio": "", "empty-bio": "",
"empty-lists": "", "empty-lists": "",
@@ -175,6 +176,7 @@
"no-grid": "", "no-grid": "",
"no-preference": "Brak preferencji", "no-preference": "Brak preferencji",
"no-results": "Brak wyników", "no-results": "Brak wyników",
"no-routes-added": "",
"not-a-valid-email-address": "Nieprawidłowy adres email", "not-a-valid-email-address": "Nieprawidłowy adres email",
"not-completed": "Nie dokończono", "not-completed": "Nie dokończono",
"notification-list-create": "", "notification-list-create": "",

View File

@@ -83,6 +83,7 @@
"elevation-gain": "Ganho de elevação", "elevation-gain": "Ganho de elevação",
"elevation-loss": "", "elevation-loss": "",
"email": "Email", "email": "Email",
"email-updated": "",
"empty-activities": "", "empty-activities": "",
"empty-bio": "", "empty-bio": "",
"empty-lists": "", "empty-lists": "",
@@ -175,6 +176,7 @@
"no-grid": "", "no-grid": "",
"no-preference": "Nenhuma preferência", "no-preference": "Nenhuma preferência",
"no-results": "Nenhum resultado encontrado", "no-results": "Nenhum resultado encontrado",
"no-routes-added": "",
"not-a-valid-email-address": "Não um endereço de e-mail válido", "not-a-valid-email-address": "Não um endereço de e-mail válido",
"not-completed": "Não preenchido", "not-completed": "Não preenchido",
"notification-list-create": "", "notification-list-create": "",

View File

@@ -83,6 +83,7 @@
"elevation-gain": "上升海拔", "elevation-gain": "上升海拔",
"elevation-loss": "", "elevation-loss": "",
"email": "电子邮箱", "email": "电子邮箱",
"email-updated": "",
"empty-activities": "", "empty-activities": "",
"empty-bio": "", "empty-bio": "",
"empty-lists": "", "empty-lists": "",
@@ -175,6 +176,7 @@
"no-grid": "", "no-grid": "",
"no-preference": "尚未规划", "no-preference": "尚未规划",
"no-results": "没有找到结果", "no-results": "没有找到结果",
"no-routes-added": "",
"not-a-valid-email-address": "无效电子邮箱地址", "not-a-valid-email-address": "无效电子邮箱地址",
"not-completed": "未完成", "not-completed": "未完成",
"notification-list-create": "", "notification-list-create": "",

View File

@@ -2,11 +2,11 @@ import { z, ZodType } from "zod";
import type { List } from "../list"; import type { List } from "../list";
const ListCreateSchema = z.object({ const ListCreateSchema = z.object({
name: z.string(), id: z.string().length(15).optional(),
name: z.string().min(1, "required"),
public: z.boolean(), public: z.boolean(),
description: z.string().optional(), description: z.string().optional(),
trails: z.array(z.string().length(15)), trails: z.array(z.string().length(15)),
avatar: z.string().optional(),
author: z.string() author: z.string()
}) satisfies ZodType<List> }) satisfies ZodType<List>
@@ -17,7 +17,6 @@ const ListUpdateSchema = z.object({
trails: z.array(z.string().length(15)).optional(), trails: z.array(z.string().length(15)).optional(),
"trails-": z.string().optional(), "trails-": z.string().optional(),
"trails+": z.string().optional(), "trails+": z.string().optional(),
avatar: z.string().optional(),
}) satisfies ZodType<Partial<List>> }) satisfies ZodType<Partial<List>>
export { ListCreateSchema, ListUpdateSchema }; export { ListCreateSchema, ListUpdateSchema };

View File

@@ -4,7 +4,7 @@ import type { SummitLog } from "../summit_log";
const SummitLogCreateSchema = z.object({ const SummitLogCreateSchema = z.object({
id: z.string().length(15).optional(), id: z.string().length(15).optional(),
date: z.string().date(), date: z.string().date().refine((val) => !isNaN(Date.parse(val)), "invalid-date"),
text: z.string().optional(), text: z.string().optional(),
gpx: z.string().optional(), gpx: z.string().optional(),
distance: z.number().nonnegative().optional(), distance: z.number().nonnegative().optional(),
@@ -16,7 +16,7 @@ const SummitLogCreateSchema = z.object({
}) satisfies ZodType<SummitLog> }) satisfies ZodType<SummitLog>
const SummitLogUpdateSchema = z.object({ const SummitLogUpdateSchema = z.object({
date: z.string().date().optional(), date: z.string().date().refine((val) => !val || !isNaN(Date.parse(val)), "invalid-date").optional(),
text: z.string().optional(), text: z.string().optional(),
gpx: z.string().optional(), gpx: z.string().optional(),
distance: z.number().nonnegative().optional(), distance: z.number().nonnegative().optional(),

View File

@@ -3,10 +3,11 @@ import type { Trail } from "../trail";
const TrailCreateSchema = z.object({ const TrailCreateSchema = z.object({
name: z.string(), id: z.string().length(15).optional(),
name: z.string().min(1, "required"),
description: z.string().optional(), description: z.string().optional(),
location: z.string().optional(), location: z.string().optional(),
date: z.string().date(), date: z.string().optional().refine((val) => !val || !isNaN(Date.parse(val)), "invalid-date"),
public: z.boolean(), public: z.boolean(),
difficulty: z.enum(["easy", "moderate", "difficult"]).optional(), difficulty: z.enum(["easy", "moderate", "difficult"]).optional(),
lat: z.number().optional(), lat: z.number().optional(),
@@ -29,7 +30,7 @@ const TrailUpdateSchema = z.object({
name: z.string(), name: z.string(),
description: z.string().optional(), description: z.string().optional(),
location: z.string().optional(), location: z.string().optional(),
date: z.string().date(), date: z.string().optional().refine((val) => !val || !isNaN(Date.parse(val)), "invalid-date"),
public: z.boolean().optional(), public: z.boolean().optional(),
difficulty: z.enum(["easy", "moderate", "difficult"]).optional(), difficulty: z.enum(["easy", "moderate", "difficult"]).optional(),
lat: z.number().optional(), lat: z.number().optional(),

View File

@@ -17,8 +17,8 @@ const WaypointUpdateSchema = z.object({
name: z.string().optional(), name: z.string().optional(),
description: z.string().optional(), description: z.string().optional(),
text: z.string().optional(), text: z.string().optional(),
lat: z.number().optional(), lat: z.number({coerce: true}).optional(),
lon: z.number().optional(), lon: z.number({coerce: true}).optional(),
icon: z.string().optional(), icon: z.string().optional(),
photos: z.array(z.string()), photos: z.array(z.string()),
"photos-": z.string().optional(), "photos-": z.string().optional(),

View File

@@ -16,7 +16,7 @@ export class List {
author?: UserAnonymous; author?: UserAnonymous;
} }
author?: string; author: string;
constructor(name: string, trails: Trail[], params?: { description?: string, public?: boolean, avatar?: string, author?: string }) { constructor(name: string, trails: Trail[], params?: { description?: string, public?: boolean, avatar?: string, author?: string }) {
this.name = name; this.name = name;
@@ -24,8 +24,8 @@ export class List {
this.expand = { trails: trails }; this.expand = { trails: trails };
this.trails = trails.map(t => t.id!); this.trails = trails.map(t => t.id!);
this.description = params?.description; this.description = params?.description;
this.avatar = params?.description; this.avatar = params?.avatar;
this.author = params?.author; this.author = params?.author ?? "000000000000000";
} }
} }

View File

@@ -13,7 +13,7 @@ class SummitLog {
elevation_gain?: number elevation_gain?: number
elevation_loss?: number elevation_loss?: number
duration?: number duration?: number
author?: string; author: string;
expand?: { expand?: {
gpx_data?: string; gpx_data?: string;
@@ -33,6 +33,7 @@ class SummitLog {
this._gpx = null this._gpx = null
this.photos = params?.photos ?? [] this.photos = params?.photos ?? []
this._photos = []; this._photos = [];
this.author = "000000000000000"
} }
} }

View File

@@ -36,7 +36,7 @@ class Trail {
} }
tags?: string[]; tags?: string[];
description?: string; description?: string;
author?: string; author: string;
constructor(name: string, constructor(name: string,
params?: { params?: {
@@ -70,10 +70,10 @@ class Trail {
this.location = params?.location; this.location = params?.location;
this.date = params?.date ?? new Date().toISOString().split('T')[0]; this.date = params?.date ?? new Date().toISOString().split('T')[0];
this.public = params?.public ?? false this.public = params?.public ?? false
this.distance = params?.distance; this.distance = params?.distance ?? 0;
this.elevation_gain = params?.elevation_gain; this.elevation_gain = params?.elevation_gain ?? 0;
this.elevation_loss = params?.elevation_loss; this.elevation_loss = params?.elevation_loss ?? 0;
this.duration = params?.duration; this.duration = params?.duration ?? 0;
this.difficulty = params?.difficulty ?? "easy"; this.difficulty = params?.difficulty ?? "easy";
this.lat = params?.lat; this.lat = params?.lat;
this.lon = params?.lon; this.lon = params?.lon;
@@ -92,6 +92,7 @@ class Trail {
this.tags = params?.tags ?? [] this.tags = params?.tags ?? []
this.description = params?.description ?? ""; this.description = params?.description ?? "";
this.created = params?.created; this.created = params?.created;
this.author = "000000000000000"
} }
} }

View File

@@ -1,4 +1,3 @@
import type { Marker } from "maplibre-gl";
import * as M from "maplibre-gl"; import * as M from "maplibre-gl";
import { number, object, string } from "yup"; import { number, object, string } from "yup";
@@ -12,7 +11,7 @@ class Waypoint {
marker?: M.Marker; marker?: M.Marker;
photos: string[]; photos: string[];
_photos?: File[]; _photos?: File[];
author?: string; author: string;
constructor(lat: number, lon: number, params?: { constructor(lat: number, lon: number, params?: {
id?: string, name?: string, description?: string, icon?: string, marker?: M.Marker, photos?: string[]; id?: string, name?: string, description?: string, icon?: string, marker?: M.Marker, photos?: string[];
@@ -26,16 +25,8 @@ class Waypoint {
this.marker = params?.marker; this.marker = params?.marker;
this.photos = params?.photos ?? [] this.photos = params?.photos ?? []
this._photos = []; this._photos = [];
this.author = "000000000000000"
} }
} }
const waypointSchema = object<Waypoint>({ export { Waypoint };
id: string().optional(),
name: string().optional(),
description: string().optional(),
lat: number().required('Required').typeError('Invalid latitude'),
lon: number().required('Required').typeError('Invalid longitude'),
icon: string().optional()
});
export { Waypoint, waypointSchema };

View File

@@ -2,14 +2,14 @@ import type { SummitLog } from "$lib/models/summit_log";
import { Trail, type TrailFilter, type TrailFilterValues } from "$lib/models/trail"; import { Trail, type TrailFilter, type TrailFilterValues } from "$lib/models/trail";
import type { Waypoint } from "$lib/models/waypoint"; import type { Waypoint } from "$lib/models/waypoint";
import { pb } from "$lib/pocketbase"; import { pb } from "$lib/pocketbase";
import { deepEqual } from "$lib/util/deep_util";
import { getFileURL } from "$lib/util/file_util"; import { getFileURL } from "$lib/util/file_util";
import { util } from "$lib/vendor/svelte-form-lib/util";
import * as M from "maplibre-gl"; import * as M from "maplibre-gl";
import type { Hits } from "meilisearch";
import { ClientResponseError, type ListResult } from "pocketbase"; import { ClientResponseError, type ListResult } from "pocketbase";
import { writable, type Writable } from "svelte/store"; import { writable, type Writable } from "svelte/store";
import { summit_logs_create, summit_logs_delete, summit_logs_update } from "./summit_log_store"; import { summit_logs_create, summit_logs_delete, summit_logs_update } from "./summit_log_store";
import { waypoints_create, waypoints_delete, waypoints_update } from "./waypoint_store"; import { waypoints_create, waypoints_delete, waypoints_update } from "./waypoint_store";
import type { Hits, SearchResponse } from "meilisearch";
let trails: Trail[] = [] let trails: Trail[] = []
export const trail: Writable<Trail> = writable(new Trail("")); export const trail: Writable<Trail> = writable(new Trail(""));
@@ -469,7 +469,7 @@ function compareObjectArrays<T extends { id?: string }>(oldArray: T[], newArray:
const oldObj = oldArray.find(oldObj => oldObj.id === newObj.id) const oldObj = oldArray.find(oldObj => oldObj.id === newObj.id)
if (!oldObj) { if (!oldObj) {
newObjects.push(newObj); newObjects.push(newObj);
} else if (!util.deepEqual(newObj, oldObj)) { } else if (!deepEqual(newObj, oldObj)) {
updatedObjects.push(newObj); updatedObjects.push(newObj);
} else { } else {
unchangedObjects.push(newObj); unchangedObjects.push(newObj);

View File

@@ -0,0 +1,37 @@
const has = Object.prototype.hasOwnProperty;
export function deepEqual(foo: any, bar: any) {
var ctor, len;
if (foo === bar) return true;
if (foo && bar && (ctor = foo.constructor) === bar.constructor) {
if (ctor === Date) return foo.getTime() === bar.getTime();
if (ctor === RegExp) return foo.toString() === bar.toString();
if (ctor === Array) {
if ((len = foo.length) === bar.length) {
while (len-- && deepEqual(foo[len], bar[len]));
}
return len === -1;
}
if (!ctor || typeof foo === 'object') {
len = 0;
for (ctor in foo) {
if (has.call(foo, ctor) && ++len && !has.call(bar, ctor)) return false;
if (!(ctor in bar) || !deepEqual(foo[ctor], bar[ctor])) return false;
}
return Object.keys(bar).length === len;
}
}
return foo !== foo && bar !== bar;
}
export function cloneDeep(foo: any) {
try {
return structuredClone(foo);
} catch (e) {
return foo;
}
}

View File

@@ -1,261 +0,0 @@
import {derived, writable, get} from 'svelte/store';
import {util} from './util';
const NO_ERROR = '';
const IS_TOUCHED = true;
function isCheckbox(element) {
return element.getAttribute && element.getAttribute('type') === 'checkbox';
}
function isFileInput(element) {
return element.getAttribute && element.getAttribute('type') === 'file';
}
function resolveValue(element) {
if (isFileInput(element)) {
return element.files;
} else if (isCheckbox(element)) {
return element.checked;
} else {
return element.value;
}
}
export const createForm = (config) => {
let initialValues = config.initialValues || {};
const validationSchema = config.validationSchema;
const validateFunction = config.validate;
const onSubmit = config.onSubmit;
const onError = config.onError;
const getInitial = {
values: () => util.cloneDeep(initialValues),
errors: () =>
validationSchema
? util.getErrorsFromSchema(initialValues, validationSchema.fields)
: util.assignDeep(initialValues, NO_ERROR),
touched: () => util.assignDeep(initialValues, !IS_TOUCHED),
};
const form = writable(getInitial.values());
const errors = writable(getInitial.errors());
const touched = writable(getInitial.touched());
const isSubmitting = writable(false);
const isValidating = writable(false);
const isValid = derived(errors, ($errors) => {
const noErrors = util
.getValues($errors)
.every((field) => field === NO_ERROR);
return noErrors;
});
const modified = derived(form, ($form) => {
const object = util.assignDeep($form, false);
for (let key in $form) {
object[key] = !util.deepEqual($form[key], initialValues[key]);
}
return object;
});
const isModified = derived(modified, ($modified) => {
return util.getValues($modified).includes(true);
});
function validateField(field) {
return util
.subscribeOnce(form)
.then((values) => validateFieldValue(field, values[field]));
}
function validateFieldValue(field, value) {
updateTouched(field, true);
if (validationSchema) {
isValidating.set(true);
return validationSchema
.validateAt(field, get(form))
.then(() => util.update(errors, field, ''))
.catch((error) => util.update(errors, field, error.message))
.finally(() => {
isValidating.set(false);
});
}
if (validateFunction) {
isValidating.set(true);
return Promise.resolve()
.then(() => validateFunction({[field]: value}))
.then((errs) =>
util.update(errors, field, !util.isNullish(errs) ? errs[field] : ''),
)
.finally(() => {
isValidating.set(false);
});
}
return Promise.resolve();
}
function updateValidateField(field, value) {
updateField(field, value);
return validateFieldValue(field, value);
}
function handleChange(event) {
const element = event.target;
const field = element.name || element.id;
const value = resolveValue(element);
return updateValidateField(field, value);
}
function handleSubmit(event) {
if (event && event.preventDefault) {
event.preventDefault();
}
isSubmitting.set(true);
return util.subscribeOnce(form).then((values) => {
if (typeof validateFunction === 'function') {
isValidating.set(true);
return Promise.resolve()
.then(() => validateFunction(values))
.then((error) => {
if (util.isNullish(error) || util.getValues(error).length === 0) {
return clearErrorsAndSubmit(values);
} else {
errors.set(error);
if(onError) {
onError(error)
}
isSubmitting.set(false);
}
})
.finally(() => isValidating.set(false));
}
if (validationSchema) {
isValidating.set(true);
return (
validationSchema
.validate(values, {abortEarly: false})
.then(() => clearErrorsAndSubmit(values))
// eslint-disable-next-line unicorn/catch-error-name
.catch((yupErrors) => {
if (yupErrors && yupErrors.inner) {
const updatedErrors = getInitial.errors();
yupErrors.inner.map((error) =>
util.set(updatedErrors, error.path, error.message),
);
if(onError) {
onError(updatedErrors)
}
errors.set(updatedErrors);
}
isSubmitting.set(false);
})
.finally(() => isValidating.set(false))
);
}
return clearErrorsAndSubmit(values);
});
}
function handleReset() {
form.set(getInitial.values());
errors.set(getInitial.errors());
touched.set(getInitial.touched());
}
function clearErrorsAndSubmit(values) {
return Promise.resolve()
.then(() => errors.set(getInitial.errors()))
.then(() => onSubmit(values, form, errors))
.finally(() => isSubmitting.set(false));
}
/**
* Handler to imperatively update the value of a form field
*/
function updateField(field, value) {
util.update(form, field, value);
}
/**
* Handler to imperatively update the touched value of a form field
*/
function updateTouched(field, value) {
util.update(touched, field, value);
}
/**
* Update the initial values and reset form. Used to dynamically display new form values
*/
function updateInitialValues(newValues) {
initialValues = newValues;
handleReset();
}
return {
form,
errors,
touched,
modified,
isValid,
isSubmitting,
isValidating,
isModified,
handleChange,
handleSubmit,
handleReset,
updateField,
updateValidateField,
updateTouched,
validateField,
updateInitialValues,
state: derived(
[
form,
errors,
touched,
modified,
isValid,
isValidating,
isSubmitting,
isModified,
],
([
$form,
$errors,
$touched,
$modified,
$isValid,
$isValidating,
$isSubmitting,
$isModified,
]) => ({
form: $form,
errors: $errors,
touched: $touched,
modified: $modified,
isValid: $isValid,
isSubmitting: $isSubmitting,
isValidating: $isValidating,
isModified: $isModified,
}),
),
};
};

View File

@@ -1,103 +0,0 @@
/// <reference lib="svelte2tsx" />
import type {SvelteComponentTyped} from 'svelte';
import type {Readable, Writable} from 'svelte/store';
import type {ObjectSchema} from 'yup';
export type FormProps<Inf = Record<string, unknown>> = {
context?: FormState;
initialValues?: Inf;
onSubmit?: ((values: Inf) => any) | ((values: Inf) => Promise<any>);
validate?: (values: Inf) => any | undefined;
validationSchema?: ObjectSchema<any>;
} & svelte.JSX.HTMLAttributes<HTMLFormElement>;
type FieldProperties = {
name: string;
type?: string;
value?: string;
} & svelte.JSX.HTMLProps<HTMLInputElement>;
type SelectProperties = {
name: string;
} & svelte.JSX.HTMLProps<HTMLSelectElement>;
type ErrorProperties = {
name: string;
} & svelte.JSX.HTMLProps<HTMLDivElement>;
type TextareaProperties = {
name: string;
} & svelte.JSX.HTMLProps<HTMLTextAreaElement>;
type FormState<Inf = Record<string, any>> = {
form: Writable<Inf>;
errors: Writable<Record<keyof Inf, string>>;
touched: Writable<Record<keyof Inf, boolean>>;
modified: Readable<Record<keyof Inf, boolean>>;
isValid: Readable<boolean>;
isSubmitting: Writable<boolean>;
isValidating: Writable<boolean>;
isModified: Readable<boolean>;
updateField: (field: keyof Inf, value: any) => void;
updateValidateField: (field: keyof Inf, value: any) => void;
updateTouched: (field: keyof Inf, value: any) => void;
validateField: (field: keyof Inf) => Promise<any>;
updateInitialValues: (newValues: Inf) => void;
handleReset: () => void;
state: Readable<{
form: Inf;
errors: Record<keyof Inf, string>;
touched: Record<keyof Inf, boolean>;
modified: Record<keyof Inf, boolean>;
isValid: boolean;
isSubmitting: boolean;
isValidating: boolean;
isModified: boolean;
}>;
handleChange: (event: Event) => any;
handleSubmit: (event: Event) => any;
};
declare function createForm<Inf = Record<string, any>>(formProperties: {
initialValues: Inf;
onSubmit: (values: Inf) => any | Promise<any>;
onError?: (errors: Record<string, any>) => any | Promise<any>;
validate?: (values: Inf) => any | undefined;
validationSchema?: ObjectSchema<any>;
}): FormState<Inf>;
declare class Form extends SvelteComponentTyped<
FormProps,
Record<string, unknown>,
{
default: FormState;
}
> {}
declare class Field extends SvelteComponentTyped<
FieldProperties,
Record<string, unknown>,
Record<string, unknown>
> {}
declare class Textarea extends SvelteComponentTyped<
TextareaProperties,
Record<string, unknown>,
Record<string, unknown>
> {}
declare class Select extends SvelteComponentTyped<
SelectProperties,
Record<string, unknown>,
{default: any}
> {}
declare class ErrorMessage extends SvelteComponentTyped<
ErrorProperties,
Record<string, unknown>,
{default: any}
> {}
declare const key: {};
export {createForm, key, Form, Field, Select, ErrorMessage, Textarea};

View File

@@ -1 +0,0 @@
export {createForm} from './create-form';

View File

@@ -1,134 +0,0 @@
import { dequal as isEqual } from 'dequal/lite';
function subscribeOnce(observable) {
return new Promise((resolve) => {
observable.subscribe(resolve)(); // immediately invoke to unsubscribe
});
}
function update(object, path, value) {
object.update((o) => {
set(o, path, value);
return o;
});
}
function cloneDeep(object) {
try {
return structuredClone(object);
} catch (e) {
return object;
}
}
function isNullish(value) {
return value === undefined || value === null;
}
function isEmpty(object) {
return isNullish(object) || Object.keys(object).length <= 0;
}
function getValues(object) {
let results = [];
for (const [, value] of Object.entries(object)) {
const values = typeof value === 'object' ? getValues(value) : [value];
results = [...results, ...values];
}
return results;
}
// TODO: refactor this so as not to rely directly on yup's API
// This should use dependency injection, with a default callback which may assume
// yup as the validation schema
function getErrorsFromSchema(initialValues, schema, errors = {}) {
for (const key in schema) {
switch (true) {
case schema[key].type === 'object' && !isEmpty(schema[key].fields): {
errors[key] = getErrorsFromSchema(
initialValues[key],
schema[key].fields,
{ ...errors[key] },
);
break;
}
case schema[key].type === 'array': {
const values =
initialValues && initialValues[key] ? initialValues[key] : [];
errors[key] = values.map((value) => {
const innerError = getErrorsFromSchema(
value,
schema[key].innerType.fields,
{ ...errors[key] },
);
return Object.keys(innerError).length > 0 ? innerError : '';
});
break;
}
default: {
errors[key] = '';
}
}
}
return errors;
}
const deepEqual = isEqual;
function assignDeep(object, value) {
if (Array.isArray(object)) {
return object.map((o) => assignDeep(o, value));
}
const copy = {};
for (const key in object) {
copy[key] =
typeof object[key] === 'object' && !isNullish(object[key]) ? assignDeep(object[key], value) : value;
}
return copy;
}
function set(object, path, value) {
if (new Object(object) !== object) return object;
if (!Array.isArray(path)) {
path = path.toString().match(/[^.[\]]+/g) || [];
}
const result = path
.slice(0, -1)
// TODO: replace this reduce with something more readable
// eslint-disable-next-line unicorn/no-array-reduce
.reduce(
(accumulator, key, index) =>
new Object(accumulator[key]) === accumulator[key]
? accumulator[key]
: (accumulator[key] =
Math.trunc(Math.abs(path[index + 1])) === +path[index + 1]
? []
: {}),
object,
);
result[path[path.length - 1]] = value;
return object;
}
export const util = {
assignDeep,
cloneDeep,
deepEqual,
getErrorsFromSchema,
getValues,
isEmpty,
isNullish,
set,
subscribeOnce,
update,
};

View File

@@ -7,9 +7,11 @@ import { json, type RequestEvent } from "@sveltejs/kit";
export async function GET(event: RequestEvent) { export async function GET(event: RequestEvent) {
try { try {
const r = await show<Comment>(event, Collection.comments) const r = await show<Comment>(event, Collection.comments)
r.expand = { if (!r.expand) {
author: await pb.collection('users_anonymous').getOne(r.author) r.expand = {} as any
} }
r.expand!.author = await pb.collection('users_anonymous').getOne(r.author)
return json(r) return json(r)
} catch (e: any) { } catch (e: any) {
throw handleError(e) throw handleError(e)

View File

@@ -7,9 +7,11 @@ import { json, type RequestEvent } from "@sveltejs/kit";
export async function GET(event: RequestEvent) { export async function GET(event: RequestEvent) {
try { try {
const r = await show<List>(event, Collection.lists) const r = await show<List>(event, Collection.lists)
r.expand = { if (!r.expand) {
author: await pb.collection('users_anonymous').getOne(r.author!) r.expand = {} as any
} }
r.expand!.author = await pb.collection('users_anonymous').getOne(r.author!)
return json(r) return json(r)
} catch (e: any) { } catch (e: any) {
throw handleError(e) throw handleError(e)

View File

@@ -5,29 +5,41 @@
import TextField from "$lib/components/base/text_field.svelte"; import TextField from "$lib/components/base/text_field.svelte";
import LogoTextTwoLineDark from "$lib/components/logo/logo_text_two_line_dark.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 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 { theme } from "$lib/stores/theme_store";
import { show_toast } from "$lib/stores/toast_store"; import { show_toast } from "$lib/stores/toast_store";
import { import { users_confirm_reset } from "$lib/stores/user_store";
users_confirm_reset, import { validator } from "@felte/validator-zod";
users_reset_password, import { createForm } from "felte";
} from "$lib/stores/user_store";
import { createForm } from "$lib/vendor/svelte-form-lib";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { object, ref, string } from "yup"; import { z } from "zod";
let loading: boolean = false; let loading: boolean = false;
const { form, errors, handleChange, handleSubmit } = createForm({ const { form, errors } = createForm({
initialValues: { initialValues: {
password: "", password: "",
passwordConfirm: "", passwordConfirm: "",
}, },
validationSchema: object({ extend: validator({
password: string().required($_("required")), schema: z
passwordConfirm: string() .object({
.required($_("required")) password: z.string().min(
.oneOf([ref("password"), ""], $_('passwords-must-match')), 8,
$_("must-be-at-least-n-characters-long", {
values: { n: 8 },
}),
),
passwordConfirm: z.string().min(
8,
$_("must-be-at-least-n-characters-long", {
values: { n: 8 },
}),
),
})
.refine((d) => d.password === d.passwordConfirm, {
message: "passwords-must-match",
path: ["passwordConfirm"],
}),
}), }),
onSubmit: async (form) => { onSubmit: async (form) => {
loading = true; loading = true;
@@ -61,7 +73,7 @@
<main class="flex justify-center"> <main class="flex justify-center">
<form <form
class="login-panel max-w-md border border-input-border rounded-xl p-8 flex flex-col justify-center items-center gap-4 w-[28rem] mt-8" class="login-panel max-w-md border border-input-border rounded-xl p-8 flex flex-col justify-center items-center gap-4 w-[28rem] mt-8"
on:submit={handleSubmit} use:form
> >
{#if $theme == "light"} {#if $theme == "light"}
<LogoTextTwoLineDark></LogoTextTwoLineDark> <LogoTextTwoLineDark></LogoTextTwoLineDark>
@@ -74,16 +86,12 @@
name="password" name="password"
label={$_("password")} label={$_("password")}
type="password" type="password"
bind:value={$form.password}
on:change={handleChange}
error={$errors.password} error={$errors.password}
></TextField> ></TextField>
<TextField <TextField
name="password" name="passwordConfirm"
label={$_("password-confirm")} label={$_("password-confirm")}
type="password" type="password"
bind:value={$form.passwordConfirm}
on:change={handleChange}
error={$errors.passwordConfirm} error={$errors.passwordConfirm}
></TextField> ></TextField>

View File

@@ -4,22 +4,27 @@
import TextField from "$lib/components/base/text_field.svelte"; import TextField from "$lib/components/base/text_field.svelte";
import LogoTextTwoLineDark from "$lib/components/logo/logo_text_two_line_dark.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 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 { theme } from "$lib/stores/theme_store";
import { show_toast } from "$lib/stores/toast_store"; import { show_toast } from "$lib/stores/toast_store";
import { users_reset_password } from "$lib/stores/user_store"; import { users_reset_password } from "$lib/stores/user_store";
import { createForm } from "$lib/vendor/svelte-form-lib"; import { validator } from "@felte/validator-zod";
import { createForm } from "felte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { object, string } from "yup"; import { z } from "zod";
let loading: boolean = false; let loading: boolean = false;
const { form, errors, handleChange, handleSubmit } = createForm({ const { form, errors } = createForm({
initialValues: { initialValues: {
email: "", email: "",
}, },
validationSchema: object<User>({ extend: validator({
email: string().required($_("required")), schema: z.object({
email: z
.string()
.min(1, "required")
.email("not-a-valid-email-address"),
}),
}), }),
onSubmit: async (email) => { onSubmit: async (email) => {
loading = true; loading = true;
@@ -50,7 +55,7 @@
<main class="flex justify-center"> <main class="flex justify-center">
<form <form
class="login-panel max-w-md border border-input-border rounded-xl p-8 flex flex-col justify-center items-center gap-4 w-[28rem] mt-8" class="login-panel max-w-md border border-input-border rounded-xl p-8 flex flex-col justify-center items-center gap-4 w-[28rem] mt-8"
on:submit={handleSubmit} use:form
> >
{#if $theme == "light"} {#if $theme == "light"}
<LogoTextTwoLineDark></LogoTextTwoLineDark> <LogoTextTwoLineDark></LogoTextTwoLineDark>
@@ -63,8 +68,6 @@
<TextField <TextField
name="email" name="email"
label={$_("email")} label={$_("email")}
bind:value={$form.email}
on:change={handleChange}
error={$errors.email} error={$errors.email}
></TextField> ></TextField>
<div class="grid grid-cols-2 gap-x-4"> <div class="grid grid-cols-2 gap-x-4">

View File

@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { listSchema, type List } from "$lib/models/list"; import { type List } from "$lib/models/list";
import { createForm } from "$lib/vendor/svelte-form-lib/index";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { page } from "$app/stores"; import { page } from "$app/stores";
@@ -10,8 +9,12 @@
} from "$lib/components/base/search.svelte"; } from "$lib/components/base/search.svelte";
import TextField from "$lib/components/base/text_field.svelte"; import TextField from "$lib/components/base/text_field.svelte";
import Textarea from "$lib/components/base/textarea.svelte"; import Textarea from "$lib/components/base/textarea.svelte";
import Toggle from "$lib/components/base/toggle.svelte";
import ConfirmModal from "$lib/components/confirm_modal.svelte"; import ConfirmModal from "$lib/components/confirm_modal.svelte";
import MapWithElevationMaplibre from "$lib/components/trail/map_with_elevation_maplibre.svelte"; import MapWithElevationMaplibre from "$lib/components/trail/map_with_elevation_maplibre.svelte";
import { ListCreateSchema } from "$lib/models/api/list_schema.js";
import { ListShareCreateSchema } from "$lib/models/api/list_share_schema.js";
import { TrailCreateSchema } from "$lib/models/api/trail_schema.js";
import type { Trail } from "$lib/models/trail.js"; import type { Trail } from "$lib/models/trail.js";
import { TrailShare } from "$lib/models/trail_share.js"; import { TrailShare } from "$lib/models/trail_share.js";
import { lists_create, lists_update } from "$lib/stores/list_store.js"; import { lists_create, lists_update } from "$lib/stores/list_store.js";
@@ -21,14 +24,16 @@
trail_share_index, trail_share_index,
} from "$lib/stores/trail_share_store.js"; } from "$lib/stores/trail_share_store.js";
import { trails_show, trails_update } from "$lib/stores/trail_store"; import { trails_show, trails_update } from "$lib/stores/trail_store";
import { currentUser } from "$lib/stores/user_store.js";
import { getFileURL } from "$lib/util/file_util.js"; import { getFileURL } from "$lib/util/file_util.js";
import { import {
formatDistance, formatDistance,
formatElevation, formatElevation,
formatTimeHHMM, formatTimeHHMM,
} from "$lib/util/format_util"; } from "$lib/util/format_util";
import Toggle from "$lib/components/base/toggle.svelte"; import { validator } from "@felte/validator-zod";
import { currentUser } from "$lib/stores/user_store.js"; import { createForm } from "felte";
import { z } from "zod";
export let data; export let data;
@@ -46,13 +51,32 @@
let openConfirmModal: () => void; let openConfirmModal: () => void;
let openPublishConfirmModal: () => void; let openPublishConfirmModal: () => void;
const { form, errors, handleChange, handleSubmit } = createForm<List>({ const ClientListCreateSchema = ListCreateSchema.extend({
_photos: z.array(z.instanceof(File)).optional(),
avatar: z.string().or(z.instanceof(File)).optional(),
expand: z
.object({
trails: z.array(TrailCreateSchema).optional(),
list_share_via_list: z.array(ListShareCreateSchema).optional(),
})
.optional(),
});
const {
form,
errors,
data: formData,
} = createForm<z.infer<typeof ClientListCreateSchema>>({
initialValues: { initialValues: {
...data.list!, ...data.list,
public: $page.data.settings?.privacy.lists === "public", public: data.list.id
? data.list.public
: $page.data.settings?.privacy.lists === "public",
}, },
validationSchema: listSchema, extend: validator({
onSubmit: async (submittedList) => { schema: ClientListCreateSchema,
}),
onSubmit: async (form) => {
if (await checkPrerequisites()) { if (await checkPrerequisites()) {
await saveList(); await saveList();
} }
@@ -61,10 +85,10 @@
async function checkPrerequisites() { async function checkPrerequisites() {
if ( if (
(data.list?.public === false && $form.public === true) || (data.list?.public === false && $formData.public === true) ||
($form.public === true && ($formData.public === true &&
data.list?.expand?.trails?.length !== data.list?.expand?.trails?.length !==
$form.expand?.trails?.length) $formData.expand?.trails?.length)
) { ) {
openPublishConfirmModal(); openPublishConfirmModal();
return false; return false;
@@ -82,11 +106,14 @@
).files![0]; ).files![0];
loading = true; loading = true;
try { try {
if ($form.id) { if ($formData.id) {
await lists_update($form, avatarFile); await lists_update($formData as List, avatarFile);
} else { } else {
const createdList = await lists_create($form, avatarFile); const createdList = await lists_create(
$form.id = createdList.id; $formData as List,
avatarFile,
);
$formData.id = createdList.id;
} }
show_toast({ show_toast({
type: "success", type: "success",
@@ -135,7 +162,7 @@
const response = await r.json(); const response = await r.json();
searchDropdownItems = response.hits searchDropdownItems = response.hits
.filter((h: List) => !$form.trails?.includes(h.id!)) .filter((h: List) => !$formData.trails?.includes(h.id!))
.map((t: Record<string, any>) => ({ .map((t: Record<string, any>) => ({
text: t.name, text: t.name,
description: `${t.location ?? "-"}`, description: `${t.location ?? "-"}`,
@@ -146,30 +173,30 @@
async function handleSearchClick(item: SearchItem) { async function handleSearchClick(item: SearchItem) {
const trail = await trails_show(item.value, true); const trail = await trails_show(item.value, true);
$form.trails?.push(trail.id!); $formData.trails?.push(trail.id!);
$form.expand = { $formData.expand = {
trails: [...($form.expand?.trails ?? []), trail], trails: [...($formData.expand?.trails ?? []), trail],
list_share_via_list: $form.expand?.list_share_via_list, list_share_via_list: $formData.expand?.list_share_via_list,
}; };
} }
function deleteTrail(trail: Trail) { function deleteTrail(trail: Trail) {
$form.trails = $form.trails?.filter((id) => id !== trail.id); $formData.trails = $formData.trails?.filter((id) => id !== trail.id);
$form.expand!.trails = $form.expand!.trails!.filter( $formData.expand!.trails = $formData.expand!.trails!.filter(
(t) => t.id !== trail.id, (t) => t.id !== trail.id,
); );
} }
async function findNewTrailShares() { async function findNewTrailShares() {
const usersWithAccess: string[] = [ const usersWithAccess: string[] = [
$form.author!, $formData.author!,
...($form.expand?.list_share_via_list ?? []).map((s) => s.user), ...($formData.expand?.list_share_via_list ?? []).map((s) => s.user),
]; ];
for (const userId of usersWithAccess) { for (const userId of usersWithAccess) {
const existingTrailShares = await trail_share_index({ const existingTrailShares = await trail_share_index({
user: userId, user: userId,
}); });
for (const trail of $form.expand?.trails ?? []) { for (const trail of $formData.expand?.trails ?? []) {
if ( if (
trail.author == userId || trail.author == userId ||
trail.author != $currentUser?.id trail.author != $currentUser?.id
@@ -195,7 +222,7 @@
} }
async function publishTrails() { async function publishTrails() {
for (const trail of $form.expand?.trails ?? []) { for (const trail of $formData.expand?.trails ?? []) {
if (trail.author !== $currentUser?.id) { if (trail.author !== $currentUser?.id) {
continue; continue;
} }
@@ -207,27 +234,32 @@
function moveTrail(trail: Trail, index: number, direction: 1 | -1) { function moveTrail(trail: Trail, index: number, direction: 1 | -1) {
(document?.activeElement as HTMLElement).blur(); (document?.activeElement as HTMLElement).blur();
if (!$form.trails || !$form.expand || !$form.expand.trails) { if (
!$formData.trails ||
!$formData.expand ||
!$formData.expand.trails
) {
return; return;
} }
const previousTrail = $form.expand?.trails?.at(index + direction); const previousTrail = $formData.expand?.trails?.at(index + direction);
const previousTrailId = $form.trails?.at(index + direction); const previousTrailId = $formData.trails?.at(index + direction);
if (!previousTrail || !previousTrailId) { if (!previousTrail || !previousTrailId) {
return; return;
} }
$form.expand!.trails![index] = previousTrail; $formData.expand!.trails![index] = previousTrail;
$form.expand!.trails![index + direction] = trail; $formData.expand!.trails![index + direction] = trail;
$form.trails[index] = previousTrailId; $formData.trails[index] = previousTrailId;
$form.trails[index + direction] = trail.id!; $formData.trails[index + direction] = trail.id!;
} }
</script> </script>
<svelte:head> <svelte:head>
<title <title
>{$form.id ? `${$form.name} | ${$_("edit")}` : $_("new-list")} | wanderer</title >{$formData.id ? `${$formData.name} | ${$_("edit")}` : $_("new-list")} |
wanderer</title
> >
</svelte:head> </svelte:head>
@@ -235,7 +267,7 @@
<form <form
id="list-form" id="list-form"
class="flex flex-col gap-4 px-8 order-1 md:order-none mt-8 md:mt-0 overflow-y-scroll" class="flex flex-col gap-4 px-8 order-1 md:order-none mt-8 md:mt-0 overflow-y-scroll"
on:submit={handleSubmit} use:form
> >
<h2 class="text-2xl font-semibold"> <h2 class="text-2xl font-semibold">
{$page.params.id === "new" ? $_("new-list") : $_("edit-list")} {$page.params.id === "new" ? $_("new-list") : $_("edit-list")}
@@ -270,23 +302,15 @@
> >
</div> </div>
<TextField <TextField name="name" label={$_("name")} error={$errors.name}
name="name"
label={$_("name")}
bind:value={$form.name}
error={$errors.name}
on:change={handleChange}
></TextField> ></TextField>
<Textarea <Textarea
name="description" name="description"
label={$_("description")} label={$_("description")}
bind:value={$form.description}
error={$errors.description} error={$errors.description}
on:change={handleChange}
></Textarea> ></Textarea>
<Toggle name="public" label={$_("public")} bind:value={$form.public} <Toggle name="public" label={$_("public")}></Toggle>
></Toggle>
<h3 class="text-xl font-semibold"> <h3 class="text-xl font-semibold">
{$_("trail", { values: { n: 2 } })} {$_("trail", { values: { n: 2 } })}
</h3> </h3>
@@ -296,8 +320,8 @@
placeholder="{$_('search-trails')}..." placeholder="{$_('search-trails')}..."
items={searchDropdownItems} items={searchDropdownItems}
></Search> ></Search>
{#if $form.expand?.trails?.length} {#if $formData.expand?.trails?.length}
{#each $form.expand?.trails ?? [] as trail, i} {#each $formData.expand?.trails ?? [] as trail, i}
<div <div
class="flex gap-4 p-4 rounded-xl border border-input-border cursor-pointer hover:bg-secondary-hover transition-colors items-center" class="flex gap-4 p-4 rounded-xl border border-input-border cursor-pointer hover:bg-secondary-hover transition-colors items-center"
class:bg-secondary-hover={i == activeTrailIndex} class:bg-secondary-hover={i == activeTrailIndex}
@@ -312,7 +336,7 @@
src={trail.photos.length src={trail.photos.length
? getFileURL( ? getFileURL(
trail, trail,
trail.photos[trail.thumbnail], trail.photos[trail.thumbnail ?? 0],
) )
: "/imgs/default_thumbnail.webp"} : "/imgs/default_thumbnail.webp"}
alt="" alt=""
@@ -363,7 +387,7 @@
<i class="fa fa-chevron-up"></i> <i class="fa fa-chevron-up"></i>
</button> </button>
{/if} {/if}
{#if i < $form.expand?.trails?.length - 1} {#if i < $formData.expand?.trails?.length - 1}
<button <button
on:click|stopPropagation={() => on:click|stopPropagation={() =>
moveTrail(trail, i, 1)} moveTrail(trail, i, 1)}
@@ -384,7 +408,7 @@
{/each} {/each}
{:else} {:else}
<span class="text-center text-sm text-gray-500 my-8" <span class="text-center text-sm text-gray-500 my-8"
>No routes added</span >{$_("no-routes-added")}</span
> >
{/if} {/if}
<Button <Button
@@ -397,7 +421,7 @@
</form> </form>
<div id="trail-map" class="max-h-full"> <div id="trail-map" class="max-h-full">
<MapWithElevationMaplibre <MapWithElevationMaplibre
trails={$form.expand?.trails ?? []} trails={$formData.expand?.trails ?? []}
bind:activeTrail={activeTrailIndex} bind:activeTrail={activeTrailIndex}
bind:this={map} bind:this={map}
></MapWithElevationMaplibre> ></MapWithElevationMaplibre>

View File

@@ -22,8 +22,9 @@ export const load: Load = async ({ params, fetch, data }) => {
} catch (e) { } catch (e) {
if (e instanceof ClientResponseError) { if (e instanceof ClientResponseError) {
return error(e.status as any, e.message) throw error(e.status as any, e.message)
} }
throw e
} }
} }

View File

@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { goto, invalidate, invalidateAll } from "$app/navigation";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { env } from "$env/dynamic/public"; import { env } from "$env/dynamic/public";
import Button from "$lib/components/base/button.svelte"; import Button from "$lib/components/base/button.svelte";
@@ -10,10 +9,11 @@
import { theme } from "$lib/stores/theme_store"; import { theme } from "$lib/stores/theme_store";
import { show_toast } from "$lib/stores/toast_store"; import { show_toast } from "$lib/stores/toast_store";
import { login } from "$lib/stores/user_store"; import { login } from "$lib/stores/user_store";
import { createForm } from "$lib/vendor/svelte-form-lib"; import { validator } from "@felte/validator-zod";
import { createForm } from "felte";
import { ClientResponseError, type AuthProviderInfo } from "pocketbase"; import { ClientResponseError, type AuthProviderInfo } from "pocketbase";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { object, string } from "yup"; import { z } from "zod";
let loading: boolean = false; let loading: boolean = false;
@@ -31,18 +31,21 @@
loginLabel = `${$_("email")}`; loginLabel = `${$_("email")}`;
} }
const { form, errors, handleChange, handleSubmit } = createForm<User>({ const { form, errors, data } = createForm<User>({
initialValues: { initialValues: {
id: "", id: "",
username: "", username: "",
password: "", password: "",
}, },
validationSchema: object<User>({ extend: validator({
username: string().required($_("required")), schema: z.object({
password: string().required($_("required")), username: z.string().min(1, "required"),
password: z.string().min(1, "required"),
}),
}), }),
onSubmit: async (newUser) => { onSubmit: async (newUser) => {
loading = true; loading = true;
try { try {
await login(newUser); await login(newUser);
window.location.href = $page.url.searchParams.get("r") ?? "/"; window.location.href = $page.url.searchParams.get("r") ?? "/";
@@ -80,7 +83,7 @@
<main class="flex justify-center"> <main class="flex justify-center">
<form <form
class="login-panel max-w-md border border-input-border rounded-xl p-8 flex flex-col justify-center items-center gap-4 w-[28rem] mt-8" class="login-panel max-w-md border border-input-border rounded-xl p-8 flex flex-col justify-center items-center gap-4 w-[28rem] mt-8"
on:submit={handleSubmit} use:form
> >
{#if $theme == "light"} {#if $theme == "light"}
<LogoTextTwoLineDark></LogoTextTwoLineDark> <LogoTextTwoLineDark></LogoTextTwoLineDark>
@@ -93,8 +96,6 @@
<TextField <TextField
name="username" name="username"
label={loginLabel} label={loginLabel}
bind:value={$form.username}
on:change={handleChange}
error={$errors.username} error={$errors.username}
></TextField> ></TextField>
<div class="flex flex-col"> <div class="flex flex-col">
@@ -102,8 +103,6 @@
name="password" name="password"
label={$_("password")} label={$_("password")}
type="password" type="password"
bind:value={$form.password}
on:change={handleChange}
error={$errors.password} error={$errors.password}
></TextField> ></TextField>
<a <a

View File

@@ -8,38 +8,38 @@
import { theme } from "$lib/stores/theme_store"; import { theme } from "$lib/stores/theme_store";
import { show_toast } from "$lib/stores/toast_store"; import { show_toast } from "$lib/stores/toast_store";
import { login, users_create } from "$lib/stores/user_store"; import { login, users_create } from "$lib/stores/user_store";
import { createForm } from "$lib/vendor/svelte-form-lib"; import { validator } from "@felte/validator-zod";
import { createForm } from "felte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { object, string } from "yup"; import { z } from "zod";
let loading: boolean = false; let loading: boolean = false;
const { form, errors, handleChange, handleSubmit } = createForm<User>({ const { form, errors } = createForm<User>({
initialValues: { initialValues: {
id: "", id: "",
username: "", username: "",
email: "", email: "",
password: "", password: "",
}, },
validationSchema: object<User>({ extend: validator({
username: string() schema: z.object({
.min( username: z.string().min(
3, 3,
$_("must-be-at-least-n-characters-long", { $_("must-be-at-least-n-characters-long", {
values: { n: 3 }, values: { n: 3 },
}), }),
) ),
.required($_("required")) email: z
.matches(/^[\w][\w\.]*$/, { message: $_("invalid-username") }), .string()
email: string() .min(1, "required")
.email($_("not-a-valid-email-address")) .email("not-a-valid-email-address"),
.required($_("required")), password: z.string().min(
password: string()
.min(
8, 8,
$_("must-be-at-least-n-characters-long", { $_("must-be-at-least-n-characters-long", {
values: { n: 8 }, values: { n: 8 },
}), }),
) ),
.required($_("required")), }),
}), }),
onSubmit: async (newUser) => { onSubmit: async (newUser) => {
loading = true; loading = true;
@@ -77,7 +77,7 @@
<main class="flex justify-center"> <main class="flex justify-center">
<form <form
class="login-panel max-w-md border border-input-border rounded-xl p-8 flex flex-col justify-center items-center gap-8 w-[28rem] mt-8" class="login-panel max-w-md border border-input-border rounded-xl p-8 flex flex-col justify-center items-center gap-8 w-[28rem] mt-8"
on:submit={handleSubmit} use:form
> >
{#if $theme == "light"} {#if $theme == "light"}
<LogoTextTwoLineDark></LogoTextTwoLineDark> <LogoTextTwoLineDark></LogoTextTwoLineDark>
@@ -89,23 +89,14 @@
<TextField <TextField
name="username" name="username"
label={$_("username")} label={$_("username")}
bind:value={$form.username}
on:change={handleChange}
error={$errors.username} error={$errors.username}
></TextField> ></TextField>
<TextField <TextField name="email" label={$_("email")} error={$errors.email}
name="email"
label={$_("email")}
bind:value={$form.email}
on:change={handleChange}
error={$errors.email}
></TextField> ></TextField>
<TextField <TextField
name="password" name="password"
label={$_("password")} label={$_("password")}
type="password" type="password"
bind:value={$form.password}
on:change={handleChange}
error={$errors.password} error={$errors.password}
></TextField> ></TextField>
<Button <Button

View File

@@ -3,9 +3,7 @@
import { page } from "$app/stores"; import { page } from "$app/stores";
import { type RadioItem } from "$lib/components/base/radio_group.svelte"; import { type RadioItem } from "$lib/components/base/radio_group.svelte";
import { type SearchItem } from "$lib/components/base/search.svelte"; import { type SearchItem } from "$lib/components/base/search.svelte";
import { import { type SelectItem } from "$lib/components/base/select.svelte";
type SelectItem,
} from "$lib/components/base/select.svelte";
import ConfirmModal from "$lib/components/confirm_modal.svelte"; import ConfirmModal from "$lib/components/confirm_modal.svelte";
import EmailModal from "$lib/components/settings/email_modal.svelte"; import EmailModal from "$lib/components/settings/email_modal.svelte";
import PasswordModal from "$lib/components/settings/password_modal.svelte"; import PasswordModal from "$lib/components/settings/password_modal.svelte";
@@ -48,22 +46,6 @@
selectedMapFocus = settings?.mapFocus ?? "trails"; 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 deleteAccount() { async function deleteAccount() {
await users_delete($currentUser!); await users_delete($currentUser!);
logout(); logout();
@@ -73,6 +55,11 @@
async function updateEmail(email: string) { async function updateEmail(email: string) {
try { try {
await users_update({ ...$currentUser!, email: email }); await users_update({ ...$currentUser!, email: email });
show_toast({
text: $_("email-updated"),
icon: "check",
type: "success",
});
} catch (e) { } catch (e) {
show_toast({ show_toast({
text: "Error updating email", text: "Error updating email",
@@ -107,34 +94,36 @@
<svelte:head> <svelte:head>
<title>{$_("settings")} | wanderer</title> <title>{$_("settings")} | wanderer</title>
</svelte:head> </svelte:head>
{#if $currentUser} {#if $currentUser}
<h2 class="text-2xl font-semibold">{$_("my-account")}</h2> <h2 class="text-2xl font-semibold">{$_("my-account")}</h2>
<hr class="mt-4 mb-6 border-input-border" /> <hr class="mt-4 mb-6 border-input-border" />
<div class="space-y-6"> <div class="space-y-6">
<h4 class="text-xl font-medium">{$_("login-details")}</h4> <h4 class="text-xl font-medium">{$_("login-details")}</h4>
<button class="btn-secondary block" on:click={openEmailModal} <button class="btn-secondary block" on:click={openEmailModal}
>{$_("change-email")}</button >{$_("change-email")}</button
>
<button class="btn-secondary" on:click={openPasswordModal}
>{$_("change-password")}</button
>
<div class="space-y-4">
<h4 class="text-xl text-red-400 font-medium">
{$_("danger-zone")}
</h4>
<button class="btn-danger" on:click={openConfirmModal}
>{$_("delete-account")}</button
> >
<button class="btn-secondary" on:click={openPasswordModal}
>{$_("change-password")}</button
>
<div class="space-y-4">
<h4 class="text-xl text-red-400 font-medium">{$_("danger-zone")}</h4>
<button class="btn-danger" on:click={openConfirmModal}
>{$_("delete-account")}</button
>
</div>
</div> </div>
<EmailModal </div>
email={$currentUser.email} <EmailModal
on:save={(e) => updateEmail(e.detail)} email={$currentUser.email}
bind:openModal={openEmailModal} on:save={(e) => updateEmail(e.detail)}
></EmailModal> bind:openModal={openEmailModal}
<PasswordModal ></EmailModal>
on:save={(e) => updatePassword(e.detail)} <PasswordModal
bind:openModal={openPasswordModal} on:save={(e) => updatePassword(e.detail)}
></PasswordModal> bind:openModal={openPasswordModal}
{/if} ></PasswordModal>
{/if}
<ConfirmModal <ConfirmModal
text={$_("account-delete-confirm")} text={$_("account-delete-confirm")}
bind:openModal={openConfirmModal} bind:openModal={openConfirmModal}

View File

@@ -14,6 +14,9 @@
import PhotoPicker from "$lib/components/trail/photo_picker.svelte"; import PhotoPicker from "$lib/components/trail/photo_picker.svelte";
import WaypointCard from "$lib/components/waypoint/waypoint_card.svelte"; import WaypointCard from "$lib/components/waypoint/waypoint_card.svelte";
import WaypointModal from "$lib/components/waypoint/waypoint_modal.svelte"; import WaypointModal from "$lib/components/waypoint/waypoint_modal.svelte";
import { SummitLogCreateSchema } from "$lib/models/api/summit_log_schema.js";
import { TrailCreateSchema } from "$lib/models/api/trail_schema.js";
import { WaypointCreateSchema } from "$lib/models/api/waypoint_schema.js";
import GPX from "$lib/models/gpx/gpx"; import GPX from "$lib/models/gpx/gpx";
import type { List } from "$lib/models/list"; import type { List } from "$lib/models/list";
import { SummitLog } from "$lib/models/summit_log"; import { SummitLog } from "$lib/models/summit_log";
@@ -31,7 +34,7 @@
trail, trail,
trails_create, trails_create,
trails_update, trails_update,
} from "$lib/stores/trail_store"; } from "$lib/stores/trail_store.js";
import { import {
anchors, anchors,
appendToRoute, appendToRoute,
@@ -55,21 +58,21 @@
createAnchorMarker, createAnchorMarker,
createMarkerFromWaypoint, createMarkerFromWaypoint,
} from "$lib/util/maplibre_util"; } from "$lib/util/maplibre_util";
import { createForm } from "$lib/vendor/svelte-form-lib"; import { validator } from "@felte/validator-zod";
import cryptoRandomString from "crypto-random-string"; import cryptoRandomString from "crypto-random-string";
import { createForm } from "felte";
import * as M from "maplibre-gl"; import * as M from "maplibre-gl";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { backInOut } from "svelte/easing"; import { backInOut } from "svelte/easing";
import { scale } from "svelte/transition"; import { scale } from "svelte/transition";
import { array, number, object, string } from "yup"; import { z } from "zod";
export let data; export let data;
let map: M.Map; let map: M.Map;
let mapTrail: Trail[] = []; let mapTrail: Trail[] = [];
$: gpxData = $form.expand!?.gpx_data; $: gpxData = $formData.expand!?.gpx_data;
$: if (gpxData) { $: if (gpxData) {
updateTrailOnMap(); updateTrailOnMap();
} }
@@ -89,20 +92,18 @@
let drawingActive = false; let drawingActive = false;
let overwriteGPX = false; let overwriteGPX = false;
const trailSchema = object<Trail>({ const ClientTrailCreateSchema = TrailCreateSchema.extend({
id: string().optional(), expand: z
name: string().required($_("required")), .object({
location: string().optional(), gpx_data: z.string().optional(),
distance: number().optional(), summit_logs: z.array(SummitLogCreateSchema),
difficulty: string() waypoints: z.array(
.oneOf(["easy", "moderate", "difficult"]) WaypointCreateSchema.extend({
marker: z.any().optional(),
}),
),
})
.optional(), .optional(),
elevation_gain: number().optional(),
duration: number().optional(),
thumbnail: string().optional(),
photos: array(string()).optional(),
gpx: string().optional(),
description: string().optional(),
}); });
const modesOfTransport = [ const modesOfTransport = [
@@ -114,7 +115,12 @@
let autoRouting = true; let autoRouting = true;
const { form, errors, handleChange, handleSubmit } = createForm<Trail>({ const {
form,
errors,
data: formData,
setFields,
} = createForm<z.infer<typeof ClientTrailCreateSchema>>({
initialValues: { initialValues: {
...data.trail, ...data.trail,
public: data.trail.id public: data.trail.id
@@ -125,28 +131,10 @@
$page.data.settings?.category || $page.data.settings?.category ||
$categories[0].id, $categories[0].id,
}, },
validationSchema: trailSchema, extend: validator({
onError: (errors) => { schema: ClientTrailCreateSchema,
if (errors.name) { }),
const nameInput = document.querySelector( onSubmit: async (form) => {
"input[name=name]",
) as HTMLElement;
if (window.innerWidth < 768) {
window?.scroll({
top: nameInput.offsetTop - 24,
behavior: "smooth",
});
} else {
const form = document.getElementById("trail-form");
form?.scroll({
top: nameInput.offsetTop - 130,
behavior: "smooth",
});
}
}
},
onSubmit: async (submittedTrail) => {
loading = true; loading = true;
try { try {
const htmlForm = document.getElementById( const htmlForm = document.getElementById(
@@ -154,43 +142,43 @@
) as HTMLFormElement; ) as HTMLFormElement;
const formData = new FormData(htmlForm); const formData = new FormData(htmlForm);
if (!formData.get("public")) { if (!formData.get("public")) {
submittedTrail.public = false; form.public = false;
} }
submittedTrail.photos = submittedTrail.photos.filter( form.photos = form.photos.filter(
(p) => !p.startsWith("data:image/svg+xml;base64"), (p) => !p.startsWith("data:image/svg+xml;base64"),
); );
if ($form.expand!.gpx_data && overwriteGPX) { if ($formData.expand!.gpx_data && overwriteGPX) {
gpxFile = new Blob([$form.expand!.gpx_data], { gpxFile = new Blob([$formData.expand!.gpx_data], {
type: "text/xml", type: "text/xml",
}); });
} }
if ( if (
(!$form.lat || !$form.lon) && (!$formData.lat || !$formData.lon) &&
route.trk?.at(0)?.trkseg?.at(0)?.trkpt?.at(0) route.trk?.at(0)?.trkseg?.at(0)?.trkpt?.at(0)
) { ) {
$form.lat = route.trk $formData.lat = route.trk
?.at(0) ?.at(0)
?.trkseg?.at(0) ?.trkseg?.at(0)
?.trkpt?.at(0)?.$.lat; ?.trkpt?.at(0)?.$.lat;
$form.lon = route.trk $formData.lon = route.trk
?.at(0) ?.at(0)
?.trkseg?.at(0) ?.trkseg?.at(0)
?.trkpt?.at(0)?.$.lon; ?.trkpt?.at(0)?.$.lon;
} }
if (!submittedTrail.id) { if (!form.id) {
const createdTrail = await trails_create( const createdTrail = await trails_create(
submittedTrail, form as Trail,
photoFiles, photoFiles,
gpxFile, gpxFile,
); );
$form.id = createdTrail.id; $formData.id = createdTrail.id;
} else { } else {
await trails_update( await trails_update(
$trail, $trail,
submittedTrail, form as Trail,
photoFiles, photoFiles,
gpxFile, gpxFile,
); );
@@ -219,8 +207,8 @@
clearAnchorMarker(); clearAnchorMarker();
clearRoute(); clearRoute();
if ($form.expand!.gpx_data) { if ($formData.expand!.gpx_data) {
const gpx = await GPX.parse($form.expand!.gpx_data); const gpx = await GPX.parse($formData.expand!.gpx_data);
if (!(gpx instanceof Error)) { if (!(gpx instanceof Error)) {
setRoute(gpx); setRoute(gpx);
initRouteAnchors(gpx); initRouteAnchors(gpx);
@@ -251,19 +239,27 @@
gpxFile = file; gpxFile = file;
try { try {
const prevId = $form.id; const prevId = $formData.id;
const parseResult = await gpx2trail(gpxData, selectedFile.name); const parseResult = await gpx2trail(gpxData, selectedFile.name);
$form = parseResult.trail; setFields(parseResult.trail);
$form.id = prevId; $formData.id = prevId;
$form.expand!.gpx_data = gpxData; $formData.expand!.gpx_data = gpxData;
$form.category = $page.data.settings.category || $categories[0].id; setFields(
$form.public = $page.data.settings?.privacy.trails === "public"; "category",
$page.data.settings.category || $categories[0].id,
);
setFields(
"public",
$page.data.settings?.privacy.trails === "public",
);
const log = new SummitLog(parseResult.trail.date as string, { const log = new SummitLog(parseResult.trail.date as string, {
distance: $form.distance, distance: $formData.distance,
elevation_gain: $form.elevation_gain, elevation_gain: $formData.elevation_gain,
elevation_loss: $form.elevation_loss, elevation_loss: $formData.elevation_loss,
duration: $form.duration ? $form.duration * 60 : undefined, duration: $formData.duration
? $formData.duration * 60
: undefined,
}); });
log.expand!.gpx_data = gpxData; log.expand!.gpx_data = gpxData;
@@ -272,16 +268,16 @@
type: selectedFile.type, type: selectedFile.type,
}); });
$form.expand!.summit_logs.push(log); $formData.expand!.summit_logs.push(log);
setRoute(parseResult.gpx); setRoute(parseResult.gpx);
initRouteAnchors(parseResult.gpx); initRouteAnchors(parseResult.gpx);
for (const waypoint of $form.expand!.waypoints) { for (const waypoint of $formData.expand!.waypoints) {
saveWaypoint(waypoint); saveWaypoint(waypoint);
} }
} catch (e) { } catch (e) {
console.log(e); console.error(e);
show_toast({ show_toast({
icon: "close", icon: "close",
@@ -295,23 +291,25 @@
body: JSON.stringify({ body: JSON.stringify({
q: "", q: "",
options: { options: {
filter: [`_geoRadius(${$form.lat}, ${$form.lon}, 10000)`], filter: [
sort: [`_geoPoint(${$form.lat}, ${$form.lon}):asc`], `_geoRadius(${$formData.lat}, ${$formData.lon}, 10000)`,
],
sort: [`_geoPoint(${$formData.lat}, ${$formData.lon}):asc`],
limit: 1, limit: 1,
}, },
}), }),
}); });
const closestCity = (await r.json()).hits[0]; const closestCity = (await r.json()).hits[0];
$form.location = closestCity.name; setFields("location", closestCity.name);
} }
function clearWaypoints() { function clearWaypoints() {
for (const waypoint of $form.expand!.waypoints) { for (const waypoint of $formData.expand!.waypoints) {
waypoint.marker?.remove(); waypoint.marker?.remove();
} }
$form.expand!.waypoints = []; $formData.expand!.waypoints = [];
$form.waypoints = []; $formData.waypoints = [];
} }
function clearAnchorMarker() { function clearAnchorMarker() {
@@ -365,23 +363,23 @@
} }
function deleteWaypoint(index: number) { function deleteWaypoint(index: number) {
$form.expand!.waypoints.splice(index, 1); $formData.expand!.waypoints.splice(index, 1);
$form.waypoints.splice(index, 1); $formData.waypoints.splice(index, 1);
$form.expand!.waypoints = $form.expand!.waypoints; $formData.expand!.waypoints = $formData.expand!.waypoints;
} }
function saveWaypoint(savedWaypoint: Waypoint) { function saveWaypoint(savedWaypoint: Waypoint) {
let editedWaypointIndex = $form.expand!.waypoints.findIndex( let editedWaypointIndex = $formData.expand!.waypoints.findIndex(
(s) => s.id == savedWaypoint.id, (s) => s.id == savedWaypoint.id,
); );
if (editedWaypointIndex >= 0) { if (editedWaypointIndex >= 0) {
$form.expand!.waypoints[editedWaypointIndex].marker?.remove(); $formData.expand!.waypoints[editedWaypointIndex].marker?.remove();
$form.expand!.waypoints[editedWaypointIndex] = savedWaypoint; $formData.expand!.waypoints[editedWaypointIndex] = savedWaypoint;
} else { } else {
savedWaypoint.id = cryptoRandomString({ length: 15 }); savedWaypoint.id = cryptoRandomString({ length: 15 });
$form.expand!.waypoints = [ $formData.expand!.waypoints = [
...$form.expand!.waypoints, ...$formData.expand!.waypoints,
savedWaypoint, savedWaypoint,
]; ];
} }
@@ -393,16 +391,17 @@
function moveMarker(marker: M.Marker, wpId?: string) { function moveMarker(marker: M.Marker, wpId?: string) {
const position = marker.getLngLat(); const position = marker.getLngLat();
const editableWaypointIndex = $form.expand!.waypoints.findIndex( const editableWaypointIndex = $formData.expand!.waypoints.findIndex(
(w) => w.id == wpId, (w) => w.id == wpId,
); );
const editableWaypoint = $form.expand!.waypoints[editableWaypointIndex]; const editableWaypoint =
$formData.expand!.waypoints[editableWaypointIndex];
if (!editableWaypoint) { if (!editableWaypoint) {
return; return;
} }
editableWaypoint.lat = position.lat; editableWaypoint.lat = position.lat;
editableWaypoint.lon = position.lng; editableWaypoint.lon = position.lng;
$form.expand!.waypoints = [...$form.expand!.waypoints]; $formData.expand!.waypoints = [...$formData.expand!.waypoints];
} }
function beforeSummitLogModalOpen() { function beforeSummitLogModalOpen() {
@@ -413,16 +412,17 @@
function saveSummitLog(e: CustomEvent<SummitLog>) { function saveSummitLog(e: CustomEvent<SummitLog>) {
const savedSummitLog = e.detail; const savedSummitLog = e.detail;
let editedSummitLogIndex = $form.expand!.summit_logs.findIndex( let editedSummitLogIndex = $formData.expand!.summit_logs.findIndex(
(s) => s.id == savedSummitLog.id, (s) => s.id == savedSummitLog.id,
); );
if (editedSummitLogIndex >= 0) { if (editedSummitLogIndex >= 0) {
$form.expand!.summit_logs[editedSummitLogIndex] = savedSummitLog; $formData.expand!.summit_logs[editedSummitLogIndex] =
savedSummitLog;
} else { } else {
savedSummitLog.id = cryptoRandomString({ length: 15 }); savedSummitLog.id = cryptoRandomString({ length: 15 });
$form.expand!.summit_logs = [ $formData.expand!.summit_logs = [
...$form.expand!.summit_logs, ...$formData.expand!.summit_logs,
savedSummitLog, savedSummitLog,
]; ];
} }
@@ -437,21 +437,21 @@
summitLog.set(currentSummitLog); summitLog.set(currentSummitLog);
openSummitLogModal(); openSummitLogModal();
} else if (e.detail.value === "delete") { } else if (e.detail.value === "delete") {
$form.expand!.summit_logs.splice(index, 1); $formData.expand!.summit_logs.splice(index, 1);
$form.summit_logs.splice(index, 1); $formData.summit_logs.splice(index, 1);
$form.expand!.summit_logs = $form.expand!.summit_logs; $formData.expand!.summit_logs = $formData.expand!.summit_logs;
} }
} }
async function handleListSelection(list: List) { async function handleListSelection(list: List) {
if (!$form.id) { if (!$formData.id) {
return; return;
} }
try { try {
if (list.trails?.includes($form.id!)) { if (list.trails?.includes($formData.id!)) {
list = await lists_remove_trail(list, $form); list = await lists_remove_trail(list, $formData as Trail);
} else { } else {
list = await lists_add_trail(list, $form); list = await lists_add_trail(list, $formData as Trail);
} }
const index = data.lists.items.findIndex((l) => l.id == list.id); const index = data.lists.items.findIndex((l) => l.id == list.id);
if (index >= 0) { if (index >= 0) {
@@ -615,21 +615,22 @@
function updateTrailWithRouteData() { function updateTrailWithRouteData() {
overwriteGPX = true; overwriteGPX = true;
const totals = route.getTotals(); const totals = route.getTotals();
$form.distance = totals.distance; $formData.distance = totals.distance;
$form.duration = totals.duration; $formData.duration = totals.duration;
$form.elevation_gain = totals.elevationGain; $formData.elevation_gain = totals.elevationGain;
$form.elevation_loss = totals.elevationLoss; $formData.elevation_loss = totals.elevationLoss;
$form.expand!.gpx_data = route.toString(); $formData.expand!.gpx_data = route.toString();
} }
function updateTrailOnMap() { function updateTrailOnMap() {
mapTrail = [{ ...$form }]; mapTrail = [{ ...($formData as Trail) }];
} }
</script> </script>
<svelte:head> <svelte:head>
<title <title
>{$form.id ? `${$form.name} | ${$_("edit")}` : $_("new-trail")} | wanderer</title >{$formData.id ? `${$formData.name} | ${$_("edit")}` : $_("new-trail")} |
wanderer</title
> >
</svelte:head> </svelte:head>
@@ -637,7 +638,7 @@
<form <form
id="trail-form" id="trail-form"
class="overflow-y-auto overflow-x-hidden flex flex-col gap-4 px-8 order-1 md:order-none mt-8 md:mt-0" class="overflow-y-auto overflow-x-hidden flex flex-col gap-4 px-8 order-1 md:order-none mt-8 md:mt-0"
on:submit={handleSubmit} use:form
> >
<h3 class="text-xl font-semibold">{$_("pick-a-trail")}</h3> <h3 class="text-xl font-semibold">{$_("pick-a-trail")}</h3>
<Button <Button
@@ -683,24 +684,27 @@
> >
</div> </div>
<div class="grid grid-cols-2 gap-4 justify-around"> <fieldset
class="grid grid-cols-2 gap-4 justify-around"
data-felte-keep-on-remove
>
{#if editingBasicInfo} {#if editingBasicInfo}
<TextField <TextField
bind:value={$form.distance} bind:value={$formData.distance}
name="distance" name="distance"
label={$_("distance")} label={$_("distance")}
></TextField> ></TextField>
<TextField <TextField
bind:value={$form.duration} bind:value={$formData.duration}
name="duration" name="duration"
label={$_("est-duration")} label={$_("est-duration")}
></TextField><TextField ></TextField><TextField
bind:value={$form.elevation_gain} bind:value={$formData.elevation_gain}
name="elevation_gain" name="elevation_gain"
label={$_("elevation-gain")} label={$_("elevation-gain")}
></TextField> ></TextField>
<TextField <TextField
bind:value={$form.elevation_loss} bind:value={$formData.elevation_loss}
name="elevation_loss" name="elevation_loss"
label={$_("elevation-loss")} label={$_("elevation-loss")}
></TextField> ></TextField>
@@ -708,75 +712,63 @@
<div> <div>
<p>{$_("distance")}</p> <p>{$_("distance")}</p>
<span class="font-medium" <span class="font-medium"
>{formatDistance($form.distance)}</span >{formatDistance($formData.distance)}</span
> >
<input <input
type="hidden" type="hidden"
name="distance" name="distance"
value={$form.distance} value={$formData.distance}
/> />
</div> </div>
<div> <div>
<p>{$_("est-duration")}</p> <p>{$_("est-duration")}</p>
<span class="font-medium" <span class="font-medium"
>{formatTimeHHMM($form.duration)}</span >{formatTimeHHMM($formData.duration)}</span
> >
<input <input
type="hidden" type="hidden"
name="duration" name="duration"
value={$form.duration} value={$formData.duration}
/> />
</div> </div>
<div> <div>
<p>{$_("elevation-gain")}</p> <p>{$_("elevation-gain")}</p>
<span class="font-medium" <span class="font-medium"
>{formatElevation($form.elevation_gain)}</span >{formatElevation($formData.elevation_gain)}</span
> >
<input <input
type="hidden" type="hidden"
name="elevation_gain" name="elevation_gain"
value={$form.elevation_gain} value={$formData.elevation_gain}
/> />
</div> </div>
<div> <div>
<p>{$_("elevation-loss")}</p> <p>{$_("elevation-loss")}</p>
<span class="font-medium" <span class="font-medium"
>{formatElevation($form.elevation_loss)}</span >{formatElevation($formData.elevation_loss)}</span
> >
<input <input
type="hidden" type="hidden"
name="elevation_gain" name="elevation_gain"
value={$form.elevation_gain} value={$formData.elevation_gain}
/> />
</div> </div>
{/if} {/if}
<input type="hidden" name="lat" value={$form.lat} /> </fieldset>
<input type="hidden" name="lon" value={$form.lon} /> <TextField name="name" label={$_("name")} error={$errors.name}
</div>
<TextField
name="name"
label={$_("name")}
on:change={handleChange}
error={$errors.name}
bind:value={$form.name}
></TextField> ></TextField>
<TextField <TextField
name="location" name="location"
label={$_("location")} label={$_("location")}
error={$errors.location} error={$errors.location}
bind:value={$form.location}
></TextField> ></TextField>
<Datepicker label={$_("date")} bind:value={$form.date}></Datepicker> <Datepicker label={$_("date")} bind:value={$formData.date}></Datepicker>
<Textarea <Textarea name="description" label={$_("describe-your-trail")}
name="description"
label={$_("describe-your-trail")}
bind:value={$form.description}
></Textarea> ></Textarea>
<div class="grid grid-cols-1 md:grid-cols-2 gap-y-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-y-4">
<Select <Select
name="difficulty" name="difficulty"
label={$_("difficulty")} label={$_("difficulty")}
bind:value={$form.difficulty}
items={[ items={[
{ text: $_("easy"), value: "easy" }, { text: $_("easy"), value: "easy" },
{ text: $_("moderate"), value: "moderate" }, { text: $_("moderate"), value: "moderate" },
@@ -786,7 +778,6 @@
<Select <Select
name="category" name="category"
label={$_("category")} label={$_("category")}
bind:value={$form.category}
items={$categories.map((c) => ({ items={$categories.map((c) => ({
text: $_(c.name), text: $_(c.name),
value: c.id, value: c.id,
@@ -794,14 +785,13 @@
></Select> ></Select>
</div> </div>
<Toggle name="public" label={$_("public")} bind:value={$form.public} <Toggle name="public" label={$_("public")}></Toggle>
></Toggle>
<hr class="border-separator" /> <hr class="border-separator" />
<h3 class="text-xl font-semibold"> <h3 class="text-xl font-semibold">
{$_("waypoints", { values: { n: 2 } })} {$_("waypoints", { values: { n: 2 } })}
</h3> </h3>
<ul> <ul>
{#each $form.expand?.waypoints ?? [] as waypoint, i} {#each $formData.expand?.waypoints ?? [] as waypoint, i}
<li on:mouseenter={() => openMarkerPopup(waypoint)}> <li on:mouseenter={() => openMarkerPopup(waypoint)}>
<WaypointCard <WaypointCard
{waypoint} {waypoint}
@@ -822,15 +812,15 @@
<h3 class="text-xl font-semibold">{$_("photos")}</h3> <h3 class="text-xl font-semibold">{$_("photos")}</h3>
<PhotoPicker <PhotoPicker
id="trail" id="trail"
parent={$form} parent={$formData}
bind:photos={$form.photos} bind:photos={$formData.photos}
bind:thumbnail={$form.thumbnail} bind:thumbnail={$formData.thumbnail}
bind:photoFiles bind:photoFiles
></PhotoPicker> ></PhotoPicker>
<hr class="border-separator" /> <hr class="border-separator" />
<h3 class="text-xl font-semibold">{$_("summit-book")}</h3> <h3 class="text-xl font-semibold">{$_("summit-book")}</h3>
<ul> <ul>
{#each $form.expand?.summit_logs ?? [] as log, i} {#each $formData.expand?.summit_logs ?? [] as log, i}
<li> <li>
<SummitLogCard <SummitLogCard
{log} {log}
@@ -853,7 +843,7 @@
</h3> </h3>
<div class="flex gap-4 flex-wrap"> <div class="flex gap-4 flex-wrap">
{#each data.lists.items as list} {#each data.lists.items as list}
{#if $form.id && list.trails?.includes($form.id)} {#if $formData.id && list.trails?.includes($formData.id)}
<div <div
class="flex gap-2 items-center border border-input-border rounded-xl p-2" class="flex gap-2 items-center border border-input-border rounded-xl p-2"
> >
@@ -873,7 +863,7 @@
<Button <Button
secondary={true} secondary={true}
tooltip={$_("save-your-trail-first")} tooltip={$_("save-your-trail-first")}
disabled={!$form.id} disabled={!$formData.id}
type="button" type="button"
on:click={openListSelectModal} on:click={openListSelectModal}
><i class="fa fa-plus mr-2"></i>{$_("add-to-list")}</Button ><i class="fa fa-plus mr-2"></i>{$_("add-to-list")}</Button