Compare commits
3 Commits
e3724a4842
...
a9ab42eda5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9ab42eda5 | ||
|
|
0de91bf814 | ||
|
|
241c962faf |
329
backend/app.py
329
backend/app.py
@ -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_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.secret_key = "your-secret-key-here-change-in-production" # needed for sessions
|
||||
@ -49,6 +50,50 @@ def get_user_by_id(user_id):
|
||||
users = load_users()
|
||||
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
|
||||
|
||||
|
||||
# ==================== Per‑user data helpers ====================
|
||||
def get_user_data_dir(user_id):
|
||||
@ -171,7 +216,7 @@ def me():
|
||||
return jsonify({"id": user["id"], "username": user["username"]})
|
||||
|
||||
|
||||
# ==================== Journey endpoints (protected, user‑specific) ====================
|
||||
# ==================== Journey helper functions ====================
|
||||
def require_login():
|
||||
if "user_id" not in session:
|
||||
return False
|
||||
@ -188,195 +233,189 @@ def get_journeys_for_current_user():
|
||||
return None
|
||||
return load_user_journeys(user_id)
|
||||
|
||||
|
||||
@app.route("/api/journeys", methods=["GET"])
|
||||
# ==================== Journey endpoints ====================
|
||||
@app.route('/api/journeys', methods=['GET'])
|
||||
def get_journeys():
|
||||
if not require_login():
|
||||
return jsonify({"error": "Authentication required"}), 401
|
||||
journeys = get_journeys_for_current_user()
|
||||
return jsonify(journeys)
|
||||
return jsonify({'error': 'Authentication required'}), 401
|
||||
user_id = get_current_user_id()
|
||||
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)
|
||||
|
||||
|
||||
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"])
|
||||
@app.route('/api/journeys', methods=['POST'])
|
||||
def create_journey():
|
||||
if not require_login():
|
||||
return jsonify({"error": "Authentication required"}), 401
|
||||
return jsonify({'error': 'Authentication required'}), 401
|
||||
data = request.get_json()
|
||||
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:
|
||||
return jsonify({"error": "Journey title is required"}), 400
|
||||
return jsonify({'error': 'Journey title is required'}), 400
|
||||
|
||||
user_id = get_current_user_id()
|
||||
journeys = get_journeys_for_current_user()
|
||||
journeys = load_all_journeys()
|
||||
new_id = get_next_journey_id(journeys)
|
||||
|
||||
new_journey = {
|
||||
"id": new_id,
|
||||
"title": title,
|
||||
"description": data.get("description", ""),
|
||||
"markers": data.get("markers", []),
|
||||
"created_at": datetime.now().isoformat(),
|
||||
'id': new_id,
|
||||
'owner_id': user_id,
|
||||
'title': title,
|
||||
'description': data.get('description', ''),
|
||||
'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)
|
||||
save_user_journeys(user_id, journeys)
|
||||
save_all_journeys(journeys)
|
||||
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):
|
||||
if not require_login():
|
||||
return jsonify({"error": "Authentication required"}), 401
|
||||
journeys = get_journeys_for_current_user()
|
||||
journey = next((j for j in journeys if j["id"] == journey_id), None)
|
||||
return jsonify({'error': 'Authentication required'}), 401
|
||||
user_id = get_current_user_id()
|
||||
journey = get_journey_by_id(journey_id)
|
||||
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)
|
||||
|
||||
|
||||
@app.route("/api/journeys/<int:journey_id>", methods=["PUT"])
|
||||
@app.route('/api/journeys/<int:journey_id>', methods=['PUT'])
|
||||
def update_journey(journey_id):
|
||||
if not require_login():
|
||||
return jsonify({"error": "Authentication required"}), 401
|
||||
journeys = get_journeys_for_current_user()
|
||||
journey = next((j for j in journeys if j["id"] == journey_id), None)
|
||||
return jsonify({'error': 'Authentication required'}), 401
|
||||
user_id = get_current_user_id()
|
||||
journeys = load_all_journeys()
|
||||
journey = next((j for j in journeys if j['id'] == journey_id), 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()
|
||||
if not data:
|
||||
return jsonify({"error": "No data provided"}), 400
|
||||
if 'title' in data:
|
||||
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:
|
||||
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)
|
||||
save_all_journeys(journeys)
|
||||
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):
|
||||
if not require_login():
|
||||
return jsonify({"error": "Authentication required"}), 401
|
||||
journeys = get_journeys_for_current_user()
|
||||
journey = next((j for j in journeys if j["id"] == journey_id), None)
|
||||
return jsonify({'error': 'Authentication required'}), 401
|
||||
user_id = get_current_user_id()
|
||||
journeys = load_all_journeys()
|
||||
journey = next((j for j in journeys if j['id'] == journey_id), 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]
|
||||
save_user_journeys(get_current_user_id(), journeys)
|
||||
return jsonify({"message": "Journey deleted successfully", "journey": journey})
|
||||
journeys = [j for j in journeys if j['id'] != journey_id]
|
||||
save_all_journeys(journeys)
|
||||
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, user‑specific) ====================
|
||||
def get_posts_for_current_user():
|
||||
user_id = get_current_user_id()
|
||||
if user_id is None:
|
||||
return None
|
||||
return load_user_posts(user_id)
|
||||
# ==================== Comments (stored inside journeys) ====================
|
||||
def save_journey(journey):
|
||||
journeys = load_all_journeys()
|
||||
for i, j in enumerate(journeys):
|
||||
if j['id'] == journey['id']:
|
||||
journeys[i] = journey
|
||||
break
|
||||
save_all_journeys(journeys)
|
||||
|
||||
@app.route('/api/journeys/<int:journey_id>/comments', methods=['GET'])
|
||||
def get_journey_comments(journey_id):
|
||||
user_id = session.get('user_id')
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Authentication required'}), 401
|
||||
|
||||
def get_next_post_id(posts):
|
||||
if not posts:
|
||||
return 1
|
||||
return max(p["id"] for p in posts) + 1
|
||||
journey = get_journey_by_id(journey_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/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
|
||||
@app.route('/api/journeys/<int:journey_id>/comments', methods=['POST'])
|
||||
def add_journey_comment(journey_id):
|
||||
user_id = session.get('user_id')
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Authentication required'}), 401
|
||||
data = request.get_json()
|
||||
title = data.get("title")
|
||||
if not title:
|
||||
return jsonify({"error": "Title required"}), 400
|
||||
text = data.get('text')
|
||||
if not text:
|
||||
return jsonify({'error': 'Comment text required'}), 400
|
||||
|
||||
user_id = get_current_user_id()
|
||||
posts = get_posts_for_current_user()
|
||||
new_id = get_next_post_id(posts)
|
||||
journey = get_journey_by_id(journey_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
|
||||
|
||||
new_post = {
|
||||
"id": new_id,
|
||||
"title": title,
|
||||
"content": data.get("content", ""),
|
||||
"journeyId": data.get("journeyId"),
|
||||
"image": data.get("image"),
|
||||
"created_at": datetime.now().isoformat(),
|
||||
comment = {
|
||||
'id': int(time.time() * 1000),
|
||||
'author_id': user_id,
|
||||
'author_name': get_user_by_id(user_id)['username'],
|
||||
'text': text,
|
||||
'created_at': datetime.now().isoformat(),
|
||||
}
|
||||
if 'comments' not in journey:
|
||||
journey['comments'] = []
|
||||
journey['comments'].append(comment)
|
||||
save_journey(journey)
|
||||
|
||||
posts.append(new_post)
|
||||
save_user_posts(user_id, posts)
|
||||
return jsonify(new_post), 201
|
||||
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
|
||||
|
||||
@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 "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"})
|
||||
|
||||
journeys = load_all_journeys()
|
||||
for journey in journeys:
|
||||
if 'comments' in journey:
|
||||
for i, c in enumerate(journey['comments']):
|
||||
if c['id'] == comment_id:
|
||||
# Check permissions: comment author or journey owner can delete
|
||||
if c['author_id'] == user_id or journey['owner_id'] == user_id:
|
||||
del journey['comments'][i]
|
||||
save_journey(journey)
|
||||
return jsonify({'message': 'Comment deleted'})
|
||||
else:
|
||||
return jsonify({'error': 'Not authorized'}), 403
|
||||
return jsonify({'error': 'Comment not found'}), 404
|
||||
|
||||
# ==================== Health and root ====================
|
||||
@app.route("/api/journeys/health", methods=["GET"])
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"title": "test",
|
||||
"content": "sfsfsfsaf",
|
||||
"journeyId": "1",
|
||||
"image": null,
|
||||
"created_at": "2026-03-27T19:49:49.410806"
|
||||
}
|
||||
]
|
||||
@ -1,82 +1,50 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"title": "test",
|
||||
"description": "sdfsf\n",
|
||||
"owner_id": 1,
|
||||
"title": "Test journey",
|
||||
"description": "test",
|
||||
"markers": [
|
||||
{
|
||||
"lat": 48.22467264956519,
|
||||
"lng": 9.536132812500002,
|
||||
"title": "New Marker",
|
||||
"lat": 46.638122462379656,
|
||||
"lng": 4.806518554687501,
|
||||
"title": "New test again",
|
||||
"date": "2026-03-03",
|
||||
"description": "asfasfadsfsa",
|
||||
"image": "",
|
||||
"videoUrl": "https://duckduckgo.com/?t=ffab&q=pythong+gif&ia=images&iax=images&iai=https%3A%2F%2Fmedia.tenor.com%2FfMUOPRVdSzUAAAAM%2Fpython.gif"
|
||||
},
|
||||
{
|
||||
"lat": 47.12621341795227,
|
||||
"lng": 6.943359375000001,
|
||||
"title": "safasf",
|
||||
"date": "",
|
||||
"description": "",
|
||||
"description": "sdfadsa",
|
||||
"image": "",
|
||||
"videoUrl": ""
|
||||
},
|
||||
{
|
||||
"lat": 49.937079756975294,
|
||||
"lng": 8.789062500000002,
|
||||
"title": "New Marker",
|
||||
"lat": 46.46813299215556,
|
||||
"lng": 6.7730712890625,
|
||||
"title": "asfaf",
|
||||
"date": "",
|
||||
"description": "",
|
||||
"videoUrl": ""
|
||||
},
|
||||
{
|
||||
"lat": 50.583236614805905,
|
||||
"lng": 9.689941406250002,
|
||||
"title": "New Marker",
|
||||
"date": "",
|
||||
"description": "",
|
||||
"description": "asdfsafa",
|
||||
"image": "",
|
||||
"videoUrl": ""
|
||||
}
|
||||
],
|
||||
"created_at": "2026-03-01T19:02:15.679031"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "test 1",
|
||||
"description": "sdfdsfafsfsdsf",
|
||||
"markers": [
|
||||
"created_at": "2026-03-28T16:34:31.421684",
|
||||
"visibility": "private",
|
||||
"shared_read": [],
|
||||
"shared_edit": [],
|
||||
"comments": [
|
||||
{
|
||||
"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": ""
|
||||
"id": 1774716476571,
|
||||
"author_id": 1,
|
||||
"author_name": "josh",
|
||||
"text": "safasdf",
|
||||
"created_at": "2026-03-28T17:47:56.571469"
|
||||
}
|
||||
],
|
||||
"created_at": "2026-03-05T13:00:48.757539"
|
||||
]
|
||||
}
|
||||
]
|
||||
@ -4,5 +4,11 @@
|
||||
"username": "josh",
|
||||
"password_hash": "scrypt:32768:8:1$HA70PiOwbBrIwlDq$2ab80bdc08bb3bb4214258566aded836062323380491a7f4c7f2e67bdccb8686367789f57b3c6c5eb3e2f08c8c07186f47f9c89d1e72179ddd3758b509f23fbe",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
@ -2,9 +2,37 @@
|
||||
{
|
||||
"id": 1,
|
||||
"title": "test",
|
||||
"content": "ksafladjsfk",
|
||||
"journeyId": "1",
|
||||
"content": "qwef",
|
||||
"journeyId": null,
|
||||
"image": null,
|
||||
"created_at": "2026-03-27T21:23:39.755057"
|
||||
"author_id": 1,
|
||||
"created_at": "2026-03-28T15:47:04.343616",
|
||||
"visibility": "private",
|
||||
"shared_read": [],
|
||||
"shared_edit": []
|
||||
},
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
]
|
||||
1
backend/data/users/2/journeys.json
Normal file
1
backend/data/users/2/journeys.json
Normal file
@ -0,0 +1 @@
|
||||
[]
|
||||
1
backend/data/users/2/posts.json
Normal file
1
backend/data/users/2/posts.json
Normal file
@ -0,0 +1 @@
|
||||
[]
|
||||
@ -193,6 +193,28 @@
|
||||
display: none;
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
@ -200,6 +222,11 @@
|
||||
<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);">
|
||||
<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">
|
||||
<a href="map-page.html">Map</a>
|
||||
<a href="blog-list.html" class="active">Blog</a>
|
||||
@ -212,7 +239,7 @@
|
||||
<main class="blog-container">
|
||||
<div class="blog-header">
|
||||
<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="map-page.html" class="btn"><i class="fas fa-plus"></i> New Journey</a>
|
||||
</div>
|
||||
<div id="posts-grid" class="posts-grid">
|
||||
<!-- Posts loaded dynamically -->
|
||||
@ -223,47 +250,73 @@
|
||||
|
||||
<script src="js/auth.js"></script>
|
||||
<script>
|
||||
// ==================== BLOG POSTS ====================
|
||||
async function loadPosts() {
|
||||
let allJourneys = [];
|
||||
|
||||
// ==================== LOAD JOURNEYS ====================
|
||||
async function loadJourneys() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/blog-posts`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error('Failed to fetch posts');
|
||||
const posts = await res.json();
|
||||
renderPosts(posts);
|
||||
const res = await fetch(`${API_BASE}/journeys`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error('Failed to fetch journeys');
|
||||
const journeys = await res.json();
|
||||
allJourneys = journeys;
|
||||
renderJourneys(journeys);
|
||||
} catch (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');
|
||||
if (!posts.length) {
|
||||
container.innerHTML = '<p class="empty-state">No posts yet. Click "New Post" to create one.</p>';
|
||||
if (!journeys.length) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>No journeys yet. Create one on the map!</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = posts.map(post => `
|
||||
|
||||
container.innerHTML = journeys.map(journey => `
|
||||
<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">
|
||||
<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=${journey.id}">${escapeHtml(journey.title)}</a></h2>
|
||||
<div class="post-card-meta">
|
||||
<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>` : ''}
|
||||
<i class="fas fa-calendar-alt"></i> ${new Date(journey.created_at).toLocaleDateString()}
|
||||
${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 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>
|
||||
</article>
|
||||
`).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 ====================
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const authenticated = await checkAuthAndRedirect();
|
||||
if (!authenticated) return;
|
||||
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>
|
||||
</body>
|
||||
|
||||
@ -1,369 +0,0 @@
|
||||
<!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>
|
||||
|
||||
<!-- Open Props CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/open-props" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/open-props/normalize.min.css" />
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
|
||||
|
||||
<!-- Google Fonts -->
|
||||
<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;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.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 {
|
||||
color: var(--indigo-4);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
/* User menu */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
.post-container {
|
||||
max-width: 800px;
|
||||
margin: var(--size-6) auto;
|
||||
padding: 0 var(--size-4);
|
||||
}
|
||||
.post-form {
|
||||
background: var(--surface-1);
|
||||
border-radius: var(--radius-3);
|
||||
padding: var(--size-6);
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: var(--size-4);
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: var(--size-2);
|
||||
font-weight: var(--font-weight-6);
|
||||
color: var(--gray-8);
|
||||
}
|
||||
input[type="text"],
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
padding: var(--size-2) var(--size-3);
|
||||
border: 1px solid var(--surface-4);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--surface-2);
|
||||
color: var(--text-1);
|
||||
font-family: inherit;
|
||||
font-size: var(--font-size-2);
|
||||
}
|
||||
textarea {
|
||||
min-height: 200px;
|
||||
resize: vertical;
|
||||
}
|
||||
.image-preview {
|
||||
margin-top: var(--size-2);
|
||||
max-width: 100%;
|
||||
border-radius: var(--radius-2);
|
||||
overflow: hidden;
|
||||
}
|
||||
.image-preview img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: var(--size-3);
|
||||
margin-top: var(--size-6);
|
||||
}
|
||||
.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(--gray-7);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--indigo-7);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--indigo-8);
|
||||
}
|
||||
.btn-danger {
|
||||
background: var(--red-7);
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background: var(--red-8);
|
||||
}
|
||||
.btn:hover {
|
||||
box-shadow: var(--shadow-3);
|
||||
}
|
||||
.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" class="active">Blog</a>
|
||||
</nav>
|
||||
<div class="user-menu" id="user-menu"></div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="post-container">
|
||||
<div class="post-form">
|
||||
<h2 id="form-title">Edit Post</h2>
|
||||
<form id="post-form">
|
||||
<div class="form-group">
|
||||
<label for="post-title">Title</label>
|
||||
<input type="text" id="post-title" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="post-content">Content</label>
|
||||
<textarea id="post-content" rows="10" required></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="post-journey">Associated Journey ID (optional)</label>
|
||||
<input type="text" id="post-journey" placeholder="e.g., 3" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="post-image">Image URL or Upload</label>
|
||||
<input type="text" id="post-image-url" placeholder="https://..." />
|
||||
<div style="margin: 8px 0; text-align: center">or</div>
|
||||
<input type="file" id="image-upload" accept="image/*" />
|
||||
<div class="image-preview" id="image-preview"></div>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<button type="submit" class="btn btn-primary"><i class="fas fa-save"></i> Save</button>
|
||||
<button type="button" id="delete-post" class="btn btn-danger"><i class="fas fa-trash"></i> Delete</button>
|
||||
<a href="blog-list.html" class="btn"><i class="fas fa-times"></i> Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<script src="js/auth.js"></script>
|
||||
<script>
|
||||
let currentPostId = null;
|
||||
|
||||
// ==================== POST CRUD ====================
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const postId = urlParams.get('id');
|
||||
const isNew = urlParams.has('new');
|
||||
|
||||
async function loadPost(id) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/blog-posts/${id}`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error('Post not found');
|
||||
const post = await res.json();
|
||||
currentPostId = post.id;
|
||||
document.getElementById('post-title').value = post.title;
|
||||
document.getElementById('post-content').value = post.content;
|
||||
document.getElementById('post-journey').value = post.journeyId || '';
|
||||
document.getElementById('post-image-url').value = post.image || '';
|
||||
updateImagePreview(post.image);
|
||||
document.getElementById('form-title').textContent = 'Edit Post';
|
||||
} catch (err) {
|
||||
showToast('Error loading post', true);
|
||||
}
|
||||
}
|
||||
|
||||
function updateImagePreview(url) {
|
||||
const preview = document.getElementById('image-preview');
|
||||
if (url) {
|
||||
preview.innerHTML = `<img src="${url}" alt="Preview" style="max-width:100%; border-radius: var(--radius-2);">`;
|
||||
} else {
|
||||
preview.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function savePost(event) {
|
||||
event.preventDefault();
|
||||
const title = document.getElementById('post-title').value.trim();
|
||||
const content = document.getElementById('post-content').value.trim();
|
||||
const journeyId = document.getElementById('post-journey').value.trim();
|
||||
let image = document.getElementById('post-image-url').value.trim();
|
||||
|
||||
if (!title || !content) {
|
||||
showToast('Title and content are required');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = { title, content, journeyId: journeyId || null, image: image || null };
|
||||
|
||||
try {
|
||||
let url, method;
|
||||
if (isNew) {
|
||||
url = `${API_BASE}/blog-posts`;
|
||||
method = 'POST';
|
||||
} else {
|
||||
url = `${API_BASE}/blog-posts/${currentPostId}`;
|
||||
method = 'PUT';
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!res.ok) throw new Error('Save failed');
|
||||
const data = await res.json();
|
||||
showToast('Post saved successfully');
|
||||
if (isNew) {
|
||||
window.location.href = `blog-post-edit.html?id=${data.id}`;
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Error saving post', true);
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePost() {
|
||||
if (!currentPostId) return;
|
||||
if (!confirm('Delete this post?')) return;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/blog-posts/${currentPostId}`, {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Image upload (convert to base64)
|
||||
document.getElementById('image-upload').addEventListener('change', function(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(evt) {
|
||||
const base64 = evt.target.result;
|
||||
document.getElementById('post-image-url').value = base64;
|
||||
updateImagePreview(base64);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
document.getElementById('post-form').addEventListener('submit', savePost);
|
||||
document.getElementById('delete-post').addEventListener('click', deletePost);
|
||||
|
||||
// ==================== INITIALIZATION ====================
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const authenticated = await checkAuthAndRedirect();
|
||||
if (!authenticated) return;
|
||||
updateUserMenu();
|
||||
|
||||
if (!isNew && postId) {
|
||||
loadPost(postId);
|
||||
} else if (isNew) {
|
||||
currentPostId = null;
|
||||
document.getElementById('form-title').textContent = 'New Post';
|
||||
document.getElementById('delete-post').style.display = 'none';
|
||||
} else {
|
||||
// No id and not new – redirect to list
|
||||
window.location.href = 'blog-list.html';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
455
blog-post.html
Normal file
455
blog-post.html
Normal file
@ -0,0 +1,455 @@
|
||||
<!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;
|
||||
}
|
||||
.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>
|
||||
</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>
|
||||
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 canComment = currentJourney.visibility === 'public' || isOwner;
|
||||
|
||||
// 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 = `
|
||||
<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>' : ''}
|
||||
</div>
|
||||
${currentJourney.image ? `<img class="post-image" src="${currentJourney.image}" alt="${currentJourney.title}">` : ''}
|
||||
<div class="post-description">${escapeHtml(currentJourney.description).replace(/\n/g, '<br>')}</div>
|
||||
${chaptersHtml}
|
||||
${isOwner ? `
|
||||
<div style="margin-top: var(--size-4); display: flex; gap: var(--size-2);">
|
||||
<a href="journey-edit.html?id=${currentJourney.id}" class="btn btn-sm"><i class="fas fa-edit"></i> Edit</a>
|
||||
<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);
|
||||
}
|
||||
}
|
||||
|
||||
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 = currentJourney.visibility === 'public' || isOwner;
|
||||
|
||||
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();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
501
journey-edit.html
Normal file
501
journey-edit.html
Normal file
@ -0,0 +1,501 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Edit Journey – Journey Mapper</title>
|
||||
|
||||
<!-- Open Props CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/open-props"/>
|
||||
<link rel="stylesheet" href="https://unpkg.com/open-props/normalize.min.css"/>
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
|
||||
<!-- Google Fonts -->
|
||||
<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;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.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 */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
.editor-container {
|
||||
max-width: 1000px;
|
||||
margin: var(--size-6) auto;
|
||||
padding: 0 var(--size-4);
|
||||
}
|
||||
.editor-form {
|
||||
background: var(--surface-1);
|
||||
border-radius: var(--radius-3);
|
||||
padding: var(--size-6);
|
||||
box-shadow: var(--shadow-2);
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: var(--size-4);
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: var(--size-2);
|
||||
font-weight: var(--font-weight-6);
|
||||
color: var(--gray-8);
|
||||
}
|
||||
input[type="text"],
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
padding: var(--size-2) var(--size-3);
|
||||
border: 1px solid var(--surface-4);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--surface-2);
|
||||
color: var(--text-1);
|
||||
font-family: inherit;
|
||||
font-size: var(--font-size-2);
|
||||
}
|
||||
textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
}
|
||||
.marker-card {
|
||||
background: var(--surface-2);
|
||||
border-radius: var(--radius-2);
|
||||
padding: var(--size-4);
|
||||
margin-bottom: var(--size-4);
|
||||
border: 1px solid var(--surface-4);
|
||||
position: relative;
|
||||
}
|
||||
.marker-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--size-2);
|
||||
cursor: move;
|
||||
}
|
||||
.marker-header h4 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-3);
|
||||
}
|
||||
.remove-marker {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--red-6);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-3);
|
||||
}
|
||||
.remove-marker:hover {
|
||||
color: var(--red-8);
|
||||
}
|
||||
.marker-coords {
|
||||
font-size: var(--font-size-0);
|
||||
color: var(--gray-6);
|
||||
margin-bottom: var(--size-2);
|
||||
}
|
||||
.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(--gray-7);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--indigo-7);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--indigo-8);
|
||||
}
|
||||
.btn-success {
|
||||
background: var(--green-7);
|
||||
}
|
||||
.btn-success:hover {
|
||||
background: var(--green-8);
|
||||
}
|
||||
.btn-danger {
|
||||
background: var(--red-7);
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background: var(--red-8);
|
||||
}
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--surface-4);
|
||||
color: var(--text-2);
|
||||
box-shadow: none;
|
||||
}
|
||||
.btn-outline:hover {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: var(--size-3);
|
||||
margin-top: var(--size-6);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.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="editor-container">
|
||||
<div class="editor-form">
|
||||
<h2 id="form-title">Edit Journey</h2>
|
||||
<form id="journey-form">
|
||||
<div class="form-group">
|
||||
<label for="journey-title">Title</label>
|
||||
<input type="text" id="journey-title" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="journey-description">Description</label>
|
||||
<textarea id="journey-description" rows="4"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="journey-visibility">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>
|
||||
|
||||
<h3><i class="fas fa-map-marker-alt"></i> Chapters (Markers)</h3>
|
||||
<div id="markers-container"></div>
|
||||
<div class="button-group">
|
||||
<button type="button" id="add-marker" class="btn btn-outline"><i class="fas fa-plus"></i> Add Chapter</button>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button type="submit" class="btn btn-primary"><i class="fas fa-save"></i> Save Journey</button>
|
||||
<a href="blog-list.html" class="btn"><i class="fas fa-times"></i> Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<script src="js/auth.js"></script>
|
||||
<script>
|
||||
// ==================== GLOBALS ====================
|
||||
let currentJourneyId = null;
|
||||
let markersData = [];
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// ==================== 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="3" placeholder="Write about this chapter...">${escapeHtml(marker.description || '')}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Image URL</label>
|
||||
<input type="text" class="marker-image" data-index="${idx}" value="${escapeHtml(marker.image || '')}" placeholder="https://...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Video URL</label>
|
||||
<input type="text" class="marker-video" data-index="${idx}" value="${escapeHtml(marker.videoUrl || '')}" placeholder="https://youtu.be/...">
|
||||
</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;
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('.marker-image').forEach(input => {
|
||||
input.addEventListener('input', (e) => {
|
||||
const idx = parseInt(input.dataset.index);
|
||||
markersData[idx].image = input.value;
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('.marker-video').forEach(input => {
|
||||
input.addEventListener('input', (e) => {
|
||||
const idx = parseInt(input.dataset.index);
|
||||
markersData[idx].videoUrl = input.value;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 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;
|
||||
document.getElementById('journey-title').value = journey.title;
|
||||
document.getElementById('journey-description').value = journey.description || '';
|
||||
document.getElementById('journey-visibility').value = journey.visibility || 'private';
|
||||
markersData = journey.markers || [];
|
||||
console.log('Markers data loaded:', markersData);
|
||||
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 || '',
|
||||
image: m.image || '',
|
||||
videoUrl: m.videoUrl || ''
|
||||
}));
|
||||
|
||||
const payload = { title, description, markers, 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: '',
|
||||
image: '',
|
||||
videoUrl: ''
|
||||
});
|
||||
renderMarkers();
|
||||
}
|
||||
|
||||
// ==================== INITIALIZATION ====================
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const authenticated = await checkAuthAndRedirect();
|
||||
if (!authenticated) return;
|
||||
updateUserMenu();
|
||||
|
||||
if (!isNew && journeyId) {
|
||||
loadJourney(journeyId);
|
||||
} else if (isNew) {
|
||||
currentJourneyId = null;
|
||||
document.getElementById('form-title').textContent = 'New Journey';
|
||||
markersData = [];
|
||||
renderMarkers();
|
||||
} else {
|
||||
window.location.href = 'blog-list.html';
|
||||
}
|
||||
|
||||
document.getElementById('journey-form').addEventListener('submit', saveJourney);
|
||||
document.getElementById('add-marker').addEventListener('click', addMarker);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -630,6 +630,14 @@
|
||||
required
|
||||
/>
|
||||
</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">
|
||||
<label for="journey-description"
|
||||
><i class="fas fa-align-left"></i>
|
||||
@ -1001,41 +1009,33 @@
|
||||
activeMarker = null;
|
||||
}
|
||||
|
||||
function saveMarker() {
|
||||
async function saveMarker() {
|
||||
if (!activeMarker) return;
|
||||
const title =
|
||||
document.getElementById("marker-title").value ||
|
||||
"Untitled";
|
||||
const title = document.getElementById("marker-title").value || "Untitled";
|
||||
const date = document.getElementById("marker-date").value;
|
||||
const description =
|
||||
document.getElementById("marker-text").value;
|
||||
const description = document.getElementById("marker-text").value;
|
||||
const videoUrl = document.getElementById("video-url").value;
|
||||
|
||||
activeMarker.setTitle(title);
|
||||
|
||||
// Update marker's tooltip title and popup
|
||||
activeMarker.options.title = title;
|
||||
activeMarker.setPopupContent(`<strong>${title}</strong>`);
|
||||
activeMarker._content = {
|
||||
title,
|
||||
date,
|
||||
description,
|
||||
videoUrl,
|
||||
};
|
||||
|
||||
|
||||
// Store content for saving
|
||||
activeMarker._content = { title, date, description, videoUrl };
|
||||
|
||||
// Update the list item in the sidebar
|
||||
const latlng = activeMarker.getLatLng();
|
||||
const items = document.querySelectorAll(
|
||||
"#current-markers-container .marker-item",
|
||||
);
|
||||
const items = document.querySelectorAll("#current-markers-container .marker-item");
|
||||
for (let item of items) {
|
||||
if (
|
||||
item.dataset.lat == latlng.lat &&
|
||||
item.dataset.lng == latlng.lng
|
||||
) {
|
||||
item.querySelector(".marker-title").textContent =
|
||||
title;
|
||||
if (item.dataset.lat == latlng.lat && item.dataset.lng == latlng.lng) {
|
||||
item.querySelector(".marker-title").textContent = title;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
closeModal();
|
||||
showToast("Marker updated");
|
||||
showToast("Saving journey...");
|
||||
await saveJourneyToAPI();
|
||||
}
|
||||
|
||||
function deleteMarkerFromMap() {
|
||||
@ -1149,8 +1149,10 @@
|
||||
videoUrl: content.videoUrl || "",
|
||||
};
|
||||
});
|
||||
|
||||
const visibility = document.getElementById('journey-visibility').value;
|
||||
|
||||
const payload = { title, description, markers };
|
||||
const payload = { title, description, markers, visibility };
|
||||
|
||||
try {
|
||||
let res;
|
||||
@ -1213,6 +1215,7 @@
|
||||
function loadJourneyMarkers(journey) {
|
||||
currentJourney.markers.forEach((m) => map.removeLayer(m));
|
||||
currentJourney.markers = [];
|
||||
document.getElementById('journey-visibility').value = journey.visibility || 'private';
|
||||
document.getElementById(
|
||||
"current-markers-container",
|
||||
).innerHTML =
|
||||
@ -1400,6 +1403,22 @@
|
||||
// This function is called after auth check
|
||||
function startMap() {
|
||||
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();
|
||||
}
|
||||
window.startMap = startMap;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user