diff --git a/api/main.py b/api/main.py index 15d1eeb8ce..01826ead78 100644 --- a/api/main.py +++ b/api/main.py @@ -27,6 +27,7 @@ health_router, libraries_router, plots_router, + proxy_router, seo_router, specs_router, stats_router, @@ -128,6 +129,7 @@ async def add_cache_headers(request: Request, call_next): app.include_router(plots_router) app.include_router(download_router) app.include_router(seo_router) +app.include_router(proxy_router) if __name__ == "__main__": diff --git a/api/routers/__init__.py b/api/routers/__init__.py index aa35c53263..e30046154a 100644 --- a/api/routers/__init__.py +++ b/api/routers/__init__.py @@ -4,6 +4,7 @@ from api.routers.health import router as health_router from api.routers.libraries import router as libraries_router from api.routers.plots import router as plots_router +from api.routers.proxy import router as proxy_router from api.routers.seo import router as seo_router from api.routers.specs import router as specs_router from api.routers.stats import router as stats_router @@ -14,6 +15,7 @@ "health_router", "libraries_router", "plots_router", + "proxy_router", "seo_router", "specs_router", "stats_router", diff --git a/api/routers/proxy.py b/api/routers/proxy.py new file mode 100644 index 0000000000..806d761034 --- /dev/null +++ b/api/routers/proxy.py @@ -0,0 +1,151 @@ +"""HTML proxy endpoint for interactive plots with size reporting.""" + +from urllib.parse import urlparse + +import httpx +from fastapi import APIRouter, HTTPException +from fastapi.responses import HTMLResponse + + +router = APIRouter(tags=["proxy"]) + +# Script injected to report content size to parent window +# Uses specific origin (pyplots.ai) for postMessage security +SIZE_REPORTER_SCRIPT = """ + +""" + +# Allowed GCS bucket for security +ALLOWED_HOST = "storage.googleapis.com" +ALLOWED_BUCKET = "pyplots-images" + + +def build_safe_gcs_url(url: str) -> str | None: + """ + Validate URL and return a reconstructed safe GCS URL. + + This prevents SSRF by constructing the URL from hardcoded values + instead of passing user input directly. + + Args: + url: User-provided URL to validate + + Returns: + Reconstructed safe URL or None if validation fails + """ + try: + parsed = urlparse(url) + # Must be HTTPS + if parsed.scheme != "https": + return None + # Must be exact host (no subdomains) + if parsed.netloc != ALLOWED_HOST: + return None + # Path must start with bucket name + path_parts = parsed.path.strip("/").split("/") + if len(path_parts) < 2: + return None + if path_parts[0] != ALLOWED_BUCKET: + return None + # Check for path traversal attempts + if ".." in parsed.path: + return None + # Validate path contains only safe characters (alphanumeric, hyphens, underscores, dots, slashes) + safe_path = parsed.path.strip("/") + if not all(c.isalnum() or c in "-_./+" for c in safe_path): + return None + # Reconstruct URL from hardcoded values to prevent SSRF + # This breaks the taint flow by not using the original URL + return f"https://{ALLOWED_HOST}/{safe_path}" + except Exception: + return None + + +@router.get("/proxy/html", response_class=HTMLResponse) +async def proxy_html(url: str): + """ + Proxy an HTML file and inject size reporting script. + + This endpoint fetches HTML from GCS, injects a script that reports + the content's actual dimensions via postMessage, and returns the + modified HTML. This allows the frontend to dynamically scale the + iframe based on actual content size. + + Args: + url: The GCS URL to fetch (must be from allowed bucket) + + Returns: + Modified HTML with size reporting script injected + """ + # Security: Validate and reconstruct URL to prevent SSRF + safe_url = build_safe_gcs_url(url) + if safe_url is None: + raise HTTPException(status_code=400, detail=f"Only URLs from {ALLOWED_HOST}/{ALLOWED_BUCKET} are allowed") + + # Fetch the HTML with shorter timeout + async with httpx.AsyncClient(timeout=10.0) as client: + try: + response = await client.get(safe_url) + response.raise_for_status() + except httpx.HTTPStatusError as e: + raise HTTPException(status_code=e.response.status_code, detail="Failed to fetch HTML") from e + except httpx.RequestError as e: + raise HTTPException(status_code=502, detail="Failed to connect to storage") from e + + html_content = response.text + + # Inject the size reporter script before + if "" in html_content: + html_content = html_content.replace("", f"{SIZE_REPORTER_SCRIPT}") + elif "" in html_content: + html_content = html_content.replace("", f"{SIZE_REPORTER_SCRIPT}") + else: + # Fallback: append to end + html_content += SIZE_REPORTER_SCRIPT + + return HTMLResponse(content=html_content) diff --git a/api/routers/seo.py b/api/routers/seo.py index 7784cd1df7..e8d865990a 100644 --- a/api/routers/seo.py +++ b/api/routers/seo.py @@ -8,7 +8,6 @@ from api.cache import cache_key, get_cache, set_cache from api.dependencies import optional_db -from core.constants import LIBRARIES_METADATA from core.database import SpecRepository @@ -20,7 +19,7 @@ async def get_sitemap(db: AsyncSession | None = Depends(optional_db)): """ Generate dynamic XML sitemap for SEO. - Includes all specs with implementations and all libraries. + Includes root, catalog page, and all specs with implementations. """ key = cache_key("sitemap_xml") cached = get_cache(key) @@ -32,6 +31,7 @@ async def get_sitemap(db: AsyncSession | None = Depends(optional_db)): '', '', " https://pyplots.ai/", + " https://pyplots.ai/catalog", ] # Add spec URLs (only specs with implementations) @@ -41,12 +41,7 @@ async def get_sitemap(db: AsyncSession | None = Depends(optional_db)): for spec in specs: if spec.impls: # Only include specs with implementations spec_id = html.escape(spec.id) - xml_lines.append(f" https://pyplots.ai/?spec={spec_id}") - - # Add library URLs (static list) - for lib in LIBRARIES_METADATA: - lib_id = html.escape(lib["id"]) - xml_lines.append(f" https://pyplots.ai/?lib={lib_id}") + xml_lines.append(f" https://pyplots.ai/{spec_id}") xml_lines.append("") xml = "\n".join(xml_lines) diff --git a/api/routers/specs.py b/api/routers/specs.py index b013eeef63..edb20162bb 100644 --- a/api/routers/specs.py +++ b/api/routers/specs.py @@ -81,6 +81,11 @@ async def get_spec(spec_id: str, db: AsyncSession = Depends(require_db)): generated_by=impl.generated_by, python_version=impl.python_version, library_version=impl.library_version, + review_strengths=impl.review_strengths or [], + review_weaknesses=impl.review_weaknesses or [], + review_image_description=impl.review_image_description, + review_criteria_checklist=impl.review_criteria_checklist, + review_verdict=impl.review_verdict, ) for impl in spec.impls ] @@ -95,6 +100,8 @@ async def get_spec(spec_id: str, db: AsyncSession = Depends(require_db)): tags=spec.tags, issue=spec.issue, suggested=spec.suggested, + created=spec.created.isoformat() if spec.created else None, + updated=spec.updated.isoformat() if spec.updated else None, implementations=impls, ) set_cache(key, result) diff --git a/api/schemas.py b/api/schemas.py index 4dac2e63c2..236c42a92d 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -23,6 +23,12 @@ class ImplementationResponse(BaseModel): generated_by: Optional[str] = None python_version: Optional[str] = None library_version: Optional[str] = None + # Review fields + review_strengths: list[str] = [] + review_weaknesses: list[str] = [] + review_image_description: Optional[str] = None + review_criteria_checklist: Optional[dict] = None + review_verdict: Optional[str] = None class SpecDetailResponse(BaseModel): @@ -37,6 +43,8 @@ class SpecDetailResponse(BaseModel): tags: Optional[dict] = None issue: Optional[int] = None suggested: Optional[str] = None + created: Optional[str] = None + updated: Optional[str] = None implementations: list[ImplementationResponse] = [] diff --git a/app/package.json b/app/package.json index be2ea33756..16e0290a73 100644 --- a/app/package.json +++ b/app/package.json @@ -22,6 +22,8 @@ "@mui/material": "^7.3.6", "react": "^19.2.3", "react-dom": "^19.2.3", + "react-helmet-async": "^2.0.5", + "react-router-dom": "^7.11.0", "react-syntax-highlighter": "^16.1.0" }, "devDependencies": { diff --git a/app/src/components/FilterBar.tsx b/app/src/components/FilterBar.tsx index 550a64709e..2c52555952 100644 --- a/app/src/components/FilterBar.tsx +++ b/app/src/components/FilterBar.tsx @@ -1,4 +1,5 @@ import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; +import { Link } from 'react-router-dom'; import Box from '@mui/material/Box'; import Chip from '@mui/material/Chip'; import Menu from '@mui/material/Menu'; @@ -7,11 +8,13 @@ import ListItemText from '@mui/material/ListItemText'; import Typography from '@mui/material/Typography'; import InputBase from '@mui/material/InputBase'; import Divider from '@mui/material/Divider'; +import Tooltip from '@mui/material/Tooltip'; import CloseIcon from '@mui/icons-material/Close'; import SearchIcon from '@mui/icons-material/Search'; import AddIcon from '@mui/icons-material/Add'; import ViewAgendaIcon from '@mui/icons-material/ViewAgenda'; import ViewModuleIcon from '@mui/icons-material/ViewModule'; +import ListIcon from '@mui/icons-material/List'; import useMediaQuery from '@mui/material/useMediaQuery'; import { useTheme } from '@mui/material/styles'; @@ -56,8 +59,10 @@ export function FilterBar({ const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); - // Scroll percentage - estimate based on total plots, not just loaded ones + // Scroll percentage and sticky detection const [scrollPercent, setScrollPercent] = useState(0); + const [isSticky, setIsSticky] = useState(false); + const filterBarRef = useRef(null); useEffect(() => { const calculatePercent = () => { @@ -73,6 +78,10 @@ export function FilterBar({ const percent = Math.round((scrollY / estimatedTotalHeight) * 100); setScrollPercent(Math.min(100, Math.max(0, percent || 0))); + + // Detect if bar is in sticky mode (scrolled past threshold) + // The bar becomes sticky when scrollY > ~200px (header height) + setIsSticky(scrollY > 200); }; calculatePercent(); window.addEventListener('scroll', calculatePercent); @@ -288,16 +297,27 @@ export function FilterBar({ return ( {/* Filter chips row */} @@ -326,28 +346,57 @@ export function FilterBar({ {scrollPercent}% · {currentTotal} )} - {/* Grid size toggle - absolute right (desktop only) */} + {/* Catalog icon + Grid size toggle - absolute right (desktop only) */} {!isMobile && ( onImageSizeChange(imageSize === 'normal' ? 'compact' : 'normal')} sx={{ position: 'absolute', right: 0, display: 'flex', alignItems: 'center', - justifyContent: 'center', - width: 32, - height: 32, - cursor: 'pointer', - color: '#9ca3af', - '&:hover': { color: '#3776AB' }, + gap: 0.5, }} > - {imageSize === 'normal' ? ( - - ) : ( - - )} + {/* Catalog icon */} + + + + + + {/* Grid size toggle */} + + onImageSizeChange(imageSize === 'normal' ? 'compact' : 'normal')} + sx={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: 32, + height: 32, + cursor: 'pointer', + color: '#9ca3af', + '&:hover': { color: '#3776AB' }, + }} + > + {imageSize === 'normal' ? ( + + ) : ( + + )} + + )} {/* Active filter chips */} @@ -426,17 +475,22 @@ export function FilterBar({ }, }} > - + + + { @@ -512,24 +566,47 @@ export function FilterBar({ ) : ( )} - onImageSizeChange(imageSize === 'normal' ? 'compact' : 'normal')} - sx={{ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - width: 32, - height: 32, - cursor: 'pointer', - color: '#9ca3af', - '&:hover': { color: '#3776AB' }, - }} - > - {imageSize === 'normal' ? ( - - ) : ( - - )} + + {/* Catalog icon */} + + + + + + {/* Grid size toggle */} + + onImageSizeChange(imageSize === 'normal' ? 'compact' : 'normal')} + sx={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: 32, + height: 32, + cursor: 'pointer', + color: '#9ca3af', + '&:hover': { color: '#3776AB' }, + }} + > + {imageSize === 'normal' ? ( + + ) : ( + + )} + + )} diff --git a/app/src/components/FullscreenModal.tsx b/app/src/components/FullscreenModal.tsx deleted file mode 100644 index 58840e3d2a..0000000000 --- a/app/src/components/FullscreenModal.tsx +++ /dev/null @@ -1,372 +0,0 @@ -import { useState, useMemo, useCallback, useEffect } from 'react'; -import Box from '@mui/material/Box'; -import Modal from '@mui/material/Modal'; -import IconButton from '@mui/material/IconButton'; -import Typography from '@mui/material/Typography'; -import CircularProgress from '@mui/material/CircularProgress'; -import CloseIcon from '@mui/icons-material/Close'; -import ContentCopyIcon from '@mui/icons-material/ContentCopy'; -import DownloadIcon from '@mui/icons-material/Download'; -import CodeIcon from '@mui/icons-material/Code'; -import ImageIcon from '@mui/icons-material/Image'; -import CheckIcon from '@mui/icons-material/Check'; -import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; -import { oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'; -import type { PlotImage } from '../types'; -import { API_URL } from '../constants'; -import { useCopyCode, useCodeFetch } from '../hooks'; - -interface FullscreenModalProps { - image: PlotImage | null; - selectedSpec: string; - onClose: () => void; - onTrackEvent?: (name: string, props?: Record) => void; -} - -export function FullscreenModal({ image, selectedSpec, onClose, onTrackEvent }: FullscreenModalProps) { - const [showCode, setShowCode] = useState(false); - const [blinkCodeButton, setBlinkCodeButton] = useState(false); - const [downloaded, setDownloaded] = useState(false); - const [fetchedCode, setFetchedCode] = useState(null); - const [codeLoading, setCodeLoading] = useState(false); - - const { fetchCode } = useCodeFetch(); - const { copied, copyToClipboard, reset: resetCopied } = useCopyCode({ - onCopy: () => { - const specId = selectedSpec || image?.spec_id; - onTrackEvent?.('copy_code', { spec: specId, library: image?.library, method: 'button' }); - }, - }); - - // Code to display - prefer image.code if available, otherwise fetched - const displayCode = image?.code ?? fetchedCode; - - // Fetch code when modal opens (if not already present in image) - useEffect(() => { - if (image && !image.code && image.spec_id) { - setCodeLoading(true); - fetchCode(image.spec_id, image.library).then((code) => { - setFetchedCode(code); - setCodeLoading(false); - }); - } else { - setFetchedCode(null); - } - }, [image, fetchCode]); - - // Reset state when modal opens - const handleOpen = useCallback(() => { - setShowCode(false); - setBlinkCodeButton(true); - resetCopied(); - setDownloaded(false); - setTimeout(() => setBlinkCodeButton(false), 1000); - }, [resetCopied]); - - // Memoize syntax-highlighted code to avoid expensive re-renders - const highlightedCode = useMemo(() => { - if (!displayCode) return null; - return ( - - {displayCode} - - ); - }, [displayCode]); - - // Copy code to clipboard - const handleCopyCode = useCallback(() => { - if (displayCode) { - copyToClipboard(displayCode); - } - }, [displayCode, copyToClipboard]); - - // Track native copy events (Ctrl+C, Cmd+C) - const handleNativeCopy = useCallback(() => { - const specId = selectedSpec || image?.spec_id; - onTrackEvent?.('copy_code', { spec: specId, library: image?.library, method: 'keyboard' }); - }, [onTrackEvent, selectedSpec, image?.library, image?.spec_id]); - - // Track contextmenu (right-click) - user may copy from context menu - const handleContextMenu = useCallback(() => { - const specId = selectedSpec || image?.spec_id; - onTrackEvent?.('copy_code', { spec: specId, library: image?.library, method: 'keyboard' }); - }, [onTrackEvent, selectedSpec, image?.library, image?.spec_id]); - - // Download image via backend proxy - const downloadImage = useCallback(() => { - const specId = selectedSpec || image?.spec_id; - if (image?.library && specId) { - const link = document.createElement('a'); - link.href = `${API_URL}/download/${specId}/${image.library}`; - link.download = `${specId}-${image.library}.png`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - setDownloaded(true); - onTrackEvent?.('download_image', { spec: specId, library: image.library }); - setTimeout(() => setDownloaded(false), 2000); - } - }, [image?.library, image?.spec_id, selectedSpec, onTrackEvent]); - - const handleClose = useCallback(() => { - setShowCode(false); - onClose(); - }, [onClose]); - - return ( - - - {/* Modal Header - Close only */} - - - - - - - {/* Modal Content */} - {image && ( - - {/* Code View - only rendered when needed */} - {showCode && ( - - {/* Button bar */} - - { - setShowCode(false); - onTrackEvent?.('view_image', { spec: selectedSpec || image?.spec_id, library: image?.library }); - }} - sx={{ - display: 'flex', - alignItems: 'center', - gap: 0.5, - px: 1, - py: 0.5, - borderRadius: 1, - cursor: 'pointer', - color: '#6b7280', - bgcolor: 'rgba(255,255,255,0.9)', - fontSize: '0.85rem', - fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace', - '&:hover': { color: '#3776AB', bgcolor: '#fff' }, - }} - > - - plot - - - {copied ? : } - - - - {highlightedCode} - - - )} - {/* PNG image - always rendered, hidden when code is shown */} - - {/* Button bar */} - - {(displayCode || codeLoading) && ( - { - if (displayCode) { - setShowCode(true); - onTrackEvent?.('view_code', { spec: selectedSpec || image?.spec_id, library: image?.library }); - } - }} - sx={{ - display: 'flex', - alignItems: 'center', - gap: 0.5, - px: 1, - py: 0.5, - borderRadius: 1, - cursor: displayCode ? 'pointer' : 'default', - color: '#6b7280', - bgcolor: 'rgba(255,255,255,0.9)', - fontSize: '0.85rem', - fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace', - '&:hover': displayCode ? { color: '#3776AB', bgcolor: '#fff' } : {}, - ...(blinkCodeButton && { - animation: 'bounce 0.6s ease-in-out', - '@keyframes bounce': { - '0%, 100%': { transform: 'translateY(0)' }, - '25%': { transform: 'translateY(-4px)' }, - '50%': { transform: 'translateY(0)' }, - '75%': { transform: 'translateY(-2px)' }, - }, - }), - }} - > - {codeLoading ? ( - - ) : ( - - )} - code - - )} - - {downloaded ? : } - - - {`${selectedSpec} - - - {image.library} - - - )} - - - ); -} diff --git a/app/src/components/Header.tsx b/app/src/components/Header.tsx index 9947c50e87..f3ca3197ff 100644 --- a/app/src/components/Header.tsx +++ b/app/src/components/Header.tsx @@ -187,6 +187,7 @@ export const Header = memo(function Header({ stats, onRandom }: HeaderProps) { )} {isXs ? '. copy. create.' : '. grab the code. make it yours.'} + ); }); diff --git a/app/src/components/ImagesGrid.tsx b/app/src/components/ImagesGrid.tsx index 3e87e1bc26..9c14d82c43 100644 --- a/app/src/components/ImagesGrid.tsx +++ b/app/src/components/ImagesGrid.tsx @@ -115,14 +115,14 @@ export function ImagesGrid({ No images found for this spec. ) : ( - + {images.map((image, index) => { const lib = libraryMap.get(image.library); const spec = specMap.get(image.spec_id || ''); @@ -148,7 +148,7 @@ export function ImagesGrid({ ); })} - + )} {/* Load more trigger (invisible) */} {hasMore && ( diff --git a/app/src/components/Layout.tsx b/app/src/components/Layout.tsx new file mode 100644 index 0000000000..70c10ea33a --- /dev/null +++ b/app/src/components/Layout.tsx @@ -0,0 +1,136 @@ +import { useState, useEffect, createContext, useContext, useRef, useCallback, type ReactNode } from 'react'; +import { Outlet } from 'react-router-dom'; +import Box from '@mui/material/Box'; +import Container from '@mui/material/Container'; + +import { API_URL } from '../constants'; +import type { LibraryInfo, SpecInfo, PlotImage, ActiveFilters, FilterCounts } from '../types'; + +interface AppData { + specsData: SpecInfo[]; + librariesData: LibraryInfo[]; + stats: { specs: number; plots: number; libraries: number } | null; +} + +// Persistent home state that survives navigation +interface HomeState { + allImages: PlotImage[]; + displayedImages: PlotImage[]; + activeFilters: ActiveFilters; + filterCounts: FilterCounts | null; + globalCounts: FilterCounts | null; + orCounts: Record[]; + hasMore: boolean; + scrollY: number; + initialized: boolean; +} + +interface HomeStateContext { + homeState: HomeState; + homeStateRef: React.MutableRefObject; + setHomeState: React.Dispatch>; + saveScrollPosition: () => void; +} + +const initialHomeState: HomeState = { + allImages: [], + displayedImages: [], + activeFilters: [], + filterCounts: null, + globalCounts: null, + orCounts: [], + hasMore: false, + scrollY: 0, + initialized: false, +}; + +const AppDataContext = createContext(null); +const HomeStateContext = createContext(null); + +export function useAppData() { + const context = useContext(AppDataContext); + if (!context) { + throw new Error('useAppData must be used within AppDataProvider'); + } + return context; +} + +export function useHomeState() { + const context = useContext(HomeStateContext); + if (!context) { + throw new Error('useHomeState must be used within AppDataProvider'); + } + return context; +} + +// Global provider that wraps the entire router (persists across all pages including InteractivePage) +export function AppDataProvider({ children }: { children: ReactNode }) { + const [specsData, setSpecsData] = useState([]); + const [librariesData, setLibrariesData] = useState([]); + const [stats, setStats] = useState<{ specs: number; plots: number; libraries: number } | null>(null); + + // Persistent home state (both ref for sync access and state for reactivity) + const [homeState, setHomeState] = useState(initialHomeState); + const homeStateRef = useRef(initialHomeState); + + // Keep ref in sync with state + useEffect(() => { + homeStateRef.current = homeState; + }, [homeState]); + + // Save scroll position synchronously to ref (called before navigation) + const saveScrollPosition = useCallback(() => { + homeStateRef.current = { ...homeStateRef.current, scrollY: window.scrollY }; + setHomeState((prev) => ({ ...prev, scrollY: window.scrollY })); + }, []); + + // Load shared data on mount + useEffect(() => { + const fetchData = async () => { + try { + const [specsRes, libsRes, statsRes] = await Promise.all([ + fetch(`${API_URL}/specs`), + fetch(`${API_URL}/libraries`), + fetch(`${API_URL}/stats`), + ]); + + if (specsRes.ok) { + const data = await specsRes.json(); + setSpecsData(Array.isArray(data) ? data : data.specs || []); + } + + if (libsRes.ok) { + const data = await libsRes.json(); + setLibrariesData(data.libraries || []); + } + + if (statsRes.ok) { + const data = await statsRes.json(); + setStats(data); + } + } catch (err) { + console.error('Error loading initial data:', err); + } + }; + fetchData(); + }, []); + + return ( + + + {children} + + + ); +} + +// Layout component for pages with standard layout (HomePage, SpecPage, CatalogPage) +export function Layout() { + return ( + + + + + + ); +} diff --git a/app/src/components/LibraryPills.tsx b/app/src/components/LibraryPills.tsx new file mode 100644 index 0000000000..9e8d8a02ba --- /dev/null +++ b/app/src/components/LibraryPills.tsx @@ -0,0 +1,181 @@ +import { memo, useMemo } from 'react'; +import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; +import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; + +// Library abbreviations (same as filter display) +const LIBRARY_ABBREV: Record = { + matplotlib: 'mpl', + seaborn: 'sns', + plotly: 'ply', + bokeh: 'bok', + altair: 'alt', + plotnine: 'p9', + pygal: 'pyg', + highcharts: 'hc', + letsplot: 'lp', +}; + +interface Implementation { + library_id: string; + library_name: string; + quality_score: number | null; +} + +interface LibraryPillsProps { + implementations: Implementation[]; + selectedLibrary: string; + onSelect: (libraryId: string) => void; +} + +export const LibraryPills = memo(function LibraryPills({ + implementations, + selectedLibrary, + onSelect, +}: LibraryPillsProps) { + // Sort implementations alphabetically + const sortedImpls = useMemo(() => { + return [...implementations].sort((a, b) => a.library_id.localeCompare(b.library_id)); + }, [implementations]); + + // Get current index + const currentIndex = useMemo(() => { + const idx = sortedImpls.findIndex((impl) => impl.library_id === selectedLibrary); + return idx >= 0 ? idx : 0; + }, [sortedImpls, selectedLibrary]); + + // Get visible items (prev, current, next) with wrap-around + const visibleItems = useMemo(() => { + const len = sortedImpls.length; + if (len === 0) return []; + if (len === 1) return [{ impl: sortedImpls[0], position: 'center' as const }]; + if (len === 2) { + return [ + { impl: sortedImpls[(currentIndex - 1 + len) % len], position: 'left' as const }, + { impl: sortedImpls[currentIndex], position: 'center' as const }, + ]; + } + + const prevIdx = (currentIndex - 1 + len) % len; + const nextIdx = (currentIndex + 1) % len; + + return [ + { impl: sortedImpls[prevIdx], position: 'left' as const }, + { impl: sortedImpls[currentIndex], position: 'center' as const }, + { impl: sortedImpls[nextIdx], position: 'right' as const }, + ]; + }, [sortedImpls, currentIndex]); + + const handlePrev = () => { + const len = sortedImpls.length; + const newIndex = (currentIndex - 1 + len) % len; + onSelect(sortedImpls[newIndex].library_id); + }; + + const handleNext = () => { + const len = sortedImpls.length; + const newIndex = (currentIndex + 1) % len; + onSelect(sortedImpls[newIndex].library_id); + }; + + if (sortedImpls.length === 0) return null; + + return ( + + {/* Left Arrow */} + + + + + {/* Pills Container */} + + {visibleItems.map(({ impl, position }) => { + const isCenter = position === 'center'; + const score = impl.quality_score; + + return ( + onSelect(impl.library_id)} + title={!isCenter ? impl.library_id : undefined} + sx={{ + px: 1.5, + py: 0.5, + borderRadius: 2, + fontFamily: '"MonoLisa", monospace', + fontSize: '0.85rem', + fontWeight: isCenter ? 600 : 400, + bgcolor: '#f3f4f6', + border: isCenter ? '1px solid #3776AB' : '1px solid transparent', + color: isCenter ? '#374151' : '#9ca3af', + cursor: 'pointer', + transition: 'all 0.2s ease', + whiteSpace: 'nowrap', + '&:hover': { + bgcolor: '#e5e7eb', + borderColor: '#3776AB', + color: '#374151', + }, + }} + > + {/* Full name on desktop, abbreviated on mobile */} + + {impl.library_id} + + + {isCenter ? impl.library_id : (LIBRARY_ABBREV[impl.library_id] || impl.library_id)} + + {score && isCenter && ( + + {Math.round(score)} + + )} + + ); + })} + + + {/* Right Arrow */} + + + + + ); +}); diff --git a/app/src/components/SpecTabs.tsx b/app/src/components/SpecTabs.tsx new file mode 100644 index 0000000000..e15f817dd2 --- /dev/null +++ b/app/src/components/SpecTabs.tsx @@ -0,0 +1,540 @@ +import { useState, useCallback, useMemo } from 'react'; +import Box from '@mui/material/Box'; +import Tabs from '@mui/material/Tabs'; +import Tab from '@mui/material/Tab'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import Chip from '@mui/material/Chip'; +import Collapse from '@mui/material/Collapse'; +import CodeIcon from '@mui/icons-material/Code'; +import DescriptionIcon from '@mui/icons-material/Description'; +import ImageIcon from '@mui/icons-material/Image'; +import StarIcon from '@mui/icons-material/Star'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import CheckIcon from '@mui/icons-material/Check'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism'; + +interface SpecTabsProps { + // Code tab + code: string | null; + // Specification tab + specId: string; + title: string; + description: string; + applications?: string[]; + data?: string[]; + notes?: string[]; + tags?: Record; + created?: string; + // Implementation tab + imageDescription?: string; + strengths?: string[]; + weaknesses?: string[]; + // Quality tab + qualityScore: number | null; + criteriaChecklist?: Record; + // Common + libraryId: string; + onTrackEvent?: (name: string, props?: Record) => void; +} + +interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number; +} + +function TabPanel({ children, value, index }: TabPanelProps) { + return ( + + ); +} + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function SectionContent({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +export function SpecTabs({ + code, + specId, + title, + description, + applications, + data, + notes, + tags, + created, + imageDescription, + strengths, + weaknesses, + qualityScore, + criteriaChecklist, + libraryId, + onTrackEvent, +}: SpecTabsProps) { + const [copied, setCopied] = useState(false); + const [tabIndex, setTabIndex] = useState(0); // Code is default (index 0) + const [expandedCategories, setExpandedCategories] = useState>({}); + + const toggleCategory = (category: string) => { + setExpandedCategories((prev) => ({ ...prev, [category]: !prev[category] })); + }; + + const handleCopy = useCallback(async () => { + if (!code) return; + try { + await navigator.clipboard.writeText(code); + setCopied(true); + onTrackEvent?.('copy_code', { library: libraryId, method: 'tab' }); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Copy failed:', err); + } + }, [code, libraryId, onTrackEvent]); + + const handleTabChange = (_: React.SyntheticEvent, newValue: number) => { + setTabIndex(newValue); + const tabNames = ['code', 'specification', 'implementation', 'quality']; + onTrackEvent?.('tab_click', { tab: tabNames[newValue], library: libraryId }); + }; + + // Memoize syntax-highlighted code + const highlightedCode = useMemo(() => { + if (!code) return null; + return ( + + {code} + + ); + }, [code]); + + // Format date + const formatDate = (dateStr?: string) => { + if (!dateStr) return null; + try { + return new Date(dateStr).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + } catch { + return dateStr; + } + }; + + // Flatten tags for display + const allTags = useMemo(() => { + if (!tags) return []; + return Object.entries(tags).flatMap(([category, values]) => + values.map((v) => ({ category, value: v })) + ); + }, [tags]); + + return ( + + + + } iconPosition="start" label="Code" /> + } iconPosition="start" label="Spec" /> + } iconPosition="start" label="Impl" /> + } + iconPosition="start" + label={qualityScore ? `${Math.round(qualityScore)}` : 'Quality'} + /> + + + + {/* Code Tab */} + + + + + {copied ? : } + + + + {highlightedCode} + + + + + {/* Specification Tab */} + + + {/* ID */} + + ID + {specId} + + + {/* Title */} + + Title + {title} + + + {/* Description */} + + Description + {description} + + + {/* Applications */} + {applications && applications.length > 0 && ( + + Applications + + {applications.map((app, i) => ( + + {app} + + ))} + + + )} + + {/* Data */} + {data && data.length > 0 && ( + + Data + + {data.map((d, i) => ( + + {d} + + ))} + + + )} + + {/* Notes */} + {notes && notes.length > 0 && ( + + Notes + + {notes.map((note, i) => ( + + {note} + + ))} + + + )} + + {/* Tags */} + {allTags.length > 0 && ( + + Tags + + {allTags.map(({ value }, i) => ( + + ))} + + + )} + + {/* Created */} + {created && ( + + Created + {formatDate(created)} + + )} + + + + {/* Implementation Tab */} + + + {/* Image Description */} + {imageDescription && ( + + AI Description + {imageDescription} + + )} + + {/* Strengths */} + {strengths && strengths.length > 0 && ( + + Strengths + + {strengths.map((s, i) => ( + + {s} + + ))} + + + )} + + {/* Weaknesses */} + {weaknesses && weaknesses.length > 0 && ( + + Weaknesses + + {weaknesses.map((w, i) => ( + + {w} + + ))} + + + )} + + {/* No data message */} + {!imageDescription && (!strengths || strengths.length === 0) && (!weaknesses || weaknesses.length === 0) && ( + + No implementation review data available. + + )} + + + + {/* Quality Tab */} + + + {/* Score */} + + Quality Score + = 90 ? '#22c55e' : qualityScore && qualityScore >= 70 ? '#f59e0b' : '#ef4444', + }} + > + {qualityScore ? `${Math.round(qualityScore)}/100` : 'N/A'} + + + + {/* Criteria Checklist */} + {criteriaChecklist && Object.keys(criteriaChecklist).length > 0 && ( + + Criteria Breakdown + + {Object.entries(criteriaChecklist).map(([category, data]) => { + const catData = data as { score?: number; max?: number; items?: Array<{ id: string; name: string; score: number; max: number; passed: boolean; comment?: string }> }; + const score = catData.score ?? 0; + const max = catData.max ?? 0; + const pct = max > 0 ? (score / max) * 100 : 0; + const items = catData.items || []; + const isExpanded = expandedCategories[category] ?? false; + + return ( + + {/* Category header - clickable */} + items.length > 0 && toggleCategory(category)} + sx={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + mb: 0.5, + cursor: items.length > 0 ? 'pointer' : 'default', + '&:hover': items.length > 0 ? { opacity: 0.8 } : {}, + }} + > + + {items.length > 0 && ( + isExpanded ? ( + + ) : ( + + ) + )} + + {category.replace(/_/g, ' ')} + + + + {score}/{max} + + + {/* Progress bar */} + + = 90 ? '#22c55e' : pct >= 70 ? '#f59e0b' : '#ef4444', + borderRadius: 2, + }} + /> + + {/* Expandable items */} + + + {items.map((item) => ( + + + + + + {item.name} + + + + {item.score}/{item.max} + + + {item.comment && ( + + {item.comment} + + )} + + ))} + + + + ); + })} + + + )} + + {/* No data message */} + {!qualityScore && (!criteriaChecklist || Object.keys(criteriaChecklist).length === 0) && ( + + No quality data available. + + )} + + + + ); +} diff --git a/app/src/components/index.ts b/app/src/components/index.ts index 01c83c4a03..3cb9e7beb0 100644 --- a/app/src/components/index.ts +++ b/app/src/components/index.ts @@ -4,4 +4,3 @@ export { LoaderSpinner } from './LoaderSpinner'; export { FilterBar } from './FilterBar'; export { ImagesGrid } from './ImagesGrid'; export { ImageCard } from './ImageCard'; -export { FullscreenModal } from './FullscreenModal'; diff --git a/app/src/hooks/useAnalytics.ts b/app/src/hooks/useAnalytics.ts index b3619418cd..6be08d12f1 100644 --- a/app/src/hooks/useAnalytics.ts +++ b/app/src/hooks/useAnalytics.ts @@ -4,12 +4,12 @@ interface EventProps { [key: string]: string | undefined; } -function debounce void>(fn: T, delay: number) { +function debounce void>(fn: T, delay: number): T { let timeoutId: ReturnType; - return (...args: Parameters) => { + return ((...args: Parameters) => { clearTimeout(timeoutId); timeoutId = setTimeout(() => fn(...args), delay); - }; + }) as T; } // Konvertiert Query-Params zu Pfad-Segmenten für Plausible @@ -41,15 +41,28 @@ export function useAnalytics() { const lastPageviewRef = useRef(''); const isProduction = typeof window !== 'undefined' && window.location.hostname === 'pyplots.ai'; - const sendPageview = useCallback(() => { - if (!isProduction) return; + const sendPageview = useCallback( + (urlOverride?: string) => { + if (!isProduction) return; + + let url: string; + if (urlOverride) { + // Validate urlOverride: must start with "/" and contain only safe characters + if (!/^\/[\w\-/]*$/.test(urlOverride)) { + return; // Invalid URL, skip tracking + } + url = `https://pyplots.ai${urlOverride}`; + } else { + url = buildPlausibleUrl(); + } - const url = buildPlausibleUrl(); - if (url === lastPageviewRef.current) return; - lastPageviewRef.current = url; + if (url === lastPageviewRef.current) return; + lastPageviewRef.current = url; - window.plausible?.('pageview', { url }); - }, [isProduction]); + window.plausible?.('pageview', { url }); + }, + [isProduction] + ); const trackPageview = useMemo(() => debounce(sendPageview, 300), [sendPageview]); diff --git a/app/src/hooks/useFilterState.ts b/app/src/hooks/useFilterState.ts index aad1a7fb03..53fc7337ec 100644 --- a/app/src/hooks/useFilterState.ts +++ b/app/src/hooks/useFilterState.ts @@ -1,7 +1,7 @@ /** * Hook for managing filter state and URL synchronization. * - * Encapsulates all filter-related state and callbacks used in App.tsx. + * Uses persistent state from Layout context to survive navigation. */ import { useState, useCallback, useEffect, useRef } from 'react'; @@ -9,19 +9,47 @@ import { useState, useCallback, useEffect, useRef } from 'react'; import type { PlotImage, FilterCategory, ActiveFilters, FilterCounts } from '../types'; import { FILTER_CATEGORIES } from '../types'; import { API_URL, BATCH_SIZE } from '../constants'; +import { useHomeState } from '../components/Layout'; /** - * Fisher-Yates shuffle algorithm. + * Seeded random number generator (mulberry32). */ -function shuffleArray(array: T[]): T[] { +function seededRandom(seed: number): () => number { + return () => { + let t = (seed += 0x6d2b79f5); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * Fisher-Yates shuffle algorithm with optional seed for deterministic results. + */ +function shuffleArray(array: T[], seed?: number): T[] { const shuffled = [...array]; + const random = seed !== undefined ? seededRandom(seed) : Math.random; for (let i = shuffled.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); + const j = Math.floor(random() * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } return shuffled; } +/** + * Generate a hash from filter state for deterministic shuffle. + */ +function hashFilters(filters: ActiveFilters): number { + const str = JSON.stringify(filters); + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; + } + return Math.abs(hash); +} + /** * Parse URL params into ActiveFilters. * URL format: ?lib=matplotlib&lib=seaborn (AND) or ?lib=matplotlib,seaborn (OR within group) @@ -106,19 +134,35 @@ export function useFilterState({ onTrackPageview, onTrackEvent, }: UseFilterStateOptions): UseFilterStateReturn { - // Filter state - initialize from URL params immediately - const [activeFilters, setActiveFilters] = useState(() => parseUrlFilters()); - const [filterCounts, setFilterCounts] = useState(null); - const [globalCounts, setGlobalCounts] = useState(null); - const [orCounts, setOrCounts] = useState[]>([]); + const { homeStateRef, setHomeState } = useHomeState(); + + // Initialize from persistent state (ref) or URL params (all using lazy initializers) + const [activeFilters, setActiveFilters] = useState(() => + homeStateRef.current.initialized ? homeStateRef.current.activeFilters : parseUrlFilters() + ); + const [filterCounts, setFilterCounts] = useState(() => + homeStateRef.current.initialized ? homeStateRef.current.filterCounts : null + ); + const [globalCounts, setGlobalCounts] = useState(() => + homeStateRef.current.initialized ? homeStateRef.current.globalCounts : null + ); + const [orCounts, setOrCounts] = useState[]>(() => + homeStateRef.current.initialized ? homeStateRef.current.orCounts : [] + ); - // Image state - const [allImages, setAllImages] = useState([]); - const [displayedImages, setDisplayedImages] = useState([]); - const [hasMore, setHasMore] = useState(false); + // Image state - restore from persistent state if available + const [allImages, setAllImages] = useState(() => + homeStateRef.current.initialized ? homeStateRef.current.allImages : [] + ); + const [displayedImages, setDisplayedImages] = useState(() => + homeStateRef.current.initialized ? homeStateRef.current.displayedImages : [] + ); + const [hasMore, setHasMore] = useState(() => + homeStateRef.current.initialized ? homeStateRef.current.hasMore : false + ); // UI state - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(() => !homeStateRef.current.initialized); const [error, setError] = useState(''); const [randomAnimation, setRandomAnimation] = useState<{ index: number; @@ -130,6 +174,23 @@ export function useFilterState({ const activeFiltersRef = useRef(activeFilters); activeFiltersRef.current = activeFilters; + // Sync state changes back to persistent context + useEffect(() => { + if (allImages.length > 0 || displayedImages.length > 0) { + setHomeState((prev) => ({ + ...prev, + allImages, + displayedImages, + activeFilters, + filterCounts, + globalCounts, + orCounts, + hasMore, + initialized: true, + })); + } + }, [allImages, displayedImages, activeFilters, filterCounts, globalCounts, orCounts, hasMore, setHomeState]); + // Add a new filter group (creates new chip - AND with other groups) const handleAddFilter = useCallback((category: FilterCategory, value: string) => { setActiveFilters((prev) => [...prev, { category, values: [value] }]); @@ -234,8 +295,23 @@ export function useFilterState({ onTrackPageview(); }, [activeFilters, onTrackPageview]); + // Track if we should skip initial fetch (restored from persistent state) + const initializedRef = useRef(homeStateRef.current.initialized); + const filtersMatchRef = useRef( + homeStateRef.current.initialized && JSON.stringify(homeStateRef.current.activeFilters) === JSON.stringify(activeFilters) + ); + // Load filtered images when filters change useEffect(() => { + // Skip fetch on first mount if restored from persistent state with same filters + if (initializedRef.current && filtersMatchRef.current) { + initializedRef.current = false; + filtersMatchRef.current = false; + return; + } + initializedRef.current = false; + filtersMatchRef.current = false; + const abortController = new AbortController(); const fetchFilteredImages = async () => { @@ -265,9 +341,12 @@ export function useFilterState({ setGlobalCounts(data.globalCounts || data.counts); setOrCounts(data.orCounts || []); - // Shuffle and set images - const shuffled = shuffleArray(data.images || []); + // Shuffle with deterministic seed based on filters + const seed = hashFilters(activeFilters); + const shuffled = shuffleArray(data.images || [], seed); setAllImages(shuffled); + + // Initial display count setDisplayedImages(shuffled.slice(0, BATCH_SIZE)); setHasMore(shuffled.length > BATCH_SIZE); } catch (err) { diff --git a/app/src/main.tsx b/app/src/main.tsx index 3349aab9a2..16a6f259c4 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -2,7 +2,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import CssBaseline from '@mui/material/CssBaseline'; import { ThemeProvider, createTheme } from '@mui/material/styles'; -import App from './App'; +import { AppRouter } from './router'; // Import MonoLisa font - hosted on GCS (all text uses MonoLisa) import './styles/fonts.css'; @@ -42,7 +42,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render( - + ); diff --git a/app/src/pages/CatalogPage.tsx b/app/src/pages/CatalogPage.tsx new file mode 100644 index 0000000000..e6d33971c4 --- /dev/null +++ b/app/src/pages/CatalogPage.tsx @@ -0,0 +1,400 @@ +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { Link } from 'react-router-dom'; +import { Helmet } from 'react-helmet-async'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Skeleton from '@mui/material/Skeleton'; +import Fab from '@mui/material/Fab'; +import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; + +import { API_URL } from '../constants'; +import { useAnalytics } from '../hooks'; +import { useAppData, useHomeState } from '../components/Layout'; +import { Footer } from '../components'; +import type { PlotImage } from '../types'; + +interface CatalogSpec { + id: string; + title: string; + description?: string; + images: PlotImage[]; +} + +export function CatalogPage() { + const { specsData } = useAppData(); + const { saveScrollPosition } = useHomeState(); + const { trackPageview, trackEvent } = useAnalytics(); + + // Track catalog page view + useEffect(() => { + trackPageview('/catalog'); + }, [trackPageview]); + + const [allImages, setAllImages] = useState([]); + const [loading, setLoading] = useState(true); + const [rotationIndex, setRotationIndex] = useState>({}); + const [expandedDescs, setExpandedDescs] = useState>({}); + const [showScrollTop, setShowScrollTop] = useState(false); + + // Fetch all images + useEffect(() => { + const abortController = new AbortController(); + + const fetchImages = async () => { + try { + const res = await fetch(`${API_URL}/plots/filter`, { + signal: abortController.signal, + }); + if (abortController.signal.aborted) return; + if (res.ok) { + const data = await res.json(); + setAllImages(data.images || []); + } + } catch (err) { + if (abortController.signal.aborted) return; + console.error('Error fetching images:', err); + } finally { + if (!abortController.signal.aborted) { + setLoading(false); + } + } + }; + fetchImages(); + + return () => abortController.abort(); + }, []); + + // Group images by spec_id and merge with spec metadata + const catalogSpecs = useMemo(() => { + // Group images by spec_id + const imagesBySpec: Record = {}; + for (const img of allImages) { + const specId = img.spec_id || ''; + if (!imagesBySpec[specId]) { + imagesBySpec[specId] = []; + } + imagesBySpec[specId].push(img); + } + + // Merge with spec metadata and sort images by library name + const specs: CatalogSpec[] = specsData + .filter((spec) => imagesBySpec[spec.id]) + .map((spec) => ({ + id: spec.id, + title: spec.title, + description: spec.description, + images: imagesBySpec[spec.id].sort((a, b) => a.library.localeCompare(b.library)), + })); + + // Sort alphabetically by title + specs.sort((a, b) => a.title.localeCompare(b.title)); + + return specs; + }, [allImages, specsData]); + + // Initialize random rotation indices once specs are loaded + useEffect(() => { + if (catalogSpecs.length > 0 && Object.keys(rotationIndex).length === 0) { + const initialIndices: Record = {}; + catalogSpecs.forEach((spec) => { + initialIndices[spec.id] = Math.floor(Math.random() * spec.images.length); + }); + setRotationIndex(initialIndices); + } + }, [catalogSpecs, rotationIndex]); + + // Show/hide scroll-to-top button based on scroll position + useEffect(() => { + const handleScroll = () => { + setShowScrollTop(window.scrollY > 300); + }; + window.addEventListener('scroll', handleScroll); + return () => window.removeEventListener('scroll', handleScroll); + }, []); + + // Handle image click - rotate to next implementation + const handleImageClick = useCallback( + (specId: string, totalImages: number) => { + setRotationIndex((prev) => ({ + ...prev, + [specId]: ((prev[specId] || 0) + 1) % totalImages, + })); + trackEvent('catalog_rotate', { spec: specId }); + }, + [trackEvent] + ); + + if (loading) { + return ( + + + {[1, 2, 3, 4, 5].map((i) => ( + + + + + + + + + ))} + + ); + } + + return ( + <> + + catalog | pyplots.ai + + + + + + + {/* Breadcrumb navigation */} + + + pyplots.ai + + + + catalog + + + + {/* Title */} + + catalog + + {catalogSpecs.length} specifications + + + + {/* Spec List */} + + {catalogSpecs.map((spec) => { + const currentIndex = rotationIndex[spec.id] || 0; + const currentImage = spec.images[currentIndex]; + + return ( + + {/* Image - Click to rotate */} + handleImageClick(spec.id, spec.images.length)} + sx={{ + position: 'relative', + width: { xs: '100%', sm: 280 }, + height: { xs: 180, sm: 158 }, + borderRadius: 1.5, + overflow: 'hidden', + bgcolor: '#fff', + boxShadow: '0 2px 8px rgba(0,0,0,0.08)', + flexShrink: 0, + cursor: spec.images.length > 1 ? 'pointer' : 'default', + '&:hover .rotate-hint': { + opacity: spec.images.length > 1 ? 1 : 0, + }, + '&:hover .library-hint': { + opacity: 1, + }, + }} + > + {currentImage && ( + + )} + + {/* Rotation hint badge */} + {spec.images.length > 1 && ( + + {currentIndex + 1}/{spec.images.length} + + )} + + {/* Current library badge */} + + {currentImage?.library} + + + + {/* Text - Click to navigate */} + + + {spec.title} + + {spec.description && ( + { + if (!expandedDescs[spec.id]) { + e.preventDefault(); + e.stopPropagation(); + setExpandedDescs((prev) => ({ ...prev, [spec.id]: true })); + } + }} + sx={{ + fontFamily: '"MonoLisa", monospace', + fontSize: '0.85rem', + color: '#6b7280', + lineHeight: 1.6, + cursor: expandedDescs[spec.id] ? 'default' : 'pointer', + ...(!expandedDescs[spec.id] && { + display: '-webkit-box', + WebkitLineClamp: 5, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', + }), + }} + > + {spec.description} + + )} + + + ); + })} + + + {/* Footer */} +