Show and edit marker images
This commit is contained in:
parent
e4759b8961
commit
806aedb984
@ -13,10 +13,11 @@ app.secret_key = "your-secret-key-here-change-in-production" # needed for sessi
|
||||
CORS(app, supports_credentials=True) # allow cookies
|
||||
|
||||
# Directories
|
||||
DATA_DIR = "data"
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DATA_DIR = os.path.join(BASE_DIR, "data")
|
||||
USERS_FILE = os.path.join(DATA_DIR, "users.json")
|
||||
JOURNEYS_FILE = os.path.join(DATA_DIR, 'journeys.json')
|
||||
UPLOAD_DIR = "uploads"
|
||||
UPLOAD_DIR = os.path.join(BASE_DIR, "uploads")
|
||||
ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
@ -227,6 +227,20 @@
|
||||
border-radius: var(--radius-2);
|
||||
margin: var(--size-2) 0;
|
||||
}
|
||||
.chapter-images {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: var(--size-3);
|
||||
margin: var(--size-3) 0;
|
||||
}
|
||||
.chapter-images img {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-2);
|
||||
box-shadow: var(--shadow-2);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.chapter-content {
|
||||
line-height: 1.6;
|
||||
}
|
||||
@ -290,7 +304,7 @@
|
||||
const title = marker.title || `Chapter ${idx + 1}`;
|
||||
const date = marker.date ? `<span class="marker-date">${new Date(marker.date).toLocaleDateString()}</span>` : '';
|
||||
const content = marker.description ? escapeHtml(marker.description).replace(/\n/g, '<br>') : '';
|
||||
const image = marker.image ? `<img src="${marker.image}" class="chapter-image">` : '';
|
||||
const images = getMarkerImagesHtml(marker.images || (marker.image ? [marker.image] : []));
|
||||
const video = marker.videoUrl ? `<iframe src="${marker.videoUrl}" frameborder="0" allowfullscreen></iframe>` : '';
|
||||
chaptersHtml += `
|
||||
<div class="chapter" data-marker-id="${marker.id || idx}">
|
||||
@ -298,7 +312,7 @@
|
||||
<h3>${escapeHtml(title)}</h3>
|
||||
${date}
|
||||
</div>
|
||||
${image}
|
||||
${images}
|
||||
${video}
|
||||
<div class="chapter-content">${content}</div>
|
||||
</div>
|
||||
@ -332,6 +346,36 @@
|
||||
document.getElementById('delete-journey-btn')?.addEventListener('click', deleteJourney);
|
||||
}
|
||||
}
|
||||
|
||||
function getUploadUrl(path) {
|
||||
if (!path) return '';
|
||||
if (path.startsWith('http')) return path;
|
||||
return API_BASE.replace('/api', path);
|
||||
}
|
||||
|
||||
function getMarkerImagesHtml(images) {
|
||||
if (!images || !images.length) return '';
|
||||
|
||||
const imageHtml = images.map((image) => {
|
||||
const url = typeof image === 'string' ? image : image.url;
|
||||
const alt = typeof image === 'string' ? 'Journey image' : image.originalName || 'Journey image';
|
||||
if (!url) return '';
|
||||
return `<img src="${escapeAttribute(getUploadUrl(url))}" alt="${escapeAttribute(alt)}">`;
|
||||
}).join('');
|
||||
|
||||
if (!imageHtml) return '';
|
||||
return `<div class="chapter-images">${imageHtml}</div>`;
|
||||
}
|
||||
|
||||
function escapeAttribute(value) {
|
||||
return String(value || '').replace(/[&<>"']/g, (char) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
})[char]);
|
||||
}
|
||||
|
||||
async function deleteJourney() {
|
||||
if (!confirm('Delete this journey permanently? All chapters and comments will be lost.')) return;
|
||||
@ -454,4 +498,4 @@
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@ -120,6 +120,8 @@
|
||||
color: var(--gray-8);
|
||||
}
|
||||
input[type="text"],
|
||||
input[type="date"],
|
||||
input[type="file"],
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
@ -169,6 +171,41 @@
|
||||
color: var(--gray-6);
|
||||
margin-bottom: var(--size-2);
|
||||
}
|
||||
.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-1);
|
||||
}
|
||||
.image-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.remove-image {
|
||||
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;
|
||||
}
|
||||
.btn {
|
||||
padding: var(--size-2) var(--size-4);
|
||||
border: none;
|
||||
@ -301,6 +338,68 @@
|
||||
setTimeout(() => { toast.style.display = 'none'; }, 3000);
|
||||
}
|
||||
|
||||
function getUploadUrl(path) {
|
||||
if (!path) return '';
|
||||
if (path.startsWith('http')) return path;
|
||||
return API_BASE.replace('/api', path);
|
||||
}
|
||||
|
||||
function escapeAttribute(value) {
|
||||
return String(value || '').replace(/[&<>"']/g, (char) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
})[char]);
|
||||
}
|
||||
|
||||
function normalizeMarkerImages(marker) {
|
||||
const images = marker.images || (marker.image ? [marker.image] : []);
|
||||
return { ...marker, images };
|
||||
}
|
||||
|
||||
function renderMarkerImages(marker, idx) {
|
||||
const images = marker.images || [];
|
||||
if (!images.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="image-preview-grid">
|
||||
${images.map((image, imageIdx) => {
|
||||
const url = typeof image === 'string' ? image : image.url;
|
||||
const alt = typeof image === 'string' ? 'Chapter image' : image.originalName || 'Chapter image';
|
||||
if (!url) return '';
|
||||
return `
|
||||
<div class="image-preview">
|
||||
<img src="${escapeAttribute(getUploadUrl(url))}" alt="${escapeAttribute(alt)}">
|
||||
<button type="button" class="remove-image" data-index="${idx}" data-image-index="${imageIdx}" aria-label="Remove image">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 || [];
|
||||
}
|
||||
|
||||
// ==================== RENDER MARKERS ====================
|
||||
function renderMarkers() {
|
||||
const container = document.getElementById('markers-container');
|
||||
@ -330,8 +429,9 @@
|
||||
<textarea class="marker-description" data-index="${idx}" rows="3" placeholder="Write about this chapter...">${escapeHtml(marker.description || '')}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Image URL</label>
|
||||
<input type="text" class="marker-image" data-index="${idx}" value="${escapeHtml(marker.image || '')}" placeholder="https://...">
|
||||
<label>Images</label>
|
||||
<input type="file" class="marker-images" data-index="${idx}" accept="image/*" multiple>
|
||||
${renderMarkerImages(marker, idx)}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Video URL</label>
|
||||
@ -368,10 +468,25 @@
|
||||
markersData[idx].description = textarea.value;
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('.marker-image').forEach(input => {
|
||||
input.addEventListener('input', (e) => {
|
||||
document.querySelectorAll('.marker-images').forEach(input => {
|
||||
input.addEventListener('change', async () => {
|
||||
const idx = parseInt(input.dataset.index);
|
||||
markersData[idx].image = input.value;
|
||||
try {
|
||||
const uploadedImages = await uploadMarkerImages(Array.from(input.files));
|
||||
markersData[idx].images = [...(markersData[idx].images || []), ...uploadedImages];
|
||||
showToast('Image uploaded');
|
||||
renderMarkers();
|
||||
} catch (err) {
|
||||
showToast(err.message, true);
|
||||
}
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('.remove-image').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const idx = parseInt(btn.dataset.index);
|
||||
const imageIdx = parseInt(btn.dataset.imageIndex);
|
||||
markersData[idx].images = (markersData[idx].images || []).filter((_, i) => i !== imageIdx);
|
||||
renderMarkers();
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('.marker-video').forEach(input => {
|
||||
@ -397,7 +512,7 @@
|
||||
document.getElementById('journey-title').value = journey.title;
|
||||
document.getElementById('journey-description').value = journey.description || '';
|
||||
document.getElementById('journey-visibility').value = journey.visibility || 'private';
|
||||
markersData = journey.markers || [];
|
||||
markersData = (journey.markers || []).map(normalizeMarkerImages);
|
||||
console.log('Markers data loaded:', markersData);
|
||||
renderMarkers();
|
||||
document.getElementById('form-title').textContent = 'Edit Journey';
|
||||
@ -428,7 +543,7 @@
|
||||
title: m.title || '',
|
||||
date: m.date || '',
|
||||
description: m.description || '',
|
||||
image: m.image || '',
|
||||
images: m.images || [],
|
||||
videoUrl: m.videoUrl || ''
|
||||
}));
|
||||
|
||||
@ -473,7 +588,7 @@
|
||||
title: 'New Chapter',
|
||||
date: '',
|
||||
description: '',
|
||||
image: '',
|
||||
images: [],
|
||||
videoUrl: ''
|
||||
});
|
||||
renderMarkers();
|
||||
@ -501,4 +616,4 @@
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user