adds photo upload for summit logs
This commit is contained in:
@@ -32,7 +32,7 @@
|
||||
></i>
|
||||
{/if}
|
||||
<div
|
||||
class="flex opacity-0 group-hover:opacity-100 absolute top-0 w-full h-full bg-white/75 rounded-xl items-center justify-center gap-6 transition-all"
|
||||
class="flex opacity-0 group-hover:opacity-100 absolute top-0 w-full h-full bg-secondary-hover/75 rounded-xl items-center justify-center gap-6 transition-all"
|
||||
>
|
||||
{#if showThumbnailControls}
|
||||
<button
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
<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";
|
||||
import { getFileURL, readAsDataURLAsync } from "$lib/util/file_util";
|
||||
|
||||
let map: Map;
|
||||
let L: any;
|
||||
let layer: any;
|
||||
let thumbnail: string = "/imgs/default_thumbnail.webp";
|
||||
|
||||
$: Promise.all(
|
||||
(log._photos ?? []).map(async (f) => {
|
||||
return await readAsDataURLAsync(f);
|
||||
}),
|
||||
).then((v) => {
|
||||
if (log.photos.length) {
|
||||
thumbnail = getFileURL(log, log.photos[0]);
|
||||
} else if (v.length) {
|
||||
thumbnail = v[0];
|
||||
} else {
|
||||
thumbnail = "/imgs/default_thumbnail.webp";
|
||||
}
|
||||
});
|
||||
|
||||
export let index: number = 0;
|
||||
export let log: SummitLog;
|
||||
export let mode: "show" | "edit" = "show";
|
||||
|
||||
@@ -27,64 +33,18 @@
|
||||
{ text: $_("edit"), value: "edit" },
|
||||
{ text: $_("delete"), value: "delete" },
|
||||
];
|
||||
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-4 my-2 border border-input-border rounded-xl">
|
||||
<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="h-24 aspect-square shrink-0 rounded-xl overflow-hidden">
|
||||
<img
|
||||
id="header-img"
|
||||
class="object-cover h-full"
|
||||
src={thumbnail}
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div class="basis-full">
|
||||
<div
|
||||
class="flex justify-between items-center"
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import Textarea from "../base/textarea.svelte";
|
||||
import TrailPicker from "../trail/trail_picker.svelte";
|
||||
import GPX from "$lib/models/gpx/gpx";
|
||||
import PhotoPicker from "../trail/photo_picker.svelte";
|
||||
export let openModal: (() => void) | undefined = undefined;
|
||||
export let closeModal: (() => void) | undefined = undefined;
|
||||
|
||||
@@ -30,6 +31,22 @@
|
||||
if (!$form.expand.gpx_data) {
|
||||
$form.gpx = "";
|
||||
}
|
||||
|
||||
if (
|
||||
!$form._photos?.length &&
|
||||
!$form.photos?.length &&
|
||||
$form.expand?.gpx_data
|
||||
) {
|
||||
const canvas = document.querySelector(
|
||||
"#trail-picker-map .maplibregl-canvas",
|
||||
) as HTMLCanvasElement;
|
||||
|
||||
const dataURL = canvas.toDataURL();
|
||||
const response = await fetch(dataURL);
|
||||
const blob = await response.blob();
|
||||
$form._photos = [new File([blob], "route")];
|
||||
}
|
||||
|
||||
dispatch("save", submittedValues);
|
||||
closeModal!();
|
||||
},
|
||||
@@ -85,6 +102,18 @@
|
||||
on:change={handleChange}
|
||||
></Datepicker>
|
||||
</div>
|
||||
<div>
|
||||
<label for="summitlog-photo-input" class="text-sm font-medium pb-1">
|
||||
{$_("photos")}
|
||||
</label>
|
||||
<PhotoPicker
|
||||
id="summitlog-photo-input"
|
||||
parent={$form}
|
||||
bind:photos={$form.photos}
|
||||
bind:photoFiles={$form._photos}
|
||||
showThumbnailControls={false}
|
||||
></PhotoPicker>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<TrailPicker
|
||||
bind:trailFile={$form._gpx}
|
||||
|
||||
@@ -79,7 +79,6 @@
|
||||
<tbody>
|
||||
{#each summitLogs as log, i}
|
||||
<SummitLogTableRow
|
||||
index={i}
|
||||
{log}
|
||||
on:open={(e) => openMap(e.detail)}
|
||||
on:text={(e) => openText(e.detail)}
|
||||
|
||||
@@ -1,66 +1,35 @@
|
||||
<script lang="ts">
|
||||
import type { SummitLog } from "$lib/models/summit_log";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
import { getFileURL } from "$lib/util/file_util";
|
||||
import {
|
||||
formatDistance,
|
||||
formatElevation,
|
||||
formatTimeHHMM,
|
||||
} from "$lib/util/format_util";
|
||||
import { gpx } from "$lib/vendor/toGeoJSON/toGeoJSON";
|
||||
import type { Map } from "leaflet";
|
||||
import { createEventDispatcher, onMount } from "svelte";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { getFileURL } from "$lib/util/file_util";
|
||||
import PhotoGallery from "../photo_gallery.svelte";
|
||||
|
||||
export let index: number;
|
||||
export let log: SummitLog;
|
||||
export let showCategory: boolean = false;
|
||||
export let showTrail: boolean = false;
|
||||
export let showAuthor: boolean = false;
|
||||
|
||||
let map: Map;
|
||||
let openGallery: (idx?: number) => void;
|
||||
|
||||
let imgSrc: string[] = [];
|
||||
$: if (log.photos?.length) {
|
||||
imgSrc = log.photos
|
||||
.filter((_, i) => i < 3)
|
||||
.reverse()
|
||||
.map((p) => getFileURL(log, p));
|
||||
} else {
|
||||
imgSrc = [];
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
onMount(async () => {
|
||||
if (log.expand.gpx_data) {
|
||||
await initMap();
|
||||
}
|
||||
});
|
||||
|
||||
async function initMap() {
|
||||
const 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 || !map) {
|
||||
return;
|
||||
}
|
||||
|
||||
const geoJson = gpx(
|
||||
new DOMParser().parseFromString(log.expand.gpx_data, "text/xml"),
|
||||
);
|
||||
const layer = (L as any)
|
||||
.geoJson(geoJson, {
|
||||
filter: (feature: any, layer: any) => {
|
||||
return feature.geometry.type !== "Point";
|
||||
},
|
||||
})
|
||||
.addTo(map);
|
||||
map.fitBounds(layer.getBounds());
|
||||
map.invalidateSize();
|
||||
}
|
||||
|
||||
function openMap() {
|
||||
dispatch("open", log);
|
||||
}
|
||||
|
||||
function openText() {
|
||||
dispatch("text", log);
|
||||
}
|
||||
@@ -75,13 +44,27 @@
|
||||
|
||||
<tr class="text-center">
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
on:click={openMap}
|
||||
class="h-20 aspect-square shrink-0 rounded-xl !bg-background hover:!bg-secondary-hover transition-colors"
|
||||
class:hidden={!log.expand?.gpx_data}
|
||||
id="mini-map-{index}"
|
||||
></button>
|
||||
{#if imgSrc.length}
|
||||
<PhotoGallery
|
||||
photos={log.photos.map((p) => getFileURL(log, p))}
|
||||
bind:open={openGallery}
|
||||
></PhotoGallery>
|
||||
<button
|
||||
class="relative w-16 aspect-square ml-2 mb-3 shrink-0"
|
||||
type="button"
|
||||
on:click={() => openGallery()}
|
||||
>
|
||||
{#each imgSrc as img, i}
|
||||
<img
|
||||
class="absolute h-full rounded-xl object-cover"
|
||||
style="top: {6 * i}px; right: {6 *
|
||||
i}px; transform: rotate(-{i * 5}deg)"
|
||||
src={img}
|
||||
alt="waypoint"
|
||||
/>
|
||||
{/each}
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
<td class:py-4={!log.expand?.gpx_data}
|
||||
>{new Date(log.date).toLocaleDateString(undefined, {
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex gap-x-4 max-w-full overflow-x-auto shrink-0 rounded-xl {offerUpload
|
||||
class="flex gap-x-4 max-w-full shrink-0 rounded-xl {offerUpload
|
||||
? 'outline-dashed outline-input-border'
|
||||
: ''}"
|
||||
role="dialog"
|
||||
@@ -125,7 +125,7 @@
|
||||
on:drop={handlePhotoDrop}
|
||||
>
|
||||
<button
|
||||
class="btn-secondary h-32 w-32 m-2 shrink-0 grow-0 basis-auto"
|
||||
class="btn-secondary h-32 w-32 shrink-0 grow-0 basis-auto"
|
||||
type="button"
|
||||
on:click={openPhotoBrowser}><i class="fa fa-plus"></i></button
|
||||
>
|
||||
@@ -137,17 +137,19 @@
|
||||
style="display: none;"
|
||||
on:change={() => handlePhotoSelection()}
|
||||
/>
|
||||
{#each (photos ?? []).concat(photoPreviews) as photo, i}
|
||||
<div class="shrink-0 grow-0 basis-auto m-2">
|
||||
<PhotoCard
|
||||
src={i >= photos.length ? photo : getFileURL(parent, photo)}
|
||||
on:delete={() => handlePhotoDelete(i)}
|
||||
isThumbnail={thumbnail === i}
|
||||
on:thumbnail={() => makePhotoThumbnail(i)}
|
||||
on:exif
|
||||
{showThumbnailControls}
|
||||
{showExifControls}
|
||||
></PhotoCard>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="flex overflow-x-auto gap-x-3">
|
||||
{#each (photos ?? []).concat(photoPreviews) as photo, i}
|
||||
<div class="shrink-0 grow-0 basis-auto overflow-hidden">
|
||||
<PhotoCard
|
||||
src={i >= photos.length ? photo : getFileURL(parent, photo)}
|
||||
on:delete={() => handlePhotoDelete(i)}
|
||||
isThumbnail={thumbnail === i}
|
||||
on:thumbnail={() => makePhotoThumbnail(i)}
|
||||
on:exif
|
||||
{showThumbnailControls}
|
||||
{showExifControls}
|
||||
></PhotoCard>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
alt=""
|
||||
/>
|
||||
<div
|
||||
class="absolute bottom-0 w-full h-1/2 bg-gradient-to-b from-transparent to-black opacity-50"
|
||||
class="absolute bottom-0 w-full h-2/3 bg-gradient-to-b from-transparent to-black opacity-50"
|
||||
></div>
|
||||
{#if (trail.public || trailIsShared) && pb.authStore.model}
|
||||
<div
|
||||
@@ -366,7 +366,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#if mode == "overview"}
|
||||
<div class="relative h-72 rounded-xl">
|
||||
<div class="relative h-72 rounded-xl overflow-hidden">
|
||||
<MapWithElevationMaplibre
|
||||
trails={[trail]}
|
||||
showElevation={false}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { gpx } from "$lib/vendor/toGeoJSON/toGeoJSON";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import * as M from "maplibre-gl";
|
||||
|
||||
import type { Map } from "leaflet";
|
||||
import { createEventDispatcher, onMount, tick } from "svelte";
|
||||
import { fromFile, toGeoJson } from "$lib/util/gpx_util";
|
||||
import { createEventDispatcher, onMount } 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;
|
||||
let map: M.Map;
|
||||
let layer: M.LineLayerSpecification | undefined;
|
||||
let source: M.GeoJSONSource | undefined;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
@@ -27,14 +26,13 @@
|
||||
});
|
||||
|
||||
async function initMap() {
|
||||
L = (await import("leaflet")).default;
|
||||
|
||||
map = L.map("trail-picker-map", {
|
||||
zoomControl: false,
|
||||
scrollWheelZoom: false,
|
||||
dragging: false,
|
||||
map = new M.Map({
|
||||
container: "trail-picker-map",
|
||||
attributionControl: false,
|
||||
dragPan: false,
|
||||
scrollZoom: false,
|
||||
preserveDrawingBuffer: true
|
||||
});
|
||||
map.attributionControl.setPrefix(false);
|
||||
}
|
||||
|
||||
function openTrailBrowser() {
|
||||
@@ -42,7 +40,7 @@
|
||||
trailFile = null;
|
||||
trailData = undefined;
|
||||
|
||||
dispatch("change", null)
|
||||
dispatch("change", null);
|
||||
} else {
|
||||
document.getElementById("trail-input")!.click();
|
||||
}
|
||||
@@ -59,35 +57,61 @@
|
||||
}
|
||||
|
||||
trailFile = files.item(0);
|
||||
trailData = await trailFile?.text();
|
||||
if (!trailFile) {
|
||||
return;
|
||||
}
|
||||
const parseResult = await fromFile(trailFile);
|
||||
|
||||
trailData = parseResult.gpxData;
|
||||
|
||||
dispatch("change", trailData);
|
||||
}
|
||||
|
||||
async function showTrailOnMap() {
|
||||
if (!trailData) {
|
||||
if (!trailData || !map) {
|
||||
return;
|
||||
}
|
||||
const sourceId = "trail-picker-geojson-source";
|
||||
const layerId = "trail-picker-geojson-layer";
|
||||
|
||||
const geojson = toGeoJson(trailData);
|
||||
|
||||
const geoJson = gpx(
|
||||
new DOMParser().parseFromString(trailData, "text/xml"),
|
||||
);
|
||||
if (layer) {
|
||||
map.removeLayer(layer);
|
||||
map.removeLayer(layerId);
|
||||
map.removeSource(sourceId);
|
||||
}
|
||||
layer = L.geoJson(geoJson, {
|
||||
filter: (feature: any, layer: any) => {
|
||||
return feature.geometry.type !== "Point";
|
||||
|
||||
map.addSource(sourceId, {
|
||||
type: "geojson",
|
||||
data: geojson,
|
||||
});
|
||||
map.addLayer({
|
||||
id: layerId,
|
||||
type: "line",
|
||||
source: sourceId,
|
||||
paint: {
|
||||
"line-color": "#3388ff",
|
||||
"line-width": 3,
|
||||
},
|
||||
}).addTo(map);
|
||||
map.fitBounds(layer.getBounds());
|
||||
map.invalidateSize();
|
||||
});
|
||||
layer = map.getLayer(layerId) as M.LineLayerSpecification;
|
||||
source = map.getSource(sourceId) as M.GeoJSONSource;
|
||||
map.resize()
|
||||
map.fitBounds(geojson.bbox as M.LngLatBoundsLike, {animate: false, padding: 8});
|
||||
}
|
||||
|
||||
function removeTrailFromMap() {
|
||||
if (layer) {
|
||||
map?.removeLayer(layer);
|
||||
map?.removeLayer(layer.id);
|
||||
layer = undefined;
|
||||
}
|
||||
if (source) {
|
||||
map?.removeSource(source.id);
|
||||
source = undefined;
|
||||
}
|
||||
|
||||
trailData = undefined;
|
||||
trailFile = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
$: if (waypoint.photos?.length) {
|
||||
imgSrc = waypoint.photos
|
||||
.filter((_, i) => i < 3)
|
||||
.reverse()
|
||||
.map((p) => getFileURL(waypoint, p));
|
||||
} else if (waypoint._photos?.length && browser) {
|
||||
Promise.all(
|
||||
@@ -37,7 +38,7 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex gap-4 p-4 border border-input-border rounded-md my-2 hover:bg-menu-item-background-hover"
|
||||
class="flex gap-4 p-4 outline outline-1 outline-input-border rounded-md my-2 hover:outline-2"
|
||||
>
|
||||
{#if imgSrc.length}
|
||||
{#if mode == "show"}
|
||||
|
||||
@@ -120,11 +120,11 @@
|
||||
></TextField>
|
||||
</div>
|
||||
<div>
|
||||
<label for="trail-photo-input" class="text-sm font-medium pb-1">
|
||||
<label for="waypoint-photo-input" class="text-sm font-medium pb-1">
|
||||
{$_("photos")}
|
||||
</label>
|
||||
<PhotoPicker
|
||||
id="waypoint"
|
||||
id="waypoint-photo-input"
|
||||
parent={$form}
|
||||
on:exif={(e) => getCoordinatesFromPhoto(e.detail)}
|
||||
bind:photos={$form.photos}
|
||||
|
||||
Reference in New Issue
Block a user