Skip to content

Commit 2031155

Browse files
authored
Merge pull request #110 from hotosm/add/new-ui
Add/new UI
2 parents e5e0093 + d76c545 commit 2031155

35 files changed

+1054
-1151
lines changed

backend/app/api/routes/chatmap/chatmap.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,39 @@
1+
import logging
12
import httpx
23
from fastapi import APIRouter, HTTPException, Request
34
from hotosm_auth_fastapi import CurrentUser
45
from app.core.config import settings
56

7+
logger = logging.getLogger(__name__)
8+
69
CHATMAP_API_BASE_URL = settings.chatmap_api_base_url
710

811
router = APIRouter(prefix="/chatmap")
912

1013

11-
@router.get("/map")
12-
async def get_my_chatmap(
14+
@router.get("/user/maps")
15+
async def get_user_chatmaps(
1316
request: Request,
1417
user: CurrentUser,
1518
) -> dict:
1619
"""
17-
Get the authenticated user's ChatMap (GeoJSON FeatureCollection).
20+
Get the authenticated user's ChatMap maps.
1821
19-
Forwards the Hanko auth cookie to the ChatMap API, which returns
20-
the map belonging to the authenticated user.
22+
Calls ChatMap API using the Hanko user ID and returns the list of maps.
2123
"""
2224
hanko_cookie = request.cookies.get("hanko")
2325
if not hanko_cookie:
24-
raise HTTPException(status_code=401, detail="Hanko auth cookie not found")
26+
raise HTTPException(status_code=401, detail="Hanko authentication cookie not found.")
2527

26-
url = f"{CHATMAP_API_BASE_URL}/map"
28+
url = f"{CHATMAP_API_BASE_URL}/user/{user.id}/map"
2729

2830
async with httpx.AsyncClient(timeout=30.0, verify=False) as client:
2931
try:
30-
response = await client.get(
31-
url,
32-
cookies={"hanko": hanko_cookie},
33-
)
32+
response = await client.get(url, cookies={"hanko": hanko_cookie})
3433
response.raise_for_status()
35-
return response.json()
34+
maps = response.json()
35+
logger.info("[ChatMap] Found %d maps for user %s", len(maps), user.id)
36+
return {"maps": maps}
3637
except httpx.HTTPStatusError as e:
3738
raise HTTPException(
3839
status_code=e.response.status_code,

backend/app/api/routes/umap/umap.py

Lines changed: 18 additions & 166 deletions
Original file line numberDiff line numberDiff line change
@@ -6,204 +6,56 @@
66
import re
77
import logging
88
import httpx
9-
from bs4 import BeautifulSoup
109
from fastapi import APIRouter, HTTPException, Path, Request
1110
from app.models.umap import (
1211
UMapFeatureCollection,
1312
ShowcaseResponse,
1413
UserMapsResponse,
15-
UserTemplatesResponse,
1614
)
1715
from app.core.cache import get_cached, set_cached, DEFAULT_TTL
1816
from app.core.config import settings
1917

20-
# Setup logging
2118
logger = logging.getLogger(__name__)
2219

2320
router = APIRouter(prefix="/umap")
2421

25-
# uMap HOT OSM URLs derived from settings
2622
UMAP_BASE_URL = settings.umap_base_url
27-
UMAP_LOCALE = settings.umap_locale
2823
UMAP_API_BASE_URL = f"{UMAP_BASE_URL}/en/datalayer"
2924
UMAP_SHOWCASE_URL = f"{UMAP_BASE_URL}/en/showcase/"
30-
31-
# SSL verification: disabled by default for .test domains (self-signed certs)
32-
# Set UMAP_VERIFY_SSL=true in production with valid certificates
3325
UMAP_VERIFY_SSL = os.getenv("UMAP_VERIFY_SSL", "false").lower() == "true"
3426

35-
# Matches /es/map/slug_123 or /map/slug_123 (any locale prefix or none)
36-
_MAP_HREF_RE = re.compile(r"^/(?:[a-z]{2}/)?map/(.+)$")
37-
38-
logger.info(f"uMap Base URL: {UMAP_BASE_URL}")
39-
logger.info(f"uMap Locale: {UMAP_LOCALE}")
40-
logger.info(f"uMap SSL Verification: {UMAP_VERIFY_SSL}")
41-
42-
43-
def _parse_map_links(html: str) -> list[dict]:
44-
"""Extract unique map entries from an HTML page using BeautifulSoup.
45-
46-
Returns a list of dicts with keys: id, slug, href, url.
47-
Skips ?share / ?edit variants and deduplicates by map ID.
48-
"""
49-
soup = BeautifulSoup(html, "html.parser")
50-
results = []
51-
seen: set[str] = set()
52-
53-
for a in soup.find_all("a", href=True):
54-
href: str = a["href"]
55-
if "?share" in href or "?edit" in href:
56-
continue
57-
match = _MAP_HREF_RE.match(href)
58-
if not match:
59-
continue
60-
slug = match.group(1)
61-
parts = slug.rsplit("_", 1)
62-
map_id = parts[-1] if len(parts) > 1 and parts[-1].isdigit() else slug
63-
if map_id in seen:
64-
continue
65-
seen.add(map_id)
66-
results.append(
67-
{"id": map_id, "slug": slug, "href": href, "url": f"{UMAP_BASE_URL}{href}"}
68-
)
69-
70-
return results
71-
72-
73-
def _check_login_redirect(response: httpx.Response, html: str) -> bool:
74-
"""Return True if uMap redirected to the login page (auth failed)."""
75-
return "/login" in str(response.url) or "Iniciar sesión" in html
76-
77-
78-
@router.get("/user/templates", response_model=UserTemplatesResponse)
79-
async def get_user_templates(request: Request) -> dict:
80-
"""Fetch the user's templates page from uMap and return a JSON list.
81-
82-
Uses Hanko authentication cookie to authenticate with the uMap instance.
83-
Returns JSON with an array under `templates` containing objects with
84-
`id`, `href`, `url` and `slug` keys.
85-
"""
86-
hanko_cookie = request.cookies.get("hanko")
87-
logger.info(f"[Templates] Hanko cookie present: {bool(hanko_cookie)}")
88-
89-
if not hanko_cookie:
90-
logger.warning("No Hanko cookie found in request")
91-
raise HTTPException(
92-
status_code=401,
93-
detail="Hanko authentication cookie not found. Please log in.",
94-
)
95-
96-
url = f"{UMAP_BASE_URL}/{UMAP_LOCALE}/me/templates"
97-
logger.info(f"[Templates] Target URL: {url}")
98-
99-
try:
100-
async with httpx.AsyncClient(
101-
timeout=30.0,
102-
verify=UMAP_VERIFY_SSL,
103-
follow_redirects=True,
104-
) as client:
105-
response = await client.get(
106-
url,
107-
headers={"User-Agent": "portal-umap-client/1.0"},
108-
cookies={"hanko": hanko_cookie},
109-
)
110-
response.raise_for_status()
111-
html = response.text
11227

113-
logger.info(f"[Templates] Final URL: {response.url}")
114-
logger.info(f"[Templates] Response length: {len(html)} chars")
115-
logger.debug(f"[Templates] HTML preview: {html[:500]}")
28+
def _umap_client() -> httpx.AsyncClient:
29+
return httpx.AsyncClient(timeout=30.0, verify=UMAP_VERIFY_SSL, follow_redirects=True)
11630

117-
if _check_login_redirect(response, html):
118-
logger.warning("[Templates] Auth failed - redirected to login page")
119-
raise HTTPException(
120-
status_code=401,
121-
detail="uMap authentication failed. Your session may have expired.",
122-
)
12331

124-
templates = _parse_map_links(html)
125-
logger.info(f"[Templates] Found {len(templates)} templates")
126-
return {"templates": templates}
32+
def _require_hanko(request: Request) -> str:
33+
cookie = request.cookies.get("hanko")
34+
if not cookie:
35+
raise HTTPException(status_code=401, detail="Hanko authentication cookie not found.")
36+
return cookie
12737

128-
except httpx.HTTPStatusError as e:
129-
logger.error(f"HTTP Error: {e.response.status_code} - {e.response.text}")
130-
raise HTTPException(
131-
status_code=e.response.status_code,
132-
detail=f"Error fetching uMap templates: {e.response.text}",
133-
)
134-
except httpx.RequestError as e:
135-
logger.error(f"Request Error: {str(e)}")
136-
raise HTTPException(status_code=503, detail=f"Connection error to uMap: {str(e)}")
137-
except HTTPException:
138-
raise
139-
except Exception as e:
140-
logger.error(f"Unexpected error: {str(e)}", exc_info=True)
141-
raise HTTPException(status_code=500, detail=f"Unexpected error: {str(e)}")
14238

14339

14440
@router.get("/user/maps", response_model=UserMapsResponse)
14541
async def get_user_maps(request: Request) -> dict:
146-
"""Fetch the user's maps page from uMap and return a JSON list.
147-
148-
Uses Hanko authentication cookie to authenticate with the uMap instance.
149-
Returns JSON with an array under `maps` containing objects with
150-
`id`, `slug`, `href` and `url` keys.
151-
"""
152-
hanko_cookie = request.cookies.get("hanko")
153-
logger.info(f"[Maps] Hanko cookie present: {bool(hanko_cookie)}")
154-
155-
if not hanko_cookie:
156-
logger.warning("No Hanko cookie found in request")
157-
raise HTTPException(
158-
status_code=401,
159-
detail="Hanko authentication cookie not found. Please log in.",
160-
)
161-
162-
url = f"{UMAP_BASE_URL}/{UMAP_LOCALE}/me"
163-
logger.info(f"[Maps] Target URL: {url}")
42+
"""Fetch the authenticated user's maps from uMap."""
43+
hanko_cookie = _require_hanko(request)
44+
url = f"{UMAP_BASE_URL}/api/v1/maps/?source=mine"
16445

16546
try:
166-
async with httpx.AsyncClient(
167-
timeout=30.0,
168-
verify=UMAP_VERIFY_SSL,
169-
follow_redirects=True,
170-
) as client:
171-
response = await client.get(
172-
url,
173-
headers={"User-Agent": "portal-umap-client/1.0"},
174-
cookies={"hanko": hanko_cookie},
175-
)
47+
async with _umap_client() as client:
48+
response = await client.get(url, cookies={"hanko": hanko_cookie})
49+
if response.status_code == 401:
50+
raise HTTPException(status_code=401, detail="uMap authentication failed.")
17651
response.raise_for_status()
177-
html = response.text
178-
179-
logger.info(f"[Maps] Final URL: {response.url}")
180-
logger.info(f"[Maps] Response length: {len(html)} chars")
181-
182-
if _check_login_redirect(response, html):
183-
logger.warning("[Maps] Auth failed - redirected to login page")
184-
raise HTTPException(
185-
status_code=401,
186-
detail="uMap authentication failed. Your session may have expired.",
187-
)
188-
189-
maps = _parse_map_links(html)
190-
logger.info(f"[Maps] Found {len(maps)} maps")
191-
return {"maps": maps}
192-
52+
data = response.json()
53+
logger.info(f"[Maps] Found {len(data.get('maps', []))} maps")
54+
return data
19355
except httpx.HTTPStatusError as e:
194-
logger.error(f"HTTP Error: {e.response.status_code} - {e.response.text}")
195-
raise HTTPException(
196-
status_code=e.response.status_code,
197-
detail=f"Error fetching uMap maps: {e.response.text}",
198-
)
56+
raise HTTPException(status_code=e.response.status_code, detail=f"uMap error: {e.response.text}")
19957
except httpx.RequestError as e:
200-
logger.error(f"Request Error: {str(e)}")
20158
raise HTTPException(status_code=503, detail=f"Connection error to uMap: {str(e)}")
202-
except HTTPException:
203-
raise
204-
except Exception as e:
205-
logger.error(f"Unexpected error: {str(e)}", exc_info=True)
206-
raise HTTPException(status_code=500, detail=f"Unexpected error: {str(e)}")
20759

20860

20961
@router.get("/showcase", response_model=ShowcaseResponse)

backend/app/models/umap.py

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -103,12 +103,14 @@ class ShowcaseResponse(BaseModel):
103103

104104

105105
class UserMap(BaseModel):
106-
"""A single map entry from the user's maps page."""
106+
"""A single map entry from the user's maps."""
107107

108-
id: str
108+
id: int
109+
name: str
110+
description: Optional[str] = None
109111
slug: str
110-
href: str
111112
url: str
113+
modified_at: str
112114

113115

114116
class UserMapsResponse(BaseModel):
@@ -117,16 +119,3 @@ class UserMapsResponse(BaseModel):
117119
maps: List[UserMap]
118120

119121

120-
class UserTemplate(BaseModel):
121-
"""A single template entry from the user's templates page."""
122-
123-
id: str
124-
slug: str
125-
href: str
126-
url: str
127-
128-
129-
class UserTemplatesResponse(BaseModel):
130-
"""Response for the /umap/user/templates endpoint."""
131-
132-
templates: List[UserTemplate]
287 KB
Loading
441 KB
Loading

frontend/src/components/Footer.tsx

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,69 @@
11
import hotLogo from "../assets/images/hot-logo.svg";
2+
3+
const categories = [
4+
{
5+
name: "Imagery",
6+
tools: ["Drone Tasking Manager", "OpenAerialMap"],
7+
},
8+
{
9+
name: "Mapping",
10+
tools: ["Tasking Manager", "fAIr"],
11+
},
12+
{
13+
name: "Field",
14+
tools: ["Field Tasking Manager", "ChatMap"],
15+
},
16+
{
17+
name: "Data",
18+
tools: ["Export Tool", "uMap"],
19+
},
20+
];
21+
222
function Footer() {
323
return (
4-
<footer className="bg-hot-gray-50">
5-
<div className="container flex flex-col sm:flex-row justify-between items-center py-lg gap-md">
6-
<img
7-
src={hotLogo}
8-
alt="HOT Logo"
9-
className="h-[34px] w-[178px] grayscale"
10-
/>
11-
<span className="text-xs text-end">
12-
Humanitarian OpenStreetMap Team is a 501(c)(3)
13-
<br /> not-for-profit organization and global community.
14-
</span>
24+
<footer>
25+
<div className="bg-hot-gray-50 py-3xl">
26+
<div className="container grid grid-cols-2 sm:grid-cols-4 gap-2xl">
27+
{categories.map((category) => (
28+
<div key={category.name}>
29+
<p
30+
className="text-xl font-bold mb-md leading-tight"
31+
style={{ fontFamily: "Barlow, sans-serif" }}
32+
>
33+
{category.name}
34+
</p>
35+
<ul className="list-none p-0 m-0 flex flex-col gap-xs">
36+
{category.tools.map((tool) => (
37+
<li key={tool}>
38+
<span
39+
className="text-lg text-hot-gray-1000"
40+
style={{ fontFamily: "'Barlow Narrow', sans-serif" }}
41+
>
42+
{tool}
43+
</span>
44+
</li>
45+
))}
46+
</ul>
47+
</div>
48+
))}
49+
</div>
50+
</div>
51+
52+
<div className="bg-hot-gray-950 py-xl">
53+
<div className="container flex justify-between items-center gap-md">
54+
<img
55+
src={hotLogo}
56+
alt="HOT Logo"
57+
className="h-[34px] brightness-0 invert"
58+
/>
59+
<p
60+
className="text-sm text-hot-gray-100 text-right m-0 max-w-sm"
61+
style={{ fontFamily: "Archivo, sans-serif" }}
62+
>
63+
This is free and open source software, brought to you by the
64+
Humanitarian OpenStreetMap Team &amp; friends
65+
</p>
66+
</div>
1567
</div>
1668
</footer>
1769
);

0 commit comments

Comments
 (0)