Compare commits

..

39 Commits

Author SHA1 Message Date
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
19 changed files with 4259 additions and 31 deletions

4
.gitignore vendored
View File

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

View File

@ -1,36 +1,15 @@
# Travel Journal (Draft README)
## Projectdescription
On this website you can document your travelstories.
Where you were, 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.
# Journey Mapper Backend
## Pages
### Map Page
Here you can create a journey through setting marker on a map.
At each marker you can write notes or implement images.
### Blog Page
Here you can look at all notes from one journey.
The markers have a date so they are in a time order.
### Journey Explorer
Here you can search for all journeys. Like overview page from a blogwebsite.
A Flask server for the Journey Mapper web application.
## Features
- Blog entry
- Log places
- Add pictures to specific places
## Installation
TBD
- RESTful API for managing journeys
- Persistent storage using JSON file
- CORS enabled for frontend integration
- Health check endpoint
- Easy to set up and run
## Tech-Stack
- HTML
- CSS
- JavaScript
- WebGL - MapLibre
## Getting Started
## Members
- Flepp Stiafen
- Kohler Joshua
- Rüegger André
1. Install dependencies:

219
backend/app.py Normal file
View File

@ -0,0 +1,219 @@
from flask import Flask, request, jsonify
from flask_cors import CORS
import json
import os
from datetime import datetime
app = Flask(__name__)
CORS(app) # Enable CORS for all routes
# Data directory and file
DATA_DIR = 'data'
DATA_FILE = os.path.join(DATA_DIR, 'journeys.json')
os.makedirs(DATA_DIR, exist_ok=True)
# In-memory store (loaded from file on startup)
journeys = []
def load_journeys():
"""Load journeys from the JSON file."""
global journeys
try:
if os.path.exists(DATA_FILE):
with open(DATA_FILE, 'r') as f:
journeys = json.load(f)
else:
journeys = []
except Exception as e:
print(f"Error loading journeys: {e}")
journeys = []
def save_journeys():
"""Save journeys to the JSON file."""
try:
with open(DATA_FILE, 'w') as f:
json.dump(journeys, f, indent=2)
except Exception as e:
print(f"Error saving journeys: {e}")
# Load existing journeys on startup
load_journeys()
def get_next_id():
"""Return the next available ID (simple integer increment)."""
if not journeys:
return 1
return max(j['id'] for j in journeys) + 1
@app.route('/api/journeys', methods=['POST'])
def create_journey():
"""Create a new journey."""
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
new_journey = {
'id': get_next_id(),
'title': title,
'description': data.get('description', ''),
'markers': data.get('markers', []), # list of marker objects
'created_at': datetime.now().isoformat()
}
journeys.append(new_journey)
save_journeys()
return jsonify(new_journey), 201
@app.route('/api/journeys', methods=['GET'])
def get_journeys():
"""Return all journeys."""
return jsonify(journeys)
@app.route('/api/journeys/<int:journey_id>', methods=['GET'])
def get_journey(journey_id):
"""Return a specific journey by ID."""
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):
"""Update an existing journey."""
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
# Update allowed fields
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_journeys()
return jsonify(journey)
@app.route('/api/journeys/<int:journey_id>', methods=['DELETE'])
def delete_journey(journey_id):
"""Delete a journey."""
global journeys
journey = next((j for j in journeys if j['id'] == journey_id), None)
if journey is None:
return jsonify({'error': 'Journey not found'}), 404
journeys = [j for j in journeys if j['id'] != journey_id]
save_journeys()
return jsonify({'message': 'Journey deleted successfully', 'journey': journey})
@app.route('/api/journeys/health', methods=['GET'])
def health_check():
"""Simple health check endpoint."""
return jsonify({'status': 'healthy', 'timestamp': datetime.now().isoformat()})
@app.route('/')
def index():
"""Root endpoint just a welcome message."""
return '''
<!DOCTYPE html>
<html>
<head><title>Journey Mapper Backend</title></head>
<body>
<h1>Journey Mapper API</h1>
<p>Backend is running. Use <code>/api/journeys</code> endpoints.</p>
</body>
</html>
'''
# Blog posts data file
BLOG_DATA_FILE = os.path.join(DATA_DIR, 'blog_posts.json')
def load_blog_posts():
try:
if os.path.exists(BLOG_DATA_FILE):
with open(BLOG_DATA_FILE, 'r') as f:
return json.load(f)
except:
pass
return []
def save_blog_posts(posts):
with open(BLOG_DATA_FILE, 'w') as f:
json.dump(posts, f, indent=2)
blog_posts = load_blog_posts()
def get_next_blog_id():
if not blog_posts:
return 1
return max(p['id'] for p in blog_posts) + 1
@app.route('/api/blog-posts', methods=['GET'])
def get_blog_posts():
return jsonify(blog_posts)
@app.route('/api/blog-posts/<int:post_id>', methods=['GET'])
def get_blog_post(post_id):
post = next((p for p in blog_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():
data = request.get_json()
title = data.get('title')
if not title:
return jsonify({'error': 'Title required'}), 400
new_post = {
'id': get_next_blog_id(),
'title': title,
'content': data.get('content', ''),
'journeyId': data.get('journeyId'),
'image': data.get('image'),
'created_at': datetime.now().isoformat()
}
blog_posts.append(new_post)
save_blog_posts(blog_posts)
return jsonify(new_post), 201
@app.route('/api/blog-posts/<int:post_id>', methods=['PUT'])
def update_blog_post(post_id):
post = next((p for p in blog_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_blog_posts(blog_posts)
return jsonify(post)
@app.route('/api/blog-posts/<int:post_id>', methods=['DELETE'])
def delete_blog_post(post_id):
global blog_posts
post = next((p for p in blog_posts if p['id'] == post_id), None)
if not post:
return jsonify({'error': 'Post not found'}), 404
blog_posts = [p for p in blog_posts if p['id'] != post_id]
save_blog_posts(blog_posts)
return jsonify({'message': 'Post deleted'})
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/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" },
]

233
blog-list.html Normal file
View File

@ -0,0 +1,233 @@
<!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: Poppins -->
<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 (same as map page) */
.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);
}
/* 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;
}
</style>
</head>
<body>
<header class="site-header">
<div class="container">
<h1 class="site-title"><a href="map-page.html">Journey Mapper</a></h1>
<nav class="site-nav">
<a href="map-page.html">Map</a>
<a href="blog-list.html" class="active">Blog</a>
</nav>
</div>
</header>
<main class="blog-container">
<div class="blog-header">
<h1><i class="fas fa-newspaper"></i> Blog Posts</h1>
<a href="blog-post.html?new" class="btn"><i class="fas fa-plus"></i> New Post</a>
</div>
<div id="posts-grid" class="posts-grid">
<!-- Posts loaded dynamically -->
</div>
</main>
<script>
const API_BASE = 'http://127.0.0.1:5000/api';
async function loadPosts() {
try {
const res = await fetch(`${API_BASE}/blog-posts`);
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.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('');
}
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;
});
}
document.addEventListener('DOMContentLoaded', loadPosts);
</script>
</body>
</html>

370
blog-page.html Normal file
View File

@ -0,0 +1,370 @@
<!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: Poppins (matching map page) -->
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
/* ===== GLOBAL RESET USING OPEN PROPS ===== */
* {
box-sizing: border-box;
}
body {
font-family: var(--font-sans);
background: var(--gray-0);
color: var(--gray-9);
line-height: var(--font-lineheight-3);
}
/* ===== 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: 1200px;
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;
}
.site-nav a:hover {
color: var(--indigo-4);
}
/* ===== MAIN LAYOUT (twocolumn) ===== */
.post-page {
display: flex;
flex-direction: column;
gap: var(--size-6);
padding: var(--size-6) var(--size-4);
max-width: 1400px;
margin: 0 auto;
}
.post {
background: var(--surface-1);
border-radius: var(--radius-3);
padding: var(--size-6);
box-shadow: var(--shadow-2);
}
.post-title {
font-size: var(--font-size-6);
font-weight: var(--font-weight-7);
margin: 0 0 var(--size-2) 0;
color: var(--gray-9);
}
.post-meta {
color: var(--gray-6);
font-size: var(--font-size-1);
margin-bottom: var(--size-4);
border-bottom: 1px solid var(--surface-4);
padding-bottom: var(--size-2);
}
.post-hero {
margin: var(--size-4) 0;
}
.post-hero img {
width: 100%;
height: auto;
border-radius: var(--radius-2);
object-fit: cover;
}
.post-content {
line-height: var(--font-lineheight-4);
color: var(--gray-7);
}
.post-content p {
margin-bottom: var(--size-4);
}
/* Sidebar */
.sidebar {
background: var(--surface-1);
border-radius: var(--radius-3);
padding: var(--size-4);
box-shadow: var(--shadow-2);
}
.sidebar section {
margin-bottom: var(--size-6);
}
.sidebar h3 {
font-size: var(--font-size-3);
margin-top: 0;
margin-bottom: var(--size-3);
color: var(--indigo-7);
border-left: 3px solid var(--indigo-6);
padding-left: var(--size-2);
}
/* Posts list */
#posts-list {
display: flex;
flex-direction: column;
gap: var(--size-3);
}
.post-preview {
background: var(--surface-2);
border-radius: var(--radius-2);
padding: var(--size-3);
transition: background 0.2s;
cursor: pointer;
}
.post-preview:hover {
background: var(--surface-3);
}
.post-preview-title {
font-weight: var(--font-weight-6);
color: var(--gray-9);
margin: 0 0 var(--size-1) 0;
}
.post-preview-meta {
font-size: var(--font-size-0);
color: var(--gray-6);
}
/* Form for creating/editing posts */
.form-group {
margin-bottom: var(--size-3);
}
.form-group label {
display: block;
margin-bottom: var(--size-1);
font-weight: var(--font-weight-5);
color: var(--gray-7);
}
.form-group input,
.form-group textarea {
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);
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--indigo-6);
box-shadow: 0 0 0 3px var(--indigo-2);
}
/* Buttons (reuse map page classes) */
.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);
}
.btn-primary {
background: var(--indigo-7);
color: white;
}
.btn-primary:hover {
background: var(--indigo-8);
box-shadow: var(--shadow-3);
}
.btn-secondary {
background: var(--gray-7);
color: white;
}
.btn-secondary:hover {
background: var(--gray-8);
}
.btn-danger {
background: var(--red-7);
color: white;
}
.btn-danger:hover {
background: var(--red-8);
}
/* Comments section */
.comments {
max-width: 1200px;
margin: var(--size-6) auto 0;
padding: 0 var(--size-4);
}
.comments h3 {
font-size: var(--font-size-4);
margin-bottom: var(--size-4);
color: var(--indigo-7);
}
.comments-list {
display: flex;
flex-direction: column;
gap: var(--size-4);
}
.comment-item {
background: var(--surface-1);
border-radius: var(--radius-2);
padding: var(--size-4);
box-shadow: var(--shadow-1);
}
.comment-item .meta {
color: var(--gray-6);
font-size: var(--font-size-0);
margin-bottom: var(--size-2);
}
.comment-item p {
margin: 0;
}
/* Footer */
.site-footer {
background: var(--surface-2);
padding: var(--size-4);
margin-top: var(--size-6);
text-align: center;
color: var(--gray-6);
font-size: var(--font-size-1);
border-top: 1px solid var(--surface-4);
}
/* Responsive twocolumn layout */
@media (min-width: 768px) {
.post-page {
flex-direction: row;
align-items: flex-start;
gap: var(--size-6);
padding: var(--size-6);
}
.post {
flex: 1;
margin: 0;
}
.sidebar {
width: 320px;
flex-shrink: 0;
position: sticky;
top: var(--size-4);
}
}
</style>
</head>
<body>
<header class="site-header">
<div class="container">
<h1 class="site-title"><a href="map-page.html">Journey Mapper</a></h1>
<nav class="site-nav">
<a href="map-page.html">Map</a>
<a href="blog-page.html" class="active">Blog</a>
</nav>
</div>
</header>
<main class="post-page">
<article class="post">
<h2 class="post-title">Post Title</h2>
<div class="post-meta">By <span class="author">Author</span><time datetime="2026-01-01">Jan 1, 2026</time></div>
<figure class="post-hero">
<img src="https://via.placeholder.com/800x350?text=Hero+Image" alt="Post image">
</figure>
<div class="post-content">
<p>This is a placeholder paragraph for the blog post content. Replace with real content.</p>
</div>
</article>
<aside class="sidebar">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<h3 style="margin:0">Posts</h3>
<button id="create-post-btn" class="btn btn-primary">New</button>
</div>
<div id="posts-list">
<!-- JS will render list of posts here -->
</div>
<section class="about" id="post-form" style="display:none;margin-top:16px;">
<h3 id="form-title">New Post</h3>
<form id="blog-form">
<div class="form-group">
<label for="post-title-input">Title</label>
<input id="post-title-input" name="title" required />
</div>
<div class="form-group">
<label for="post-journey-input">Journey ID</label>
<input id="post-journey-input" name="journeyId" placeholder="optional" />
</div>
<div class="form-group">
<label for="post-image-input">Image URL</label>
<input id="post-image-input" name="image" placeholder="https://..." />
</div>
<div class="form-group">
<label for="post-content-input">Content</label>
<textarea id="post-content-input" name="content" rows="6" required></textarea>
</div>
<div style="display:flex;gap:8px">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" id="cancel-post-btn" class="btn btn-secondary">Cancel</button>
</div>
</form>
</section>
<section class="about" style="margin-top:18px;">
<h3>About</h3>
<p>Short author bio or related links.</p>
</section>
</aside>
</main>
<section class="comments">
<h3>Comments</h3>
<div class="comments-list"></div>
</section>
<footer class="site-footer">
<div class="container">&copy; 2026 My Blog</div>
</footer>
<!-- JavaScript (keep your existing logic) -->
<script src="js/main.js"></script>
<script src="js/blog-posts.js"></script>
</body>
</html>

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

@ -0,0 +1,347 @@
<!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: Poppins -->
<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);
}
/* 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>
<nav class="site-nav">
<a href="map-page.html">Map</a>
<a href="blog-list.html">Blog</a>
</nav>
</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>
const API_BASE = 'http://127.0.0.1:5000/api';
let currentPostId = null;
// Get post id from URL
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}`);
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)
});
if (!res.ok) throw new Error('Save failed');
const data = await res.json();
showToast('Post saved successfully');
// Redirect to the new post if it was created
if (isNew) {
window.location.href = `blog-post.html?id=${data.id}`;
} else {
// reload if needed
}
} 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' });
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);
}
}
function showToast(message, isError = false) {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.style.backgroundColor = isError ? 'var(--red-7)' : 'var(--green-7)';
toast.style.display = 'block';
setTimeout(() => { toast.style.display = 'none'; }, 3000);
}
// Image upload (convert to base64 and set as image URL)
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);
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 maybe go 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;
}

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);
}

1152
map-page.html Normal file

File diff suppressed because it is too large Load Diff