390 lines
15 KiB
JavaScript
390 lines
15 KiB
JavaScript
// ==================== GLOBALS ====================
|
|
let currentJourneyId = null;
|
|
let currentJourneyOwnerId = null;
|
|
let markersData = [];
|
|
let availableUsers = [];
|
|
let sharedReadIds = [];
|
|
let sharedEditIds = [];
|
|
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const journeyId = urlParams.get('id');
|
|
const isNew = urlParams.has('new');
|
|
|
|
// ==================== UI HELPERS ====================
|
|
function showToast(message, isError = false) {
|
|
const toast = document.getElementById('toast');
|
|
toast.textContent = message;
|
|
toast.style.backgroundColor = isError ? 'var(--red-7)' : 'var(--green-7)';
|
|
toast.style.display = 'block';
|
|
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 canManageSharing() {
|
|
return !currentJourneyOwnerId || currentJourneyOwnerId === currentUser?.id;
|
|
}
|
|
|
|
function uniqueIds(ids) {
|
|
return [...new Set((ids || []).map((id) => parseInt(id)).filter(Number.isInteger))];
|
|
}
|
|
|
|
async function loadUsers() {
|
|
try {
|
|
const res = await fetch(`${API_BASE}/users`, { credentials: 'include' });
|
|
if (!res.ok) throw new Error('Could not load users');
|
|
availableUsers = await res.json();
|
|
} catch (err) {
|
|
availableUsers = [];
|
|
showToast('Sharing users could not be loaded', true);
|
|
}
|
|
}
|
|
|
|
function getUserCheckboxHtml(user, group, checked, disabled) {
|
|
return `
|
|
<label class="sharing-user">
|
|
<input type="checkbox" class="${group}-user" value="${user.id}" ${checked ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
|
|
<span>${escapeHtml(user.username)}</span>
|
|
</label>
|
|
`;
|
|
}
|
|
|
|
function renderSharingControls() {
|
|
const panel = document.getElementById('sharing-panel');
|
|
const visibilitySelect = document.getElementById('journey-visibility');
|
|
const visibility = visibilitySelect.value;
|
|
const readContainer = document.getElementById('shared-read-users');
|
|
const editContainer = document.getElementById('shared-edit-users');
|
|
const isShared = visibility === 'shared';
|
|
const disabled = !canManageSharing();
|
|
|
|
visibilitySelect.disabled = disabled;
|
|
panel.classList.toggle('visible', isShared);
|
|
if (!isShared) return;
|
|
|
|
if (!availableUsers.length) {
|
|
readContainer.innerHTML = '<p class="empty-share-list">No other users found.</p>';
|
|
editContainer.innerHTML = '<p class="empty-share-list">No other users found.</p>';
|
|
return;
|
|
}
|
|
|
|
readContainer.innerHTML = availableUsers.map((user) =>
|
|
getUserCheckboxHtml(user, 'shared-read', sharedReadIds.includes(user.id), disabled)
|
|
).join('');
|
|
editContainer.innerHTML = availableUsers.map((user) =>
|
|
getUserCheckboxHtml(user, 'shared-edit', sharedEditIds.includes(user.id), disabled)
|
|
).join('');
|
|
|
|
document.querySelectorAll('.shared-read-user').forEach((input) => {
|
|
input.addEventListener('change', updateSharedUsersFromForm);
|
|
});
|
|
document.querySelectorAll('.shared-edit-user').forEach((input) => {
|
|
input.addEventListener('change', updateSharedUsersFromForm);
|
|
});
|
|
}
|
|
|
|
function updateSharedUsersFromForm() {
|
|
sharedReadIds = uniqueIds([...document.querySelectorAll('.shared-read-user:checked')].map((input) => input.value));
|
|
sharedEditIds = uniqueIds([...document.querySelectorAll('.shared-edit-user:checked')].map((input) => input.value));
|
|
sharedReadIds = uniqueIds([...sharedReadIds, ...sharedEditIds]);
|
|
renderSharingControls();
|
|
}
|
|
|
|
function getSharingPayload(visibility) {
|
|
if (visibility !== 'shared') {
|
|
return { shared_read: [], shared_edit: [] };
|
|
}
|
|
return {
|
|
shared_read: uniqueIds([...sharedReadIds, ...sharedEditIds]),
|
|
shared_edit: uniqueIds(sharedEditIds)
|
|
};
|
|
}
|
|
|
|
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>
|
|
`;
|
|
}
|
|
|
|
function renderMarkdownPreview(markdown) {
|
|
const html = renderMarkdown(markdown || '');
|
|
return html || '<p class="markdown-empty">Nothing to preview yet.</p>';
|
|
}
|
|
|
|
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');
|
|
if (!markersData.length) {
|
|
container.innerHTML = '<p class="empty-state" style="text-align:center; color: var(--gray-6);">No chapters yet. Click "Add Chapter" to create one.</p>';
|
|
return;
|
|
}
|
|
container.innerHTML = markersData.map((marker, idx) => `
|
|
<div class="marker-card" data-marker-index="${idx}">
|
|
<div class="marker-header">
|
|
<h4><i class="fas fa-map-pin"></i> Chapter ${idx+1}</h4>
|
|
<button type="button" class="remove-marker" data-index="${idx}"><i class="fas fa-trash-alt"></i></button>
|
|
</div>
|
|
<div class="marker-coords">
|
|
<i class="fas fa-location-dot"></i> ${marker.lat.toFixed(6)}, ${marker.lng.toFixed(6)}
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Chapter Title</label>
|
|
<input type="text" class="marker-title" data-index="${idx}" value="${escapeHtml(marker.title || '')}" placeholder="Title">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Date (optional)</label>
|
|
<input type="date" class="marker-date" data-index="${idx}" value="${marker.date || ''}">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Description</label>
|
|
<textarea class="marker-description" data-index="${idx}" rows="5" placeholder="Describe this chapter. You can use Markdown like **bold**, [link](https://example.com), or .">${escapeHtml(marker.description || '')}</textarea>
|
|
<div class="markdown-preview markdown-content" data-preview-index="${idx}">
|
|
${renderMarkdownPreview(marker.description)}
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Images</label>
|
|
<input type="file" class="marker-images" data-index="${idx}" accept="image/*" multiple>
|
|
${renderMarkerImages(marker, idx)}
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
// Attach remove listeners
|
|
document.querySelectorAll('.remove-marker').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
const idx = parseInt(btn.dataset.index);
|
|
markersData.splice(idx, 1);
|
|
renderMarkers();
|
|
});
|
|
});
|
|
|
|
// Attach input listeners to update markersData
|
|
document.querySelectorAll('.marker-title').forEach(input => {
|
|
input.addEventListener('input', (e) => {
|
|
const idx = parseInt(input.dataset.index);
|
|
markersData[idx].title = input.value;
|
|
});
|
|
});
|
|
document.querySelectorAll('.marker-date').forEach(input => {
|
|
input.addEventListener('input', (e) => {
|
|
const idx = parseInt(input.dataset.index);
|
|
markersData[idx].date = input.value;
|
|
});
|
|
});
|
|
document.querySelectorAll('.marker-description').forEach(textarea => {
|
|
textarea.addEventListener('input', (e) => {
|
|
const idx = parseInt(textarea.dataset.index);
|
|
markersData[idx].description = textarea.value;
|
|
const preview = document.querySelector(`.markdown-preview[data-preview-index="${idx}"]`);
|
|
if (preview) preview.innerHTML = renderMarkdownPreview(textarea.value);
|
|
});
|
|
});
|
|
document.querySelectorAll('.marker-images').forEach(input => {
|
|
input.addEventListener('change', async () => {
|
|
const idx = parseInt(input.dataset.index);
|
|
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();
|
|
});
|
|
});
|
|
}
|
|
|
|
// ==================== LOAD JOURNEY ====================
|
|
async function loadJourney(id) {
|
|
console.log('Loading journey with id:', id);
|
|
try {
|
|
const res = await fetch(`${API_BASE}/journeys/${id}`, { credentials: 'include' });
|
|
if (!res.ok) {
|
|
console.error('Failed to fetch journey, status:', res.status);
|
|
throw new Error('Journey not found');
|
|
}
|
|
const journey = await res.json();
|
|
console.log('Journey data:', journey);
|
|
currentJourneyId = journey.id;
|
|
currentJourneyOwnerId = journey.owner_id;
|
|
document.getElementById('journey-title').value = journey.title;
|
|
document.getElementById('journey-description').value = journey.description || '';
|
|
document.getElementById('journey-visibility').value = journey.visibility || 'private';
|
|
sharedReadIds = uniqueIds(journey.shared_read || []);
|
|
sharedEditIds = uniqueIds(journey.shared_edit || []);
|
|
markersData = (journey.markers || []).map(normalizeMarkerImages);
|
|
console.log('Markers data loaded:', markersData);
|
|
renderSharingControls();
|
|
renderMarkers();
|
|
document.getElementById('form-title').textContent = 'Edit Journey';
|
|
} catch (err) {
|
|
console.error('Error loading journey:', err);
|
|
showToast('Error loading journey: ' + err.message, true);
|
|
// Optionally redirect after a delay
|
|
setTimeout(() => window.location.href = 'blog-list.html', 2000);
|
|
}
|
|
}
|
|
|
|
// ==================== SAVE JOURNEY ====================
|
|
async function saveJourney(event) {
|
|
event.preventDefault();
|
|
const title = document.getElementById('journey-title').value.trim();
|
|
const description = document.getElementById('journey-description').value.trim();
|
|
const visibility = document.getElementById('journey-visibility').value;
|
|
|
|
if (!title) {
|
|
showToast('Please enter a title', true);
|
|
return;
|
|
}
|
|
|
|
// Build markers array from current form data (already in markersData)
|
|
const markers = markersData.map(m => ({
|
|
lat: m.lat,
|
|
lng: m.lng,
|
|
title: m.title || '',
|
|
date: m.date || '',
|
|
description: m.description || '',
|
|
images: m.images || []
|
|
}));
|
|
|
|
const payload = { title, description, markers };
|
|
if (canManageSharing()) {
|
|
payload.visibility = visibility;
|
|
Object.assign(payload, getSharingPayload(visibility));
|
|
}
|
|
console.log('Saving payload:', payload);
|
|
|
|
try {
|
|
let res;
|
|
if (isNew) {
|
|
res = await fetch(`${API_BASE}/journeys`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
credentials: 'include'
|
|
});
|
|
} else {
|
|
res = await fetch(`${API_BASE}/journeys/${currentJourneyId}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
credentials: 'include'
|
|
});
|
|
}
|
|
if (!res.ok) throw new Error('Save failed');
|
|
const data = await res.json();
|
|
console.log('Save response:', data);
|
|
showToast('Journey saved!');
|
|
// Redirect to read view
|
|
window.location.href = `blog-post.html?id=${data.id}`;
|
|
} catch (err) {
|
|
console.error('Error saving journey:', err);
|
|
showToast('Error saving journey: ' + err.message, true);
|
|
}
|
|
}
|
|
|
|
// ==================== ADD MARKER ====================
|
|
function addMarker() {
|
|
console.log('Adding new marker');
|
|
markersData.push({
|
|
lat: 46.8182,
|
|
lng: 8.2275,
|
|
title: 'New Chapter',
|
|
date: '',
|
|
description: '',
|
|
images: []
|
|
});
|
|
renderMarkers();
|
|
}
|
|
|
|
// ==================== INITIALIZATION ====================
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
const authenticated = await checkAuthAndRedirect();
|
|
if (!authenticated) return;
|
|
updateUserMenu();
|
|
|
|
if (!isNew && journeyId) {
|
|
await loadUsers();
|
|
await loadJourney(journeyId);
|
|
} else if (isNew) {
|
|
currentJourneyId = null;
|
|
currentJourneyOwnerId = currentUser.id;
|
|
document.getElementById('form-title').textContent = 'New Journey';
|
|
markersData = [];
|
|
sharedReadIds = [];
|
|
sharedEditIds = [];
|
|
await loadUsers();
|
|
renderSharingControls();
|
|
renderMarkers();
|
|
} else {
|
|
window.location.href = 'blog-list.html';
|
|
}
|
|
|
|
document.getElementById('journey-form').addEventListener('submit', saveJourney);
|
|
document.getElementById('add-marker').addEventListener('click', addMarker);
|
|
document.getElementById('journey-visibility').addEventListener('change', renderSharingControls);
|
|
});
|