Add marker image uploads

This commit is contained in:
André Rüegger 2026-06-01 03:18:36 +02:00
parent 8d92d64fa3
commit e4759b8961
3 changed files with 169 additions and 2 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
Lession_material
*.DS_Store
.aider*
backend/uploads/

View File

@ -1,9 +1,11 @@
import os
import time
import json
import uuid
from datetime import datetime
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
app = Flask(__name__)
@ -14,7 +16,10 @@ CORS(app, supports_credentials=True) # allow cookies
DATA_DIR = "data"
USERS_FILE = os.path.join(DATA_DIR, "users.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(UPLOAD_DIR, exist_ok=True)
@ -59,6 +64,13 @@ def get_user_by_username(username):
def get_user_by_id(user_id):
users = load_users()
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 ====================
@app.route("/api/register", methods=["POST"])
@ -354,6 +366,45 @@ def delete_comment(comment_id):
return jsonify({'error': 'Not authorized'}), 403
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 ====================
@app.route("/api/journeys/health", methods=["GET"])
def health_check():

View File

@ -183,6 +183,12 @@
min-height: 80px;
resize: vertical;
}
input[type="file"] {
width: 100%;
padding: var(--size-2) 0;
font-size: var(--font-size-1);
color: var(--text-2);
}
/* Instructions */
.instructions {
@ -420,6 +426,41 @@
gap: var(--size-2);
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 {
@ -824,6 +865,19 @@
placeholder="https://youtu.be/..."
/>
</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">
<label for="marker-coords">Coordinates</label>
<input
@ -968,6 +1022,52 @@
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 =====================
function openMarkerEditor(marker) {
activeMarker = marker;
@ -982,6 +1082,8 @@
content.description || "";
document.getElementById("video-url").value =
content.videoUrl || "";
document.getElementById("marker-images").value = "";
renderMarkerImages(content.images || []);
document.getElementById("marker-coords").value =
`${latlng.lat.toFixed(6)}, ${latlng.lng.toFixed(6)}`;
document
@ -1002,13 +1104,24 @@
const date = document.getElementById("marker-date").value;
const description = document.getElementById("marker-text").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
activeMarker.options.title = title;
activeMarker.setPopupContent(`<strong>${title}</strong>`);
// Store content for saving
activeMarker._content = { title, date, description, videoUrl };
activeMarker._content = { title, date, description, videoUrl, images };
// Update the list item in the sidebar
const latlng = activeMarker.getLatLng();
@ -1135,6 +1248,7 @@
date: content.date || "",
description: content.description || "",
videoUrl: content.videoUrl || "",
images: content.images || [],
};
});
@ -1217,6 +1331,7 @@
date: m.date,
description: m.description,
videoUrl: m.videoUrl,
images: m.images || [],
});
marker._content = { ...m };
});