Skip to content

Commit 6cca872

Browse files
feat: add spec pages, catalog, and interactive views (#3167)
## Summary - **Spec Pages** (`/:specId/:library`): Individual pages for each spec/library with code, specification, implementation, and quality tabs - **Catalog Page** (`/catalog`): Alphabetically sorted list of all specs with thumbnail rotation - **Interactive Page** (`/interactive/:specId/:library`): Fullscreen interactive HTML view with auto-scaling - **HTML Proxy** (`/proxy/html`): Backend endpoint to inject size reporting script into HTML plots - **Layout Component**: Shared layout with breadcrumb navigation and persistent state across pages - **Analytics**: Pageview tracking for catalog/interactive, tab click events - **Sitemap Update**: New URL structure (`/`, `/catalog`, `/{spec_id}`) - **Cleanup**: Removed dead FullscreenModal code, fixed TypeScript/Ruff issues, updated tests ## New Components - `SpecPage.tsx` - Main spec detail view with image, tabs, and library pills - `CatalogPage.tsx` - Alphabetical spec listing with rotating thumbnails - `InteractivePage.tsx` - Fullscreen interactive plot viewer - `SpecTabs.tsx` - Code/Spec/Impl/Quality tabbed content - `LibraryPills.tsx` - Horizontal scrollable library selector - `Layout.tsx` - Shared layout with data providers ## Test plan - [x] TypeScript compiles without errors - [x] Ruff linting passes - [x] All unit tests pass - [ ] Manual testing of new pages 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 5293b34 commit 6cca872

25 files changed

+2656
-582
lines changed

api/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
health_router,
2828
libraries_router,
2929
plots_router,
30+
proxy_router,
3031
seo_router,
3132
specs_router,
3233
stats_router,
@@ -128,6 +129,7 @@ async def add_cache_headers(request: Request, call_next):
128129
app.include_router(plots_router)
129130
app.include_router(download_router)
130131
app.include_router(seo_router)
132+
app.include_router(proxy_router)
131133

132134

133135
if __name__ == "__main__":

api/routers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from api.routers.health import router as health_router
55
from api.routers.libraries import router as libraries_router
66
from api.routers.plots import router as plots_router
7+
from api.routers.proxy import router as proxy_router
78
from api.routers.seo import router as seo_router
89
from api.routers.specs import router as specs_router
910
from api.routers.stats import router as stats_router
@@ -14,6 +15,7 @@
1415
"health_router",
1516
"libraries_router",
1617
"plots_router",
18+
"proxy_router",
1719
"seo_router",
1820
"specs_router",
1921
"stats_router",

api/routers/proxy.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
"""HTML proxy endpoint for interactive plots with size reporting."""
2+
3+
from urllib.parse import urlparse
4+
5+
import httpx
6+
from fastapi import APIRouter, HTTPException
7+
from fastapi.responses import HTMLResponse
8+
9+
10+
router = APIRouter(tags=["proxy"])
11+
12+
# Script injected to report content size to parent window
13+
# Uses specific origin (pyplots.ai) for postMessage security
14+
SIZE_REPORTER_SCRIPT = """
15+
<script>
16+
(function() {
17+
function reportSize() {
18+
try {
19+
// Find the main content element (try common patterns for different libraries)
20+
var content = document.querySelector(
21+
'.bk-root, .vega-embed, .plotly, .chart-container, #container, .lp-plot, svg, canvas'
22+
) || document.body.firstElementChild || document.body;
23+
24+
// Get actual rendered size
25+
var rect = content.getBoundingClientRect();
26+
var width = Math.max(rect.width, content.scrollWidth || 0, document.body.scrollWidth || 0);
27+
var height = Math.max(rect.height, content.scrollHeight || 0, document.body.scrollHeight || 0);
28+
29+
// Add padding to account for action buttons, toolbars, and other UI elements
30+
var padding = 40;
31+
width += padding;
32+
height += padding;
33+
34+
// Send to parent with specific origin for security
35+
if (width > 0 && height > 0 && window.parent !== window) {
36+
window.parent.postMessage({
37+
type: 'pyplots-size',
38+
width: Math.ceil(width),
39+
height: Math.ceil(height)
40+
}, 'https://pyplots.ai');
41+
}
42+
} catch (e) {
43+
// Silently fail if postMessage is blocked
44+
}
45+
}
46+
47+
// Report after load and after delays (for async rendering libraries)
48+
if (document.readyState === 'complete') {
49+
setTimeout(reportSize, 100);
50+
setTimeout(reportSize, 500);
51+
setTimeout(reportSize, 1000);
52+
} else {
53+
window.addEventListener('load', function() {
54+
setTimeout(reportSize, 100);
55+
setTimeout(reportSize, 500);
56+
setTimeout(reportSize, 1000);
57+
});
58+
}
59+
})();
60+
</script>
61+
"""
62+
63+
# Allowed GCS bucket for security
64+
ALLOWED_HOST = "storage.googleapis.com"
65+
ALLOWED_BUCKET = "pyplots-images"
66+
67+
68+
def build_safe_gcs_url(url: str) -> str | None:
69+
"""
70+
Validate URL and return a reconstructed safe GCS URL.
71+
72+
This prevents SSRF by constructing the URL from hardcoded values
73+
instead of passing user input directly.
74+
75+
Args:
76+
url: User-provided URL to validate
77+
78+
Returns:
79+
Reconstructed safe URL or None if validation fails
80+
"""
81+
try:
82+
parsed = urlparse(url)
83+
# Must be HTTPS
84+
if parsed.scheme != "https":
85+
return None
86+
# Must be exact host (no subdomains)
87+
if parsed.netloc != ALLOWED_HOST:
88+
return None
89+
# Path must start with bucket name
90+
path_parts = parsed.path.strip("/").split("/")
91+
if len(path_parts) < 2:
92+
return None
93+
if path_parts[0] != ALLOWED_BUCKET:
94+
return None
95+
# Check for path traversal attempts
96+
if ".." in parsed.path:
97+
return None
98+
# Validate path contains only safe characters (alphanumeric, hyphens, underscores, dots, slashes)
99+
safe_path = parsed.path.strip("/")
100+
if not all(c.isalnum() or c in "-_./+" for c in safe_path):
101+
return None
102+
# Reconstruct URL from hardcoded values to prevent SSRF
103+
# This breaks the taint flow by not using the original URL
104+
return f"https://{ALLOWED_HOST}/{safe_path}"
105+
except Exception:
106+
return None
107+
108+
109+
@router.get("/proxy/html", response_class=HTMLResponse)
110+
async def proxy_html(url: str):
111+
"""
112+
Proxy an HTML file and inject size reporting script.
113+
114+
This endpoint fetches HTML from GCS, injects a script that reports
115+
the content's actual dimensions via postMessage, and returns the
116+
modified HTML. This allows the frontend to dynamically scale the
117+
iframe based on actual content size.
118+
119+
Args:
120+
url: The GCS URL to fetch (must be from allowed bucket)
121+
122+
Returns:
123+
Modified HTML with size reporting script injected
124+
"""
125+
# Security: Validate and reconstruct URL to prevent SSRF
126+
safe_url = build_safe_gcs_url(url)
127+
if safe_url is None:
128+
raise HTTPException(status_code=400, detail=f"Only URLs from {ALLOWED_HOST}/{ALLOWED_BUCKET} are allowed")
129+
130+
# Fetch the HTML with shorter timeout
131+
async with httpx.AsyncClient(timeout=10.0) as client:
132+
try:
133+
response = await client.get(safe_url)
134+
response.raise_for_status()
135+
except httpx.HTTPStatusError as e:
136+
raise HTTPException(status_code=e.response.status_code, detail="Failed to fetch HTML") from e
137+
except httpx.RequestError as e:
138+
raise HTTPException(status_code=502, detail="Failed to connect to storage") from e
139+
140+
html_content = response.text
141+
142+
# Inject the size reporter script before </body>
143+
if "</body>" in html_content:
144+
html_content = html_content.replace("</body>", f"{SIZE_REPORTER_SCRIPT}</body>")
145+
elif "</html>" in html_content:
146+
html_content = html_content.replace("</html>", f"{SIZE_REPORTER_SCRIPT}</html>")
147+
else:
148+
# Fallback: append to end
149+
html_content += SIZE_REPORTER_SCRIPT
150+
151+
return HTMLResponse(content=html_content)

api/routers/seo.py

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88

99
from api.cache import cache_key, get_cache, set_cache
1010
from api.dependencies import optional_db
11-
from core.constants import LIBRARIES_METADATA
1211
from core.database import SpecRepository
1312

1413

@@ -20,7 +19,7 @@ async def get_sitemap(db: AsyncSession | None = Depends(optional_db)):
2019
"""
2120
Generate dynamic XML sitemap for SEO.
2221
23-
Includes all specs with implementations and all libraries.
22+
Includes root, catalog page, and all specs with implementations.
2423
"""
2524
key = cache_key("sitemap_xml")
2625
cached = get_cache(key)
@@ -32,6 +31,7 @@ async def get_sitemap(db: AsyncSession | None = Depends(optional_db)):
3231
'<?xml version="1.0" encoding="UTF-8"?>',
3332
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
3433
" <url><loc>https://pyplots.ai/</loc></url>",
34+
" <url><loc>https://pyplots.ai/catalog</loc></url>",
3535
]
3636

3737
# Add spec URLs (only specs with implementations)
@@ -41,12 +41,7 @@ async def get_sitemap(db: AsyncSession | None = Depends(optional_db)):
4141
for spec in specs:
4242
if spec.impls: # Only include specs with implementations
4343
spec_id = html.escape(spec.id)
44-
xml_lines.append(f" <url><loc>https://pyplots.ai/?spec={spec_id}</loc></url>")
45-
46-
# Add library URLs (static list)
47-
for lib in LIBRARIES_METADATA:
48-
lib_id = html.escape(lib["id"])
49-
xml_lines.append(f" <url><loc>https://pyplots.ai/?lib={lib_id}</loc></url>")
44+
xml_lines.append(f" <url><loc>https://pyplots.ai/{spec_id}</loc></url>")
5045

5146
xml_lines.append("</urlset>")
5247
xml = "\n".join(xml_lines)

api/routers/specs.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ async def get_spec(spec_id: str, db: AsyncSession = Depends(require_db)):
8181
generated_by=impl.generated_by,
8282
python_version=impl.python_version,
8383
library_version=impl.library_version,
84+
review_strengths=impl.review_strengths or [],
85+
review_weaknesses=impl.review_weaknesses or [],
86+
review_image_description=impl.review_image_description,
87+
review_criteria_checklist=impl.review_criteria_checklist,
88+
review_verdict=impl.review_verdict,
8489
)
8590
for impl in spec.impls
8691
]
@@ -95,6 +100,8 @@ async def get_spec(spec_id: str, db: AsyncSession = Depends(require_db)):
95100
tags=spec.tags,
96101
issue=spec.issue,
97102
suggested=spec.suggested,
103+
created=spec.created.isoformat() if spec.created else None,
104+
updated=spec.updated.isoformat() if spec.updated else None,
98105
implementations=impls,
99106
)
100107
set_cache(key, result)

api/schemas.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ class ImplementationResponse(BaseModel):
2323
generated_by: Optional[str] = None
2424
python_version: Optional[str] = None
2525
library_version: Optional[str] = None
26+
# Review fields
27+
review_strengths: list[str] = []
28+
review_weaknesses: list[str] = []
29+
review_image_description: Optional[str] = None
30+
review_criteria_checklist: Optional[dict] = None
31+
review_verdict: Optional[str] = None
2632

2733

2834
class SpecDetailResponse(BaseModel):
@@ -37,6 +43,8 @@ class SpecDetailResponse(BaseModel):
3743
tags: Optional[dict] = None
3844
issue: Optional[int] = None
3945
suggested: Optional[str] = None
46+
created: Optional[str] = None
47+
updated: Optional[str] = None
4048
implementations: list[ImplementationResponse] = []
4149

4250

app/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
"@mui/material": "^7.3.6",
2323
"react": "^19.2.3",
2424
"react-dom": "^19.2.3",
25+
"react-helmet-async": "^2.0.5",
26+
"react-router-dom": "^7.11.0",
2527
"react-syntax-highlighter": "^16.1.0"
2628
},
2729
"devDependencies": {

0 commit comments

Comments
 (0)