adds gpx upload for summit logs
This commit is contained in:
23
web/src/lib/components/base/file_input.svelte
Normal file
23
web/src/lib/components/base/file_input.svelte
Normal 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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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,22 +27,118 @@
|
||||
{ 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">
|
||||
<h5 class="font-medium mr-2">
|
||||
{new Date(log.date).toLocaleDateString(undefined, {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
})}
|
||||
</h5>
|
||||
<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",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
})}
|
||||
</h5>
|
||||
|
||||
{#if mode == "edit"}
|
||||
<Dropdown items={dropdownItems} on:change></Dropdown>
|
||||
{/if}
|
||||
{#if mode == "edit"}
|
||||
<Dropdown items={dropdownItems} on:change></Dropdown>
|
||||
{/if}
|
||||
</div>
|
||||
{#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>
|
||||
<span>{log.text}</span>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -176,7 +176,7 @@
|
||||
<div class="trail-info-panel-header">
|
||||
<section class="relative h-80">
|
||||
<img
|
||||
class="w-full h-80 "
|
||||
class="w-full h-80"
|
||||
class:rounded-t-3xl={mode !== "list"}
|
||||
src={thumbnail}
|
||||
alt=""
|
||||
@@ -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}
|
||||
|
||||
119
web/src/lib/components/trail/trail_picker.svelte
Normal file
119
web/src/lib/components/trail/trail_picker.svelte
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 || [];
|
||||
@@ -349,8 +359,8 @@ export async function trails_upload(file: File, f: (url: RequestInfo | URL, conf
|
||||
const fd = new FormData()
|
||||
|
||||
fd.append("name", file.name),
|
||||
fd.append("file", file)
|
||||
|
||||
fd.append("file", file)
|
||||
|
||||
const r = await f('/api/v1/trail/upload', {
|
||||
method: 'PUT',
|
||||
body: fd
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
13
web/src/routes/api/v1/summit-log/[id]/file/+server.ts
Normal file
13
web/src/routes/api/v1/summit-log/[id]/file/+server.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user