diff --git a/backend/app/main.py b/backend/app/main.py index f3c94f3..7fd934c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,7 +13,7 @@ logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger(__name__) -from app.routes import api_keys, papers, scripts, slides, media, images, auth, papertovideo, youtube_upload, feedback, patents, reels, podcast, external_api, poster, business_brief +from app.routes import api_keys, papers, scripts, slides, media, images, auth, papertovideo, youtube_upload, feedback, patents, reels, podcast, external_api, poster, business_brief, webpage from app.workers import init_worker_pool, close_worker_pool ## Removed: from app.database import create_tables (no longer needed) @@ -31,7 +31,7 @@ temp_dirs = [ "temp/arxiv_sources", "temp/images", "temp/title_slides", "temp/videos", "temp/audio", "temp/latex_template", - "temp/slides", "temp/scripts" + "temp/slides", "temp/scripts", "temp/webpages" ] for dir_path in temp_dirs: @@ -197,6 +197,7 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE app.include_router(podcast.router, prefix="/api/podcast", tags=["podcast"]) app.include_router(external_api.router, prefix="/api/external", tags=["External API"]) app.include_router(business_brief.router, prefix="/api/business-brief", tags=["Business Brief"]) +app.include_router(webpage.router, prefix="/api/webpage", tags=["Webpage"]) instrumentator = Instrumentator( should_group_status_codes=True, diff --git a/backend/app/models/request_models.py b/backend/app/models/request_models.py index 4f97df3..cd791ef 100644 --- a/backend/app/models/request_models.py +++ b/backend/app/models/request_models.py @@ -134,4 +134,17 @@ class BusinessBriefResponse(BaseModel): status: str class BusinessBriefUpdateRequest(BaseModel): - sections: Dict[str, str] \ No newline at end of file + sections: Dict[str, str] + + +class WebpageVariant(BaseModel): + variant_id: str + theme: str + preview_url: str + download_url: str + created_at: str + + +class WebpageGenerateResponse(BaseModel): + paper_id: str + variants: List[WebpageVariant] \ No newline at end of file diff --git a/backend/app/routes/webpage.py b/backend/app/routes/webpage.py new file mode 100644 index 0000000..c126595 --- /dev/null +++ b/backend/app/routes/webpage.py @@ -0,0 +1,151 @@ +import os +import random +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import FileResponse, HTMLResponse + +from app.auth.dependencies import get_current_user +from app.models.request_models import WebpageGenerateResponse, WebpageVariant +from app.routes.api_keys import get_api_keys +from app.services.storage_manager import storage_manager +from app.services.webpage_generator import STYLE_PROFILES, clear_variants, generate_variant, list_variants, save_variant_meta + +router = APIRouter() + + +def _resolve_existing_path(raw_path: str | None) -> Path | None: + if not raw_path: + return None + + p = Path(raw_path) + backend_dir = Path(__file__).resolve().parents[2] + repo_dir = Path(__file__).resolve().parents[3] + + candidates = [p, backend_dir / p, repo_dir / p] + if raw_path.startswith("backend/"): + tail = Path(raw_path[len("backend/"):]) + candidates.append(backend_dir / tail) + + for cand in candidates: + if cand.exists(): + return cand + return None + + +@router.post("/{paper_id}/generate", response_model=WebpageGenerateResponse) +async def generate_webpage( + paper_id: str, + current_user: dict = Depends(get_current_user), + api_keys: dict = Depends(get_api_keys), +): + paper_info = storage_manager.get_paper(paper_id) + if not paper_info: + raise HTTPException(status_code=404, detail="Paper not found") + + gemini_key = api_keys.get("gemini_key") or os.getenv("GEMINI_API_KEY") + if not gemini_key: + raise HTTPException(status_code=400, detail="Gemini API key not configured") + + variants = [] + count = 1 + clear_variants(paper_id) + + unique_profiles = random.sample(STYLE_PROFILES, k=count) + used_theme_names = set() + for idx in range(count): + last_error = None + variant_id = None + meta = None + + # Retry a few times because model outputs can occasionally violate constraints. + for attempt in range(4): + preferred = unique_profiles[idx] + if preferred["name"] in used_theme_names: + pool = [p for p in STYLE_PROFILES if p["name"] not in used_theme_names] + preferred = random.choice(pool) if pool else random.choice(STYLE_PROFILES) + + try: + variant_id, meta = generate_variant( + paper_id=paper_id, + user_id=current_user.get("id", "local-user"), + paper_info=paper_info, + api_key=gemini_key, + variant_index=idx, + profile_override=preferred, + ) + used_theme_names.add(meta["theme"]) + break + except Exception as exc: + last_error = exc + continue + + if not variant_id or not meta: + raise HTTPException(status_code=502, detail=f"Unable to generate high-quality webpage variant: {last_error}") + + save_variant_meta(paper_id, meta) + variants.append( + WebpageVariant( + variant_id=variant_id, + theme=meta["theme"], + preview_url=f"/api/webpage/{paper_id}/preview/{variant_id}", + download_url=f"/api/webpage/{paper_id}/download/{variant_id}", + created_at=meta["created_at"], + ) + ) + + return WebpageGenerateResponse(paper_id=paper_id, variants=variants) + + +@router.get("/{paper_id}/variants", response_model=list[WebpageVariant]) +async def get_variants(paper_id: str, current_user: dict = Depends(get_current_user)): + _ = current_user + metas = list_variants(paper_id) + out = [] + for meta in metas: + variant_id = meta.get("variant_id") + if not variant_id: + continue + out.append( + WebpageVariant( + variant_id=variant_id, + theme=meta.get("theme", "Custom"), + preview_url=f"/api/webpage/{paper_id}/preview/{variant_id}", + download_url=f"/api/webpage/{paper_id}/download/{variant_id}", + created_at=meta.get("created_at", ""), + ) + ) + return out + + +@router.get("/{paper_id}/preview/{variant_id}") +async def preview_variant(paper_id: str, variant_id: str, current_user: dict = Depends(get_current_user)): + _ = current_user + path = Path(f"temp/webpages/{paper_id}/{variant_id}.html") + if not path.exists(): + raise HTTPException(status_code=404, detail="Variant not found") + return HTMLResponse(path.read_text(encoding="utf-8")) + + +@router.get("/{paper_id}/download/{variant_id}") +async def download_variant(paper_id: str, variant_id: str, current_user: dict = Depends(get_current_user)): + _ = current_user + path = Path(f"temp/webpages/{paper_id}/{variant_id}.html") + if not path.exists(): + raise HTTPException(status_code=404, detail="Variant not found") + return FileResponse(path, media_type="text/html", filename=f"webpage_{variant_id}.html") + + +@router.get("/{paper_id}/asset/{image_name}") +async def get_variant_asset(paper_id: str, image_name: str, current_user: dict = Depends(get_current_user)): + _ = current_user + paper_info = storage_manager.get_paper(paper_id) + if not paper_info: + raise HTTPException(status_code=404, detail="Paper not found") + + for raw_path in paper_info.get("image_files", []): + resolved = _resolve_existing_path(raw_path) + if resolved and resolved.name == image_name: + return FileResponse(resolved) + + raise HTTPException(status_code=404, detail="Asset not found") diff --git a/backend/app/services/webpage_generator.py b/backend/app/services/webpage_generator.py new file mode 100644 index 0000000..4500658 --- /dev/null +++ b/backend/app/services/webpage_generator.py @@ -0,0 +1,412 @@ +import json +import os +import random +import re +import uuid +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Tuple +import logging + +import google.generativeai as genai + +from app.services.script_generator import extract_text_from_file, clean_text +from app.utils.timing import track_performance + +logger = logging.getLogger(__name__) + +BACKEND_DIR = Path(__file__).resolve().parents[2] +REPO_DIR = Path(__file__).resolve().parents[3] + +MAX_TEXT_CHARS = 12000 +MAX_IMAGES = 6 + +STYLE_PROFILES = [ + { + "name": "Aurora Lab", + "palette": "slate + emerald + cyan gradients with deep shadows", + "layout": "split hero with sticky side meta rail and figure showcase", + "mood": "clean, futuristic, data-forward, cutting-edge", + }, + { + "name": "Paper Atlas", + "palette": "stone + amber + navy with warm undertones", + "layout": "editorial with wide typographic sections and full-width gallery", + "mood": "academic, warm, trustworthy, scholarly", + }, + { + "name": "Signal Grid", + "palette": "zinc + rose + indigo with bold accents", + "layout": "modular card grid with metric band and figure cards", + "mood": "technical, punchy, modern, engineering-focused", + }, + { + "name": "Monograph", + "palette": "neutral + sky + lime with minimal contrast", + "layout": "single-column narrative with visual callouts and embedded figures", + "mood": "minimal, readable, calm, timeless", + }, + { + "name": "Research Bloom", + "palette": "gray + teal + orange with vibrant accents", + "layout": "gallery-first with abstract and contribution chips plus feature cards", + "mood": "bold, visual, explanatory, inspiring", + }, +] + + +@track_performance +def _paper_text_from_info(paper_info: Dict) -> str: + text_path_raw = paper_info.get("text_file_path") or paper_info.get("tex_file_path") + text_path = _resolve_existing_path(text_path_raw) + if not text_path: + return "" + + text = extract_text_from_file(str(text_path)) + text = clean_text(text or "") + return text[:MAX_TEXT_CHARS] + + +@track_performance +def _resolve_existing_path(raw_path: str | None) -> Path | None: + if not raw_path: + return None + + p = Path(raw_path) + candidates: List[Path] = [p] + + # Paths in storage can be relative to backend or repo root. + candidates.append(BACKEND_DIR / p) + candidates.append(REPO_DIR / p) + + if raw_path.startswith("backend/"): + tail = Path(raw_path[len("backend/"):]) + candidates.append(BACKEND_DIR / tail) + + for cand in candidates: + if cand.exists(): + return cand + return None + + +@track_performance +def _image_urls(paper_id: str, paper_info: Dict) -> List[str]: + out: List[str] = [] + for image_path in paper_info.get("image_files", [])[:MAX_IMAGES]: + resolved = _resolve_existing_path(image_path) + if resolved: + out.append(f"/api/webpage/{paper_id}/asset/{resolved.name}") + return out + + +@track_performance +def _random_profile(used_names: set[str] | None = None) -> Dict: + used_names = used_names or set() + available = [p for p in STYLE_PROFILES if p["name"] not in used_names] + if not available: + available = STYLE_PROFILES + return random.SystemRandom().choice(available) + + +@track_performance +def _extract_resource_links(text: str) -> List[str]: + if not text: + return [] + + pattern = re.compile(r"(?:https?://)?(?:www\.)?github\.com/[A-Za-z0-9._-]+/[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)?(?:/[A-Za-z0-9._-]+)?", re.IGNORECASE) + links: List[str] = [] + for match in pattern.findall(text): + cleaned = match.rstrip(".,;:)\"]'\"") + if not cleaned.startswith("http"): + cleaned = f"https://{cleaned}" + if cleaned not in links: + links.append(cleaned) + return links + + +@track_performance +def _build_prompt(title: str, authors: str, text: str, links: List[str], image_urls: List[str], profile: Dict) -> str: + """Build prompt for Gemini to generate webpage HTML.""" + figures_instruction = ( + "No figure URLs available. Omit the figures section cleanly." + if not image_urls + else "Use every figure URL exactly once in the figures/gallery section. Do not use placeholder image URLs." + ) + + return f"""You are an award-winning web designer and technical writer. +Create ONE adaptive, presentation-ready research landing page as a single self-contained HTML document. + +CRITICAL REQUIREMENTS: +1. Return ONLY the HTML code - no markdown, no code fences, no explanation +2. Start with and end properly with +3. Use Tailwind CSS from CDN: https://cdn.tailwindcss.com +4. Make it BEAUTIFUL and presentation-ready (like a polished product landing page) +5. Primary goal: communicate the paper's key highlights in short, crisp, visually clear form +6. Do NOT force a fixed template. Infer the best page structure from the paper type, title, figures, and content. +7. Responsive: Works perfectly on mobile and desktop +8. Fast: no heavy JS, minimal inline CSS +9. Use ONLY source material from paper content. No hallucinated claims. +10. Never use placeholders like via.placeholder.com. + +STYLE PROFILE (use this for inspiration, adapt creatively): +- Name: {profile['name']} +- Colors: {profile['palette']} +- Layout: {profile['layout']} +- Mood: {profile['mood']} + +VISUAL DIRECTION: +- Choose a dominant background and one strong accent color, then use 1-2 supporting accents only. +- Keep contrast deliberate: dark text on light surfaces or light text on dark surfaces, never muddy mid-tone blocks. +- Use generous whitespace, clear section spacing, and well-defined card boundaries. +- Make figures feel like first-class content with frame, caption, and context. +- Prefer a sharp editorial look over decorative clutter. +- Keep the hero bold, the highlights compact, and the repository link clearly visible. + +LAYOUT DIRECTION: +- Decide the best structure from the paper itself. +- If it is experimental or benchmark-heavy, prioritize key findings, metrics, and figures. +- If it is method-heavy, prioritize a compact methodology or pipeline section. +- If it is survey or conceptual, prioritize themes, sections, and takeaways. +- Keep the page focused on what would help a reader understand the paper quickly and visually. + +PAPER DETAILS: +Title: {title} +Authors: {authors} + +CONTENT TO INCLUDE (base your content on this): +{text} + +FIGURE URLS (local project assets): +{json.dumps(image_urls)} +FIGURE RULE: +{figures_instruction} + +RESOURCES (if available): +{json.dumps(links)} + +BEST PRACTICES: +- Keep text concise: favor highlight cards/bullets over long blocks +- Add meaningful section headings and concise, crisp summaries +- Display figures prominently and give each a short caption inferred from nearby paper context +- Use nice typography with Google Fonts +- Add subtle gradients and shadows for depth +- Create visual hierarchy with sizes and colors +- Use cards and sections with breathing room +- If there's a GitHub link, make it a prominent CTA button with an icon +- Show key metrics/findings as visual callouts +- Make it feel premium and polished, not generic +- If the paper has only a few strong claims, prioritize those instead of stretching the content. + +Write clean, semantic HTML with Tailwind classes only. No JavaScript needed.""" + + +@track_performance +def _build_repair_prompt(original_html: str, issues: List[str], image_urls: List[str], links: List[str]) -> str: + return f"""Fix the following HTML page by editing it and returning full corrected HTML only. + +Issues to fix: +{json.dumps(issues)} + +Mandatory constraints: +- Keep the visual quality high. +- Keep a complete valid HTML document with . +- Do not use placeholder image URLs. +- If image URLs are provided, include them in tags in a visible figure/gallery section. +- Use only these figure URLs: {json.dumps(image_urls)} +- Use only these resource links: {json.dumps(links)} + +Original HTML: +{original_html} +""" + + +@track_performance +def _validate_generated_html(html: str, image_urls: List[str], links: List[str]) -> List[str]: + issues: List[str] = [] + lower = html.lower() + + if " 1: + issues.append(f"Figure URL appears more than once: {url}") + + if links: + has_resource = any(link in html for link in links) + if not has_resource: + issues.append("No provided resource links found in HTML") + + return issues + + +@track_performance +def _replace_placeholder_images(html: str, image_urls: List[str]) -> str: + if not image_urls: + return html + + placeholder_pattern = re.compile( + r'(]*src=["\'])(https?://[^"\']*(?:via\.placeholder\.com|placeholder\.com)[^"\']*)(["\'][^>]*>)', + re.IGNORECASE, + ) + idx = 0 + + def repl(match): + nonlocal idx + replacement = image_urls[idx % len(image_urls)] + idx += 1 + return f"{match.group(1)}{replacement}{match.group(3)}" + + return placeholder_pattern.sub(repl, html) + + +@track_performance +def _generate_with_gemini(prompt: str, api_key: str) -> str: + """Generate HTML content using Gemini API.""" + genai.configure(api_key=api_key) + models = ["gemini-2.5-flash", "gemini-2.0-flash", "gemini-1.5-flash"] + + last_error: Exception | None = None + for model_name in models: + try: + model = genai.GenerativeModel( + model_name, + generation_config={ + "temperature": 1.0, + "max_output_tokens": 16000, + } + ) + response = model.generate_content(prompt, stream=False) + html = (response.text or "").strip() + + if html and html.lower().startswith(" str: + cleaned = (html or "").strip() + if cleaned.startswith("```html"): + cleaned = cleaned[7:].strip() + if cleaned.startswith("```"): + cleaned = cleaned[3:].strip() + if cleaned.endswith("```"): + cleaned = cleaned[:-3].strip() + if not cleaned.lower().startswith("\n" + cleaned + return cleaned + + +@track_performance +def _variant_dir(paper_id: str) -> Path: + path = Path(f"temp/webpages/{paper_id}") + path.mkdir(parents=True, exist_ok=True) + return path + + +@track_performance +def _read_manifest(paper_id: str) -> Dict: + mpath = _variant_dir(paper_id) / "manifest.json" + if not mpath.exists(): + return {"paper_id": paper_id, "variants": []} + try: + return json.loads(mpath.read_text(encoding="utf-8")) + except Exception: + return {"paper_id": paper_id, "variants": []} + + +@track_performance +def _write_manifest(paper_id: str, manifest: Dict): + mpath = _variant_dir(paper_id) / "manifest.json" + mpath.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + + +@track_performance +def generate_variant( + paper_id: str, + user_id: str, + paper_info: Dict, + api_key: str, + variant_index: int, + profile_override: Dict | None = None, +) -> Tuple[str, Dict]: + """Generate a webpage variant using Gemini-only adaptive prompting.""" + metadata = paper_info.get("metadata", {}) + title = metadata.get("title", "Research Project") + authors = metadata.get("authors", "Authors") + text = _paper_text_from_info(paper_info) + links = _extract_resource_links(text) + image_urls = _image_urls(paper_id, paper_info) + + profile = profile_override or _random_profile() + prompt = _build_prompt(title, authors, text, links, image_urls, profile) + + html = _normalize_html(_generate_with_gemini(prompt, api_key)) + issues = _validate_generated_html(html, image_urls, links) + + if issues: + repair_prompt = _build_repair_prompt(html, issues, image_urls, links) + html = _normalize_html(_generate_with_gemini(repair_prompt, api_key)) + html = _replace_placeholder_images(html, image_urls) + issues = _validate_generated_html(html, image_urls, links) + + if issues: + raise RuntimeError(f"Generated HTML did not pass quality checks: {issues}") + + # Save the HTML + variant_id = f"v{datetime.utcnow().strftime('%Y%m%d%H%M%S%f')}_{uuid.uuid4().hex[:8]}" + vpath = _variant_dir(paper_id) / f"{variant_id}.html" + vpath.write_text(html, encoding="utf-8") + + return variant_id, { + "variant_id": variant_id, + "theme": profile["name"], + "created_at": datetime.utcnow().isoformat() + "Z", + "file": str(vpath), + } + + +@track_performance +def save_variant_meta(paper_id: str, variant_meta: Dict): + manifest = _read_manifest(paper_id) + variants = manifest.get("variants", []) + variants.insert(0, variant_meta) + manifest["variants"] = variants[:1] + _write_manifest(paper_id, manifest) + + +@track_performance +def clear_variants(paper_id: str): + vdir = _variant_dir(paper_id) + for html_file in vdir.glob("*.html"): + try: + html_file.unlink() + except Exception as exc: + logger.warning(f"Failed to delete old variant {html_file}: {exc}") + + _write_manifest(paper_id, {"paper_id": paper_id, "variants": []}) + + +@track_performance +def list_variants(paper_id: str) -> List[Dict]: + return _read_manifest(paper_id).get("variants", []) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 96734d4..50a26fd 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -41,6 +41,7 @@ import ReelScriptEditorPage from "./pages/ReelScriptEditorPage"; import PodcastDisplay from "./pages/PodcastDisplay"; import PosterGenerator from "./pages/PosterGenerator"; import BusinessBriefPage from "./pages/BusinessBriefPage"; +import WebpageGeneration from "./pages/WebpageGeneration"; import ScrollToTop from "./components/common/ScrollToTop"; @@ -88,117 +89,125 @@ function App() { - +
- {/* Global status / outage banner – always visible above routes */} - {/* */} -
- For better experience, please use Google Chrome browser and configure your own api keys in the API Setup section. -
+ {/* Global status / outage banner – always visible above routes */} + {/* */} +
+ For better experience, please use Google Chrome browser and configure your own api keys in the API Setup section. +
+ + + } /> + } /> + } /> + } /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> - - } /> - } /> - } /> - } /> - - - - - } - /> - - - - - } - /> - - - - - } - /> - - - - - } - /> - - - - - } - /> - - - - - } - /> - - } /> - } - /> - } /> - } /> - } - /> - } /> - } - /> - } /> - } - /> - - } /> - - - } /> + } + /> + } /> + } /> + } + /> + } /> + } /> + } /> + } + /> + + + + } + /> + + } /> + + +
diff --git a/frontend/src/components/navigation/Sidebar.jsx b/frontend/src/components/navigation/Sidebar.jsx index d449b71..8edcb3b 100644 --- a/frontend/src/components/navigation/Sidebar.jsx +++ b/frontend/src/components/navigation/Sidebar.jsx @@ -3,7 +3,7 @@ import { motion } from 'framer-motion'; import { Link, useLocation, useNavigate } from 'react-router-dom'; import { FiKey, FiUpload, FiEdit3, FiSliders, - FiPlay, FiDownload, FiCheck, FiClock, FiLogOut, FiUser, FiZap + FiPlay, FiDownload, FiCheck, FiClock, FiLogOut, FiUser, FiZap, FiGlobe } from 'react-icons/fi'; import { useWorkflow } from '../../contexts/WorkflowContext'; import { useResponsive } from '../../hooks/useResponsive'; @@ -68,6 +68,7 @@ const Sidebar = ({ isOpen, onClose }) => { { to: '/media-generation', icon: FiPlay, label: 'Media Generation', step: 5 }, { to: '/poster', icon: FiZap, label: 'Poster Generation', step: 6 }, { to: '/results', icon: FiDownload, label: 'Results', step: 7 }, + { to: '/webpage-generation', icon: FiGlobe, label: 'Webpage Generation', step: 0 }, ]; const handleLinkClick = (step) => { diff --git a/frontend/src/pages/WebpageGeneration.jsx b/frontend/src/pages/WebpageGeneration.jsx new file mode 100644 index 0000000..056f35e --- /dev/null +++ b/frontend/src/pages/WebpageGeneration.jsx @@ -0,0 +1,231 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { FiDownload, FiGlobe, FiMonitor, FiSmartphone, FiTablet } from "react-icons/fi"; + +import Layout from "../components/common/Layout"; +import { useWorkflow } from "../contexts/WorkflowContext"; +import { apiService } from "../services/api"; +import toast from "../services/toastService"; +import { downloadBlob } from "../utils/helpers"; + +const ViewportButton = ({ active, onClick, icon: Icon, label }) => ( + +); + +const blobToDataUrl = (blob) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result); + reader.onerror = () => reject(new Error("Failed to read preview asset")); + reader.readAsDataURL(blob); + }); + +const WebpageGeneration = () => { + const { paperId: ctxPaperId } = useWorkflow(); + const paperId = ctxPaperId; + const [loading, setLoading] = useState(false); + const [selected, setSelected] = useState(null); + const [previewHtml, setPreviewHtml] = useState(""); + const [previewLoading, setPreviewLoading] = useState(false); + const [viewport, setViewport] = useState("desktop"); + + const crumbs = [{ label: "Webpage Generation", href: "/webpage-generation" }]; + + const frameStyle = useMemo(() => { + if (viewport === "mobile") return { width: "390px", height: "75vh" }; + if (viewport === "tablet") return { width: "820px", height: "75vh" }; + return { width: "100%", height: "75vh" }; + }, [viewport]); + + const loadLatestVariant = async () => { + if (!paperId) return; + try { + const resp = await apiService.listWebpageVariants(paperId); + const list = resp?.data || []; + if (list.length) setSelected(list[0]); + } catch (e) { + console.warn("Failed to fetch variants", e); + } + }; + + useEffect(() => { + setSelected(null); + setPreviewHtml(""); + loadLatestVariant(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [paperId]); + + useEffect(() => { + let cancelled = false; + + const loadPreview = async () => { + if (!paperId || !selected?.variant_id) { + setPreviewHtml(""); + setPreviewLoading(false); + return; + } + + setPreviewLoading(true); + try { + const resp = await apiService.getWebpagePreviewHtml(paperId, selected.variant_id); + let html = resp?.data || ""; + const matches = [...html.matchAll(/(\/api\/webpage\/[^"'\s>]+\/asset\/[^"'\s>]+)/g)]; + const uniqueUrls = [...new Set(matches.map((match) => match[1]))]; + + for (const url of uniqueUrls) { + const fileName = url.split("/").pop(); + const assetResp = await apiService.getWebpagePreviewAsset(paperId, fileName); + const dataUrl = await blobToDataUrl(assetResp.data); + html = html.split(url).join(dataUrl); + } + + if (!cancelled) { + setPreviewHtml(html); + } + } catch (error) { + if (!cancelled) { + setPreviewHtml(""); + toast.error("Failed to load preview."); + } + } finally { + if (!cancelled) { + setPreviewLoading(false); + } + } + }; + + loadPreview(); + + return () => { + cancelled = true; + }; + }, [paperId, selected]); + + const generate = async () => { + if (!paperId) { + toast.error("Please upload/process a paper first."); + return; + } + + setLoading(true); + try { + const resp = await apiService.generateWebpage(paperId); + const created = resp?.data?.variants || []; + if (!created.length) { + toast.error("No variants were generated."); + } else { + setSelected(created[0]); + toast.success("Generated a new webpage."); + } + } catch (error) { + const msg = error?.response?.data?.detail || error?.message || "Failed to generate webpage"; + toast.error(msg); + } finally { + setLoading(false); + } + }; + + const downloadSelected = async () => { + if (!paperId || !selected?.variant_id) return; + try { + const resp = await apiService.downloadWebpageVariant(paperId, selected.variant_id); + downloadBlob(resp.data, `webpage_${selected.variant_id}.html`); + toast.success("Downloaded HTML"); + } catch (error) { + toast.error("Download failed"); + } + }; + + return ( + +
+
+
+
+

Generate a polished research webpage

+

+ Focused on paper highlights, figures, and repository links. +

+
+ +
+ + + +
+
+
+ +
+
+

Live Preview

+
+ setViewport("desktop")} + icon={FiMonitor} + label="Desktop" + /> + setViewport("tablet")} + icon={FiTablet} + label="Tablet" + /> + setViewport("mobile")} + icon={FiSmartphone} + label="Mobile" + /> +
+
+ + {!selected ? ( +
+ Generate a webpage to preview it here. +
+ ) : ( +
+