Compare commits

..

No commits in common. "590c842072a2058fc254073079bb41b461f6b0b8" and "1d74105910d44b3a0afce55c17ef3f3a000fd440" have entirely different histories.

10 changed files with 229 additions and 574 deletions

View file

@ -19,14 +19,6 @@ columns:
defaultCenter: [43.836699, 4.360054]
defaultZoom: 13
maxMarkers: 50
mapStaticImage:
label: Image statique générée
type: files
multiple: false
query: page.files.filterBy("name", "map-static")
layout: cards
disabled: true
help: Cette image est automatiquement générée à la sauvegarde de la page ou d'un marqueur
sidebar:
width: 1/3
sections:

View file

@ -1,20 +1,23 @@
<?php
/**
* Markers CRUD Routes
* GET, POST, PATCH, DELETE operations for markers
* API Routes for Map Editor Plugin
*
* Provides CRUD operations for marker subpages
*/
return [
// GET all markers for a map page
[
'pattern' => 'map-editor/pages/(:all)/markers',
'method' => 'GET',
'auth' => false,
'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',
@ -23,6 +26,7 @@ return [
];
}
// Get the map page
$mapPage = kirby()->page($pageId);
if (!$mapPage) {
return [
@ -32,6 +36,7 @@ return [
];
}
// Check if user can read the page
if (!$mapPage->isReadable()) {
return [
'status' => 'error',
@ -40,16 +45,21 @@ return [
];
}
// 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) {
// Get custom icon if available
$iconFile = $marker->markerIcon()->toFile();
$iconUrl = $iconFile ? $iconFile->url() : null;
// Get icon size if set
$iconSize = $marker->markerIconSize()->isNotEmpty()
? (int) $marker->markerIconSize()->value()
: 40;
@ -86,15 +96,16 @@ return [
}
],
// POST create new marker
[
'pattern' => 'map-editor/pages/(:all)/markers',
'method' => 'POST',
'auth' => false,
'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',
@ -103,6 +114,10 @@ return [
];
}
// 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 [
@ -112,6 +127,7 @@ return [
];
}
// Check if user can create children
if (!$mapPage->permissions()->can('create')) {
return [
'status' => 'error',
@ -120,6 +136,8 @@ return [
];
}
// 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'])) {
@ -133,6 +151,7 @@ return [
$lat = (float) $data['position']['lat'];
$lon = (float) $data['position']['lon'];
// Validate coordinates
if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) {
return [
'status' => 'error',
@ -141,14 +160,17 @@ return [
];
}
// Get existing markers to determine next num
$existingMarkers = $mapPage
->children()
->filterBy('intendedTemplate', 'marker');
$nextNum = $existingMarkers->count() + 1;
// Generate title and slug based on title
$title = 'Marqueur ' . $nextNum;
$slug = Str::slug($title);
// Create the new marker page
$newMarker = $mapPage->createChild([
'slug' => $slug,
'template' => 'marker',
@ -159,8 +181,10 @@ return [
]
]);
// Publish the page as listed with the correct num
$newMarker->changeStatus('listed', $nextNum);
// Get custom icon if available (new markers won't have one initially)
$iconFile = $newMarker->markerIcon()->toFile();
$iconUrl = $iconFile ? $iconFile->url() : null;
$iconSize = $newMarker->markerIconSize()->isNotEmpty()
@ -196,13 +220,13 @@ return [
}
],
// PATCH update marker position
[
'pattern' => 'map-editor/pages/(:all)/markers/(:all)',
'method' => 'PATCH',
'auth' => false,
'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)) {
@ -213,6 +237,10 @@ return [
];
}
// 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 [
@ -222,6 +250,7 @@ return [
];
}
// Check if user can update the page
if (!$marker->permissions()->can('update')) {
return [
'status' => 'error',
@ -230,6 +259,7 @@ return [
];
}
// Get position from request body
$data = kirby()->request()->data();
if (!isset($data['position']['lat']) || !isset($data['position']['lon'])) {
@ -243,6 +273,7 @@ return [
$lat = (float) $data['position']['lat'];
$lon = (float) $data['position']['lon'];
// Validate coordinates
if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) {
return [
'status' => 'error',
@ -251,11 +282,13 @@ return [
];
}
// Update the marker position
$marker->update([
'latitude' => $lat,
'longitude' => $lon
]);
// Get custom icon if available
$iconFile = $marker->markerIcon()->toFile();
$iconUrl = $iconFile ? $iconFile->url() : null;
$iconSize = $marker->markerIconSize()->isNotEmpty()
@ -291,13 +324,13 @@ return [
}
],
// DELETE marker
[
'pattern' => 'map-editor/pages/(:all)/markers/(:all)',
'method' => 'DELETE',
'auth' => false,
'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)) {
@ -308,6 +341,10 @@ return [
];
}
// 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 [
@ -317,6 +354,7 @@ return [
];
}
// Check if user can delete the page
if (!$marker->permissions()->can('delete')) {
return [
'status' => 'error',
@ -325,7 +363,8 @@ return [
];
}
$marker->delete(true);
// Delete the marker page
$marker->delete(true); // true = force delete
return [
'status' => 'success',
@ -334,6 +373,92 @@ return [
]
];
} 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',

File diff suppressed because one or more lines are too long

View file

@ -50,28 +50,6 @@ Kirby::plugin('geoproject/map-editor', [
]
],
'api' => [
'routes' => [
require __DIR__ . '/routes/markers.php',
require __DIR__ . '/routes/position.php',
require __DIR__ . '/routes/image.php',
]
],
'hooks' => [
'page.update:after' => function ($newPage, $oldPage) {
// Mark map page for image regeneration
if ($newPage->intendedTemplate()->name() === 'map') {
$markerFile = $newPage->root() . '/.regenerate-map-image';
file_put_contents($markerFile, time());
}
// If a marker is updated, mark the parent map page for regeneration
if ($newPage->intendedTemplate()->name() === 'marker') {
$mapPage = $newPage->parent();
if ($mapPage && $mapPage->intendedTemplate()->name() === 'map') {
$markerFile = $mapPage->root() . '/.regenerate-map-image';
file_put_contents($markerFile, time());
}
}
}
'routes' => require __DIR__ . '/api/routes.php'
]
]);

View file

@ -8,10 +8,8 @@
"name": "map-editor",
"version": "1.0.0",
"dependencies": {
"html-to-image": "^1.11.13",
"js-yaml": "^4.1.0",
"maplibre-gl": "^3.6.0",
"maplibre-gl-map-to-image": "^1.2.0"
"maplibre-gl": "^3.6.0"
},
"devDependencies": {
"kirbyup": "^3.3.0"
@ -2412,12 +2410,6 @@
"node": ">=4"
}
},
"node_modules/html-to-image": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
"license": "MIT"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@ -3202,12 +3194,6 @@
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
}
},
"node_modules/maplibre-gl-map-to-image": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/maplibre-gl-map-to-image/-/maplibre-gl-map-to-image-1.2.0.tgz",
"integrity": "sha512-U4IKKalUd/rudZMHDkpNWqHlOtdLOANDD/s7t8dBPiCAL14zmLAEJ/PE+yFRyl4ZsVymIPAhEPHg/n+7Rq47mA==",
"license": "MIT"
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",

View file

@ -8,10 +8,8 @@
"build": "npx -y kirbyup src/index.js"
},
"dependencies": {
"html-to-image": "^1.11.13",
"js-yaml": "^4.1.0",
"maplibre-gl": "^3.6.0",
"maplibre-gl-map-to-image": "^1.2.0"
"js-yaml": "^4.1.0"
},
"devDependencies": {
"kirbyup": "^3.3.0"

View file

@ -1,185 +0,0 @@
<?php
/**
* Image Capture Routes
* POST for capturing map image, GET/DELETE for regeneration flag
*/
return [
// POST capture and save map image
[
'pattern' => 'map-editor/pages/(:all)/capture-image',
'method' => 'POST',
'auth' => false,
'action' => function (string $pageId) {
try {
$user = kirby()->user();
if (!$user && !kirby()->option('debug', false)) {
return [
'status' => 'error',
'message' => 'Unauthorized',
'code' => 401
];
}
$mapPage = kirby()->page($pageId);
if (!$mapPage) {
return [
'status' => 'error',
'message' => 'Map page not found',
'code' => 404
];
}
if (!$mapPage->permissions()->can('update')) {
return [
'status' => 'error',
'message' => 'Forbidden',
'code' => 403
];
}
$data = kirby()->request()->data();
if (!isset($data['image'])) {
return [
'status' => 'error',
'message' => 'Image data is required',
'code' => 400
];
}
$imageData = $data['image'];
if (preg_match('/^data:image\/(png|jpeg|jpg);base64,(.+)$/', $imageData, $matches)) {
$imageData = $matches[2];
$extension = $matches[1] === 'jpeg' ? 'jpg' : $matches[1];
} else {
return [
'status' => 'error',
'message' => 'Invalid image format',
'code' => 400
];
}
$decodedImage = base64_decode($imageData);
if ($decodedImage === false) {
return [
'status' => 'error',
'message' => 'Failed to decode image',
'code' => 400
];
}
$filename = 'map-static.' . $extension;
$tempPath = sys_get_temp_dir() . '/' . uniqid() . '.' . $extension;
file_put_contents($tempPath, $decodedImage);
$existingFile = $mapPage->files()->filterBy('name', 'map-static')->first();
if ($existingFile) {
$existingFile->delete();
}
try {
$file = $mapPage->createFile([
'source' => $tempPath,
'filename' => $filename
]);
@unlink($tempPath);
} catch (Exception $e) {
@unlink($tempPath);
throw $e;
}
return [
'status' => 'success',
'data' => [
'message' => 'Image saved successfully',
'filename' => $filename,
'path' => $file->root()
]
];
} catch (Exception $e) {
return [
'status' => 'error',
'message' => $e->getMessage(),
'code' => 500
];
}
}
],
// GET check if regeneration flag exists
[
'pattern' => 'map-editor/pages/(:all)/check-regenerate-flag',
'method' => 'GET',
'auth' => false,
'action' => function (string $pageId) {
try {
$page = kirby()->page($pageId);
if (!$page) {
return [
'status' => 'error',
'message' => 'Page not found',
'code' => 404
];
}
$markerFile = $page->root() . '/.regenerate-map-image';
$needsRegeneration = file_exists($markerFile);
return [
'status' => 'success',
'data' => [
'needsRegeneration' => $needsRegeneration
]
];
} catch (Exception $e) {
return [
'status' => 'error',
'message' => $e->getMessage(),
'code' => 500
];
}
}
],
// DELETE clear regeneration flag
[
'pattern' => 'map-editor/pages/(:all)/clear-regenerate-flag',
'method' => 'DELETE',
'auth' => false,
'action' => function (string $pageId) {
try {
$page = kirby()->page($pageId);
if (!$page) {
return [
'status' => 'error',
'message' => 'Page not found',
'code' => 404
];
}
$markerFile = $page->root() . '/.regenerate-map-image';
if (file_exists($markerFile)) {
unlink($markerFile);
}
return [
'status' => 'success',
'data' => [
'message' => 'Flag cleared'
]
];
} catch (Exception $e) {
return [
'status' => 'error',
'message' => $e->getMessage(),
'code' => 500
];
}
}
]
];

View file

@ -1,83 +0,0 @@
<?php
/**
* Position Update Route
* PATCH route for updating page position (single mode)
*/
return [
'pattern' => 'map-editor/pages/(:all)/position',
'method' => 'PATCH',
'auth' => false,
'action' => function (string $pageId) {
try {
$user = kirby()->user();
if (!$user && !kirby()->option('debug', false)) {
return [
'status' => 'error',
'message' => 'Unauthorized',
'code' => 401
];
}
$page = kirby()->page($pageId);
if (!$page) {
return [
'status' => 'error',
'message' => 'Page not found',
'code' => 404
];
}
if (!$page->permissions()->can('update')) {
return [
'status' => 'error',
'message' => 'Forbidden',
'code' => 403
];
}
$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'];
if ($lat < -90 || $lat > 90 || $lon < -180 || $lon > 180) {
return [
'status' => 'error',
'message' => 'Invalid coordinates',
'code' => 400
];
}
$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
];
}
}
];

View file

@ -308,11 +308,6 @@ export default {
// Use nextTick to ensure all reactive updates from loadMapData are done
await nextTick();
isInitialLoad.value = false;
// Check if we need to regenerate the map image (multi mode only)
if (props.mode === 'multi') {
await checkAndRegenerateImage();
}
});
// Watch center and zoom for automatic save (multi mode only)
@ -534,111 +529,6 @@ export default {
saveMapData();
}
/**
* Check if map image needs regeneration and do it if needed
*/
async function checkAndRegenerateImage() {
if (!pageId.value) return;
try {
// Check if regeneration flag exists
const checkResponse = await fetch(
`/api/map-editor/pages/${pageId.value}/check-regenerate-flag`
);
const checkResult = await checkResponse.json();
if (checkResult.status === 'success' && checkResult.data.needsRegeneration) {
console.log('Regeneration flag detected, waiting for map to be ready...');
// Wait for the map to be fully loaded with markers
await waitForMapReady();
console.log('Map ready, capturing image...');
// Capture and save the image
await captureAndSaveMapImage();
// Clear the flag
await fetch(
`/api/map-editor/pages/${pageId.value}/clear-regenerate-flag`,
{ method: 'DELETE' }
);
console.log('Map image regenerated successfully');
}
} catch (error) {
console.error('Error checking/regenerating map image:', error);
}
}
/**
* Wait for the map to be fully loaded and ready for capture
*/
async function waitForMapReady(maxAttempts = 10) {
for (let i = 0; i < maxAttempts; i++) {
// Check if map is loaded
if (mapPreview.value?.map?.loaded && mapPreview.value.map.loaded()) {
// Wait an additional 500ms for markers to render
await new Promise(resolve => setTimeout(resolve, 500));
return true;
}
// Wait 500ms before next attempt
await new Promise(resolve => setTimeout(resolve, 500));
}
throw new Error('Map failed to load within timeout');
}
/**
* Capture map as image and send to server
*/
async function captureAndSaveMapImage() {
if (!mapPreview.value || !mapPreview.value.captureMapImage) {
console.warn('Map preview not ready for capture');
return;
}
try {
console.log('Starting image capture...');
// Capture the map as base64 image with timeout
const imageDataUrl = await Promise.race([
mapPreview.value.captureMapImage(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Capture timeout after 10s')), 10000)
)
]);
console.log('Image captured, size:', imageDataUrl.length, 'bytes');
// Send to API
console.log('Sending to API:', `/api/map-editor/pages/${pageId.value}/capture-image`);
const response = await fetch(
`/api/map-editor/pages/${pageId.value}/capture-image`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ image: imageDataUrl }),
}
);
console.log('Response status:', response.status);
const result = await response.json();
console.log('Response data:', result);
if (result.status === 'error') {
throw new Error(result.message || 'Failed to save map image');
}
console.log('Map image saved successfully:', result.data.filename);
} catch (error) {
console.error('Error capturing and saving map image:', error);
throw error; // Re-throw to see the error in checkAndRegenerateImage
}
}
return {
// State
center,

View file

@ -12,7 +12,6 @@
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from "vue";
import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import { toPng } from "html-to-image";
export default {
props: {
@ -98,7 +97,6 @@ export default {
try {
map.value = new maplibregl.Map({
container: mapContainer.value,
preserveDrawingBuffer: true, // Required for canvas.toDataURL()
style: {
version: 8,
sources: {
@ -328,55 +326,12 @@ export default {
}
}
async function captureMapImage() {
console.log('[MapPreview] captureMapImage called');
if (!map.value) {
throw new Error("Map is not initialized");
}
if (!map.value.loaded()) {
throw new Error("Map is not loaded");
}
if (!mapContainer.value) {
throw new Error("Map container not found");
}
console.log('[MapPreview] Map is loaded, capturing container...');
try {
// Capture the entire map container (includes canvas + markers)
const imageDataUrl = await toPng(mapContainer.value, {
quality: 0.95,
pixelRatio: 2, // Higher quality
cacheBust: true,
filter: (node) => {
// Exclude MapLibre controls (zoom buttons, etc.)
if (node.classList && node.classList.contains('maplibregl-ctrl')) {
return false;
}
return true;
}
});
console.log('[MapPreview] Container captured, image size:', imageDataUrl?.length);
return imageDataUrl;
} catch (error) {
console.error("[MapPreview] Error capturing map image:", error);
throw error;
}
}
return {
mapContainer,
loading,
map,
getCurrentCenter,
getCurrentZoom,
centerOnPosition,
captureMapImage
centerOnPosition
};
}
};