350 lines
13 KiB
JavaScript
350 lines
13 KiB
JavaScript
let currentJourney = null;
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const journeyId = urlParams.get('id');
|
||
|
||
// ==================== LOAD JOURNEY ====================
|
||
async function loadJourney() {
|
||
if (!journeyId) {
|
||
window.location.href = 'blog-list.html';
|
||
return;
|
||
}
|
||
try {
|
||
const res = await fetch(`${API_BASE}/journeys/${journeyId}`, { credentials: 'include' });
|
||
if (!res.ok) throw new Error('Journey not found');
|
||
currentJourney = await res.json();
|
||
renderJourney();
|
||
loadComments();
|
||
} catch (err) {
|
||
showToast('Error loading journey', true);
|
||
setTimeout(() => window.location.href = 'blog-list.html', 2000);
|
||
}
|
||
}
|
||
|
||
function renderJourney() {
|
||
const container = document.getElementById('post-content');
|
||
const isOwner = currentUser && currentUser.id === currentJourney.owner_id;
|
||
const canEdit = canEditJourney();
|
||
|
||
// Build chapters from markers
|
||
let chaptersHtml = '';
|
||
if (currentJourney.markers && currentJourney.markers.length) {
|
||
chaptersHtml = '<div class="chapters">';
|
||
currentJourney.markers.forEach((marker, idx) => {
|
||
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 ? renderMarkdown(marker.description) : '';
|
||
marker.images = getMarkerImages(marker);
|
||
const images = getMarkerImagesHtml(marker.images, idx, canEdit);
|
||
chaptersHtml += `
|
||
<div class="chapter" data-marker-id="${marker.id || idx}">
|
||
<div class="chapter-header">
|
||
<h3>${escapeHtml(title)}</h3>
|
||
${date}
|
||
</div>
|
||
${images}
|
||
${canEdit ? getImageToolsHtml(idx) : ''}
|
||
<div class="chapter-content markdown-content">${content}</div>
|
||
</div>
|
||
`;
|
||
});
|
||
chaptersHtml += '</div>';
|
||
} else {
|
||
chaptersHtml = '<p>No chapters yet.</p>';
|
||
}
|
||
|
||
container.innerHTML = `
|
||
<article class="post-card">
|
||
<h1 class="post-title">${escapeHtml(currentJourney.title)}</h1>
|
||
<div class="post-meta">
|
||
<i class="fas fa-calendar-alt"></i> ${new Date(currentJourney.created_at).toLocaleDateString()}
|
||
${currentJourney.visibility === 'public' ? '<span class="badge">Public</span>' : ''}
|
||
${currentJourney.visibility === 'shared' && !isOwner ? `<span class="badge">${canEdit ? 'Shared edit' : 'Shared'}</span>` : ''}
|
||
</div>
|
||
${currentJourney.image ? `<img class="post-image" src="${currentJourney.image}" alt="${currentJourney.title}">` : ''}
|
||
<div class="post-description markdown-content">${renderMarkdown(currentJourney.description)}</div>
|
||
${chaptersHtml}
|
||
${canEdit || isOwner ? `
|
||
<div style="margin-top: var(--size-4); display: flex; gap: var(--size-2);">
|
||
${canEdit ? `<a href="journey-edit.html?id=${currentJourney.id}" class="btn btn-sm"><i class="fas fa-edit"></i> Edit</a>` : ''}
|
||
${isOwner ? `<button id="delete-journey-btn" class="btn btn-danger btn-sm"><i class="fas fa-trash"></i> Delete</button>` : ''}
|
||
</div>
|
||
` : ''}
|
||
</article>
|
||
`;
|
||
|
||
if (isOwner) {
|
||
document.getElementById('delete-journey-btn')?.addEventListener('click', deleteJourney);
|
||
}
|
||
if (canEdit) {
|
||
attachChapterImageControls();
|
||
}
|
||
}
|
||
|
||
function getUploadUrl(path) {
|
||
if (!path) return '';
|
||
if (path.startsWith('http')) return path;
|
||
return API_BASE.replace('/api', path);
|
||
}
|
||
|
||
function canEditJourney() {
|
||
if (!currentUser || !currentJourney) return false;
|
||
if (currentUser.id === currentJourney.owner_id) return true;
|
||
return currentJourney.visibility === 'shared'
|
||
&& Array.isArray(currentJourney.shared_edit)
|
||
&& currentJourney.shared_edit.includes(currentUser.id);
|
||
}
|
||
|
||
function canViewJourney() {
|
||
if (!currentUser || !currentJourney) return false;
|
||
if (currentUser.id === currentJourney.owner_id) return true;
|
||
if (currentJourney.visibility === 'public') return true;
|
||
return currentJourney.visibility === 'shared'
|
||
&& (
|
||
(Array.isArray(currentJourney.shared_read) && currentJourney.shared_read.includes(currentUser.id))
|
||
|| (Array.isArray(currentJourney.shared_edit) && currentJourney.shared_edit.includes(currentUser.id))
|
||
);
|
||
}
|
||
|
||
function getMarkerImages(marker) {
|
||
return marker.images || (marker.image ? [marker.image] : []);
|
||
}
|
||
|
||
function getMarkerImagesHtml(images, markerIndex, canEdit) {
|
||
if (!images || !images.length) return '';
|
||
|
||
const imageHtml = images.map((image, imageIndex) => {
|
||
const url = typeof image === 'string' ? image : image.url;
|
||
const alt = typeof image === 'string' ? 'Journey image' : image.originalName || 'Journey image';
|
||
if (!url) return '';
|
||
return `
|
||
<div class="chapter-image-item">
|
||
<img src="${escapeAttribute(getUploadUrl(url))}" alt="${escapeAttribute(alt)}">
|
||
${canEdit ? `
|
||
<button type="button" class="remove-chapter-image" data-marker-index="${markerIndex}" data-image-index="${imageIndex}" aria-label="Remove image">
|
||
<i class="fas fa-times"></i>
|
||
</button>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
if (!imageHtml) return '';
|
||
return `<div class="chapter-images">${imageHtml}</div>`;
|
||
}
|
||
|
||
function getImageToolsHtml(markerIndex) {
|
||
return `
|
||
<div class="chapter-image-tools">
|
||
<input type="file" class="chapter-image-input" data-marker-index="${markerIndex}" accept="image/*" multiple>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function attachChapterImageControls() {
|
||
document.querySelectorAll('.chapter-image-input').forEach(input => {
|
||
input.addEventListener('change', () => uploadChapterImages(input));
|
||
});
|
||
document.querySelectorAll('.remove-chapter-image').forEach(button => {
|
||
button.addEventListener('click', () => removeChapterImage(
|
||
parseInt(button.dataset.markerIndex),
|
||
parseInt(button.dataset.imageIndex)
|
||
));
|
||
});
|
||
}
|
||
|
||
async function uploadImages(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 || [];
|
||
}
|
||
|
||
async function uploadChapterImages(input) {
|
||
const markerIndex = parseInt(input.dataset.markerIndex);
|
||
const files = Array.from(input.files || []);
|
||
if (!files.length || !currentJourney.markers[markerIndex]) return;
|
||
|
||
const originalImages = [...getMarkerImages(currentJourney.markers[markerIndex])];
|
||
try {
|
||
showToast('Uploading images...');
|
||
const uploadedImages = await uploadImages(files);
|
||
const marker = currentJourney.markers[markerIndex];
|
||
marker.images = [...originalImages, ...uploadedImages];
|
||
await saveJourneyMarkers();
|
||
showToast('Images uploaded');
|
||
renderJourney();
|
||
} catch (err) {
|
||
currentJourney.markers[markerIndex].images = originalImages;
|
||
showToast(err.message || 'Error uploading images', true);
|
||
} finally {
|
||
input.value = '';
|
||
}
|
||
}
|
||
|
||
async function removeChapterImage(markerIndex, imageIndex) {
|
||
const marker = currentJourney.markers[markerIndex];
|
||
if (!marker) return;
|
||
|
||
const originalImages = [...getMarkerImages(marker)];
|
||
marker.images = getMarkerImages(marker).filter((_, idx) => idx !== imageIndex);
|
||
try {
|
||
await saveJourneyMarkers();
|
||
showToast('Image removed');
|
||
renderJourney();
|
||
} catch (err) {
|
||
marker.images = originalImages;
|
||
showToast('Error removing image', true);
|
||
}
|
||
}
|
||
|
||
async function saveJourneyMarkers() {
|
||
const res = await fetch(`${API_BASE}/journeys/${currentJourney.id}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ markers: currentJourney.markers }),
|
||
credentials: 'include'
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.error || 'Could not save images');
|
||
currentJourney = data;
|
||
}
|
||
|
||
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;
|
||
try {
|
||
const res = await fetch(`${API_BASE}/journeys/${currentJourney.id}`, {
|
||
method: 'DELETE',
|
||
credentials: 'include'
|
||
});
|
||
if (!res.ok) throw new Error('Delete failed');
|
||
showToast('Journey deleted');
|
||
setTimeout(() => window.location.href = 'blog-list.html', 1000);
|
||
} catch (err) {
|
||
showToast('Error deleting journey', true);
|
||
}
|
||
}
|
||
|
||
// ==================== COMMENTS ====================
|
||
async function loadComments() {
|
||
try {
|
||
const res = await fetch(`${API_BASE}/journeys/${journeyId}/comments`, { credentials: 'include' });
|
||
if (!res.ok) throw new Error('Failed to load comments');
|
||
const comments = await res.json();
|
||
renderComments(comments);
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
|
||
function renderComments(comments) {
|
||
const container = document.getElementById('comments-section');
|
||
const isOwner = currentUser && currentUser.id === currentJourney.owner_id;
|
||
const canComment = canViewJourney();
|
||
|
||
if (!comments.length) {
|
||
container.innerHTML = `
|
||
<h3><i class="fas fa-comments"></i> Comments</h3>
|
||
<p class="empty-state">No comments yet. Be the first to comment!</p>
|
||
${canComment && currentUser ? getCommentFormHtml() : (!currentUser ? '<p><a href="login.html">Login</a> to comment.</p>' : '')}
|
||
`;
|
||
if (canComment && currentUser) attachCommentForm();
|
||
return;
|
||
}
|
||
|
||
let commentsHtml = '<h3><i class="fas fa-comments"></i> Comments</h3>';
|
||
comments.forEach(comment => {
|
||
const isOwnerOrAuthor = currentUser && (currentUser.id === comment.author_id || currentUser.id === currentJourney.owner_id);
|
||
commentsHtml += `
|
||
<div class="comment" data-comment-id="${comment.id}">
|
||
<div class="comment-meta">
|
||
<strong>${escapeHtml(comment.author_name)}</strong> – ${new Date(comment.created_at).toLocaleString()}
|
||
${isOwnerOrAuthor ? `<button class="delete-comment" data-id="${comment.id}"><i class="fas fa-trash-alt"></i> Delete</button>` : ''}
|
||
</div>
|
||
<p class="comment-text">${escapeHtml(comment.text)}</p>
|
||
</div>
|
||
`;
|
||
});
|
||
container.innerHTML = commentsHtml + (canComment && currentUser ? getCommentFormHtml() : (canComment ? '<p><a href="login.html">Login</a> to comment.</p>' : ''));
|
||
|
||
document.querySelectorAll('.delete-comment').forEach(btn => {
|
||
btn.addEventListener('click', () => deleteComment(parseInt(btn.dataset.id)));
|
||
});
|
||
if (canComment && currentUser) attachCommentForm();
|
||
}
|
||
|
||
function getCommentFormHtml() {
|
||
return `
|
||
<div class="comment-form">
|
||
<textarea id="comment-text" rows="3" placeholder="Write a comment..."></textarea>
|
||
<button id="submit-comment" class="btn btn-sm" style="margin-top: var(--size-2);"><i class="fas fa-paper-plane"></i> Post Comment</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function attachCommentForm() {
|
||
document.getElementById('submit-comment')?.addEventListener('click', submitComment);
|
||
}
|
||
|
||
async function submitComment() {
|
||
const text = document.getElementById('comment-text').value.trim();
|
||
if (!text) {
|
||
showToast('Comment cannot be empty', true);
|
||
return;
|
||
}
|
||
try {
|
||
const res = await fetch(`${API_BASE}/journeys/${journeyId}/comments`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ text }),
|
||
credentials: 'include'
|
||
});
|
||
if (!res.ok) throw new Error('Failed to post comment');
|
||
showToast('Comment posted');
|
||
loadComments();
|
||
} catch (err) {
|
||
showToast('Error posting comment', true);
|
||
}
|
||
}
|
||
|
||
async function deleteComment(commentId) {
|
||
if (!confirm('Delete this comment?')) return;
|
||
try {
|
||
const res = await fetch(`${API_BASE}/comments/${commentId}`, {
|
||
method: 'DELETE',
|
||
credentials: 'include'
|
||
});
|
||
if (!res.ok) throw new Error('Delete failed');
|
||
showToast('Comment deleted');
|
||
loadComments();
|
||
} catch (err) {
|
||
showToast('Error deleting comment', true);
|
||
}
|
||
}
|
||
|
||
// ==================== INIT ====================
|
||
document.addEventListener('DOMContentLoaded', async () => {
|
||
const authenticated = await checkAuthAndRedirect();
|
||
if (!authenticated) return;
|
||
updateUserMenu();
|
||
loadJourney();
|
||
});
|