350 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
})[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();
});