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
|
||||
*.DS_Store
|
||||
.aider*
|
||||
backend/uploads/
|
||||
|
||||
@ -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():
|
||||
|
||||
117
map-page.html
117
map-page.html
@ -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 };
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user