First comment function implementation

This commit is contained in:
Josh-Dev-Quest 2026-03-28 14:25:13 +01:00
parent e3724a4842
commit 241c962faf
No known key found for this signature in database
7 changed files with 502 additions and 7 deletions

View File

@ -1,9 +1,10 @@
import os
import time
import json
from datetime import datetime
from werkzeug.security import generate_password_hash, check_password_hash
from flask import Flask, request, jsonify, session from flask import Flask, request, jsonify, session
from flask_cors import CORS from flask_cors import CORS
from werkzeug.security import generate_password_hash, check_password_hash
import json
import os
from datetime import datetime
app = Flask(__name__) app = Flask(__name__)
app.secret_key = "your-secret-key-here-change-in-production" # needed for sessions app.secret_key = "your-secret-key-here-change-in-production" # needed for sessions
@ -333,6 +334,7 @@ def create_blog_post():
"content": data.get("content", ""), "content": data.get("content", ""),
"journeyId": data.get("journeyId"), "journeyId": data.get("journeyId"),
"image": data.get("image"), "image": data.get("image"),
"author_id": user_id,
"created_at": datetime.now().isoformat(), "created_at": datetime.now().isoformat(),
} }
@ -351,6 +353,8 @@ def update_blog_post(post_id):
return jsonify({"error": "Post not found"}), 404 return jsonify({"error": "Post not found"}), 404
data = request.get_json() data = request.get_json()
if not get_current_user_id() == data["author_id"]:
return jsonify({"error": "Wrong user"})
if "title" in data: if "title" in data:
post["title"] = data["title"] post["title"] = data["title"]
if "content" in data: if "content" in data:
@ -378,6 +382,77 @@ def delete_blog_post(post_id):
return jsonify({"message": "Post deleted"}) return jsonify({"message": "Post deleted"})
# ==================== Comments (stored inside posts) ====================
def get_post_by_id(user_id, post_id):
posts = load_user_posts(user_id)
return next((p for p in posts if p['id'] == post_id), None)
def save_post(user_id, post):
posts = load_user_posts(user_id)
for i, p in enumerate(posts):
if p['id'] == post['id']:
posts[i] = post
break
save_user_posts(user_id, posts)
@app.route('/api/posts/<int:post_id>/comments', methods=['GET'])
def get_comments(post_id):
user_id = session.get('user_id')
if not user_id:
return jsonify({'error': 'Authentication required'}), 401
post = get_post_by_id(user_id, post_id)
if not post:
return jsonify({'error': 'Post not found'}), 404
return jsonify(post.get('comments', []))
@app.route('/api/posts/<int:post_id>/comments', methods=['POST'])
def add_comment(post_id):
user_id = session.get('user_id')
if not user_id:
return jsonify({'error': 'Authentication required'}), 401
data = request.get_json()
text = data.get('text')
if not text:
return jsonify({'error': 'Comment text required'}), 400
post = get_post_by_id(user_id, post_id)
if not post:
return jsonify({'error': 'Post not found'}), 404
comment = {
'id': int(time.time() * 1000), # simple unique id
'author_id': user_id,
'author_name': get_user_by_id(user_id)['username'],
'text': text,
'created_at': datetime.now().isoformat()
}
if 'comments' not in post:
post['comments'] = []
post['comments'].append(comment)
save_post(user_id, post)
return jsonify(comment), 201
@app.route('/api/comments/<int:comment_id>', methods=['DELETE'])
def delete_comment(comment_id):
user_id = session.get('user_id')
if not user_id:
return jsonify({'error': 'Authentication required'}), 401
# Find which post contains this comment
posts = load_user_posts(user_id)
for post in posts:
if 'comments' in post:
for i, c in enumerate(post['comments']):
if c['id'] == comment_id:
# Allow deletion if current user is comment author or post author
if c['author_id'] == user_id or post['id'] == post.get('author_id', user_id):
del post['comments'][i]
save_post(user_id, post)
return jsonify({'message': 'Comment deleted'})
else:
return jsonify({'error': 'Not authorized'}), 403
return jsonify({'error': 'Comment not found'}), 404
# ==================== Health and root ==================== # ==================== Health and root ====================
@app.route("/api/journeys/health", methods=["GET"]) @app.route("/api/journeys/health", methods=["GET"])
def health_check(): def health_check():

View File

@ -4,5 +4,11 @@
"username": "josh", "username": "josh",
"password_hash": "scrypt:32768:8:1$HA70PiOwbBrIwlDq$2ab80bdc08bb3bb4214258566aded836062323380491a7f4c7f2e67bdccb8686367789f57b3c6c5eb3e2f08c8c07186f47f9c89d1e72179ddd3758b509f23fbe", "password_hash": "scrypt:32768:8:1$HA70PiOwbBrIwlDq$2ab80bdc08bb3bb4214258566aded836062323380491a7f4c7f2e67bdccb8686367789f57b3c6c5eb3e2f08c8c07186f47f9c89d1e72179ddd3758b509f23fbe",
"created_at": "2026-03-27T20:32:43.107028" "created_at": "2026-03-27T20:32:43.107028"
},
{
"id": 2,
"username": "test1",
"password_hash": "scrypt:32768:8:1$hPfITQadZq8438bv$38262bf82d93c596a82a1b052a4ba72f8d6729b796ca5273faa7dd47b409112959c4501e77922605a1f3a7ef08e68fa545ce03818eb82e6fb2503cc817c43e2a",
"created_at": "2026-03-28T14:13:32.860143"
} }
] ]

View File

@ -5,6 +5,15 @@
"content": "ksafladjsfk", "content": "ksafladjsfk",
"journeyId": "1", "journeyId": "1",
"image": null, "image": null,
"created_at": "2026-03-27T21:23:39.755057" "created_at": "2026-03-27T21:23:39.755057",
"comments": [
{
"id": 1774703592361,
"author_id": 1,
"author_name": "josh",
"text": "hello",
"created_at": "2026-03-28T14:13:12.362078"
}
]
} }
] ]

View File

@ -0,0 +1 @@
[]

View File

@ -0,0 +1 @@
[]

View File

@ -212,7 +212,7 @@
<main class="blog-container"> <main class="blog-container">
<div class="blog-header"> <div class="blog-header">
<h1><i class="fas fa-newspaper"></i> Blog Posts</h1> <h1><i class="fas fa-newspaper"></i> Blog Posts</h1>
<a href="blog-post-edit.html?new" class="btn"><i class="fas fa-plus"></i> New Post</a> <a href="blog-post.html?new" class="btn"><i class="fas fa-plus"></i> New Post</a>
</div> </div>
<div id="posts-grid" class="posts-grid"> <div id="posts-grid" class="posts-grid">
<!-- Posts loaded dynamically --> <!-- Posts loaded dynamically -->
@ -247,7 +247,7 @@
<article class="post-card"> <article class="post-card">
${post.image ? `<img class="post-card-image" src="${post.image}" alt="${post.title}">` : '<div class="post-card-image" style="background: var(--surface-3); display: flex; align-items: center; justify-content: center;"><i class="fas fa-image" style="font-size: 3rem; color: var(--gray-5);"></i></div>'} ${post.image ? `<img class="post-card-image" src="${post.image}" alt="${post.title}">` : '<div class="post-card-image" style="background: var(--surface-3); display: flex; align-items: center; justify-content: center;"><i class="fas fa-image" style="font-size: 3rem; color: var(--gray-5);"></i></div>'}
<div class="post-card-content"> <div class="post-card-content">
<h2 class="post-card-title"><a href="blog-post-edit.html?id=${post.id}">${escapeHtml(post.title)}</a></h2> <h2 class="post-card-title"><a href="blog-post.html?id=${post.id}">${escapeHtml(post.title)}</a></h2>
<div class="post-card-meta"> <div class="post-card-meta">
<i class="fas fa-calendar-alt"></i> ${new Date(post.created_at).toLocaleDateString()} <i class="fas fa-calendar-alt"></i> ${new Date(post.created_at).toLocaleDateString()}
${post.journeyId ? `<span style="margin-left: 12px;"><i class="fas fa-route"></i> Journey #${post.journeyId}</span>` : ''} ${post.journeyId ? `<span style="margin-left: 12px;"><i class="fas fa-route"></i> Journey #${post.journeyId}</span>` : ''}

403
blog-post.html Normal file
View File

@ -0,0 +1,403 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blog Post Journey Mapper</title>
<link rel="stylesheet" href="https://unpkg.com/open-props"/>
<link rel="stylesheet" href="https://unpkg.com/open-props/normalize.min.css"/>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; }
body {
font-family: var(--font-sans);
background: var(--gray-0);
color: var(--gray-9);
line-height: var(--font-lineheight-3);
margin: 0;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.site-header {
background: var(--gray-9);
padding: var(--size-4) var(--size-6);
border-bottom: 1px solid var(--surface-4);
}
.site-header .container {
max-width: 1400px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: var(--size-2);
}
.site-title {
margin: 0;
font-size: var(--font-size-4);
font-weight: var(--font-weight-6);
}
.site-title a {
color: var(--indigo-4);
text-decoration: none;
}
.site-nav {
display: flex;
gap: var(--size-4);
}
.site-nav a {
color: var(--gray-2);
text-decoration: none;
font-weight: var(--font-weight-5);
transition: color 0.2s;
padding: var(--size-1) var(--size-2);
border-radius: var(--radius-2);
}
.site-nav a:hover,
.site-nav a.active {
color: var(--indigo-4);
background: var(--surface-2);
}
.user-menu {
display: flex;
align-items: center;
gap: var(--size-2);
}
.user-menu .username {
color: var(--gray-2);
font-weight: var(--font-weight-5);
}
.logout-btn {
background: var(--indigo-7);
color: white;
border: none;
border-radius: var(--radius-2);
padding: var(--size-1) var(--size-3);
cursor: pointer;
font-size: var(--font-size-1);
font-weight: var(--font-weight-5);
transition: background 0.2s;
}
.logout-btn:hover {
background: var(--indigo-8);
}
.post-container {
max-width: 800px;
margin: var(--size-6) auto;
padding: 0 var(--size-4);
}
.post-card {
background: var(--surface-1);
border-radius: var(--radius-3);
padding: var(--size-6);
box-shadow: var(--shadow-2);
margin-bottom: var(--size-6);
}
.post-title {
font-size: var(--font-size-6);
margin-top: 0;
margin-bottom: var(--size-2);
}
.post-meta {
color: var(--gray-6);
margin-bottom: var(--size-4);
border-bottom: 1px solid var(--surface-4);
padding-bottom: var(--size-2);
}
.post-image {
max-width: 100%;
border-radius: var(--radius-2);
margin: var(--size-4) 0;
}
.post-content {
line-height: 1.6;
}
.comments-section {
background: var(--surface-1);
border-radius: var(--radius-3);
padding: var(--size-6);
box-shadow: var(--shadow-2);
}
.comment {
border-bottom: 1px solid var(--surface-4);
padding: var(--size-4) 0;
}
.comment:last-child {
border-bottom: none;
}
.comment-meta {
font-size: var(--font-size-1);
color: var(--gray-6);
margin-bottom: var(--size-2);
}
.comment-text {
margin: 0;
}
.delete-comment {
background: none;
border: none;
color: var(--red-6);
cursor: pointer;
font-size: var(--font-size-1);
margin-left: var(--size-2);
}
.delete-comment:hover {
text-decoration: underline;
}
.comment-form {
margin-top: var(--size-4);
}
.comment-form textarea {
width: 100%;
padding: var(--size-2);
border: 1px solid var(--surface-4);
border-radius: var(--radius-2);
background: var(--surface-2);
font-family: inherit;
font-size: var(--font-size-2);
resize: vertical;
}
.btn {
padding: var(--size-2) var(--size-4);
border: none;
border-radius: var(--radius-2);
cursor: pointer;
font-size: var(--font-size-2);
font-weight: var(--font-weight-5);
display: inline-flex;
align-items: center;
gap: var(--size-2);
transition: all 0.2s var(--ease-2);
box-shadow: var(--shadow-2);
background: var(--indigo-7);
color: white;
text-decoration: none;
}
.btn-secondary {
background: var(--gray-7);
}
.btn-danger {
background: var(--red-7);
}
.btn-sm {
padding: var(--size-1) var(--size-2);
font-size: var(--font-size-1);
}
.toast {
position: fixed;
bottom: var(--size-4);
right: var(--size-4);
background: var(--green-7);
color: white;
padding: var(--size-2) var(--size-4);
border-radius: var(--radius-2);
display: none;
z-index: 1100;
}
</style>
</head>
<body>
<header class="site-header">
<div class="container">
<h1 class="site-title"><a href="map-page.html">Journey Mapper</a></h1>
<div style="display: flex; align-items: center; gap: var(--size-4);">
<nav class="site-nav">
<a href="map-page.html">Map</a>
<a href="blog-list.html">Blog</a>
</nav>
<div class="user-menu" id="user-menu"></div>
</div>
</div>
</header>
<main class="post-container">
<div id="post-content"></div>
<div id="comments-section" class="comments-section"></div>
</main>
<div id="toast" class="toast"></div>
<script src="js/auth.js"></script>
<script>
// ==================== GLOBALS ====================
let currentPost = null;
const urlParams = new URLSearchParams(window.location.search);
const postId = urlParams.get('id');
// ==================== LOAD POST ====================
async function loadPost() {
if (!postId) {
window.location.href = 'blog-list.html';
return;
}
try {
const res = await fetch(`${API_BASE}/blog-posts/${postId}`, { credentials: 'include' });
if (!res.ok) throw new Error('Post not found');
currentPost = await res.json();
renderPost();
loadComments();
} catch (err) {
showToast('Error loading post', true);
setTimeout(() => window.location.href = 'blog-list.html', 2000);
}
}
function renderPost() {
const container = document.getElementById('post-content');
const isAuthor = currentUser && currentUser.id === currentPost.author_id; // we need to store author_id in post
// For now we assume post.author_id is stored (we should add it when creating post).
// If not, you can compare with journey owner later.
container.innerHTML = `
<article class="post-card">
<h1 class="post-title">${escapeHtml(currentPost.title)}</h1>
<div class="post-meta">
<i class="fas fa-calendar-alt"></i> ${new Date(currentPost.created_at).toLocaleDateString()}
${currentPost.journeyId ? `<span style="margin-left: 12px;"><i class="fas fa-route"></i> <a href="map-page.html?journey=${currentPost.journeyId}" style="color: var(--indigo-7);">View Journey</a></span>` : ''}
</div>
${currentPost.image ? `<img class="post-image" src="${currentPost.image}" alt="${currentPost.title}">` : ''}
<div class="post-content">${formatContent(currentPost.content)}</div>
${isAuthor ? `
<div style="margin-top: var(--size-4); display: flex; gap: var(--size-2);">
<a href="blog-post-edit.html?id=${currentPost.id}" class="btn btn-sm"><i class="fas fa-edit"></i> Edit</a>
<button id="delete-post-btn" class="btn btn-danger btn-sm"><i class="fas fa-trash"></i> Delete</button>
</div>
` : ''}
</article>
`;
if (isAuthor) {
document.getElementById('delete-post-btn')?.addEventListener('click', deletePost);
}
}
function formatContent(text) {
// Simple markdown-like: convert newlines to <br>
return escapeHtml(text).replace(/\n/g, '<br>');
}
async function deletePost() {
if (!confirm('Delete this post permanently?')) return;
try {
const res = await fetch(`${API_BASE}/blog-posts/${currentPost.id}`, {
method: 'DELETE',
credentials: 'include'
});
if (!res.ok) throw new Error('Delete failed');
showToast('Post deleted');
setTimeout(() => window.location.href = 'blog-list.html', 1000);
} catch (err) {
showToast('Error deleting post', true);
}
}
// ==================== COMMENTS ====================
async function loadComments() {
try {
const res = await fetch(`${API_BASE}/posts/${postId}/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');
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>
${currentUser ? getCommentFormHtml() : '<p><a href="login.html">Login</a> to comment.</p>'}
`;
if (currentUser) attachCommentForm();
return;
}
let commentsHtml = '<h3><i class="fas fa-comments"></i> Comments</h3>';
comments.forEach(comment => {
const isOwner = currentUser && (currentUser.id === comment.author_id || (currentPost && currentPost.author_id === currentUser.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()}
${isOwner ? `<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 + (currentUser ? getCommentFormHtml() : '<p><a href="login.html">Login</a> to comment.</p>');
// Attach delete handlers
document.querySelectorAll('.delete-comment').forEach(btn => {
btn.addEventListener('click', () => deleteComment(parseInt(btn.dataset.id)));
});
if (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}/posts/${postId}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
credentials: 'include'
});
if (!res.ok) throw new Error('Failed to post comment');
const newComment = await res.json();
showToast('Comment posted');
loadComments(); // refresh list
} 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();
loadPost();
});
</script>
</body>
</html>