finishes trail create

This commit is contained in:
Christian Beutel
2024-01-29 21:30:54 +01:00
parent 2d4c673060
commit efaf0d4e5c
47 changed files with 1739 additions and 130 deletions

16
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,16 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "dev",
"path": "web",
"problemMatcher": [
"$vite"
],
"label": "npm: dev - web",
"detail": "vite dev",
"isBackground": true
}
]
}

View File

@@ -0,0 +1,58 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("e864strfxo14pm4")
// remove
collection.schema.removeField("jjiidvbm")
// add
collection.schema.addField(new SchemaField({
"system": false,
"id": "mcqce8l9",
"name": "thumbnail",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
}))
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("e864strfxo14pm4")
// add
collection.schema.addField(new SchemaField({
"system": false,
"id": "jjiidvbm",
"name": "thumbnail",
"type": "file",
"required": false,
"presentable": false,
"unique": false,
"options": {
"mimeTypes": [
"image/png",
"image/vnd.mozilla.apng",
"image/jpeg",
"image/webp",
"image/svg+xml"
],
"thumbs": [],
"maxSelect": 1,
"maxSize": 5242880,
"protected": false
}
}))
// remove
collection.schema.removeField("mcqce8l9")
return dao.saveCollection(collection)
})

View File

@@ -0,0 +1,76 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("goeo2ubp103rzp9")
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "ygotgxzy",
"name": "lat",
"type": "number",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"noDecimal": false
}
}))
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "q0ygnxd2",
"name": "lon",
"type": "number",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"noDecimal": false
}
}))
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("goeo2ubp103rzp9")
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "ygotgxzy",
"name": "lat",
"type": "number",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": 0,
"max": null,
"noDecimal": false
}
}))
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "q0ygnxd2",
"name": "lon",
"type": "number",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": 0,
"max": null,
"noDecimal": false
}
}))
return dao.saveCollection(collection)
})

View File

@@ -0,0 +1,16 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("e864strfxo14pm4")
collection.createRule = ""
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("e864strfxo14pm4")
collection.createRule = null
return dao.saveCollection(collection)
})

View File

@@ -0,0 +1,84 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("e864strfxo14pm4")
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "e1lwowvd",
"name": "summit_logs",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "dd2l9a4vxpy2ni8",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": null,
"displayFields": null
}
}))
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "ppq2sist",
"name": "waypoints",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "goeo2ubp103rzp9",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": null,
"displayFields": null
}
}))
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("e864strfxo14pm4")
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "e1lwowvd",
"name": "summit_logs",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "dd2l9a4vxpy2ni8",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": null,
"displayFields": null
}
}))
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "ppq2sist",
"name": "waypoints",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "goeo2ubp103rzp9",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": null,
"displayFields": null
}
}))
return dao.saveCollection(collection)
})

View File

@@ -0,0 +1,16 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("goeo2ubp103rzp9")
collection.createRule = ""
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("goeo2ubp103rzp9")
collection.createRule = null
return dao.saveCollection(collection)
})

View File

@@ -0,0 +1,84 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("e864strfxo14pm4")
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "e1lwowvd",
"name": "summit_logs",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "dd2l9a4vxpy2ni8",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": null,
"displayFields": null
}
}))
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "ppq2sist",
"name": "waypoints",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "goeo2ubp103rzp9",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": null,
"displayFields": null
}
}))
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("e864strfxo14pm4")
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "e1lwowvd",
"name": "summit_logs",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "dd2l9a4vxpy2ni8",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": null,
"displayFields": null
}
}))
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "ppq2sist",
"name": "waypoints",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "goeo2ubp103rzp9",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": null,
"displayFields": null
}
}))
return dao.saveCollection(collection)
})

View File

@@ -0,0 +1,16 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("dd2l9a4vxpy2ni8")
collection.createRule = ""
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("dd2l9a4vxpy2ni8")
collection.createRule = null
return dao.saveCollection(collection)
})

View File

@@ -0,0 +1,16 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("dd2l9a4vxpy2ni8")
collection.name = "summit_logs"
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("dd2l9a4vxpy2ni8")
collection.name = "summit_log"
return dao.saveCollection(collection)
})

View File

@@ -0,0 +1,16 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("e864strfxo14pm4")
collection.updateRule = ""
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("e864strfxo14pm4")
collection.updateRule = null
return dao.saveCollection(collection)
})

View File

@@ -0,0 +1,16 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("e864strfxo14pm4")
collection.deleteRule = ""
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("e864strfxo14pm4")
collection.deleteRule = null
return dao.saveCollection(collection)
})

View File

@@ -0,0 +1,18 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("goeo2ubp103rzp9")
collection.updateRule = ""
collection.deleteRule = ""
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("goeo2ubp103rzp9")
collection.updateRule = null
collection.deleteRule = null
return dao.saveCollection(collection)
})

View File

@@ -0,0 +1,18 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("dd2l9a4vxpy2ni8")
collection.updateRule = ""
collection.deleteRule = ""
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("dd2l9a4vxpy2ni8")
collection.updateRule = null
collection.deleteRule = null
return dao.saveCollection(collection)
})

35
web/package-lock.json generated
View File

@@ -10,12 +10,13 @@
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^6.5.1", "@fortawesome/fontawesome-free": "^6.5.1",
"@types/leaflet.awesome-markers": "^2.0.28", "@types/leaflet.awesome-markers": "^2.0.28",
"crypto-random-string": "^5.0.0",
"date-fns": "^3.3.1",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet-gpx": "^1.7.0", "leaflet-gpx": "^1.7.0",
"leaflet.awesome-markers": "^2.0.5", "leaflet.awesome-markers": "^2.0.5",
"photoswipe": "^5.4.3", "photoswipe": "^5.4.3",
"pocketbase": "^0.21.0", "pocketbase": "^0.21.0",
"svelte-forms-lib": "^2.0.1",
"yup": "^1.3.3" "yup": "^1.3.3"
}, },
"devDependencies": { "devDependencies": {
@@ -1341,6 +1342,20 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/crypto-random-string": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-5.0.0.tgz",
"integrity": "sha512-KWjTXWwxFd6a94m5CdRGW/t82Tr8DoBc9dNnPCAbFI1EBweN6v1tv8y4Y1m7ndkp/nkIBRxUxAzpaBnR2k3bcQ==",
"dependencies": {
"type-fest": "^2.12.2"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/css-tree": { "node_modules/css-tree": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
@@ -1366,6 +1381,15 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/date-fns": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.3.1.tgz",
"integrity": "sha512-y8e109LYGgoQDveiEBD3DYXKba1jWf5BA8YU1FL5Tvm0BTdEfy54WLCwnuYWZNnzzvALy/QQ4Hov+Q9RVRv+Zw==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.3.4", "version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@@ -1408,6 +1432,7 @@
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@@ -3131,14 +3156,6 @@
"svelte": "^3.55.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0" "svelte": "^3.55.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0"
} }
}, },
"node_modules/svelte-forms-lib": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/svelte-forms-lib/-/svelte-forms-lib-2.0.1.tgz",
"integrity": "sha512-kwbJ3ynsepsrrJyAMrvSc0Lj/myc9vfI2DL8OKxgArZimrNYsRh1gENYhvrcKEI3BiZrv8q3VFfmGo/GMyk7Zg==",
"dependencies": {
"dequal": "^2.0.2"
}
},
"node_modules/svelte-hmr": { "node_modules/svelte-hmr": {
"version": "0.15.3", "version": "0.15.3",
"resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.15.3.tgz", "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.15.3.tgz",

View File

@@ -33,12 +33,13 @@
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^6.5.1", "@fortawesome/fontawesome-free": "^6.5.1",
"@types/leaflet.awesome-markers": "^2.0.28", "@types/leaflet.awesome-markers": "^2.0.28",
"crypto-random-string": "^5.0.0",
"date-fns": "^3.3.1",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"leaflet-gpx": "^1.7.0", "leaflet-gpx": "^1.7.0",
"leaflet.awesome-markers": "^2.0.5", "leaflet.awesome-markers": "^2.0.5",
"photoswipe": "^5.4.3", "photoswipe": "^5.4.3",
"pocketbase": "^0.21.0", "pocketbase": "^0.21.0",
"svelte-forms-lib": "^2.0.1",
"yup": "^1.3.3" "yup": "^1.3.3"
} }
} }

6
web/src/app.d.ts vendored
View File

@@ -1,3 +1,6 @@
import PocketBase from 'pocketbase';
// See https://kit.svelte.dev/docs/types#app // See https://kit.svelte.dev/docs/types#app
// for information about these interfaces // for information about these interfaces
declare global { declare global {
@@ -7,6 +10,9 @@ declare global {
// interface PageData {} // interface PageData {}
// interface PageState {} // interface PageState {}
// interface Platform {} // interface Platform {}
interface Locals {
pb: PocketBase
}
} }
} }

View File

@@ -3,17 +3,21 @@
@import 'tailwindcss/components'; @import 'tailwindcss/components';
.btn-primary { .btn-primary {
@apply text-white rounded-lg px-4 py-2 bg-primary font-semibold transition-all hover:bg-gray-900 focus:ring-4 ring-gray-400 @apply h-10 text-white rounded-lg px-4 py-2 bg-primary font-semibold transition-all hover:bg-gray-900 focus:ring-4 ring-gray-400
} }
.btn-large { .btn-large {
@apply py-3 @apply h-12 leading-8
} }
.btn-secondary { .btn-secondary {
@apply rounded-lg px-4 py-2 border border-primary font-medium transition-all hover:bg-gray-100 focus:ring-4 ring-gray-200 @apply rounded-lg px-4 py-2 border border-primary font-medium transition-all hover:bg-gray-100 focus:ring-4 ring-gray-200
} }
.btn-disabled {
@apply bg-gray-500 hover:bg-gray-500
}
.tab { .tab {
@apply p-4 rounded-t-lg @apply p-4 rounded-t-lg
} }
@@ -29,3 +33,62 @@
.tab-active:hover { .tab-active:hover {
@apply bg-primary @apply bg-primary
} }
.tooltip {
position: relative;
}
.tooltip:before,
.tooltip:after {
display: block;
opacity: 0;
pointer-events: none;
position: absolute;
transform: translate3d(0, -10px, 0);
transition: all .15s ease-in-out;
}
.tooltip:before {
background: rgba(36, 39, 52, 0.75);
border-radius: 4px;
color: #fff;
content: attr(data-title);
font-size: 14px;
padding: 6px 10px;
top: 24px;
white-space: nowrap;
}
.tooltip:hover:after,
.tooltip:hover:before {
opacity: 1;
transform: translate3d(0, 0, 0);
}
.spinner {
display: inline-block;
width: 32px;
height: 32px;
}
.spinner:after {
content: " ";
display: block;
width: 32px;
height: 32px;
border-radius: 50%;
border: 6px solid #fff;
border-color: #fff transparent #fff transparent;
animation: spinner 1.2s linear infinite;
}
@keyframes spinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

23
web/src/hooks.server.ts Normal file
View File

@@ -0,0 +1,23 @@
import PocketBase from 'pocketbase';
export async function handle({ event, resolve }) {
event.locals.pb = new PocketBase('http://127.0.0.1:8090');
// load the store data from the request cookie string
event.locals.pb.authStore.loadFromCookie(event.request.headers.get('cookie') || '');
try {
// get an up-to-date auth store state by verifying and refreshing the loaded auth model (if any)
event.locals.pb.authStore.isValid && await event.locals.pb.collection('users').authRefresh();
} catch (_) {
// clear the auth store on failed refresh
event.locals.pb.authStore.clear();
}
const response = await resolve(event);
// send back the default 'pb_auth' cookie to the client with the latest store state
response.headers.append('set-cookie', event.locals.pb.authStore.exportToCookie());
return response;
}

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

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

View File

@@ -7,7 +7,9 @@
let isOpen = false; let isOpen = false;
function toggleMenu() { function toggleMenu(e: MouseEvent) {
e.stopPropagation();
e.preventDefault();
isOpen = !isOpen; isOpen = !isOpen;
} }
@@ -15,7 +17,7 @@
isOpen = false; isOpen = false;
} }
function handleItemClick(item: { text: string, value: any }) { function handleItemClick(item: { text: string; value: any }) {
dispatch("change", item); dispatch("change", item);
closeMenu(); closeMenu();
} }
@@ -27,6 +29,7 @@
<button <button
class="hover:bg-gray-100 w-8 h-8 rounded-full" class="hover:bg-gray-100 w-8 h-8 rounded-full"
on:click={toggleMenu} on:click={toggleMenu}
type="button"
> >
<i class="fa fa-ellipsis-vertical"></i> <i class="fa fa-ellipsis-vertical"></i>
</button> </button>
@@ -42,8 +45,8 @@
<li <li
class="menu-item p-4 cursor-pointer hover:bg-gray-100 focus:bg-gray-200 transition-colors" class="menu-item p-4 cursor-pointer hover:bg-gray-100 focus:bg-gray-200 transition-colors"
tabindex="0" tabindex="0"
on:mouseup={() => handleItemClick(item)} on:mouseup|stopPropagation={() => handleItemClick(item)}
on:keydown={() => handleItemClick(item)} on:keydown|stopPropagation={() => handleItemClick(item)}
> >
{item.text} {item.text}
</li> </li>

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
export let name: string = "";
export let value: any; export let value: any;
export let items: { text: string; value: any }[] = []; export let items: { text: string; value: any }[] = [];
export let label: string = ""; export let label: string = "";
@@ -11,6 +12,7 @@
</p> </p>
{/if} {/if}
<select <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" 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 bind:value
> >

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
export let name: string = "";
export let value: string | number = ""; export let value: string | number = "";
export let placeholder: string = ""; export let placeholder: string = "";
export let label: string = ""; export let label: string = "";
@@ -17,6 +18,7 @@
<i class="fa fa-{icon}"></i> <i class="fa fa-{icon}"></i>
{/if} {/if}
<input <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="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:border-red-400={error.length > 0}
class:bg-red-50={error.length > 0} class:bg-red-50={error.length > 0}

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
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;
@@ -13,6 +14,7 @@
</p> </p>
{/if} {/if}
<textarea <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" 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} {rows}
{placeholder} {placeholder}

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

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

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

View File

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

View File

@@ -2,17 +2,26 @@
import type { Trail } from "$lib/models/trail"; import type { Trail } from "$lib/models/trail";
import { getFileURL } from "$lib/util/file_util"; import { getFileURL } from "$lib/util/file_util";
import { formatMeters, formatTimeHHMM } from "$lib/util/format_util"; import { formatMeters, formatTimeHHMM } from "$lib/util/format_util";
import Dropdown from "../base/dropdown.svelte";
export let trail: Trail; export let trail: Trail;
const dropdownItems = [
{ text: "Edit", value: "edit" },
{ text: "Delete", value: "delete" },
];
</script> </script>
<div <div class="trail-card rounded-2xl shadow-md w-72 cursor-pointer">
class="trail-card rounded-2xl shadow-md w-72 overflow-hidden cursor-pointer" <div class="w-full h-48 overflow-hidden rounded-t-2xl">
> <img src={getFileURL(trail, trail.thumbnail)} alt="" />
<img class="w-full h-48" src={getFileURL(trail, trail.thumbnail)} alt="" /> </div>
<div class="p-4"> <div class="p-4">
<div> <div>
<div class="flex justify-between items-center">
<h4 class="font-semibold text-lg">{trail.name}</h4> <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> <h5><i class="fa fa-location-dot mr-3"></i>{trail.location}</h5>
</div> </div>
<div class="flex mt-2 gap-4 text-sm text-gray-500"> <div class="flex mt-2 gap-4 text-sm text-gray-500">

View File

@@ -2,11 +2,12 @@
import { Waypoint, waypointSchema } from "$lib/models/waypoint"; import { Waypoint, waypointSchema } from "$lib/models/waypoint";
import { createEventDispatcher } from "svelte"; 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 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 { waypoint } from "$lib/stores/waypoint_store"; import { waypoint } from "$lib/stores/waypoint_store";
import { util } from "$lib/vendor/svelte-form-lib/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;
@@ -21,7 +22,7 @@
closeModal!(); closeModal!();
}, },
}); });
$: form.set($waypoint); $: form.set(util.cloneDeep($waypoint));
</script> </script>
<Modal <Modal
@@ -41,6 +42,7 @@
<div class="flex gap-4"> <div class="flex gap-4">
<div class="basis-full"> <div class="basis-full">
<TextField <TextField
name="name"
label="Name" label="Name"
bind:value={$form.name} bind:value={$form.name}
error={$errors.name} error={$errors.name}
@@ -49,6 +51,7 @@
</div> </div>
<TextField <TextField
name="icon"
label="Icon" label="Icon"
bind:value={$form.icon} bind:value={$form.icon}
icon={$form.icon} icon={$form.icon}
@@ -58,6 +61,7 @@
</div> </div>
<Textarea <Textarea
name="description"
label="Description" label="Description"
bind:value={$form.description} bind:value={$form.description}
error={$errors.description} error={$errors.description}
@@ -65,12 +69,14 @@
></Textarea> ></Textarea>
<div class="flex gap-4"> <div class="flex gap-4">
<TextField <TextField
name="lat"
label="Latitude" label="Latitude"
bind:value={$form.lat} bind:value={$form.lat}
error={$errors.lat} error={$errors.lat}
on:change={handleChange} on:change={handleChange}
></TextField> ></TextField>
<TextField <TextField
name="lon"
label="Longitude" label="Longitude"
bind:value={$form.lon} bind:value={$form.lon}
error={$errors.lat} error={$errors.lat}

View File

@@ -1,7 +1,26 @@
interface SummitLog { import { parse } from "date-fns";
id: string; import { date, number, object, string } from "yup";
class SummitLog {
id?: string;
date: string; date: string;
text: 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 }

View File

@@ -1,3 +1,4 @@
import { array, number, object, string } from "yup";
import type { Category } from "./category"; import type { Category } from "./category";
import type { SummitLog } from "./summit_log"; import type { SummitLog } from "./summit_log";
import type { Waypoint } from "./waypoint"; import type { Waypoint } from "./waypoint";
@@ -20,7 +21,10 @@ class Trail {
tags?: string[]; tags?: string[];
description?: string; description?: string;
_photoFiles: File[]
constructor(name: string, constructor(name: string,
params?: {
id?: string, id?: string,
location?: string, location?: string,
distance?: number, distance?: number,
@@ -34,24 +38,40 @@ class Trail {
summit_logs?: SummitLog[], summit_logs?: SummitLog[],
tags?: string[], tags?: string[],
description?: 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 ?? []
} }
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 };

View File

@@ -25,8 +25,8 @@ const waypointSchema = object<Waypoint>({
id: string().optional(), id: string().optional(),
name: string().optional(), name: string().optional(),
description: string().optional(), description: string().optional(),
lat: number().min(0).required('Required').typeError('Invalid latitude'), lat: number().required('Required').typeError('Invalid latitude'),
lon: number().min(0).required('Required').typeError('Invalid longitude'), lon: number().required('Required').typeError('Invalid longitude'),
icon: string().optional() icon: string().optional()
}); });

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

View File

@@ -23,6 +23,31 @@ export async function trails_show(id: string, loadGPX?: boolean) {
trail.set(response); 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) { async function fetchGPX(trail: Trail) {
if (!trail.gpx) { if (!trail.gpx) {
return ""; return "";

View File

@@ -1,4 +1,22 @@
import { pb } from "$lib/constants";
import { Waypoint } from "$lib/models/waypoint"; import { Waypoint } from "$lib/models/waypoint";
import { writable, type Writable } from "svelte/store"; import { writable, type Writable } from "svelte/store";
export const waypoint: Writable<Waypoint> = writable(new Waypoint(0, 0)); 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;
}

View File

@@ -1,4 +1,3 @@
import type { Waypoint } from "$lib/models/waypoint";
export function formatTimeHHMM(minutes?: number) { export function formatTimeHHMM(minutes?: number) {
if (!minutes) { if (!minutes) {
@@ -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}`;
}

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

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

View File

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

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

View File

@@ -1,8 +1,37 @@
<script lang="ts"> <script lang="ts">
import CategoryCard from "$lib/components/category_card.svelte"; import CategoryCard from "$lib/components/category_card.svelte";
import TrailCard from "$lib/components/trail_card.svelte"; import TrailCard from "$lib/components/trail/trail_card.svelte";
import { pb } from "$lib/constants";
import type { Trail } from "$lib/models/trail";
import { categories } from "$lib/stores/category_store"; import { categories } from "$lib/stores/category_store";
import { trails } from "$lib/stores/trail_store"; import { summit_logs_delete } from "$lib/stores/summit_log_store";
import {
trails,
trails_delete,
trails_index,
} from "$lib/stores/trail_store";
import { waypoints_delete } from "$lib/stores/waypoint_store";
async function handleDropdownClick(
currentTrail: Trail,
item: { text: string; value: any },
) {
if (item.value == "delete") {
if (currentTrail.expand.waypoints) {
for (const waypoint of currentTrail.expand.waypoints) {
waypoints_delete(waypoint.id!);
}
}
if (currentTrail.expand.summit_logs) {
for (const summit_log of currentTrail.expand.summit_logs) {
summit_logs_delete(summit_log.id!);
}
}
await trails_delete(currentTrail.id!);
await trails_index();
}
}
</script> </script>
<section class="hero flex justify-center items-center" style="height: 50vh"> <section class="hero flex justify-center items-center" style="height: 50vh">
@@ -26,7 +55,12 @@
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 justify-items-center gap-8 py-8" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 justify-items-center gap-8 py-8"
> >
{#each $trails as trail} {#each $trails as trail}
<a href="/trail/{trail.id}"> <TrailCard {trail}></TrailCard></a> <a href="/trail/{trail.id}">
<TrailCard
{trail}
on:change={(e) => handleDropdownClick(trail, e.detail)}
></TrailCard></a
>
{/each} {/each}
</div> </div>
</section> </section>

View File

@@ -1,8 +1,8 @@
import { categories_index } from "$lib/stores/category_store"; import { categories_index } from "$lib/stores/category_store";
import { trails_index } from "$lib/stores/trail_store"; import { trails_index } from "$lib/stores/trail_store";
import type { Load } from "@sveltejs/kit"; import type { ServerLoad } from "@sveltejs/kit";
export const load: Load = async ({ params }) => { export const load: ServerLoad = async ({ params, locals }) => {
await trails_index() await trails_index()
await categories_index() await categories_index()
}; };

View File

@@ -1,29 +1,80 @@
<script lang="ts"> <script lang="ts">
import Modal from "$lib/components/base/modal.svelte"; import Button from "$lib/components/base/button.svelte";
import Select from "$lib/components/base/select.svelte"; import Select from "$lib/components/base/select.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 PhotoCard from "$lib/components/photo_card.svelte";
import SummitLogCard from "$lib/components/summit_log/summit_log_card.svelte";
import SummitLogModal from "$lib/components/summit_log/summit_log_modal.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 { Trail } from "$lib/models/trail"; import { pb } from "$lib/constants";
import { SummitLog } from "$lib/models/summit_log";
import { Trail, trailSchema } from "$lib/models/trail";
import { Waypoint } from "$lib/models/waypoint"; import { Waypoint } from "$lib/models/waypoint";
import { categories } from "$lib/stores/category_store"; import { categories } from "$lib/stores/category_store";
import { trail } from "$lib/stores/trail_store"; import { summitLog, summit_logs_create } from "$lib/stores/summit_log_store";
import { waypoint } from "$lib/stores/waypoint_store"; import { trail, trails_create, trails_update } from "$lib/stores/trail_store";
import { waypoint, waypoints_create } from "$lib/stores/waypoint_store";
import { formatMeters, formatTimeHHMM } from "$lib/util/format_util"; import { formatMeters, formatTimeHHMM } from "$lib/util/format_util";
import { createMarkerFromWaypoint } from "$lib/util/leaflet_util"; import { createMarkerFromWaypoint } from "$lib/util/leaflet_util";
import { createForm } from "$lib/vendor/svelte-form-lib";
import cryptoRandomString from "crypto-random-string";
import { format } from "date-fns";
import type { GPX, Icon, LeafletEvent, Map } from "leaflet"; import type { GPX, Icon, LeafletEvent, Map } from "leaflet";
import "leaflet.awesome-markers/dist/leaflet.awesome-markers.css"; import "leaflet.awesome-markers/dist/leaflet.awesome-markers.css";
import "leaflet/dist/leaflet.css"; import "leaflet/dist/leaflet.css";
import { onMount } from "svelte"; import { onMount } from "svelte";
import type { W } from "vitest/dist/reporters-rzC174PQ.js";
let L: any; let L: any;
let map: Map; let map: Map;
let gpxLayer: GPX; let gpxLayer: GPX;
let openWayPointModal: () => void; let openWaypointModal: () => void;
let openSummitLogModal: () => void;
let loading = false;
const { form, errors, handleChange, handleSubmit } = createForm<Trail>({
initialValues: new Trail("adsf", { category: $categories[0] }),
validationSchema: trailSchema,
onSubmit: async (trail) => {
loading = true;
try {
const form = document.getElementById(
"trail-form",
) as HTMLFormElement;
const formData = new FormData(form);
formData.set("category", trail.expand.category!.id);
for (const file of trail._photoFiles) {
formData.append("photos", file);
}
for (const waypoint of trail.expand.waypoints) {
const model = await waypoints_create({...waypoint, marker: undefined});
formData.append("waypoints", model.id!);
}
for (const summitLog of trail.expand.summit_logs) {
const model = await summit_logs_create(summitLog);
formData.append("summit_logs", model.id!);
}
const model = await trails_create(formData);
const thumbnailIndex = trail.photos.findIndex(
(p) => p == trail.thumbnail,
);
let thumbnail: string | undefined = "/imgs/thumbnail.jpg";
if (thumbnailIndex >= 0) {
thumbnail = model.photos.at(thumbnailIndex);
}
await trails_update(model.id!, { thumbnail: thumbnail });
} catch (e) {
} finally {
loading = false;
}
},
});
onMount(async () => { onMount(async () => {
L = (await import("leaflet")).default; L = (await import("leaflet")).default;
@@ -50,7 +101,8 @@
return; return;
} }
$trail.gpx = selectedFile?.name; $form.gpx = selectedFile?.name;
$form.expand.waypoints = [];
var reader = new FileReader(); var reader = new FileReader();
@@ -96,14 +148,14 @@
e.point._latlng.lng, e.point._latlng.lng,
{ name: e.point.options.title, marker: e.point }, { name: e.point.options.title, marker: e.point },
); );
$trail.expand.waypoints.push(waypoint); $form.expand.waypoints.push(waypoint);
} }
}) })
.on("loaded", function (e: LeafletEvent) { .on("loaded", function (e: LeafletEvent) {
map.fitBounds(e.target.getBounds()); map.fitBounds(e.target.getBounds());
$trail.distance = e.target.get_distance(); $form.distance = e.target.get_distance();
$trail.elevation_gain = e.target.get_elevation_gain(); $form.elevation_gain = e.target.get_elevation_gain();
$trail.duration = e.target.get_total_time() / 1000 / 60; $form.duration = e.target.get_total_time() / 1000 / 60;
}) })
.addTo(map); .addTo(map);
}; };
@@ -119,10 +171,10 @@
) { ) {
if (e.detail.value === "edit") { if (e.detail.value === "edit") {
waypoint.set(currentWaypoint); waypoint.set(currentWaypoint);
openWayPointModal(); openWaypointModal();
} else if (e.detail.value === "delete") { } else if (e.detail.value === "delete") {
currentWaypoint.marker?.remove(); currentWaypoint.marker?.remove();
$trail.expand.waypoints = $trail.expand.waypoints.filter( $form.expand.waypoints = $form.expand.waypoints.filter(
(w) => (w) =>
w.lat !== currentWaypoint.lat && w.lat !== currentWaypoint.lat &&
w.lon !== currentWaypoint.lon, w.lon !== currentWaypoint.lon,
@@ -130,39 +182,136 @@
} }
} }
function findMapCenterAndOpenModal() { function beforeWaypointModalOpen() {
const mapCenter = map.getCenter(); const mapCenter = map.getCenter();
waypoint.set(new Waypoint(mapCenter.lat, mapCenter.lng)); waypoint.set(new Waypoint(mapCenter.lat, mapCenter.lng));
openWayPointModal(); openWaypointModal();
} }
function saveWaypoint(e: CustomEvent<Waypoint>) { function saveWaypoint(e: CustomEvent<Waypoint>) {
const savedWaypoint = e.detail; const savedWaypoint = e.detail;
let editedWaypointIndex = $form.expand.waypoints.findIndex(
(s) => s.id == savedWaypoint.id,
);
if ($trail.expand.waypoints.includes(savedWaypoint)) { if (editedWaypointIndex >= 0) {
$trail.expand.waypoints = $trail.expand.waypoints; $form.expand.waypoints[editedWaypointIndex].marker?.remove();
savedWaypoint.marker?.remove(); $form.expand.waypoints[editedWaypointIndex] = savedWaypoint;
} else { } else {
$trail.expand.waypoints = [ savedWaypoint.id = cryptoRandomString({ length: 15 });
...$trail.expand.waypoints,
savedWaypoint, $form.expand.waypoints = [...$form.expand.waypoints, savedWaypoint];
];
} }
const marker = createMarkerFromWaypoint(L, savedWaypoint); const marker = createMarkerFromWaypoint(L, savedWaypoint);
marker.addTo(map); marker.addTo(map);
savedWaypoint.marker = marker; savedWaypoint.marker = marker;
} }
function openPhotoBrowser() {
document.getElementById("photoInput")!.click();
}
function handlePhotoSelection() {
const files = (
document.getElementById("photoInput") as HTMLInputElement
).files;
if (!files) {
return;
}
for (const file of files) {
$form._photoFiles.push(file);
(function (file) {
var reader = new FileReader();
reader.onload = function (e) {
if (e.target?.result) {
if ($form.photos.length == 0) {
$form.thumbnail = e.target.result as string;
}
$form.photos = [
...$form.photos,
e.target.result as string,
];
}
};
reader.readAsDataURL(file);
})(file);
}
}
function makePhotoThumbnail(index: number) {
$form.thumbnail = $form.photos[index];
}
function handlePhotoDelete(index: number) {
const photoToDelete = $form.photos[index];
let reassignThumbnail: boolean = false;
if ($form.thumbnail == photoToDelete) {
reassignThumbnail = true;
}
$form.photos.splice(index, 1);
$form._photoFiles.splice(index, 1);
$form.photos = $form.photos;
if (reassignThumbnail) {
$form.thumbnail = $form.photos[0];
}
}
function beforeSummitLogModalOpen() {
summitLog.set(new SummitLog(new Date().toISOString()));
openSummitLogModal();
}
function saveSummitLog(e: CustomEvent<SummitLog>) {
const savedSummitLog = e.detail;
savedSummitLog.date = format(savedSummitLog.date, "yyyy-MM-dd");
let editedSummitLogIndex = $form.expand.summit_logs.findIndex(
(s) => s.id == savedSummitLog.id,
);
if (editedSummitLogIndex >= 0) {
$form.expand.summit_logs[editedSummitLogIndex] = savedSummitLog;
} else {
savedSummitLog.id = cryptoRandomString({ length: 15 });
$form.expand.summit_logs = [
...$form.expand.summit_logs,
savedSummitLog,
];
}
}
function handleSummitLogMenuClick(
currentSummitLog: SummitLog,
index: number,
e: CustomEvent<{ text: string; value: string }>,
) {
if (e.detail.value === "edit") {
summitLog.set(currentSummitLog);
openSummitLogModal();
} else if (e.detail.value === "delete") {
$form.expand.summit_logs.splice(index, 1);
$form.expand.summit_logs = $form.expand.summit_logs;
}
}
</script> </script>
<main class="grid grid-cols-[400px_1fr]"> <main class="grid grid-cols-[400px_1fr]">
<form class="overflow-scroll flex flex-col gap-4 px-8"> <form
id="trail-form"
class="overflow-y-scroll overflow-x-hidden flex flex-col gap-4 px-8"
on:submit={handleSubmit}
>
<h3 class="text-xl font-semibold">Pick a trail</h3> <h3 class="text-xl font-semibold">Pick a trail</h3>
<button class="btn-primary" on:click={openFileBrowser} <button class="btn-primary" type="button" on:click={openFileBrowser}
>Upload GPX</button >Upload GPX</button
> >
<input <input
type="file" type="file"
name="gpx"
id="fileInput" id="fileInput"
accept=".gpx"
style="display: none;" style="display: none;"
on:change={handleFileSelection} on:change={handleFileSelection}
/> />
@@ -171,33 +320,48 @@
<div class="flex gap-4 justify-around"> <div class="flex gap-4 justify-around">
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<span>Distance</span> <span>Distance</span>
<span class="font-medium">{formatMeters($trail.distance)}</span> <span class="font-medium">{formatMeters($form.distance)}</span>
<input type="hidden" name="distance" value={$form.distance} />
</div> </div>
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<span>Elevation gain</span> <span>Elevation gain</span>
<span class="font-medium" <span class="font-medium"
>{formatMeters($trail.elevation_gain)}</span >{formatMeters($form.elevation_gain)}</span
> >
<input
type="hidden"
name="elevation_gain"
value={$form.elevation_gain}
/>
</div> </div>
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<span>Est. duration</span> <span>Est. duration</span>
<span class="font-medium" <span class="font-medium">{formatTimeHHMM($form.duration)}</span
>{formatTimeHHMM($trail.duration)}</span
> >
<input type="hidden" name="duration" value={$form.duration} />
</div> </div>
</div> </div>
<TextField label="Name*" bind:value={$trail.name}></TextField> <TextField
<Textarea label="Describe your trail" bind:value={$trail.description} name="name"
label="Name"
error={$errors.name}
bind:value={$form.name}
></TextField>
<Textarea
name="description"
label="Describe your trail"
bind:value={$form.description}
></Textarea> ></Textarea>
<Select <Select
name="category"
label="Category" label="Category"
bind:value={$trail.expand.category} bind:value={$form.expand.category}
items={$categories.map((c) => ({ text: c.name, value: c }))} items={$categories.map((c) => ({ text: c.name, value: c }))}
></Select> ></Select>
<hr /> <hr />
<h3 class="text-xl font-semibold">Waypoints</h3> <h3 class="text-xl font-semibold">Waypoints</h3>
<ul> <ul>
{#each $trail.expand.waypoints as waypoint, i} {#each $form.expand.waypoints as waypoint, i}
<li on:mouseenter={() => openMarkerPopup(waypoint)}> <li on:mouseenter={() => openMarkerPopup(waypoint)}>
<WaypointCard <WaypointCard
{waypoint} {waypoint}
@@ -207,14 +371,73 @@
</li> </li>
{/each} {/each}
</ul> </ul>
<button class="btn-secondary" on:click={findMapCenterAndOpenModal} <button
class="btn-secondary"
type="button"
on:click={beforeWaypointModalOpen}
><i class="fa fa-plus mr-2"></i>Add Waypoint</button ><i class="fa fa-plus mr-2"></i>Add Waypoint</button
> >
<hr />
<h3 class="text-xl font-semibold">Photos</h3>
<div class="flex gap-4 max-w-full overflow-x-scroll shrink-0">
<button
class="btn-secondary h-32 w-32 shrink-0 grow-0 basis-auto"
type="button"
on:click={openPhotoBrowser}><i class="fa fa-plus"></i></button
>
<input
type="file"
id="photoInput"
accept="image/*"
multiple={true}
style="display: none;"
on:change={handlePhotoSelection}
/>
{#each $form.photos as photo, i}
<div class="shrink-0 grow-0 basis-auto">
<PhotoCard
src={photo}
on:delete={() => handlePhotoDelete(i)}
isThumbnail={$form.thumbnail === photo}
on:thumbnail={() => makePhotoThumbnail(i)}
></PhotoCard>
</div>
{/each}
</div>
<hr />
<h3 class="text-xl font-semibold">Summit Book</h3>
<ul>
{#each $form.expand.summit_logs as log, i}
<li>
<SummitLogCard
{log}
mode="edit"
on:change={(e) => handleSummitLogMenuClick(log, i, e)}
></SummitLogCard>
</li>
{/each}
</ul>
<button
class="btn-secondary"
type="button"
on:click={beforeSummitLogModalOpen}
><i class="fa fa-plus mr-2"></i>Add Entry</button
>
<hr />
<Button
primary={true}
large={true}
type="submit"
extraClasses="mb-2"
{loading}>Save Trail</Button
>
</form> </form>
<div class="rounded-xl" id="map"></div> <div class="rounded-xl" id="map"></div>
</main> </main>
<WaypointModal bind:openModal={openWayPointModal} on:save={saveWaypoint} <WaypointModal bind:openModal={openWaypointModal} on:save={saveWaypoint}
></WaypointModal> ></WaypointModal>
<SummitLogModal bind:openModal={openSummitLogModal} on:save={saveSummitLog}
></SummitLogModal>
<style> <style>
#map, #map,

View File

@@ -1,4 +1,6 @@
import { Trail } from "$lib/models/trail";
import { categories_index } from "$lib/stores/category_store"; import { categories_index } from "$lib/stores/category_store";
import { trail } from "$lib/stores/trail_store";
import type { Load } from "@sveltejs/kit"; import type { Load } from "@sveltejs/kit";
export const load: Load = async ({ params }) => { export const load: Load = async ({ params }) => {

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import SummitLogCard from "$lib/components/summit_log_card.svelte"; import SummitLogCard from "$lib/components/summit_log/summit_log_card.svelte";
import Tabs from "$lib/components/tabs.svelte"; import Tabs from "$lib/components/tabs.svelte";
import WaypointCard from "$lib/components/waypoint/waypoint_card.svelte"; import WaypointCard from "$lib/components/waypoint/waypoint_card.svelte";
import { trail } from "$lib/stores/trail_store"; import { trail } from "$lib/stores/trail_store";

View File

@@ -2,7 +2,7 @@
"extends": "./.svelte-kit/tsconfig.json", "extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"allowJs": true, "allowJs": true,
"checkJs": true, "checkJs": false,
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"resolveJsonModule": true, "resolveJsonModule": true,