adds gpx upload for summit logs

This commit is contained in:
Christian Beutel
2024-11-01 20:35:50 +01:00
parent 18e57aa211
commit 9c07aae339
13 changed files with 505 additions and 27 deletions

View File

@@ -0,0 +1,57 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models/schema"
)
func init() {
m.Register(func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("dd2l9a4vxpy2ni8")
if err != nil {
return err
}
// add
new_gpx := &schema.SchemaField{}
if err := json.Unmarshal([]byte(`{
"system": false,
"id": "rfwmdcpt",
"name": "gpx",
"type": "file",
"required": false,
"presentable": false,
"unique": false,
"options": {
"mimeTypes": [],
"thumbs": [],
"maxSelect": 1,
"maxSize": 5242880,
"protected": false
}
}`), new_gpx); err != nil {
return err
}
collection.Schema.AddField(new_gpx)
return dao.SaveCollection(collection)
}, func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("dd2l9a4vxpy2ni8")
if err != nil {
return err
}
// remove
collection.Schema.RemoveField("rfwmdcpt")
return dao.SaveCollection(collection)
})
}

View File

@@ -0,0 +1,74 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tools/types"
)
func init() {
m.Register(func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("dd2l9a4vxpy2ni8")
if err != nil {
return err
}
collection.ListRule = types.Pointer("(@request.auth.id != \"\" && @collection.trails.summit_logs.id ?= id && @collection.trails.author ?= @request.auth.id) || (@collection.trails.summit_logs.id ?= id && @collection.trails.public = true) || (author = @request.auth.id)")
collection.ViewRule = types.Pointer("(@request.auth.id != \"\" && @collection.trails.summit_logs.id ?= id && @collection.trails.author ?= @request.auth.id) || (@collection.trails.summit_logs.id ?= id && @collection.trails.public = true) || (author = @request.auth.id)")
collection.UpdateRule = types.Pointer("@request.auth.id != \"\" && @collection.trails.summit_logs.id ?= id && (@collection.trails.author ?= @request.auth.id) || (author = @request.auth.id)")
collection.DeleteRule = types.Pointer("@request.auth.id != \"\" && @collection.trails.summit_logs.id ?= id && (@collection.trails.author ?= @request.auth.id) || (author = @request.auth.id)")
// add
new_author := &schema.SchemaField{}
if err := json.Unmarshal([]byte(`{
"system": false,
"id": "r0mj3tkr",
"name": "author",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
}`), new_author); err != nil {
return err
}
collection.Schema.AddField(new_author)
return dao.SaveCollection(collection)
}, func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("dd2l9a4vxpy2ni8")
if err != nil {
return err
}
collection.ListRule = types.Pointer("(@request.auth.id != \"\" && @collection.trails.summit_logs.id ?= id && @collection.trails.author ?= @request.auth.id) || (@collection.trails.summit_logs.id ?= id && @collection.trails.public = true)")
collection.ViewRule = types.Pointer("(@request.auth.id != \"\" && @collection.trails.summit_logs.id ?= id && @collection.trails.author ?= @request.auth.id) || (@collection.trails.summit_logs.id ?= id && @collection.trails.public = true)")
collection.UpdateRule = types.Pointer("@request.auth.id != \"\" && @collection.trails.summit_logs.id ?= id && (@collection.trails.author ?= @request.auth.id)")
collection.DeleteRule = types.Pointer("@request.auth.id != \"\" && @collection.trails.summit_logs.id ?= id && (@collection.trails.author ?= @request.auth.id)")
// remove
collection.Schema.RemoveField("r0mj3tkr")
return dao.SaveCollection(collection)
})
}

View File

@@ -0,0 +1,23 @@
<script lang="ts">
export let label: string = "";
export let name: string = "";
export let accept: string = "*";
export let files: FileList | null = null;
</script>
<div>
{#if label.length}
<label for={name} class="text-sm font-medium pb-1">
{label}
</label>
{/if}
<input
{name}
{accept}
bind:files
class="cursor-pointer bg-input-background border border-input-border rounded-md p-3 transition-colors focus:border-input-border-focus focus:outline-none focus:ring-0 w-full file:rounded-lg file:px-4 file:py-2 file:border file:border-input-border-focus file:font-medium file:mr-4"
type="file"
on:change
/>
</div>

View File

@@ -5,6 +5,7 @@
export let rows: number = 3;
export let label: string = "";
export let error: string = "";
export let extraClasses: string = "";
</script>
<div>
@@ -15,7 +16,7 @@
{/if}
<textarea
{name}
class="bg-input-background border border-input-border rounded-md p-3 resize-none transition-colors focus:border-input-border-focus focus:outline-none focus:ring-0 w-full"
class="bg-input-background border border-input-border rounded-md p-3 resize-none transition-colors focus:border-input-border-focus focus:outline-none focus:ring-0 w-full {extraClasses}"
{rows}
{placeholder}
class:border-red-400={error.length > 0}

View File

@@ -1,8 +1,25 @@
<script lang="ts">
import type { SummitLog } from "$lib/models/summit_log";
import "leaflet/dist/leaflet.css";
import { _ } from "svelte-i18n";
import Dropdown, { type DropdownItem } from "../base/dropdown.svelte";
import { browser } from "$app/environment";
import GPX from "$lib/models/gpx/gpx";
import {
formatDistance,
formatElevation,
formatTimeHHMM,
} from "$lib/util/format_util";
import { gpx } from "$lib/vendor/toGeoJSON/toGeoJSON";
import type { Map } from "leaflet";
import { onMount } from "svelte";
let map: Map;
let L: any;
let layer: any;
export let index: number = 0;
export let log: SummitLog;
export let mode: "show" | "edit" = "show";
@@ -10,10 +27,83 @@
{ text: $_("edit"), value: "edit" },
{ text: $_("delete"), value: "delete" },
];
let totals: {
distance: number;
elevationGain: number;
duration: number;
} | null = null;
onMount(async () => {
if (!map) {
await initMap();
}
if (log.expand.gpx_data) {
showTrailOnMap();
}
});
async function initMap() {
L = (await import("leaflet")).default;
map = L.map("mini-map-" + index, {
zoomControl: false,
scrollWheelZoom: false,
dragging: false,
});
map.attributionControl.setPrefix(false);
}
$: if (log.expand.gpx_data) {
showTrailOnMap();
} else {
removeTrailFromMap();
}
async function showTrailOnMap() {
if (!log.expand.gpx_data || !browser || !map) {
return;
}
const gpxObject = await GPX.parse(log.expand.gpx_data);
if (gpxObject instanceof Error) {
throw gpxObject;
}
totals = gpxObject.getTotals();
const geoJson = gpx(
new DOMParser().parseFromString(log.expand.gpx_data, "text/xml"),
);
layer = L.geoJson(geoJson, {
filter: (feature: any, layer: any) => {
return feature.geometry.type !== "Point";
},
}).addTo(map)
map.fitBounds(layer.getBounds());
map.invalidateSize();
}
function removeTrailFromMap() {
if (layer) {
map?.removeLayer(layer);
}
totals = null;
}
</script>
<div class="p-4 my-2 border border-input-border rounded-xl">
<div class="flex justify-between items-center mb-2">
<div class="flex items-center gap-x-4">
<div
class="h-24 aspect-square shrink-0 rounded-xl !bg-background"
class:hidden={!log.expand.gpx_data}
id="mini-map-{index}"
></div>
<div class="basis-full">
<div
class="flex justify-between items-center"
class:mb-2={log.text}
>
<h5 class="font-medium mr-2">
{new Date(log.date).toLocaleDateString(undefined, {
month: "2-digit",
@@ -27,5 +117,28 @@
<Dropdown items={dropdownItems} on:change></Dropdown>
{/if}
</div>
<span>{log.text}</span>
{#if totals}
<div
class="flex mt-1 gap-x-4 text-sm text-gray-500 flex-wrap mb-2"
>
<span
><i class="fa fa-left-right mr-2"></i>{formatDistance(
totals.distance,
)}</span
>
<span
><i class="fa fa-up-down mr-2"></i>{formatElevation(
totals.elevationGain,
)}</span
>
<span
><i class="fa fa-clock mr-2"></i>{formatTimeHHMM(
totals.duration,
)}</span
>
</div>
{/if}
<span class="whitespace-pre-wrap">{log.text}</span>
</div>
</div>
</div>

View File

@@ -9,7 +9,8 @@
import { date, object, string } from "yup";
import Datepicker from "../base/datepicker.svelte";
import Modal from "../base/modal.svelte";
import TextField from "../base/text_field.svelte";
import Textarea from "../base/textarea.svelte";
import TrailPicker from "../trail/trail_picker.svelte";
export let openModal: (() => void) | undefined = undefined;
export let closeModal: (() => void) | undefined = undefined;
@@ -25,11 +26,18 @@
initialValues: $summitLog,
validationSchema: summitLogSchema,
onSubmit: async (submittedValues) => {
if(!$form._gpx) {
$form.gpx = "";
}
dispatch("save", submittedValues);
closeModal!();
},
});
$: form.set(util.cloneDeep($summitLog));
$: if ($summitLog._gpx) {
$form._gpx = $summitLog._gpx;
}
</script>
<Modal
@@ -46,7 +54,7 @@
class="modal-content space-y-4"
on:submit={handleSubmit}
>
<div class="flex gap-4">
<div class="flex">
<Datepicker
name="date"
label={$_("date")}
@@ -54,14 +62,22 @@
error={$errors.date}
on:change={handleChange}
></Datepicker>
</div>
<div class="flex gap-4">
<TrailPicker
bind:trailFile={$form._gpx}
bind:trailData={$form.expand.gpx_data}
label={$_("trail", { values: { n: 1 } })}
></TrailPicker>
<div class="basis-full">
<TextField
<Textarea
name="text"
extraClasses="h-28"
label={$_("text")}
bind:value={$form.text}
error={$errors.text}
on:change={handleChange}
></TextField>
></Textarea>
</div>
</div>
</form>

View File

@@ -302,7 +302,10 @@
{#if activeTab == 1}
<ul>
{#each trail.expand.waypoints ?? [] as waypoint, i}
<li on:mouseenter={() => openMarkerPopup(i)} on:mouseleave={() => closeMarkerPopup(i)}>
<li
on:mouseenter={() => openMarkerPopup(i)}
on:mouseleave={() => closeMarkerPopup(i)}
>
<WaypointCard {waypoint}></WaypointCard>
</li>
{/each}
@@ -333,8 +336,8 @@
{/if}
{#if activeTab == 3}
<ul>
{#each trail.expand.summit_logs ?? [] as log}
<li><SummitLogCard {log}></SummitLogCard></li>
{#each trail.expand.summit_logs ?? [] as log, i}
<li><SummitLogCard {log} index={i}></SummitLogCard></li>
{/each}
</ul>
{/if}

View File

@@ -0,0 +1,119 @@
<script lang="ts">
import { gpx } from "$lib/vendor/toGeoJSON/toGeoJSON";
import "leaflet/dist/leaflet.css";
import type { Map } from "leaflet";
import { onMount, tick } from "svelte";
export let trailFile: File | null;
export let trailData: string | undefined;
export let label: string = "";
let map: Map;
let L: any;
let layer: any;
$: if (trailData !== undefined) {
showTrailOnMap();
} else {
removeTrailFromMap();
}
onMount(async () => {
if (!map) {
await initMap();
}
});
async function initMap() {
L = (await import("leaflet")).default;
map = L.map("trail-picker-map", {
zoomControl: false,
scrollWheelZoom: false,
dragging: false,
});
map.attributionControl.setPrefix(false);
}
function openTrailBrowser() {
if (trailData) {
trailFile = null;
trailData = undefined;
} else {
document.getElementById("trail-input")!.click();
}
}
async function handleTrailSelection(files?: FileList | null) {
if (!files) {
files = (document.getElementById("trail-input") as HTMLInputElement)
.files;
}
if (!files) {
return;
}
trailFile = files.item(0);
trailData = await trailFile?.text();
}
async function showTrailOnMap() {
if (!trailData) {
return;
}
const geoJson = gpx(
new DOMParser().parseFromString(trailData, "text/xml"),
);
if (layer) {
map.removeLayer(layer);
}
layer = L.geoJson(geoJson, {
filter: (feature: any, layer: any) => {
return feature.geometry.type !== "Point";
},
}).addTo(map);
map.fitBounds(layer.getBounds());
map.invalidateSize();
}
function removeTrailFromMap() {
if (layer) {
map?.removeLayer(layer);
}
}
</script>
<div>
{#if label.length}
<p class="text-sm font-medium pb-1">
{label}
</p>
{/if}
<div class="flex gap-x-4" role="dialog">
<input
type="file"
id="trail-input"
accept=".gpx,.GPX,.tcx,.TCX,.kml,.KML,.fit,.FIT"
multiple={false}
style="display: none;"
on:change={() => handleTrailSelection()}
/>
<button
type="button"
on:click={openTrailBrowser}
class="h-28 aspect-square rounded-xl !bg-background border border-input-border-focus hover:!bg-secondary-hover group"
id="trail-picker-map"
>
{#if !trailFile && !trailData}
<i class="fa fa-plus text-lg"></i>
{:else}
<i
class="fa fa-trash text-red-500 text-lg hidden group-hover:block relative"
style="z-index: 1000"
></i>
{/if}
</button>
</div>
</div>

View File

@@ -3,11 +3,20 @@ class SummitLog {
id?: string;
date: string;
text?: string;
gpx?: string;
_gpx: File | null;
author?: string;
expand: {
gpx_data?: string;
}
constructor(date: string, params?: { id?: string, text?: string }) {
this.date = date;
this.id = params?.id;
this.text = params?.text ?? "";
this.expand = {}
this._gpx = null
}
}

View File

@@ -1,15 +1,35 @@
import { SummitLog } from "$lib/models/summit_log";
import { pb } from "$lib/pocketbase";
import { ClientResponseError } from "pocketbase";
import { writable, type Writable } from "svelte/store";
export const summitLog: Writable<SummitLog> = writable(new SummitLog(new Date().toISOString().substring(0, 10)));
export async function summit_logs_create(summitLog: SummitLog) {
const r = await fetch('/api/v1/summit-log', {
summitLog.author = pb.authStore.model!.id
let r = await fetch('/api/v1/summit-log', {
method: 'PUT',
body: JSON.stringify(summitLog),
})
if (!r.ok) {
throw new ClientResponseError(await r.json())
}
if (summitLog._gpx && summitLog._gpx instanceof File) {
let model: SummitLog = await r.json();
const formData = new FormData()
formData.append("gpx", summitLog._gpx)
r = await fetch(`/api/v1/summit-log/${model.id!}/file`, {
method: 'POST',
body: formData,
})
}
if (r.ok) {
return await r.json();
} else {
@@ -18,11 +38,28 @@ export async function summit_logs_create(summitLog: SummitLog) {
}
export async function summit_logs_update(summitLog: SummitLog) {
const r = await fetch('/api/v1/summit-log/' + summitLog.id, {
summitLog.author = pb.authStore.model!.id
let r = await fetch('/api/v1/summit-log/' + summitLog.id, {
method: 'POST',
body: JSON.stringify(summitLog),
})
if (!r.ok) {
throw new ClientResponseError(await r.json())
}
if (summitLog._gpx) {
const formData = new FormData()
formData.append("gpx", summitLog._gpx);
r = await fetch(`/api/v1/summit-log/${summitLog.id!}/file`, {
method: 'POST',
body: formData,
})
}
if (r.ok) {
return await r.json();
} else {

View File

@@ -152,6 +152,16 @@ export async function trails_show(id: string, loadGPX?: boolean, f: (url: Reques
response.expand = {};
}
response.expand.gpx_data = gpxData;
for (const log of response.expand.summit_logs) {
const gpxData: string = await fetchGPX(log, f);
if (!log.expand) {
log.expand = {};
}
log.expand.gpx_data = gpxData;
}
}
response.expand.waypoints = response.expand.waypoints || [];
@@ -363,7 +373,7 @@ export async function trails_upload(file: File, f: (url: RequestInfo | URL, conf
}
}
export async function fetchGPX(trail: Trail, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
export async function fetchGPX(trail: { gpx: string } & Record<string, any>, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
if (!trail.gpx) {
return "";
}

View File

@@ -0,0 +1,13 @@
import type { SummitLog } from "$lib/models/summit_log";
import { pb } from "$lib/pocketbase";
import { error, json, type RequestEvent } from "@sveltejs/kit";
export async function POST(event: RequestEvent) {
const data = await event.request.formData()
try {
const r = await pb.collection("summit_logs").update<SummitLog>(event.params.id as string, data,);
return json(r);
} catch (e: any) {
throw error(e.status, e)
}
}

View File

@@ -422,6 +422,8 @@
function saveSummitLog(e: CustomEvent<SummitLog>) {
const savedSummitLog = e.detail;
console.log(savedSummitLog);
let editedSummitLogIndex = $form.expand.summit_logs.findIndex(
(s) => s.id == savedSummitLog.id,
);
@@ -817,6 +819,7 @@
<li>
<SummitLogCard
{log}
index={i}
mode="edit"
on:change={(e) => handleSummitLogMenuClick(log, i, e)}
></SummitLogCard>