From ba4b98dcd408d1d38b466112205049a233362893 Mon Sep 17 00:00:00 2001 From: Markus Neusinger <2921697+MarkusNeusinger@users.noreply.github.com> Date: Sat, 3 Jan 2026 23:28:44 +0100 Subject: [PATCH 01/14] ci: enhance test workflow with manual trigger and config change detection - Add workflow_dispatch input to force run tests - Include test configuration file changes in test detection logic - Update .gitignore to ensure JetBrains IDE files are ignored --- app/package.json | 2 + app/src/components/Header.tsx | 29 +++ app/src/components/Layout.tsx | 70 ++++++ app/src/components/LibraryPills.tsx | 67 ++++++ app/src/components/SpecAccordions.tsx | 290 ++++++++++++++++++++++++ app/src/main.tsx | 4 +- app/src/pages/CatalogPage.tsx | 296 ++++++++++++++++++++++++ app/src/pages/HomePage.tsx | 236 ++++++++++++++++++++ app/src/pages/SpecPage.tsx | 309 ++++++++++++++++++++++++++ app/src/router.tsx | 27 +++ app/yarn.lock | 58 ++++- 11 files changed, 1380 insertions(+), 8 deletions(-) create mode 100644 app/src/components/Layout.tsx create mode 100644 app/src/components/LibraryPills.tsx create mode 100644 app/src/components/SpecAccordions.tsx create mode 100644 app/src/pages/CatalogPage.tsx create mode 100644 app/src/pages/HomePage.tsx create mode 100644 app/src/pages/SpecPage.tsx create mode 100644 app/src/router.tsx 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/Header.tsx b/app/src/components/Header.tsx index 9947c50e87..14b4301695 100644 --- a/app/src/components/Header.tsx +++ b/app/src/components/Header.tsx @@ -1,4 +1,5 @@ import { memo, useState, useEffect } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import Link from '@mui/material/Link'; @@ -7,6 +8,7 @@ import ClickAwayListener from '@mui/material/ClickAwayListener'; import useMediaQuery from '@mui/material/useMediaQuery'; import { useTheme } from '@mui/material/styles'; import ShuffleIcon from '@mui/icons-material/Shuffle'; +import ListIcon from '@mui/icons-material/List'; interface HeaderProps { stats?: { specs: number; plots: number; libraries: number } | null; @@ -187,6 +189,33 @@ export const Header = memo(function Header({ stats, onRandom }: HeaderProps) { )} {isXs ? '. copy. create.' : '. grab the code. make it yours.'} + + {/* Catalog Link */} + + + catalog + ); }); diff --git a/app/src/components/Layout.tsx b/app/src/components/Layout.tsx new file mode 100644 index 0000000000..d4714bcbee --- /dev/null +++ b/app/src/components/Layout.tsx @@ -0,0 +1,70 @@ +import { useState, useEffect, createContext, useContext } 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 } from '../types'; + +interface AppData { + specsData: SpecInfo[]; + librariesData: LibraryInfo[]; + stats: { specs: number; plots: number; libraries: number } | null; +} + +const AppDataContext = createContext(null); + +export function useAppData() { + const context = useContext(AppDataContext); + if (!context) { + throw new Error('useAppData must be used within Layout'); + } + return context; +} + +export function Layout() { + const [specsData, setSpecsData] = useState([]); + const [librariesData, setLibrariesData] = useState([]); + const [stats, setStats] = useState<{ specs: number; plots: number; libraries: number } | null>(null); + + // 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 ( + + + + + + + + ); +} diff --git a/app/src/components/LibraryPills.tsx b/app/src/components/LibraryPills.tsx new file mode 100644 index 0000000000..40b6954470 --- /dev/null +++ b/app/src/components/LibraryPills.tsx @@ -0,0 +1,67 @@ +import { memo } from 'react'; +import Box from '@mui/material/Box'; +import Chip from '@mui/material/Chip'; + +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) { + return ( + + {implementations.map((impl) => { + const isSelected = impl.library_id === selectedLibrary; + const score = impl.quality_score; + + return ( + onSelect(impl.library_id)} + variant={isSelected ? 'filled' : 'outlined'} + sx={{ + fontFamily: '"MonoLisa", monospace', + fontSize: '0.875rem', + fontWeight: isSelected ? 600 : 400, + bgcolor: isSelected ? '#3776AB' : 'transparent', + color: isSelected ? '#fff' : '#6b7280', + borderColor: isSelected ? '#3776AB' : '#d1d5db', + '&:hover': { + bgcolor: isSelected ? '#2c5f8a' : '#f3f4f6', + borderColor: '#3776AB', + }, + transition: 'all 0.15s ease', + // Show quality score as subtle indicator + '&::after': score ? { + content: `"${Math.round(score)}"`, + fontSize: '0.65rem', + ml: 0.5, + opacity: 0.7, + } : undefined, + }} + /> + ); + })} + + ); +}); diff --git a/app/src/components/SpecAccordions.tsx b/app/src/components/SpecAccordions.tsx new file mode 100644 index 0000000000..74e4d99724 --- /dev/null +++ b/app/src/components/SpecAccordions.tsx @@ -0,0 +1,290 @@ +import { useState, useCallback } from 'react'; +import Box from '@mui/material/Box'; +import Accordion from '@mui/material/Accordion'; +import AccordionSummary from '@mui/material/AccordionSummary'; +import AccordionDetails from '@mui/material/AccordionDetails'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import CodeIcon from '@mui/icons-material/Code'; +import DescriptionIcon from '@mui/icons-material/Description'; +import StarIcon from '@mui/icons-material/Star'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import CheckIcon from '@mui/icons-material/Check'; + +// Import Prism for syntax highlighting (same as FullscreenModal) +import Prism from 'prismjs'; +import 'prismjs/components/prism-python'; +import 'prismjs/themes/prism.css'; + +interface SpecAccordionsProps { + code: string | null; + description: string; + applications?: string[]; + notes?: string[]; + qualityScore: number | null; + libraryId: string; + onTrackEvent?: (name: string, props?: Record) => void; +} + +export function SpecAccordions({ + code, + description, + applications, + notes, + qualityScore, + libraryId, + onTrackEvent, +}: SpecAccordionsProps) { + const [copied, setCopied] = useState(false); + const [expanded, setExpanded] = useState(false); + + const handleCopy = useCallback(async () => { + if (!code) return; + try { + await navigator.clipboard.writeText(code); + setCopied(true); + onTrackEvent?.('copy_code', { library: libraryId, method: 'accordion' }); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Copy failed:', err); + } + }, [code, libraryId, onTrackEvent]); + + const handleChange = (panel: string) => (_: React.SyntheticEvent, isExpanded: boolean) => { + setExpanded(isExpanded ? panel : false); + if (isExpanded) { + onTrackEvent?.(`expand_${panel}`, { library: libraryId }); + } + }; + + // Highlight code with Prism + const highlightedCode = code + ? Prism.highlight(code, Prism.languages.python, 'python') + : ''; + + return ( + + {/* Code Accordion */} + + } + sx={{ + '& .MuiAccordionSummary-content': { + alignItems: 'center', + gap: 1, + }, + }} + > + + + Code + + + + + + + {copied ? : } + + + + + + + + + + {/* Description Accordion */} + + } + sx={{ + '& .MuiAccordionSummary-content': { + alignItems: 'center', + gap: 1, + }, + }} + > + + + Description + + + + + {description} + + + {applications && applications.length > 0 && ( + + + Applications + + + {applications.map((app, i) => ( + + {app} + + ))} + + + )} + + {notes && notes.length > 0 && ( + + + Notes + + + {notes.map((note, i) => ( + + {note} + + ))} + + + )} + + + + {/* Quality Accordion */} + + } + sx={{ + '& .MuiAccordionSummary-content': { + alignItems: 'center', + gap: 1, + }, + }} + > + + + Quality: {qualityScore ? `${Math.round(qualityScore)}/100` : 'N/A'} + + + + + {qualityScore && qualityScore >= 90 ? ( + <>This implementation scored {Math.round(qualityScore)}/100 in AI quality review. + ) : qualityScore ? ( + <>Quality score: {Math.round(qualityScore)}/100 + ) : ( + 'Quality score not available.' + )} + + + + + ); +} 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..60b1527ed2 --- /dev/null +++ b/app/src/pages/CatalogPage.tsx @@ -0,0 +1,296 @@ +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 Button from '@mui/material/Button'; +import Skeleton from '@mui/material/Skeleton'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; + +import { API_URL } from '../constants'; +import { useAnalytics } from '../hooks'; +import { useAppData } from '../components/Layout'; +import type { PlotImage } from '../types'; + +interface CatalogSpec { + id: string; + title: string; + description?: string; + images: PlotImage[]; +} + +export function CatalogPage() { + const { specsData } = useAppData(); + const { trackEvent } = useAnalytics(); + + const [allImages, setAllImages] = useState([]); + const [loading, setLoading] = useState(true); + const [rotationIndex, setRotationIndex] = useState>({}); + + // Fetch all images + useEffect(() => { + const fetchImages = async () => { + try { + const res = await fetch(`${API_URL}/plots/filter`); + if (res.ok) { + const data = await res.json(); + setAllImages(data.images || []); + } + } catch (err) { + console.error('Error fetching images:', err); + } finally { + setLoading(false); + } + }; + fetchImages(); + }, []); + + // 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 + 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 alphabetically by title + specs.sort((a, b) => a.title.localeCompare(b.title)); + + return specs; + }, [allImages, specsData]); + + // 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 + + + + + + + {/* Back Button */} + + + {/* Title */} + + Catalog + + {catalogSpecs.length} examples + + + + {/* 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: 280, + height: 158, + flexShrink: 0, + borderRadius: 1, + overflow: 'hidden', + bgcolor: '#f3f4f6', + cursor: spec.images.length > 1 ? 'pointer' : 'default', + '&:hover .rotate-hint': { + opacity: spec.images.length > 1 ? 1 : 0, + }, + }} + > + {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 && ( + + {spec.description} + + )} + + + ); + })} + + + + ); +} diff --git a/app/src/pages/HomePage.tsx b/app/src/pages/HomePage.tsx new file mode 100644 index 0000000000..dad29a61c3 --- /dev/null +++ b/app/src/pages/HomePage.tsx @@ -0,0 +1,236 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import Box from '@mui/material/Box'; +import Alert from '@mui/material/Alert'; +import Fab from '@mui/material/Fab'; +import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; + +import type { PlotImage } from '../types'; +import type { ImageSize } from '../constants'; +import { useInfiniteScroll, useAnalytics, useFilterState, isFiltersEmpty } from '../hooks'; +import { Header, Footer, FilterBar, ImagesGrid, FullscreenModal } from '../components'; +import { useAppData } from '../components/Layout'; + +export function HomePage() { + const navigate = useNavigate(); + const { specsData, librariesData, stats } = useAppData(); + + // Handle scroll restoration on back navigation + useEffect(() => { + if ('scrollRestoration' in history) { + history.scrollRestoration = 'manual'; + } + + // Check for saved scroll position from back navigation + const savedScrollY = sessionStorage.getItem('homeScrollY'); + if (savedScrollY) { + // Delay scroll restoration to allow images to load + const timer = setTimeout(() => { + window.scrollTo(0, parseInt(savedScrollY, 10)); + // Clear the saved position + sessionStorage.removeItem('homeScrollY'); + }, 150); + return () => clearTimeout(timer); + } else { + window.scrollTo(0, 0); + } + }, []); + + // Custom hooks + const { trackPageview, trackEvent } = useAnalytics(); + + const { + activeFilters, + filterCounts, + orCounts, + allImages, + displayedImages, + hasMore, + loading, + error, + setDisplayedImages, + setHasMore, + handleAddFilter, + handleAddValueToGroup, + handleRemoveFilter, + handleRemoveGroup, + handleRandom, + randomAnimation, + } = useFilterState({ + onTrackPageview: trackPageview, + onTrackEvent: trackEvent, + }); + + const { loadMoreRef } = useInfiniteScroll({ + allImages, + displayedImages, + hasMore, + setDisplayedImages, + setHasMore, + }); + + // UI state + const [modalImage, setModalImage] = useState(null); + const [openImageTooltip, setOpenImageTooltip] = useState(null); + const [imageSize, setImageSize] = useState(() => { + const stored = localStorage.getItem('imageSize'); + return stored === 'normal' || stored === 'compact' ? stored : 'normal'; + }); + const [showScrollTop, setShowScrollTop] = useState(false); + + // Refs + const searchInputRef = useRef(null); + + // Persist imageSize to localStorage + useEffect(() => { + localStorage.setItem('imageSize', imageSize); + }, [imageSize]); + + // 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 card click - navigate to spec page + const handleCardClick = useCallback( + (img: PlotImage) => { + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + + // Save scroll position for back navigation + sessionStorage.setItem('homeScrollY', String(window.scrollY)); + + // Navigate to spec page + const specId = img.spec_id || ''; + const library = img.library; + navigate(`/${specId}/${library}`); + trackEvent('navigate_to_spec', { spec: specId, library }); + }, + [navigate, trackEvent] + ); + + // Close tooltip when clicking anywhere + const handleContainerClick = useCallback( + (e: React.MouseEvent) => { + const target = e.target as HTMLElement; + if (target.closest('[data-description-btn]')) return; + if (openImageTooltip) setOpenImageTooltip(null); + }, + [openImageTooltip] + ); + + // Global keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || modalImage) return; + + if (e.key === ' ') { + e.preventDefault(); + handleRandom('space'); + } else if (e.key === 'Enter' && searchInputRef.current) { + e.preventDefault(); + searchInputRef.current.focus(); + } else if (e.key === 'Backspace' && activeFilters.length > 0) { + e.preventDefault(); + handleRemoveGroup(activeFilters.length - 1); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [handleRandom, handleRemoveGroup, activeFilters.length, modalImage]); + + // Get selected spec/library for compatibility with existing components + const specFilter = activeFilters.find((f) => f.category === 'spec'); + const libFilter = activeFilters.find((f) => f.category === 'lib'); + const selectedSpec = specFilter?.values[0] || ''; + const selectedLibrary = libFilter?.values[0] || ''; + + return ( + +
+ + {error && ( + + {error} + + )} + + + + + + {!loading && allImages.length === 0 && !isFiltersEmpty(activeFilters) && ( + + No plots match these filters. + + )} + +