Allow picking coordinates from map.
This commit is contained in:
parent
56e247e031
commit
b5eaf0b637
|
@ -1,21 +1,32 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
import { onMounted, ref } from "vue";
|
import { onMounted, ref, watch } from "vue";
|
||||||
import { useConfigStore } from "@/stores/config";
|
import { useConfigStore } from "@/stores/config";
|
||||||
import type { Coordinates } from "@/types";
|
import type { Coordinates } from "@/types";
|
||||||
import * as L from "leaflet";
|
import * as L from "leaflet";
|
||||||
import { parseToFloat } from "@/utils/Numbers";
|
import { parseToFloat } from "@/utils/Numbers";
|
||||||
|
import type { LatLngTuple } from "leaflet";
|
||||||
|
import { forConstraint } from "@/shared/validation/validator";
|
||||||
|
import CONSTRAINTS from "@/shared/validation/constraints";
|
||||||
|
|
||||||
const wrapper = ref<HTMLElement>();
|
const wrapper = ref<HTMLElement>();
|
||||||
const configStore = useConfigStore();
|
const configStore = useConfigStore();
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
coordinates?: Coordinates;
|
coordinates?: Coordinates;
|
||||||
|
editable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "coordinatesSelected", value: Coordinates): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let map: L.Map | null = null;
|
||||||
|
let marker: L.Marker | null = null;
|
||||||
|
|
||||||
onMounted(renderMap);
|
onMounted(renderMap);
|
||||||
|
watch(props, updateMarker);
|
||||||
|
|
||||||
function getLayers(): {
|
function getLayers(): {
|
||||||
layers: { [name: string]: L.Layer };
|
layers: { [name: string]: L.Layer };
|
||||||
|
@ -41,6 +52,18 @@ function getLayers(): {
|
||||||
return { layers, defaultLayers };
|
return { layers, defaultLayers };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCoordinates(): LatLngTuple | undefined {
|
||||||
|
const coordinates = props.coordinates;
|
||||||
|
if (!coordinates) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!forConstraint(CONSTRAINTS.node.coords, false)(coordinates)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const [lat, lng] = props.coordinates.split(" ").map(parseToFloat);
|
||||||
|
return [lat, lng];
|
||||||
|
}
|
||||||
|
|
||||||
function createMap(defaultLayers: L.Layer[], layers: { [p: string]: L.Layer }) {
|
function createMap(defaultLayers: L.Layer[], layers: { [p: string]: L.Layer }) {
|
||||||
const element = wrapper.value;
|
const element = wrapper.value;
|
||||||
if (!element) {
|
if (!element) {
|
||||||
|
@ -57,27 +80,64 @@ function createMap(defaultLayers: L.Layer[], layers: { [p: string]: L.Layer }) {
|
||||||
attributionControl: true,
|
attributionControl: true,
|
||||||
layers: defaultLayers,
|
layers: defaultLayers,
|
||||||
};
|
};
|
||||||
const map = L.map(element, options);
|
map = L.map(element, options);
|
||||||
L.control.layers(layers).addTo(map);
|
L.control.layers(layers).addTo(map);
|
||||||
|
|
||||||
return map;
|
if (props.editable) {
|
||||||
|
map.on("click", onClick);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function centerOnCoordinates(map: L.Map) {
|
function updateMarker() {
|
||||||
|
const coordinates = getCoordinates();
|
||||||
|
if (!coordinates) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map) {
|
||||||
|
console.error("Map is not initialized.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (marker) {
|
||||||
|
marker.setLatLng(coordinates);
|
||||||
|
} else {
|
||||||
|
marker = L.marker(coordinates, {}).addTo(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.getBounds().contains(marker.getLatLng())) {
|
||||||
|
map.setView(marker.getLatLng(), map.getZoom());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function centerOnCoordinates() {
|
||||||
|
if (!map) {
|
||||||
|
console.error("Map is not initialized.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
let { lat, lng, defaultZoom: zoom } = configStore.getConfig.coordsSelector;
|
let { lat, lng, defaultZoom: zoom } = configStore.getConfig.coordsSelector;
|
||||||
|
|
||||||
if (props.coordinates) {
|
const coordinates = getCoordinates();
|
||||||
[lat, lng] = props.coordinates.split(" ").map(parseToFloat);
|
|
||||||
|
if (coordinates) {
|
||||||
|
[lat, lng] = coordinates;
|
||||||
zoom = map.getMaxZoom();
|
zoom = map.getMaxZoom();
|
||||||
L.marker([lat, lng], {}).addTo(map);
|
|
||||||
}
|
}
|
||||||
map.setView([lat, lng], zoom);
|
map.setView([lat, lng], zoom);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMap() {
|
function renderMap() {
|
||||||
const { layers, defaultLayers } = getLayers();
|
const { layers, defaultLayers } = getLayers();
|
||||||
const map = createMap(defaultLayers, layers);
|
createMap(defaultLayers, layers);
|
||||||
centerOnCoordinates(map);
|
centerOnCoordinates();
|
||||||
|
updateMarker();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClick(event: L.LeafletMouseEvent) {
|
||||||
|
emit(
|
||||||
|
"coordinatesSelected",
|
||||||
|
`${event.latlng.lat} ${event.latlng.lng}` as Coordinates
|
||||||
|
);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -4,8 +4,9 @@ import { type Constraint, forConstraint } from "@/shared/validation/validator";
|
||||||
import ExpandableHelpBox from "@/components/ExpandableHelpBox.vue";
|
import ExpandableHelpBox from "@/components/ExpandableHelpBox.vue";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
name: string;
|
||||||
modelValue?: string;
|
modelValue?: string;
|
||||||
label: string;
|
label?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
constraint: Constraint;
|
constraint: Constraint;
|
||||||
|
@ -19,7 +20,11 @@ const emit = defineEmits<{
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const displayLabel = computed(() =>
|
const displayLabel = computed(() =>
|
||||||
props.constraint.optional ? props.label : `${props.label}*`
|
props.label
|
||||||
|
? props.constraint.optional
|
||||||
|
? props.label
|
||||||
|
: `${props.label}*`
|
||||||
|
: undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
const input = ref<HTMLInputElement>();
|
const input = ref<HTMLInputElement>();
|
||||||
|
@ -75,17 +80,27 @@ onMounted(() => {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="validation-form-input">
|
<div class="validation-form-input">
|
||||||
<label>
|
<label v-if="displayLabel">
|
||||||
{{ displayLabel }}:
|
{{ displayLabel }}:
|
||||||
<ExpandableHelpBox v-if="props.help" :text="props.help" />
|
<ExpandableHelpBox v-if="props.help" :text="props.help" />
|
||||||
<input
|
<input
|
||||||
ref="input"
|
ref="input"
|
||||||
|
:name="props.name"
|
||||||
:value="props.modelValue"
|
:value="props.modelValue"
|
||||||
@input="onInput"
|
@input="onInput"
|
||||||
:type="props.type || 'text'"
|
:type="props.type || 'text'"
|
||||||
:placeholder="props.placeholder"
|
:placeholder="props.placeholder"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<input
|
||||||
|
v-if="!displayLabel"
|
||||||
|
ref="input"
|
||||||
|
:name="props.name"
|
||||||
|
:value="props.modelValue"
|
||||||
|
@input="onInput"
|
||||||
|
:type="props.type || 'text'"
|
||||||
|
:placeholder="props.placeholder"
|
||||||
|
/>
|
||||||
<div class="validation-error" v-if="!valid">
|
<div class="validation-error" v-if="!valid">
|
||||||
{{ props.validationError }}
|
{{ props.validationError }}
|
||||||
</div>
|
</div>
|
||||||
|
|
95
frontend/src/components/nodes/NodeCoordinatesInput.vue
Normal file
95
frontend/src/components/nodes/NodeCoordinatesInput.vue
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ValidationFormInput from "@/components/form/ValidationFormInput.vue";
|
||||||
|
import type { Coordinates } from "@/types";
|
||||||
|
import { useConfigStore } from "@/stores/config";
|
||||||
|
import CONSTRAINTS from "@/shared/validation/constraints";
|
||||||
|
import NodeMap from "@/components/NodeMap.vue";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue?: Coordinates;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", value: Coordinates): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const configStore = useConfigStore();
|
||||||
|
|
||||||
|
function onUpdateModelValue(value: string) {
|
||||||
|
emit("update:modelValue", value as Coordinates);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="node-coordinates-input">
|
||||||
|
<div class="input-panel">
|
||||||
|
<p class="help-block">
|
||||||
|
Wenn Du möchtest, dass Dein Knoten an der richtigen Stelle auf
|
||||||
|
der
|
||||||
|
<a :href="configStore.getConfig.map.mapUrl" target="_blank"
|
||||||
|
>Knotenkarte</a
|
||||||
|
>
|
||||||
|
angezeigt wird, kannst Du seine Koordinaten hier eintragen.
|
||||||
|
Klicke einfach in der auf dieser Seite angezeigten Karte an die
|
||||||
|
Stelle, wo Dein Knoten erscheinen soll. Durch erneutes Klicken
|
||||||
|
kannst Du die Position jederzeit anpassen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ValidationFormInput
|
||||||
|
name="coords"
|
||||||
|
:model-value="props.modelValue"
|
||||||
|
@update:modelValue="onUpdateModelValue"
|
||||||
|
:placeholder="`z. B. ${configStore.getConfig.coordsSelector.lat} ${configStore.getConfig.coordsSelector.lng}`"
|
||||||
|
:constraint="CONSTRAINTS.node.coords"
|
||||||
|
:validation-error="`Bitte gib die Koordinaten wie folgt an, Beispiel: ${configStore.getConfig.coordsSelector.lat} ${configStore.getConfig.coordsSelector.lng}`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="node-map">
|
||||||
|
<NodeMap
|
||||||
|
@coordinatesSelected="onUpdateModelValue"
|
||||||
|
:coordinates="props.modelValue"
|
||||||
|
:editable="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@import "../../scss/variables";
|
||||||
|
@import "../../scss/mixins";
|
||||||
|
|
||||||
|
.node-coordinates-input {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin: $node-coordinates-input-margin;
|
||||||
|
|
||||||
|
.help-block {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-panel {
|
||||||
|
width: $node-coordinates-input-input-panel-width;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-map {
|
||||||
|
margin-left: $node-coordinates-input-horizontal-gap;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include max-page-breakpoint(small) {
|
||||||
|
.node-coordinates-input {
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.input-panel {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-map {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -26,6 +26,7 @@ import ValidationFormInput from "@/components/form/ValidationFormInput.vue";
|
||||||
import { route, RouteName } from "@/router";
|
import { route, RouteName } from "@/router";
|
||||||
import RouteButton from "@/components/form/RouteButton.vue";
|
import RouteButton from "@/components/form/RouteButton.vue";
|
||||||
import { ApiError } from "@/utils/Api";
|
import { ApiError } from "@/utils/Api";
|
||||||
|
import NodeCoordinatesInput from "@/components/nodes/NodeCoordinatesInput.vue";
|
||||||
|
|
||||||
const configStore = useConfigStore();
|
const configStore = useConfigStore();
|
||||||
const nodeStore = useNodeStore();
|
const nodeStore = useNodeStore();
|
||||||
|
@ -143,6 +144,7 @@ async function onSubmit() {
|
||||||
|
|
||||||
<ValidationFormInput
|
<ValidationFormInput
|
||||||
v-model="hostnameModel"
|
v-model="hostnameModel"
|
||||||
|
name="hostname"
|
||||||
label="Knotenname"
|
label="Knotenname"
|
||||||
placeholder="z. B. Lisas-Freifunk"
|
placeholder="z. B. Lisas-Freifunk"
|
||||||
:constraint="CONSTRAINTS.node.hostname"
|
:constraint="CONSTRAINTS.node.hostname"
|
||||||
|
@ -151,6 +153,7 @@ async function onSubmit() {
|
||||||
/>
|
/>
|
||||||
<ValidationFormInput
|
<ValidationFormInput
|
||||||
v-model="fastdKeyModel"
|
v-model="fastdKeyModel"
|
||||||
|
name="key"
|
||||||
label="VPN-Schlüssel (bitte nur weglassen, wenn Du weisst, was Du tust)"
|
label="VPN-Schlüssel (bitte nur weglassen, wenn Du weisst, was Du tust)"
|
||||||
placeholder="Dein 64-stelliger VPN-Schlüssel"
|
placeholder="Dein 64-stelliger VPN-Schlüssel"
|
||||||
:constraint="CONSTRAINTS.node.key"
|
:constraint="CONSTRAINTS.node.key"
|
||||||
|
@ -159,6 +162,7 @@ async function onSubmit() {
|
||||||
/>
|
/>
|
||||||
<ValidationFormInput
|
<ValidationFormInput
|
||||||
v-model="macModel"
|
v-model="macModel"
|
||||||
|
name="mac"
|
||||||
label="MAC-Adresse"
|
label="MAC-Adresse"
|
||||||
placeholder="z. B. 12:34:56:78:9a:bc oder 123456789abc"
|
placeholder="z. B. 12:34:56:78:9a:bc oder 123456789abc"
|
||||||
:constraint="CONSTRAINTS.node.mac"
|
:constraint="CONSTRAINTS.node.mac"
|
||||||
|
@ -167,7 +171,11 @@ async function onSubmit() {
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<h1>TODO: Standort</h1>
|
<fieldset>
|
||||||
|
<h3>Wo soll Dein Router stehen?</h3>
|
||||||
|
|
||||||
|
<NodeCoordinatesInput v-model="coordsModel" />
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<h3>Wie können wir Dich erreichen?</h3>
|
<h3>Wie können wir Dich erreichen?</h3>
|
||||||
|
@ -186,6 +194,7 @@ async function onSubmit() {
|
||||||
|
|
||||||
<ValidationFormInput
|
<ValidationFormInput
|
||||||
v-model="nicknameModel"
|
v-model="nicknameModel"
|
||||||
|
name="nickname"
|
||||||
label="Nickname / Name"
|
label="Nickname / Name"
|
||||||
placeholder="z. B. Lisa"
|
placeholder="z. B. Lisa"
|
||||||
:constraint="CONSTRAINTS.node.nickname"
|
:constraint="CONSTRAINTS.node.nickname"
|
||||||
|
@ -193,6 +202,7 @@ async function onSubmit() {
|
||||||
/>
|
/>
|
||||||
<ValidationFormInput
|
<ValidationFormInput
|
||||||
v-model="emailModel"
|
v-model="emailModel"
|
||||||
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
label="E-Mail-Adresse"
|
label="E-Mail-Adresse"
|
||||||
:placeholder="`z. B. lisa@${configStore.getConfig.community.domain}`"
|
:placeholder="`z. B. lisa@${configStore.getConfig.community.domain}`"
|
||||||
|
@ -201,7 +211,11 @@ async function onSubmit() {
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<h1>TODO: Monitoring</h1>
|
<fieldset>
|
||||||
|
<h3>TODO: Monitoring</h3>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<h1>TODO: Check community bounds</h1>
|
||||||
|
|
||||||
<ButtonGroup
|
<ButtonGroup
|
||||||
:align="ComponentAlignment.RIGHT"
|
:align="ComponentAlignment.RIGHT"
|
||||||
|
|
|
@ -87,6 +87,7 @@ async function onSubmit() {
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<ValidationFormInput
|
<ValidationFormInput
|
||||||
class="token-input"
|
class="token-input"
|
||||||
|
name="token"
|
||||||
v-model="token"
|
v-model="token"
|
||||||
label="Token"
|
label="Token"
|
||||||
placeholder="Dein 16-stelliger Token"
|
placeholder="Dein 16-stelliger Token"
|
||||||
|
|
|
@ -82,7 +82,7 @@ $link-focus-outline: 0.1em solid $link-hover-color;
|
||||||
|
|
||||||
// Form
|
// Form
|
||||||
$label-font-weight: 600;
|
$label-font-weight: 600;
|
||||||
$fieldset-margin: 0;
|
$fieldset-margin: 1em 0;
|
||||||
$fieldset-padding: 0.5em 0.75em;
|
$fieldset-padding: 0.5em 0.75em;
|
||||||
$fieldset-border: 0.1em solid $gray;
|
$fieldset-border: 0.1em solid $gray;
|
||||||
$fieldset-border-radius: 0.5em;
|
$fieldset-border-radius: 0.5em;
|
||||||
|
@ -141,6 +141,11 @@ $node-preview-card-map-margin: 1em 0 0.25em 0;
|
||||||
$node-preview-card-field-margin: 0.25em 0;
|
$node-preview-card-field-margin: 0.25em 0;
|
||||||
$node-preview-card-field-code-font-size: 1.25em;
|
$node-preview-card-field-code-font-size: 1.25em;
|
||||||
|
|
||||||
|
// Node coordinates input
|
||||||
|
$node-coordinates-input-margin: 1em 0;
|
||||||
|
$node-coordinates-input-input-panel-width: 40%;
|
||||||
|
$node-coordinates-input-horizontal-gap: 1em;
|
||||||
|
|
||||||
// Created node summary
|
// Created node summary
|
||||||
$node-created-summary-padding: $node-preview-card-padding;
|
$node-created-summary-padding: $node-preview-card-padding;
|
||||||
$node-created-summary-background-color: darken($variant-color-success, 30%);
|
$node-created-summary-background-color: darken($variant-color-success, 30%);
|
||||||
|
|
Loading…
Reference in a new issue