Unified the journeys and blogpost datastructure in the backend

This commit is contained in:
Josh-Dev-Quest 2026-03-28 16:40:16 +01:00
parent 241c962faf
commit 0de91bf814
No known key found for this signature in database
8 changed files with 423 additions and 362 deletions

View File

@ -50,6 +50,50 @@ def get_user_by_id(user_id):
users = load_users() users = load_users()
return next((u for u in users if u["id"] == user_id), None) return next((u for u in users if u["id"] == user_id), None)
# Central journeys storage
JOURNEYS_FILE = os.path.join(DATA_DIR, 'journeys.json')
def load_all_journeys():
try:
if os.path.exists(JOURNEYS_FILE):
with open(JOURNEYS_FILE, 'r') as f:
return json.load(f)
except Exception as e:
print(f"Error loading journeys: {e}")
return []
def save_all_journeys(journeys):
try:
with open(JOURNEYS_FILE, 'w') as f:
json.dump(journeys, f, indent=2)
except Exception as e:
print(f"Error saving journeys: {e}")
def get_next_journey_id(journeys):
if not journeys:
return 1
return max(j['id'] for j in journeys) + 1
def get_journey_by_id(journey_id):
journeys = load_all_journeys()
return next((j for j in journeys if j['id'] == journey_id), None)
def user_can_view_journey(journey, user_id):
if journey['owner_id'] == user_id:
return True
if journey.get('visibility') == 'public':
return True
if journey.get('visibility') == 'shared' and user_id in journey.get('shared_read', []):
return True
return False
def user_can_edit_journey(journey, user_id):
if journey['owner_id'] == user_id:
return True
if journey.get('visibility') == 'shared' and user_id in journey.get('shared_edit', []):
return True
return False
# ==================== Peruser data helpers ==================== # ==================== Peruser data helpers ====================
def get_user_data_dir(user_id): def get_user_data_dir(user_id):
@ -172,7 +216,7 @@ def me():
return jsonify({"id": user["id"], "username": user["username"]}) return jsonify({"id": user["id"], "username": user["username"]})
# ==================== Journey endpoints (protected, userspecific) ==================== # ==================== Journey helper functions ====================
def require_login(): def require_login():
if "user_id" not in session: if "user_id" not in session:
return False return False
@ -189,224 +233,142 @@ def get_journeys_for_current_user():
return None return None
return load_user_journeys(user_id) return load_user_journeys(user_id)
# ==================== Journey endpoints ====================
@app.route("/api/journeys", methods=["GET"]) @app.route('/api/journeys', methods=['GET'])
def get_journeys(): def get_journeys():
if not require_login(): if not require_login():
return jsonify({"error": "Authentication required"}), 401 return jsonify({'error': 'Authentication required'}), 401
journeys = get_journeys_for_current_user() user_id = get_current_user_id()
return jsonify(journeys) all_journeys = load_all_journeys()
result = []
for j in all_journeys:
if user_can_view_journey(j, user_id):
# Add extra flags for the frontend
j_copy = j.copy()
j_copy['can_edit'] = user_can_edit_journey(j, user_id)
result.append(j_copy)
return jsonify(result)
@app.route('/api/journeys', methods=['POST'])
def get_next_journey_id(journeys):
if not journeys:
return 1
return max(j["id"] for j in journeys) + 1
@app.route("/api/journeys", methods=["POST"])
def create_journey(): def create_journey():
if not require_login(): if not require_login():
return jsonify({"error": "Authentication required"}), 401 return jsonify({'error': 'Authentication required'}), 401
data = request.get_json() data = request.get_json()
if not data: if not data:
return jsonify({"error": "No data provided"}), 400 return jsonify({'error': 'No data provided'}), 400
title = data.get("title") title = data.get('title')
if not title: if not title:
return jsonify({"error": "Journey title is required"}), 400 return jsonify({'error': 'Journey title is required'}), 400
user_id = get_current_user_id() user_id = get_current_user_id()
journeys = get_journeys_for_current_user() journeys = load_all_journeys()
new_id = get_next_journey_id(journeys) new_id = get_next_journey_id(journeys)
new_journey = { new_journey = {
"id": new_id, 'id': new_id,
"title": title, 'owner_id': user_id,
"description": data.get("description", ""), 'title': title,
"markers": data.get("markers", []), 'description': data.get('description', ''),
"created_at": datetime.now().isoformat(), 'markers': data.get('markers', []),
'created_at': datetime.now().isoformat(),
'visibility': data.get('visibility', 'private'),
'shared_read': data.get('shared_read', []),
'shared_edit': data.get('shared_edit', []),
'comments': data.get('comments', [])
} }
journeys.append(new_journey) journeys.append(new_journey)
save_user_journeys(user_id, journeys) save_all_journeys(journeys)
return jsonify(new_journey), 201 return jsonify(new_journey), 201
@app.route('/api/journeys/<int:journey_id>', methods=['GET'])
@app.route("/api/journeys/<int:journey_id>", methods=["GET"])
def get_journey(journey_id): def get_journey(journey_id):
if not require_login(): if not require_login():
return jsonify({"error": "Authentication required"}), 401 return jsonify({'error': 'Authentication required'}), 401
journeys = get_journeys_for_current_user() user_id = get_current_user_id()
journey = next((j for j in journeys if j["id"] == journey_id), None) journey = get_journey_by_id(journey_id)
if journey is None: if journey is None:
return jsonify({"error": "Journey not found"}), 404 return jsonify({'error': 'Journey not found'}), 404
if not user_can_view_journey(journey, user_id):
return jsonify({'error': 'Access denied'}), 403
return jsonify(journey) return jsonify(journey)
@app.route('/api/journeys/<int:journey_id>', methods=['PUT'])
@app.route("/api/journeys/<int:journey_id>", methods=["PUT"])
def update_journey(journey_id): def update_journey(journey_id):
if not require_login(): if not require_login():
return jsonify({"error": "Authentication required"}), 401 return jsonify({'error': 'Authentication required'}), 401
journeys = get_journeys_for_current_user() user_id = get_current_user_id()
journey = next((j for j in journeys if j["id"] == journey_id), None) journeys = load_all_journeys()
journey = next((j for j in journeys if j['id'] == journey_id), None)
if journey is None: if journey is None:
return jsonify({"error": "Journey not found"}), 404 return jsonify({'error': 'Journey not found'}), 404
if not user_can_edit_journey(journey, user_id):
return jsonify({'error': 'Not authorized to edit this journey'}), 403
data = request.get_json() data = request.get_json()
if not data: if 'title' in data:
return jsonify({"error": "No data provided"}), 400 journey['title'] = data['title']
if 'description' in data:
journey['description'] = data['description']
if 'markers' in data:
journey['markers'] = data['markers']
if 'visibility' in data:
journey['visibility'] = data['visibility']
if 'shared_read' in data:
journey['shared_read'] = data['shared_read']
if 'shared_edit' in data:
journey['shared_edit'] = data['shared_edit']
if 'comments' in data:
journey['comments'] = data ['comments']
if "title" in data: save_all_journeys(journeys)
journey["title"] = data["title"]
if "description" in data:
journey["description"] = data["description"]
if "markers" in data:
journey["markers"] = data["markers"]
save_user_journeys(get_current_user_id(), journeys)
return jsonify(journey) return jsonify(journey)
@app.route('/api/journeys/<int:journey_id>', methods=['DELETE'])
@app.route("/api/journeys/<int:journey_id>", methods=["DELETE"])
def delete_journey(journey_id): def delete_journey(journey_id):
if not require_login(): if not require_login():
return jsonify({"error": "Authentication required"}), 401 return jsonify({'error': 'Authentication required'}), 401
journeys = get_journeys_for_current_user() user_id = get_current_user_id()
journey = next((j for j in journeys if j["id"] == journey_id), None) journeys = load_all_journeys()
journey = next((j for j in journeys if j['id'] == journey_id), None)
if journey is None: if journey is None:
return jsonify({"error": "Journey not found"}), 404 return jsonify({'error': 'Journey not found'}), 404
if journey['owner_id'] != user_id:
return jsonify({'error': 'Only the owner can delete this journey'}), 403
journeys = [j for j in journeys if j["id"] != journey_id] journeys = [j for j in journeys if j['id'] != journey_id]
save_user_journeys(get_current_user_id(), journeys) save_all_journeys(journeys)
return jsonify({"message": "Journey deleted successfully", "journey": journey}) return jsonify({'message': 'Journey deleted successfully', 'journey': journey})
# ==================== Journey endpoints (already present) ====================
# (Keep the existing journey endpoints: GET /api/journeys, POST, etc.)
# ==================== Blog Post endpoints (protected, userspecific) ==================== # ==================== Comments (stored inside journeys) ====================
def get_posts_for_current_user(): def save_journey(journey):
user_id = get_current_user_id() journeys = load_all_journeys()
if user_id is None: for i, j in enumerate(journeys):
return None if j['id'] == journey['id']:
return load_user_posts(user_id) journeys[i] = journey
def get_next_post_id(posts):
if not posts:
return 1
return max(p["id"] for p in posts) + 1
@app.route("/api/blog-posts", methods=["GET"])
def get_blog_posts():
if not require_login():
return jsonify({"error": "Authentication required"}), 401
posts = get_posts_for_current_user()
return jsonify(posts)
@app.route("/api/blog-posts/<int:post_id>", methods=["GET"])
def get_blog_post(post_id):
if not require_login():
return jsonify({"error": "Authentication required"}), 401
posts = get_posts_for_current_user()
post = next((p for p in posts if p["id"] == post_id), None)
if not post:
return jsonify({"error": "Post not found"}), 404
return jsonify(post)
@app.route("/api/blog-posts", methods=["POST"])
def create_blog_post():
if not require_login():
return jsonify({"error": "Authentication required"}), 401
data = request.get_json()
title = data.get("title")
if not title:
return jsonify({"error": "Title required"}), 400
user_id = get_current_user_id()
posts = get_posts_for_current_user()
new_id = get_next_post_id(posts)
new_post = {
"id": new_id,
"title": title,
"content": data.get("content", ""),
"journeyId": data.get("journeyId"),
"image": data.get("image"),
"author_id": user_id,
"created_at": datetime.now().isoformat(),
}
posts.append(new_post)
save_user_posts(user_id, posts)
return jsonify(new_post), 201
@app.route("/api/blog-posts/<int:post_id>", methods=["PUT"])
def update_blog_post(post_id):
if not require_login():
return jsonify({"error": "Authentication required"}), 401
posts = get_posts_for_current_user()
post = next((p for p in posts if p["id"] == post_id), None)
if not post:
return jsonify({"error": "Post not found"}), 404
data = request.get_json()
if not get_current_user_id() == data["author_id"]:
return jsonify({"error": "Wrong user"})
if "title" in data:
post["title"] = data["title"]
if "content" in data:
post["content"] = data["content"]
if "journeyId" in data:
post["journeyId"] = data["journeyId"]
if "image" in data:
post["image"] = data["image"]
save_user_posts(get_current_user_id(), posts)
return jsonify(post)
@app.route("/api/blog-posts/<int:post_id>", methods=["DELETE"])
def delete_blog_post(post_id):
if not require_login():
return jsonify({"error": "Authentication required"}), 401
posts = get_posts_for_current_user()
post = next((p for p in posts if p["id"] == post_id), None)
if not post:
return jsonify({"error": "Post not found"}), 404
posts = [p for p in posts if p["id"] != post_id]
save_user_posts(get_current_user_id(), posts)
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 break
save_user_posts(user_id, posts) save_all_journeys(journeys)
@app.route('/api/posts/<int:post_id>/comments', methods=['GET']) @app.route('/api/journeys/<int:journey_id>/comments', methods=['GET'])
def get_comments(post_id): def get_journey_comments(journey_id):
user_id = session.get('user_id') user_id = session.get('user_id')
if not user_id: if not user_id:
return jsonify({'error': 'Authentication required'}), 401 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']) journey = get_journey_by_id(journey_id)
def add_comment(post_id): if not journey:
return jsonify({'error': 'Journey not found'}), 404
if not user_can_view_journey(journey, user_id):
return jsonify({'error': 'Access denied'}), 403
return jsonify(journey.get('comments', []))
@app.route('/api/journeys/<int:journey_id>/comments', methods=['POST'])
def add_journey_comment(journey_id):
user_id = session.get('user_id') user_id = session.get('user_id')
if not user_id: if not user_id:
return jsonify({'error': 'Authentication required'}), 401 return jsonify({'error': 'Authentication required'}), 401
@ -415,21 +377,24 @@ def add_comment(post_id):
if not text: if not text:
return jsonify({'error': 'Comment text required'}), 400 return jsonify({'error': 'Comment text required'}), 400
post = get_post_by_id(user_id, post_id) journey = get_journey_by_id(journey_id)
if not post: if not journey:
return jsonify({'error': 'Post not found'}), 404 return jsonify({'error': 'Journey not found'}), 404
if not user_can_view_journey(journey, user_id):
return jsonify({'error': 'Access denied'}), 403
comment = { comment = {
'id': int(time.time() * 1000), # simple unique id 'id': int(time.time() * 1000),
'author_id': user_id, 'author_id': user_id,
'author_name': get_user_by_id(user_id)['username'], 'author_name': get_user_by_id(user_id)['username'],
'text': text, 'text': text,
'created_at': datetime.now().isoformat() 'created_at': datetime.now().isoformat(),
} }
if 'comments' not in post: if 'comments' not in journey:
post['comments'] = [] journey['comments'] = []
post['comments'].append(comment) journey['comments'].append(comment)
save_post(user_id, post) save_journey(journey)
return jsonify(comment), 201 return jsonify(comment), 201
@app.route('/api/comments/<int:comment_id>', methods=['DELETE']) @app.route('/api/comments/<int:comment_id>', methods=['DELETE'])
@ -438,16 +403,15 @@ def delete_comment(comment_id):
if not user_id: if not user_id:
return jsonify({'error': 'Authentication required'}), 401 return jsonify({'error': 'Authentication required'}), 401
# Find which post contains this comment journeys = load_all_journeys()
posts = load_user_posts(user_id) for journey in journeys:
for post in posts: if 'comments' in journey:
if 'comments' in post: for i, c in enumerate(journey['comments']):
for i, c in enumerate(post['comments']):
if c['id'] == comment_id: if c['id'] == comment_id:
# Allow deletion if current user is comment author or post author # Check permissions: comment author or journey owner can delete
if c['author_id'] == user_id or post['id'] == post.get('author_id', user_id): if c['author_id'] == user_id or journey['owner_id'] == user_id:
del post['comments'][i] del journey['comments'][i]
save_post(user_id, post) save_journey(journey)
return jsonify({'message': 'Comment deleted'}) return jsonify({'message': 'Comment deleted'})
else: else:
return jsonify({'error': 'Not authorized'}), 403 return jsonify({'error': 'Not authorized'}), 403

View File

@ -1,10 +0,0 @@
[
{
"id": 1,
"title": "test",
"content": "sfsfsfsaf",
"journeyId": "1",
"image": null,
"created_at": "2026-03-27T19:49:49.410806"
}
]

View File

@ -1,82 +1,39 @@
[ [
{ {
"id": 1, "id": 1,
"title": "test", "owner_id": 1,
"description": "sdfsf\n", "title": "Test journey",
"description": "test",
"markers": [ "markers": [
{ {
"lat": 48.22467264956519, "lat": 46.638122462379656,
"lng": 9.536132812500002, "lng": 4.806518554687501,
"title": "New Marker", "title": "New Marker",
"date": "", "date": "",
"description": "", "description": "",
"videoUrl": "" "videoUrl": ""
}, },
{ {
"lat": 49.937079756975294, "lat": 47.12621341795227,
"lng": 8.789062500000002, "lng": 6.943359375000001,
"title": "New Marker", "title": "New Marker",
"date": "", "date": "",
"description": "", "description": "",
"videoUrl": "" "videoUrl": ""
}, },
{ {
"lat": 50.583236614805905, "lat": 46.46813299215556,
"lng": 9.689941406250002, "lng": 6.7730712890625,
"title": "New Marker", "title": "New Marker",
"date": "", "date": "",
"description": "", "description": "",
"videoUrl": "" "videoUrl": ""
} }
], ],
"created_at": "2026-03-01T19:02:15.679031" "created_at": "2026-03-28T16:34:31.421684",
}, "visibility": "private",
{ "shared_read": [],
"id": 2, "shared_edit": [],
"title": "test 1", "comments": []
"description": "sdfdsfafsfsdsf",
"markers": [
{
"lat": 48.705462895790575,
"lng": 2.4334716796875,
"title": "New Marker",
"date": "",
"description": "",
"videoUrl": ""
},
{
"lat": 47.37603463349758,
"lng": 2.0654296875000004,
"title": "New Marker",
"date": "",
"description": "",
"videoUrl": ""
},
{
"lat": 47.25686404408872,
"lng": 5.020751953125,
"title": "New Marker",
"date": "",
"description": "",
"videoUrl": ""
},
{
"lat": 47.25686404408872,
"lng": 5.954589843750001,
"title": "New Marker",
"date": "",
"description": "",
"videoUrl": ""
},
{
"lat": 47.357431944587034,
"lng": 7.289428710937501,
"title": "New Marker",
"date": "",
"description": "",
"videoUrl": ""
}
],
"created_at": "2026-03-05T13:00:48.757539"
} }
] ]

View File

@ -2,18 +2,37 @@
{ {
"id": 1, "id": 1,
"title": "test", "title": "test",
"content": "ksafladjsfk", "content": "qwef",
"journeyId": "1", "journeyId": null,
"image": null, "image": null,
"created_at": "2026-03-27T21:23:39.755057", "author_id": 1,
"comments": [ "created_at": "2026-03-28T15:47:04.343616",
{ "visibility": "private",
"id": 1774703592361, "shared_read": [],
"author_id": 1, "shared_edit": []
"author_name": "josh", },
"text": "hello", {
"created_at": "2026-03-28T14:13:12.362078" "id": 2,
} "title": "another test post",
] "content": "sgadsfg",
"journeyId": null,
"image": null,
"author_id": 1,
"created_at": "2026-03-28T16:08:36.820019",
"visibility": "private",
"shared_read": [],
"shared_edit": []
},
{
"id": 3,
"title": "another post",
"content": "sfadfas",
"journeyId": null,
"image": null,
"author_id": 1,
"created_at": "2026-03-28T16:08:52.889611",
"visibility": "private",
"shared_read": [],
"shared_edit": []
} }
] ]

View File

@ -193,6 +193,28 @@
display: none; display: none;
z-index: 1100; z-index: 1100;
} }
.filter-tabs {
display: flex;
gap: var(--size-2);
}
.filter-btn {
background: var(--surface-3);
color: var(--text-2);
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: all 0.2s;
}
.filter-btn.active {
background: var(--indigo-7);
color: white;
}
.filter-btn:hover {
background: var(--surface-4);
}
</style> </style>
</head> </head>
<body> <body>
@ -200,6 +222,11 @@
<div class="container"> <div class="container">
<h1 class="site-title"><a href="map-page.html">Journey Mapper</a></h1> <h1 class="site-title"><a href="map-page.html">Journey Mapper</a></h1>
<div style="display: flex; align-items: center; gap: var(--size-4);"> <div style="display: flex; align-items: center; gap: var(--size-4);">
<div class="filter-tabs">
<button class="filter-btn active" data-filter="all">All</button>
<button class="filter-btn" data-filter="own">My Posts</button>
<button class="filter-btn" data-filter="public">Public Posts</button>
</div>
<nav class="site-nav"> <nav class="site-nav">
<a href="map-page.html">Map</a> <a href="map-page.html">Map</a>
<a href="blog-list.html" class="active">Blog</a> <a href="blog-list.html" class="active">Blog</a>
@ -212,7 +239,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.html?new" class="btn"><i class="fas fa-plus"></i> New Post</a> <a href="map-page.html" class="btn"><i class="fas fa-plus"></i> New Journey</a>
</div> </div>
<div id="posts-grid" class="posts-grid"> <div id="posts-grid" class="posts-grid">
<!-- Posts loaded dynamically --> <!-- Posts loaded dynamically -->
@ -223,47 +250,73 @@
<script src="js/auth.js"></script> <script src="js/auth.js"></script>
<script> <script>
// ==================== BLOG POSTS ==================== let allJourneys = [];
async function loadPosts() {
// ==================== LOAD JOURNEYS ====================
async function loadJourneys() {
try { try {
const res = await fetch(`${API_BASE}/blog-posts`, { credentials: 'include' }); const res = await fetch(`${API_BASE}/journeys`, { credentials: 'include' });
if (!res.ok) throw new Error('Failed to fetch posts'); if (!res.ok) throw new Error('Failed to fetch journeys');
const posts = await res.json(); const journeys = await res.json();
renderPosts(posts); allJourneys = journeys;
renderJourneys(journeys);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
document.getElementById('posts-grid').innerHTML = '<p class="empty-state">Failed to load posts. Make sure the backend is running.</p>'; document.getElementById('posts-grid').innerHTML = '<p class="empty-state">Failed to load journeys. Make sure the backend is running.</p>';
} }
} }
function renderPosts(posts) { function renderJourneys(journeys) {
const container = document.getElementById('posts-grid'); const container = document.getElementById('posts-grid');
if (!posts.length) { if (!journeys.length) {
container.innerHTML = '<p class="empty-state">No posts yet. Click "New Post" to create one.</p>'; container.innerHTML = `
<div class="empty-state">
<p>No journeys yet. Create one on the map!</p>
</div>
`;
return; return;
} }
container.innerHTML = posts.map(post => ` container.innerHTML = journeys.map(journey => `
<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>'} ${journey.image ? `<img class="post-card-image" src="${journey.image}" alt="${journey.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.html?id=${post.id}">${escapeHtml(post.title)}</a></h2> <h2 class="post-card-title"><a href="blog-post.html?id=${journey.id}">${escapeHtml(journey.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(journey.created_at).toLocaleDateString()}
${post.journeyId ? `<span style="margin-left: 12px;"><i class="fas fa-route"></i> Journey #${post.journeyId}</span>` : ''} ${journey.markers ? `<span style="margin-left: 12px;"><i class="fas fa-map-marker-alt"></i> ${journey.markers.length} chapters</span>` : ''}
${journey.visibility === 'public' ? '<span class="badge">Public</span>' : ''}
</div> </div>
<div class="post-card-excerpt">${escapeHtml(post.excerpt || post.content.substring(0, 150) + '…')}</div> <div class="post-card-excerpt">${escapeHtml(journey.description || journey.markers?.[0]?.text?.substring(0, 150) + '…')}</div>
</div> </div>
</article> </article>
`).join(''); `).join('');
} }
function filterJourneys(filter) {
if (filter === 'all') {
renderJourneys(allJourneys);
} else if (filter === 'own') {
renderJourneys(allJourneys.filter(j => j.owner_id === currentUser?.id));
} else if (filter === 'public') {
renderJourneys(allJourneys.filter(j => j.visibility === 'public'));
}
}
// ==================== INITIALIZATION ==================== // ==================== INITIALIZATION ====================
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
const authenticated = await checkAuthAndRedirect(); const authenticated = await checkAuthAndRedirect();
if (!authenticated) return; if (!authenticated) return;
updateUserMenu(); updateUserMenu();
loadPosts(); loadJourneys();
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
filterJourneys(btn.dataset.filter);
});
});
}); });
</script> </script>
</body> </body>

View File

@ -280,14 +280,14 @@
const content = document.getElementById('post-content').value.trim(); const content = document.getElementById('post-content').value.trim();
const journeyId = document.getElementById('post-journey').value.trim(); const journeyId = document.getElementById('post-journey').value.trim();
let image = document.getElementById('post-image-url').value.trim(); let image = document.getElementById('post-image-url').value.trim();
if (!title || !content) { if (!title || !content) {
showToast('Title and content are required'); showToast('Title and content are required');
return; return;
} }
const payload = { title, content, journeyId: journeyId || null, image: image || null }; const payload = { title, content, journeyId: journeyId || null, image: image || null };
try { try {
let url, method; let url, method;
if (isNew) { if (isNew) {
@ -297,7 +297,7 @@
url = `${API_BASE}/blog-posts/${currentPostId}`; url = `${API_BASE}/blog-posts/${currentPostId}`;
method = 'PUT'; method = 'PUT';
} }
const res = await fetch(url, { const res = await fetch(url, {
method, method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -307,9 +307,8 @@
if (!res.ok) throw new Error('Save failed'); if (!res.ok) throw new Error('Save failed');
const data = await res.json(); const data = await res.json();
showToast('Post saved successfully'); showToast('Post saved successfully');
if (isNew) { // Redirect to the read page
window.location.href = `blog-post-edit.html?id=${data.id}`; window.location.href = `blog-post.html?id=${data.id}`;
}
} catch (err) { } catch (err) {
showToast('Error saving post', true); showToast('Error saving post', true);
} }

View File

@ -198,6 +198,36 @@
display: none; display: none;
z-index: 1100; z-index: 1100;
} }
.chapters {
margin-top: var(--size-4);
}
.chapter {
margin-bottom: var(--size-6);
border-left: 3px solid var(--indigo-6);
padding-left: var(--size-4);
}
.chapter-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: var(--size-2);
}
.chapter-header h3 {
margin: 0;
font-size: var(--font-size-4);
}
.marker-date {
font-size: var(--font-size-1);
color: var(--gray-6);
}
.chapter-image {
max-width: 100%;
border-radius: var(--radius-2);
margin: var(--size-2) 0;
}
.chapter-content {
line-height: 1.6;
}
</style> </style>
</head> </head>
<body> <body>
@ -223,82 +253,103 @@
<script src="js/auth.js"></script> <script src="js/auth.js"></script>
<script> <script>
// ==================== GLOBALS ==================== let currentJourney = null;
let currentPost = null;
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const postId = urlParams.get('id'); const journeyId = urlParams.get('id');
// ==================== LOAD POST ==================== // ==================== LOAD JOURNEY ====================
async function loadPost() { async function loadJourney() {
if (!postId) { if (!journeyId) {
window.location.href = 'blog-list.html'; window.location.href = 'blog-list.html';
return; return;
} }
try { try {
const res = await fetch(`${API_BASE}/blog-posts/${postId}`, { credentials: 'include' }); const res = await fetch(`${API_BASE}/journeys/${journeyId}`, { credentials: 'include' });
if (!res.ok) throw new Error('Post not found'); if (!res.ok) throw new Error('Journey not found');
currentPost = await res.json(); currentJourney = await res.json();
renderPost(); renderJourney();
loadComments(); loadComments();
} catch (err) { } catch (err) {
showToast('Error loading post', true); showToast('Error loading journey', true);
setTimeout(() => window.location.href = 'blog-list.html', 2000); setTimeout(() => window.location.href = 'blog-list.html', 2000);
} }
} }
function renderPost() { function renderJourney() {
const container = document.getElementById('post-content'); const container = document.getElementById('post-content');
const isAuthor = currentUser && currentUser.id === currentPost.author_id; // we need to store author_id in post const isOwner = currentUser && currentUser.id === currentJourney.owner_id;
// For now we assume post.author_id is stored (we should add it when creating post). const canComment = currentJourney.visibility === 'public' || isOwner;
// If not, you can compare with journey owner later.
// 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 ? escapeHtml(marker.description).replace(/\n/g, '<br>') : '';
const image = marker.image ? `<img src="${marker.image}" class="chapter-image">` : '';
const video = marker.videoUrl ? `<iframe src="${marker.videoUrl}" frameborder="0" allowfullscreen></iframe>` : '';
chaptersHtml += `
<div class="chapter" data-marker-id="${marker.id || idx}">
<div class="chapter-header">
<h3>${escapeHtml(title)}</h3>
${date}
</div>
${image}
${video}
<div class="chapter-content">${content}</div>
</div>
`;
});
chaptersHtml += '</div>';
} else {
chaptersHtml = '<p>No chapters yet.</p>';
}
container.innerHTML = ` container.innerHTML = `
<article class="post-card"> <article class="post-card">
<h1 class="post-title">${escapeHtml(currentPost.title)}</h1> <h1 class="post-title">${escapeHtml(currentJourney.title)}</h1>
<div class="post-meta"> <div class="post-meta">
<i class="fas fa-calendar-alt"></i> ${new Date(currentPost.created_at).toLocaleDateString()} <i class="fas fa-calendar-alt"></i> ${new Date(currentJourney.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>` : ''} ${currentJourney.visibility === 'public' ? '<span class="badge">Public</span>' : ''}
</div> </div>
${currentPost.image ? `<img class="post-image" src="${currentPost.image}" alt="${currentPost.title}">` : ''} ${currentJourney.image ? `<img class="post-image" src="${currentJourney.image}" alt="${currentJourney.title}">` : ''}
<div class="post-content">${formatContent(currentPost.content)}</div> <div class="post-description">${escapeHtml(currentJourney.description).replace(/\n/g, '<br>')}</div>
${isAuthor ? ` ${chaptersHtml}
${isOwner ? `
<div style="margin-top: var(--size-4); display: flex; gap: var(--size-2);"> <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> <a href="map-page.html?journey=${currentJourney.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> <button id="delete-journey-btn" class="btn btn-danger btn-sm"><i class="fas fa-trash"></i> Delete</button>
</div> </div>
` : ''} ` : ''}
</article> </article>
`; `;
if (isAuthor) { if (isOwner) {
document.getElementById('delete-post-btn')?.addEventListener('click', deletePost); document.getElementById('delete-journey-btn')?.addEventListener('click', deleteJourney);
} }
} }
function formatContent(text) { async function deleteJourney() {
// Simple markdown-like: convert newlines to <br> if (!confirm('Delete this journey permanently? All chapters and comments will be lost.')) return;
return escapeHtml(text).replace(/\n/g, '<br>');
}
async function deletePost() {
if (!confirm('Delete this post permanently?')) return;
try { try {
const res = await fetch(`${API_BASE}/blog-posts/${currentPost.id}`, { const res = await fetch(`${API_BASE}/journeys/${currentJourney.id}`, {
method: 'DELETE', method: 'DELETE',
credentials: 'include' credentials: 'include'
}); });
if (!res.ok) throw new Error('Delete failed'); if (!res.ok) throw new Error('Delete failed');
showToast('Post deleted'); showToast('Journey deleted');
setTimeout(() => window.location.href = 'blog-list.html', 1000); setTimeout(() => window.location.href = 'blog-list.html', 1000);
} catch (err) { } catch (err) {
showToast('Error deleting post', true); showToast('Error deleting journey', true);
} }
} }
// ==================== COMMENTS ==================== // ==================== COMMENTS ====================
async function loadComments() { async function loadComments() {
try { try {
const res = await fetch(`${API_BASE}/posts/${postId}/comments`, { credentials: 'include' }); const res = await fetch(`${API_BASE}/journeys/${journeyId}/comments`, { credentials: 'include' });
if (!res.ok) throw new Error('Failed to load comments'); if (!res.ok) throw new Error('Failed to load comments');
const comments = await res.json(); const comments = await res.json();
renderComments(comments); renderComments(comments);
@ -306,41 +357,43 @@
console.error(err); console.error(err);
} }
} }
function renderComments(comments) { function renderComments(comments) {
const container = document.getElementById('comments-section'); const container = document.getElementById('comments-section');
const isOwner = currentUser && currentUser.id === currentJourney.owner_id;
const canComment = currentJourney.visibility === 'public' || isOwner;
if (!comments.length) { if (!comments.length) {
container.innerHTML = ` container.innerHTML = `
<h3><i class="fas fa-comments"></i> Comments</h3> <h3><i class="fas fa-comments"></i> Comments</h3>
<p class="empty-state">No comments yet. Be the first to comment!</p> <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>'} ${canComment && currentUser ? getCommentFormHtml() : (!currentUser ? '<p><a href="login.html">Login</a> to comment.</p>' : '')}
`; `;
if (currentUser) attachCommentForm(); if (canComment && currentUser) attachCommentForm();
return; return;
} }
let commentsHtml = '<h3><i class="fas fa-comments"></i> Comments</h3>'; let commentsHtml = '<h3><i class="fas fa-comments"></i> Comments</h3>';
comments.forEach(comment => { comments.forEach(comment => {
const isOwner = currentUser && (currentUser.id === comment.author_id || (currentPost && currentPost.author_id === currentUser.id)); const isOwnerOrAuthor = currentUser && (currentUser.id === comment.author_id || currentUser.id === currentJourney.owner_id);
commentsHtml += ` commentsHtml += `
<div class="comment" data-comment-id="${comment.id}"> <div class="comment" data-comment-id="${comment.id}">
<div class="comment-meta"> <div class="comment-meta">
<strong>${escapeHtml(comment.author_name)}</strong> ${new Date(comment.created_at).toLocaleString()} <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>` : ''} ${isOwnerOrAuthor ? `<button class="delete-comment" data-id="${comment.id}"><i class="fas fa-trash-alt"></i> Delete</button>` : ''}
</div> </div>
<p class="comment-text">${escapeHtml(comment.text)}</p> <p class="comment-text">${escapeHtml(comment.text)}</p>
</div> </div>
`; `;
}); });
container.innerHTML = commentsHtml + (currentUser ? getCommentFormHtml() : '<p><a href="login.html">Login</a> to comment.</p>'); container.innerHTML = commentsHtml + (canComment && currentUser ? getCommentFormHtml() : (canComment ? '<p><a href="login.html">Login</a> to comment.</p>' : ''));
// Attach delete handlers
document.querySelectorAll('.delete-comment').forEach(btn => { document.querySelectorAll('.delete-comment').forEach(btn => {
btn.addEventListener('click', () => deleteComment(parseInt(btn.dataset.id))); btn.addEventListener('click', () => deleteComment(parseInt(btn.dataset.id)));
}); });
if (currentUser) attachCommentForm(); if (canComment && currentUser) attachCommentForm();
} }
function getCommentFormHtml() { function getCommentFormHtml() {
return ` return `
<div class="comment-form"> <div class="comment-form">
@ -349,11 +402,11 @@
</div> </div>
`; `;
} }
function attachCommentForm() { function attachCommentForm() {
document.getElementById('submit-comment')?.addEventListener('click', submitComment); document.getElementById('submit-comment')?.addEventListener('click', submitComment);
} }
async function submitComment() { async function submitComment() {
const text = document.getElementById('comment-text').value.trim(); const text = document.getElementById('comment-text').value.trim();
if (!text) { if (!text) {
@ -361,21 +414,20 @@
return; return;
} }
try { try {
const res = await fetch(`${API_BASE}/posts/${postId}/comments`, { const res = await fetch(`${API_BASE}/journeys/${journeyId}/comments`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }), body: JSON.stringify({ text }),
credentials: 'include' credentials: 'include'
}); });
if (!res.ok) throw new Error('Failed to post comment'); if (!res.ok) throw new Error('Failed to post comment');
const newComment = await res.json();
showToast('Comment posted'); showToast('Comment posted');
loadComments(); // refresh list loadComments();
} catch (err) { } catch (err) {
showToast('Error posting comment', true); showToast('Error posting comment', true);
} }
} }
async function deleteComment(commentId) { async function deleteComment(commentId) {
if (!confirm('Delete this comment?')) return; if (!confirm('Delete this comment?')) return;
try { try {
@ -390,13 +442,13 @@
showToast('Error deleting comment', true); showToast('Error deleting comment', true);
} }
} }
// ==================== INIT ==================== // ==================== INIT ====================
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
const authenticated = await checkAuthAndRedirect(); const authenticated = await checkAuthAndRedirect();
if (!authenticated) return; if (!authenticated) return;
updateUserMenu(); updateUserMenu();
loadPost(); loadJourney();
}); });
</script> </script>
</body> </body>

View File

@ -630,6 +630,14 @@
required required
/> />
</div> </div>
<div class="form-group">
<label for="journey-visibility"><i class="fas fa-lock"></i> Visibility</label>
<select id="journey-visibility">
<option value="private">Private (only you)</option>
<option value="public">Public (anyone can view)</option>
<option value="shared">Shared (with specific users)</option>
</select>
</div>
<div class="form-group"> <div class="form-group">
<label for="journey-description" <label for="journey-description"
><i class="fas fa-align-left"></i> ><i class="fas fa-align-left"></i>
@ -1149,8 +1157,10 @@
videoUrl: content.videoUrl || "", videoUrl: content.videoUrl || "",
}; };
}); });
const visibility = document.getElementById('journey-visibility').value;
const payload = { title, description, markers }; const payload = { title, description, markers, visibility };
try { try {
let res; let res;
@ -1213,6 +1223,7 @@
function loadJourneyMarkers(journey) { function loadJourneyMarkers(journey) {
currentJourney.markers.forEach((m) => map.removeLayer(m)); currentJourney.markers.forEach((m) => map.removeLayer(m));
currentJourney.markers = []; currentJourney.markers = [];
document.getElementById('journey-visibility').value = journey.visibility || 'private';
document.getElementById( document.getElementById(
"current-markers-container", "current-markers-container",
).innerHTML = ).innerHTML =
@ -1400,6 +1411,22 @@
// This function is called after auth check // This function is called after auth check
function startMap() { function startMap() {
initMap(); initMap();
// Check for journey parameter
const urlParams = new URLSearchParams(window.location.search);
const journeyToLoad = urlParams.get('journey');
if (journeyToLoad) {
// Fetch the journey and load its markers
fetch(`${API_BASE}/journeys/${journeyToLoad}`, { credentials: 'include' })
.then(res => res.json())
.then(journey => {
if (journey.can_edit) {
loadJourneyMarkers(journey);
} else {
showToast('You cannot edit this journey', true);
}
})
.catch(err => console.error(err));
}
bindEventListeners(); bindEventListeners();
} }
window.startMap = startMap; window.startMap = startMap;