Compare commits

..

No commits in common. "main" and "user-backend" have entirely different histories.

28 changed files with 1464 additions and 4596 deletions

3
.gitignore vendored
View File

@ -1,6 +1,3 @@
Lession_material
*.DS_Store
.aider*
backend/uploads/
backend/data/*.json
.venv

154
README.md
View File

@ -1,151 +1,37 @@
# Travel Journal
## Project Description
On this website you can document your travel stories: where you were, what you did, and which videos and pictures you took. Keep your journal for yourself or share it with others.
# Travel Journal (Draft README)
## Projectdescription
On this website you can document your travelstories.
Where you where, what you did and what videos and pictures you took.
Keep your journal and share it with friends or just log it for your memory.
## Pages
### Map Page
Create a journey by placing markers on a map. Each marker can contain notes, dates, images, and videos.
Here you can create a journey through setting marker on a map.
At each marker you can write notes or implement images.
### Blog Page
View the notes from one journey as a blog post. Markers are shown as chapters in date order.
Here you can look at all notes from one journey.
The markers have a date so they are in a time order.
### Journey Explorer
Browse and open existing journeys.
Here you can search for all journeys. Like overview page from a blogwebsite.
## Features
- Create and edit journeys
- Add markers/chapters to a map
- Add text, dates, videos, and images to markers
- View journeys as blog posts
- Register, log in, and save journeys through the backend
- Blog entry
- Log places
- Add pictures to specific places
## Setup And Running
## Installation
TBD
The app has two parts that must run at the same time:
- Backend API: Flask, running on `http://127.0.0.1:5000`
- Frontend: static HTML/CSS/JavaScript, running on `http://127.0.0.1:8000`
Use `127.0.0.1` for both frontend and backend while developing. Mixing `localhost` and `127.0.0.1` can cause login/session cookies to fail.
### Software Requirements
- Python 3.12 or newer
- A terminal such as PowerShell, Command Prompt, or Git Bash
- Optional: `uv` for dependency management
### Backend Setup With uv
From the project root:
```powershell
cd backend
uv sync
uv run python app.py
```
The backend should show:
```text
Running on http://127.0.0.1:5000
```
Leave this terminal running.
### Backend Setup With requirements.txt
If you are not using `uv`, create and activate a virtual environment manually.
From the project root:
```powershell
cd backend
python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
python app.py
```
For mac/linux
```bash
cd backend
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python app.py
```
If the virtual environment already exists, you can skip `python -m venv .venv`.
The backend should show:
```text
Running on http://127.0.0.1:5000
```
Leave this terminal running.
### Frontend Setup
Open a second terminal from the project root:
Windows:
```powershell
.\backend\.venv\Scripts\Activate.ps1
python -m http.server 8000
```
Linux/Mac
```bash
source backend/.venv/bin/activate
python -m http.server 8000
```
Then open:
```text
http://127.0.0.1:8000/login.html
```
Keep both terminals open while using the app.
### API Documentation
The backend API is documented with an OpenAPI/Swagger specification:
```text
backend/openapi.yaml
```
To view the interactive documentation, open [Swagger Editor](https://editor.swagger.io/)
and import `backend/openapi.yaml`. To make requests, start the backend and use
`http://127.0.0.1:5000`. Browser cookie policies may make authenticated requests
from the hosted Swagger Editor unreliable, so the app or an API client may be
easier for testing authenticated endpoints.
The API uses a Flask session cookie for authentication. Register or log in first,
then include the returned `session` cookie in authenticated requests. The
specification documents all authentication, user, journey, comment, image upload,
uploaded-file, and health endpoints.
### Daily Development Workflow
1. Start the backend in `backend/`.
2. Start the frontend server from the project root.
3. Open `http://127.0.0.1:8000/login.html`.
4. Register or log in.
5. Use the map and blog pages.
If backend code changes, restart the backend with `Ctrl + C` and run `python app.py` again.
If frontend HTML/CSS/JS changes, usually a hard browser reload is enough: `Ctrl + Shift + R`.
## Tech Stack
## Tech-Stack
- HTML
- CSS
- JavaScript
- Python
- Flask
- Leaflet / OpenStreetMap
- WebGL - MapLibre
## Members
- Flepp Stiafen
- Kohler Joshua
- Ruegger Andre
- Rüegger André

View File

@ -1,27 +0,0 @@
@startuml travel-journey-container
!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml
LAYOUT_LEFT_RIGHT()
LAYOUT_WITH_LEGEND()
title Container diagram — Travel Journey Blog & Map Application
Person(traveler, "Traveler", "Writes blog posts, edits journeys, views maps")
System_Boundary(app_sys, "Travel Journey Platform") {
Container(web_app, "Frontend SPA", "HTML, CSS, JavaScript", "Static web pages served by browser.\nKey modules: auth, blog, journey-edit, map, markdown.")
Container(backend_api, "Backend API", "Python (Flask/FastAPI)", "app.py — serves REST endpoints\nfor users, journeys, uploads.\nReads/writes local JSON & file storage.")
ContainerDb(json_data, "JSON Data Store", "File System", "journeys.json, users.json\n— structured travel & user data.")
ContainerDb(uploads, "Uploads Storage", "File System", "backend/uploads/\n— user-uploaded images & videos.")
}
Rel(traveler, web_app, "Views pages, interacts via browser", "HTTPS")
Rel(web_app, backend_api, "REST API calls (JSON)", "HTTPS")
Rel(backend_api, json_data, "Reads/Writes", "File I/O")
Rel(backend_api, uploads, "Stores/Retrieves", "File I/O")
@enduml

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

View File

@ -1,140 +1,21 @@
import os
import time
import json
import uuid
import math
import re
from datetime import datetime
from werkzeug.security import generate_password_hash, check_password_hash
from werkzeug.utils import secure_filename
from flask import Flask, request, jsonify, session, send_from_directory
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
CORS(app, supports_credentials=True) # allow cookies
# Directories
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, "data")
DATA_DIR = "data"
USERS_FILE = os.path.join(DATA_DIR, "users.json")
JOURNEYS_FILE = os.path.join(DATA_DIR, 'journeys.json')
UPLOAD_DIR = os.path.join(BASE_DIR, "uploads")
ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}
VALID_VISIBILITIES = {"private", "public", "shared"}
MAX_MARKERS_PER_JOURNEY = 500
os.makedirs(DATA_DIR, exist_ok=True)
os.makedirs(UPLOAD_DIR, exist_ok=True)
class ValidationError(ValueError):
pass
@app.errorhandler(ValidationError)
def handle_validation_error(error):
return jsonify({"error": str(error)}), 400
def get_json_object():
data = request.get_json(silent=True)
if not isinstance(data, dict):
raise ValidationError("Request body must be a JSON object")
return data
def clean_text(value, field, max_length, required=False, strip=True):
if value is None:
value = ""
if not isinstance(value, str):
raise ValidationError(f"{field} must be text")
# Keep newlines/tabs for Markdown, but remove non-printing control characters.
value = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", value)
if strip:
value = value.strip()
if required and not value:
raise ValidationError(f"{field} is required")
if len(value) > max_length:
raise ValidationError(f"{field} must be at most {max_length} characters")
return value
def clean_number(value, field, minimum, maximum):
if isinstance(value, bool):
raise ValidationError(f"{field} must be a number")
try:
value = float(value)
except (TypeError, ValueError):
raise ValidationError(f"{field} must be a number")
if not math.isfinite(value) or not minimum <= value <= maximum:
raise ValidationError(f"{field} must be between {minimum} and {maximum}")
return value
def clean_visibility(value):
if value not in VALID_VISIBILITIES:
raise ValidationError("Invalid journey visibility")
return value
def clean_images(images):
if images is None:
return []
if not isinstance(images, list):
raise ValidationError("Marker images must be a list")
if len(images) > 20:
raise ValidationError("A marker can contain at most 20 images")
cleaned = []
for image in images:
if isinstance(image, str):
cleaned.append(clean_text(image, "Image URL", 2048, required=True))
continue
if not isinstance(image, dict):
raise ValidationError("Invalid marker image")
url = clean_text(image.get("url"), "Image URL", 2048, required=True)
cleaned.append({
"filename": clean_text(image.get("filename"), "Image filename", 255),
"originalName": clean_text(image.get("originalName"), "Original image name", 255),
"url": url,
})
return cleaned
def clean_markers(markers):
if markers is None:
return []
if not isinstance(markers, list):
raise ValidationError("Markers must be a list")
if len(markers) > MAX_MARKERS_PER_JOURNEY:
raise ValidationError(f"A journey can contain at most {MAX_MARKERS_PER_JOURNEY} markers")
cleaned = []
for marker in markers:
if not isinstance(marker, dict):
raise ValidationError("Invalid marker")
cleaned.append({
"lat": clean_number(marker.get("lat"), "Marker latitude", -90, 90),
"lng": clean_number(marker.get("lng"), "Marker longitude", -180, 180),
"title": clean_text(marker.get("title"), "Marker title", 200),
"date": clean_text(marker.get("date"), "Marker date", 20),
"description": clean_text(marker.get("description"), "Marker description", 10000),
"images": clean_images(marker.get("images", [])),
})
return cleaned
# ==================== User helpers ====================
def require_login():
if "user_id" not in session:
return False
return True
def get_current_user_id():
return session.get("user_id")
def load_users():
try:
if os.path.exists(USERS_FILE):
@ -169,41 +50,62 @@ def get_user_by_id(user_id):
return next((u for u in users if u["id"] == user_id), None)
def public_user(user):
return {
"id": user["id"],
"username": user["username"],
}
# ==================== Peruser data helpers ====================
def get_user_data_dir(user_id):
user_dir = os.path.join(DATA_DIR, "users", str(user_id))
os.makedirs(user_dir, exist_ok=True)
return user_dir
def normalize_user_ids(user_ids):
users = load_users()
valid_ids = {user["id"] for user in users}
normalized = []
for user_id in user_ids or []:
try:
user_id = int(user_id)
except (TypeError, ValueError):
continue
if user_id in valid_ids and user_id not in normalized:
normalized.append(user_id)
return normalized
def load_user_journeys(user_id):
file_path = os.path.join(get_user_data_dir(user_id), "journeys.json")
try:
if os.path.exists(file_path):
with open(file_path, "r") as f:
return json.load(f)
except Exception as e:
print(f"Error loading journeys for user {user_id}: {e}")
return []
def save_user_journeys(user_id, journeys):
file_path = os.path.join(get_user_data_dir(user_id), "journeys.json")
try:
with open(file_path, "w") as f:
json.dump(journeys, f, indent=2)
except Exception as e:
print(f"Error saving journeys for user {user_id}: {e}")
def load_user_posts(user_id):
file_path = os.path.join(get_user_data_dir(user_id), "posts.json")
try:
if os.path.exists(file_path):
with open(file_path, "r") as f:
return json.load(f)
except Exception as e:
print(f"Error loading posts for user {user_id}: {e}")
return []
def save_user_posts(user_id, posts):
file_path = os.path.join(get_user_data_dir(user_id), "posts.json")
try:
with open(file_path, "w") as f:
json.dump(posts, f, indent=2)
except Exception as e:
print(f"Error saving posts for user {user_id}: {e}")
def allowed_image_file(filename):
if "." not in filename:
return False
extension = filename.rsplit(".", 1)[1].lower()
return extension in ALLOWED_IMAGE_EXTENSIONS
# ==================== Authentication endpoints ====================
@app.route("/api/register", methods=["POST"])
def register():
data = get_json_object()
username = clean_text(data.get("username"), "Username", 50, required=True)
password = clean_text(data.get("password"), "Password", 200, required=True, strip=False)
if len(password) < 4:
raise ValidationError("Password must be at least 4 characters")
data = request.get_json()
username = data.get("username")
password = data.get("password")
if not username or not password:
return jsonify({"error": "Username and password required"}), 400
# Check if username already exists
if get_user_by_username(username):
@ -221,7 +123,11 @@ def register():
}
users.append(new_user)
save_users(users)
# Create empty data files for the new user
save_user_journeys(new_id, [])
save_user_posts(new_id, [])
# Log the user in automatically
session["user_id"] = new_id
@ -232,9 +138,9 @@ def register():
@app.route("/api/login", methods=["POST"])
def login():
data = get_json_object()
username = clean_text(data.get("username"), "Username", 50, required=True)
password = clean_text(data.get("password"), "Password", 200, required=True, strip=False)
data = request.get_json()
username = data.get("username")
password = data.get("password")
user = get_user_by_username(username)
if not user or not check_password_hash(user["password_hash"], password):
@ -265,278 +171,212 @@ def me():
return jsonify({"id": user["id"], "username": user["username"]})
@app.route("/api/users", methods=["GET"])
def get_users():
# ==================== Journey endpoints (protected, userspecific) ====================
def require_login():
if "user_id" not in session:
return False
return True
def get_current_user_id():
return session.get("user_id")
def get_journeys_for_current_user():
user_id = get_current_user_id()
if user_id is None:
return None
return load_user_journeys(user_id)
@app.route("/api/journeys", methods=["GET"])
def get_journeys():
if not require_login():
return jsonify({"error": "Authentication required"}), 401
current_user_id = get_current_user_id()
users = [
public_user(user)
for user in load_users()
if user["id"] != current_user_id
]
return jsonify(users)
journeys = get_journeys_for_current_user()
return jsonify(journeys)
# ==================== Journey helper functions ====================
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
if journey.get('visibility') == 'shared' and user_id in journey.get('shared_edit', []):
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
return max(j["id"] for j in journeys) + 1
# ==================== Journey endpoints ====================
@app.route('/api/journeys', methods=['GET'])
def get_journeys():
if not require_login():
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)
@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
data = get_json_object()
return jsonify({"error": "Authentication required"}), 401
data = request.get_json()
if not data:
return jsonify({"error": "No data provided"}), 400
title = clean_text(data.get('title'), "Journey title", 200, required=True)
title = data.get("title")
if not title:
return jsonify({"error": "Journey title is required"}), 400
user_id = get_current_user_id()
journeys = load_all_journeys()
journeys = get_journeys_for_current_user()
new_id = get_next_journey_id(journeys)
new_journey = {
'id': new_id,
'owner_id': user_id,
'title': title,
'description': clean_text(data.get('description'), "Journey description", 20000),
'markers': clean_markers(data.get('markers', [])),
'created_at': datetime.now().isoformat(),
'visibility': clean_visibility(data.get('visibility', 'private')),
'shared_read': normalize_user_ids(data.get('shared_read', [])),
'shared_edit': normalize_user_ids(data.get('shared_edit', [])),
'comments': []
"id": new_id,
"title": title,
"description": data.get("description", ""),
"markers": data.get("markers", []),
"created_at": datetime.now().isoformat(),
}
journeys.append(new_journey)
save_all_journeys(journeys)
save_user_journeys(user_id, 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
user_id = get_current_user_id()
journey = get_journey_by_id(journey_id)
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)
if journey is None:
return jsonify({'error': 'Journey not found'}), 404
if not user_can_view_journey(journey, user_id):
return jsonify({'error': 'Access denied'}), 403
return jsonify({"error": "Journey not found"}), 404
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
user_id = get_current_user_id()
journeys = load_all_journeys()
journey = next((j for j in journeys if j['id'] == journey_id), None)
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)
if journey is None:
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
return jsonify({"error": "Journey not found"}), 404
data = get_json_object()
if 'title' in data:
journey['title'] = clean_text(data['title'], "Journey title", 200, required=True)
if 'description' in data:
journey['description'] = clean_text(data['description'], "Journey description", 20000)
if 'markers' in data:
journey['markers'] = clean_markers(data['markers'])
sharing_fields = {'visibility', 'shared_read', 'shared_edit'}
if sharing_fields.intersection(data.keys()):
if journey['owner_id'] != user_id:
return jsonify({'error': 'Only the owner can update sharing settings'}), 403
if 'visibility' in data:
journey['visibility'] = clean_visibility(data['visibility'])
if 'shared_read' in data:
journey['shared_read'] = normalize_user_ids(data['shared_read'])
if 'shared_edit' in data:
journey['shared_edit'] = normalize_user_ids(data['shared_edit'])
save_all_journeys(journeys)
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"]
save_user_journeys(get_current_user_id(), 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
user_id = get_current_user_id()
journeys = load_all_journeys()
journey = next((j for j in journeys if j['id'] == journey_id), None)
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)
if journey is None:
return jsonify({'error': 'Journey not found'}), 404
if journey['owner_id'] != user_id:
return jsonify({'error': 'Only the owner can delete this journey'}), 403
return jsonify({"error": "Journey not found"}), 404
journeys = [j for j in journeys if j['id'] != journey_id]
save_all_journeys(journeys)
return jsonify({'message': 'Journey deleted successfully', 'journey': journey})
# ==================== 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
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/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 = get_json_object()
text = clean_text(data.get('text'), "Comment", 2000, required=True)
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
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)
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
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
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})
# ==================== Uploads ====================
@app.route("/api/uploads/images", methods=["POST"])
def upload_images():
# ==================== Blog Post endpoints (protected, userspecific) ====================
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)
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
files = request.files.getlist("images")
if not files:
return jsonify({"error": "No images provided"}), 400
uploaded = []
for file in files:
if not file or file.filename == "":
continue
if not allowed_image_file(file.filename):
return jsonify({"error": f"Unsupported image type: {file.filename}"}), 400
original_name = secure_filename(file.filename)
extension = original_name.rsplit(".", 1)[1].lower()
filename = f"{uuid.uuid4().hex}.{extension}"
file.save(os.path.join(UPLOAD_DIR, filename))
uploaded.append(
{
"filename": filename,
"originalName": original_name,
"url": f"/uploads/{filename}",
}
)
if not uploaded:
return jsonify({"error": "No valid images provided"}), 400
return jsonify({"images": uploaded}), 201
posts = get_posts_for_current_user()
return jsonify(posts)
@app.route("/uploads/<path:filename>")
def uploaded_file(filename):
return send_from_directory(UPLOAD_DIR, filename)
@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"),
"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 "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"})
# ==================== Health and root ====================
@app.route("/api/journeys/health", methods=["GET"])

View File

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

View File

@ -0,0 +1,82 @@
[
{
"id": 1,
"title": "test",
"description": "sdfsf\n",
"markers": [
{
"lat": 48.22467264956519,
"lng": 9.536132812500002,
"title": "New Marker",
"date": "",
"description": "",
"videoUrl": ""
},
{
"lat": 49.937079756975294,
"lng": 8.789062500000002,
"title": "New Marker",
"date": "",
"description": "",
"videoUrl": ""
},
{
"lat": 50.583236614805905,
"lng": 9.689941406250002,
"title": "New Marker",
"date": "",
"description": "",
"videoUrl": ""
}
],
"created_at": "2026-03-01T19:02:15.679031"
},
{
"id": 2,
"title": "test 1",
"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"
}
]

8
backend/data/users.json Normal file
View File

@ -0,0 +1,8 @@
[
{
"id": 1,
"username": "josh",
"password_hash": "scrypt:32768:8:1$HA70PiOwbBrIwlDq$2ab80bdc08bb3bb4214258566aded836062323380491a7f4c7f2e67bdccb8686367789f57b3c6c5eb3e2f08c8c07186f47f9c89d1e72179ddd3758b509f23fbe",
"created_at": "2026-03-27T20:32:43.107028"
}
]

View File

@ -0,0 +1,42 @@
[
{
"id": 1,
"title": "test",
"description": "11",
"markers": [
{
"lat": 48.356249029540734,
"lng": 4.866943359375,
"title": "New Marker",
"date": "",
"description": "",
"videoUrl": ""
},
{
"lat": 46.961510504873104,
"lng": 9.371337890625002,
"title": "New Marker",
"date": "",
"description": "",
"videoUrl": ""
},
{
"lat": 45.51404592560427,
"lng": 11.656494140625002,
"title": "New Marker",
"date": "",
"description": "",
"videoUrl": ""
},
{
"lat": 43.52465500687188,
"lng": 11.162109375,
"title": "New Marker",
"date": "",
"description": "",
"videoUrl": ""
}
],
"created_at": "2026-03-27T21:49:26.885353"
}
]

View File

@ -0,0 +1,10 @@
[
{
"id": 1,
"title": "test",
"content": "ksafladjsfk",
"journeyId": "1",
"image": null,
"created_at": "2026-03-27T21:23:39.755057"
}
]

View File

@ -1,942 +0,0 @@
openapi: 3.0.3
info:
title: Journey Mapper API
version: 1.0.0
description: |
Backend API for the Journey Mapper application.
Authentication uses a Flask session cookie. After registering or logging in,
clients must send the returned `session` cookie with authenticated requests.
Browser requests from the frontend must use credentials, for example
`fetch(url, { credentials: "include" })`.
Journey and marker descriptions support Markdown. Raw HTML is stored as text
and escaped by the frontend when rendered.
servers:
- url: http://127.0.0.1:5000
description: Local development backend
tags:
- name: Authentication
- name: Users
- name: Journeys
- name: Comments
- name: Uploads
- name: System
paths:
/api/register:
post:
tags: [Authentication]
summary: Register a user
description: Creates a user, starts a session, and returns the new public user data.
operationId: registerUser
security: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AuthRequest"
example:
username: traveller
password: secret123
responses:
"201":
description: Registration successful
headers:
Set-Cookie:
description: Flask session cookie used for authenticated requests.
schema:
type: string
content:
application/json:
schema:
$ref: "#/components/schemas/AuthResponse"
example:
id: 1
username: traveller
message: Registration successful
"400":
$ref: "#/components/responses/ValidationError"
"409":
description: Username is already taken
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
error: Username already taken
/api/login:
post:
tags: [Authentication]
summary: Log in
description: Validates the credentials and starts a session.
operationId: loginUser
security: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AuthRequest"
example:
username: traveller
password: secret123
responses:
"200":
description: Login successful
headers:
Set-Cookie:
description: Flask session cookie used for authenticated requests.
schema:
type: string
content:
application/json:
schema:
$ref: "#/components/schemas/AuthResponse"
example:
id: 1
username: traveller
message: Login successful
"400":
$ref: "#/components/responses/ValidationError"
"401":
description: Invalid username or password
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
error: Invalid username or password
/api/logout:
post:
tags: [Authentication]
summary: Log out
description: Removes the user ID from the current session. This operation also succeeds if no session exists.
operationId: logoutUser
security: []
responses:
"200":
description: Logged out
content:
application/json:
schema:
$ref: "#/components/schemas/Message"
example:
message: Logged out
/api/me:
get:
tags: [Authentication]
summary: Get the current user
operationId: getCurrentUser
responses:
"200":
description: Current public user data
content:
application/json:
schema:
$ref: "#/components/schemas/User"
"401":
$ref: "#/components/responses/NotLoggedIn"
/api/users:
get:
tags: [Users]
summary: List other users
description: Returns every public user except the currently logged-in user.
operationId: listUsers
responses:
"200":
description: Public users
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/User"
"401":
$ref: "#/components/responses/AuthenticationRequired"
/api/journeys:
get:
tags: [Journeys]
summary: List visible journeys
description: |
Returns journeys owned by the current user, public journeys, and journeys
shared with the current user. Each returned journey contains a `can_edit`
flag calculated for the current user.
operationId: listJourneys
responses:
"200":
description: Visible journeys
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/JourneyListItem"
"401":
$ref: "#/components/responses/AuthenticationRequired"
post:
tags: [Journeys]
summary: Create a journey
description: |
Creates a journey owned by the current user. Supplied comments and
server-managed fields such as IDs and timestamps are ignored.
operationId: createJourney
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/JourneyCreate"
responses:
"201":
description: Journey created
content:
application/json:
schema:
$ref: "#/components/schemas/Journey"
"400":
$ref: "#/components/responses/ValidationError"
"401":
$ref: "#/components/responses/AuthenticationRequired"
/api/journeys/{journeyId}:
parameters:
- $ref: "#/components/parameters/JourneyId"
get:
tags: [Journeys]
summary: Get a journey
operationId: getJourney
responses:
"200":
description: Journey data
content:
application/json:
schema:
$ref: "#/components/schemas/Journey"
"401":
$ref: "#/components/responses/AuthenticationRequired"
"403":
description: The current user cannot view this journey
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
error: Access denied
"404":
$ref: "#/components/responses/JourneyNotFound"
put:
tags: [Journeys]
summary: Update a journey
description: |
The owner and users with shared edit access may update the title,
description, and markers. Only the owner may update visibility or sharing.
Omitted fields remain unchanged.
operationId: updateJourney
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/JourneyUpdate"
responses:
"200":
description: Journey updated
content:
application/json:
schema:
$ref: "#/components/schemas/Journey"
"400":
$ref: "#/components/responses/ValidationError"
"401":
$ref: "#/components/responses/AuthenticationRequired"
"403":
description: The current user cannot edit the journey or its sharing settings
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
examples:
cannotEdit:
value:
error: Not authorized to edit this journey
sharingOwnerOnly:
value:
error: Only the owner can update sharing settings
"404":
$ref: "#/components/responses/JourneyNotFound"
delete:
tags: [Journeys]
summary: Delete a journey
description: Only the journey owner may delete it.
operationId: deleteJourney
responses:
"200":
description: Journey deleted
content:
application/json:
schema:
type: object
required: [message, journey]
properties:
message:
type: string
journey:
$ref: "#/components/schemas/Journey"
example:
message: Journey deleted successfully
journey:
id: 1
owner_id: 1
title: Switzerland
description: My summer journey
markers: []
created_at: "2026-06-07T18:30:00"
visibility: private
shared_read: []
shared_edit: []
comments: []
"401":
$ref: "#/components/responses/AuthenticationRequired"
"403":
description: Only the journey owner may delete it
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
error: Only the owner can delete this journey
"404":
$ref: "#/components/responses/JourneyNotFound"
/api/journeys/{journeyId}/comments:
parameters:
- $ref: "#/components/parameters/JourneyId"
get:
tags: [Comments]
summary: List journey comments
description: The current user must be able to view the journey.
operationId: listJourneyComments
responses:
"200":
description: Journey comments
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Comment"
"401":
$ref: "#/components/responses/AuthenticationRequired"
"403":
description: The current user cannot view the journey
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
error: Access denied
"404":
$ref: "#/components/responses/JourneyNotFound"
post:
tags: [Comments]
summary: Add a journey comment
description: The current user must be able to view the journey.
operationId: addJourneyComment
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CommentCreate"
example:
text: This looks like a wonderful trip.
responses:
"201":
description: Comment created
content:
application/json:
schema:
$ref: "#/components/schemas/Comment"
"400":
$ref: "#/components/responses/ValidationError"
"401":
$ref: "#/components/responses/AuthenticationRequired"
"403":
description: The current user cannot view the journey
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
error: Access denied
"404":
$ref: "#/components/responses/JourneyNotFound"
/api/comments/{commentId}:
parameters:
- $ref: "#/components/parameters/CommentId"
delete:
tags: [Comments]
summary: Delete a comment
description: A comment may be deleted by its author or the journey owner.
operationId: deleteComment
responses:
"200":
description: Comment deleted
content:
application/json:
schema:
$ref: "#/components/schemas/Message"
example:
message: Comment deleted
"401":
$ref: "#/components/responses/AuthenticationRequired"
"403":
description: The current user cannot delete the comment
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
error: Not authorized
"404":
description: Comment not found
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
error: Comment not found
/api/uploads/images:
post:
tags: [Uploads]
summary: Upload marker images
description: |
Uploads one or more images using repeated `images` form fields. Accepted
filename extensions are `.png`, `.jpg`, `.jpeg`, `.gif`, and `.webp`.
The returned image objects can be included in a marker's `images` array.
operationId: uploadImages
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [images]
properties:
images:
type: array
items:
type: string
format: binary
responses:
"201":
description: Images uploaded
content:
application/json:
schema:
type: object
required: [images]
properties:
images:
type: array
items:
$ref: "#/components/schemas/Image"
"400":
description: No valid images were provided or an extension is unsupported
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
examples:
noImages:
value:
error: No images provided
unsupported:
value:
error: "Unsupported image type: notes.txt"
"401":
$ref: "#/components/responses/AuthenticationRequired"
/uploads/{filename}:
parameters:
- name: filename
in: path
required: true
description: Server-generated image filename returned by the upload endpoint.
schema:
type: string
example: 79ce8a0727d846f4bffb1fbf94365191.jpg
get:
tags: [Uploads]
summary: Get an uploaded image
description: Uploaded images are publicly accessible.
operationId: getUploadedImage
security: []
responses:
"200":
description: Image file
content:
image/png:
schema:
type: string
format: binary
image/jpeg:
schema:
type: string
format: binary
image/gif:
schema:
type: string
format: binary
image/webp:
schema:
type: string
format: binary
"404":
description: Image not found
/api/journeys/health:
get:
tags: [System]
summary: Check API health
operationId: getHealth
security: []
responses:
"200":
description: Backend is healthy
content:
application/json:
schema:
$ref: "#/components/schemas/Health"
/:
get:
tags: [System]
summary: Get the API landing page
description: Returns a small HTML page confirming that the backend is running.
operationId: getApiLandingPage
security: []
responses:
"200":
description: API landing page
content:
text/html:
schema:
type: string
components:
securitySchemes:
cookieAuth:
type: apiKey
in: cookie
name: session
description: Flask session cookie returned after registration or login.
parameters:
JourneyId:
name: journeyId
in: path
required: true
description: Journey ID
schema:
type: integer
minimum: 1
CommentId:
name: commentId
in: path
required: true
description: Millisecond timestamp used as the comment ID
schema:
type: integer
format: int64
minimum: 1
responses:
ValidationError:
description: Request validation failed
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
examples:
missingTitle:
value:
error: Journey title is required
invalidCoordinates:
value:
error: Marker latitude must be between -90 and 90
AuthenticationRequired:
description: Authentication is required
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
error: Authentication required
NotLoggedIn:
description: There is no valid current user session
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
examples:
noSession:
value:
error: Not logged in
missingUser:
value:
error: User not found
JourneyNotFound:
description: Journey not found
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
example:
error: Journey not found
schemas:
Error:
type: object
required: [error]
additionalProperties: false
properties:
error:
type: string
Message:
type: object
required: [message]
additionalProperties: false
properties:
message:
type: string
User:
type: object
required: [id, username]
additionalProperties: false
properties:
id:
type: integer
minimum: 1
username:
type: string
maxLength: 50
AuthRequest:
type: object
required: [username, password]
properties:
username:
type: string
minLength: 1
maxLength: 50
password:
type: string
format: password
minLength: 4
maxLength: 200
AuthResponse:
type: object
required: [id, username, message]
additionalProperties: false
properties:
id:
type: integer
minimum: 1
username:
type: string
maxLength: 50
message:
type: string
Image:
type: object
required: [filename, originalName, url]
additionalProperties: false
properties:
filename:
type: string
maxLength: 255
description: Server-generated filename.
originalName:
type: string
maxLength: 255
description: Sanitized original filename.
url:
type: string
maxLength: 2048
description: Relative or absolute image URL.
example: /uploads/79ce8a0727d846f4bffb1fbf94365191.jpg
MarkerImageInput:
description: A marker image may be supplied as a URL string or a full image object.
oneOf:
- type: string
minLength: 1
maxLength: 2048
- $ref: "#/components/schemas/Image"
Marker:
type: object
required: [lat, lng, title, date, description, images]
additionalProperties: false
properties:
lat:
type: number
format: double
minimum: -90
maximum: 90
lng:
type: number
format: double
minimum: -180
maximum: 180
title:
type: string
maxLength: 200
date:
type: string
maxLength: 20
description: Date text, normally formatted as `YYYY-MM-DD`.
example: "2026-06-07"
description:
type: string
maxLength: 10000
description: Markdown-supported marker description.
images:
type: array
maxItems: 20
items:
$ref: "#/components/schemas/MarkerImageInput"
MarkerInput:
type: object
required: [lat, lng]
properties:
lat:
type: number
format: double
minimum: -90
maximum: 90
lng:
type: number
format: double
minimum: -180
maximum: 180
title:
type: string
maxLength: 200
default: ""
date:
type: string
maxLength: 20
default: ""
example: "2026-06-07"
description:
type: string
maxLength: 10000
default: ""
description: Markdown-supported marker description.
images:
type: array
maxItems: 20
default: []
items:
$ref: "#/components/schemas/MarkerImageInput"
Comment:
type: object
required: [id, author_id, author_name, text, created_at]
additionalProperties: false
properties:
id:
type: integer
format: int64
minimum: 1
author_id:
type: integer
minimum: 1
author_name:
type: string
maxLength: 50
text:
type: string
minLength: 1
maxLength: 2000
created_at:
type: string
format: date-time
CommentCreate:
type: object
required: [text]
properties:
text:
type: string
minLength: 1
maxLength: 2000
Visibility:
type: string
enum: [private, public, shared]
default: private
Journey:
type: object
required:
- id
- owner_id
- title
- description
- markers
- created_at
- visibility
- shared_read
- shared_edit
- comments
properties:
id:
type: integer
minimum: 1
owner_id:
type: integer
minimum: 1
title:
type: string
minLength: 1
maxLength: 200
description:
type: string
maxLength: 20000
description: Markdown-supported journey description.
markers:
type: array
maxItems: 500
items:
$ref: "#/components/schemas/Marker"
created_at:
type: string
format: date-time
visibility:
$ref: "#/components/schemas/Visibility"
shared_read:
type: array
uniqueItems: true
items:
type: integer
minimum: 1
shared_edit:
type: array
uniqueItems: true
items:
type: integer
minimum: 1
comments:
type: array
items:
$ref: "#/components/schemas/Comment"
JourneyListItem:
allOf:
- $ref: "#/components/schemas/Journey"
- type: object
required: [can_edit]
properties:
can_edit:
type: boolean
description: Whether the current user may edit this journey.
JourneyCreate:
type: object
required: [title]
properties:
title:
type: string
minLength: 1
maxLength: 200
description:
type: string
maxLength: 20000
default: ""
description: Markdown-supported journey description.
markers:
type: array
maxItems: 500
default: []
items:
$ref: "#/components/schemas/MarkerInput"
visibility:
$ref: "#/components/schemas/Visibility"
shared_read:
type: array
default: []
description: Invalid, duplicate, and unknown user IDs are silently removed.
items:
type: integer
minimum: 1
shared_edit:
type: array
default: []
description: Invalid, duplicate, and unknown user IDs are silently removed.
items:
type: integer
minimum: 1
JourneyUpdate:
type: object
properties:
title:
type: string
minLength: 1
maxLength: 200
description:
type: string
maxLength: 20000
description: Markdown-supported journey description.
markers:
type: array
maxItems: 500
items:
$ref: "#/components/schemas/MarkerInput"
visibility:
$ref: "#/components/schemas/Visibility"
shared_read:
type: array
description: Owner-only field. Invalid, duplicate, and unknown user IDs are silently removed.
items:
type: integer
minimum: 1
shared_edit:
type: array
description: Owner-only field. Invalid, duplicate, and unknown user IDs are silently removed.
items:
type: integer
minimum: 1
Health:
type: object
required: [status, timestamp]
additionalProperties: false
properties:
status:
type: string
enum: [healthy]
timestamp:
type: string
format: date-time
security:
- cookieAuth: []

Binary file not shown.

View File

@ -14,10 +14,7 @@
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- Responsive Design -->
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/responsive.css">
<style>
* { box-sizing: border-box; }
body {
@ -196,39 +193,6 @@
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);
}
.badge {
display: inline-flex;
align-items: center;
margin-left: var(--size-2);
padding: 2px var(--size-2);
border-radius: var(--radius-2);
background: var(--indigo-1);
color: var(--indigo-8);
font-size: var(--font-size-0);
font-weight: var(--font-weight-6);
}
</style>
</head>
<body>
@ -236,12 +200,6 @@
<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="shared">Shared With Me</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>
@ -254,7 +212,7 @@
<main class="blog-container">
<div class="blog-header">
<h1><i class="fas fa-newspaper"></i> Blog Posts</h1>
<a href="map-page.html" class="btn"><i class="fas fa-plus"></i> New Journey</a>
<a href="blog-post-edit.html?new" class="btn"><i class="fas fa-plus"></i> New Post</a>
</div>
<div id="posts-grid" class="posts-grid">
<!-- Posts loaded dynamically -->
@ -264,6 +222,49 @@
<div id="toast" class="toast"></div>
<script src="js/auth.js"></script>
<script src="js/blog-list.js"></script>
<script>
// ==================== BLOG POSTS ====================
async function loadPosts() {
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);
} 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>';
}
}
function renderPosts(posts) {
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>';
return;
}
container.innerHTML = posts.map(post => `
<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>'}
<div class="post-card-content">
<h2 class="post-card-title"><a href="blog-post-edit.html?id=${post.id}">${escapeHtml(post.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>` : ''}
</div>
<div class="post-card-excerpt">${escapeHtml(post.excerpt || post.content.substring(0, 150) + '…')}</div>
</div>
</article>
`).join('');
}
// ==================== INITIALIZATION ====================
document.addEventListener('DOMContentLoaded', async () => {
const authenticated = await checkAuthAndRedirect();
if (!authenticated) return;
updateUserMenu();
loadPosts();
});
</script>
</body>
</html>
</html>

369
blog-post-edit.html Normal file
View File

@ -0,0 +1,369 @@
<!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>

View File

@ -1,317 +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>
<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">
<!-- Responsive Design -->
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/responsive.css">
<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);
}
.badge {
display: inline-flex;
align-items: center;
margin-left: var(--size-2);
padding: 2px var(--size-2);
border-radius: var(--radius-2);
background: var(--indigo-1);
color: var(--indigo-8);
font-size: var(--font-size-0);
font-weight: var(--font-weight-6);
}
.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-images {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--size-3);
margin: var(--size-3) 0;
}
.chapter-image-item {
position: relative;
border-radius: var(--radius-2);
overflow: hidden;
box-shadow: var(--shadow-2);
background: var(--surface-2);
}
.chapter-images img {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
display: block;
}
.remove-chapter-image {
position: absolute;
top: var(--size-1);
right: var(--size-1);
width: 28px;
height: 28px;
border: none;
border-radius: var(--radius-round);
background: rgba(0, 0, 0, 0.72);
color: white;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-2);
}
.chapter-image-tools {
display: flex;
align-items: center;
gap: var(--size-2);
flex-wrap: wrap;
margin: var(--size-3) 0;
}
.chapter-image-input {
max-width: 360px;
}
.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 src="js/markdown.js"></script>
<script src="js/blog-post.js"></script>
</body>
</html>

View File

@ -1,193 +0,0 @@
/* ===== RESPONSIVE DESIGN ===== */
/* Large desktop screens (≥ 1400px) */
@media (min-width: 1400px) {
.blog-container,
.editor-container,
.post-container {
max-width: 1400px;
}
.post-card {
transition: transform 0.2s;
}
.post-card:hover {
transform: translateY(-6px);
}
}
/* Tablet landscape and small desktops (768px 1399px) */
@media (max-width: 1399px) and (min-width: 768px) {
.sidebar {
width: 280px;
}
.blog-container,
.editor-container,
.post-container {
padding: 0 var(--size-4);
}
.posts-grid {
grid-template-columns: repeat(2, 1fr);
gap: var(--size-5);
}
}
/* Tablet portrait (≤ 768px) */
@media (max-width: 768px) {
/* General spacing */
.blog-container,
.editor-container,
.post-container {
margin: var(--size-4) auto;
padding: 0 var(--size-3);
}
.post-card,
.editor-form,
.post-form {
padding: var(--size-4);
}
.post-title {
font-size: var(--font-size-5);
}
/* Blog list single column */
.posts-grid {
grid-template-columns: 1fr;
gap: var(--size-4);
}
.post-card-image {
height: 180px;
}
/* Header stacking */
.site-header .container {
flex-direction: column;
gap: var(--size-3);
}
.site-header .container > div {
justify-content: center;
flex-wrap: wrap;
}
.filter-tabs {
order: 2;
margin-top: var(--size-2);
}
.site-nav {
order: 1;
}
.user-menu {
order: 3;
margin-left: 0;
}
/* Journey edit page markers stack */
.marker-card {
padding: var(--size-3);
}
.marker-header h4 {
font-size: var(--font-size-2);
}
/* Buttons full width on mobile for better touch */
.button-group {
flex-direction: column;
}
.button-group .btn,
.button-group a.btn {
width: 100%;
justify-content: center;
}
/* Map page adjustments */
.sidebar {
position: fixed;
top: 0;
left: 0;
height: 100dvh;
width: 85%;
max-width: 300px;
transform: translateX(-100%);
transition: transform 0.3s;
box-shadow: var(--shadow-5);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
z-index: 10;
}
.sidebar:not(.collapsed) {
transform: translateX(0);
}
/* Map controls */
.map-controls {
position: absolute;
top: var(--size-4);
right: var(--size-4);
display: flex;
flex-direction: column;
gap: var(--size-2);
z-index: 400;
}
.mode-indicator {
top: var(--size-4);
left: var(--size-4);
font-size: var(--font-size-0);
padding: var(--size-1) var(--size-2);
}
/* Blog post chapters */
.chapter {
padding-left: var(--size-3);
margin-bottom: var(--size-4);
}
.chapter-header {
flex-direction: column;
align-items: flex-start;
gap: var(--size-1);
}
.chapter-header h3 {
font-size: var(--font-size-3);
}
/* Comments */
.comment-meta {
font-size: var(--font-size-0);
flex-wrap: wrap;
}
.delete-comment {
margin-left: 0;
margin-top: var(--size-1);
display: inline-block;
}
/* Login page */
.login-container {
width: 90%;
padding: var(--size-4);
margin: var(--size-4);
}
}
/* Extra small devices (≤ 480px) */
@media (max-width: 480px) {
.post-title {
font-size: var(--font-size-4);
}
.post-meta {
font-size: var(--font-size-0);
}
.chapter-header h3 {
font-size: var(--font-size-3);
}
.marker-card .form-group label {
font-size: var(--font-size-0);
}
.btn {
padding: var(--size-2) var(--size-3);
font-size: var(--font-size-1);
}
.toast {
bottom: var(--size-2);
right: var(--size-2);
left: var(--size-2);
width: auto;
text-align: center;
}
}

View File

@ -1,263 +1,3 @@
/* App-wide theme tokens.
Dark is the default. The data-theme attribute is set by js/auth.js. */
:root {
color-scheme: dark;
--app-bg: #121820;
--app-panel: #1c232b;
--app-panel-soft: #242d37;
--app-border: #46515f;
--app-text: #f4f7fb;
--app-text-muted: #b8c4d3;
--app-link: #8cb4ff;
--app-accent: #5d7cff;
--app-focus: #8cb4ff;
--surface-1: var(--app-panel);
--surface-2: var(--app-panel-soft);
--surface-3: #303a46;
--surface-4: var(--app-border);
--text-1: var(--app-text);
--text-2: var(--app-text-muted);
}
html[data-theme="light"] {
color-scheme: light;
--app-bg: #f8fafc;
--app-panel: #ffffff;
--app-panel-soft: #f1f5f9;
--app-border: #cbd5e1;
--app-text: #17202a;
--app-text-muted: #526174;
--app-link: #3157d5;
--app-accent: #3157d5;
--app-focus: #3157d5;
--surface-1: var(--app-panel);
--surface-2: var(--app-panel-soft);
--surface-3: #e2e8f0;
--surface-4: var(--app-border);
--text-1: var(--app-text);
--text-2: var(--app-text-muted);
}
@media (prefers-color-scheme: light) {
html[data-theme="system"] {
color-scheme: light;
--app-bg: #f8fafc;
--app-panel: #ffffff;
--app-panel-soft: #f1f5f9;
--app-border: #cbd5e1;
--app-text: #17202a;
--app-text-muted: #526174;
--app-link: #3157d5;
--app-accent: #3157d5;
--app-focus: #3157d5;
--surface-1: var(--app-panel);
--surface-2: var(--app-panel-soft);
--surface-3: #e2e8f0;
--surface-4: var(--app-border);
--text-1: var(--app-text);
--text-2: var(--app-text-muted);
}
}
body,
.post-card,
.editor-form,
.login-container,
.comments-section,
.marker-card,
.sharing-panel,
.sharing-user,
.post,
.comment-item,
.sidebar .about {
background: var(--app-panel) !important;
color: var(--app-text) !important;
}
body {
background: var(--app-bg) !important;
}
.site-header {
background: var(--app-panel) !important;
border-color: var(--app-border) !important;
}
.site-title a,
.site-nav a:hover,
.site-nav a.active,
a {
color: var(--app-link) !important;
}
.site-nav a,
.user-menu .username,
.post-meta,
.post-card-meta,
.post-card-excerpt,
.marker-date,
.marker-coords,
.comment-meta,
.sharing-help,
.empty-share-list,
.timeline-event .date {
color: var(--app-text-muted) !important;
}
.post-title,
.post-card-title a,
.chapter-header h3,
.login-header h1,
label,
.marker-header h4,
.post h3,
.sidebar h3,
.chapter-content,
.post-description,
.comment-text {
color: var(--app-text) !important;
}
input,
select,
textarea {
background: var(--app-panel-soft) !important;
border-color: var(--app-border) !important;
color: var(--app-text) !important;
}
input::placeholder,
textarea::placeholder {
color: var(--app-text-muted) !important;
}
input[type="file"] {
background: var(--app-panel-soft) !important;
color: var(--app-text) !important;
}
input[type="file"]::file-selector-button {
border: 1px solid var(--app-border);
border-radius: 6px;
background: var(--app-panel);
color: var(--app-text);
padding: 0.35rem 0.7rem;
margin-right: 0.75rem;
cursor: pointer;
}
input[type="file"]::file-selector-button:hover {
background: var(--surface-3);
}
.badge {
background: color-mix(in srgb, var(--app-accent) 18%, transparent) !important;
color: var(--app-text) !important;
}
input:focus,
select:focus,
textarea:focus {
outline: 2px solid var(--app-focus);
outline-offset: 2px;
}
.theme-switcher {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.theme-switcher label {
color: var(--app-text-muted) !important;
font-size: 0.85rem;
margin: 0;
}
.theme-select {
width: auto;
min-width: 6.5rem;
padding: 0.35rem 0.55rem;
border-radius: 6px;
font-size: 0.9rem;
}
.theme-switcher-floating {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 1200;
background: var(--app-panel) !important;
border: 1px solid var(--app-border);
border-radius: 8px;
padding: 0.45rem 0.6rem;
box-shadow: var(--shadow-3);
}
.markdown-content {
color: var(--app-text) !important;
line-height: 1.65;
}
.markdown-content > *:first-child {
margin-top: 0;
}
.markdown-content > *:last-child {
margin-bottom: 0;
}
.markdown-content h3,
.markdown-content h4,
.markdown-content h5 {
color: var(--app-text) !important;
margin: var(--size-3) 0 var(--size-2);
}
.markdown-content p,
.markdown-content li {
color: var(--app-text) !important;
}
.markdown-content a {
color: var(--app-link) !important;
}
.markdown-content img {
max-width: 100%;
height: auto;
border-radius: 6px;
margin: var(--size-2) 0;
}
.markdown-content code {
background: var(--surface-3);
border: 1px solid var(--app-border);
border-radius: 4px;
padding: 0.1rem 0.3rem;
}
.markdown-content pre {
overflow-x: auto;
background: var(--surface-3);
border: 1px solid var(--app-border);
border-radius: 6px;
padding: var(--size-3);
}
.markdown-preview {
margin-top: var(--size-2);
padding: var(--size-3);
border: 1px solid var(--app-border);
border-radius: 6px;
background: var(--app-panel-soft) !important;
}
.markdown-empty {
color: var(--app-text-muted) !important;
font-style: italic;
}
/* Mobile-first approach */
.map-container {
height: 100vh;
@ -282,8 +22,7 @@ textarea:focus {
border-left: 3px solid #3498db;
padding: 15px 20px;
margin: 20px 0;
background-color: var(--app-panel-soft);
color: var(--app-text);
background-color: #f9f9f9;
}
.timeline-event .date {
@ -293,7 +32,7 @@ textarea:focus {
.timeline-event .location {
font-size: 0.9em;
color: var(--app-text-muted);
color: #2c3e50;
}
.images-container {

View File

@ -1,387 +0,0 @@
<!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">
<!-- Responsive Design -->
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/responsive.css">
<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);
}
.sharing-panel {
display: none;
background: var(--surface-2);
border: 1px solid var(--surface-4);
border-radius: var(--radius-2);
padding: var(--size-4);
margin-bottom: var(--size-4);
}
.sharing-panel.visible {
display: block;
}
.sharing-panel h3 {
margin-top: 0;
margin-bottom: var(--size-2);
font-size: var(--font-size-3);
}
.sharing-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--size-4);
}
.sharing-user-list {
display: grid;
gap: var(--size-2);
}
.sharing-user {
display: flex;
align-items: center;
gap: var(--size-2);
padding: var(--size-2);
border-radius: var(--radius-2);
background: var(--surface-1);
}
.sharing-user input {
width: auto;
}
.sharing-help {
color: var(--gray-6);
font-size: var(--font-size-1);
margin-top: 0;
}
.empty-share-list {
color: var(--gray-6);
font-style: italic;
margin: 0;
}
label {
display: block;
margin-bottom: var(--size-2);
font-weight: var(--font-weight-6);
color: var(--gray-8);
}
input[type="text"],
input[type="date"],
input[type="file"],
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);
}
.image-preview-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
gap: var(--size-2);
margin-top: var(--size-2);
}
.image-preview {
position: relative;
aspect-ratio: 1;
border-radius: var(--radius-2);
overflow: hidden;
border: 1px solid var(--surface-4);
background: var(--surface-1);
}
.image-preview img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.remove-image {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
border: none;
border-radius: var(--radius-round);
background: rgba(0, 0, 0, 0.7);
color: white;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.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" maxlength="200" required>
</div>
<div class="form-group">
<label for="journey-description">Description</label>
<textarea id="journey-description" rows="4" maxlength="20000"></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>
<div id="sharing-panel" class="sharing-panel">
<h3><i class="fas fa-user-group"></i> Share With Users</h3>
<p class="sharing-help">Viewers can open the journey. Editors can also update its chapters, markers, and images.</p>
<div class="sharing-grid">
<div>
<label>Can View</label>
<div id="shared-read-users" class="sharing-user-list"></div>
</div>
<div>
<label>Can Edit</label>
<div id="shared-edit-users" class="sharing-user-list"></div>
</div>
</div>
</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 src="js/markdown.js"></script>
<script src="js/journey-edit.js"></script>
</body>
</html>

View File

@ -1,57 +1,6 @@
const API_BASE = "http://127.0.0.1:5000/api";
const THEME_STORAGE_KEY = "journey-theme";
const DEFAULT_THEME = "dark";
const VALID_THEMES = ["dark", "light", "system"];
let currentUser = null;
function getThemePreference() {
const storedTheme = localStorage.getItem(THEME_STORAGE_KEY);
return VALID_THEMES.includes(storedTheme) ? storedTheme : DEFAULT_THEME;
}
function applyThemePreference(theme = getThemePreference()) {
const selectedTheme = VALID_THEMES.includes(theme) ? theme : DEFAULT_THEME;
document.documentElement.dataset.theme = selectedTheme;
}
function setThemePreference(theme) {
const selectedTheme = VALID_THEMES.includes(theme) ? theme : DEFAULT_THEME;
localStorage.setItem(THEME_STORAGE_KEY, selectedTheme);
applyThemePreference(selectedTheme);
}
function getThemeControlHtml() {
return `
<div class="theme-switcher">
<label for="theme-select">Theme</label>
<select id="theme-select" class="theme-select" aria-label="Theme">
<option value="dark">Dark</option>
<option value="light">Light</option>
<option value="system">System</option>
</select>
</div>
`;
}
function attachThemeControl(root = document) {
const select = root.querySelector("#theme-select");
if (!select) return;
select.value = getThemePreference();
select.addEventListener("change", () => setThemePreference(select.value));
}
function ensureStandaloneThemeControl() {
if (document.getElementById("user-menu") || document.getElementById("theme-switcher-floating")) return;
const container = document.createElement("div");
container.id = "theme-switcher-floating";
container.className = "theme-switcher-floating";
container.innerHTML = getThemeControlHtml();
document.body.appendChild(container);
attachThemeControl(container);
}
applyThemePreference();
async function checkAuth() {
try {
const res = await fetch(`${API_BASE}/me`, { credentials: "include" });
@ -79,18 +28,12 @@ function updateUserMenu() {
if (!container) return;
if (currentUser) {
container.innerHTML = `
${getThemeControlHtml()}
<span class="username"><i class="fas fa-user"></i> ${escapeHtml(currentUser.username)}</span>
<button id="logout-btn" class="logout-btn"><i class="fas fa-sign-out-alt"></i> Logout</button>
`;
attachThemeControl(container);
document.getElementById("logout-btn")?.addEventListener("click", logout);
} else {
container.innerHTML = `
${getThemeControlHtml()}
<button id="login-open-btn" class="login-btn"><i class="fas fa-sign-in-alt"></i> Login</button>
`;
attachThemeControl(container);
container.innerHTML = `<button id="login-open-btn" class="login-btn"><i class="fas fa-sign-in-alt"></i> Login</button>`;
document.getElementById("login-open-btn")?.addEventListener("click", () => {
window.location.href = "login.html";
});
@ -104,28 +47,14 @@ async function logout() {
function escapeHtml(str) {
if (!str) return "";
return String(str).replace(/[&<>"']/g, function (m) {
return str.replace(/[&<>]/g, function (m) {
if (m === "&") return "&amp;";
if (m === "<") return "&lt;";
if (m === ">") return "&gt;";
if (m === '"') return "&quot;";
if (m === "'") return "&#39;";
return m;
});
}
function escapeAttribute(str) {
return escapeHtml(str);
}
function sanitizeDisplayUrl(url) {
const value = String(url || "").trim().replace(/[\u0000-\u001f\u007f\s]+/g, "");
if (/^(https?:\/\/|\/uploads\/|uploads\/)/i.test(value)) {
return value;
}
return "";
}
function showToast(msg, isError = false) {
const toast = document.getElementById("toast");
if (!toast) return;
@ -136,5 +65,3 @@ function showToast(msg, isError = false) {
toast.style.display = "none";
}, 3000);
}
document.addEventListener("DOMContentLoaded", ensureStandaloneThemeControl);

View File

@ -1,84 +0,0 @@
let allJourneys = [];
// ==================== LOAD JOURNEYS ====================
async function loadJourneys() {
try {
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 journeys. Make sure the backend is running.</p>';
}
}
function renderJourneys(journeys) {
const container = document.getElementById('posts-grid');
if (!journeys.length) {
container.innerHTML = `
<div class="empty-state">
<p>No journeys yet. Create one on the map!</p>
</div>
`;
return;
}
container.innerHTML = journeys.map(journey => {
const imageUrl = sanitizeDisplayUrl(journey.image || '');
return `
<article class="post-card">
${imageUrl ? `<img class="post-card-image" src="${escapeAttribute(imageUrl)}" alt="${escapeAttribute(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.html?id=${encodeURIComponent(journey.id)}">${escapeHtml(journey.title)}</a></h2>
<div class="post-card-meta">
<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>` : ''}
${getJourneyBadges(journey)}
</div>
<div class="post-card-excerpt">${escapeHtml(journey.description || journey.markers?.[0]?.text?.substring(0, 150) + '…')}</div>
</div>
</article>
`;
}).join('');
}
function getJourneyBadges(journey) {
const badges = [];
const isOwner = journey.owner_id === currentUser?.id;
const isShared = journey.visibility === 'shared' && !isOwner;
if (journey.visibility === 'public') badges.push('Public');
if (isShared) badges.push(journey.can_edit ? 'Shared edit' : 'Shared');
return badges.map((badge) => `<span class="badge">${badge}</span>`).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 === 'shared') {
renderJourneys(allJourneys.filter(j => j.owner_id !== currentUser?.id && j.visibility === 'shared'));
} else if (filter === 'public') {
renderJourneys(allJourneys.filter(j => j.visibility === 'public'));
}
}
// ==================== INITIALIZATION ====================
document.addEventListener('DOMContentLoaded', async () => {
const authenticated = await checkAuthAndRedirect();
if (!authenticated) return;
updateUserMenu();
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);
});
});
});

View File

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

View File

@ -1,390 +0,0 @@
// ==================== GLOBALS ====================
let currentJourneyId = null;
let currentJourneyOwnerId = null;
let markersData = [];
let availableUsers = [];
let sharedReadIds = [];
let sharedEditIds = [];
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);
}
function getUploadUrl(path) {
if (!path) return '';
if (path.startsWith('http')) return sanitizeDisplayUrl(path);
return API_BASE.replace('/api', path);
}
function escapeAttribute(value) {
return String(value || '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
})[char]);
}
function normalizeMarkerImages(marker) {
const images = marker.images || (marker.image ? [marker.image] : []);
return { ...marker, images };
}
function canManageSharing() {
return !currentJourneyOwnerId || currentJourneyOwnerId === currentUser?.id;
}
function uniqueIds(ids) {
return [...new Set((ids || []).map((id) => parseInt(id)).filter(Number.isInteger))];
}
async function loadUsers() {
try {
const res = await fetch(`${API_BASE}/users`, { credentials: 'include' });
if (!res.ok) throw new Error('Could not load users');
availableUsers = await res.json();
} catch (err) {
availableUsers = [];
showToast('Sharing users could not be loaded', true);
}
}
function getUserCheckboxHtml(user, group, checked, disabled) {
return `
<label class="sharing-user">
<input type="checkbox" class="${group}-user" value="${user.id}" ${checked ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
<span>${escapeHtml(user.username)}</span>
</label>
`;
}
function renderSharingControls() {
const panel = document.getElementById('sharing-panel');
const visibilitySelect = document.getElementById('journey-visibility');
const visibility = visibilitySelect.value;
const readContainer = document.getElementById('shared-read-users');
const editContainer = document.getElementById('shared-edit-users');
const isShared = visibility === 'shared';
const disabled = !canManageSharing();
visibilitySelect.disabled = disabled;
panel.classList.toggle('visible', isShared);
if (!isShared) return;
if (!availableUsers.length) {
readContainer.innerHTML = '<p class="empty-share-list">No other users found.</p>';
editContainer.innerHTML = '<p class="empty-share-list">No other users found.</p>';
return;
}
readContainer.innerHTML = availableUsers.map((user) =>
getUserCheckboxHtml(user, 'shared-read', sharedReadIds.includes(user.id), disabled)
).join('');
editContainer.innerHTML = availableUsers.map((user) =>
getUserCheckboxHtml(user, 'shared-edit', sharedEditIds.includes(user.id), disabled)
).join('');
document.querySelectorAll('.shared-read-user').forEach((input) => {
input.addEventListener('change', updateSharedUsersFromForm);
});
document.querySelectorAll('.shared-edit-user').forEach((input) => {
input.addEventListener('change', updateSharedUsersFromForm);
});
}
function updateSharedUsersFromForm() {
sharedReadIds = uniqueIds([...document.querySelectorAll('.shared-read-user:checked')].map((input) => input.value));
sharedEditIds = uniqueIds([...document.querySelectorAll('.shared-edit-user:checked')].map((input) => input.value));
sharedReadIds = uniqueIds([...sharedReadIds, ...sharedEditIds]);
renderSharingControls();
}
function getSharingPayload(visibility) {
if (visibility !== 'shared') {
return { shared_read: [], shared_edit: [] };
}
return {
shared_read: uniqueIds([...sharedReadIds, ...sharedEditIds]),
shared_edit: uniqueIds(sharedEditIds)
};
}
function renderMarkerImages(marker, idx) {
const images = marker.images || [];
if (!images.length) {
return '';
}
return `
<div class="image-preview-grid">
${images.map((image, imageIdx) => {
const url = typeof image === 'string' ? image : image.url;
const alt = typeof image === 'string' ? 'Chapter image' : image.originalName || 'Chapter image';
const displayUrl = sanitizeDisplayUrl(getUploadUrl(url));
if (!displayUrl) return '';
return `
<div class="image-preview">
<img src="${escapeAttribute(displayUrl)}" alt="${escapeAttribute(alt)}">
<button type="button" class="remove-image" data-index="${idx}" data-image-index="${imageIdx}" aria-label="Remove image">
<i class="fas fa-times"></i>
</button>
</div>
`;
}).join('')}
</div>
`;
}
function renderMarkdownPreview(markdown) {
const html = renderMarkdown(markdown || '');
return html || '<p class="markdown-empty">Nothing to preview yet.</p>';
}
async function uploadMarkerImages(files) {
if (!files.length) return [];
const formData = new FormData();
files.forEach((file) => formData.append('images', file));
const res = await fetch(`${API_BASE}/uploads/images`, {
method: 'POST',
body: formData,
credentials: 'include'
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Image upload failed');
return data.images || [];
}
// ==================== 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 || '')}" maxlength="200" placeholder="Title">
</div>
<div class="form-group">
<label>Date (optional)</label>
<input type="date" class="marker-date" data-index="${idx}" value="${escapeAttribute(marker.date || '')}">
</div>
<div class="form-group">
<label>Description</label>
<textarea class="marker-description" data-index="${idx}" rows="5" maxlength="10000" placeholder="Describe this chapter. You can use Markdown like **bold**, [link](https://example.com), or ![image](https://example.com/image.jpg).">${escapeHtml(marker.description || '')}</textarea>
<div class="markdown-preview markdown-content" data-preview-index="${idx}">
${renderMarkdownPreview(marker.description)}
</div>
</div>
<div class="form-group">
<label>Images</label>
<input type="file" class="marker-images" data-index="${idx}" accept="image/*" multiple>
${renderMarkerImages(marker, idx)}
</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;
const preview = document.querySelector(`.markdown-preview[data-preview-index="${idx}"]`);
if (preview) preview.innerHTML = renderMarkdownPreview(textarea.value);
});
});
document.querySelectorAll('.marker-images').forEach(input => {
input.addEventListener('change', async () => {
const idx = parseInt(input.dataset.index);
try {
const uploadedImages = await uploadMarkerImages(Array.from(input.files));
markersData[idx].images = [...(markersData[idx].images || []), ...uploadedImages];
showToast('Image uploaded');
renderMarkers();
} catch (err) {
showToast(err.message, true);
}
});
});
document.querySelectorAll('.remove-image').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt(btn.dataset.index);
const imageIdx = parseInt(btn.dataset.imageIndex);
markersData[idx].images = (markersData[idx].images || []).filter((_, i) => i !== imageIdx);
renderMarkers();
});
});
}
// ==================== 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;
currentJourneyOwnerId = journey.owner_id;
document.getElementById('journey-title').value = journey.title;
document.getElementById('journey-description').value = journey.description || '';
document.getElementById('journey-visibility').value = journey.visibility || 'private';
sharedReadIds = uniqueIds(journey.shared_read || []);
sharedEditIds = uniqueIds(journey.shared_edit || []);
markersData = (journey.markers || []).map(normalizeMarkerImages);
console.log('Markers data loaded:', markersData);
renderSharingControls();
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 || '',
images: m.images || []
}));
const payload = { title, description, markers };
if (canManageSharing()) {
payload.visibility = visibility;
Object.assign(payload, getSharingPayload(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: '',
images: []
});
renderMarkers();
}
// ==================== INITIALIZATION ====================
document.addEventListener('DOMContentLoaded', async () => {
const authenticated = await checkAuthAndRedirect();
if (!authenticated) return;
updateUserMenu();
if (!isNew && journeyId) {
await loadUsers();
await loadJourney(journeyId);
} else if (isNew) {
currentJourneyId = null;
currentJourneyOwnerId = currentUser.id;
document.getElementById('form-title').textContent = 'New Journey';
markersData = [];
sharedReadIds = [];
sharedEditIds = [];
await loadUsers();
renderSharingControls();
renderMarkers();
} else {
window.location.href = 'blog-list.html';
}
document.getElementById('journey-form').addEventListener('submit', saveJourney);
document.getElementById('add-marker').addEventListener('click', addMarker);
document.getElementById('journey-visibility').addEventListener('change', renderSharingControls);
});

View File

@ -12,6 +12,7 @@ function displayJourney(journey) {
<p class="date">${marker.content.date}</p>
<p class="location">${marker.lngLat.lng.toFixed(4)}, ${marker.lngLat.lat.toFixed(4)}</p>
<p>${marker.content.text}</p>
${marker.content.videoUrl ? `<iframe src="${marker.content.videoUrl}" frameborder="0"></iframe>` : ''}
<div class="images-container">
${marker.content.images.map(img => `<img src="${img}" alt="Event image">`).join('')}
</div>

View File

@ -1,62 +0,0 @@
document.addEventListener('DOMContentLoaded', () => {
// Tab switching
document.querySelectorAll('.auth-tab').forEach(tab => {
tab.addEventListener('click', () => {
const target = tab.dataset.tab;
document.querySelectorAll('.auth-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
document.querySelectorAll('.auth-form').forEach(f => f.classList.remove('active'));
document.getElementById(`${target}-form`).classList.add('active');
});
});
// Login button
document.getElementById('login-submit').addEventListener('click', async () => {
const username = document.getElementById('login-username').value.trim();
const password = document.getElementById('login-password').value;
if (!username || !password) {
showToast('Please enter username and password', true);
return;
}
try {
const res = await fetch(`${API_BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
credentials: 'include'
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Login failed');
showToast(`Welcome, ${data.username}`);
window.location.href = 'map-page.html';
} catch (err) {
showToast(err.message, true);
}
});
// Register button
document.getElementById('register-submit').addEventListener('click', async () => {
const username = document.getElementById('register-username').value.trim();
const password = document.getElementById('register-password').value;
if (!username || !password) {
showToast('Please enter username and password', true);
return;
}
if (password.length < 4) {
showToast('Password must be at least 4 characters', true);
return;
}
try {
const res = await fetch(`${API_BASE}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
credentials: 'include'
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Registration failed');
showToast(`Registered as ${data.username}. Logging in...`);
window.location.href = 'map-page.html';
} catch (err) {
showToast(err.message, true);
}
});
});

View File

@ -1,716 +0,0 @@
// ==================== MAP CODE ====================
(function () {
// ==================== STATE =====================
let map;
let currentJourney = {
id: null,
title: "",
description: "",
markers: [],
};
let availableJourneys = [];
let journeyPath;
let activeMarker = null;
// ==================== MAP INIT ==================
function initMap() {
map = L.map("map", {
worldCopyJump: true,
}).setView([46.8182, 8.2275], 8);
L.tileLayer(
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
{
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
},
).addTo(map);
journeyPath = L.polyline([], {
color: "#4263eb",
weight: 4,
}).addTo(map);
map.on("click", function (e) {
if (
document
.getElementById("mode-create")
.classList.contains("active")
) {
createMarker(normalizeLatLng(e.latlng), { title: "New Marker" });
}
});
setTimeout(() => map.invalidateSize(), 300);
}
// ==================== MARKER FUNCTIONS ==========
function normalizeLatLng(latlng) {
return L.latLng(
latlng.lat,
((((latlng.lng + 180) % 360) + 360) % 360) - 180,
);
}
function createMarker(latlng, content = {}) {
latlng = normalizeLatLng(latlng);
const marker = L.marker(latlng, {
draggable: true,
title: content.title || "Untitled",
}).addTo(map);
marker.bindPopup(
`<strong>${escapeHtml(content.title || "Untitled")}</strong>`,
);
marker.on("click", () => openMarkerEditor(marker));
marker.on("dragend", () => {
marker.setLatLng(normalizeLatLng(marker.getLatLng()));
updateJourneyPath();
});
marker._content = {
...content,
lat: latlng.lat,
lng: latlng.lng,
};
currentJourney.markers.push(marker);
updateJourneyPath();
addMarkerToList(marker, latlng, content);
return marker;
}
function addMarkerToList(marker, latlng, content) {
const container = document.getElementById(
"current-markers-container",
);
const empty = container.querySelector(".empty-message");
if (empty) empty.remove();
const div = document.createElement("div");
div.className = "marker-item";
div.dataset.lat = latlng.lat;
div.dataset.lng = latlng.lng;
div.innerHTML = `
<div class="marker-title">${escapeHtml(content.title || "Untitled")}</div>
<div class="marker-coords">${latlng.lat.toFixed(4)}, ${latlng.lng.toFixed(4)}</div>
`;
div.addEventListener("click", () => {
map.flyTo(latlng, 12);
openMarkerEditor(marker);
});
container.appendChild(div);
}
function updateJourneyPath() {
const coords = currentJourney.markers.map((m) => [
m.getLatLng().lat,
m.getLatLng().lng,
]);
journeyPath.setLatLngs(coords);
}
function getUploadUrl(path) {
if (!path) return "";
if (path.startsWith("http")) return sanitizeDisplayUrl(path);
return API_BASE.replace("/api", path);
}
function renderMarkerImages(images = []) {
const preview = document.getElementById("marker-image-preview");
preview.innerHTML = "";
images.forEach((image, index) => {
const item = document.createElement("div");
item.className = "image-preview";
const imagePath = typeof image === "string" ? image : image.url;
const imageName = typeof image === "string" ? "Marker image" : image.originalName;
const url = sanitizeDisplayUrl(getUploadUrl(imagePath));
const alt = escapeAttribute(imageName || "Marker image");
if (!url) return;
item.innerHTML = `
<img src="${escapeAttribute(url)}" alt="${alt}">
<button type="button" class="remove-image-btn" data-index="${index}" aria-label="Remove image">
<i class="fas fa-times"></i>
</button>
`;
item.querySelector(".remove-image-btn").addEventListener("click", () => {
if (!activeMarker) return;
const content = activeMarker._content || {};
content.images = (content.images || []).filter((_, i) => i !== index);
activeMarker._content = content;
renderMarkerImages(content.images);
});
preview.appendChild(item);
});
}
function updateMarkerMarkdownPreview(markdown) {
const preview = document.getElementById("marker-markdown-preview");
if (!preview) return;
preview.innerHTML = renderMarkdown(markdown || "") || '<p class="markdown-empty">Nothing to preview yet.</p>';
}
async function uploadMarkerImages(files) {
if (!files.length) return [];
const formData = new FormData();
files.forEach((file) => formData.append("images", file));
const res = await fetch(`${API_BASE}/uploads/images`, {
method: "POST",
body: formData,
credentials: "include",
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Image upload failed");
return data.images || [];
}
// ==================== MODAL =====================
function openMarkerEditor(marker) {
activeMarker = marker;
const latlng = marker.getLatLng();
const content = marker._content || {};
document.getElementById("marker-title").value =
content.title || "";
document.getElementById("marker-date").value =
content.date || "";
document.getElementById("marker-text").value =
content.description || "";
updateMarkerMarkdownPreview(content.description || "");
document.getElementById("marker-images").value = "";
renderMarkerImages(content.images || []);
document.getElementById("marker-coords").value =
`${latlng.lat.toFixed(6)}, ${latlng.lng.toFixed(6)}`;
document
.getElementById("marker-modal")
.classList.add("active");
}
function closeModal() {
document
.getElementById("marker-modal")
.classList.remove("active");
activeMarker = null;
}
async function saveMarker() {
if (!activeMarker) return;
const title = document.getElementById("marker-title").value || "Untitled";
const date = document.getElementById("marker-date").value;
const description = document.getElementById("marker-text").value;
const imageInput = document.getElementById("marker-images");
const existingImages = activeMarker._content?.images || [];
let images = existingImages;
try {
const uploadedImages = await uploadMarkerImages(Array.from(imageInput.files));
images = [...existingImages, ...uploadedImages];
} catch (err) {
showToast(err.message, "error");
return;
}
// Update marker's tooltip title and popup
activeMarker.options.title = title;
activeMarker.setPopupContent(`<strong>${escapeHtml(title)}</strong>`);
// Store content for saving
activeMarker._content = { title, date, description, images };
// Update the list item in the sidebar
const latlng = activeMarker.getLatLng();
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;
break;
}
}
closeModal();
showToast("Saving journey...");
await saveJourneyToAPI();
}
function deleteMarkerFromMap() {
if (!activeMarker) return;
const latlng = activeMarker.getLatLng();
map.removeLayer(activeMarker);
const idx = currentJourney.markers.indexOf(activeMarker);
if (idx > -1) currentJourney.markers.splice(idx, 1);
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.remove();
break;
}
}
if (currentJourney.markers.length === 0) {
document.getElementById(
"current-markers-container",
).innerHTML =
'<p class="empty-message">No markers yet. Click on the map to add markers.</p>';
}
updateJourneyPath();
closeModal();
showToast("Marker deleted");
}
// ==================== API INTERACTION (with credentials) ====================
async function fetchJourneys() {
try {
const res = await fetch(`${API_BASE}/journeys`, {
credentials: "include",
});
if (!res.ok) throw new Error("Failed to fetch");
const journeys = await res.json();
availableJourneys = journeys;
populateJourneySelect(journeys);
} catch (err) {
showToast("Error loading journeys", "error");
}
}
function populateJourneySelect(journeys) {
const select = document.getElementById("journey-select");
select.innerHTML =
'<option value="">-- Choose a journey --</option><option value="all">Show All Journeys</option>';
journeys.forEach((j) => {
const opt = document.createElement("option");
opt.value = j.id;
opt.textContent = j.title;
select.appendChild(opt);
});
}
async function loadJourney(journeyId) {
try {
const res = await fetch(
`${API_BASE}/journeys/${journeyId}`,
{ credentials: "include" },
);
if (!res.ok) throw new Error("Failed to load");
const journey = await res.json();
displayJourneyInfo(journey);
} catch (err) {
showToast("Error loading journey", "error");
}
}
function displayJourneyInfo(journey) {
document.getElementById("info-title").textContent =
journey.title;
document.getElementById("info-description").textContent =
journey.description || "-";
document.getElementById("info-marker-count").textContent =
journey.markers ? journey.markers.length : 0;
document.getElementById("info-date").textContent =
new Date(journey.created_at).toLocaleDateString() ||
"-";
document.getElementById(
"load-journey-btn",
).dataset.journey = JSON.stringify(journey);
document.getElementById("edit-journey-btn").disabled = false;
document.getElementById("delete-journey-btn").disabled = false;
}
function displayAllJourneysInfo() {
const markerCount = availableJourneys.reduce(
(total, journey) => total + (journey.markers ? journey.markers.length : 0),
0,
);
document.getElementById("info-title").textContent = "All Journeys";
document.getElementById("info-description").textContent =
`${availableJourneys.length} journeys selected`;
document.getElementById("info-marker-count").textContent = markerCount;
document.getElementById("info-date").textContent = "-";
document.getElementById("load-journey-btn").dataset.journey = JSON.stringify({
type: "all",
});
document.getElementById("edit-journey-btn").disabled = true;
document.getElementById("delete-journey-btn").disabled = true;
}
async function saveJourneyToAPI() {
const title = document
.getElementById("journey-title")
.value.trim();
if (!title) {
showToast("Please enter a title", "error");
return;
}
const description = document.getElementById(
"journey-description",
).value;
const markers = currentJourney.markers.map((m) => {
const latlng = normalizeLatLng(m.getLatLng());
m.setLatLng(latlng);
const content = m._content || {};
return {
lat: latlng.lat,
lng: latlng.lng,
title: content.title || "",
date: content.date || "",
description: content.description || "",
images: content.images || [],
};
});
const visibility = document.getElementById('journey-visibility').value;
const payload = { title, description, markers, visibility };
try {
let res;
if (currentJourney.id) {
res = await fetch(
`${API_BASE}/journeys/${currentJourney.id}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
credentials: "include",
},
);
} else {
res = await fetch(`${API_BASE}/journeys`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
credentials: "include",
});
}
if (!res.ok) throw new Error("Save failed");
const data = await res.json();
currentJourney.id = data.id;
showToast("Journey saved!");
fetchJourneys();
} catch (err) {
showToast("Error saving journey", "error");
}
}
async function deleteJourneyFromAPI(journeyId) {
if (!confirm("Delete this journey?")) return;
try {
const res = await fetch(
`${API_BASE}/journeys/${journeyId}`,
{
method: "DELETE",
credentials: "include",
},
);
if (!res.ok) throw new Error("Delete failed");
showToast("Journey deleted");
fetchJourneys();
document.getElementById("info-title").textContent = "-";
document.getElementById(
"info-description",
).textContent = "-";
document.getElementById(
"info-marker-count",
).textContent = "0";
document.getElementById("info-date").textContent = "-";
} catch (err) {
showToast("Error deleting journey", "error");
}
}
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 =
'<p class="empty-message">No markers yet. Click on the map to add markers.</p>';
if (journey.markers) {
journey.markers.forEach((m) => {
const latlng = L.latLng(m.lat, m.lng);
const marker = createMarker(latlng, {
title: m.title,
date: m.date,
description: m.description,
images: m.images || [],
});
marker._content = { ...m };
});
}
currentJourney.id = journey.id;
document.getElementById("journey-title").value =
journey.title;
document.getElementById("journey-description").value =
journey.description || "";
setMode("create");
showToast("Journey loaded on map");
}
function loadAllJourneyMarkers() {
currentJourney.markers.forEach((m) => map.removeLayer(m));
currentJourney.markers = [];
currentJourney.id = null;
currentJourney.title = "All Journeys";
currentJourney.description = "";
document.getElementById("current-markers-container").innerHTML =
'<p class="empty-message">No markers found in the selected journeys.</p>';
availableJourneys.forEach((journey) => {
(journey.markers || []).forEach((m) => {
const latlng = L.latLng(m.lat, m.lng);
const markerTitle = m.title || "Untitled";
const displayTitle = `${journey.title}: ${markerTitle}`;
const marker = createMarker(latlng, {
title: displayTitle,
date: m.date,
description: m.description,
images: m.images || [],
});
marker._content = {
...m,
title: displayTitle,
journeyId: journey.id,
journeyTitle: journey.title,
};
});
});
document.getElementById("journey-title").value = "All Journeys";
document.getElementById("journey-description").value = "";
setMode("view");
if (currentJourney.markers.length > 0) {
journeyPath.setLatLngs([]);
map.fitBounds(L.featureGroup(currentJourney.markers).getBounds());
showToast("All journey markers loaded");
} else {
updateJourneyPath();
showToast("No markers found", "warning");
}
}
// ==================== UI HELPERS ================
function showToast(msg, type = "success") {
const toast = document.getElementById("toast");
document.getElementById("toast-message").textContent = msg;
toast.style.display = "block";
setTimeout(() => {
toast.style.display = "none";
}, 3000);
}
function setMode(mode) {
const createBtn = document.getElementById("mode-create");
const viewBtn = document.getElementById("mode-view");
const createPanel = document.getElementById("create-panel");
const viewPanel = document.getElementById("view-panel");
const indicator = document.getElementById("mode-indicator");
if (mode === "create") {
createBtn.classList.add("active");
viewBtn.classList.remove("active");
createPanel.classList.add("active-panel");
viewPanel.classList.remove("active-panel");
indicator.querySelector(".indicator-text").textContent =
"Creating: New Journey";
indicator.querySelector(".indicator-dot").className =
"indicator-dot creating";
} else {
viewBtn.classList.add("active");
createBtn.classList.remove("active");
viewPanel.classList.add("active-panel");
createPanel.classList.remove("active-panel");
indicator.querySelector(".indicator-text").textContent =
"Viewing Journeys";
indicator.querySelector(".indicator-dot").className =
"indicator-dot viewing";
}
}
function clearAllMarkers() {
currentJourney.markers.forEach((m) => map.removeLayer(m));
currentJourney.markers = [];
document.getElementById(
"current-markers-container",
).innerHTML =
'<p class="empty-message">No markers yet. Click on the map to add markers.</p>';
updateJourneyPath();
showToast("All markers cleared");
}
// ==================== EVENT LISTENERS (run after auth) ====================
function bindEventListeners() {
document
.getElementById("mode-create")
.addEventListener("click", () => setMode("create"));
document
.getElementById("mode-view")
.addEventListener("click", () => {
setMode("view");
fetchJourneys();
});
document
.getElementById("clear-markers-btn")
.addEventListener("click", clearAllMarkers);
document
.getElementById("save-journey-btn")
.addEventListener("click", saveJourneyToAPI);
document
.getElementById("close-modal")
.addEventListener("click", closeModal);
document
.getElementById("close-modal-footer")
.addEventListener("click", closeModal);
document
.getElementById("save-marker")
.addEventListener("click", saveMarker);
document
.getElementById("delete-marker")
.addEventListener("click", deleteMarkerFromMap);
document
.getElementById("toggle-sidebar")
.addEventListener("click", () => {
document
.querySelector(".sidebar")
.classList.toggle("collapsed");
});
document
.getElementById("zoom-in")
.addEventListener("click", () => map.zoomIn());
document
.getElementById("zoom-out")
.addEventListener("click", () => map.zoomOut());
document
.getElementById("fit-all")
.addEventListener("click", () => {
if (currentJourney.markers.length > 0) {
const group = L.featureGroup(
currentJourney.markers,
);
map.fitBounds(group.getBounds());
} else {
showToast("No markers to fit", "warning");
}
});
document
.getElementById("locate-me")
.addEventListener("click", () => {
map.locate({ setView: true, maxZoom: 16 });
});
document
.getElementById("journey-select")
.addEventListener("change", (e) => {
const val = e.target.value;
if (val === "all") {
displayAllJourneysInfo();
} else if (val) {
loadJourney(val);
}
});
document
.getElementById("load-journey-btn")
.addEventListener("click", () => {
const journeyData =
document.getElementById("load-journey-btn")
.dataset.journey;
if (!journeyData) return;
const journey = JSON.parse(journeyData);
if (journey.type === "all") {
loadAllJourneyMarkers();
} else {
loadJourneyMarkers(journey);
}
});
document
.getElementById("edit-journey-btn")
.addEventListener("click", () => {
const journeyData =
document.getElementById("load-journey-btn")
.dataset.journey;
if (journeyData)
loadJourneyMarkers(JSON.parse(journeyData));
});
document
.getElementById("delete-journey-btn")
.addEventListener("click", () => {
const journeyData =
document.getElementById("load-journey-btn")
.dataset.journey;
if (journeyData)
deleteJourneyFromAPI(
JSON.parse(journeyData).id,
);
});
document
.getElementById("add-marker-btn")
.addEventListener("click", () => {
setMode("create");
showToast("Click on the map to add a marker");
});
document
.getElementById("marker-text")
.addEventListener("input", (e) => updateMarkerMarkdownPreview(e.target.value));
}
// Expose functions globally (optional)
window.createMarker = createMarker;
window.updateJourneyPath = updateJourneyPath;
window.clearAllMarkers = clearAllMarkers;
window.setMode = setMode;
// ==================== START ====================
function startMap() {
initMap();
// Check for journey parameter
const urlParams = new URLSearchParams(window.location.search);
const journeyToLoad = urlParams.get('journey');
if (journeyToLoad) {
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;
})(); // end of IIFE
// ==================== SIDEBAR HELPER (outside IIFE) ====================
function initSidebar() {
const sidebar = document.querySelector('.sidebar');
if (!sidebar) return;
const isMobile = window.innerWidth <= 768;
if (isMobile) {
sidebar.classList.add('collapsed');
} else {
sidebar.classList.remove('collapsed');
}
}
// ==================== AUTHENTICATION CHECK ====================
document.addEventListener("DOMContentLoaded", async () => {
const authenticated = await checkAuthAndRedirect();
if (authenticated) {
updateUserMenu();
window.addEventListener('resize', initSidebar);
initSidebar();
window.startMap();
}
});

View File

@ -1,137 +0,0 @@
(function () {
const tokenPattern = /%%MDTOKEN(\d+)%%/g;
function escapeHtml(value) {
return String(value || "").replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[char]);
}
function sanitizeUrl(value) {
const url = String(value || "").trim().replace(/[\u0000-\u001f\u007f\s]+/g, "");
if (/^(https?:\/\/|\/|\.\/|\.\.\/|uploads\/|mailto:|#)/i.test(url)) {
return escapeHtml(url);
}
return "#";
}
function storeToken(tokens, html) {
const token = `%%MDTOKEN${tokens.length}%%`;
tokens.push(html);
return token;
}
function renderInline(markdown) {
const tokens = [];
let text = String(markdown || "");
text = text.replace(/`([^`]+)`/g, (_, code) =>
storeToken(tokens, `<code>${escapeHtml(code)}</code>`),
);
text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, url) =>
storeToken(
tokens,
`<img src="${sanitizeUrl(url)}" alt="${escapeHtml(alt)}" loading="lazy">`,
),
);
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) =>
storeToken(
tokens,
`<a href="${sanitizeUrl(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(label)}</a>`,
),
);
text = escapeHtml(text)
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/__([^_]+)__/g, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
.replace(/_([^_]+)_/g, "<em>$1</em>");
return text.replace(tokenPattern, (_, index) => tokens[Number(index)] || "");
}
function renderList(lines) {
const items = lines
.map((line) => line.replace(/^\s*[-*]\s+/, ""))
.map((line) => `<li>${renderInline(line)}</li>`)
.join("");
return `<ul>${items}</ul>`;
}
function renderMarkdown(markdown) {
const lines = String(markdown || "").replace(/\r\n/g, "\n").split("\n");
const blocks = [];
let paragraph = [];
let list = [];
let codeBlock = null;
function flushParagraph() {
if (!paragraph.length) return;
blocks.push(`<p>${renderInline(paragraph.join(" "))}</p>`);
paragraph = [];
}
function flushList() {
if (!list.length) return;
blocks.push(renderList(list));
list = [];
}
lines.forEach((line) => {
if (line.trim().startsWith("```")) {
if (codeBlock) {
blocks.push(`<pre><code>${escapeHtml(codeBlock.join("\n"))}</code></pre>`);
codeBlock = null;
} else {
flushParagraph();
flushList();
codeBlock = [];
}
return;
}
if (codeBlock) {
codeBlock.push(line);
return;
}
if (!line.trim()) {
flushParagraph();
flushList();
return;
}
const heading = line.match(/^(#{1,3})\s+(.+)$/);
if (heading) {
flushParagraph();
flushList();
const level = heading[1].length + 2;
blocks.push(`<h${level}>${renderInline(heading[2])}</h${level}>`);
return;
}
if (/^\s*[-*]\s+/.test(line)) {
flushParagraph();
list.push(line);
return;
}
flushList();
paragraph.push(line.trim());
});
flushParagraph();
flushList();
if (codeBlock) {
blocks.push(`<pre><code>${escapeHtml(codeBlock.join("\n"))}</code></pre>`);
}
return blocks.join("");
}
window.renderMarkdown = renderMarkdown;
})();

View File

@ -14,10 +14,7 @@
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- Responsive Design -->
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/responsive.css">
<style>
* { box-sizing: border-box; }
body {
@ -135,22 +132,22 @@
<div id="login-form" class="auth-form active">
<div class="form-group">
<label>Username</label>
<input type="text" id="login-username" maxlength="50" required>
<input type="text" id="login-username" required>
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="login-password" maxlength="200" required>
<input type="password" id="login-password" required>
</div>
<button id="login-submit" class="btn">Login</button>
</div>
<div id="register-form" class="auth-form">
<div class="form-group">
<label>Username</label>
<input type="text" id="register-username" maxlength="50" required>
<input type="text" id="register-username" required>
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="register-password" minlength="4" maxlength="200" required>
<input type="password" id="register-password" required>
</div>
<button id="register-submit" class="btn">Register</button>
</div>
@ -158,6 +155,69 @@
<div id="toast" class="toast"></div>
<script src="js/auth.js"></script>
<script src="js/login.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Tab switching
document.querySelectorAll('.auth-tab').forEach(tab => {
tab.addEventListener('click', () => {
const target = tab.dataset.tab;
document.querySelectorAll('.auth-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
document.querySelectorAll('.auth-form').forEach(f => f.classList.remove('active'));
document.getElementById(`${target}-form`).classList.add('active');
});
});
// Login button
document.getElementById('login-submit').addEventListener('click', async () => {
const username = document.getElementById('login-username').value.trim();
const password = document.getElementById('login-password').value;
if (!username || !password) {
showToast('Please enter username and password', true);
return;
}
try {
const res = await fetch(`${API_BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
credentials: 'include'
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Login failed');
showToast(`Welcome, ${data.username}`);
window.location.href = 'map-page.html';
} catch (err) {
showToast(err.message, true);
}
});
// Register button
document.getElementById('register-submit').addEventListener('click', async () => {
const username = document.getElementById('register-username').value.trim();
const password = document.getElementById('register-password').value;
if (!username || !password) {
showToast('Please enter username and password', true);
return;
}
if (password.length < 4) {
showToast('Password must be at least 4 characters', true);
return;
}
try {
const res = await fetch(`${API_BASE}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
credentials: 'include'
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Registration failed');
showToast(`Registered as ${data.username}. Logging in...`);
window.location.href = 'map-page.html';
} catch (err) {
showToast(err.message, true);
}
});
});
</script>
</body>
</html>

View File

@ -30,10 +30,7 @@
href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap"
rel="stylesheet"
/>
<!-- Responsive Design -->
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="css/responsive.css">
<style>
/* Use Open Props design tokens */
* {
@ -74,7 +71,6 @@
flex: 1;
min-width: 0;
position: relative;
isolation: isolate;
}
#map {
@ -184,12 +180,6 @@
min-height: 80px;
resize: vertical;
}
input[type="file"] {
width: 100%;
padding: var(--size-2) 0;
font-size: var(--font-size-1);
color: var(--text-2);
}
/* Instructions */
.instructions {
@ -306,7 +296,16 @@
padding: var(--size-4);
}
/* Map controls */
.map-controls {
position: absolute;
top: var(--size-4);
right: var(--size-4);
display: flex;
flex-direction: column;
gap: var(--size-2);
z-index: 10;
}
.control-btn {
width: 40px;
height: 40px;
@ -427,41 +426,6 @@
gap: var(--size-2);
justify-content: flex-end;
}
.image-preview-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(88px, 1fr));
gap: var(--size-2);
margin-top: var(--size-2);
}
.image-preview {
position: relative;
aspect-ratio: 1;
border-radius: var(--radius-2);
overflow: hidden;
border: 1px solid var(--surface-4);
background: var(--surface-2);
}
.image-preview img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.remove-image-btn {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
border: none;
border-radius: var(--radius-round);
background: rgba(0, 0, 0, 0.7);
color: white;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Toast */
.toast {
@ -517,6 +481,27 @@
margin: 0;
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {
position: fixed;
top: 0;
left: 0;
height: 100%;
transform: translateX(-100%);
}
.sidebar.active {
transform: translateX(0);
}
.app-container {
flex-direction: column;
}
.map-controls {
top: auto;
bottom: var(--size-4);
flex-direction: row;
}
}
.site-header {
background: var(--gray-9);
padding: var(--size-4) var(--size-6);
@ -641,19 +626,10 @@
<input
type="text"
id="journey-title"
maxlength="200"
placeholder="My European Adventure"
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>
@ -661,7 +637,6 @@
>
<textarea
id="journey-description"
maxlength="20000"
placeholder="Describe your journey..."
></textarea>
</div>
@ -844,7 +819,6 @@
<input
type="text"
id="marker-title"
maxlength="200"
placeholder="Eiffel Tower"
/>
</div>
@ -856,26 +830,18 @@
<label for="marker-text">Description</label>
<textarea
id="marker-text"
maxlength="10000"
placeholder="Describe this marker. You can use Markdown like **bold**, [link](https://example.com), or ![image](https://example.com/image.jpg)."
placeholder="What happened here?"
></textarea>
<div
id="marker-markdown-preview"
class="markdown-preview markdown-content"
></div>
</div>
<div class="form-group">
<label for="marker-images">Images</label>
<label for="video-url"
>Video URL (optional)</label
>
<input
type="file"
id="marker-images"
accept="image/*"
multiple
type="text"
id="video-url"
placeholder="https://youtu.be/..."
/>
<div
id="marker-image-preview"
class="image-preview-grid"
></div>
</div>
<div class="form-group">
<label for="marker-coords">Coordinates</label>
@ -911,7 +877,542 @@
</div>
<div id="toast" class="toast"><p id="toast-message"></p></div>
<script src="js/auth.js"></script>
<script src="js/markdown.js"></script>
<script src="js/map-page.js"></script>
<script>
// ==================== MAP CODE ====================
(function () {
// ==================== STATE =====================
let map;
let currentJourney = {
id: null,
title: "",
description: "",
markers: [],
};
let journeyPath;
let activeMarker = null;
// ==================== MAP INIT ==================
function initMap() {
map = L.map("map").setView([46.8182, 8.2275], 8);
L.tileLayer(
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
{
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
},
).addTo(map);
journeyPath = L.polyline([], {
color: "#4263eb",
weight: 4,
}).addTo(map);
map.on("click", function (e) {
if (
document
.getElementById("mode-create")
.classList.contains("active")
) {
createMarker(e.latlng, { title: "New Marker" });
}
});
setTimeout(() => map.invalidateSize(), 300);
}
// ==================== MARKER FUNCTIONS ==========
function createMarker(latlng, content = {}) {
const marker = L.marker(latlng, {
draggable: true,
title: content.title || "Untitled",
}).addTo(map);
marker.bindPopup(
`<strong>${content.title || "Untitled"}</strong>`,
);
marker.on("click", () => openMarkerEditor(marker));
marker._content = {
...content,
lat: latlng.lat,
lng: latlng.lng,
};
currentJourney.markers.push(marker);
updateJourneyPath();
addMarkerToList(marker, latlng, content);
return marker;
}
function addMarkerToList(marker, latlng, content) {
const container = document.getElementById(
"current-markers-container",
);
const empty = container.querySelector(".empty-message");
if (empty) empty.remove();
const div = document.createElement("div");
div.className = "marker-item";
div.dataset.lat = latlng.lat;
div.dataset.lng = latlng.lng;
div.innerHTML = `
<div class="marker-title">${content.title || "Untitled"}</div>
<div class="marker-coords">${latlng.lat.toFixed(4)}, ${latlng.lng.toFixed(4)}</div>
`;
div.addEventListener("click", () => {
map.flyTo(latlng, 12);
openMarkerEditor(marker);
});
container.appendChild(div);
}
function updateJourneyPath() {
const coords = currentJourney.markers.map((m) => [
m.getLatLng().lat,
m.getLatLng().lng,
]);
journeyPath.setLatLngs(coords);
}
// ==================== MODAL =====================
function openMarkerEditor(marker) {
activeMarker = marker;
const latlng = marker.getLatLng();
const content = marker._content || {};
document.getElementById("marker-title").value =
content.title || "";
document.getElementById("marker-date").value =
content.date || "";
document.getElementById("marker-text").value =
content.description || "";
document.getElementById("video-url").value =
content.videoUrl || "";
document.getElementById("marker-coords").value =
`${latlng.lat.toFixed(6)}, ${latlng.lng.toFixed(6)}`;
document
.getElementById("marker-modal")
.classList.add("active");
}
function closeModal() {
document
.getElementById("marker-modal")
.classList.remove("active");
activeMarker = null;
}
function saveMarker() {
if (!activeMarker) return;
const title =
document.getElementById("marker-title").value ||
"Untitled";
const date = document.getElementById("marker-date").value;
const description =
document.getElementById("marker-text").value;
const videoUrl = document.getElementById("video-url").value;
activeMarker.setTitle(title);
activeMarker.setPopupContent(`<strong>${title}</strong>`);
activeMarker._content = {
title,
date,
description,
videoUrl,
};
const latlng = activeMarker.getLatLng();
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;
break;
}
}
closeModal();
showToast("Marker updated");
}
function deleteMarkerFromMap() {
if (!activeMarker) return;
const latlng = activeMarker.getLatLng();
map.removeLayer(activeMarker);
const idx = currentJourney.markers.indexOf(activeMarker);
if (idx > -1) currentJourney.markers.splice(idx, 1);
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.remove();
break;
}
}
if (currentJourney.markers.length === 0) {
document.getElementById(
"current-markers-container",
).innerHTML =
'<p class="empty-message">No markers yet. Click on the map to add markers.</p>';
}
updateJourneyPath();
closeModal();
showToast("Marker deleted");
}
// ==================== API INTERACTION (with credentials) ====================
async function fetchJourneys() {
try {
const res = await fetch(`${API_BASE}/journeys`, {
credentials: "include",
});
if (!res.ok) throw new Error("Failed to fetch");
const journeys = await res.json();
populateJourneySelect(journeys);
} catch (err) {
showToast("Error loading journeys", "error");
}
}
function populateJourneySelect(journeys) {
const select = document.getElementById("journey-select");
select.innerHTML =
'<option value="">-- Choose a journey --</option><option value="all">Show All Journeys</option>';
journeys.forEach((j) => {
const opt = document.createElement("option");
opt.value = j.id;
opt.textContent = j.title;
select.appendChild(opt);
});
}
async function loadJourney(journeyId) {
try {
const res = await fetch(
`${API_BASE}/journeys/${journeyId}`,
{ credentials: "include" },
);
if (!res.ok) throw new Error("Failed to load");
const journey = await res.json();
displayJourneyInfo(journey);
} catch (err) {
showToast("Error loading journey", "error");
}
}
function displayJourneyInfo(journey) {
document.getElementById("info-title").textContent =
journey.title;
document.getElementById("info-description").textContent =
journey.description || "-";
document.getElementById("info-marker-count").textContent =
journey.markers ? journey.markers.length : 0;
document.getElementById("info-date").textContent =
new Date(journey.created_at).toLocaleDateString() ||
"-";
document.getElementById(
"load-journey-btn",
).dataset.journey = JSON.stringify(journey);
}
async function saveJourneyToAPI() {
const title = document
.getElementById("journey-title")
.value.trim();
if (!title) {
showToast("Please enter a title", "error");
return;
}
const description = document.getElementById(
"journey-description",
).value;
const markers = currentJourney.markers.map((m) => {
const latlng = m.getLatLng();
const content = m._content || {};
return {
lat: latlng.lat,
lng: latlng.lng,
title: content.title || "",
date: content.date || "",
description: content.description || "",
videoUrl: content.videoUrl || "",
};
});
const payload = { title, description, markers };
try {
let res;
if (currentJourney.id) {
res = await fetch(
`${API_BASE}/journeys/${currentJourney.id}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
credentials: "include",
},
);
} else {
res = await fetch(`${API_BASE}/journeys`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
credentials: "include",
});
}
if (!res.ok) throw new Error("Save failed");
const data = await res.json();
currentJourney.id = data.id;
showToast("Journey saved!");
fetchJourneys();
} catch (err) {
showToast("Error saving journey", "error");
}
}
async function deleteJourneyFromAPI(journeyId) {
if (!confirm("Delete this journey?")) return;
try {
const res = await fetch(
`${API_BASE}/journeys/${journeyId}`,
{
method: "DELETE",
credentials: "include",
},
);
if (!res.ok) throw new Error("Delete failed");
showToast("Journey deleted");
fetchJourneys();
document.getElementById("info-title").textContent = "-";
document.getElementById(
"info-description",
).textContent = "-";
document.getElementById(
"info-marker-count",
).textContent = "0";
document.getElementById("info-date").textContent = "-";
} catch (err) {
showToast("Error deleting journey", "error");
}
}
function loadJourneyMarkers(journey) {
currentJourney.markers.forEach((m) => map.removeLayer(m));
currentJourney.markers = [];
document.getElementById(
"current-markers-container",
).innerHTML =
'<p class="empty-message">No markers yet. Click on the map to add markers.</p>';
if (journey.markers) {
journey.markers.forEach((m) => {
const latlng = L.latLng(m.lat, m.lng);
const marker = createMarker(latlng, {
title: m.title,
date: m.date,
description: m.description,
videoUrl: m.videoUrl,
});
marker._content = { ...m };
});
}
currentJourney.id = journey.id;
document.getElementById("journey-title").value =
journey.title;
document.getElementById("journey-description").value =
journey.description || "";
setMode("create");
showToast("Journey loaded on map");
}
// ==================== UI HELPERS ================
function showToast(msg, type = "success") {
const toast = document.getElementById("toast");
document.getElementById("toast-message").textContent = msg;
toast.style.display = "block";
setTimeout(() => {
toast.style.display = "none";
}, 3000);
}
function setMode(mode) {
const createBtn = document.getElementById("mode-create");
const viewBtn = document.getElementById("mode-view");
const createPanel = document.getElementById("create-panel");
const viewPanel = document.getElementById("view-panel");
const indicator = document.getElementById("mode-indicator");
if (mode === "create") {
createBtn.classList.add("active");
viewBtn.classList.remove("active");
createPanel.classList.add("active-panel");
viewPanel.classList.remove("active-panel");
indicator.querySelector(".indicator-text").textContent =
"Creating: New Journey";
indicator.querySelector(".indicator-dot").className =
"indicator-dot creating";
} else {
viewBtn.classList.add("active");
createBtn.classList.remove("active");
viewPanel.classList.add("active-panel");
createPanel.classList.remove("active-panel");
indicator.querySelector(".indicator-text").textContent =
"Viewing Journeys";
indicator.querySelector(".indicator-dot").className =
"indicator-dot viewing";
}
}
function clearAllMarkers() {
currentJourney.markers.forEach((m) => map.removeLayer(m));
currentJourney.markers = [];
document.getElementById(
"current-markers-container",
).innerHTML =
'<p class="empty-message">No markers yet. Click on the map to add markers.</p>';
updateJourneyPath();
showToast("All markers cleared");
}
// ==================== EVENT LISTENERS (run after auth) ====================
function bindEventListeners() {
document
.getElementById("mode-create")
.addEventListener("click", () => setMode("create"));
document
.getElementById("mode-view")
.addEventListener("click", () => {
setMode("view");
fetchJourneys();
});
document
.getElementById("clear-markers-btn")
.addEventListener("click", clearAllMarkers);
document
.getElementById("save-journey-btn")
.addEventListener("click", saveJourneyToAPI);
document
.getElementById("close-modal")
.addEventListener("click", closeModal);
document
.getElementById("close-modal-footer")
.addEventListener("click", closeModal);
document
.getElementById("save-marker")
.addEventListener("click", saveMarker);
document
.getElementById("delete-marker")
.addEventListener("click", deleteMarkerFromMap);
document
.getElementById("toggle-sidebar")
.addEventListener("click", () => {
document
.querySelector(".sidebar")
.classList.toggle("collapsed");
});
document
.getElementById("zoom-in")
.addEventListener("click", () => map.zoomIn());
document
.getElementById("zoom-out")
.addEventListener("click", () => map.zoomOut());
document
.getElementById("fit-all")
.addEventListener("click", () => {
if (currentJourney.markers.length > 0) {
const group = L.featureGroup(
currentJourney.markers,
);
map.fitBounds(group.getBounds());
} else {
showToast("No markers to fit", "warning");
}
});
document
.getElementById("locate-me")
.addEventListener("click", () => {
map.locate({ setView: true, maxZoom: 16 });
});
document
.getElementById("journey-select")
.addEventListener("change", (e) => {
const val = e.target.value;
if (val && val !== "all") loadJourney(val);
});
document
.getElementById("load-journey-btn")
.addEventListener("click", () => {
const journeyData =
document.getElementById("load-journey-btn")
.dataset.journey;
if (journeyData)
loadJourneyMarkers(JSON.parse(journeyData));
});
document
.getElementById("edit-journey-btn")
.addEventListener("click", () => {
const journeyData =
document.getElementById("load-journey-btn")
.dataset.journey;
if (journeyData)
loadJourneyMarkers(JSON.parse(journeyData));
});
document
.getElementById("delete-journey-btn")
.addEventListener("click", () => {
const journeyData =
document.getElementById("load-journey-btn")
.dataset.journey;
if (journeyData)
deleteJourneyFromAPI(
JSON.parse(journeyData).id,
);
});
document
.getElementById("add-marker-btn")
.addEventListener("click", () => {
setMode("create");
showToast("Click on the map to add a marker");
});
}
// Expose functions globally (optional)
window.createMarker = createMarker;
window.updateJourneyPath = updateJourneyPath;
window.clearAllMarkers = clearAllMarkers;
window.setMode = setMode;
// ==================== START ====================
// This function is called after auth check
function startMap() {
initMap();
bindEventListeners();
}
window.startMap = startMap;
})();
// ==================== AUTHENTICATION CHECK ====================
document.addEventListener("DOMContentLoaded", async () => {
const authenticated = await checkAuthAndRedirect();
if (authenticated) {
updateUserMenu();
window.startMap(); // call the map initializer from the IIFE
}
});
</script>
</body>
</html>