Compare commits

..

43 Commits

Author SHA1 Message Date
Josh-Dev-Quest
e3724a4842
Moved authentification code into js/auth.js 2026-03-28 13:43:05 +01:00
Josh-Dev-Quest
fec4f513c8
Add login page and authentification JS 2026-03-27 21:20:07 +01:00
Josh-Dev-Quest
f88fed7c89
Add backend user authentification 2026-03-27 20:31:14 +01:00
Josh-Dev-Quest
bcc86be6c4
Merge branch 'blog-map-fusion' into dev 2026-03-27 20:17:56 +01:00
Josh-Dev-Quest
50aae732aa
Reworked blog overview website 2026-03-27 20:14:20 +01:00
Josh-Dev-Quest
4b09f09ccf
Unified pages navigation 2026-03-27 19:37:09 +01:00
e07547c449 added Open Props to blog-page.html, normalized poppins to match style with map-page. Background colors not adjusted to text yet. 2026-03-18 23:19:29 +01:00
3f5fdc2ff7 Added basic html, js and css for blogpost page. Still mainly vibe code, need to review later. Backend not yet updated. Added venv and requirements. 2026-03-12 00:33:49 +01:00
Josh-Dev-Quest
471d629a93
Map page v1 2026-03-05 14:35:35 +01:00
Josh-Dev-Quest
2b2cd32847
FINISH V1: Crafted a working backend and frontent with deepseek. open page map-page.html and start backend app2.py 2026-03-01 19:04:16 +01:00
Josh-Dev-Quest
a7474a8c64
fix(remove): Remove duplicate body content and merge conflicts
Co-authored-by: aider (ollama/qwen2.5-coder:32b) <aider@aider.chat>
2026-03-01 18:48:39 +01:00
Josh-Dev-Quest
ba11d093cc
fix: remove duplicate body content and properly structure the page layout
Co-authored-by: aider (ollama/qwen2.5-coder:32b) <aider@aider.chat>
2026-03-01 18:35:15 +01:00
Josh-Dev-Quest
4b51448eb4
fix(map): improve map display styling
Co-authored-by: aider (ollama/qwen2.5-coder:32b) <aider@aider.chat>
2026-03-01 18:15:38 +01:00
Josh-Dev-Quest
e8298cf88d
The commit message should be:
feat: update clear markers functionality to use currentJourney and updateMarkersList

Co-authored-by: aider (ollama/qwen2.5-coder:32b) <aider@aider.chat>
2026-03-01 18:10:14 +01:00
Josh-Dev-Quest
9603f87573
The changes involve restructuring the code to use an object for markers and adding new functionalities.
refactor: replace markers array with currentJourney object

Co-authored-by: aider (ollama/qwen2.5-coder:32b) <aider@aider.chat>
2026-03-01 18:08:02 +01:00
Josh-Dev-Quest
942de8c297
feat: add marker list ordering in sidebar with clickable zoom
Co-authored-by: aider (ollama/qwen2.5-coder:32b) <aider@aider.chat>
2026-03-01 18:05:16 +01:00
Josh-Dev-Quest
e0653c654b
refactor: switch from maplibregl to Leaflet 2026-03-01 18:00:03 +01:00
Josh-Dev-Quest
a8281e0d20
fix: Fix journey ID comparison in delete endpoint
Co-authored-by: aider (ollama/qwen2.5-coder:32b) <aider@aider.chat>
2026-03-01 17:51:48 +01:00
Josh-Dev-Quest
2b4386c942
chore: remove create journey 2026-03-01 17:50:01 +01:00
Josh-Dev-Quest
ba352d4edd
fix: remove duplicate route and fix journey id matching
Co-authored-by: aider (ollama/qwen2.5-coder:32b) <aider@aider.chat>
2026-03-01 17:45:42 +01:00
Josh-Dev-Quest
a36ea77fb1
feat: add journey creation functionality
Co-authored-by: aider (ollama/qwen2.5-coder:32b) <aider@aider.chat>
2026-03-01 17:40:11 +01:00
Josh-Dev-Quest
2e2ed1b217
style: simplify map area styles 2026-03-01 17:38:07 +01:00
Josh-Dev-Quest
6ba26cb792
feat: add journey path drawing functionality
Co-authored-by: aider (ollama/qwen2.5-coder:32b) <aider@aider.chat>
2026-03-01 17:04:14 +01:00
Josh-Dev-Quest
64e8728ea4
The commit message is:
feat: add journey path drawing with markers and path updating

Co-authored-by: aider (ollama/qwen2.5-coder:32b) <aider@aider.chat>
2026-03-01 17:00:49 +01:00
Josh-Dev-Quest
2c1ba684c0
feat: add journey creation with title, description, and marker management
Co-authored-by: aider (ollama/qwen2.5-coder:32b) <aider@aider.chat>
2026-03-01 16:54:39 +01:00
Josh-Dev-Quest
0165e9a4bf
chore: update map library to leaflet and adjust styles 2026-03-01 16:43:38 +01:00
Josh-Dev-Quest
25017ef0dc
feat: create basic map page with Leaflet.js integration
Co-authored-by: aider (ollama/qwen2.5-coder:32b) <aider@aider.chat>
2026-03-01 16:35:19 +01:00
Josh-Dev-Quest
4f26bd6981
The commit adds a new CSS class for the map area and includes comments explaining code functionality, fitting the 'feat' type for introducing new styling features.
feat: add map area styles for flex layout

Co-authored-by: aider (ollama/qwen2.5-coder:32b) <aider@aider.chat>
2026-03-01 16:25:46 +01:00
Josh-Dev-Quest
6c4599dffc
refactor: switch from maplibregl to Leaflet
Co-authored-by: aider (ollama/qwen2.5-coder:32b) <aider@aider.chat>
2026-03-01 16:18:56 +01:00
Josh-Dev-Quest
f94ecc9ef8
The changes include adding CORS support in the backend and updating the map style URL to include an access token. These are new features.
feat: add CORS support and update map style URL

Co-authored-by: aider (ollama/qwen2.5-coder:32b) <aider@aider.chat>
2026-03-01 16:12:55 +01:00
Josh-Dev-Quest
a20036e67c
feat: improve mapping functionality with markers, path, CORS
Co-authored-by: aider (ollama/qwen2.5-coder:32b) <aider@aider.chat>
2026-03-01 16:12:00 +01:00
Josh-Dev-Quest
42cb05367c
The commit adds a Flask backend implementing API endpoints for journey management, so it's a feature addition.
feat: Add journey mapping backend API
2026-03-01 16:11:06 +01:00
Josh-Dev-Quest
33c25f01ef
fix: initialize map properly in main.js
Co-authored-by: aider (ollama_chat/qwen3:30b-a3b-instruct-2507-q4_K_M) <aider@aider.chat>
2026-03-01 15:55:59 +01:00
Josh-Dev-Quest
b4ad401287
feat: implement interactive map with backend marker saving
Co-authored-by: aider (ollama_chat/qwen3:30b-a3b-instruct-2507-q4_K_M) <aider@aider.chat>
2026-03-01 15:53:05 +01:00
Josh-Dev-Quest
c005a6f1ea
fix: refactor journey management and add backend integration
Co-authored-by: aider (ollama_chat/qwen3:30b-a3b-instruct-2507-q4_K_M) <aider@aider.chat>
2026-03-01 15:41:17 +01:00
Josh-Dev-Quest
bf6e4a1918
feat: add test.md file
Co-authored-by: aider (ollama_chat/qwen3:30b-a3b-instruct-2507-q4_K_M) <aider@aider.chat>
2026-03-01 13:51:35 +01:00
Josh-Dev-Quest
99c22987e3
Map is displayed and interactive 2026-02-27 07:10:06 +01:00
Josh-Dev-Quest
10a5db9d05
README ready for submission 2026-02-27 07:04:44 +01:00
Josh-Dev-Quest
b4fae21038
Basic project structure and some drafts 2026-02-26 12:11:59 +01:00
Josh-Dev-Quest
f98c74d515
feat: add journey map marker creation and editing 2026-02-23 06:34:32 +01:00
Josh-Dev-Quest
b55e4bba31
feat: add responsive map page with sidebar 2026-02-23 06:11:44 +01:00
Josh-Dev-Quest
32fa54196a
Add folder structure 2026-02-10 17:51:44 +01:00
Josh-Dev-Quest
0380e35a58
Draft README.md 2026-02-10 17:32:40 +01:00
23 changed files with 4741 additions and 2 deletions

4
.gitignore vendored
View File

@ -1 +1,3 @@
.DS_Store Lession_material
*.DS_Store
.aider*

View File

@ -1,7 +1,7 @@
# Travel Journal (Draft README) # Travel Journal (Draft README)
## Projectdescription ## Projectdescription
On this website you can document your travelstories. On this website you can document your travelstories.
Where you were, what you did and what videos and pictures you took. 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. Keep your journal and share it with friends or just log it for your memory.
## Pages ## Pages
@ -34,3 +34,4 @@ TBD
- Flepp Stiafen - Flepp Stiafen
- Kohler Joshua - Kohler Joshua
- Rüegger André - Rüegger André

403
backend/app.py Normal file
View File

@ -0,0 +1,403 @@
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
DATA_DIR = "data"
USERS_FILE = os.path.join(DATA_DIR, "users.json")
os.makedirs(DATA_DIR, exist_ok=True)
# ==================== User helpers ====================
def load_users():
try:
if os.path.exists(USERS_FILE):
with open(USERS_FILE, "r") as f:
return json.load(f)
except Exception as e:
print(f"Error loading users: {e}")
return []
def save_users(users):
try:
with open(USERS_FILE, "w") as f:
json.dump(users, f, indent=2)
except Exception as e:
print(f"Error saving users: {e}")
def get_next_user_id(users):
if not users:
return 1
return max(u["id"] for u in users) + 1
def get_user_by_username(username):
users = load_users()
return next((u for u in users if u["username"] == username), None)
def get_user_by_id(user_id):
users = load_users()
return next((u for u in users if u["id"] == user_id), None)
# ==================== Peruser data helpers ====================
def get_user_data_dir(user_id):
user_dir = os.path.join(DATA_DIR, "users", str(user_id))
os.makedirs(user_dir, exist_ok=True)
return user_dir
def 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}")
# ==================== Authentication endpoints ====================
@app.route("/api/register", methods=["POST"])
def register():
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):
return jsonify({"error": "Username already taken"}), 409
users = load_users()
new_id = get_next_user_id(users)
hashed = generate_password_hash(password)
new_user = {
"id": new_id,
"username": username,
"password_hash": hashed,
"created_at": datetime.now().isoformat(),
}
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
return jsonify(
{"id": new_id, "username": username, "message": "Registration successful"}
), 201
@app.route("/api/login", methods=["POST"])
def login():
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):
return jsonify({"error": "Invalid username or password"}), 401
session["user_id"] = user["id"]
return jsonify(
{"id": user["id"], "username": user["username"], "message": "Login successful"}
)
@app.route("/api/logout", methods=["POST"])
def logout():
session.pop("user_id", None)
return jsonify({"message": "Logged out"})
@app.route("/api/me", methods=["GET"])
def me():
user_id = session.get("user_id")
if not user_id:
return jsonify({"error": "Not logged in"}), 401
user = get_user_by_id(user_id)
if not user:
# Should not happen, but clean session
session.pop("user_id", None)
return jsonify({"error": "User not found"}), 401
return jsonify({"id": user["id"], "username": user["username"]})
# ==================== Journey endpoints (protected, userspecific) ====================
def require_login():
if "user_id" not in session:
return False
return True
def get_current_user_id():
return session.get("user_id")
def get_journeys_for_current_user():
user_id = get_current_user_id()
if user_id is None:
return None
return load_user_journeys(user_id)
@app.route("/api/journeys", methods=["GET"])
def get_journeys():
if not require_login():
return jsonify({"error": "Authentication required"}), 401
journeys = get_journeys_for_current_user()
return jsonify(journeys)
def get_next_journey_id(journeys):
if not journeys:
return 1
return max(j["id"] for j in journeys) + 1
@app.route("/api/journeys", methods=["POST"])
def create_journey():
if not require_login():
return jsonify({"error": "Authentication required"}), 401
data = request.get_json()
if not data:
return jsonify({"error": "No data provided"}), 400
title = data.get("title")
if not title:
return jsonify({"error": "Journey title is required"}), 400
user_id = get_current_user_id()
journeys = get_journeys_for_current_user()
new_id = get_next_journey_id(journeys)
new_journey = {
"id": new_id,
"title": title,
"description": data.get("description", ""),
"markers": data.get("markers", []),
"created_at": datetime.now().isoformat(),
}
journeys.append(new_journey)
save_user_journeys(user_id, journeys)
return jsonify(new_journey), 201
@app.route("/api/journeys/<int:journey_id>", methods=["GET"])
def get_journey(journey_id):
if not require_login():
return jsonify({"error": "Authentication required"}), 401
journeys = get_journeys_for_current_user()
journey = next((j for j in journeys if j["id"] == journey_id), None)
if journey is None:
return jsonify({"error": "Journey not found"}), 404
return jsonify(journey)
@app.route("/api/journeys/<int:journey_id>", methods=["PUT"])
def update_journey(journey_id):
if not require_login():
return jsonify({"error": "Authentication required"}), 401
journeys = get_journeys_for_current_user()
journey = next((j for j in journeys if j["id"] == journey_id), None)
if journey is None:
return jsonify({"error": "Journey not found"}), 404
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"])
def delete_journey(journey_id):
if not require_login():
return jsonify({"error": "Authentication required"}), 401
journeys = get_journeys_for_current_user()
journey = next((j for j in journeys if j["id"] == journey_id), None)
if journey is None:
return jsonify({"error": "Journey 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})
# ==================== Blog Post endpoints (protected, userspecific) ====================
def get_posts_for_current_user():
user_id = get_current_user_id()
if user_id is None:
return None
return load_user_posts(user_id)
def get_next_post_id(posts):
if not posts:
return 1
return max(p["id"] for p in posts) + 1
@app.route("/api/blog-posts", methods=["GET"])
def get_blog_posts():
if not require_login():
return jsonify({"error": "Authentication required"}), 401
posts = get_posts_for_current_user()
return jsonify(posts)
@app.route("/api/blog-posts/<int:post_id>", methods=["GET"])
def get_blog_post(post_id):
if not require_login():
return jsonify({"error": "Authentication required"}), 401
posts = get_posts_for_current_user()
post = next((p for p in posts if p["id"] == post_id), None)
if not post:
return jsonify({"error": "Post not found"}), 404
return jsonify(post)
@app.route("/api/blog-posts", methods=["POST"])
def create_blog_post():
if not require_login():
return jsonify({"error": "Authentication required"}), 401
data = request.get_json()
title = data.get("title")
if not title:
return jsonify({"error": "Title required"}), 400
user_id = get_current_user_id()
posts = get_posts_for_current_user()
new_id = get_next_post_id(posts)
new_post = {
"id": new_id,
"title": title,
"content": data.get("content", ""),
"journeyId": data.get("journeyId"),
"image": data.get("image"),
"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"])
def health_check():
return jsonify({"status": "healthy", "timestamp": datetime.now().isoformat()})
@app.route("/")
def index():
return """
<!DOCTYPE html>
<html>
<head><title>Journey Mapper API</title></head>
<body>
<h1>Journey Mapper API</h1>
<p>Backend is running. Use <code>/api/journeys</code> endpoints.</p>
<p>Authentication required: register, login, then use session cookies.</p>
</body>
</html>
"""
if __name__ == "__main__":
app.run(debug=True, port=5000)

View File

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

View File

@ -0,0 +1,82 @@
[
{
"id": 1,
"title": "test",
"description": "sdfsf\n",
"markers": [
{
"lat": 48.22467264956519,
"lng": 9.536132812500002,
"title": "New Marker",
"date": "",
"description": "",
"videoUrl": ""
},
{
"lat": 49.937079756975294,
"lng": 8.789062500000002,
"title": "New Marker",
"date": "",
"description": "",
"videoUrl": ""
},
{
"lat": 50.583236614805905,
"lng": 9.689941406250002,
"title": "New Marker",
"date": "",
"description": "",
"videoUrl": ""
}
],
"created_at": "2026-03-01T19:02:15.679031"
},
{
"id": 2,
"title": "test 1",
"description": "sdfdsfafsfsdsf",
"markers": [
{
"lat": 48.705462895790575,
"lng": 2.4334716796875,
"title": "New Marker",
"date": "",
"description": "",
"videoUrl": ""
},
{
"lat": 47.37603463349758,
"lng": 2.0654296875000004,
"title": "New Marker",
"date": "",
"description": "",
"videoUrl": ""
},
{
"lat": 47.25686404408872,
"lng": 5.020751953125,
"title": "New Marker",
"date": "",
"description": "",
"videoUrl": ""
},
{
"lat": 47.25686404408872,
"lng": 5.954589843750001,
"title": "New Marker",
"date": "",
"description": "",
"videoUrl": ""
},
{
"lat": 47.357431944587034,
"lng": 7.289428710937501,
"title": "New Marker",
"date": "",
"description": "",
"videoUrl": ""
}
],
"created_at": "2026-03-05T13:00:48.757539"
}
]

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

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

View File

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

View File

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

8
backend/pyproject.toml Normal file
View File

@ -0,0 +1,8 @@
[project]
name = "backend"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"flask>=3.1.3",
"flask-cors>=6.0.2",
]

BIN
backend/requirements.txt Normal file

Binary file not shown.

174
backend/uv.lock generated Normal file
View File

@ -0,0 +1,174 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "backend"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "flask" },
{ name = "flask-cors" },
]
[package.metadata]
requires-dist = [
{ name = "flask", specifier = ">=3.1.3" },
{ name = "flask-cors", specifier = ">=6.0.2" },
]
[[package]]
name = "blinker"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "flask"
version = "3.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "blinker" },
{ name = "click" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "markupsafe" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
]
[[package]]
name = "flask-cors"
version = "6.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/70/74/0fc0fa68d62f21daef41017dafab19ef4b36551521260987eb3a5394c7ba/flask_cors-6.0.2.tar.gz", hash = "sha256:6e118f3698249ae33e429760db98ce032a8bf9913638d085ca0f4c5534ad2423", size = 13472, upload-time = "2025-12-12T20:31:42.861Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/af/72ad54402e599152de6d067324c46fe6a4f531c7c65baf7e96c63db55eaf/flask_cors-6.0.2-py3-none-any.whl", hash = "sha256:e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a", size = 13257, upload-time = "2025-12-12T20:31:41.3Z" },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "werkzeug"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" },
]

270
blog-list.html Normal file
View File

@ -0,0 +1,270 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blog Journey Mapper</title>
<!-- Open Props CSS -->
<link rel="stylesheet" href="https://unpkg.com/open-props"/>
<link rel="stylesheet" href="https://unpkg.com/open-props/normalize.min.css"/>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; }
body {
font-family: var(--font-sans);
background: var(--gray-0);
color: var(--gray-9);
line-height: var(--font-lineheight-3);
margin: 0;
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* Header */
.site-header {
background: var(--gray-9);
padding: var(--size-4) var(--size-6);
border-bottom: 1px solid var(--surface-4);
}
.site-header .container {
max-width: 1400px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: var(--size-2);
}
.site-title {
margin: 0;
font-size: var(--font-size-4);
font-weight: var(--font-weight-6);
}
.site-title a {
color: var(--indigo-4);
text-decoration: none;
}
.site-nav {
display: flex;
gap: var(--size-4);
}
.site-nav a {
color: var(--gray-2);
text-decoration: none;
font-weight: var(--font-weight-5);
transition: color 0.2s;
padding: var(--size-1) var(--size-2);
border-radius: var(--radius-2);
}
.site-nav a:hover,
.site-nav a.active {
color: var(--indigo-4);
background: var(--surface-2);
}
/* User menu */
.user-menu {
display: flex;
align-items: center;
gap: var(--size-2);
}
.user-menu .username {
color: var(--gray-2);
font-weight: var(--font-weight-5);
}
.logout-btn {
background: var(--indigo-7);
color: white;
border: none;
border-radius: var(--radius-2);
padding: var(--size-1) var(--size-3);
cursor: pointer;
font-size: var(--font-size-1);
font-weight: var(--font-weight-5);
transition: background 0.2s;
}
.logout-btn:hover {
background: var(--indigo-8);
}
/* Main content */
.blog-container {
max-width: 1200px;
margin: var(--size-6) auto;
padding: 0 var(--size-4);
}
.blog-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--size-6);
}
.blog-header h1 {
margin: 0;
font-size: var(--font-size-6);
color: var(--indigo-8);
}
.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:hover {
background: var(--indigo-8);
box-shadow: var(--shadow-3);
}
.posts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: var(--size-6);
}
.post-card {
background: var(--surface-1);
border-radius: var(--radius-3);
overflow: hidden;
box-shadow: var(--shadow-2);
transition: transform 0.2s;
}
.post-card:hover {
transform: translateY(-4px);
}
.post-card-image {
width: 100%;
height: 200px;
object-fit: cover;
background: var(--surface-3);
}
.post-card-content {
padding: var(--size-4);
}
.post-card-title {
margin: 0 0 var(--size-2) 0;
font-size: var(--font-size-4);
}
.post-card-title a {
color: var(--gray-9);
text-decoration: none;
}
.post-card-title a:hover {
color: var(--indigo-7);
}
.post-card-meta {
color: var(--gray-6);
font-size: var(--font-size-1);
margin-bottom: var(--size-3);
}
.post-card-excerpt {
color: var(--gray-7);
line-height: var(--font-lineheight-3);
}
.empty-state {
text-align: center;
color: var(--gray-6);
padding: var(--size-8);
font-style: italic;
}
.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="blog-container">
<div class="blog-header">
<h1><i class="fas fa-newspaper"></i> Blog Posts</h1>
<a href="blog-post-edit.html?new" class="btn"><i class="fas fa-plus"></i> New Post</a>
</div>
<div id="posts-grid" class="posts-grid">
<!-- Posts loaded dynamically -->
</div>
</main>
<div id="toast" class="toast"></div>
<script src="js/auth.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>

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

@ -0,0 +1,369 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Blog Post Journey Mapper</title>
<!-- Open Props CSS -->
<link rel="stylesheet" href="https://unpkg.com/open-props" />
<link rel="stylesheet" href="https://unpkg.com/open-props/normalize.min.css" />
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<style>
* { box-sizing: border-box; }
body {
font-family: var(--font-sans);
background: var(--gray-0);
color: var(--gray-9);
line-height: var(--font-lineheight-3);
margin: 0;
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* Header */
.site-header {
background: var(--gray-9);
padding: var(--size-4) var(--size-6);
border-bottom: 1px solid var(--surface-4);
}
.site-header .container {
max-width: 1400px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: var(--size-2);
}
.site-title {
margin: 0;
font-size: var(--font-size-4);
font-weight: var(--font-weight-6);
}
.site-title a {
color: var(--indigo-4);
text-decoration: none;
}
.site-nav {
display: flex;
gap: var(--size-4);
}
.site-nav a {
color: var(--gray-2);
text-decoration: none;
font-weight: var(--font-weight-5);
transition: color 0.2s;
padding: var(--size-1) var(--size-2);
border-radius: var(--radius-2);
}
.site-nav a:hover {
color: var(--indigo-4);
background: var(--surface-2);
}
/* User menu */
.user-menu {
display: flex;
align-items: center;
gap: var(--size-2);
}
.user-menu .username {
color: var(--gray-2);
font-weight: var(--font-weight-5);
}
.logout-btn {
background: var(--indigo-7);
color: white;
border: none;
border-radius: var(--radius-2);
padding: var(--size-1) var(--size-3);
cursor: pointer;
font-size: var(--font-size-1);
font-weight: var(--font-weight-5);
transition: background 0.2s;
}
.logout-btn:hover {
background: var(--indigo-8);
}
/* Main content */
.post-container {
max-width: 800px;
margin: var(--size-6) auto;
padding: 0 var(--size-4);
}
.post-form {
background: var(--surface-1);
border-radius: var(--radius-3);
padding: var(--size-6);
box-shadow: var(--shadow-2);
}
.form-group {
margin-bottom: var(--size-4);
}
label {
display: block;
margin-bottom: var(--size-2);
font-weight: var(--font-weight-6);
color: var(--gray-8);
}
input[type="text"],
textarea,
select {
width: 100%;
padding: var(--size-2) var(--size-3);
border: 1px solid var(--surface-4);
border-radius: var(--radius-2);
background: var(--surface-2);
color: var(--text-1);
font-family: inherit;
font-size: var(--font-size-2);
}
textarea {
min-height: 200px;
resize: vertical;
}
.image-preview {
margin-top: var(--size-2);
max-width: 100%;
border-radius: var(--radius-2);
overflow: hidden;
}
.image-preview img {
max-width: 100%;
height: auto;
display: block;
}
.button-group {
display: flex;
gap: var(--size-3);
margin-top: var(--size-6);
}
.btn {
padding: var(--size-2) var(--size-4);
border: none;
border-radius: var(--radius-2);
cursor: pointer;
font-size: var(--font-size-2);
font-weight: var(--font-weight-5);
display: inline-flex;
align-items: center;
gap: var(--size-2);
transition: all 0.2s var(--ease-2);
box-shadow: var(--shadow-2);
background: var(--gray-7);
color: white;
text-decoration: none;
}
.btn-primary {
background: var(--indigo-7);
}
.btn-primary:hover {
background: var(--indigo-8);
}
.btn-danger {
background: var(--red-7);
}
.btn-danger:hover {
background: var(--red-8);
}
.btn:hover {
box-shadow: var(--shadow-3);
}
.toast {
position: fixed;
bottom: var(--size-4);
right: var(--size-4);
background: var(--green-7);
color: white;
padding: var(--size-2) var(--size-4);
border-radius: var(--radius-2);
display: none;
z-index: 1100;
}
</style>
</head>
<body>
<header class="site-header">
<div class="container">
<h1 class="site-title"><a href="map-page.html">Journey Mapper</a></h1>
<div style="display: flex; align-items: center; gap: var(--size-4);">
<nav class="site-nav">
<a href="map-page.html">Map</a>
<a href="blog-list.html" class="active">Blog</a>
</nav>
<div class="user-menu" id="user-menu"></div>
</div>
</div>
</header>
<main class="post-container">
<div class="post-form">
<h2 id="form-title">Edit Post</h2>
<form id="post-form">
<div class="form-group">
<label for="post-title">Title</label>
<input type="text" id="post-title" required />
</div>
<div class="form-group">
<label for="post-content">Content</label>
<textarea id="post-content" rows="10" required></textarea>
</div>
<div class="form-group">
<label for="post-journey">Associated Journey ID (optional)</label>
<input type="text" id="post-journey" placeholder="e.g., 3" />
</div>
<div class="form-group">
<label for="post-image">Image URL or Upload</label>
<input type="text" id="post-image-url" placeholder="https://..." />
<div style="margin: 8px 0; text-align: center">or</div>
<input type="file" id="image-upload" accept="image/*" />
<div class="image-preview" id="image-preview"></div>
</div>
<div class="button-group">
<button type="submit" class="btn btn-primary"><i class="fas fa-save"></i> Save</button>
<button type="button" id="delete-post" class="btn btn-danger"><i class="fas fa-trash"></i> Delete</button>
<a href="blog-list.html" class="btn"><i class="fas fa-times"></i> Cancel</a>
</div>
</form>
</div>
</main>
<div id="toast" class="toast"></div>
<script src="js/auth.js"></script>
<script>
let currentPostId = null;
// ==================== POST CRUD ====================
const urlParams = new URLSearchParams(window.location.search);
const postId = urlParams.get('id');
const isNew = urlParams.has('new');
async function loadPost(id) {
try {
const res = await fetch(`${API_BASE}/blog-posts/${id}`, { credentials: 'include' });
if (!res.ok) throw new Error('Post not found');
const post = await res.json();
currentPostId = post.id;
document.getElementById('post-title').value = post.title;
document.getElementById('post-content').value = post.content;
document.getElementById('post-journey').value = post.journeyId || '';
document.getElementById('post-image-url').value = post.image || '';
updateImagePreview(post.image);
document.getElementById('form-title').textContent = 'Edit Post';
} catch (err) {
showToast('Error loading post', true);
}
}
function updateImagePreview(url) {
const preview = document.getElementById('image-preview');
if (url) {
preview.innerHTML = `<img src="${url}" alt="Preview" style="max-width:100%; border-radius: var(--radius-2);">`;
} else {
preview.innerHTML = '';
}
}
async function savePost(event) {
event.preventDefault();
const title = document.getElementById('post-title').value.trim();
const content = document.getElementById('post-content').value.trim();
const journeyId = document.getElementById('post-journey').value.trim();
let image = document.getElementById('post-image-url').value.trim();
if (!title || !content) {
showToast('Title and content are required');
return;
}
const payload = { title, content, journeyId: journeyId || null, image: image || null };
try {
let url, method;
if (isNew) {
url = `${API_BASE}/blog-posts`;
method = 'POST';
} else {
url = `${API_BASE}/blog-posts/${currentPostId}`;
method = 'PUT';
}
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
credentials: 'include'
});
if (!res.ok) throw new Error('Save failed');
const data = await res.json();
showToast('Post saved successfully');
if (isNew) {
window.location.href = `blog-post-edit.html?id=${data.id}`;
}
} catch (err) {
showToast('Error saving post', true);
}
}
async function deletePost() {
if (!currentPostId) return;
if (!confirm('Delete this post?')) return;
try {
const res = await fetch(`${API_BASE}/blog-posts/${currentPostId}`, {
method: 'DELETE',
credentials: 'include'
});
if (!res.ok) throw new Error('Delete failed');
showToast('Post deleted');
setTimeout(() => { window.location.href = 'blog-list.html'; }, 1000);
} catch (err) {
showToast('Error deleting post', true);
}
}
// Image upload (convert to base64)
document.getElementById('image-upload').addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(evt) {
const base64 = evt.target.result;
document.getElementById('post-image-url').value = base64;
updateImagePreview(base64);
};
reader.readAsDataURL(file);
});
document.getElementById('post-form').addEventListener('submit', savePost);
document.getElementById('delete-post').addEventListener('click', deletePost);
// ==================== INITIALIZATION ====================
document.addEventListener('DOMContentLoaded', async () => {
const authenticated = await checkAuthAndRedirect();
if (!authenticated) return;
updateUserMenu();
if (!isNew && postId) {
loadPost(postId);
} else if (isNew) {
currentPostId = null;
document.getElementById('form-title').textContent = 'New Post';
document.getElementById('delete-post').style.display = 'none';
} else {
// No id and not new redirect to list
window.location.href = 'blog-list.html';
}
});
</script>
</body>
</html>

154
css/blog.css Normal file
View File

@ -0,0 +1,154 @@
/* Blog page styles - mobile first, matching existing palette */
/* Layout container override (keeps existing .container behavior) */
.post-page {
display: flex;
flex-direction: column;
gap: 24px;
padding: 20px 16px;
}
.post {
background: #ffffff;
border-radius: 10px;
padding: 18px;
box-shadow: 0 6px 18px rgba(37, 51, 66, 0.06);
max-width: 900px;
margin: 0 auto;
}
.post-title {
font-size: 1.6rem;
margin: 0 0 8px 0;
color: #253342;
font-weight: 600;
}
.post-meta {
color: #7f8c8d;
font-size: 0.9rem;
margin-bottom: 12px;
}
.post-hero img {
display: block;
width: 100%;
height: auto;
border-radius: 8px;
margin: 12px 0;
}
.post-content p {
line-height: 1.7;
color: #2c3e50;
margin: 0 0 1rem 0;
}
.sidebar {
margin: 0 auto;
max-width: 320px;
}
.sidebar .about {
background: #f8f9fa;
padding: 14px;
border-radius: 8px;
color: #2c3e50;
}
.comments {
max-width: 900px;
margin: 0 auto 32px auto;
padding: 0 16px;
}
.comments-list {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.comment-item {
background: #ffffff;
border-radius: 8px;
padding: 12px;
border: 1px solid rgba(37, 51, 66, 0.06);
}
.comment-item .meta {
color: #7f8c8d;
font-size: 0.85rem;
margin-bottom: 6px;
}
.site-header {
background-color: #253342;
color: #ecf0f1;
padding: 14px 16px;
}
.site-header .site-title {
margin: 0;
font-size: 1.1rem;
}
.site-header .site-title a {
color: #3498db;
text-decoration: none;
}
.site-nav {
margin-top: 8px;
display: flex;
gap: 12px;
}
.site-nav a {
color: #ecf0f1;
text-decoration: none;
font-weight: 500;
}
.site-footer {
background: #f7f8f9;
padding: 16px;
margin-top: 30px;
}
/* Larger screens: two-column layout */
@media (min-width: 768px) {
.post-page {
flex-direction: row;
align-items: flex-start;
gap: 28px;
padding: 28px;
}
.post {
flex: 1 1 0;
margin: 0;
}
.sidebar {
width: 320px;
flex: 0 0 320px;
margin: 0;
align-self: flex-start;
}
}
/* Small visual tweaks to match the rest of the site */
.post h3, .sidebar h3 {
color: #253342;
margin-top: 0;
}
.btn-inline {
display: inline-block;
padding: 8px 12px;
background: #3498db;
color: white;
border-radius: 6px;
text-decoration: none;
}

586
css/map.css Normal file
View File

@ -0,0 +1,586 @@
/* Map Page Specific Styles */
/* App Layout */
.app-container {
display: flex;
height: 100vh;
overflow: hidden;
}
.sidebar {
width: 300px;
min-width: 300px;
background-color: #253342;
color: white;
padding: 20px;
}
.map-area {
flex: 1;
width: calc(100% - 300px);
}
#map {
width: 100%;
height: 100vh;
}
.map-area {
flex: 1;
}
.sidebar-header {
padding: 20px;
border-bottom: 1px solid #34495e;
}
.sidebar-header h1 {
margin: 0;
font-size: 1.5rem;
display: flex;
align-items: center;
gap: 10px;
}
.tagline {
margin: 5px 0 0;
font-size: 0.85rem;
color: #bdc3c7;
font-family: 'Roboto', sans-serif;
}
/* Mode Selector */
.mode-selector {
display: flex;
padding: 15px;
gap: 10px;
border-bottom: 1px solid #34495e;
}
.mode-btn {
flex: 1;
padding: 10px;
background-color: #34495e;
border: none;
color: #ecf0f1;
border-radius: 5px;
cursor: pointer;
font-family: 'Poppins', sans-serif;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.2s ease;
}
.mode-btn:hover {
background-color: #3d566e;
}
.mode-btn.active {
background-color: #3498db;
box-shadow: 0 2px 5px rgba(52, 152, 219, 0.3);
}
/* Panels */
.panel {
padding: 20px;
border-bottom: 1px solid #34495e;
display: none;
}
.panel.active-panel {
display: block;
}
.panel h3 {
margin-top: 0;
display: flex;
align-items: center;
gap: 10px;
font-size: 1.2rem;
}
/* Form Styles */
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
display: flex;
align-items: center;
gap: 5px;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 10px;
border-radius: 5px;
border: 1px solid #34495e;
background-color: #34495e;
color: #ecf0f1;
font-family: 'Roboto', sans-serif;
}
.form-group textarea {
min-height: 80px;
resize: vertical;
}
/* Instructions */
.instructions {
background-color: rgba(52, 73, 94, 0.5);
border-radius: 5px;
padding: 15px;
margin: 15px 0;
}
.instructions h4 {
margin-top: 0;
display: flex;
align-items: center;
gap: 8px;
}
.instructions ol {
margin: 10px 0 0;
padding-left: 20px;
}
.instructions li {
margin-bottom: 5px;
font-size: 0.9rem;
}
/* Buttons */
.button-group {
display: flex;
gap: 10px;
margin-top: 15px;
}
.btn {
padding: 10px 15px;
border: none;
border-radius: 5px;
cursor: pointer;
font-family: 'Poppins', sans-serif;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.2s ease;
}
.btn-primary {
background-color: #3498db;
color: white;
}
.btn-primary:hover {
background-color: #2980b9;
}
.btn-secondary {
background-color: #7f8c8d;
color: white;
}
.btn-secondary:hover {
background-color: #6c7b7d;
}
.btn-danger {
background-color: #e74c3c;
color: white;
}
.btn-danger:hover {
background-color: #c0392b;
}
.btn-small {
padding: 5px 10px;
font-size: 0.85rem;
}
/* Filter Options */
.filter-options {
margin: 15px 0;
}
.checkbox-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
/* Journey Info */
.journey-info {
background-color: rgba(52, 73, 94, 0.5);
border-radius: 5px;
padding: 15px;
margin-top: 15px;
}
.info-content p {
margin: 8px 0;
}
/* Markers List */
.markers-list {
flex: 1;
padding: 20px;
overflow-y: auto;
}
.markers-list h3 {
display: flex;
align-items: center;
gap: 10px;
margin-top: 0;
}
#markers-container {
margin-top: 15px;
}
.empty-message {
text-align: center;
color: #7f8c8d;
font-style: italic;
padding: 20px;
}
/* Journey Panel Styles */
.journey-panel {
padding: 20px;
}
.journey-form .form-group {
margin-bottom: 15px;
}
.journey-form label {
display: block;
margin-bottom: 5px;
}
.marker-item {
padding: 8px;
margin: 3px 0;
background-color: #f8f9fa;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.marker-item:hover {
background-color: #e9ecef;
}
.button-group {
display: flex;
gap: 10px;
margin-top: 15px;
}
.btn {
padding: 8px 12px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-primary {
background-color: #3498db;
color: white;
}
.btn-success {
background-color: #2ecc71;
color: white;
}
.btn-danger {
background-color: #e74c3c;
color: white;
}
/* Footer */
.sidebar-footer {
padding: 15px 20px;
border-top: 1px solid #34495e;
background-color: #253342;
}
.navigation {
display: flex;
justify-content: space-around;
margin-bottom: 10px;
}
.nav-link {
color: #3498db;
text-decoration: none;
display: flex;
align-items: center;
gap: 5px;
font-size: 0.9rem;
}
.nav-link:hover {
text-decoration: underline;
}
.footer-text {
text-align: center;
font-size: 0.8rem;
color: #7f8c8d;
margin: 0;
}
#map {
width: 100%;
height: 100%;
}
/* Map Controls */
.map-controls {
position: absolute;
top: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: 1;
}
.control-btn {
width: 40px;
height: 40px;
border-radius: 5px;
background-color: white;
border: none;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
color: #2c3e50;
transition: all 0.2s ease;
}
.control-btn:hover {
background-color: #f8f9fa;
transform: translateY(-2px);
}
/* Mode Indicator */
.mode-indicator {
position: absolute;
top: 20px;
left: 20px;
background-color: rgba(255, 255, 255, 0.9);
padding: 10px 15px;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
gap: 10px;
z-index: 1;
}
.indicator-text {
font-weight: 500;
color: #2c3e50;
}
.indicator-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.indicator-dot.creating {
background-color: #3498db;
}
.indicator-dot.viewing {
background-color: #2ecc71;
}
/* Modal */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 100;
align-items: center;
justify-content: center;
}
.modal.active {
display: flex;
}
.modal-content {
background-color: white;
border-radius: 10px;
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2);
}
.modal-header {
padding: 20px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
display: flex;
align-items: center;
gap: 10px;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #7f8c8d;
}
.close-btn:hover {
color: #2c3e50;
}
.modal-body {
padding: 20px;
}
/* Image Upload */
.image-upload-area {
border: 2px dashed #ddd;
border-radius: 5px;
padding: 15px;
margin-top: 5px;
}
.image-preview {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 15px;
min-height: 60px;
}
.upload-actions {
display: flex;
gap: 10px;
}
.help-text {
display: block;
margin-top: 5px;
color: #7f8c8d;
font-size: 0.85rem;
}
.coordinates {
padding: 10px;
background-color: #f8f9fa;
border-radius: 5px;
margin-top: 15px;
}
.modal-footer {
padding: 15px 20px;
border-top: 1px solid #eee;
display: flex;
gap: 10px;
justify-content: flex-end;
}
/* Toast */
.toast {
position: fixed;
bottom: 20px;
right: 20px;
background-color: #2ecc71;
color: white;
padding: 15px 20px;
border-radius: 5px;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
display: none;
z-index: 100;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* 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: 20px;
right: 20px;
flex-direction: row;
}
.mode-indicator {
top: 10px;
left: 10px;
font-size: 0.9rem;
}
}

47
css/style.css Normal file
View File

@ -0,0 +1,47 @@
/* Mobile-first approach */
.map-container {
height: 100vh;
width: 100%;
}
/* Tablet and larger */
@media (min-width: 768px) {
.sidebar {
width: 350px;
}
}
/* Journey timeline styles */
.journey-timeline {
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
.timeline-event {
border-left: 3px solid #3498db;
padding: 15px 20px;
margin: 20px 0;
background-color: #f9f9f9;
}
.timeline-event .date {
color: #7f8c8d;
font-style: italic;
}
.timeline-event .location {
font-size: 0.9em;
color: #2c3e50;
}
.images-container {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.images-container img {
max-width: 100px;
height: auto;
}

67
js/auth.js Normal file
View File

@ -0,0 +1,67 @@
const API_BASE = "http://127.0.0.1:5000/api";
let currentUser = null;
async function checkAuth() {
try {
const res = await fetch(`${API_BASE}/me`, { credentials: "include" });
if (res.ok) {
currentUser = await res.json();
return true;
}
return false;
} catch (err) {
return false;
}
}
async function checkAuthAndRedirect() {
const ok = await checkAuth();
if (!ok) {
window.location.href = "login.html";
return false;
}
return true;
}
function updateUserMenu() {
const container = document.getElementById("user-menu");
if (!container) return;
if (currentUser) {
container.innerHTML = `
<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>
`;
document.getElementById("logout-btn")?.addEventListener("click", logout);
} else {
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";
});
}
}
async function logout() {
await fetch(`${API_BASE}/logout`, { method: "POST", credentials: "include" });
window.location.href = "login.html";
}
function escapeHtml(str) {
if (!str) return "";
return str.replace(/[&<>]/g, function (m) {
if (m === "&") return "&amp;";
if (m === "<") return "&lt;";
if (m === ">") return "&gt;";
return m;
});
}
function showToast(msg, isError = false) {
const toast = document.getElementById("toast");
if (!toast) return;
toast.textContent = msg;
toast.style.backgroundColor = isError ? "var(--red-7)" : "var(--green-7)";
toast.style.display = "block";
setTimeout(() => {
toast.style.display = "none";
}, 3000);
}

174
js/blog-posts.js Normal file
View File

@ -0,0 +1,174 @@
/* Minimal blog-posts.js
- Stores posts in localStorage under `blogPosts`
- Posts: {id, title, content, image, journeyId, created_at}
- Supports create, edit, delete, view
*/
const STORAGE_KEY = 'blogPosts';
function loadPosts() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
} catch (e) {
console.error('Failed to parse posts', e);
return [];
}
}
function savePosts(posts) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(posts));
}
function getNextId(posts) {
if (!posts || posts.length === 0) return 1;
return Math.max(...posts.map(p => p.id)) + 1;
}
function renderPostList() {
const posts = loadPosts().sort((a,b)=> new Date(b.created_at) - new Date(a.created_at));
const container = document.getElementById('posts-list');
container.innerHTML = '';
if (posts.length === 0) {
container.innerHTML = '<p class="empty-message">No posts yet.</p>';
return;
}
posts.forEach(post => {
const el = document.createElement('div');
el.className = 'marker-item';
el.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px;">
<div style="flex:1">
<strong>${escapeHtml(post.title)}</strong>
<div style="font-size:.85rem;color:#7f8c8d">${new Date(post.created_at).toLocaleString()}${post.journeyId ? ' • Journey '+post.journeyId : ''}</div>
</div>
<div style="display:flex;gap:6px">
<button class="btn btn-small" data-action="view" data-id="${post.id}">View</button>
<button class="btn btn-small" data-action="edit" data-id="${post.id}">Edit</button>
<button class="btn btn-small btn-danger" data-action="delete" data-id="${post.id}">Delete</button>
</div>
</div>
`;
container.appendChild(el);
});
}
function escapeHtml(s){
return String(s || '').replace(/[&<>"']/g, (m)=>({
'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
}[m]));
}
function showPost(id) {
const posts = loadPosts();
const post = posts.find(p=>p.id===id);
if (!post) return;
const titleEl = document.querySelector('.post-title');
const metaEl = document.querySelector('.post-meta');
const imgEl = document.querySelector('.post-hero img');
const contentEl = document.querySelector('.post-content');
titleEl.textContent = post.title;
metaEl.innerHTML = `By <span class="author">Author</span> — <time datetime="${post.created_at}">${new Date(post.created_at).toLocaleString()}</time>`;
if (post.image) imgEl.src = post.image; else imgEl.src = 'https://via.placeholder.com/800x350?text=Hero+Image';
contentEl.innerHTML = post.content;
}
function openForm(mode='create', post=null) {
const formWrap = document.getElementById('post-form');
formWrap.style.display = 'block';
document.getElementById('form-title').textContent = mode === 'create' ? 'New Post' : 'Edit Post';
const form = document.getElementById('blog-form');
form.dataset.mode = mode;
form.dataset.id = post ? post.id : '';
document.getElementById('post-title-input').value = post ? post.title : '';
document.getElementById('post-journey-input').value = post ? (post.journeyId || '') : '';
document.getElementById('post-image-input').value = post ? (post.image || '') : '';
document.getElementById('post-content-input').value = post ? post.content : '';
}
function closeForm() {
const formWrap = document.getElementById('post-form');
formWrap.style.display = 'none';
const form = document.getElementById('blog-form');
form.removeAttribute('data-id');
}
function deletePost(id) {
if (!confirm('Delete this post?')) return;
let posts = loadPosts();
posts = posts.filter(p=>p.id!==id);
savePosts(posts);
renderPostList();
// if the shown post was deleted, clear article
const currentTitle = document.querySelector('.post-title').textContent;
if (!posts.find(p=>p.title===currentTitle)) {
document.querySelector('.post-title').textContent = 'Post Title';
document.querySelector('.post-content').innerHTML = '<p>No post selected.</p>';
}
}
document.addEventListener('DOMContentLoaded', ()=>{
renderPostList();
document.getElementById('create-post-btn').addEventListener('click', ()=> openForm('create'));
document.getElementById('cancel-post-btn').addEventListener('click', (e)=>{ e.preventDefault(); closeForm(); });
document.getElementById('posts-list').addEventListener('click', (e)=>{
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const id = parseInt(btn.dataset.id,10);
const action = btn.dataset.action;
if (action === 'view') showPost(id);
if (action === 'edit') {
const posts = loadPosts();
const post = posts.find(p=>p.id===id);
openForm('edit', post);
}
if (action === 'delete') deletePost(id);
});
document.getElementById('blog-form').addEventListener('submit', (e)=>{
e.preventDefault();
const form = e.target;
const mode = form.dataset.mode || 'create';
const id = parseInt(form.dataset.id,10) || null;
const title = document.getElementById('post-title-input').value.trim();
const journeyId = document.getElementById('post-journey-input').value.trim() || null;
const image = document.getElementById('post-image-input').value.trim() || '';
const content = document.getElementById('post-content-input').value.trim();
let posts = loadPosts();
if (mode === 'create') {
const newPost = {
id: getNextId(posts),
title,
content,
image,
journeyId: journeyId || null,
created_at: new Date().toISOString()
};
posts.push(newPost);
savePosts(posts);
renderPostList();
showPost(newPost.id);
} else if (mode === 'edit' && id) {
const idx = posts.findIndex(p=>p.id===id);
if (idx !== -1) {
posts[idx].title = title;
posts[idx].content = content;
posts[idx].image = image;
posts[idx].journeyId = journeyId || null;
savePosts(posts);
renderPostList();
showPost(id);
}
}
closeForm();
});
});

37
js/journey-post.js Normal file
View File

@ -0,0 +1,37 @@
function displayJourney(journey) {
document.getElementById('journey-title').textContent = journey.name;
const container = document.getElementById('timeline-container');
container.innerHTML = '';
journey.markers.forEach(marker => {
const markerEl = document.createElement('div');
markerEl.className = 'timeline-event';
markerEl.innerHTML = `
<h3>${marker.content.title || 'Untitled Event'}</h3>
<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>
`;
container.appendChild(markerEl);
});
}
// Get journey ID from URL
const urlParams = new URLSearchParams(window.location.search);
const journeyId = urlParams.get('id');
// Load journey data from localStorage
if (journeyId) {
loadJourneysFromLocalStorage();
const journey = journeys.find(j => j.id === parseInt(journeyId));
if (journey) {
displayJourney(journey);
} else {
document.getElementById('journey-title').textContent = 'Journey not found';
}
}

553
js/main.js Normal file
View File

@ -0,0 +1,553 @@
// /Volumes/Data/Code/FHGR/Frontend/js/main.js
document.addEventListener('DOMContentLoaded', function() {
// Journey Management
let currentJourney = {
name: "",
description: "",
markers: []
};
function saveJourney() {
const journeyData = {
name: document.getElementById('journey-name').value,
description: document.getElementById('journey-desc').value,
markers: currentJourney.markers.map(marker => ({
lat: marker.getLatLng().lat,
lng: marker.getLatLng().lng
}))
};
// Save to backend
fetch('/api/journeys', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(journeyData)
})
.then(response => response.json())
.then(data => {
console.log('Journey saved:', data);
alert('Journey saved successfully!');
currentJourney = {
name: "",
description: "",
markers: []
};
document.getElementById('journey-name').value = '';
document.getElementById('journey-desc').value = '';
})
.catch(error => console.error('Error saving journey:', error));
}
function updateMarkersList() {
const container = document.getElementById('markers-list');
container.innerHTML = '';
currentJourney.markers.forEach((marker, index) => {
const markerElement = document.createElement('div');
markerElement.className = 'marker-item';
markerElement.innerHTML = `
<strong>${index + 1}</strong>
${marker.getLatLng().lat.toFixed(4)}, ${marker.getLngLat().lng.toFixed(4)}
`;
// Add click handler to focus on marker
markerElement.addEventListener('click', () => {
map.flyTo(marker.getLatLng(), 10);
});
container.appendChild(markerElement);
});
}
// Event Listeners
document.getElementById('save-journey').addEventListener('click', saveJourney);
// Initialize current journey when page loads
window.currentJourney = {
id: Date.now(),
name: "",
description: "",
markers: [],
path: null
};
// Function to prepare and save the journey
function prepareAndSaveJourney() {
const journeyData = {
name: document.getElementById('journey-title').value,
description: document.getElementById('journey-description').value,
markers: window.currentJourney.markers.map(marker => ({
id: marker.id,
lngLat: [marker.getLatLng().lat, marker.getLatLng().lng],
content: marker.content
}))
};
// Save to backend
fetch('http://localhost:5000/api/journeys', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(journeyData)
})
.then(response => response.json())
.then(data => {
alert('Journey saved successfully!');
window.currentJourney = {
id: Date.now(),
name: "",
description: "",
markers: [],
path: null
};
document.getElementById('journey-title').value = '';
document.getElementById('journey-description').value = '';
})
.catch(error => {
console.error('Error:', error);
alert('Failed to save journey. Please try again.');
});
}
// Event listeners for the buttons
document.getElementById('add-marker-btn').addEventListener('click', function() {
map.on('click', function(e) {
const marker = L.marker(e.latlng, {draggable: true}).addTo(map);
// Add popup with input field
marker.bindPopup('<input type="text" id="marker-title" placeholder="Enter title">');
window.currentJourney.markers.push(marker);
updateMarkersList();
});
});
document.getElementById('save-journey-btn').addEventListener('click', prepareAndSaveJourney);
document.getElementById('clear-markers-btn').addEventListener('click', function() {
map.eachLayer(function(layer) {
if (layer instanceof L.Marker) {
map.removeLayer(layer);
}
});
window.currentJourney.markers = [];
updateMarkersList();
});
function updateMarkersList() {
const markersContainer = document.getElementById('markers-container');
markersContainer.innerHTML = '';
if (window.currentJourney.markers.length === 0) {
markersContainer.innerHTML = '<p class="empty-message">No markers yet. Click on the map to add markers.</p>';
return;
}
window.currentJourney.markers.forEach((marker, index) => {
const markerElement = document.createElement('div');
markerElement.className = 'marker-item';
markerElement.innerHTML = `
<strong>${index + 1}</strong>
${marker.getLatLng().lat.toFixed(4)}, ${marker.getLngLat().lng.toFixed(4)}
`;
// Add click event to edit marker
markerElement.addEventListener('click', () => {
marker.openPopup();
});
markersContainer.appendChild(markerElement);
});
}
window.journeys = [];
window.isCreatingJourney = true;
// Initialize the map
const map = L.map('map').setView([8.5, 47.3], 10);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(map);
// Add navigation controls
L.control.scale().addTo(map);
// Add geolocate control
map.addControl(new maplibregl.GeolocateControl({
positionOptions: {
enableHighAccuracy: true
},
trackUserLocation: true
}));
// Add fullscreen control
map.addControl(L.control.fullscreen());
// Mode switching
const modeCreateBtn = document.getElementById('mode-create');
const modeViewBtn = document.getElementById('mode-view');
const createPanel = document.getElementById('create-panel');
const viewPanel = document.getElementById('view-panel');
const markersContainer = document.getElementById('markers-container');
const emptyMarkers = document.getElementById('empty-markers');
function switchMode(mode) {
if (mode === 'create') {
modeCreateBtn.classList.add('active');
modeViewBtn.classList.remove('active');
createPanel.classList.add('active-panel');
viewPanel.classList.remove('active-panel');
// Enable marker adding
window.isCreatingJourney = true;
} else { // view mode
modeCreateBtn.classList.remove('active');
modeViewBtn.classList.add('active');
createPanel.classList.remove('active-panel');
viewPanel.classList.add('active-panel');
// Disable marker adding
window.isCreatingJourney = false;
}
}
modeCreateBtn.addEventListener('click', () => switchMode('create'));
modeViewBtn.addEventListener('click', () => switchMode('view'));
async function loadJourneysFromBackend() {
try {
const response = await fetch('http://localhost:5000/api/journeys');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const journeysData = await response.json();
// Ensure we always have an array
if (!Array.isArray(journeysData)) {
console.warn('Expected array of journeys, got:', journeysData);
window.journeys = [];
return [];
}
window.journeys = journeysData;
populateJourneySelect(journeysData);
return journeysData;
} catch (err) {
console.error('Error loading journeys from backend:', err);
// Fallback to local storage
const savedJourneys = localStorage.getItem('journeys');
if (savedJourneys) {
try {
const parsedJourneys = JSON.parse(savedJourneys);
if (Array.isArray(parsedJourneys)) {
window.journeys = parsedJourneys;
populateJourneySelect(window.journeys);
return window.journeys;
} else {
console.warn('Saved journeys are not an array:', parsedJourneys);
window.journeys = [];
populateJourneySelect([]);
return [];
}
} catch (parseError) {
console.error('Error parsing saved journeys:', parseError);
window.journeys = [];
populateJourneySelect([]);
return [];
}
}
// If no data available, initialize empty array
window.journeys = [];
populateJourneySelect([]);
return [];
}
}
async function saveJourneyToBackend(journey) {
try {
const response = await fetch('http://localhost:5000/api/journeys', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
id: journey.id,
name: journey.name,
description: journey.description,
markers: journey.markers.map(marker => ({
lngLat: marker.getLngLat().toArray(),
content: marker.content
}))
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const journeyData = await response.json();
console.log('Journey saved:', journeyData);
return journeyData;
} catch (err) {
console.error('Error saving journey to backend:', err);
// Fallback to local storage
const savedJourneys = localStorage.getItem('journeys') || '[]';
const journeys = JSON.parse(savedJourneys);
// Update or add the journey
const existingIndex = journeys.findIndex(j => j.id === journey.id);
if (existingIndex !== -1) {
journeys[existingIndex] = {
id: journey.id,
name: journey.name,
description: journey.description,
markers: journey.markers.map(marker => ({
id: marker.id,
lngLat: marker.getLngLat(),
content: marker.content
}))
};
} else {
journeys.push({
id: journey.id,
name: journey.name,
description: journey.description,
markers: journey.markers.map(marker => ({
id: marker.id,
lngLat: marker.getLngLat(),
content: marker.content
}))
});
}
localStorage.setItem('journeys', JSON.stringify(journeys));
console.log('Journey saved to local storage');
return journey;
}
}
async function loadCurrentJourneyFromBackend(journeyId) {
try {
const response = await fetch(`http://localhost:5000/api/journeys/${journeyId}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const journeyData = await response.json();
// Update the current journey object
window.currentJourney = {
id: journeyData.id,
name: journeyData.name,
description: journeyData.description,
markers: [],
path: null
};
// Create markers from the journey data
if (Array.isArray(journeyData.markers)) {
journeyData.markers.forEach(markerData => {
const lngLat = maplibregl.LngLat.fromArray(markerData.lngLat);
const marker = window.createMarker(lngLat, markerData.content);
window.currentJourney.markers.push(marker);
});
}
// Update the journey path
updateJourneyPath();
return journeyData;
} catch (err) {
console.error('Error loading journey from backend:', err);
// Fallback to local storage
const savedJourneys = localStorage.getItem('journeys');
if (savedJourneys) {
try {
const journeys = JSON.parse(savedJourneys);
if (Array.isArray(journeys)) {
const journey = journeys.find(j => j.id === journeyId);
if (journey) {
// Update the current journey object
window.currentJourney = {
id: journey.id,
name: journey.name,
description: journey.description,
markers: [],
path: null
};
// Create markers from the journey data
if (Array.isArray(journey.markers)) {
journey.markers.forEach(markerData => {
const lngLat = maplibregl.LngLat.fromArray(markerData.lngLat);
const marker = window.createMarker(lngLat, markerData.content);
window.currentJourney.markers.push(marker);
});
}
// Update the journey path
updateJourneyPath();
console.log('Journey loaded from local storage');
return journey;
}
} else {
console.warn('Saved journeys are not an array:', journeys);
}
} catch (parseError) {
console.error('Error parsing saved journeys:', parseError);
}
}
// If no data available, return null
return null;
}
}
// Update the journey select dropdown
function populateJourneySelect(journeys) {
const select = document.getElementById('journey-select');
select.innerHTML = '<option value="">-- Choose a journey --</option>';
select.innerHTML += '<option value="all">Show All Journeys</option>';
// Sort journeys by name for consistent ordering
const sortedJourneys = [...journeys].sort((a, b) => a.name.localeCompare(b.name));
sortedJourneys.forEach(journey => {
const option = document.createElement('option');
option.value = journey.id;
option.textContent = journey.name;
select.appendChild(option);
});
}
// Toggle sidebar
document.getElementById('toggle-sidebar').addEventListener('click', function() {
document.querySelector('.sidebar').classList.toggle('collapsed');
});
// Initialize in create mode
switchMode('create');
// Load journeys from backend when the page loads
loadJourneysFromBackend().then(() => {
// If there are journeys, set the first one as the current journey
if (window.journeys.length > 0) {
// Set the first journey as current
const firstJourney = window.journeys[0];
loadCurrentJourneyFromBackend(firstJourney.id).then(() => {
// Update the journey title and description
document.getElementById('journey-title').value = currentJourney.name;
document.getElementById('journey-description').value = currentJourney.description;
});
}
});
// Add journey selection functionality
document.getElementById('journey-select').addEventListener('change', function() {
const selectedId = this.value;
if (selectedId === 'all') {
// Show all journeys
// Implementation depends on how you want to display multiple journeys
return;
}
if (selectedId) {
// Load the selected journey
loadCurrentJourneyFromBackend(selectedId).then(() => {
// Update the journey title and description
document.getElementById('journey-title').value = currentJourney.name;
document.getElementById('journey-description').value = currentJourney.description;
// Update the journey info panel
document.getElementById('info-title').textContent = currentJourney.name;
document.getElementById('info-description').textContent = currentJourney.description;
document.getElementById('info-marker-count').textContent = currentJourney.markers.length;
document.getElementById('info-date').textContent = new Date().toLocaleDateString();
});
}
});
// View blog post button
document.getElementById('view-blog').addEventListener('click', function() {
// Implementation depends on your blog system
alert('Viewing blog post for this journey...');
});
// Edit journey button
document.getElementById('edit-journey').addEventListener('click', function() {
// Switch to create mode
switchMode('create');
// Update the journey title and description
document.getElementById('journey-title').value = currentJourney.name;
document.getElementById('journey-description').value = currentJourney.description;
});
// Delete journey button
document.getElementById('delete-journey').addEventListener('click', async function() {
if (confirm('Are you sure you want to delete this journey?')) {
try {
const response = await fetch(`http://localhost:5000/api/journeys/${currentJourney.id}`, {
method: 'DELETE'
});
if (response.ok) {
// Remove from the journeys array
const index = window.journeys.findIndex(j => j.id === currentJourney.id);
if (index !== -1) {
window.journeys.splice(index, 1);
}
// Update the journey select dropdown
populateJourneySelect(window.journeys);
// Clear the current journey
currentJourney = {
id: Date.now(),
name: "Untitled Journey",
description: "",
markers: [],
path: null
};
// Clear the map
map.getSource('journey-path').setData({
type: 'Feature',
properties: {},
geometry: {
type: 'LineString',
coordinates: []
}
});
// Clear the form
document.getElementById('journey-title').value = '';
document.getElementById('journey-description').value = '';
alert('Journey deleted successfully!');
} else {
alert('Failed to delete journey.');
}
} catch (err) {
console.error('Error deleting journey:', err);
// Fallback to local storage
const savedJourneys = localStorage.getItem('journeys');
if (savedJourneys) {
try {
const journeys = JSON.parse(savedJourneys);
if (Array.isArray(journeys)) {
const index = journeys.findIndex(j => j.id === currentJourney.id);
if (index !== -1) {
journeys.splice(index, 1);
localStorage.setItem('journeys', JSON.stringify(journeys));
}
}
} catch (parseError) {
console.error('Error parsing saved journeys:', parseError);
}
}
alert('Journey deleted successfully (local storage)');
}
}
});
});

101
js/map.js Normal file
View File

@ -0,0 +1,101 @@
// /Volumes/Data/Code/FHGR/Frontend/js/map.js
// Add the createMarker function to the window object
window.createMarker = function(lngLat, content = {}) {
const marker = L.marker(lngLat, {
draggable: true,
title: content.title || 'Untitled'
}).addTo(map);
// Create popup with marker content
marker.bindPopup(`<strong>${content.title || 'Untitled'}</strong>${content.description ? `<br>${content.description}` : ''}`);
// When the marker is clicked, open the editor
marker.on('click', () => {
openMarkerEditor(marker);
});
// Add marker to current journey
const markerData = {
id: Date.now(),
lngLat: [lngLat.lat, lngLat.lng], // Leaflet uses [lat, lng]
content: content,
coordinates: [lngLat.lat, lngLat.lng]
};
if (!currentJourney.markers) currentJourney.markers = [];
currentJourney.markers.push(marker);
updateJourneyPath();
// Add marker to the markers list
const markersContainer = document.getElementById('markers-container');
const markerElement = document.createElement('div');
markerElement.className = 'marker-item';
markerElement.innerHTML = `
<div class="marker-title">${content.title || 'Untitled'}</div>
<div class="marker-coords">${lngLat.lat.toFixed(4)}, ${lngLat.lng.toFixed(4)}</div>
`;
// Add click event to marker item
markerElement.addEventListener('click', function() {
map.flyTo({
center: [lngLat.lat, lngLat.lng],
zoom: 10
});
openMarkerEditor(marker);
});
if (markersContainer.children.length === 1 &&
markersContainer.firstElementChild.id === 'empty-markers') {
markersContainer.removeChild(markersContainer.firstElementChild);
}
markersContainer.appendChild(markerElement);
updateMarkersList(); // Call the new function to update the list
return marker;
};
// Update the updateJourneyPath function to handle cases where markers array is empty
function updateMarkersList() {
const markersContainer = document.getElementById('markers-container');
markersContainer.innerHTML = '';
if (currentJourney.markers.length === 0) {
markersContainer.innerHTML = '<p class="empty-message">No markers yet. Click on the map to add markers.</p>';
return;
}
currentJourney.markers.forEach((marker, index) => {
const markerElement = document.createElement('div');
markerElement.className = 'marker-item';
markerElement.innerHTML = `
<strong>${index + 1}</strong>
${marker.getLatLng().lat.toFixed(4)}, ${marker.getLngLat().lng.toFixed(4)}
`;
// Add click handler to zoom to marker
markerElement.addEventListener('click', () => {
map.flyTo({
center: [marker.getLatLng().lat, marker.getLatLng().lng],
zoom: 10
});
});
markersContainer.appendChild(markerElement);
});
}
function updateJourneyPath() {
if (!map.hasLayer(journeyPath)) {
journeyPath = L.polyline([], {color: '#3887be', weight: 4});
map.addLayer(journeyPath);
}
const coordinates = currentJourney.markers.map(marker => [
marker.getLatLng().lat,
marker.getLatLng().lng
]);
journeyPath.setLatLngs(coordinates);
}

223
login.html Normal file
View File

@ -0,0 +1,223 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login 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);
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
padding: var(--size-4);
}
.login-container {
max-width: 400px;
width: 100%;
background: var(--surface-1);
border-radius: var(--radius-3);
padding: var(--size-6);
box-shadow: var(--shadow-4);
}
.login-header {
text-align: center;
margin-bottom: var(--size-6);
}
.login-header h1 {
color: var(--indigo-8);
margin: 0;
font-size: var(--font-size-5);
}
.auth-tabs {
display: flex;
gap: var(--size-2);
border-bottom: 1px solid var(--surface-4);
margin-bottom: var(--size-4);
}
.auth-tab {
padding: var(--size-2) var(--size-4);
cursor: pointer;
background: none;
border: none;
font-size: var(--font-size-2);
font-weight: var(--font-weight-5);
color: var(--gray-6);
transition: all 0.2s;
}
.auth-tab.active {
color: var(--indigo-7);
border-bottom: 2px solid var(--indigo-7);
}
.auth-form {
display: none;
}
.auth-form.active {
display: block;
}
.form-group {
margin-bottom: var(--size-4);
}
label {
display: block;
margin-bottom: var(--size-1);
font-weight: var(--font-weight-5);
}
input {
width: 100%;
padding: var(--size-2) var(--size-3);
border: 1px solid var(--surface-4);
border-radius: var(--radius-2);
background: var(--surface-2);
font-size: var(--font-size-2);
}
.btn {
width: 100%;
padding: var(--size-2);
border: none;
border-radius: var(--radius-2);
background: var(--indigo-7);
color: white;
font-size: var(--font-size-2);
font-weight: var(--font-weight-5);
cursor: pointer;
transition: background 0.2s;
}
.btn:hover {
background: var(--indigo-8);
}
.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>
<div class="login-container">
<div class="login-header">
<h1><i class="fas fa-map-marked-alt"></i> Journey Mapper</h1>
<p>Sign in to continue</p>
</div>
<div class="auth-tabs">
<button class="auth-tab active" data-tab="login">Login</button>
<button class="auth-tab" data-tab="register">Register</button>
</div>
<div id="login-form" class="auth-form active">
<div class="form-group">
<label>Username</label>
<input type="text" id="login-username" required>
</div>
<div class="form-group">
<label>Password</label>
<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" required>
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="register-password" required>
</div>
<button id="register-submit" class="btn">Register</button>
</div>
</div>
<div id="toast" class="toast"></div>
<script src="js/auth.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>

1418
map-page.html Normal file

File diff suppressed because it is too large Load Diff