adds upload dialog and duplicates
This commit is contained in:
14
web/package-lock.json
generated
14
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "wanderer",
|
"name": "wanderer",
|
||||||
"version": "0.13.2",
|
"version": "0.14.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "wanderer",
|
"name": "wanderer",
|
||||||
"version": "0.13.2",
|
"version": "0.14.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@felte/validator-zod": "^1.0.18",
|
"@felte/validator-zod": "^1.0.18",
|
||||||
"@fortawesome/fontawesome-free": "^6.5.1",
|
"@fortawesome/fontawesome-free": "^6.5.1",
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"maplibre-gl": "^4.7.1",
|
"maplibre-gl": "^4.7.1",
|
||||||
"meilisearch": "^0.37.0",
|
"meilisearch": "^0.37.0",
|
||||||
|
"ngeohash": "^0.6.3",
|
||||||
"nouislider": "^15.7.1",
|
"nouislider": "^15.7.1",
|
||||||
"pdfkit": "^0.15.0",
|
"pdfkit": "^0.15.0",
|
||||||
"photoswipe": "^5.4.3",
|
"photoswipe": "^5.4.3",
|
||||||
@@ -4372,6 +4373,15 @@
|
|||||||
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
|
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ngeohash": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ngeohash/-/ngeohash-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-kltF0cOxgx1AbmVzKxYZaoB0aj7mOxZeHaerEtQV0YaqnkXNq26WWqMmJ6lTqShYxVRWZ/mwvvTrNeOwdslWiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=v0.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-fetch": {
|
"node_modules/node-fetch": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"maplibre-gl": "^4.7.1",
|
"maplibre-gl": "^4.7.1",
|
||||||
"meilisearch": "^0.37.0",
|
"meilisearch": "^0.37.0",
|
||||||
|
"ngeohash": "^0.6.3",
|
||||||
"nouislider": "^15.7.1",
|
"nouislider": "^15.7.1",
|
||||||
"pdfkit": "^0.15.0",
|
"pdfkit": "^0.15.0",
|
||||||
"photoswipe": "^5.4.3",
|
"photoswipe": "^5.4.3",
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ const auth: Handle = async ({ event, resolve }) => {
|
|||||||
try {
|
try {
|
||||||
// get an up-to-date auth store state by verifying and refreshing the loaded auth model (if any)
|
// get an up-to-date auth store state by verifying and refreshing the loaded auth model (if any)
|
||||||
if (pb.authStore.isValid) {
|
if (pb.authStore.isValid) {
|
||||||
await pb.collection('users').authRefresh()
|
await pb.collection('users').authRefresh({requestKey: null})
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// clear the auth store on failed refresh
|
// clear the auth store on failed refresh
|
||||||
@@ -78,7 +78,7 @@ const auth: Handle = async ({ event, resolve }) => {
|
|||||||
let settings: Settings | undefined;
|
let settings: Settings | undefined;
|
||||||
if (pb.authStore.model) {
|
if (pb.authStore.model) {
|
||||||
meiliApiKey = pb.authStore.model.token
|
meiliApiKey = pb.authStore.model.token
|
||||||
settings = await pb.collection('settings').getFirstListItem<Settings>(`user="${pb.authStore.model.id}"`)
|
settings = await pb.collection('settings').getFirstListItem<Settings>(`user="${pb.authStore.model.id}"`, {requestKey: null})
|
||||||
} else {
|
} else {
|
||||||
const r = await event.fetch(pb.buildUrl("/public/search/token"));
|
const r = await event.fetch(pb.buildUrl("/public/search/token"));
|
||||||
const response = await r.json();
|
const response = await r.json();
|
||||||
@@ -111,4 +111,12 @@ const auth: Handle = async ({ event, resolve }) => {
|
|||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
export const handle = sequence(csrf(['/api/v1']), auth)
|
const removeLinkFromHeaders: Handle =
|
||||||
|
async ({ event, resolve }) => {
|
||||||
|
const response = await resolve(event);
|
||||||
|
response.headers.delete('link');
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const handle = sequence(csrf(['/api/v1']), auth, removeLinkFromHeaders)
|
||||||
195
web/src/lib/components/settings/upload_dialog.svelte
Normal file
195
web/src/lib/components/settings/upload_dialog.svelte
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
processUploadQueue,
|
||||||
|
uploadStore,
|
||||||
|
type Upload,
|
||||||
|
} from "$lib/stores/upload_store.svelte";
|
||||||
|
import { slide } from "svelte/transition";
|
||||||
|
import { _ } from "svelte-i18n";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
|
||||||
|
let minimized: boolean = $state(false);
|
||||||
|
|
||||||
|
let visibleUploads = $derived(
|
||||||
|
uploadStore.enqueuedUploads
|
||||||
|
.concat(uploadStore.completedUploads)
|
||||||
|
.sort((a, b) => a.file.size - b.file.size),
|
||||||
|
);
|
||||||
|
|
||||||
|
let remaining = $derived(uploadStore.enqueuedUploads.length);
|
||||||
|
|
||||||
|
let successfulUploads = $derived(
|
||||||
|
uploadStore.completedUploads.reduce(
|
||||||
|
(sum, u) => (sum += u.status == "success" ? 1 : 0),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let errorUploads = $derived(
|
||||||
|
uploadStore.completedUploads.reduce(
|
||||||
|
(sum, u) => (sum += u.status == "error" ? 1 : 0),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let duplicateUploads = $derived(
|
||||||
|
uploadStore.completedUploads.reduce(
|
||||||
|
(sum, u) => (sum += u.status == "duplicate" ? 1 : 0),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
function dismissUpload(u: Upload) {
|
||||||
|
const index = uploadStore.completedUploads.indexOf(u);
|
||||||
|
uploadStore.completedUploads.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissAllCompleted() {
|
||||||
|
uploadStore.completedUploads = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelUpload(u: Upload) {
|
||||||
|
const index = uploadStore.enqueuedUploads.indexOf(u);
|
||||||
|
u.status = "cancelled";
|
||||||
|
uploadStore.enqueuedUploads.splice(index, 1);
|
||||||
|
uploadStore.completedUploads.push(u);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reUpload(u: Upload, ignoreDuplicates: boolean = false) {
|
||||||
|
const index = uploadStore.completedUploads.indexOf(u);
|
||||||
|
u.status = "enqueued";
|
||||||
|
u.progress = 0;
|
||||||
|
u.error = undefined;
|
||||||
|
u.duplicate = undefined;
|
||||||
|
uploadStore.completedUploads.splice(index, 1);
|
||||||
|
uploadStore.enqueuedUploads.push(u);
|
||||||
|
processUploadQueue(undefined, ignoreDuplicates);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if visibleUploads.length}
|
||||||
|
<div
|
||||||
|
class="fixed bottom-4 right-4 z-10 p-4 bg-background rounded-xl border border-input-border shadow-xl"
|
||||||
|
class:cursor-pointer={minimized}
|
||||||
|
in:slide
|
||||||
|
out:slide
|
||||||
|
role="presentation"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
minimized = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex gap-x-2 items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">
|
||||||
|
Remaining {remaining} - Processed {uploadStore
|
||||||
|
.completedUploads.length}/{uploadStore.enqueuedUploads
|
||||||
|
.length + uploadStore.completedUploads.length}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm">
|
||||||
|
Uploaded <span class="text-emerald-400"
|
||||||
|
>{successfulUploads}</span
|
||||||
|
>
|
||||||
|
- Error <span class="text-red-400">{errorUploads}</span> -
|
||||||
|
Duplicates
|
||||||
|
<span class="text-amber-500">{duplicateUploads}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-x-2">
|
||||||
|
<button aria-label="Dismiss all" onclick={dismissAllCompleted}
|
||||||
|
><i class="fa fa-ban"></i></button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label="Minimize"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
minimized = true;
|
||||||
|
}}><i class="fa fa-minus"></i></button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="max-h-96 max-w-72 mt-4 overflow-y-auto space-y-2"
|
||||||
|
class:hidden={minimized}
|
||||||
|
>
|
||||||
|
{#each visibleUploads as u}
|
||||||
|
<div class="bg-menu-item-background-hover rounded-lg py-2 px-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-6 shrink-0">
|
||||||
|
{#if u.status === "enqueued" || u.status == "uploading"}
|
||||||
|
<div class="spinner spinner-small"></div>
|
||||||
|
{:else}
|
||||||
|
<i
|
||||||
|
class={{
|
||||||
|
fa: true,
|
||||||
|
"fa-circle-exclamation text-red-400":
|
||||||
|
u.status == "error",
|
||||||
|
"fa-triangle-exclamation text-amber-500":
|
||||||
|
u.status == "duplicate",
|
||||||
|
"fa-circle-check text-emerald-400":
|
||||||
|
u.status == "success",
|
||||||
|
"fa-ban text-gray-500":
|
||||||
|
u.status == "cancelled",
|
||||||
|
}}
|
||||||
|
></i>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="text-xs basis-full min-w-0 break-all">
|
||||||
|
{u.file.name}
|
||||||
|
</p>
|
||||||
|
{#if u.status == "error" || u.status == "cancelled"}
|
||||||
|
<button
|
||||||
|
aria-label="Re-upload"
|
||||||
|
onclick={() => reUpload(u)}
|
||||||
|
><i class="fa fa-redo text-sm"></i></button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if u.status == "enqueued"}
|
||||||
|
<button
|
||||||
|
aria-label="Cancel upload"
|
||||||
|
onclick={() => cancelUpload(u)}
|
||||||
|
><i class="fa fa-stop text-sm"></i></button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if u.status == "duplicate"}
|
||||||
|
<button
|
||||||
|
title="Force upload"
|
||||||
|
aria-label="Force upload"
|
||||||
|
onclick={() => reUpload(u, true)}
|
||||||
|
><i class="fa fa-upload text-sm"></i></button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if u.status != "enqueued" && u.status != "uploading"}
|
||||||
|
<button
|
||||||
|
aria-label="Dismiss"
|
||||||
|
onclick={() => dismissUpload(u)}
|
||||||
|
><i class="fa fa-close text-sm"></i></button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if u.status == "uploading"}
|
||||||
|
<div
|
||||||
|
class="progress-bar my-1 rounded-md"
|
||||||
|
style="height:2px; width:{u.progress}%; background-color:#3549bb;transition: width 0.5s ease-in-out;"
|
||||||
|
></div>
|
||||||
|
{:else if u.error}
|
||||||
|
<p class="text-red-400 text-xs">
|
||||||
|
{u.error}
|
||||||
|
</p>
|
||||||
|
{:else if u.duplicate}
|
||||||
|
<p class="text-amber-400 text-xs">
|
||||||
|
{$_("duplicate")}:
|
||||||
|
<button
|
||||||
|
class="underline"
|
||||||
|
onclick={() =>
|
||||||
|
goto(`/trail/view/${u.duplicate!.id}`)}
|
||||||
|
>{u.duplicate.name}</button
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
throw gpxObject;
|
throw gpxObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
const totals = gpxObject.getTotals();
|
const totals = gpxObject.features;
|
||||||
|
|
||||||
$data.duration = totals.duration / 1000;
|
$data.duration = totals.duration / 1000;
|
||||||
$data.elevation_gain = totals.elevationGain;
|
$data.elevation_gain = totals.elevationGain;
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
trail: trail.id ?? "",
|
trail: trail.id ?? "",
|
||||||
});
|
});
|
||||||
|
|
||||||
let commentsLoading: boolean = $state(activeTab == 4);
|
let commentsLoading: boolean = $state(activeTab == 2);
|
||||||
let commentCreateLoading: boolean = $state(false);
|
let commentCreateLoading: boolean = $state(false);
|
||||||
let commentDeleteLoading: boolean = false;
|
let commentDeleteLoading: boolean = false;
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@
|
|||||||
: emptyStateTrailDark,
|
: emptyStateTrailDark,
|
||||||
);
|
);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (browser && activeTab == 4) {
|
if (browser && activeTab == 2) {
|
||||||
fetchComments();
|
fetchComments();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -74,6 +74,7 @@
|
|||||||
"download": "Herunterladen",
|
"download": "Herunterladen",
|
||||||
"draw-a-route": "Route zeichnen",
|
"draw-a-route": "Route zeichnen",
|
||||||
"driving": "Auto",
|
"driving": "Auto",
|
||||||
|
"duplicate": "",
|
||||||
"duration": "Dauer",
|
"duration": "Dauer",
|
||||||
"dutch": "Niederländisch",
|
"dutch": "Niederländisch",
|
||||||
"easy": "Einfach",
|
"easy": "Einfach",
|
||||||
|
|||||||
@@ -74,6 +74,7 @@
|
|||||||
"download": "Download",
|
"download": "Download",
|
||||||
"draw-a-route": "Draw a route",
|
"draw-a-route": "Draw a route",
|
||||||
"driving": "Driving",
|
"driving": "Driving",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
"duration": "Duration",
|
"duration": "Duration",
|
||||||
"dutch": "Dutch",
|
"dutch": "Dutch",
|
||||||
"easy": "Easy",
|
"easy": "Easy",
|
||||||
|
|||||||
@@ -74,6 +74,7 @@
|
|||||||
"download": "Descarga",
|
"download": "Descarga",
|
||||||
"draw-a-route": "Dibujar una ruta",
|
"draw-a-route": "Dibujar una ruta",
|
||||||
"driving": "Conducir",
|
"driving": "Conducir",
|
||||||
|
"duplicate": "",
|
||||||
"duration": "Duración",
|
"duration": "Duración",
|
||||||
"dutch": "Holandés",
|
"dutch": "Holandés",
|
||||||
"easy": "Fácil",
|
"easy": "Fácil",
|
||||||
|
|||||||
@@ -74,6 +74,7 @@
|
|||||||
"download": "Download",
|
"download": "Download",
|
||||||
"draw-a-route": "Tracer un itinéraire",
|
"draw-a-route": "Tracer un itinéraire",
|
||||||
"driving": "Conduire",
|
"driving": "Conduire",
|
||||||
|
"duplicate": "",
|
||||||
"duration": "Durée",
|
"duration": "Durée",
|
||||||
"dutch": "Néerlandais",
|
"dutch": "Néerlandais",
|
||||||
"easy": "Facile",
|
"easy": "Facile",
|
||||||
|
|||||||
@@ -74,6 +74,7 @@
|
|||||||
"download": "Download",
|
"download": "Download",
|
||||||
"draw-a-route": "Draw a route",
|
"draw-a-route": "Draw a route",
|
||||||
"driving": "Driving",
|
"driving": "Driving",
|
||||||
|
"duplicate": "",
|
||||||
"duration": "Duration",
|
"duration": "Duration",
|
||||||
"dutch": "Holland",
|
"dutch": "Holland",
|
||||||
"easy": "Könnyű",
|
"easy": "Könnyű",
|
||||||
|
|||||||
@@ -74,6 +74,7 @@
|
|||||||
"download": "Scarica",
|
"download": "Scarica",
|
||||||
"draw-a-route": "Disegna un percorso",
|
"draw-a-route": "Disegna un percorso",
|
||||||
"driving": "Guida",
|
"driving": "Guida",
|
||||||
|
"duplicate": "",
|
||||||
"duration": "Durata",
|
"duration": "Durata",
|
||||||
"dutch": "Olandese",
|
"dutch": "Olandese",
|
||||||
"easy": "Facile",
|
"easy": "Facile",
|
||||||
|
|||||||
@@ -74,6 +74,7 @@
|
|||||||
"download": "Download",
|
"download": "Download",
|
||||||
"draw-a-route": "Draw a route",
|
"draw-a-route": "Draw a route",
|
||||||
"driving": "Driving",
|
"driving": "Driving",
|
||||||
|
"duplicate": "",
|
||||||
"duration": "Duration",
|
"duration": "Duration",
|
||||||
"dutch": "Nederlands",
|
"dutch": "Nederlands",
|
||||||
"easy": "Makkelijk",
|
"easy": "Makkelijk",
|
||||||
|
|||||||
@@ -74,6 +74,7 @@
|
|||||||
"download": "Pobierz",
|
"download": "Pobierz",
|
||||||
"draw-a-route": "Narysuj trasę",
|
"draw-a-route": "Narysuj trasę",
|
||||||
"driving": "Samochód",
|
"driving": "Samochód",
|
||||||
|
"duplicate": "",
|
||||||
"duration": "Czas trwania",
|
"duration": "Czas trwania",
|
||||||
"dutch": "Niderlandzki",
|
"dutch": "Niderlandzki",
|
||||||
"easy": "Łatwy",
|
"easy": "Łatwy",
|
||||||
|
|||||||
@@ -74,6 +74,7 @@
|
|||||||
"download": "Download",
|
"download": "Download",
|
||||||
"draw-a-route": "Desenhar uma rota",
|
"draw-a-route": "Desenhar uma rota",
|
||||||
"driving": "Conduzir",
|
"driving": "Conduzir",
|
||||||
|
"duplicate": "",
|
||||||
"duration": "Duração",
|
"duration": "Duração",
|
||||||
"dutch": "Holandês",
|
"dutch": "Holandês",
|
||||||
"easy": "Fácil",
|
"easy": "Fácil",
|
||||||
|
|||||||
@@ -74,6 +74,7 @@
|
|||||||
"download": "Download",
|
"download": "Download",
|
||||||
"draw-a-route": "绘制路线",
|
"draw-a-route": "绘制路线",
|
||||||
"driving": "驾驶",
|
"driving": "驾驶",
|
||||||
|
"duplicate": "",
|
||||||
"duration": "持续时间",
|
"duration": "持续时间",
|
||||||
"dutch": "荷兰语",
|
"dutch": "荷兰语",
|
||||||
"easy": "简单",
|
"easy": "简单",
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import Route from './route';
|
|||||||
import Track from './track';
|
import Track from './track';
|
||||||
import { allDatesToISOString, haversineDistance, removeEmpty } from './utils';
|
import { allDatesToISOString, haversineDistance, removeEmpty } from './utils';
|
||||||
import Waypoint from './waypoint';
|
import Waypoint from './waypoint';
|
||||||
|
//@ts-ignore
|
||||||
|
import geohash from "ngeohash"
|
||||||
|
|
||||||
const defaultAttributes = {
|
const defaultAttributes = {
|
||||||
version: '1.1',
|
version: '1.1',
|
||||||
@@ -14,6 +16,17 @@ const defaultAttributes = {
|
|||||||
'http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd'
|
'http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type GPXFeature = {
|
||||||
|
centroid: { lat: number; lon: number };
|
||||||
|
boundingBox: { minLat: number; maxLat: number; minLon: number; maxLon: number };
|
||||||
|
distance: number;
|
||||||
|
elevationGain?: number;
|
||||||
|
elevationLoss?: number;
|
||||||
|
duration: number;
|
||||||
|
hash: string; // MinHash or Geohash for track shape
|
||||||
|
};
|
||||||
|
|
||||||
export default class GPX {
|
export default class GPX {
|
||||||
$: {
|
$: {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -27,6 +40,7 @@ export default class GPX {
|
|||||||
wpt?: Waypoint[];
|
wpt?: Waypoint[];
|
||||||
rte?: Route[];
|
rte?: Route[];
|
||||||
trk?: Track[];
|
trk?: Track[];
|
||||||
|
features: GPXFeature
|
||||||
|
|
||||||
constructor(object: {
|
constructor(object: {
|
||||||
$?: {
|
$?: {
|
||||||
@@ -69,18 +83,26 @@ export default class GPX {
|
|||||||
this.trk = object.trk.map(trk => new Track(trk))
|
this.trk = object.trk.map(trk => new Track(trk))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.features = this.getTotals();
|
||||||
|
|
||||||
removeEmpty(this);
|
removeEmpty(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
getTotals() {
|
getTotals(): GPXFeature {
|
||||||
let totalElevationGain = 0;
|
let totalElevationGain = 0;
|
||||||
let totalElevationLoss = 0;
|
let totalElevationLoss = 0;
|
||||||
let totalDuration = 0;
|
let totalDuration = 0;
|
||||||
let totalDistance = 0;
|
let totalDistance = 0;
|
||||||
|
let totalLat = 0
|
||||||
|
let totalLon = 0
|
||||||
|
|
||||||
|
let minLat = Infinity, maxLat = -Infinity, minLon = Infinity, maxLon = -Infinity;
|
||||||
|
|
||||||
|
const allPoints: Waypoint[] = []
|
||||||
for (const track of this.trk ?? []) {
|
for (const track of this.trk ?? []) {
|
||||||
for (const segment of track.trkseg ?? []) {
|
for (const segment of track.trkseg ?? []) {
|
||||||
const points = segment.trkpt ?? [];
|
const points = segment.trkpt ?? [];
|
||||||
|
allPoints.push(...points);
|
||||||
|
|
||||||
if (points.length >= 2) {
|
if (points.length >= 2) {
|
||||||
const startTime = points[0].time;
|
const startTime = points[0].time;
|
||||||
@@ -110,12 +132,51 @@ export default class GPX {
|
|||||||
point.$.lat ?? 0,
|
point.$.lat ?? 0,
|
||||||
point.$.lon ?? 0,
|
point.$.lon ?? 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
totalLat += point.$.lat ?? 0;
|
||||||
|
totalLon += point.$.lon ?? 0;
|
||||||
|
|
||||||
|
minLat = Math.min(minLat, point.$.lat ?? Infinity);
|
||||||
|
maxLat = Math.max(maxLat, point.$.lat ?? -Infinity);
|
||||||
|
minLon = Math.min(minLon, point.$.lon ?? Infinity);
|
||||||
|
maxLon = Math.max(maxLon, point.$.lon ?? -Infinity);
|
||||||
totalDistance += distance;
|
totalDistance += distance;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { distance: totalDistance, elevationGain: totalElevationGain, elevationLoss: totalElevationLoss, duration: totalDuration }
|
const boundingBox = { minLat, maxLat, minLon, maxLon };
|
||||||
|
const centroid = { lat: totalLat / allPoints.length, lon: totalLon / allPoints.length };
|
||||||
|
|
||||||
|
return {
|
||||||
|
centroid,
|
||||||
|
boundingBox,
|
||||||
|
distance: totalDistance,
|
||||||
|
elevationGain: totalElevationGain,
|
||||||
|
elevationLoss: totalElevationLoss,
|
||||||
|
duration: totalDuration,
|
||||||
|
hash: this.generateMinHash(allPoints)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateMinHash(points: Waypoint[]): string {
|
||||||
|
const hashes = points.map(pt => geohash.encode(pt.$.lat, pt.$.lon));
|
||||||
|
return hashes.sort().join('').slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
isSimilar(other: GPX, distanceThreshold = 50): boolean {
|
||||||
|
const f1 = this.features;
|
||||||
|
const f2 = other.features;
|
||||||
|
const centroidDistance = haversineDistance(f1.centroid.lat, f1.centroid.lon, f2.centroid.lat, f2.centroid.lon);
|
||||||
|
const boundingBoxOverlap =
|
||||||
|
f1.boundingBox.minLat <= f2.boundingBox.maxLat && f1.boundingBox.maxLat >= f2.boundingBox.minLat &&
|
||||||
|
f1.boundingBox.minLon <= f2.boundingBox.maxLon && f1.boundingBox.maxLon >= f2.boundingBox.minLon;
|
||||||
|
|
||||||
|
const lengthDifference = Math.abs(f1.distance - f2.distance);
|
||||||
|
const hashSimilarity = f1.hash === f2.hash;
|
||||||
|
|
||||||
|
return centroidDistance < distanceThreshold || boundingBoxOverlap || (lengthDifference < 100 && hashSimilarity);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static parse(gpxString: string): Promise<GPX | Error> {
|
static parse(gpxString: string): Promise<GPX | Error> {
|
||||||
|
|||||||
@@ -401,23 +401,40 @@ export async function trails_get_bounding_box(f: (url: RequestInfo | URL, config
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function trails_upload(file: File, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch): Promise<TrailFilterValues> {
|
export async function trails_upload(file: File, ignoreDuplicates: boolean = false, onProgress?: (progress: number) => void) {
|
||||||
const fd = new FormData()
|
return new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("name", file.name);
|
||||||
|
fd.append("file", file);
|
||||||
|
fd.append("ignoreDuplicates", ignoreDuplicates ? "true" : "false")
|
||||||
|
|
||||||
fd.append("name", file.name),
|
xhr.open("PUT", "/api/v1/trail/upload", true);
|
||||||
fd.append("file", file)
|
|
||||||
|
|
||||||
const r = await f('/api/v1/trail/upload', {
|
xhr.upload.onprogress = function (event) {
|
||||||
method: 'PUT',
|
if (event.lengthComputable) {
|
||||||
body: fd
|
const percentComplete = (event.loaded / event.total) * 100;
|
||||||
})
|
onProgress?.(percentComplete)
|
||||||
if (!r.ok) {
|
}
|
||||||
const response = await r.json();
|
};
|
||||||
throw new APIError(r.status, response.message, response.detail)
|
|
||||||
}
|
|
||||||
|
|
||||||
return await r.json();
|
xhr.onload = async () => {
|
||||||
|
const responseText = xhr.responseText;
|
||||||
|
const response = responseText ? JSON.parse(responseText) : null;
|
||||||
|
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
resolve(response);
|
||||||
|
} else {
|
||||||
|
reject(new APIError(xhr.status, response?.message || "Upload failed", response));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = () => {
|
||||||
|
reject(new APIError(xhr.status, xhr.statusText));
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.send(fd);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchGPX(trail: { gpx?: string } & Record<string, any>, 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) {
|
||||||
|
|||||||
57
web/src/lib/stores/upload_store.svelte.ts
Normal file
57
web/src/lib/stores/upload_store.svelte.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { APIError } from "$lib/util/api_util";
|
||||||
|
|
||||||
|
export type Upload = {
|
||||||
|
file: File;
|
||||||
|
status: "enqueued" | "uploading" | "cancelled" | "success" | "error" | "duplicate";
|
||||||
|
error?: string;
|
||||||
|
duplicate?: { id: string, name: string };
|
||||||
|
progress: number;
|
||||||
|
function: (f: File, ignoreDuplicates?: boolean, onProgress?: (p: number) => void) => Promise<unknown>
|
||||||
|
};
|
||||||
|
|
||||||
|
class UploadStore {
|
||||||
|
enqueuedUploads: Upload[] = $state([]);
|
||||||
|
completedUploads: Upload[] = $state([]);
|
||||||
|
uploading: boolean = $state(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uploadStore = new UploadStore();
|
||||||
|
|
||||||
|
|
||||||
|
export async function processUploadQueue(batchSize: number = 3, ignoreDuplicates: boolean = false) {
|
||||||
|
if (uploadStore.uploading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploadStore.uploading = true;
|
||||||
|
|
||||||
|
while (uploadStore.enqueuedUploads.length > 0) {
|
||||||
|
const batch = uploadStore.enqueuedUploads.slice(0, batchSize);
|
||||||
|
const uploadPromises: Promise<unknown>[] = [];
|
||||||
|
for (const b of batch) {
|
||||||
|
b.status = "uploading";
|
||||||
|
uploadPromises.push(
|
||||||
|
b.function(b.file, ignoreDuplicates, (p: number) => {
|
||||||
|
b.progress = p
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const results = await Promise.all(
|
||||||
|
uploadPromises.map((p) => p.catch((e) => e)),
|
||||||
|
);
|
||||||
|
results.forEach((r, i) => {
|
||||||
|
const u = batch[i];
|
||||||
|
if (r instanceof APIError && r.message == "Duplicate trail") {
|
||||||
|
u.status = "duplicate"
|
||||||
|
u.duplicate = { id: r.detail.id, name: r.detail.name };
|
||||||
|
} else if (r instanceof APIError) {
|
||||||
|
u.status = "error"
|
||||||
|
u.error = r.message
|
||||||
|
} else {
|
||||||
|
u.status = "success"
|
||||||
|
}
|
||||||
|
uploadStore.completedUploads.push(u);
|
||||||
|
});
|
||||||
|
uploadStore.enqueuedUploads.splice(0, batchSize)
|
||||||
|
}
|
||||||
|
uploadStore.uploading = false;
|
||||||
|
}
|
||||||
@@ -80,7 +80,7 @@ export async function create<T>(event: RequestEvent, schema: ZodSchema, collecti
|
|||||||
const data = await event.request.json();
|
const data = await event.request.json();
|
||||||
const safeData = schema.parse(data);
|
const safeData = schema.parse(data);
|
||||||
|
|
||||||
const r = await pb.collection(Collection[collection]).create<T>(safeData, safeSearchParams)
|
const r = await pb.collection(Collection[collection]).create<T>(safeData, {...safeSearchParams, requestKey: null})
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
@@ -124,7 +124,7 @@ export function handleError(e: any) {
|
|||||||
if (e instanceof ZodError) {
|
if (e instanceof ZodError) {
|
||||||
return error(400, { message: "invalid_params", detail: e.issues } as any)
|
return error(400, { message: "invalid_params", detail: e.issues } as any)
|
||||||
} else if (e instanceof ClientResponseError && e.status > 0) {
|
} else if (e instanceof ClientResponseError && e.status > 0) {
|
||||||
return error(e.status as NumericRange<400, 599>, { message: e.message, detail: e.originalError.data } as any)
|
return error(e.status as NumericRange<400, 599>, {...e.response, message: e.message, detail: e.originalError.data } as any)
|
||||||
} else if (e instanceof SyntaxError) {
|
} else if (e instanceof SyntaxError) {
|
||||||
return error(400, "invalid_json")
|
return error(400, "invalid_json")
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import Track from "$lib/models/gpx/track";
|
|||||||
import TrackSegment from "$lib/models/gpx/track-segment";
|
import TrackSegment from "$lib/models/gpx/track-segment";
|
||||||
import GPXWaypoint from "$lib/models/gpx/waypoint";
|
import GPXWaypoint from "$lib/models/gpx/waypoint";
|
||||||
import EasyFit from "$lib/vendor/easy-fit/easy-fit";
|
import EasyFit from "$lib/vendor/easy-fit/easy-fit";
|
||||||
import type { GeoJSON, Feature, FeatureCollection, GeoJsonProperties, Position } from 'geojson';
|
import type { Feature, FeatureCollection, GeoJSON, GeoJsonProperties, Position } from 'geojson';
|
||||||
import * as xmldom from 'xmldom';
|
import * as xmldom from 'xmldom';
|
||||||
import { bbox, splitMultiLineStringToLineStrings } from "./geojson_util";
|
import { bbox, splitMultiLineStringToLineStrings } from "./geojson_util";
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ export async function gpx2trail(gpxString: string, fallbackName?: string) {
|
|||||||
trail.expand!.waypoints.push(wp);
|
trail.expand!.waypoints.push(wp);
|
||||||
}
|
}
|
||||||
|
|
||||||
const totals = gpx.getTotals()
|
const totals = gpx.features
|
||||||
|
|
||||||
const trackPoints = gpx.trk?.at(0)?.trkseg?.at(0)?.trkpt
|
const trackPoints = gpx.trk?.at(0)?.trkseg?.at(0)?.trkpt
|
||||||
const routePoints = gpx.rte?.at(0)?.rtept;
|
const routePoints = gpx.rte?.at(0)?.rtept;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
import Toast from "$lib/components/base/toast.svelte";
|
import Toast from "$lib/components/base/toast.svelte";
|
||||||
import Footer from "$lib/components/footer.svelte";
|
import Footer from "$lib/components/footer.svelte";
|
||||||
import NavBar from "$lib/components/nav_bar.svelte";
|
import NavBar from "$lib/components/nav_bar.svelte";
|
||||||
|
import PageLoadingBar from "$lib/components/page_loading_bar.svelte";
|
||||||
|
import UploadDialog from "$lib/components/settings/upload_dialog.svelte";
|
||||||
import { currentUser } from "$lib/stores/user_store";
|
import { currentUser } from "$lib/stores/user_store";
|
||||||
import { isRouteProtected } from "$lib/util/authorization_util";
|
import { isRouteProtected } from "$lib/util/authorization_util";
|
||||||
import "@fortawesome/fontawesome-free/css/all.min.css";
|
import "@fortawesome/fontawesome-free/css/all.min.css";
|
||||||
@@ -13,7 +15,6 @@
|
|||||||
import "../css/app.css";
|
import "../css/app.css";
|
||||||
import "../css/components.css";
|
import "../css/components.css";
|
||||||
import "../css/theme.css";
|
import "../css/theme.css";
|
||||||
import PageLoadingBar from "$lib/components/page_loading_bar.svelte";
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
}
|
}
|
||||||
@@ -71,8 +72,9 @@
|
|||||||
may cause errors.
|
may cause errors.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
class="btn-icon self-end" onclick={() => (showWarning = false)}
|
class="btn-icon self-end"
|
||||||
|
onclick={() => (showWarning = false)}
|
||||||
><i class="fa fa-close"></i></button
|
><i class="fa fa-close"></i></button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,6 +83,7 @@
|
|||||||
<NavBar></NavBar>
|
<NavBar></NavBar>
|
||||||
<PageLoadingBar class="text-content"></PageLoadingBar>
|
<PageLoadingBar class="text-content"></PageLoadingBar>
|
||||||
<Toast></Toast>
|
<Toast></Toast>
|
||||||
|
<UploadDialog></UploadDialog>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
|
|
||||||
<Footer></Footer>
|
<Footer></Footer>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import GPX from "$lib/models/gpx/gpx";
|
||||||
|
import { haversineDistance } from "$lib/models/gpx/utils";
|
||||||
import { SummitLog } from "$lib/models/summit_log";
|
import { SummitLog } from "$lib/models/summit_log";
|
||||||
import type { Trail } from "$lib/models/trail";
|
import type { Trail } from "$lib/models/trail";
|
||||||
import { trails_create } from "$lib/stores/trail_store";
|
import { pb } from "$lib/pocketbase";
|
||||||
|
import { fetchGPX, trails_create } from "$lib/stores/trail_store";
|
||||||
import { handleError } from "$lib/util/api_util";
|
import { handleError } from "$lib/util/api_util";
|
||||||
import { fromFile, gpx2trail } from "$lib/util/gpx_util";
|
import { fromFile, gpx2trail } from "$lib/util/gpx_util";
|
||||||
import { json, type RequestEvent } from "@sveltejs/kit";
|
import { json, type RequestEvent } from "@sveltejs/kit";
|
||||||
@@ -15,29 +18,45 @@ export async function PUT(event: RequestEvent) {
|
|||||||
if (!gpxData.length) {
|
if (!gpxData.length) {
|
||||||
throw new ClientResponseError({ status: 400, response: { message: "Empty file" } })
|
throw new ClientResponseError({ status: 400, response: { message: "Empty file" } })
|
||||||
}
|
}
|
||||||
let trail: Trail;
|
let parseResult: { trail: Trail, gpx: GPX };
|
||||||
try {
|
try {
|
||||||
trail = (await gpx2trail(gpxData, data.get("name") as string | undefined)).trail;
|
parseResult = (await gpx2trail(gpxData, data.get("name") as string | undefined));
|
||||||
|
|
||||||
const log = new SummitLog(trail.date as string, {
|
|
||||||
distance: trail.distance,
|
|
||||||
elevation_gain: trail.elevation_gain,
|
|
||||||
elevation_loss: trail.elevation_loss,
|
|
||||||
duration: trail.duration ? trail.duration * 60 : undefined,
|
|
||||||
})
|
|
||||||
log.expand!.gpx_data = gpxData;
|
|
||||||
const fileName = (data.get("name") as string | null)?.length ? data.get("name") as string : "file"
|
|
||||||
log._gpx = new File([gpxFile], fileName);
|
|
||||||
|
|
||||||
trail.expand!.summit_logs.push(log);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
throw new ClientResponseError({ status: 400, response: { message: "Invalid file" } })
|
throw new ClientResponseError({ status: 400, response: { message: "Invalid file" } })
|
||||||
}
|
}
|
||||||
|
let trail = parseResult.trail;
|
||||||
|
|
||||||
|
const ignoreDuplicates = data.get("ignoreDuplicates") === "true"
|
||||||
|
if (!ignoreDuplicates) {
|
||||||
|
let duplicate: Trail | null = null;
|
||||||
|
try {
|
||||||
|
duplicate = await findDuplicate(trail)
|
||||||
|
} catch (e: any) {
|
||||||
|
throw new ClientResponseError({ status: 500, response: { message: "Error checking for duplicates" } })
|
||||||
|
}
|
||||||
|
if (duplicate !== null) {
|
||||||
|
throw new ClientResponseError({ status: 400, response: { message: `Duplicate trail`, id: duplicate.id, name: duplicate.name }, })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const log = new SummitLog(trail.date as string, {
|
||||||
|
distance: trail.distance,
|
||||||
|
elevation_gain: trail.elevation_gain,
|
||||||
|
elevation_loss: trail.elevation_loss,
|
||||||
|
duration: trail.duration ? trail.duration * 60 : undefined,
|
||||||
|
})
|
||||||
|
log.expand!.gpx_data = gpxData;
|
||||||
|
const fileName = (data.get("name") as string | null)?.length ? data.get("name") as string : "file"
|
||||||
|
log._gpx = new File([gpxFile], fileName);
|
||||||
|
|
||||||
|
trail.expand!.summit_logs.push(log);
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
trail = await trails_create(trail, [], gpxFile, event.fetch);
|
trail = await trails_create(trail, [], gpxFile, event.fetch);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
console.error(e)
|
||||||
throw handleError(e)
|
throw handleError(e)
|
||||||
}
|
}
|
||||||
return json(trail);
|
return json(trail);
|
||||||
@@ -46,3 +65,24 @@ export async function PUT(event: RequestEvent) {
|
|||||||
throw handleError(e)
|
throw handleError(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function findDuplicate(t1: Trail) {
|
||||||
|
const trails: Trail[] = await pb.collection('trails').getFullList({ requestKey: null });
|
||||||
|
|
||||||
|
const distanceThreshold = 100;
|
||||||
|
const elevationThreshhold = 50;
|
||||||
|
const lengthThreshhold = 50;
|
||||||
|
|
||||||
|
for (const t2 of trails) {
|
||||||
|
const lengthDifference = Math.abs((t1.distance ?? 0) - (t2.distance ?? 0));
|
||||||
|
const elevationGainDifference = Math.abs((t1.elevation_gain ?? 0) - (t2.elevation_gain ?? 0));
|
||||||
|
const elevationLossDifference = Math.abs((t1.elevation_loss ?? 0) - (t2.elevation_loss ?? 0));
|
||||||
|
const startpointDifference = haversineDistance(t1.lat ?? 0, t1.lon ?? 0, t2.lat ?? 0, t2.lon ?? 0)
|
||||||
|
|
||||||
|
if (lengthDifference < lengthThreshhold && elevationGainDifference < elevationThreshhold && elevationLossDifference < elevationThreshhold && startpointDifference < distanceThreshold) {
|
||||||
|
return t2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,45 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import emptyStateUploadDark from "$lib/assets/svgs/empty_states/empty_state_upload_dark.svg";
|
||||||
|
import emptyStateUploadLight from "$lib/assets/svgs/empty_states/empty_state_upload_light.svg";
|
||||||
import Button from "$lib/components/base/button.svelte";
|
import Button from "$lib/components/base/button.svelte";
|
||||||
|
import { } from "$lib/components/settings/upload_dialog.svelte";
|
||||||
import TrailExportModal from "$lib/components/trail/trail_export_modal.svelte";
|
import TrailExportModal from "$lib/components/trail/trail_export_modal.svelte";
|
||||||
|
import { theme } from "$lib/stores/theme_store";
|
||||||
import { show_toast } from "$lib/stores/toast_store";
|
import { show_toast } from "$lib/stores/toast_store";
|
||||||
import {
|
import { fetchGPX, trails_index, trails_upload } from "$lib/stores/trail_store";
|
||||||
fetchGPX,
|
import { processUploadQueue, uploadStore, type Upload } from "$lib/stores/upload_store.svelte";
|
||||||
trails_index,
|
|
||||||
trails_upload,
|
|
||||||
} from "$lib/stores/trail_store";
|
|
||||||
import { getFileURL, saveAs } from "$lib/util/file_util";
|
import { getFileURL, saveAs } from "$lib/util/file_util";
|
||||||
import { trail2gpx } from "$lib/util/gpx_util";
|
import { trail2gpx } from "$lib/util/gpx_util";
|
||||||
import { gpx } from "$lib/vendor/toGeoJSON/toGeoJSON";
|
import { gpx } from "$lib/vendor/toGeoJSON/toGeoJSON";
|
||||||
import JSZip from "jszip";
|
import JSZip from "jszip";
|
||||||
import { _ } from "svelte-i18n";
|
|
||||||
import { linear } from "svelte/easing";
|
|
||||||
import { Tween } from "svelte/motion";
|
|
||||||
import emptyStateUploadDark from "$lib/assets/svgs/empty_states/empty_state_upload_dark.svg";
|
|
||||||
import emptyStateUploadLight from "$lib/assets/svgs/empty_states/empty_state_upload_light.svg";
|
|
||||||
import { theme } from "$lib/stores/theme_store";
|
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import JSConfetti from "js-confetti";
|
import { _ } from "svelte-i18n";
|
||||||
|
|
||||||
const uploadProgress = new Tween(0, {
|
|
||||||
duration: 300,
|
|
||||||
easing: linear,
|
|
||||||
});
|
|
||||||
|
|
||||||
let exportModal: TrailExportModal;
|
let exportModal: TrailExportModal;
|
||||||
|
|
||||||
let offerUpload: boolean = $state(false);
|
let offerUpload: boolean = $state(false);
|
||||||
let uploading: boolean = $state(false);
|
|
||||||
|
|
||||||
let jsConfetti: JSConfetti;
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
jsConfetti = new JSConfetti({
|
|
||||||
canvas:
|
|
||||||
(document.getElementById(
|
|
||||||
"confetti-canvas",
|
|
||||||
) as HTMLCanvasElement | null) ?? undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function openFileBrowser() {
|
function openFileBrowser() {
|
||||||
document.getElementById("file-input")!.click();
|
document.getElementById("file-input")!.click();
|
||||||
@@ -56,13 +34,41 @@
|
|||||||
|
|
||||||
function handleDrop(e: DragEvent) {
|
function handleDrop(e: DragEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (uploading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
offerUpload = false;
|
offerUpload = false;
|
||||||
handleFileSelection(e.dataTransfer?.files);
|
handleFileSelection(e.dataTransfer?.files);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mockUpload(f: File, onProgess?: (progress: number) => void) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
console.log("here");
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
onProgess?.(10);
|
||||||
|
}, 400);
|
||||||
|
setTimeout(() => {
|
||||||
|
onProgess?.(30);
|
||||||
|
}, 500);
|
||||||
|
setTimeout(() => {
|
||||||
|
onProgess?.(50);
|
||||||
|
}, 800);
|
||||||
|
setTimeout(() => {
|
||||||
|
onProgess?.(70);
|
||||||
|
}, 1200);
|
||||||
|
setTimeout(() => {
|
||||||
|
onProgess?.(99);
|
||||||
|
}, 1300);
|
||||||
|
setTimeout(() => {
|
||||||
|
onProgess?.(100);
|
||||||
|
const random = Math.random();
|
||||||
|
if (random < 0.5) {
|
||||||
|
reject(false);
|
||||||
|
} else {
|
||||||
|
resolve(true);
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function handleFileSelection(files?: FileList | null) {
|
async function handleFileSelection(files?: FileList | null) {
|
||||||
if (!files) {
|
if (!files) {
|
||||||
files = (document.getElementById("file-input") as HTMLInputElement)
|
files = (document.getElementById("file-input") as HTMLInputElement)
|
||||||
@@ -73,42 +79,17 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let errorsThrown = 0;
|
|
||||||
uploading = true;
|
|
||||||
let progress = 0;
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
const file = files[i];
|
const u: Upload = {
|
||||||
try {
|
file: files[i],
|
||||||
uploadProgress.set((progress += 100 / (files.length * 2)));
|
progress: 0,
|
||||||
await trails_upload(file);
|
status: "enqueued",
|
||||||
} catch (e) {
|
function: trails_upload
|
||||||
errorsThrown += 1;
|
};
|
||||||
show_toast({
|
uploadStore.enqueuedUploads.push(u);
|
||||||
type: "error",
|
|
||||||
icon: "close",
|
|
||||||
text: `Error uploading file: ${file.name}`,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
uploadProgress.set((progress += 100 / (files.length * 2)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
await processUploadQueue();
|
||||||
uploadProgress.set(0, { duration: 0 });
|
|
||||||
uploading = false;
|
|
||||||
jsConfetti.addConfetti({
|
|
||||||
confettiRadius: 4,
|
|
||||||
emojis: ["🍃", "🍁"],
|
|
||||||
});
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
if (errorsThrown == 0) {
|
|
||||||
show_toast({
|
|
||||||
type: "success",
|
|
||||||
icon: "check",
|
|
||||||
text: `${files.length} ${$_("trail", { values: { n: files.length } })} ${$_("uploaded")}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exportTrails(exportSettings: {
|
async function exportTrails(exportSettings: {
|
||||||
@@ -182,27 +163,18 @@
|
|||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{$_("settings")} | wanderer</title>
|
<title>{$_("settings")} | wanderer</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<h3 class="text-2xl font-semibold">{$_("import")}</h3>
|
<h3 class="text-2xl font-semibold">{$_("import")}</h3>
|
||||||
<hr class="mt-4 mb-6 border-input-border" />
|
<hr class="mt-4 mb-6 border-input-border" />
|
||||||
<button
|
<button
|
||||||
class="drop-area relative h-64 w-full p-4 {uploading
|
class="drop-area relative h-64 w-full p-4 border border-content border-dashed rounded-xl flex items-center justify-center text-gray-500 bg-background cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
|
||||||
? ''
|
class:border-2={offerUpload}
|
||||||
: 'border border-content border-dashed'} rounded-xl flex items-center justify-center text-gray-500 bg-background cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
|
|
||||||
class:bg-menu-item-background-hover={uploading}
|
|
||||||
class:border-2={offerUpload && !uploading}
|
|
||||||
style="--progress: {uploadProgress.current}%"
|
|
||||||
onclick={openFileBrowser}
|
onclick={openFileBrowser}
|
||||||
ondragover={handleDragOver}
|
ondragover={handleDragOver}
|
||||||
ondragleave={handleDragLeave}
|
ondragleave={handleDragLeave}
|
||||||
ondrop={handleDrop}
|
ondrop={handleDrop}
|
||||||
>
|
>
|
||||||
<canvas
|
|
||||||
id="confetti-canvas"
|
|
||||||
class="absolute"
|
|
||||||
style="width: 100%; height: 200%"
|
|
||||||
>
|
|
||||||
</canvas>
|
|
||||||
<div class="">
|
<div class="">
|
||||||
<img
|
<img
|
||||||
class="rounded-full aspect-square mx-auto"
|
class="rounded-full aspect-square mx-auto"
|
||||||
@@ -230,27 +202,5 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TrailExportModal
|
<TrailExportModal bind:this={exportModal} onexport={exportTrails}
|
||||||
bind:this={exportModal}
|
|
||||||
onexport={exportTrails}
|
|
||||||
></TrailExportModal>
|
></TrailExportModal>
|
||||||
|
|
||||||
<style>
|
|
||||||
.drop-area::after {
|
|
||||||
content: "";
|
|
||||||
display: block;
|
|
||||||
position: absolute;
|
|
||||||
left: -4px;
|
|
||||||
top: -4px;
|
|
||||||
right: -4px;
|
|
||||||
bottom: -4px;
|
|
||||||
background-color: transparent;
|
|
||||||
z-index: -100;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
background-image: conic-gradient(
|
|
||||||
rgba(var(--content)),
|
|
||||||
rgba(var(--content)) var(--progress),
|
|
||||||
transparent var(--progress)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -792,7 +792,7 @@
|
|||||||
|
|
||||||
function updateTrailWithRouteData() {
|
function updateTrailWithRouteData() {
|
||||||
overwriteGPX = true;
|
overwriteGPX = true;
|
||||||
const totals = route.getTotals();
|
const totals = route.features;
|
||||||
$formData.distance = totals.distance;
|
$formData.distance = totals.distance;
|
||||||
$formData.duration = totals.duration;
|
$formData.duration = totals.duration;
|
||||||
$formData.elevation_gain = totals.elevationGain;
|
$formData.elevation_gain = totals.elevationGain;
|
||||||
|
|||||||
Reference in New Issue
Block a user