finishes trail create
This commit is contained in:
31
web/src/lib/components/base/button.svelte
Normal file
31
web/src/lib/components/base/button.svelte
Normal file
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
export let type: "button" | "submit" | "reset" | null | undefined =
|
||||
undefined;
|
||||
export let icon: string = "";
|
||||
export let extraClasses: string = "";
|
||||
export let primary: boolean = false;
|
||||
export let secondary: boolean = false;
|
||||
export let large: boolean = false;
|
||||
export let loading: boolean = false;
|
||||
export let disabled: boolean = false;
|
||||
</script>
|
||||
|
||||
<button
|
||||
class={extraClasses}
|
||||
class:btn-primary={primary}
|
||||
class:btn-secondary={secondary}
|
||||
class:btn-large={large}
|
||||
class:btn-disabled={disabled || loading}
|
||||
disabled={disabled || loading}
|
||||
on:click
|
||||
{type}
|
||||
>
|
||||
{#if !loading}
|
||||
{#if icon}
|
||||
<i class="fa fa-{icon} mr-2"></i>
|
||||
{/if}
|
||||
<slot />
|
||||
{:else}
|
||||
<div class="spinner"></div>
|
||||
{/if}
|
||||
</button>
|
||||
29
web/src/lib/components/base/datepicker.svelte
Normal file
29
web/src/lib/components/base/datepicker.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
export let name: string = "";
|
||||
export let value: string | number | Date = "";
|
||||
export let label: string = "";
|
||||
export let error: string = "";
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#if label.length}
|
||||
<p class="text-sm font-medium pb-1">
|
||||
{label}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
{name}
|
||||
class="bg-gray-50 border rounded-md p-3 transition-colors focus:border-primary focus:outline-none focus:ring-0 w-full"
|
||||
class:border-red-400={error.length > 0}
|
||||
class:bg-red-50={error.length > 0}
|
||||
type="date"
|
||||
bind:value
|
||||
on:change
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span class="textfield-error text-xs text-red-400">
|
||||
{error}
|
||||
</span>
|
||||
</div>
|
||||
@@ -7,7 +7,9 @@
|
||||
|
||||
let isOpen = false;
|
||||
|
||||
function toggleMenu() {
|
||||
function toggleMenu(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
isOpen = !isOpen;
|
||||
}
|
||||
|
||||
@@ -15,8 +17,8 @@
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
function handleItemClick(item: { text: string, value: any }) {
|
||||
dispatch("change", item);
|
||||
function handleItemClick(item: { text: string; value: any }) {
|
||||
dispatch("change", item);
|
||||
closeMenu();
|
||||
}
|
||||
</script>
|
||||
@@ -27,6 +29,7 @@
|
||||
<button
|
||||
class="hover:bg-gray-100 w-8 h-8 rounded-full"
|
||||
on:click={toggleMenu}
|
||||
type="button"
|
||||
>
|
||||
<i class="fa fa-ellipsis-vertical"></i>
|
||||
</button>
|
||||
@@ -42,8 +45,8 @@
|
||||
<li
|
||||
class="menu-item p-4 cursor-pointer hover:bg-gray-100 focus:bg-gray-200 transition-colors"
|
||||
tabindex="0"
|
||||
on:mouseup={() => handleItemClick(item)}
|
||||
on:keydown={() => handleItemClick(item)}
|
||||
on:mouseup|stopPropagation={() => handleItemClick(item)}
|
||||
on:keydown|stopPropagation={() => handleItemClick(item)}
|
||||
>
|
||||
{item.text}
|
||||
</li>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
export let name: string = "";
|
||||
export let value: any;
|
||||
export let items: { text: string; value: any }[] = [];
|
||||
export let label: string = "";
|
||||
@@ -11,6 +12,7 @@
|
||||
</p>
|
||||
{/if}
|
||||
<select
|
||||
{name}
|
||||
class="bg-gray-50 h-10 w-full px-4 border-r-8 border-transparent outline outline-1 outline-gray-200 rounded-md focus:outline-primary transition-colors"
|
||||
bind:value
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
export let name: string = "";
|
||||
export let value: string | number = "";
|
||||
export let placeholder: string = "";
|
||||
export let label: string = "";
|
||||
@@ -17,6 +18,7 @@
|
||||
<i class="fa fa-{icon}"></i>
|
||||
{/if}
|
||||
<input
|
||||
{name}
|
||||
class="bg-gray-50 border rounded-md p-3 transition-colors focus:border-primary focus:outline-none focus:ring-0 w-full"
|
||||
class:border-red-400={error.length > 0}
|
||||
class:bg-red-50={error.length > 0}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
export let name: string = "";
|
||||
export let value: string | number = "";
|
||||
export let placeholder: string = "";
|
||||
export let rows: number = 3;
|
||||
@@ -13,6 +14,7 @@
|
||||
</p>
|
||||
{/if}
|
||||
<textarea
|
||||
{name}
|
||||
class="bg-gray-50 border rounded-md p-3 resize-none transition-colors focus:border-primary focus:outline-none focus:ring-0 w-full"
|
||||
{rows}
|
||||
{placeholder}
|
||||
|
||||
44
web/src/lib/components/photo_card.svelte
Normal file
44
web/src/lib/components/photo_card.svelte
Normal file
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
export let src: string;
|
||||
export let isThumbnail: boolean = false;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function handleThumbnailClick() {
|
||||
dispatch("thumbnail", src);
|
||||
}
|
||||
|
||||
function handleDeleteClick() {
|
||||
dispatch("delete", src);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="group relative h-32 w-32 rounded-xl bg-cover bg-no-repeat"
|
||||
style="background-image: url({src});"
|
||||
>
|
||||
{#if isThumbnail}
|
||||
<i
|
||||
class="fa fa-file-image absolute top-2 right-2 text-primary bg-white rounded-full px-[10px] py-2 shadow-lg"
|
||||
></i>
|
||||
{/if}
|
||||
<div
|
||||
class="flex opacity-0 group-hover:opacity-100 absolute top-0 w-full h-full bg-white bg-opacity-75 items-center justify-center gap-6 transition-all"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="tooltip"
|
||||
data-title="Make thumbnail"
|
||||
on:click={handleThumbnailClick}
|
||||
><i class="fa fa-file-image text-primary"></i></button
|
||||
>
|
||||
<button
|
||||
class="tooltip"
|
||||
data-title="Delete"
|
||||
on:click={handleDeleteClick}
|
||||
type="button"><i class="fa fa-trash text-red-500"></i></button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
24
web/src/lib/components/summit_log/summit_log_card.svelte
Normal file
24
web/src/lib/components/summit_log/summit_log_card.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import type { SummitLog } from "$lib/models/summit_log";
|
||||
import { format, parse } from "date-fns";
|
||||
import Dropdown from "../base/dropdown.svelte";
|
||||
|
||||
export let log: SummitLog;
|
||||
export let mode: "show" | "edit" = "show";
|
||||
|
||||
const dropdownItems = [
|
||||
{ text: "Edit", value: "edit" },
|
||||
{ text: "Delete", value: "delete" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="p-4 my-2 border rounded-xl">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<h5 class="font-medium mr-2">{format(log.date, 'dd.MM.yyyy')}</h5>
|
||||
|
||||
{#if mode == "edit"}
|
||||
<Dropdown items={dropdownItems} on:change></Dropdown>
|
||||
{/if}
|
||||
</div>
|
||||
<span>{log.text}</span>
|
||||
</div>
|
||||
67
web/src/lib/components/summit_log/summit_log_modal.svelte
Normal file
67
web/src/lib/components/summit_log/summit_log_modal.svelte
Normal file
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import { summitLogSchema, type SummitLog } from "$lib/models/summit_log";
|
||||
import { summitLog } from "$lib/stores/summit_log_store";
|
||||
import { createForm } from "$lib/vendor/svelte-form-lib/index";
|
||||
import Datepicker from "../base/datepicker.svelte";
|
||||
import Modal from "../base/modal.svelte";
|
||||
import TextField from "../base/text_field.svelte";
|
||||
import { util } from "$lib/vendor/svelte-form-lib/util";
|
||||
|
||||
export let openModal: (() => void) | undefined = undefined;
|
||||
export let closeModal: (() => void) | undefined = undefined;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const { form, errors, handleChange, handleSubmit } = createForm<SummitLog>({
|
||||
initialValues: $summitLog,
|
||||
validationSchema: summitLogSchema,
|
||||
onSubmit: async (submittedValues) => {
|
||||
dispatch("save", submittedValues);
|
||||
closeModal!();
|
||||
},
|
||||
});
|
||||
$: form.set(util.cloneDeep($summitLog));
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
id="summit-log-modal"
|
||||
title="Add Entry"
|
||||
let:openModal
|
||||
bind:openModal
|
||||
bind:closeModal
|
||||
>
|
||||
<slot {openModal} />
|
||||
<form
|
||||
id="summit-log-form"
|
||||
slot="content"
|
||||
class="modal-content space-y-4"
|
||||
on:submit={handleSubmit}
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<Datepicker
|
||||
name="date"
|
||||
label="Date"
|
||||
bind:value={$form.date}
|
||||
error={$errors.date}
|
||||
on:change={handleChange}
|
||||
></Datepicker>
|
||||
<div class="basis-full">
|
||||
<TextField
|
||||
name="text"
|
||||
label="Text"
|
||||
bind:value={$form.text}
|
||||
error={$errors.text}
|
||||
on:change={handleChange}
|
||||
></TextField>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div slot="footer" class="flex items-center gap-4">
|
||||
<button class="btn-secondary" on:click={closeModal}>Cancel</button>
|
||||
<button class="btn-primary" type="submit" form="summit-log-form"
|
||||
>Save</button
|
||||
>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -1,11 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { SummitLog } from "$lib/models/summit_log";
|
||||
import { formatISODate } from "$lib/util/format_util";
|
||||
|
||||
export let log: SummitLog;
|
||||
</script>
|
||||
|
||||
<div class="p-4 my-2 border rounded-xl">
|
||||
<h5 class="font-medium mr-2">{formatISODate(log.date)}</h5>
|
||||
<span>{log.text}</span>
|
||||
</div>
|
||||
@@ -2,17 +2,26 @@
|
||||
import type { Trail } from "$lib/models/trail";
|
||||
import { getFileURL } from "$lib/util/file_util";
|
||||
import { formatMeters, formatTimeHHMM } from "$lib/util/format_util";
|
||||
import Dropdown from "../base/dropdown.svelte";
|
||||
|
||||
export let trail: Trail;
|
||||
|
||||
const dropdownItems = [
|
||||
{ text: "Edit", value: "edit" },
|
||||
{ text: "Delete", value: "delete" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="trail-card rounded-2xl shadow-md w-72 overflow-hidden cursor-pointer"
|
||||
>
|
||||
<img class="w-full h-48" src={getFileURL(trail, trail.thumbnail)} alt="" />
|
||||
<div class="trail-card rounded-2xl shadow-md w-72 cursor-pointer">
|
||||
<div class="w-full h-48 overflow-hidden rounded-t-2xl">
|
||||
<img src={getFileURL(trail, trail.thumbnail)} alt="" />
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div>
|
||||
<h4 class="font-semibold text-lg">{trail.name}</h4>
|
||||
<div class="flex justify-between items-center">
|
||||
<h4 class="font-semibold text-lg">{trail.name}</h4>
|
||||
<Dropdown on:change items={dropdownItems}></Dropdown>
|
||||
</div>
|
||||
<h5><i class="fa fa-location-dot mr-3"></i>{trail.location}</h5>
|
||||
</div>
|
||||
<div class="flex mt-2 gap-4 text-sm text-gray-500">
|
||||
@@ -2,11 +2,12 @@
|
||||
import { Waypoint, waypointSchema } from "$lib/models/waypoint";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import { createForm } from "svelte-forms-lib";
|
||||
import { createForm } from "$lib/vendor/svelte-form-lib/index";
|
||||
import Modal from "../base/modal.svelte";
|
||||
import TextField from "../base/text_field.svelte";
|
||||
import Textarea from "../base/textarea.svelte";
|
||||
import { waypoint } from "$lib/stores/waypoint_store";
|
||||
import { util } from "$lib/vendor/svelte-form-lib/util";
|
||||
|
||||
export let openModal: (() => void) | undefined = undefined;
|
||||
export let closeModal: (() => void) | undefined = undefined;
|
||||
@@ -21,7 +22,7 @@
|
||||
closeModal!();
|
||||
},
|
||||
});
|
||||
$: form.set($waypoint);
|
||||
$: form.set(util.cloneDeep($waypoint));
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
@@ -41,6 +42,7 @@
|
||||
<div class="flex gap-4">
|
||||
<div class="basis-full">
|
||||
<TextField
|
||||
name="name"
|
||||
label="Name"
|
||||
bind:value={$form.name}
|
||||
error={$errors.name}
|
||||
@@ -49,6 +51,7 @@
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
name="icon"
|
||||
label="Icon"
|
||||
bind:value={$form.icon}
|
||||
icon={$form.icon}
|
||||
@@ -58,6 +61,7 @@
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
name="description"
|
||||
label="Description"
|
||||
bind:value={$form.description}
|
||||
error={$errors.description}
|
||||
@@ -65,12 +69,14 @@
|
||||
></Textarea>
|
||||
<div class="flex gap-4">
|
||||
<TextField
|
||||
name="lat"
|
||||
label="Latitude"
|
||||
bind:value={$form.lat}
|
||||
error={$errors.lat}
|
||||
on:change={handleChange}
|
||||
></TextField>
|
||||
<TextField
|
||||
name="lon"
|
||||
label="Longitude"
|
||||
bind:value={$form.lon}
|
||||
error={$errors.lat}
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
interface SummitLog {
|
||||
id: string;
|
||||
date: string;
|
||||
text: string;
|
||||
import { parse } from "date-fns";
|
||||
import { date, number, object, string } from "yup";
|
||||
|
||||
class SummitLog {
|
||||
id?: string;
|
||||
date: string;
|
||||
text?: string;
|
||||
|
||||
constructor(date: string, params?: { id?: string, text?: string }) {
|
||||
this.date = date;
|
||||
this.id = params?.id;
|
||||
this.text = params?.text ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
export type {SummitLog}
|
||||
const summitLogSchema = object<SummitLog>({
|
||||
id: string().optional(),
|
||||
date: date().transform((value, originalValue, context) => {
|
||||
if (context.isType(value)) return value;
|
||||
return parse(originalValue, 'dd.MM.yyyy', new Date());
|
||||
}).required('Required').typeError('Invalid Date'),
|
||||
text: string().optional(),
|
||||
});
|
||||
|
||||
|
||||
export { SummitLog, summitLogSchema }
|
||||
@@ -1,3 +1,4 @@
|
||||
import { array, number, object, string } from "yup";
|
||||
import type { Category } from "./category";
|
||||
import type { SummitLog } from "./summit_log";
|
||||
import type { Waypoint } from "./waypoint";
|
||||
@@ -20,38 +21,57 @@ class Trail {
|
||||
tags?: string[];
|
||||
description?: string;
|
||||
|
||||
_photoFiles: File[]
|
||||
|
||||
constructor(name: string,
|
||||
id?: string,
|
||||
location?: string,
|
||||
distance?: number,
|
||||
elevation_gain?: number,
|
||||
duration?: number,
|
||||
thumbnail?: string,
|
||||
photos?: string[],
|
||||
gpx?: string,
|
||||
category?: Category,
|
||||
waypoints?: Waypoint[],
|
||||
summit_logs?: SummitLog[],
|
||||
tags?: string[],
|
||||
description?: string
|
||||
) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.location = location;
|
||||
this.distance = distance;
|
||||
this.elevation_gain = elevation_gain;
|
||||
this.duration = duration;
|
||||
this.thumbnail = thumbnail;
|
||||
this.photos = photos ?? [];
|
||||
this.gpx = gpx;
|
||||
this.expand = {
|
||||
category: category,
|
||||
waypoints: waypoints ?? [],
|
||||
summit_logs: summit_logs ?? []
|
||||
params?: {
|
||||
id?: string,
|
||||
location?: string,
|
||||
distance?: number,
|
||||
elevation_gain?: number,
|
||||
duration?: number,
|
||||
thumbnail?: string,
|
||||
photos?: string[],
|
||||
gpx?: string,
|
||||
category?: Category,
|
||||
waypoints?: Waypoint[],
|
||||
summit_logs?: SummitLog[],
|
||||
tags?: string[],
|
||||
description?: string
|
||||
}
|
||||
this.tags = tags ?? []
|
||||
this.description = description ?? "";
|
||||
|
||||
) {
|
||||
this.id = params?.id;
|
||||
this.name = name;
|
||||
this.location = params?.location;
|
||||
this.distance = params?.distance;
|
||||
this.elevation_gain = params?.elevation_gain;
|
||||
this.duration = params?.duration;
|
||||
this.thumbnail = params?.thumbnail;
|
||||
this.photos = params?.photos ?? [];
|
||||
this.gpx = params?.gpx;
|
||||
this.expand = {
|
||||
category: params?.category,
|
||||
waypoints: params?.waypoints ?? [],
|
||||
summit_logs: params?.summit_logs ?? []
|
||||
}
|
||||
this.tags = params?.tags ?? []
|
||||
this.description = params?.description ?? "";
|
||||
this._photoFiles = [];
|
||||
}
|
||||
}
|
||||
|
||||
export { Trail };
|
||||
const trailSchema = object<SummitLog>({
|
||||
id: string().optional(),
|
||||
name: string().required("Required"),
|
||||
location: string().optional(),
|
||||
distance: number().optional(),
|
||||
elevation_gain: number().optional(),
|
||||
duration: number().optional(),
|
||||
thumbnail: string().optional(),
|
||||
photos: array(string()).optional(),
|
||||
gpx: string().optional(),
|
||||
description: string().optional()
|
||||
});
|
||||
|
||||
export { Trail, trailSchema };
|
||||
|
||||
@@ -25,8 +25,8 @@ const waypointSchema = object<Waypoint>({
|
||||
id: string().optional(),
|
||||
name: string().optional(),
|
||||
description: string().optional(),
|
||||
lat: number().min(0).required('Required').typeError('Invalid latitude'),
|
||||
lon: number().min(0).required('Required').typeError('Invalid longitude'),
|
||||
lat: number().required('Required').typeError('Invalid latitude'),
|
||||
lon: number().required('Required').typeError('Invalid longitude'),
|
||||
icon: string().optional()
|
||||
});
|
||||
|
||||
|
||||
21
web/src/lib/stores/summit_log_store.ts
Normal file
21
web/src/lib/stores/summit_log_store.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { pb } from "$lib/constants";
|
||||
import { SummitLog } from "$lib/models/summit_log";
|
||||
import { writable, type Writable } from "svelte/store";
|
||||
|
||||
export const summitLog: Writable<SummitLog> = writable(new SummitLog(new Date().toISOString()));
|
||||
|
||||
export async function summit_logs_create(bodyParams?: { [key: string]: any; } | FormData) {
|
||||
const model = await pb
|
||||
.collection("summit_logs")
|
||||
.create<SummitLog>(bodyParams);
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
export async function summit_logs_delete(id: string) {
|
||||
const success = await pb
|
||||
.collection("summit_logs_delete")
|
||||
.delete(id);
|
||||
|
||||
return success;
|
||||
}
|
||||
@@ -23,6 +23,31 @@ export async function trails_show(id: string, loadGPX?: boolean) {
|
||||
trail.set(response);
|
||||
}
|
||||
|
||||
export async function trails_create(bodyParams?: { [key: string]: any; } | FormData) {
|
||||
const model = await pb
|
||||
.collection("trails")
|
||||
.create<Trail>(bodyParams);
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
export async function trails_update(id: string, bodyParams?: { [key: string]: any; } | FormData) {
|
||||
const model = await pb
|
||||
.collection("trails")
|
||||
.update<Trail>(id, bodyParams);
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
|
||||
export async function trails_delete(id: string) {
|
||||
const success = await pb
|
||||
.collection("trails")
|
||||
.delete(id);
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
async function fetchGPX(trail: Trail) {
|
||||
if (!trail.gpx) {
|
||||
return "";
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
import { pb } from "$lib/constants";
|
||||
import { Waypoint } from "$lib/models/waypoint";
|
||||
import { writable, type Writable } from "svelte/store";
|
||||
|
||||
export const waypoint: Writable<Waypoint> = writable(new Waypoint(0, 0));
|
||||
|
||||
export async function waypoints_create(bodyParams?: { [key: string]: any; } | FormData) {
|
||||
|
||||
const model = await pb
|
||||
.collection("waypoints")
|
||||
.create<Waypoint>(bodyParams);
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
export async function waypoints_delete(id: string) {
|
||||
const success = await pb
|
||||
.collection("waypoints")
|
||||
.delete(id);
|
||||
|
||||
return success;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Waypoint } from "$lib/models/waypoint";
|
||||
|
||||
export function formatTimeHHMM(minutes?: number) {
|
||||
if(!minutes) {
|
||||
if (!minutes) {
|
||||
return "-";
|
||||
}
|
||||
const m = minutes % 60;
|
||||
@@ -12,7 +11,7 @@ export function formatTimeHHMM(minutes?: number) {
|
||||
}
|
||||
|
||||
export function formatMeters(meters?: number) {
|
||||
if(!meters) {
|
||||
if (!meters) {
|
||||
return "-";
|
||||
}
|
||||
if (meters % 1 === 0) {
|
||||
@@ -22,16 +21,3 @@ export function formatMeters(meters?: number) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export function formatISODate(isoTimestamp?: string) {
|
||||
if(!isoTimestamp) {
|
||||
return "-"
|
||||
}
|
||||
const date = new Date(isoTimestamp);
|
||||
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0'); // Month is zero-based
|
||||
const year = date.getFullYear();
|
||||
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
255
web/src/lib/vendor/svelte-form-lib/create-form.js
vendored
Normal file
255
web/src/lib/vendor/svelte-form-lib/create-form.js
vendored
Normal file
@@ -0,0 +1,255 @@
|
||||
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 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);
|
||||
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),
|
||||
);
|
||||
|
||||
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,
|
||||
}),
|
||||
),
|
||||
};
|
||||
};
|
||||
102
web/src/lib/vendor/svelte-form-lib/index.d.ts
vendored
Normal file
102
web/src/lib/vendor/svelte-form-lib/index.d.ts
vendored
Normal file
@@ -0,0 +1,102 @@
|
||||
/// <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>;
|
||||
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};
|
||||
1
web/src/lib/vendor/svelte-form-lib/index.js
vendored
Normal file
1
web/src/lib/vendor/svelte-form-lib/index.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export {createForm} from './create-form';
|
||||
135
web/src/lib/vendor/svelte-form-lib/util.js
vendored
Normal file
135
web/src/lib/vendor/svelte-form-lib/util.js
vendored
Normal file
@@ -0,0 +1,135 @@
|
||||
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 JSON.parse(JSON.stringify(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,
|
||||
};
|
||||
Reference in New Issue
Block a user