Compare commits
No commits in common. "main" and "user-backend" have entirely different histories.
main
...
user-backe
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,6 +1,3 @@
|
||||
Lession_material
|
||||
*.DS_Store
|
||||
.aider*
|
||||
backend/uploads/
|
||||
backend/data/*.json
|
||||
.venv
|
||||
154
README.md
154
README.md
@ -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é
|
||||
|
||||
|
||||
@ -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 |
612
backend/app.py
612
backend/app.py
@ -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"],
|
||||
}
|
||||
# ==================== Per‑user 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, user‑specific) ====================
|
||||
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, user‑specific) ====================
|
||||
def get_posts_for_current_user():
|
||||
user_id = get_current_user_id()
|
||||
if user_id is None:
|
||||
return None
|
||||
return load_user_posts(user_id)
|
||||
|
||||
|
||||
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"])
|
||||
|
||||
10
backend/data/blog_posts.json
Normal file
10
backend/data/blog_posts.json
Normal file
@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"title": "test",
|
||||
"content": "sfsfsfsaf",
|
||||
"journeyId": "1",
|
||||
"image": null,
|
||||
"created_at": "2026-03-27T19:49:49.410806"
|
||||
}
|
||||
]
|
||||
82
backend/data/journeys.json
Normal file
82
backend/data/journeys.json
Normal 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
8
backend/data/users.json
Normal 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"
|
||||
}
|
||||
]
|
||||
42
backend/data/users/1/journeys.json
Normal file
42
backend/data/users/1/journeys.json
Normal 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"
|
||||
}
|
||||
]
|
||||
10
backend/data/users/1/posts.json
Normal file
10
backend/data/users/1/posts.json
Normal file
@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"title": "test",
|
||||
"content": "ksafladjsfk",
|
||||
"journeyId": "1",
|
||||
"image": null,
|
||||
"created_at": "2026-03-27T21:23:39.755057"
|
||||
}
|
||||
]
|
||||
@ -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.
@ -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
369
blog-post-edit.html
Normal 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>
|
||||
317
blog-post.html
317
blog-post.html
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
265
css/style.css
265
css/style.css
@ -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 {
|
||||
|
||||
@ -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>
|
||||
77
js/auth.js
77
js/auth.js
@ -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 "&";
|
||||
if (m === "<") return "<";
|
||||
if (m === ">") return ">";
|
||||
if (m === '"') return """;
|
||||
if (m === "'") return "'";
|
||||
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);
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
350
js/blog-post.js
350
js/blog-post.js
@ -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) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
})[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();
|
||||
});
|
||||
@ -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) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
})[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 .">${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);
|
||||
});
|
||||
@ -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>
|
||||
|
||||
62
js/login.js
62
js/login.js
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
716
js/map-page.js
716
js/map-page.js
@ -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:
|
||||
'© <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();
|
||||
}
|
||||
});
|
||||
137
js/markdown.js
137
js/markdown.js
@ -1,137 +0,0 @@
|
||||
(function () {
|
||||
const tokenPattern = /%%MDTOKEN(\d+)%%/g;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "").replace(/[&<>"']/g, (char) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
})[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;
|
||||
})();
|
||||
78
login.html
78
login.html
@ -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>
|
||||
|
||||
651
map-page.html
651
map-page.html
@ -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 ."
|
||||
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:
|
||||
'© <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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user