Skip to content

Commit d8e45de

Browse files
authored
Merge pull request #266 from VoidShake/feature/advanced-location-edit
Advanced Location Creating/Editing
2 parents 27ca0cf + 8f34510 commit d8e45de

23 files changed

+1975
-6840
lines changed

components/Menu.vue

+3-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
</template>
1111

1212
<script lang="ts" setup>
13-
import type { MenuButton, MenuOptions } from '~/composables/useMenu'
13+
import type { MenuButton, MenuOptions } from '~/composables/useMenu';
1414
1515
const menu = useMenu()
1616
@@ -38,6 +38,8 @@ async function click(button: MenuButton) {
3838
top: 0;
3939
left: 0;
4040
41+
min-width: 200px;
42+
4143
@apply bg-solid-700;
4244
4345
button {

components/action/Button.vue

+9-5
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
<template>
2-
<Teleport to="#action-buttons">
3-
<ActionButtonStyle @click="$emit('click')">
4-
<slot />
5-
</ActionButtonStyle>
6-
</Teleport>
2+
<!-- This ClientOnly can be removed once nuxt has support for other teleport targets in SSR: -->
3+
<!-- https://nuxt.com/docs/api/components/teleports -->
4+
<ClientOnly>
5+
<Teleport to="#action-buttons">
6+
<ActionButtonStyle @click="$emit('click')">
7+
<slot />
8+
</ActionButtonStyle>
9+
</Teleport>
10+
</ClientOnly>
711
</template>
812

913
<script lang="ts" setup>

components/dialog/CreatePlace.vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<template>
22
<DialogBase title="Add Marker">
3-
<FormCreatePlace :initial="{ pos }" @saved="closeDialog" />
3+
<FormCreatePlace :initial="{ pos }" hide-map @saved="closeDialog" />
44
</DialogBase>
55
</template>
66

components/form/CreateArea.vue

+80
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<template>
2+
<section>
3+
<FormKit v-slot="{ value, state: { valid } }" type="form" :actions="false" :errors="errors">
4+
<FormKit name="world" validation="required" type="hidden" :value="initial?.pos?.world ?? 'overworld'" />
5+
6+
<FormKit name="name" :value="initial?.name" validation="required" label="Name" type="text" />
7+
8+
<div class="grid grid-flow-col gap-4">
9+
<FormKit name="minY" label="Min-Y" type="number" step="1" :value="-64" />
10+
<FormKit name="maxY" label="Max-Y" type="number" step="1" :value="320" />
11+
</div>
12+
13+
<InputArea :initial="initial?.points" />
14+
15+
<div id="buttons">
16+
<slot name="buttons" :valid="valid" :value="value">
17+
<FormKit v-if="!onlyDraft" type="submit" :disabled="!valid" @click.prevent="save(value, false)" />
18+
<FormKit type="submit" :disabled="!valid" :classes="{ input: 'bg-solid-600' }"
19+
@click.prevent="save(value, true)">
20+
Save as Draft
21+
</FormKit>
22+
</slot>
23+
</div>
24+
</FormKit>
25+
</section>
26+
</template>
27+
28+
<script lang="ts" setup>
29+
import {
30+
CreateAreaDocument,
31+
CreateAreaDraftDocument,
32+
Permission,
33+
type AbstractArea,
34+
type CreateAreaDraftMutation,
35+
type CreateAreaInput,
36+
type CreateAreaMutation,
37+
} from '~~/graphql/generated';
38+
39+
const { hasPermission } = useSession()
40+
const { query } = useRoute()
41+
42+
const onlyDraft = computed(() => {
43+
if ('draft' in query) return true
44+
return !hasPermission(Permission.CreateLocation)
45+
})
46+
47+
const emit = defineEmits<{
48+
(e: 'saved', data: CreateAreaMutation | CreateAreaDraftMutation): void
49+
}>()
50+
51+
defineProps<{
52+
updateId?: number
53+
initial?: Partial<AbstractArea>
54+
}>()
55+
56+
const refetchQueries = ['getArea', 'getAreas']
57+
const { mutate: createArea, error } = useMutation(CreateAreaDocument, { refetchQueries })
58+
const { mutate: createAreaDraft, error: draftError } = useMutation(CreateAreaDraftDocument, { refetchQueries })
59+
const errors = computed(() =>
60+
[error, draftError]
61+
.map(it => it.value)
62+
.filter(notNull)
63+
.flatMap(extractMessages),
64+
)
65+
66+
async function save(input: CreateAreaInput, draft: boolean) {
67+
const create = draft ? createAreaDraft : createArea
68+
const response = await create({ input })
69+
const data = response?.data
70+
if (data) emit('saved', data)
71+
}
72+
</script>
73+
74+
<style lang="scss" scoped>
75+
form {
76+
#buttons {
77+
@apply flex gap-2;
78+
}
79+
}
80+
</style>

components/form/CreatePlace.vue

+5-6
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,15 @@
33
<FormKit v-slot="{ value, state: { valid } }" type="form" :actions="false" :errors="errors">
44
<FormKit name="world" validation="required" type="hidden" :value="initial?.pos?.world ?? 'overworld'" />
55

6-
<InputPos :initial="initial?.pos" />
7-
86
<FormKit name="name" :value="initial?.name" validation="required" label="Name" type="text" />
97

8+
<InputPos :initial="initial?.pos" :show-map="!hideMap" />
9+
1010
<div id="buttons">
1111
<slot name="buttons" :valid="valid" :value="value">
1212
<FormKit v-if="!onlyDraft" type="submit" :disabled="!valid" @click.prevent="save(value, false)" />
13-
<FormKit
14-
type="submit" :disabled="!valid" :classes="{ input: 'bg-solid-600' }"
15-
@click.prevent="save(value, true)"
16-
>
13+
<FormKit type="submit" :disabled="!valid" :classes="{ input: 'bg-solid-600' }"
14+
@click.prevent="save(value, true)">
1715
Save as Draft
1816
</FormKit>
1917
</slot>
@@ -49,6 +47,7 @@ const emit = defineEmits<{
4947
defineProps<{
5048
updateId?: number
5149
initial?: DeepPartial<AbstractPlace>
50+
hideMap?: boolean
5251
}>()
5352
5453
const refetchQueries = ['getPlace', 'getPlaces']

components/input/Area.vue

+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
<template>
2+
<FormKit v-model="points" name="points" placeholder="optional" label="Max-Y" type="hidden" />
3+
<MapView id="map" :zoom="8" @click="addPoint">
4+
<l-polygon :lat-lngs="latLngs" color="#41b782" :fill="true" :fill-opacity="0.5" fill-color="#41b782" />
5+
<MapDraggableMarker v-for="point, i in points" :key="i" :pos="point" @dragend="updatePoint(i, $event)" />
6+
</MapView>
7+
</template>
8+
9+
<script lang="ts" setup>
10+
import { LPolygon } from '@vue-leaflet/vue-leaflet';
11+
import { minBy } from 'lodash-es';
12+
import type { FlatPoint, PosFragment } from '~/graphql/generated';
13+
14+
const context = useMap()
15+
16+
const props = defineProps<{
17+
initial?: FlatPoint[]
18+
}>()
19+
20+
const points = ref<FlatPoint[]>(props.initial ?? [])
21+
22+
const latLngs = computed(() => points.value.map(it => toMapPos(context.value!.map, it)))
23+
24+
function distance(a: FlatPoint, b: FlatPoint) {
25+
return Math.sqrt((a.x - b.x) ** 2 + (a.z - b.z) ** 2)
26+
}
27+
28+
function findClosest(point: FlatPoint, between: FlatPoint[]) {
29+
const min = minBy(between, it => distance(point, it))
30+
return min && between.indexOf(min)
31+
}
32+
33+
function addPoint(pos: PosFragment) {
34+
const { x, z } = roundPos(pos)
35+
const current = points.value
36+
const closest = findClosest({ x, z }, current)
37+
if (notNull(closest)) {
38+
const neighbours = [current[(closest - 1 + current.length) % current.length], current[(closest + 1) % current.length]]
39+
const closestNeighbour = findClosest({ x, z }, neighbours)!
40+
41+
const newPoints = [...current]
42+
if (closestNeighbour === 0) {
43+
newPoints.splice(closest, 0, { x, z })
44+
} else {
45+
newPoints.splice((closest + 1) % current.length, 0, { x, z })
46+
}
47+
48+
points.value = newPoints
49+
} else {
50+
points.value = [...points.value, { x, z }]
51+
}
52+
}
53+
54+
function updatePoint(index: number, { x, z }: PosFragment) {
55+
const newPoints = [...points.value]
56+
newPoints[index] = roundPos({ x, z })
57+
points.value = newPoints.map(it => {
58+
const { x, z } = roundPos(it)
59+
return { x, z }
60+
})
61+
}
62+
</script>
63+
64+
<style lang="scss" scoped>
65+
#map {
66+
width: 100%;
67+
aspect-ratio: 1 / 1;
68+
max-height: 500px;
69+
}
70+
</style>

components/input/Pos.vue

+31-3
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,45 @@
11
<template>
22
<div class="grid grid-flow-col gap-4">
3-
<FormKit name="x" validation="required" label="X" type="number" step="1" :value="floored?.x" />
3+
<FormKit name="x" validation="required" label="X" type="number" step="1" v-model="xRef" />
44
<FormKit name="y" placeholder="optional" label="Y" type="number" step="1" :value="floored?.y" />
5-
<FormKit name="z" validation="required" label="Z" type="number" step="1" :value="floored?.z" />
5+
<FormKit name="z" validation="required" label="Z" type="number" step="1" v-model="zRef" />
66
</div>
7+
<MapView v-if="showMap" id="map" :zoom="8" :center="marker" @click="moveTo">
8+
<MapDraggableMarker v-if="marker" :pos="marker" @dragend="moveTo" />
9+
</MapView>
710
</template>
811

912
<script lang="ts" setup>
10-
import type { Point } from '~~/graphql/generated';
13+
import { type Point, type PosFragment } from '~~/graphql/generated';
1114
1215
const props = defineProps<{
1316
initial?: Partial<Point>
17+
showMap?: boolean
1418
}>()
1519
1620
const floored = computed(() => props.initial && roundPos(props.initial))
21+
22+
const xRef = ref<number | undefined>(floored.value?.x)
23+
const zRef = ref<number | undefined>(floored.value?.z)
24+
25+
const marker = computed(() => {
26+
const x = xRef?.value
27+
const z = zRef?.value
28+
if (notNull(x) && notNull(z)) return { x, z } as PosFragment
29+
return undefined
30+
})
31+
32+
function moveTo(pos: PosFragment) {
33+
const { x, z } = roundPos(pos)
34+
xRef.value = x
35+
zRef.value = z
36+
}
1737
</script>
38+
39+
<style lang="scss" scoped>
40+
#map {
41+
width: 100%;
42+
aspect-ratio: 1 / 1;
43+
max-height: 500px;
44+
}
45+
</style>

components/location/Page.vue

+4-2
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<slot name="title" />
66
</h1>
77

8-
<ActionLink :to="`${$route.path}/edit`">
8+
<ActionLink v-if="hasPermission(Permission.CreateLocation)" :to="`${$route.path}/edit`">
99
<PencilIcon />
1010
</ActionLink>
1111

@@ -18,7 +18,9 @@
1818

1919
<script lang="ts" setup>
2020
import { PencilIcon } from '@heroicons/vue/24/solid';
21-
import type { AreaFragment, PlaceDraftFragment, PlaceFragment } from '~~/graphql/generated';
21+
import { Permission, type AreaFragment, type PlaceDraftFragment, type PlaceFragment } from '~~/graphql/generated';
22+
23+
const { hasPermission } = useSession()
2224
2325
const props = defineProps<{
2426
location: PlaceFragment | PlaceDraftFragment | AreaFragment

components/map/AreaMarker.vue

+2-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
<template>
2-
<l-polygon :lat-lngs="latLngs" color="#41b782" :fill="true" :fill-opacity="0.5" fill-color="#41b782" />
2+
<l-polygon :lat-lngs="latLngs" color="#41b782" :fill="true" :fill-opacity="0.2" fill-color="#41b782" />
33
</template>
44

55
<script lang="ts" setup>
66
import { LPolygon } from '@vue-leaflet/vue-leaflet';
7-
import { LatLng } from 'leaflet';
87
import type { MapAreaFragment } from '~/graphql/generated';
98
109
const context = useMap()
@@ -13,5 +12,5 @@ const props = defineProps<{
1312
area: MapAreaFragment
1413
}>()
1514
16-
const latLngs = computed(() => props.area.points.map(it => new LatLng(...toMapPos(context.value!.map, it))))
15+
const latLngs = computed(() => props.area.points.map(it => toMapPos(context.value!.map, it)))
1716
</script>

components/map/DraggableMarker.vue

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<template>
2+
<l-marker :lat-lng="latLng" draggable @dragend="dragEnd" />
3+
</template>
4+
5+
<script lang="ts" setup>
6+
import { LMarker } from '@vue-leaflet/vue-leaflet';
7+
import type { DragEndEvent } from 'leaflet';
8+
import type { FlatPoint, PosFragment } from '~/graphql/generated';
9+
10+
const context = useMap()
11+
12+
const props = defineProps<{
13+
pos: PosFragment | FlatPoint
14+
}>()
15+
16+
const emit = defineEmits<{
17+
(event: 'dragend', payload: PosFragment): void
18+
}>()
19+
20+
function dragEnd(event: DragEndEvent) {
21+
emit('dragend', toWorldPos(context.value!.map, event.target._latlng))
22+
}
23+
24+
const latLng = computed(() => toMapPos(context.value!.map, props.pos))
25+
</script>

components/map/Interactive.vue

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
<template>
22
<MapView @click="closeMenu" @contextmenu="mapMenu">
3-
<DialogCreatePlace v-if="selected?.action == 'add-marker'" :pos="selected.pos" @close="selected = null" />
3+
<template #dialogs>
4+
<DialogCreatePlace v-if="selected?.action == 'add-marker'" :pos="selected.pos" @close="selected = null" />
5+
</template>
6+
7+
<MapLocations />
48
</MapView>
59
</template>
610

components/map/Leaflet.vue

+8-2
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,20 @@
22
<l-map :zoom="zoom ?? 0" :center="initialCenter" zoom-animation fade-animation :crs="crs"
33
:min-zoom="context?.minZoom!" :max-zoom="context?.maxZoom!" :max-native-zoom="context?.maxNativeZoom"
44
:options="options" @ready="ready">
5+
<slot />
56
<MapTiles />
6-
<MapLocations />
77
</l-map>
88
</template>
99

1010
<script lang="ts" setup>
1111
import { LMap } from '@vue-leaflet/vue-leaflet';
12-
import { CRS, Map, type LeafletMouseEvent } from 'leaflet';
12+
import { CRS, type Bounds, type LeafletMouseEvent, type Map } from 'leaflet';
1313
import type { PosFragment } from '~/graphql/generated';
1414
1515
const props = defineProps<{
1616
center?: PosFragment
1717
zoom?: number
18+
bounds?: Bounds
1819
disableControls?: boolean
1920
}>()
2021
@@ -23,9 +24,14 @@ const emit = defineEmits<{
2324
(e: 'contextmenu', pos: PosFragment, event: LeafletMouseEvent): void
2425
}>()
2526
27+
const leaflet = ref<Map>()
28+
29+
defineExpose({ leaflet })
30+
2631
function ready(map: Map) {
2732
map.on('contextmenu', e => emitWithPos('contextmenu', e))
2833
map.on('click', e => emitWithPos('click', e))
34+
leaflet.value = map
2935
}
3036
3137
function emitWithPos(e: 'click' | 'contextmenu', event: LeafletMouseEvent | PointerEvent) {

0 commit comments

Comments
 (0)