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

View File

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

View File

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

View File

@@ -5,29 +5,41 @@
import TextField from "$lib/components/base/text_field.svelte";
import LogoTextTwoLineDark from "$lib/components/logo/logo_text_two_line_dark.svelte";
import LogoTextTwoLineLight from "$lib/components/logo/logo_text_two_line_light.svelte";
import type { User } from "$lib/models/user";
import { theme } from "$lib/stores/theme_store";
import { show_toast } from "$lib/stores/toast_store";
import {
users_confirm_reset,
users_reset_password,
} from "$lib/stores/user_store";
import { createForm } from "$lib/vendor/svelte-form-lib";
import { users_confirm_reset } from "$lib/stores/user_store";
import { validator } from "@felte/validator-zod";
import { createForm } from "felte";
import { _ } from "svelte-i18n";
import { object, ref, string } from "yup";
import { z } from "zod";
let loading: boolean = false;
const { form, errors, handleChange, handleSubmit } = createForm({
const { form, errors } = createForm({
initialValues: {
password: "",
passwordConfirm: "",
},
validationSchema: object({
password: string().required($_("required")),
passwordConfirm: string()
.required($_("required"))
.oneOf([ref("password"), ""], $_('passwords-must-match')),
extend: validator({
schema: z
.object({
password: z.string().min(
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) => {
loading = true;
@@ -61,7 +73,7 @@
<main class="flex justify-center">
<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"
on:submit={handleSubmit}
use:form
>
{#if $theme == "light"}
<LogoTextTwoLineDark></LogoTextTwoLineDark>
@@ -74,16 +86,12 @@
name="password"
label={$_("password")}
type="password"
bind:value={$form.password}
on:change={handleChange}
error={$errors.password}
></TextField>
<TextField
name="password"
name="passwordConfirm"
label={$_("password-confirm")}
type="password"
bind:value={$form.passwordConfirm}
on:change={handleChange}
error={$errors.passwordConfirm}
></TextField>

View File

@@ -4,22 +4,27 @@
import TextField from "$lib/components/base/text_field.svelte";
import LogoTextTwoLineDark from "$lib/components/logo/logo_text_two_line_dark.svelte";
import LogoTextTwoLineLight from "$lib/components/logo/logo_text_two_line_light.svelte";
import type { User } from "$lib/models/user";
import { theme } from "$lib/stores/theme_store";
import { show_toast } from "$lib/stores/toast_store";
import { 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 { object, string } from "yup";
import { z } from "zod";
let loading: boolean = false;
const { form, errors, handleChange, handleSubmit } = createForm({
const { form, errors } = createForm({
initialValues: {
email: "",
},
validationSchema: object<User>({
email: string().required($_("required")),
extend: validator({
schema: z.object({
email: z
.string()
.min(1, "required")
.email("not-a-valid-email-address"),
}),
}),
onSubmit: async (email) => {
loading = true;
@@ -50,7 +55,7 @@
<main class="flex justify-center">
<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"
on:submit={handleSubmit}
use:form
>
{#if $theme == "light"}
<LogoTextTwoLineDark></LogoTextTwoLineDark>
@@ -63,8 +68,6 @@
<TextField
name="email"
label={$_("email")}
bind:value={$form.email}
on:change={handleChange}
error={$errors.email}
></TextField>
<div class="grid grid-cols-2 gap-x-4">

View File

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

View File

@@ -22,8 +22,9 @@ export const load: Load = async ({ params, fetch, data }) => {
} catch (e) {
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">
import { goto, invalidate, invalidateAll } from "$app/navigation";
import { page } from "$app/stores";
import { env } from "$env/dynamic/public";
import Button from "$lib/components/base/button.svelte";
@@ -10,10 +9,11 @@
import { theme } from "$lib/stores/theme_store";
import { show_toast } from "$lib/stores/toast_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 { _ } from "svelte-i18n";
import { object, string } from "yup";
import { z } from "zod";
let loading: boolean = false;
@@ -31,18 +31,21 @@
loginLabel = `${$_("email")}`;
}
const { form, errors, handleChange, handleSubmit } = createForm<User>({
const { form, errors, data } = createForm<User>({
initialValues: {
id: "",
username: "",
password: "",
},
validationSchema: object<User>({
username: string().required($_("required")),
password: string().required($_("required")),
extend: validator({
schema: z.object({
username: z.string().min(1, "required"),
password: z.string().min(1, "required"),
}),
}),
onSubmit: async (newUser) => {
loading = true;
try {
await login(newUser);
window.location.href = $page.url.searchParams.get("r") ?? "/";
@@ -80,7 +83,7 @@
<main class="flex justify-center">
<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"
on:submit={handleSubmit}
use:form
>
{#if $theme == "light"}
<LogoTextTwoLineDark></LogoTextTwoLineDark>
@@ -93,8 +96,6 @@
<TextField
name="username"
label={loginLabel}
bind:value={$form.username}
on:change={handleChange}
error={$errors.username}
></TextField>
<div class="flex flex-col">
@@ -102,8 +103,6 @@
name="password"
label={$_("password")}
type="password"
bind:value={$form.password}
on:change={handleChange}
error={$errors.password}
></TextField>
<a

View File

@@ -8,38 +8,38 @@
import { theme } from "$lib/stores/theme_store";
import { show_toast } from "$lib/stores/toast_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 { object, string } from "yup";
import { z } from "zod";
let loading: boolean = false;
const { form, errors, handleChange, handleSubmit } = createForm<User>({
const { form, errors } = createForm<User>({
initialValues: {
id: "",
username: "",
email: "",
password: "",
},
validationSchema: object<User>({
username: string()
.min(
extend: validator({
schema: z.object({
username: z.string().min(
3,
$_("must-be-at-least-n-characters-long", {
values: { n: 3 },
}),
)
.required($_("required"))
.matches(/^[\w][\w\.]*$/, { message: $_("invalid-username") }),
email: string()
.email($_("not-a-valid-email-address"))
.required($_("required")),
password: string()
.min(
),
email: z
.string()
.min(1, "required")
.email("not-a-valid-email-address"),
password: z.string().min(
8,
$_("must-be-at-least-n-characters-long", {
values: { n: 8 },
}),
)
.required($_("required")),
),
}),
}),
onSubmit: async (newUser) => {
loading = true;
@@ -77,7 +77,7 @@
<main class="flex justify-center">
<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"
on:submit={handleSubmit}
use:form
>
{#if $theme == "light"}
<LogoTextTwoLineDark></LogoTextTwoLineDark>
@@ -89,23 +89,14 @@
<TextField
name="username"
label={$_("username")}
bind:value={$form.username}
on:change={handleChange}
error={$errors.username}
></TextField>
<TextField
name="email"
label={$_("email")}
bind:value={$form.email}
on:change={handleChange}
error={$errors.email}
<TextField name="email" label={$_("email")} error={$errors.email}
></TextField>
<TextField
name="password"
label={$_("password")}
type="password"
bind:value={$form.password}
on:change={handleChange}
error={$errors.password}
></TextField>
<Button

View File

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

View File

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