diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..5f56fc9a --- /dev/null +++ b/.vscode/tasks.json @@ -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 + } + ] +} \ No newline at end of file diff --git a/db/pb_migrations/1706529850_updated_trails.js b/db/pb_migrations/1706529850_updated_trails.js new file mode 100644 index 00000000..9746cbf1 --- /dev/null +++ b/db/pb_migrations/1706529850_updated_trails.js @@ -0,0 +1,58 @@ +/// +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) +}) diff --git a/db/pb_migrations/1706533600_updated_waypoints.js b/db/pb_migrations/1706533600_updated_waypoints.js new file mode 100644 index 00000000..4796117f --- /dev/null +++ b/db/pb_migrations/1706533600_updated_waypoints.js @@ -0,0 +1,76 @@ +/// +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) +}) diff --git a/db/pb_migrations/1706548284_updated_trails.js b/db/pb_migrations/1706548284_updated_trails.js new file mode 100644 index 00000000..382cc817 --- /dev/null +++ b/db/pb_migrations/1706548284_updated_trails.js @@ -0,0 +1,16 @@ +/// +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) +}) diff --git a/db/pb_migrations/1706551417_updated_trails.js b/db/pb_migrations/1706551417_updated_trails.js new file mode 100644 index 00000000..093cb6b7 --- /dev/null +++ b/db/pb_migrations/1706551417_updated_trails.js @@ -0,0 +1,84 @@ +/// +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) +}) diff --git a/db/pb_migrations/1706552026_updated_waypoints.js b/db/pb_migrations/1706552026_updated_waypoints.js new file mode 100644 index 00000000..21c37336 --- /dev/null +++ b/db/pb_migrations/1706552026_updated_waypoints.js @@ -0,0 +1,16 @@ +/// +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) +}) diff --git a/db/pb_migrations/1706552830_updated_trails.js b/db/pb_migrations/1706552830_updated_trails.js new file mode 100644 index 00000000..97b890aa --- /dev/null +++ b/db/pb_migrations/1706552830_updated_trails.js @@ -0,0 +1,84 @@ +/// +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) +}) diff --git a/db/pb_migrations/1706552880_updated_summit_log.js b/db/pb_migrations/1706552880_updated_summit_log.js new file mode 100644 index 00000000..24e98f51 --- /dev/null +++ b/db/pb_migrations/1706552880_updated_summit_log.js @@ -0,0 +1,16 @@ +/// +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) +}) diff --git a/db/pb_migrations/1706552897_updated_summit_log.js b/db/pb_migrations/1706552897_updated_summit_log.js new file mode 100644 index 00000000..f3c7e51b --- /dev/null +++ b/db/pb_migrations/1706552897_updated_summit_log.js @@ -0,0 +1,16 @@ +/// +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) +}) diff --git a/db/pb_migrations/1706553833_updated_trails.js b/db/pb_migrations/1706553833_updated_trails.js new file mode 100644 index 00000000..36368481 --- /dev/null +++ b/db/pb_migrations/1706553833_updated_trails.js @@ -0,0 +1,16 @@ +/// +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) +}) diff --git a/db/pb_migrations/1706557823_updated_trails.js b/db/pb_migrations/1706557823_updated_trails.js new file mode 100644 index 00000000..cd4ae5f8 --- /dev/null +++ b/db/pb_migrations/1706557823_updated_trails.js @@ -0,0 +1,16 @@ +/// +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) +}) diff --git a/db/pb_migrations/1706558119_updated_waypoints.js b/db/pb_migrations/1706558119_updated_waypoints.js new file mode 100644 index 00000000..2427c307 --- /dev/null +++ b/db/pb_migrations/1706558119_updated_waypoints.js @@ -0,0 +1,18 @@ +/// +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) +}) diff --git a/db/pb_migrations/1706558125_updated_summit_logs.js b/db/pb_migrations/1706558125_updated_summit_logs.js new file mode 100644 index 00000000..2d993407 --- /dev/null +++ b/db/pb_migrations/1706558125_updated_summit_logs.js @@ -0,0 +1,18 @@ +/// +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) +}) diff --git a/web/package-lock.json b/web/package-lock.json index 35d8b5a6..02e64839 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -10,12 +10,13 @@ "dependencies": { "@fortawesome/fontawesome-free": "^6.5.1", "@types/leaflet.awesome-markers": "^2.0.28", + "crypto-random-string": "^5.0.0", + "date-fns": "^3.3.1", "leaflet": "^1.9.4", "leaflet-gpx": "^1.7.0", "leaflet.awesome-markers": "^2.0.5", "photoswipe": "^5.4.3", "pocketbase": "^0.21.0", - "svelte-forms-lib": "^2.0.1", "yup": "^1.3.3" }, "devDependencies": { @@ -1341,6 +1342,20 @@ "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": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", @@ -1366,6 +1381,15 @@ "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": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -1408,6 +1432,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, "engines": { "node": ">=6" } @@ -3131,14 +3156,6 @@ "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": { "version": "0.15.3", "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.15.3.tgz", diff --git a/web/package.json b/web/package.json index d5fd10a9..c55f8fd0 100644 --- a/web/package.json +++ b/web/package.json @@ -33,12 +33,13 @@ "dependencies": { "@fortawesome/fontawesome-free": "^6.5.1", "@types/leaflet.awesome-markers": "^2.0.28", + "crypto-random-string": "^5.0.0", + "date-fns": "^3.3.1", "leaflet": "^1.9.4", "leaflet-gpx": "^1.7.0", "leaflet.awesome-markers": "^2.0.5", "photoswipe": "^5.4.3", "pocketbase": "^0.21.0", - "svelte-forms-lib": "^2.0.1", "yup": "^1.3.3" } } diff --git a/web/src/app.d.ts b/web/src/app.d.ts index 743f07b2..a8fd724c 100644 --- a/web/src/app.d.ts +++ b/web/src/app.d.ts @@ -1,3 +1,6 @@ +import PocketBase from 'pocketbase'; + + // See https://kit.svelte.dev/docs/types#app // for information about these interfaces declare global { @@ -7,7 +10,10 @@ declare global { // interface PageData {} // interface PageState {} // interface Platform {} + interface Locals { + pb: PocketBase + } } } -export {}; +export { }; diff --git a/web/src/components.css b/web/src/components.css index fd737969..4fe67d7f 100644 --- a/web/src/components.css +++ b/web/src/components.css @@ -3,17 +3,21 @@ @import 'tailwindcss/components'; .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 { - @apply py-3 + @apply h-12 leading-8 } .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 } +.btn-disabled { + @apply bg-gray-500 hover:bg-gray-500 +} + .tab { @apply p-4 rounded-t-lg } @@ -28,4 +32,63 @@ .tab-active:hover { @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); + } } \ No newline at end of file diff --git a/web/src/hooks.server.ts b/web/src/hooks.server.ts new file mode 100644 index 00000000..2d3fd75a --- /dev/null +++ b/web/src/hooks.server.ts @@ -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; +} \ No newline at end of file diff --git a/web/src/lib/components/base/button.svelte b/web/src/lib/components/base/button.svelte new file mode 100644 index 00000000..7e1c343d --- /dev/null +++ b/web/src/lib/components/base/button.svelte @@ -0,0 +1,31 @@ + + + diff --git a/web/src/lib/components/base/datepicker.svelte b/web/src/lib/components/base/datepicker.svelte new file mode 100644 index 00000000..e48ebd13 --- /dev/null +++ b/web/src/lib/components/base/datepicker.svelte @@ -0,0 +1,29 @@ + + +
+ {#if label.length} +

+ {label} +

+ {/if} +
+ 0} + class:bg-red-50={error.length > 0} + type="date" + bind:value + on:change + /> +
+ + + {error} + +
diff --git a/web/src/lib/components/base/dropdown.svelte b/web/src/lib/components/base/dropdown.svelte index 9abe57ef..5a7040ed 100644 --- a/web/src/lib/components/base/dropdown.svelte +++ b/web/src/lib/components/base/dropdown.svelte @@ -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(); } @@ -27,6 +29,7 @@ @@ -42,8 +45,8 @@ diff --git a/web/src/lib/components/base/select.svelte b/web/src/lib/components/base/select.svelte index 720c5692..1f634e3d 100644 --- a/web/src/lib/components/base/select.svelte +++ b/web/src/lib/components/base/select.svelte @@ -1,4 +1,5 @@ + +
+ {#if isThumbnail} + + {/if} +
+ + +
+
diff --git a/web/src/lib/components/summit_log/summit_log_card.svelte b/web/src/lib/components/summit_log/summit_log_card.svelte new file mode 100644 index 00000000..9473d69e --- /dev/null +++ b/web/src/lib/components/summit_log/summit_log_card.svelte @@ -0,0 +1,24 @@ + + +
+
+
{format(log.date, 'dd.MM.yyyy')}
+ + {#if mode == "edit"} + + {/if} +
+ {log.text} +
diff --git a/web/src/lib/components/summit_log/summit_log_modal.svelte b/web/src/lib/components/summit_log/summit_log_modal.svelte new file mode 100644 index 00000000..70ed07ab --- /dev/null +++ b/web/src/lib/components/summit_log/summit_log_modal.svelte @@ -0,0 +1,67 @@ + + + + + +
+ + +
+
diff --git a/web/src/lib/components/summit_log_card.svelte b/web/src/lib/components/summit_log_card.svelte deleted file mode 100644 index 07894f08..00000000 --- a/web/src/lib/components/summit_log_card.svelte +++ /dev/null @@ -1,11 +0,0 @@ - - -
-
{formatISODate(log.date)}
- {log.text} -
diff --git a/web/src/lib/components/trail_card.svelte b/web/src/lib/components/trail/trail_card.svelte similarity index 65% rename from web/src/lib/components/trail_card.svelte rename to web/src/lib/components/trail/trail_card.svelte index eef71f3d..cbad64b4 100644 --- a/web/src/lib/components/trail_card.svelte +++ b/web/src/lib/components/trail/trail_card.svelte @@ -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" }, + ]; -
- +
+
+ +
-

{trail.name}

+
+

{trail.name}

+ +
{trail.location}
diff --git a/web/src/lib/components/waypoint/waypoint_modal.svelte b/web/src/lib/components/waypoint/waypoint_modal.svelte index c6741dce..76aa7031 100644 --- a/web/src/lib/components/waypoint/waypoint_modal.svelte +++ b/web/src/lib/components/waypoint/waypoint_modal.svelte @@ -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));
({ + 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 } \ No newline at end of file diff --git a/web/src/lib/models/trail.ts b/web/src/lib/models/trail.ts index 63f0b278..5d57cc09 100644 --- a/web/src/lib/models/trail.ts +++ b/web/src/lib/models/trail.ts @@ -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({ + 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 }; diff --git a/web/src/lib/models/waypoint.ts b/web/src/lib/models/waypoint.ts index 8cbc986c..61775c7e 100644 --- a/web/src/lib/models/waypoint.ts +++ b/web/src/lib/models/waypoint.ts @@ -25,8 +25,8 @@ const waypointSchema = object({ 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() }); diff --git a/web/src/lib/stores/summit_log_store.ts b/web/src/lib/stores/summit_log_store.ts new file mode 100644 index 00000000..8d07da5f --- /dev/null +++ b/web/src/lib/stores/summit_log_store.ts @@ -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 = 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(bodyParams); + + return model; +} + +export async function summit_logs_delete(id: string) { + const success = await pb + .collection("summit_logs_delete") + .delete(id); + + return success; +} \ No newline at end of file diff --git a/web/src/lib/stores/trail_store.ts b/web/src/lib/stores/trail_store.ts index c262f170..e1a7dc36 100644 --- a/web/src/lib/stores/trail_store.ts +++ b/web/src/lib/stores/trail_store.ts @@ -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(bodyParams); + + return model; +} + +export async function trails_update(id: string, bodyParams?: { [key: string]: any; } | FormData) { + const model = await pb + .collection("trails") + .update(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 ""; diff --git a/web/src/lib/stores/waypoint_store.ts b/web/src/lib/stores/waypoint_store.ts index 85236133..919798e4 100644 --- a/web/src/lib/stores/waypoint_store.ts +++ b/web/src/lib/stores/waypoint_store.ts @@ -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 = writable(new Waypoint(0, 0)); + +export async function waypoints_create(bodyParams?: { [key: string]: any; } | FormData) { + + const model = await pb + .collection("waypoints") + .create(bodyParams); + + return model; +} + +export async function waypoints_delete(id: string) { + const success = await pb + .collection("waypoints") + .delete(id); + + return success; +} \ No newline at end of file diff --git a/web/src/lib/util/format_util.ts b/web/src/lib/util/format_util.ts index 4e10d8b6..afc72a4b 100644 --- a/web/src/lib/util/format_util.ts +++ b/web/src/lib/util/format_util.ts @@ -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}`; -} \ No newline at end of file diff --git a/web/src/lib/vendor/svelte-form-lib/create-form.js b/web/src/lib/vendor/svelte-form-lib/create-form.js new file mode 100644 index 00000000..b88c0691 --- /dev/null +++ b/web/src/lib/vendor/svelte-form-lib/create-form.js @@ -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, + }), + ), + }; +}; diff --git a/web/src/lib/vendor/svelte-form-lib/index.d.ts b/web/src/lib/vendor/svelte-form-lib/index.d.ts new file mode 100644 index 00000000..83a75f6a --- /dev/null +++ b/web/src/lib/vendor/svelte-form-lib/index.d.ts @@ -0,0 +1,102 @@ +/// +import type {SvelteComponentTyped} from 'svelte'; +import type {Readable, Writable} from 'svelte/store'; +import type {ObjectSchema} from 'yup'; + +export type FormProps> = { + context?: FormState; + initialValues?: Inf; + onSubmit?: ((values: Inf) => any) | ((values: Inf) => Promise); + validate?: (values: Inf) => any | undefined; + validationSchema?: ObjectSchema; +} & svelte.JSX.HTMLAttributes; + +type FieldProperties = { + name: string; + type?: string; + value?: string; +} & svelte.JSX.HTMLProps; + +type SelectProperties = { + name: string; +} & svelte.JSX.HTMLProps; + +type ErrorProperties = { + name: string; +} & svelte.JSX.HTMLProps; + +type TextareaProperties = { + name: string; +} & svelte.JSX.HTMLProps; + +type FormState> = { + form: Writable; + errors: Writable>; + touched: Writable>; + modified: Readable>; + isValid: Readable; + isSubmitting: Writable; + isValidating: Writable; + isModified: Readable; + 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; + updateInitialValues: (newValues: Inf) => void; + handleReset: () => void; + state: Readable<{ + form: Inf; + errors: Record; + touched: Record; + modified: Record; + isValid: boolean; + isSubmitting: boolean; + isValidating: boolean; + isModified: boolean; + }>; + handleChange: (event: Event) => any; + handleSubmit: (event: Event) => any; +}; + +declare function createForm>(formProperties: { + initialValues: Inf; + onSubmit: (values: Inf) => any | Promise; + validate?: (values: Inf) => any | undefined; + validationSchema?: ObjectSchema; +}): FormState; + +declare class Form extends SvelteComponentTyped< + FormProps, + Record, + { + default: FormState; + } +> {} + +declare class Field extends SvelteComponentTyped< + FieldProperties, + Record, + Record +> {} + +declare class Textarea extends SvelteComponentTyped< + TextareaProperties, + Record, + Record +> {} + +declare class Select extends SvelteComponentTyped< + SelectProperties, + Record, + {default: any} +> {} + +declare class ErrorMessage extends SvelteComponentTyped< + ErrorProperties, + Record, + {default: any} +> {} + +declare const key: {}; + +export {createForm, key, Form, Field, Select, ErrorMessage, Textarea}; diff --git a/web/src/lib/vendor/svelte-form-lib/index.js b/web/src/lib/vendor/svelte-form-lib/index.js new file mode 100644 index 00000000..db96af5b --- /dev/null +++ b/web/src/lib/vendor/svelte-form-lib/index.js @@ -0,0 +1 @@ +export {createForm} from './create-form'; \ No newline at end of file diff --git a/web/src/lib/vendor/svelte-form-lib/util.js b/web/src/lib/vendor/svelte-form-lib/util.js new file mode 100644 index 00000000..32f6f27f --- /dev/null +++ b/web/src/lib/vendor/svelte-form-lib/util.js @@ -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, +}; diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 871b0730..1014f5e4 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -1,8 +1,37 @@
@@ -26,7 +55,12 @@ class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 justify-items-center gap-8 py-8" > {#each $trails as trail} - + + handleDropdownClick(trail, e.detail)} + > {/each}
diff --git a/web/src/routes/+page.ts b/web/src/routes/+page.ts index 35061744..04c515e4 100644 --- a/web/src/routes/+page.ts +++ b/web/src/routes/+page.ts @@ -1,8 +1,8 @@ import { categories_index } from "$lib/stores/category_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 categories_index() }; \ No newline at end of file diff --git a/web/src/routes/new/+page.svelte b/web/src/routes/new/+page.svelte index bb9c0bd3..6fc7446f 100644 --- a/web/src/routes/new/+page.svelte +++ b/web/src/routes/new/+page.svelte @@ -1,29 +1,80 @@
-
+

Pick a trail

- @@ -171,33 +320,48 @@
Distance - {formatMeters($trail.distance)} + {formatMeters($form.distance)} +
Elevation gain {formatMeters($trail.elevation_gain)}{formatMeters($form.elevation_gain)} +
Est. duration - {formatTimeHHMM($trail.duration)}{formatTimeHHMM($form.duration)} +
- -

Waypoints

    - {#each $trail.expand.waypoints as waypoint, i} + {#each $form.expand.waypoints as waypoint, i}
  • openMarkerPopup(waypoint)}> {/each}
- +
+

Photos

+
+ + + {#each $form.photos as photo, i} +
+ handlePhotoDelete(i)} + isThumbnail={$form.thumbnail === photo} + on:thumbnail={() => makePhotoThumbnail(i)} + > +
+ {/each} +
+
+

Summit Book

+
    + {#each $form.expand.summit_logs as log, i} +
  • + handleSummitLogMenuClick(log, i, e)} + > +
  • + {/each} +
+ +
+
- +