Add marker image uploads
This commit is contained in:
parent
8d92d64fa3
commit
e4759b8961
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,3 +1,4 @@
|
|||||||
Lession_material
|
Lession_material
|
||||||
*.DS_Store
|
*.DS_Store
|
||||||
.aider*
|
.aider*
|
||||||
|
backend/uploads/
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
from flask import Flask, request, jsonify, session
|
from werkzeug.utils import secure_filename
|
||||||
|
from flask import Flask, request, jsonify, session, send_from_directory
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
@ -14,7 +16,10 @@ CORS(app, supports_credentials=True) # allow cookies
|
|||||||
DATA_DIR = "data"
|
DATA_DIR = "data"
|
||||||
USERS_FILE = os.path.join(DATA_DIR, "users.json")
|
USERS_FILE = os.path.join(DATA_DIR, "users.json")
|
||||||
JOURNEYS_FILE = os.path.join(DATA_DIR, 'journeys.json')
|
JOURNEYS_FILE = os.path.join(DATA_DIR, 'journeys.json')
|
||||||
|
UPLOAD_DIR = "uploads"
|
||||||
|
ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}
|
||||||
os.makedirs(DATA_DIR, exist_ok=True)
|
os.makedirs(DATA_DIR, exist_ok=True)
|
||||||
|
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -59,6 +64,13 @@ def get_user_by_username(username):
|
|||||||
def get_user_by_id(user_id):
|
def get_user_by_id(user_id):
|
||||||
users = load_users()
|
users = load_users()
|
||||||
return next((u for u in users if u["id"] == user_id), None)
|
return next((u for u in users if u["id"] == user_id), None)
|
||||||
|
|
||||||
|
|
||||||
|
def allowed_image_file(filename):
|
||||||
|
if "." not in filename:
|
||||||
|
return False
|
||||||
|
extension = filename.rsplit(".", 1)[1].lower()
|
||||||
|
return extension in ALLOWED_IMAGE_EXTENSIONS
|
||||||
|
|
||||||
# ==================== Authentication endpoints ====================
|
# ==================== Authentication endpoints ====================
|
||||||
@app.route("/api/register", methods=["POST"])
|
@app.route("/api/register", methods=["POST"])
|
||||||
@ -354,6 +366,45 @@ def delete_comment(comment_id):
|
|||||||
return jsonify({'error': 'Not authorized'}), 403
|
return jsonify({'error': 'Not authorized'}), 403
|
||||||
return jsonify({'error': 'Comment not found'}), 404
|
return jsonify({'error': 'Comment not found'}), 404
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Uploads ====================
|
||||||
|
@app.route("/api/uploads/images", methods=["POST"])
|
||||||
|
def upload_images():
|
||||||
|
if not require_login():
|
||||||
|
return jsonify({"error": "Authentication required"}), 401
|
||||||
|
|
||||||
|
files = request.files.getlist("images")
|
||||||
|
if not files:
|
||||||
|
return jsonify({"error": "No images provided"}), 400
|
||||||
|
|
||||||
|
uploaded = []
|
||||||
|
for file in files:
|
||||||
|
if not file or file.filename == "":
|
||||||
|
continue
|
||||||
|
if not allowed_image_file(file.filename):
|
||||||
|
return jsonify({"error": f"Unsupported image type: {file.filename}"}), 400
|
||||||
|
|
||||||
|
original_name = secure_filename(file.filename)
|
||||||
|
extension = original_name.rsplit(".", 1)[1].lower()
|
||||||
|
filename = f"{uuid.uuid4().hex}.{extension}"
|
||||||
|
file.save(os.path.join(UPLOAD_DIR, filename))
|
||||||
|
uploaded.append(
|
||||||
|
{
|
||||||
|
"filename": filename,
|
||||||
|
"originalName": original_name,
|
||||||
|
"url": f"/uploads/{filename}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not uploaded:
|
||||||
|
return jsonify({"error": "No valid images provided"}), 400
|
||||||
|
return jsonify({"images": uploaded}), 201
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/uploads/<path:filename>")
|
||||||
|
def uploaded_file(filename):
|
||||||
|
return send_from_directory(UPLOAD_DIR, filename)
|
||||||
|
|
||||||
# ==================== Health and root ====================
|
# ==================== Health and root ====================
|
||||||
@app.route("/api/journeys/health", methods=["GET"])
|
@app.route("/api/journeys/health", methods=["GET"])
|
||||||
def health_check():
|
def health_check():
|
||||||
|
|||||||
117
map-page.html
117
map-page.html
@ -183,6 +183,12 @@
|
|||||||
min-height: 80px;
|
min-height: 80px;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
input[type="file"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--size-2) 0;
|
||||||
|
font-size: var(--font-size-1);
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
/* Instructions */
|
/* Instructions */
|
||||||
.instructions {
|
.instructions {
|
||||||
@ -420,6 +426,41 @@
|
|||||||
gap: var(--size-2);
|
gap: var(--size-2);
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
.image-preview-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
|
||||||
|
gap: var(--size-2);
|
||||||
|
margin-top: var(--size-2);
|
||||||
|
}
|
||||||
|
.image-preview {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: var(--radius-2);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--surface-4);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
.image-preview img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.remove-image-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-round);
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
/* Toast */
|
/* Toast */
|
||||||
.toast {
|
.toast {
|
||||||
@ -824,6 +865,19 @@
|
|||||||
placeholder="https://youtu.be/..."
|
placeholder="https://youtu.be/..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="marker-images">Images</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="marker-images"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
id="marker-image-preview"
|
||||||
|
class="image-preview-grid"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="marker-coords">Coordinates</label>
|
<label for="marker-coords">Coordinates</label>
|
||||||
<input
|
<input
|
||||||
@ -968,6 +1022,52 @@
|
|||||||
journeyPath.setLatLngs(coords);
|
journeyPath.setLatLngs(coords);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getUploadUrl(path) {
|
||||||
|
if (!path) return "";
|
||||||
|
if (path.startsWith("http")) return path;
|
||||||
|
return API_BASE.replace("/api", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkerImages(images = []) {
|
||||||
|
const preview = document.getElementById("marker-image-preview");
|
||||||
|
preview.innerHTML = "";
|
||||||
|
|
||||||
|
images.forEach((image, index) => {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.className = "image-preview";
|
||||||
|
item.innerHTML = `
|
||||||
|
<img src="${getUploadUrl(image.url)}" alt="${image.originalName || "Marker image"}">
|
||||||
|
<button type="button" class="remove-image-btn" data-index="${index}" aria-label="Remove image">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
item.querySelector(".remove-image-btn").addEventListener("click", () => {
|
||||||
|
if (!activeMarker) return;
|
||||||
|
const content = activeMarker._content || {};
|
||||||
|
content.images = (content.images || []).filter((_, i) => i !== index);
|
||||||
|
activeMarker._content = content;
|
||||||
|
renderMarkerImages(content.images);
|
||||||
|
});
|
||||||
|
preview.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadMarkerImages(files) {
|
||||||
|
if (!files.length) return [];
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
files.forEach((file) => formData.append("images", file));
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/uploads/images`, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || "Image upload failed");
|
||||||
|
return data.images || [];
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== MODAL =====================
|
// ==================== MODAL =====================
|
||||||
function openMarkerEditor(marker) {
|
function openMarkerEditor(marker) {
|
||||||
activeMarker = marker;
|
activeMarker = marker;
|
||||||
@ -982,6 +1082,8 @@
|
|||||||
content.description || "";
|
content.description || "";
|
||||||
document.getElementById("video-url").value =
|
document.getElementById("video-url").value =
|
||||||
content.videoUrl || "";
|
content.videoUrl || "";
|
||||||
|
document.getElementById("marker-images").value = "";
|
||||||
|
renderMarkerImages(content.images || []);
|
||||||
document.getElementById("marker-coords").value =
|
document.getElementById("marker-coords").value =
|
||||||
`${latlng.lat.toFixed(6)}, ${latlng.lng.toFixed(6)}`;
|
`${latlng.lat.toFixed(6)}, ${latlng.lng.toFixed(6)}`;
|
||||||
document
|
document
|
||||||
@ -1002,13 +1104,24 @@
|
|||||||
const date = document.getElementById("marker-date").value;
|
const date = document.getElementById("marker-date").value;
|
||||||
const description = document.getElementById("marker-text").value;
|
const description = document.getElementById("marker-text").value;
|
||||||
const videoUrl = document.getElementById("video-url").value;
|
const videoUrl = document.getElementById("video-url").value;
|
||||||
|
const imageInput = document.getElementById("marker-images");
|
||||||
|
const existingImages = activeMarker._content?.images || [];
|
||||||
|
let images = existingImages;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const uploadedImages = await uploadMarkerImages(Array.from(imageInput.files));
|
||||||
|
images = [...existingImages, ...uploadedImages];
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Update marker's tooltip title and popup
|
// Update marker's tooltip title and popup
|
||||||
activeMarker.options.title = title;
|
activeMarker.options.title = title;
|
||||||
activeMarker.setPopupContent(`<strong>${title}</strong>`);
|
activeMarker.setPopupContent(`<strong>${title}</strong>`);
|
||||||
|
|
||||||
// Store content for saving
|
// Store content for saving
|
||||||
activeMarker._content = { title, date, description, videoUrl };
|
activeMarker._content = { title, date, description, videoUrl, images };
|
||||||
|
|
||||||
// Update the list item in the sidebar
|
// Update the list item in the sidebar
|
||||||
const latlng = activeMarker.getLatLng();
|
const latlng = activeMarker.getLatLng();
|
||||||
@ -1135,6 +1248,7 @@
|
|||||||
date: content.date || "",
|
date: content.date || "",
|
||||||
description: content.description || "",
|
description: content.description || "",
|
||||||
videoUrl: content.videoUrl || "",
|
videoUrl: content.videoUrl || "",
|
||||||
|
images: content.images || [],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1217,6 +1331,7 @@
|
|||||||
date: m.date,
|
date: m.date,
|
||||||
description: m.description,
|
description: m.description,
|
||||||
videoUrl: m.videoUrl,
|
videoUrl: m.videoUrl,
|
||||||
|
images: m.images || [],
|
||||||
});
|
});
|
||||||
marker._content = { ...m };
|
marker._content = { ...m };
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user