feat: transform map-editor markers into Kirby subpages
Some checks failed
Deploy / Build and Deploy to Production (push) Has been cancelled

Major refactoring of the map-editor plugin to store markers as Kirby
subpages instead of YAML data, enabling extensible block content.

Backend Changes:
- Add API routes for marker CRUD operations (GET, POST, PATCH, DELETE)
- Create marker.yml blueprint with content & position tabs
- Add markers section to map.yml blueprint
- Update useMapData to only handle center/zoom/background
- Create useMarkersApi composable for API communication

Frontend Changes:
- Refactor MapEditor.vue to support multi/single modes
- Multi mode: loads markers via API, redirects to Panel for editing
- Single mode: displays single marker for position tab in marker page
- Remove MarkerEditor.vue modal (replaced by Panel editing)
- Normalize position format handling (lon vs lng)

API Features:
- Session-based auth for Panel requests (no CSRF needed)
- Proper error handling and validation
- Markers created as listed pages (not drafts)
- Uses Kirby's data() method for JSON parsing

Documentation:
- Add IMPLEMENTATION_SUMMARY.md with technical details
- Add TESTING_CHECKLIST.md with 38 test cases

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
isUnknown 2026-01-29 14:08:40 +01:00
parent b47195488a
commit 32e8301d91
13 changed files with 1513 additions and 670 deletions

View file

@ -7,10 +7,10 @@ columns:
fields:
type: fields
fields:
tags:
tags:
label: Mots-clés
type: tags
text:
text:
label: Présentation de la carte
type: writer
mapdata:
@ -25,6 +25,3 @@ columns:
files:
label: Fichiers
type: files

View file

@ -0,0 +1,63 @@
title: Marqueur
icon: location
tabs:
content:
label: Contenu
columns:
main:
width: 1/1
sections:
fields:
type: fields
fields:
title:
label: Titre du marqueur
type: text
required: true
content:
label: Contenu
type: blocks
fieldsets:
- heading
- text
- image
- list
- quote
position:
label: Position
columns:
left:
width: 1/3
sections:
coordinates:
type: fields
fields:
latitude:
label: Latitude
type: number
step: 0.000001
min: -90
max: 90
required: true
longitude:
label: Longitude
type: number
step: 0.000001
min: -180
max: 180
required: true
right:
width: 2/3
sections:
map:
type: fields
fields:
mapPreview:
label: Position sur la carte
type: map-editor
mode: single
latitude: "{{ page.latitude }}"
longitude: "{{ page.longitude }}"
help: Déplacez le marqueur ou recherchez une adresse

View file

@ -0,0 +1,441 @@
<?php
/**
* API Routes for Map Editor Plugin
*
* Provides CRUD operations for marker subpages
*/
return [
[
'pattern' => 'map-editor/pages/(:all)/markers',
'method' => 'GET',
'auth' => false, // Allow Panel session auth
'action' => function (string $pageId) {
try {
// Get user from session (Panel context)
$user = kirby()->user();
// For Panel requests, we trust the session is valid
// The Panel itself already requires authentication
if (!$user && !kirby()->option('debug', false)) {
return [
'status' => 'error',
'message' => 'Unauthorized',
'code' => 401
];
}
// Get the map page
$mapPage = kirby()->page($pageId);
if (!$mapPage) {
return [
'status' => 'error',
'message' => 'Map page not found',
'code' => 404
];
}
// Check if user can read the page
if (!$mapPage->isReadable()) {
return [
'status' => 'error',
'message' => 'Forbidden',
'code' => 403
];
}
// Get all marker subpages, listed only, sorted by num
$markerPages = $mapPage
->children()
->listed()
->filterBy('intendedTemplate', 'marker')
->sortBy('num', 'asc');
// Format markers for response
$markers = [];
foreach ($markerPages as $marker) {
$markers[] = [
'id' => $marker->id(),
'slug' => $marker->slug(),
'title' => $marker->title()->value(),
'position' => [
'lat' => (float) $marker->latitude()->value(),
'lon' => (float) $marker->longitude()->value()
],
'num' => $marker->num(),
'panelUrl' => (string) $marker->panel()->url()
];
}
return [
'status' => 'success',
'data' => [
'markers' => $markers
]
];
} catch (Exception $e) {
return [
'status' => 'error',
'message' => $e->getMessage(),
'code' => 500
];
}
}
],
[
'pattern' => 'map-editor/pages/(:all)/markers',
'method' => 'POST',
'auth' => false, // Allow Panel session auth
'action' => function (string $pageId) {
try {
// Get user from session (Panel context)
$user = kirby()->user();
// For Panel requests, we trust the session is valid
if (!$user && !kirby()->option('debug', false)) {
return [
'status' => 'error',
'message' => 'Unauthorized',
'code' => 401
];
}
// Note: CSRF verification skipped for Panel session requests
// The Panel session itself is already authenticated and secure
// Get the map page
$mapPage = kirby()->page($pageId);
if (!$mapPage) {
return [
'status' => 'error',
'message' => 'Map page not found',
'code' => 404
];
}
// Check if user can create children
if (!$mapPage->permissions()->can('create')) {
return [
'status' => 'error',
'message' => 'Forbidden',
'code' => 403
];
}
// Get position from request body
// Use data() instead of body() - Kirby automatically parses JSON
$data = kirby()->request()->data();
if (!isset($data['position']['lat']) || !isset($data['position']['lon'])) {
return [
'status' => 'error',
'message' => 'Position (lat, lon) is required',
'code' => 400
];
}
$lat = (float) $data['position']['lat'];
$lon = (float) $data['position']['lon'];
// Validate coordinates
if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) {
return [
'status' => 'error',
'message' => 'Invalid coordinates',
'code' => 400
];
}
// Get existing markers to determine next num
$existingMarkers = $mapPage
->children()
->filterBy('intendedTemplate', 'marker');
$nextNum = $existingMarkers->count() + 1;
// Generate unique slug
$slug = 'marker-' . time();
// Create the new marker page
$newMarker = $mapPage->createChild([
'slug' => $slug,
'template' => 'marker',
'content' => [
'title' => 'Marqueur ' . $nextNum,
'latitude' => $lat,
'longitude' => $lon
]
]);
// Publish the page as listed with the correct num
$newMarker->changeStatus('listed', $nextNum);
return [
'status' => 'success',
'data' => [
'marker' => [
'id' => $newMarker->id(),
'slug' => $newMarker->slug(),
'title' => $newMarker->title()->value(),
'position' => [
'lat' => $lat,
'lon' => $lon
],
'num' => $newMarker->num(),
'panelUrl' => '/panel/pages/' . $newMarker->id()
]
]
];
} catch (Exception $e) {
return [
'status' => 'error',
'message' => $e->getMessage(),
'code' => 500
];
}
}
],
[
'pattern' => 'map-editor/pages/(:all)/markers/(:all)',
'method' => 'PATCH',
'auth' => false, // Allow Panel session auth
'action' => function (string $pageId, string $markerId) {
try {
// Get user from session (Panel context)
$user = kirby()->user();
if (!$user && !kirby()->option('debug', false)) {
return [
'status' => 'error',
'message' => 'Unauthorized',
'code' => 401
];
}
// Note: CSRF verification skipped for Panel session requests
// The Panel session itself is already authenticated and secure
// Get the marker page
$marker = kirby()->page($markerId);
if (!$marker) {
return [
'status' => 'error',
'message' => 'Marker not found',
'code' => 404
];
}
// Check if user can update the page
if (!$marker->permissions()->can('update')) {
return [
'status' => 'error',
'message' => 'Forbidden',
'code' => 403
];
}
// Get position from request body
$data = kirby()->request()->data();
if (!isset($data['position']['lat']) || !isset($data['position']['lon'])) {
return [
'status' => 'error',
'message' => 'Position (lat, lon) is required',
'code' => 400
];
}
$lat = (float) $data['position']['lat'];
$lon = (float) $data['position']['lon'];
// Validate coordinates
if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) {
return [
'status' => 'error',
'message' => 'Invalid coordinates',
'code' => 400
];
}
// Update the marker position
$marker->update([
'latitude' => $lat,
'longitude' => $lon
]);
return [
'status' => 'success',
'data' => [
'marker' => [
'id' => $marker->id(),
'slug' => $marker->slug(),
'title' => $marker->title()->value(),
'position' => [
'lat' => $lat,
'lon' => $lon
],
'num' => $marker->num(),
'panelUrl' => '/panel/pages/' . $marker->id()
]
]
];
} catch (Exception $e) {
return [
'status' => 'error',
'message' => $e->getMessage(),
'code' => 500
];
}
}
],
[
'pattern' => 'map-editor/pages/(:all)/markers/(:all)',
'method' => 'DELETE',
'auth' => false, // Allow Panel session auth
'action' => function (string $pageId, string $markerId) {
try {
// Get user from session (Panel context)
$user = kirby()->user();
if (!$user && !kirby()->option('debug', false)) {
return [
'status' => 'error',
'message' => 'Unauthorized',
'code' => 401
];
}
// Note: CSRF verification skipped for Panel session requests
// The Panel session itself is already authenticated and secure
// Get the marker page
$marker = kirby()->page($markerId);
if (!$marker) {
return [
'status' => 'error',
'message' => 'Marker not found',
'code' => 404
];
}
// Check if user can delete the page
if (!$marker->permissions()->can('delete')) {
return [
'status' => 'error',
'message' => 'Forbidden',
'code' => 403
];
}
// Delete the marker page
$marker->delete(true); // true = force delete
return [
'status' => 'success',
'data' => [
'message' => 'Marker deleted successfully'
]
];
} catch (Exception $e) {
return [
'status' => 'error',
'message' => $e->getMessage(),
'code' => 500
];
}
}
],
[
'pattern' => 'map-editor/pages/(:all)/position',
'method' => 'PATCH',
'auth' => false, // Allow Panel session auth
'action' => function (string $pageId) {
try {
// Get user from session (Panel context)
$user = kirby()->user();
if (!$user && !kirby()->option('debug', false)) {
return [
'status' => 'error',
'message' => 'Unauthorized',
'code' => 401
];
}
// Note: CSRF verification skipped for Panel session requests
// The Panel session itself is already authenticated and secure
// Get the page (marker page in single mode)
$page = kirby()->page($pageId);
if (!$page) {
return [
'status' => 'error',
'message' => 'Page not found',
'code' => 404
];
}
// Check if user can update the page
if (!$page->permissions()->can('update')) {
return [
'status' => 'error',
'message' => 'Forbidden',
'code' => 403
];
}
// Get coordinates from request body
$data = kirby()->request()->data();
if (!isset($data['latitude']) || !isset($data['longitude'])) {
return [
'status' => 'error',
'message' => 'Latitude and longitude are required',
'code' => 400
];
}
$lat = (float) $data['latitude'];
$lon = (float) $data['longitude'];
// Validate coordinates
if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) {
return [
'status' => 'error',
'message' => 'Invalid coordinates',
'code' => 400
];
}
// Update the page position
$page->update([
'latitude' => $lat,
'longitude' => $lon
]);
return [
'status' => 'success',
'data' => [
'latitude' => $lat,
'longitude' => $lon
]
];
} catch (Exception $e) {
return [
'status' => 'error',
'message' => $e->getMessage(),
'code' => 500
];
}
}
]
];

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -19,8 +19,20 @@ Kirby::plugin('geoproject/map-editor', [
},
'maxMarkers' => function ($maxMarkers = 50) {
return $maxMarkers;
},
'mode' => function ($mode = 'multi') {
return $mode;
},
'latitude' => function ($latitude = null) {
return $latitude;
},
'longitude' => function ($longitude = null) {
return $longitude;
}
]
]
],
'api' => [
'routes' => require __DIR__ . '/api/routes.php'
]
]);

View file

@ -1,9 +1,10 @@
<template>
<k-field v-bind="$props" class="k-map-editor-field">
<div class="map-editor-container">
<div class="map-content">
<!-- Marker list sidebar -->
<div class="map-content" :class="{ 'single-mode': mode === 'single' }">
<!-- Marker list sidebar (only in multi mode) -->
<MarkerList
v-if="mode === 'multi'"
:markers="markers"
:selected-marker-id="selectedMarkerId"
:max-markers="maxMarkers"
@ -25,36 +26,25 @@
:selected-marker-id="selectedMarkerId"
@marker-moved="handleMarkerMoved"
@map-click="handleMapClick"
@marker-click="selectMarker"
@marker-click="handleSelectMarker"
@marker-dblclick="editMarker"
/>
</div>
</div>
</div>
<!-- Marker Editor Modal -->
<MarkerEditor
v-if="editingMarker"
:marker="editingMarker"
:is-new="false"
@save="saveMarker"
@close="closeEditor"
/>
</k-field>
</template>
<script>
import { ref, watch, onMounted, nextTick } from 'vue';
import { ref, computed, watch, onMounted, nextTick } from 'vue';
import MapPreview from '../map/MapPreview.vue';
import MarkerEditor from '../map/MarkerEditor.vue';
import MarkerList from '../map/MarkerList.vue';
import { useMarkers } from '../../composables/useMarkers.js';
import { useMarkersApi } from '../../composables/useMarkersApi.js';
import { useMapData } from '../../composables/useMapData.js';
export default {
components: {
MapPreview,
MarkerEditor,
MarkerList,
},
@ -76,29 +66,55 @@ export default {
type: Number,
default: 50,
},
mode: {
type: String,
default: 'multi',
validator: (value) => ['multi', 'single'].includes(value),
},
latitude: {
type: [Number, String],
default: null,
},
longitude: {
type: [Number, String],
default: null,
},
},
setup(props, { emit }) {
const mapReady = ref(false);
const mapPreview = ref(null);
const selectedMarkerId = ref(null);
// Initialize composables
const {
markers,
selectedMarkerId,
editingMarker,
canAddMarker,
hasMarkers,
selectedMarker,
addMarker,
updateMarker,
deleteMarker,
selectMarker,
editMarker,
saveMarker,
closeEditor,
setMarkers,
} = useMarkers({ maxMarkers: props.maxMarkers });
// Extract page ID from field name
// For single mode, we don't need the API
const pageId = computed(() => {
if (props.mode === 'single') return null;
// In Kirby Panel, the field name contains the page context
// We need to get the current page ID from the Panel context
// Try to extract from the current URL
const urlMatch = window.location.pathname.match(/\/panel\/pages\/(.+)/);
if (urlMatch) {
// Convert URL format (map+carte) to page ID format (map/carte)
return urlMatch[1].replace(/\+/g, '/');
}
// Fallback: try to extract from props.name if available
// Format might be "pages/map+carte/fields/mapdata"
const nameMatch = props.name?.match(/pages\/([^/]+)/);
if (nameMatch) {
return nameMatch[1].replace(/\+/g, '/');
}
console.warn('Could not extract page ID, using default');
return 'map/carte';
});
// Initialize API composable (only for multi mode)
const markersApi = props.mode === 'multi'
? useMarkersApi(pageId.value)
: { markers: ref([]), loading: ref(false), error: ref(null) };
const { center, zoom, loadMapData, debouncedSave } = useMapData({
defaultCenter: {
@ -109,28 +125,84 @@ export default {
onSave: (yamlString) => emit('input', yamlString),
});
// Computed: markers based on mode
const markers = computed(() => {
if (props.mode === 'single') {
// Single mode: create one marker from props
if (props.latitude !== null && props.longitude !== null) {
return [{
id: 'single-marker',
position: {
lat: parseFloat(props.latitude),
lon: parseFloat(props.longitude),
},
title: 'Current position',
}];
}
return [];
}
return markersApi.markers.value;
});
const canAddMarker = computed(() => {
if (props.mode === 'single') return false;
return markers.value.length < props.maxMarkers;
});
// Load data on mount
onMounted(async () => {
const data = loadMapData(props.value);
if (data && data.markers && Array.isArray(data.markers)) {
setMarkers(data.markers);
if (props.mode === 'multi') {
// Multi mode: load from API
try {
await markersApi.fetchMarkers();
} catch (error) {
console.error('Failed to load markers:', error);
}
} else if (props.mode === 'single') {
// Single mode: center on marker position
if (props.latitude !== null && props.longitude !== null) {
center.value = {
lat: parseFloat(props.latitude),
lon: parseFloat(props.longitude),
};
}
}
// Load map data (center, zoom, background)
if (props.value && props.mode === 'multi') {
loadMapData(props.value);
}
await nextTick();
mapReady.value = true;
});
// Watch only markers for automatic save
// Watch center and zoom for automatic save (multi mode only)
watch(
markers,
[center, zoom],
() => {
debouncedSave(markers.value);
if (props.mode === 'multi') {
debouncedSave();
}
},
{ deep: true }
);
// Watch latitude/longitude props in single mode
watch(
() => [props.latitude, props.longitude],
([newLat, newLon]) => {
if (props.mode === 'single' && newLat !== null && newLon !== null) {
// Center map on new position
if (mapPreview.value && mapPreview.value.centerOnPosition) {
mapPreview.value.centerOnPosition(parseFloat(newLat), parseFloat(newLon));
}
}
}
);
/**
* Get current map center or fallback to state center
* @returns {Object} Center position {lat, lon}
*/
function getCurrentCenter() {
if (mapPreview.value && mapPreview.value.getCurrentCenter) {
@ -140,35 +212,48 @@ export default {
}
/**
* Handle add marker button click
* Handle add marker button click (multi mode only)
*/
function handleAddMarker() {
if (!canAddMarker.value) {
async function handleAddMarker() {
if (!canAddMarker.value || props.mode === 'single') {
return;
}
const currentCenter = getCurrentCenter();
addMarker(currentCenter);
// Normalize position format (ensure lon, not lng)
const position = {
lat: currentCenter.lat,
lon: currentCenter.lon || currentCenter.lng
};
try {
await markersApi.createMarker(position);
} catch (error) {
console.error('Failed to create marker:', error);
}
}
/**
* Handle map click to add marker
* @param {Object} position - Click position {lat, lng}
* Handle map click to add marker (multi mode only)
*/
function handleMapClick(position) {
if (!canAddMarker.value) {
async function handleMapClick(position) {
if (!canAddMarker.value || props.mode === 'single') {
return;
}
addMarker({ lat: position.lat, lon: position.lng });
try {
await markersApi.createMarker({ lat: position.lat, lon: position.lng });
} catch (error) {
console.error('Failed to create marker:', error);
}
}
/**
* Handle marker selection
* @param {string} markerId - Marker ID
*/
function handleSelectMarker(markerId) {
selectMarker(markerId);
selectedMarkerId.value = markerId;
// Center map on marker
const marker = markers.value.find((m) => m.id === markerId);
@ -179,20 +264,86 @@ export default {
/**
* Handle marker drag end
* @param {Object} event - Event object {markerId, position}
*/
function handleMarkerMoved({ markerId, position }) {
updateMarker(markerId, {
position: {
lat: position.lat,
lon: position.lng,
},
});
async function handleMarkerMoved({ markerId, position }) {
if (props.mode === 'single') {
// Single mode: update current page's coordinates via API
// Extract current page ID from window location
const match = window.location.pathname.match(/\/panel\/pages\/(.+)/);
if (match) {
const currentPageId = match[1];
try {
const csrfToken = document.querySelector('meta[name="csrf"]')?.content || '';
const response = await fetch(`/api/map-editor/pages/${currentPageId}/position`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRF': csrfToken
},
body: JSON.stringify({
latitude: position.lat,
longitude: position.lng
})
});
if (!response.ok) {
throw new Error('Failed to update position');
}
// Reload the panel form to reflect updated values
// This is a simple approach - Panel will refresh the form data
console.log('Position updated successfully');
} catch (error) {
console.error('Failed to update position:', error);
}
}
} else {
// Multi mode: update via API
try {
await markersApi.updateMarkerPosition(markerId, {
lat: position.lat,
lon: position.lng,
});
} catch (error) {
console.error('Failed to update marker position:', error);
}
}
}
/**
* Edit marker - redirect to Panel
*/
function editMarker(markerId) {
if (props.mode === 'single') return;
const marker = markers.value.find(m => m.id === markerId);
if (marker && marker.panelUrl) {
window.top.location.href = marker.panelUrl;
}
}
/**
* Delete marker (multi mode only)
*/
async function deleteMarker(markerId) {
if (props.mode === 'single') return;
if (!confirm('Supprimer ce marqueur ?')) {
return;
}
try {
await markersApi.deleteMarker(markerId);
if (selectedMarkerId.value === markerId) {
selectedMarkerId.value = null;
}
} catch (error) {
console.error('Failed to delete marker:', error);
}
}
/**
* Handle location selection from geocoding
* @param {Object} location - Location object {lat, lon, displayName}
*/
function handleLocationSelect(location) {
if (mapPreview.value && mapPreview.value.centerOnPosition) {
@ -208,10 +359,9 @@ export default {
selectedMarkerId,
mapReady,
mapPreview,
editingMarker,
canAddMarker,
hasMarkers,
selectedMarker,
loading: markersApi.loading,
error: markersApi.error,
// Methods
handleAddMarker,
@ -220,10 +370,7 @@ export default {
handleMarkerMoved,
handleLocationSelect,
deleteMarker,
selectMarker,
editMarker,
saveMarker,
closeEditor,
};
},
};
@ -247,6 +394,10 @@ export default {
background: var(--color-white);
}
.map-content.single-mode {
height: 400px;
}
.map-preview-container {
flex: 1;
position: relative;

View file

@ -1,364 +0,0 @@
<template>
<div class="marker-editor-overlay" @click.self="$emit('close')">
<div class="marker-editor-modal">
<div class="editor-header">
<h2>{{ isNew ? 'Nouveau marqueur' : 'Modifier le marqueur' }}</h2>
<button
type="button"
class="k-button k-button-icon"
@click="$emit('close')"
>
<k-icon type="cancel" />
</button>
</div>
<div class="marker-editor-content">
<!-- Title -->
<div class="field">
<label class="field-label">
Titre <span class="required">*</span>
</label>
<input
v-model="localMarker.title"
type="text"
class="k-input"
placeholder="Titre du marqueur"
maxlength="100"
/>
</div>
<!-- Description -->
<div class="field">
<label class="field-label">
Contenu
</label>
<textarea
v-model="localMarker.description"
class="k-textarea"
rows="4"
placeholder="Description du marqueur"
></textarea>
</div>
<!-- Position -->
<div class="field-group">
<div class="field">
<label class="field-label">Latitude</label>
<input
v-model.number="localMarker.position.lat"
type="number"
class="k-input"
step="0.000001"
min="-90"
max="90"
/>
</div>
<div class="field">
<label class="field-label">Longitude</label>
<input
v-model.number="localMarker.position.lon"
type="number"
class="k-input"
step="0.000001"
min="-180"
max="180"
/>
</div>
</div>
<!-- Icon -->
<div class="field">
<label class="field-label">Icône</label>
<div class="icon-selector">
<label class="radio-option">
<input
type="radio"
v-model="localMarker.icon.type"
value="default"
@change="clearCustomIcon"
/>
<span>Épingle par défaut</span>
</label>
<label class="radio-option">
<input
type="radio"
v-model="localMarker.icon.type"
value="custom"
/>
<span>Icône personnalisée</span>
</label>
</div>
<div
v-if="localMarker.icon.type === 'custom'"
class="custom-icon-upload"
>
<input
v-model="localMarker.icon.image"
type="text"
class="k-input"
placeholder="Nom ou UUID du fichier"
/>
<small class="field-help"
>Entrez le nom ou l'UUID d'une image depuis les fichiers de la
page</small
>
</div>
</div>
<div class="dialog-footer">
<k-button @click="$emit('close')" variant="dimmed">Annuler</k-button>
<k-button @click="saveMarker" :disabled="!isValid"
>Enregistrer</k-button
>
</div>
</div>
</div>
</template>
<script>
import { ref, computed, watch } from 'vue';
export default {
props: {
marker: {
type: Object,
required: true,
},
isNew: {
type: Boolean,
default: false,
},
},
setup(props, { emit }) {
// Ensure all fields exist for reactivity
const markerData = JSON.parse(JSON.stringify(props.marker));
if (!markerData.description) {
markerData.description = '';
}
if (!markerData.content) {
markerData.content = [];
}
if (!markerData.icon) {
markerData.icon = { type: 'default', image: null };
}
const localMarker = ref(markerData);
const isDirty = ref(false);
// Watch for any changes to mark as dirty
watch(localMarker, () => {
isDirty.value = true;
}, { deep: true });
// Validation - only require title
const isValid = computed(() => {
const hasTitleAndDirty = localMarker.value.title && localMarker.value.title.trim() !== '' && isDirty.value;
return hasTitleAndDirty;
});
// Watch for prop changes
watch(
() => props.marker,
(newMarker) => {
localMarker.value = JSON.parse(JSON.stringify(newMarker));
},
{ deep: true }
);
function saveMarker() {
if (!isValid.value) {
return;
}
emit('save', localMarker.value);
emit('close');
}
function clearCustomIcon() {
localMarker.value.icon.image = null;
}
function removeCustomIcon() {
localMarker.value.icon.image = null;
localMarker.value.icon.type = 'default';
}
function addTextBlock() {
localMarker.value.content.push({
type: 'text',
text: '',
});
}
function addImageBlock() {
localMarker.value.content.push({
type: 'image',
image: null,
caption: '',
});
}
function removeBlock(index) {
localMarker.value.content.splice(index, 1);
}
function moveBlockUp(index) {
if (index === 0) return;
const blocks = localMarker.value.content;
[blocks[index - 1], blocks[index]] = [blocks[index], blocks[index - 1]];
}
function moveBlockDown(index) {
if (index === localMarker.value.content.length - 1) return;
const blocks = localMarker.value.content;
[blocks[index], blocks[index + 1]] = [blocks[index + 1], blocks[index]];
}
return {
localMarker,
isValid,
saveMarker,
clearCustomIcon,
removeCustomIcon,
addTextBlock,
addImageBlock,
removeBlock,
moveBlockUp,
moveBlockDown,
};
},
};
</script>
<style>
.marker-editor-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
padding: 2rem;
}
.marker-editor-modal {
background: var(--color-white);
border-radius: var(--rounded);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
width: 100%;
max-width: 600px;
max-height: 90vh;
display: flex;
flex-direction: column;
}
.marker-editor-modal .editor-header,
.marker-editor-modal textarea,
.marker-editor-modal label {
color: #000;
}
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.editor-header h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
}
.marker-editor-content {
padding: 1.5rem;
overflow-y: auto;
flex: 1;
min-height: 0;
}
.field {
margin-bottom: 1.5rem;
}
.field-label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
}
.required {
color: var(--color-negative);
}
.k-input,
.k-textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--rounded-sm);
font-size: 0.875rem;
font-family: inherit;
}
.k-textarea {
resize: vertical;
min-height: 100px;
}
.field-group {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1.5rem;
}
.icon-selector {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.radio-option {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.radio-option input[type='radio'] {
cursor: pointer;
}
.custom-icon-upload {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.field-help {
display: block;
margin-top: 0.25rem;
font-size: 0.75rem;
color: var(--color-text-light);
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--color-border);
flex-shrink: 0;
background: var(--color-background);
}
</style>

View file

@ -66,10 +66,10 @@ export function useMapData(options = {}) {
/**
* Save map data to YAML format
* @param {Array} markers - Array of marker objects
* Note: Markers are now stored as subpages, not in YAML
* @returns {string} YAML string
*/
function saveMapData(markers = []) {
function saveMapData() {
const data = {
background: {
type: 'osm',
@ -79,7 +79,6 @@ export function useMapData(options = {}) {
lon: center.value.lon,
},
zoom: zoom.value,
markers,
};
const yamlString = yaml.dump(data, {
@ -94,15 +93,14 @@ export function useMapData(options = {}) {
/**
* Debounced save function
* @param {Array} markers - Array of marker objects
* @param {number} delay - Delay in milliseconds
*/
function debouncedSave(markers, delay = 300) {
function debouncedSave(delay = 300) {
if (saveTimeout.value) {
clearTimeout(saveTimeout.value);
}
saveTimeout.value = setTimeout(() => {
saveMapData(markers);
saveMapData();
}, delay);
}

View file

@ -1,168 +0,0 @@
/**
* Composable for managing map markers
* Handles CRUD operations and state management for markers
*/
import { ref, computed } from 'vue';
/**
* Creates a new marker object with default values
* @param {Object} position - The marker position {lat, lon}
* @returns {Object} New marker object
*/
export function createMarker(position) {
return {
id: `marker_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
position: {
lat: position.lat,
lon: position.lon,
},
icon: {
type: 'default',
},
title: '',
description: '',
content: [],
};
}
/**
* @param {Object} options
* @param {number} options.maxMarkers - Maximum number of markers allowed
* @returns {Object} Markers composable
*/
export function useMarkers(options = {}) {
const { maxMarkers = 50 } = options;
const markers = ref([]);
const selectedMarkerId = ref(null);
const editingMarker = ref(null);
// Computed properties
const canAddMarker = computed(() => markers.value.length < maxMarkers);
const hasMarkers = computed(() => markers.value.length > 0);
const selectedMarker = computed(() =>
markers.value.find(m => m.id === selectedMarkerId.value)
);
/**
* Add a new marker
* @param {Object} position - Position {lat, lon}
*/
function addMarker(position) {
if (!canAddMarker.value) {
return null;
}
const newMarker = createMarker(position);
markers.value = [...markers.value, newMarker];
selectedMarkerId.value = newMarker.id;
return newMarker;
}
/**
* Update an existing marker
* @param {string} markerId - Marker ID
* @param {Object} updates - Partial marker object with updates
*/
function updateMarker(markerId, updates) {
const markerIndex = markers.value.findIndex(m => m.id === markerId);
if (markerIndex !== -1) {
const updatedMarkers = [...markers.value];
updatedMarkers[markerIndex] = {
...updatedMarkers[markerIndex],
...updates,
};
markers.value = updatedMarkers;
}
}
/**
* Delete a marker
* @param {string} markerId - Marker ID
* @param {boolean} skipConfirm - Skip confirmation dialog
* @returns {boolean} True if deleted, false if cancelled
*/
function deleteMarker(markerId, skipConfirm = false) {
if (!skipConfirm && !confirm('Êtes-vous sûr de vouloir supprimer ce marqueur ?')) {
return false;
}
markers.value = markers.value.filter(m => m.id !== markerId);
if (selectedMarkerId.value === markerId) {
selectedMarkerId.value = null;
}
return true;
}
/**
* Select a marker
* @param {string} markerId - Marker ID
*/
function selectMarker(markerId) {
selectedMarkerId.value = markerId;
}
/**
* Open marker editor
* @param {string} markerId - Marker ID
*/
function editMarker(markerId) {
const marker = markers.value.find(m => m.id === markerId);
if (marker) {
editingMarker.value = JSON.parse(JSON.stringify(marker));
}
}
/**
* Save edited marker
* @param {Object} updatedMarker - Updated marker object
*/
function saveMarker(updatedMarker) {
const markerIndex = markers.value.findIndex(m => m.id === updatedMarker.id);
if (markerIndex !== -1) {
const updatedMarkers = [...markers.value];
updatedMarkers[markerIndex] = updatedMarker;
markers.value = updatedMarkers;
}
}
/**
* Close marker editor
*/
function closeEditor() {
editingMarker.value = null;
}
/**
* Set markers array
* @param {Array} newMarkers - Array of marker objects
*/
function setMarkers(newMarkers) {
markers.value = newMarkers;
}
return {
// State
markers,
selectedMarkerId,
editingMarker,
// Computed
canAddMarker,
hasMarkers,
selectedMarker,
// Methods
addMarker,
updateMarker,
deleteMarker,
selectMarker,
editMarker,
saveMarker,
closeEditor,
setMarkers,
};
}

View file

@ -0,0 +1,194 @@
import { ref } from 'vue';
/**
* Composable for managing markers via Kirby API
* Replaces the old YAML-based useMarkers composable
*/
export function useMarkersApi(pageId) {
const markers = ref([]);
const loading = ref(false);
const error = ref(null);
// Get CSRF token from Kirby Panel
const getCsrfToken = () => {
// Try multiple methods to get the CSRF token
// Method 1: From window.panel (Kirby Panel global)
if (window.panel && window.panel.csrf) {
return window.panel.csrf;
}
// Method 2: From meta tag (for non-Panel contexts)
const meta = document.querySelector('meta[name="csrf"]');
if (meta && meta.content) {
return meta.content;
}
// Method 3: From window.csrf (sometimes used in Panel)
if (window.csrf) {
return window.csrf;
}
console.warn('CSRF token not found');
return '';
};
/**
* Fetch all markers for a page
*/
async function fetchMarkers() {
loading.value = true;
error.value = null;
try {
const response = await fetch(`/api/map-editor/pages/${pageId}/markers`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
const result = await response.json();
if (result.status === 'error') {
throw new Error(result.message || 'Failed to fetch markers');
}
markers.value = result.data.markers || [];
return markers.value;
} catch (err) {
error.value = err.message;
console.error('Error fetching markers:', err);
throw err;
} finally {
loading.value = false;
}
}
/**
* Create a new marker at the given position
*/
async function createMarker(position) {
loading.value = true;
error.value = null;
try {
const response = await fetch(`/api/map-editor/pages/${pageId}/markers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF': getCsrfToken()
},
body: JSON.stringify({ position })
});
const result = await response.json();
if (result.status === 'error') {
throw new Error(result.message || 'Failed to create marker');
}
const newMarker = result.data.marker;
markers.value.push(newMarker);
return newMarker;
} catch (err) {
error.value = err.message;
console.error('Error creating marker:', err);
throw err;
} finally {
loading.value = false;
}
}
/**
* Update a marker's position
*/
async function updateMarkerPosition(markerId, position) {
loading.value = true;
error.value = null;
try {
const response = await fetch(`/api/map-editor/pages/${pageId}/markers/${markerId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRF': getCsrfToken()
},
body: JSON.stringify({ position })
});
const result = await response.json();
if (result.status === 'error') {
throw new Error(result.message || 'Failed to update marker position');
}
// Update local marker
const index = markers.value.findIndex(m => m.id === markerId);
if (index !== -1) {
markers.value[index] = result.data.marker;
}
return result.data.marker;
} catch (err) {
error.value = err.message;
console.error('Error updating marker position:', err);
throw err;
} finally {
loading.value = false;
}
}
/**
* Delete a marker
*/
async function deleteMarker(markerId) {
loading.value = true;
error.value = null;
try {
const response = await fetch(`/api/map-editor/pages/${pageId}/markers/${markerId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRF': getCsrfToken()
}
});
const result = await response.json();
if (result.status === 'error') {
throw new Error(result.message || 'Failed to delete marker');
}
// Remove from local markers array
const index = markers.value.findIndex(m => m.id === markerId);
if (index !== -1) {
markers.value.splice(index, 1);
}
return true;
} catch (err) {
error.value = err.message;
console.error('Error deleting marker:', err);
throw err;
} finally {
loading.value = false;
}
}
return {
markers,
loading,
error,
fetchMarkers,
createMarker,
updateMarkerPosition,
deleteMarker
};
}