diff --git a/api/exceptions.py b/api/exceptions.py index 2446d4740d..436e85ec72 100644 --- a/api/exceptions.py +++ b/api/exceptions.py @@ -74,6 +74,15 @@ def __init__(self, detail: str): super().__init__(f"Validation failed: {detail}", status_code=400) +class DatabaseQueryError(PyplotsException): + """Database query failed (500).""" + + def __init__(self, operation: str, detail: str): + message = f"Database query failed during '{operation}': {detail}" + super().__init__(message, status_code=500) + self.operation = operation + + # ===== Exception Handlers ===== @@ -153,3 +162,17 @@ def raise_validation_error(detail: str) -> None: ValidationError: Always raises """ raise ValidationError(detail) + + +def raise_database_query_error(operation: str, detail: str) -> None: + """ + Raise a standardized 500 error for database query failures. + + Args: + operation: The operation that failed (e.g., "fetch_specs", "filter_plots") + detail: Error details + + Raises: + DatabaseQueryError: Always raises + """ + raise DatabaseQueryError(operation, detail) diff --git a/api/routers/plots.py b/api/routers/plots.py index cf9ba3e16a..cf1d2c10cd 100644 --- a/api/routers/plots.py +++ b/api/routers/plots.py @@ -1,14 +1,21 @@ """Filter endpoint for plots.""" +import logging + from fastapi import APIRouter, Depends, Request +from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession from api.cache import get_cache, set_cache from api.dependencies import require_db +from api.exceptions import DatabaseQueryError from api.schemas import FilteredPlotsResponse from core.database import SpecRepository +logger = logging.getLogger(__name__) + + router = APIRouter(tags=["plots"]) @@ -446,13 +453,21 @@ async def get_filtered_plots(request: Request, db: AsyncSession = Depends(requir # Check cache cache_key = _build_cache_key(filter_groups) - cached = get_cache(cache_key) - if cached: - return cached + try: + cached = get_cache(cache_key) + if cached: + return cached + except Exception as e: + # Cache failures are non-fatal, log and continue + logger.warning("Cache read failed for key %s: %s", cache_key, e) # Fetch data from database - repo = SpecRepository(db) - all_specs = await repo.get_all() + try: + repo = SpecRepository(db) + all_specs = await repo.get_all() + except SQLAlchemyError as e: + logger.error("Database query failed in get_filtered_plots: %s", e) + raise DatabaseQueryError("fetch_specs", str(e)) from e # Build data structures spec_lookup = _build_spec_lookup(all_specs) @@ -476,5 +491,11 @@ async def get_filtered_plots(request: Request, db: AsyncSession = Depends(requir globalCounts=global_counts, orCounts=or_counts, ) - set_cache(cache_key, result) + + try: + set_cache(cache_key, result) + except Exception as e: + # Cache failures are non-fatal, log and continue + logger.warning("Cache write failed for key %s: %s", cache_key, e) + return result diff --git a/app/src/components/Breadcrumb.tsx b/app/src/components/Breadcrumb.tsx new file mode 100644 index 0000000000..e60d2ae873 --- /dev/null +++ b/app/src/components/Breadcrumb.tsx @@ -0,0 +1,88 @@ +/** + * Shared Breadcrumb component for consistent navigation across pages. + */ + +import { Link } from 'react-router-dom'; +import Box from '@mui/material/Box'; +import type { SxProps, Theme } from '@mui/material/styles'; + +export interface BreadcrumbItem { + label: string; + to?: string; // If undefined, this is the current page (not linked) +} + +export interface BreadcrumbProps { + items: BreadcrumbItem[]; + rightAction?: React.ReactNode; + /** Additional sx props for the container */ + sx?: SxProps; +} + +/** + * Breadcrumb navigation component. + * + * @example + * // Simple: pyplots.ai > catalog + * + * + * @example + * // With right action + * suggest spec} + * /> + */ +export function Breadcrumb({ items, rightAction, sx }: BreadcrumbProps) { + return ( + + {/* Breadcrumb links */} + + {items.map((item, index) => ( + + {index > 0 && ( + + › + + )} + {item.to ? ( + + {item.label} + + ) : ( + + {item.label} + + )} + + ))} + + + {/* Right action slot */} + {rightAction} + + ); +} diff --git a/app/src/components/SpecDetailView.tsx b/app/src/components/SpecDetailView.tsx new file mode 100644 index 0000000000..90b140d8c5 --- /dev/null +++ b/app/src/components/SpecDetailView.tsx @@ -0,0 +1,189 @@ +/** + * SpecDetailView component - Single implementation detail view. + * + * Shows large image with library carousel and action buttons. + */ + +import { Link } from 'react-router-dom'; +import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import Skeleton from '@mui/material/Skeleton'; +import DownloadIcon from '@mui/icons-material/Download'; +import OpenInNewIcon from '@mui/icons-material/OpenInNew'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import CheckIcon from '@mui/icons-material/Check'; + +import type { Implementation } from '../types'; + +interface SpecDetailViewProps { + specId: string; + specTitle: string; + selectedLibrary: string; + currentImpl: Implementation | null; + implementations: Implementation[]; + imageLoaded: boolean; + codeCopied: string | null; + onImageLoad: () => void; + onImageClick: () => void; + onCopyCode: (impl: Implementation) => void; + onDownload: (impl: Implementation) => void; + onTrackEvent: (event: string, props?: Record) => void; +} + +export function SpecDetailView({ + specId, + specTitle, + selectedLibrary, + currentImpl, + implementations, + imageLoaded, + codeCopied, + onImageLoad, + onImageClick, + onCopyCode, + onDownload, + onTrackEvent, +}: SpecDetailViewProps) { + // Sort implementations alphabetically for the counter + const sortedImpls = [...implementations].sort((a, b) => a.library_id.localeCompare(b.library_id)); + const currentIndex = sortedImpls.findIndex((impl) => impl.library_id === selectedLibrary); + + return ( + + + {!imageLoaded && ( + + )} + {currentImpl?.preview_url && ( + + )} + + {/* Action Buttons (top-right) - stop propagation */} + e.stopPropagation()} + sx={{ + position: 'absolute', + top: 8, + right: 8, + display: 'flex', + gap: 0.5, + }} + > + {currentImpl?.code && ( + + onCopyCode(currentImpl)} + sx={{ + bgcolor: 'rgba(255,255,255,0.9)', + '&:hover': { bgcolor: '#fff' }, + }} + size="small" + > + {codeCopied === currentImpl.library_id ? ( + + ) : ( + + )} + + + )} + {currentImpl && ( + + onDownload(currentImpl)} + sx={{ + bgcolor: 'rgba(255,255,255,0.9)', + '&:hover': { bgcolor: '#fff' }, + }} + size="small" + > + + + + )} + {currentImpl?.preview_html && ( + + { + e.stopPropagation(); + onTrackEvent('open_interactive', { spec: specId, library: selectedLibrary }); + }} + sx={{ + bgcolor: 'rgba(255,255,255,0.9)', + '&:hover': { bgcolor: '#fff' }, + }} + size="small" + > + + + + )} + + + {/* Implementation counter (hover) */} + {implementations.length > 1 && ( + + {currentIndex + 1}/{implementations.length} + + )} + + + ); +} diff --git a/app/src/components/SpecOverview.tsx b/app/src/components/SpecOverview.tsx new file mode 100644 index 0000000000..c925c36e8e --- /dev/null +++ b/app/src/components/SpecOverview.tsx @@ -0,0 +1,320 @@ +/** + * SpecOverview component - Grid of implementations for a specification. + * + * Displays all implementations in a 3-column grid with hover actions. + */ + +import { Link } from 'react-router-dom'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import Skeleton from '@mui/material/Skeleton'; +import MuiLink from '@mui/material/Link'; +import ClickAwayListener from '@mui/material/ClickAwayListener'; +import DownloadIcon from '@mui/icons-material/Download'; +import OpenInNewIcon from '@mui/icons-material/OpenInNew'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import CheckIcon from '@mui/icons-material/Check'; + +import type { Implementation } from '../types'; + +interface LibraryMeta { + id: string; + name: string; + description?: string; + documentation_url?: string; +} + +interface SpecOverviewProps { + specId: string; + specTitle: string; + implementations: Implementation[]; + codeCopied: string | null; + openTooltip: string | null; + onImplClick: (libraryId: string) => void; + onCopyCode: (impl: Implementation) => void; + onDownload: (impl: Implementation) => void; + onTooltipToggle: (tooltipId: string | null) => void; + getLibraryMeta: (libraryId: string) => LibraryMeta | undefined; + onTrackEvent: (event: string, props?: Record) => void; +} + +export function SpecOverview({ + specId, + specTitle, + implementations, + codeCopied, + openTooltip, + onImplClick, + onCopyCode, + onDownload, + onTooltipToggle, + getLibraryMeta, + onTrackEvent, +}: SpecOverviewProps) { + // Sort implementations alphabetically + const sortedImpls = [...implementations].sort((a, b) => a.library_id.localeCompare(b.library_id)); + + return ( + + {sortedImpls.map((impl) => ( + + ))} + + ); +} + +interface ImplementationCardProps { + impl: Implementation; + specId: string; + specTitle: string; + codeCopied: string | null; + openTooltip: string | null; + onImplClick: (libraryId: string) => void; + onCopyCode: (impl: Implementation) => void; + onDownload: (impl: Implementation) => void; + onTooltipToggle: (tooltipId: string | null) => void; + getLibraryMeta: (libraryId: string) => LibraryMeta | undefined; + onTrackEvent: (event: string, props?: Record) => void; +} + +function ImplementationCard({ + impl, + specId, + specTitle, + codeCopied, + openTooltip, + onImplClick, + onCopyCode, + onDownload, + onTooltipToggle, + getLibraryMeta, + onTrackEvent, +}: ImplementationCardProps) { + const libMeta = getLibraryMeta(impl.library_id); + const tooltipId = `lib-${impl.library_id}`; + const isTooltipOpen = openTooltip === tooltipId; + + return ( + + {/* Card */} + onImplClick(impl.library_id)} + sx={{ + position: 'relative', + borderRadius: 3, + overflow: 'hidden', + border: '2px solid rgba(55, 118, 171, 0.2)', + boxShadow: '0 2px 8px rgba(0,0,0,0.1)', + cursor: 'pointer', + transition: 'all 0.3s ease', + '&:hover': { + border: '2px solid rgba(55, 118, 171, 0.4)', + boxShadow: '0 8px 30px rgba(0,0,0,0.15)', + transform: 'scale(1.03)', + }, + '&:hover .action-buttons': { + opacity: 1, + }, + }} + > + {impl.preview_thumb || impl.preview_url ? ( + + ) : ( + + )} + + {/* Action Buttons (top-right) */} + e.stopPropagation()} + sx={{ + position: 'absolute', + top: 8, + right: 8, + display: 'flex', + gap: 0.5, + opacity: 0, + transition: 'opacity 0.2s', + }} + > + {impl.code && ( + + onCopyCode(impl)} + sx={{ + bgcolor: 'rgba(255,255,255,0.9)', + '&:hover': { bgcolor: '#fff' }, + }} + size="small" + > + {codeCopied === impl.library_id ? ( + + ) : ( + + )} + + + )} + + onDownload(impl)} + sx={{ + bgcolor: 'rgba(255,255,255,0.9)', + '&:hover': { bgcolor: '#fff' }, + }} + size="small" + > + + + + {impl.preview_html && ( + + { + e.stopPropagation(); + onTrackEvent('open_interactive', { spec: specId, library: impl.library_id }); + }} + sx={{ + bgcolor: 'rgba(255,255,255,0.9)', + '&:hover': { bgcolor: '#fff' }, + }} + size="small" + > + + + + )} + + + + {/* Label below card with library tooltip */} + + isTooltipOpen && onTooltipToggle(null)}> + + + + {libMeta?.description || 'No description available'} + + {libMeta?.documentation_url && ( + e.stopPropagation()} + sx={{ + display: 'inline-flex', + alignItems: 'center', + gap: 0.5, + fontSize: '0.75rem', + color: '#90caf9', + textDecoration: 'underline', + '&:hover': { color: '#fff' }, + }} + > + {libMeta.documentation_url.replace(/^https?:\/\//, '')} + + + )} + + } + arrow + placement="bottom" + open={isTooltipOpen} + disableFocusListener + disableHoverListener + disableTouchListener + slotProps={{ + tooltip: { + sx: { + maxWidth: { xs: '80vw', sm: 400 }, + fontFamily: '"MonoLisa", monospace', + fontSize: '0.8rem', + }, + }, + }} + > + { + e.stopPropagation(); + onTooltipToggle(isTooltipOpen ? null : tooltipId); + }} + sx={{ + fontSize: '0.8rem', + fontWeight: 600, + fontFamily: '"MonoLisa", monospace', + color: isTooltipOpen ? '#3776AB' : '#9ca3af', + textTransform: 'lowercase', + cursor: 'pointer', + '&:hover': { color: '#3776AB' }, + }} + > + {impl.library_id} + + + + + {impl.quality_score && ( + <> + · + + {Math.round(impl.quality_score)} + + + )} + + + ); +} diff --git a/app/src/components/index.ts b/app/src/components/index.ts index 3cb9e7beb0..cec972b538 100644 --- a/app/src/components/index.ts +++ b/app/src/components/index.ts @@ -1,6 +1,9 @@ +export { Breadcrumb } from './Breadcrumb'; export { Header } from './Header'; export { Footer } from './Footer'; export { LoaderSpinner } from './LoaderSpinner'; export { FilterBar } from './FilterBar'; export { ImagesGrid } from './ImagesGrid'; export { ImageCard } from './ImageCard'; +export { SpecOverview } from './SpecOverview'; +export { SpecDetailView } from './SpecDetailView'; diff --git a/app/src/hooks/index.ts b/app/src/hooks/index.ts index bdac35ac65..2eb831b1d3 100644 --- a/app/src/hooks/index.ts +++ b/app/src/hooks/index.ts @@ -4,3 +4,5 @@ export { useCopyCode } from './useCopyCode'; export { useCodeFetch } from './useCodeFetch'; export { useLocalStorage } from './useLocalStorage'; export { useFilterState, isFiltersEmpty } from './useFilterState'; +export { useUrlSync, parseUrlFilters, buildFilterUrl } from './useUrlSync'; +export { useFilterFetch } from './useFilterFetch'; diff --git a/app/src/hooks/useFilterFetch.ts b/app/src/hooks/useFilterFetch.ts new file mode 100644 index 0000000000..b750111f7d --- /dev/null +++ b/app/src/hooks/useFilterFetch.ts @@ -0,0 +1,140 @@ +/** + * Hook for fetching filtered plot images. + * + * Handles API calls, image shuffling, and pagination state. + */ + +import { useState, useEffect, useRef } from 'react'; + +import type { PlotImage, ActiveFilters, FilterCounts } from '../types'; +import { API_URL, BATCH_SIZE } from '../constants'; + +/** + * Fisher-Yates shuffle algorithm. + */ +function shuffleArray(array: T[]): T[] { + const shuffled = [...array]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + return shuffled; +} + +interface FilterFetchState { + filterCounts: FilterCounts | null; + globalCounts: FilterCounts | null; + orCounts: Record[]; + allImages: PlotImage[]; + displayedImages: PlotImage[]; + hasMore: boolean; + loading: boolean; + error: string; +} + +interface UseFilterFetchOptions { + activeFilters: ActiveFilters; + initialState?: Partial; + skipInitialFetch?: boolean; +} + +interface UseFilterFetchReturn extends FilterFetchState { + setDisplayedImages: React.Dispatch>; + setHasMore: React.Dispatch>; + setError: React.Dispatch>; +} + +/** + * Hook to fetch filtered images and manage related state. + */ +export function useFilterFetch({ + activeFilters, + initialState = {}, + skipInitialFetch = false, +}: UseFilterFetchOptions): UseFilterFetchReturn { + const [filterCounts, setFilterCounts] = useState(initialState.filterCounts ?? null); + const [globalCounts, setGlobalCounts] = useState(initialState.globalCounts ?? null); + const [orCounts, setOrCounts] = useState[]>(initialState.orCounts ?? []); + const [allImages, setAllImages] = useState(initialState.allImages ?? []); + const [displayedImages, setDisplayedImages] = useState(initialState.displayedImages ?? []); + const [hasMore, setHasMore] = useState(initialState.hasMore ?? false); + const [loading, setLoading] = useState(!skipInitialFetch); + const [error, setError] = useState(''); + + // Track if we should skip initial fetch + const skipRef = useRef(skipInitialFetch); + const initialFiltersRef = useRef(JSON.stringify(activeFilters)); + + useEffect(() => { + // Skip fetch on first mount if requested and filters match + if (skipRef.current && JSON.stringify(activeFilters) === initialFiltersRef.current) { + skipRef.current = false; + return; + } + skipRef.current = false; + + const abortController = new AbortController(); + + const fetchFilteredImages = async () => { + setLoading(true); + + try { + // Build query string from filters + const params = new URLSearchParams(); + activeFilters.forEach(({ category, values }) => { + if (values.length > 0) { + params.append(category, values.join(',')); + } + }); + + const queryString = params.toString(); + const url = `${API_URL}/plots/filter${queryString ? `?${queryString}` : ''}`; + + const response = await fetch(url, { signal: abortController.signal }); + if (!response.ok) throw new Error('Failed to fetch filtered plots'); + + const data = await response.json(); + + if (abortController.signal.aborted) return; + + // Update filter counts + setFilterCounts(data.counts); + setGlobalCounts(data.globalCounts || data.counts); + setOrCounts(data.orCounts || []); + + // Shuffle images randomly on each load + const shuffled = shuffleArray(data.images || []); + setAllImages(shuffled); + + // Initial display count + setDisplayedImages(shuffled.slice(0, BATCH_SIZE)); + setHasMore(shuffled.length > BATCH_SIZE); + } catch (err) { + if (abortController.signal.aborted) return; + setError(`Error loading images: ${err}`); + } finally { + if (!abortController.signal.aborted) { + setLoading(false); + } + } + }; + + fetchFilteredImages(); + + return () => abortController.abort(); + }, [activeFilters]); + + return { + filterCounts, + globalCounts, + orCounts, + allImages, + displayedImages, + hasMore, + loading, + error, + setDisplayedImages, + setHasMore, + setError, + }; +} diff --git a/app/src/hooks/useFilterState.ts b/app/src/hooks/useFilterState.ts index fbe817b9cd..4ffc84bf5e 100644 --- a/app/src/hooks/useFilterState.ts +++ b/app/src/hooks/useFilterState.ts @@ -2,66 +2,16 @@ * Hook for managing filter state and URL synchronization. * * Uses persistent state from Layout context to survive navigation. + * Composes useUrlSync and useFilterFetch for cleaner separation of concerns. */ 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. - */ -function shuffleArray(array: T[]): T[] { - const shuffled = [...array]; - for (let i = shuffled.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; - } - return shuffled; -} - -/** - * Parse URL params into ActiveFilters. - * URL format: ?lib=matplotlib&lib=seaborn (AND) or ?lib=matplotlib,seaborn (OR within group) - */ -function parseUrlFilters(): ActiveFilters { - const params = new URLSearchParams(window.location.search); - const filters: ActiveFilters = []; - - FILTER_CATEGORIES.forEach((category) => { - const allValues = params.getAll(category); - allValues.forEach((value) => { - if (value) { - const values = value - .split(',') - .map((v) => v.trim()) - .filter(Boolean); - if (values.length > 0) { - filters.push({ category, values }); - } - } - }); - }); - - return filters; -} - -/** - * Build URL from ActiveFilters. - */ -function buildFilterUrl(filters: ActiveFilters): string { - const params = new URLSearchParams(); - filters.forEach(({ category, values }) => { - if (values.length > 0) { - params.append(category, values.join(',')); - } - }); - const queryString = params.toString(); - return queryString ? `?${queryString}` : '/'; -} +import { parseUrlFilters, useUrlSync } from './useUrlSync'; +import { useFilterFetch } from './useFilterFetch'; /** * Check if filters are empty. @@ -109,34 +59,11 @@ export function useFilterState({ }: UseFilterStateOptions): UseFilterStateReturn { const { homeStateRef, setHomeState } = useHomeState(); - // Initialize from persistent state (ref) or URL params (all using lazy initializers) + // Initialize from persistent state (ref) or URL params 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 - 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(() => !homeStateRef.current.initialized); - const [error, setError] = useState(''); const [randomAnimation, setRandomAnimation] = useState<{ index: number; phase: 'out' | 'in'; @@ -147,6 +74,41 @@ export function useFilterState({ const activeFiltersRef = useRef(activeFilters); activeFiltersRef.current = activeFilters; + // Check if we should skip initial fetch (restored from persistent state with same filters) + const shouldSkipInitialFetch = + homeStateRef.current.initialized && + JSON.stringify(homeStateRef.current.activeFilters) === JSON.stringify(activeFilters); + + // Use extracted hooks + useUrlSync({ activeFilters, onTrackPageview }); + + const { + filterCounts, + globalCounts, + orCounts, + allImages, + displayedImages, + hasMore, + loading, + error, + setDisplayedImages, + setHasMore, + setError, + } = useFilterFetch({ + activeFilters, + initialState: homeStateRef.current.initialized + ? { + filterCounts: homeStateRef.current.filterCounts, + globalCounts: homeStateRef.current.globalCounts, + orCounts: homeStateRef.current.orCounts, + allImages: homeStateRef.current.allImages, + displayedImages: homeStateRef.current.displayedImages, + hasMore: homeStateRef.current.hasMore, + } + : undefined, + skipInitialFetch: shouldSkipInitialFetch, + }); + // Sync state changes back to persistent context useEffect(() => { if (allImages.length > 0 || displayedImages.length > 0) { @@ -182,33 +144,39 @@ export function useFilterState({ }, []); // Remove a filter value from a specific group - const handleRemoveFilter = useCallback((groupIndex: number, value: string) => { - const group = activeFiltersRef.current[groupIndex]; - if (group) { - onTrackEvent('filter_remove', { category: group.category, value }); - } - setActiveFilters((prev) => { - const newFilters = [...prev]; - const grp = newFilters[groupIndex]; - if (!grp) return prev; - - const updatedValues = grp.values.filter((v) => v !== value); - if (updatedValues.length === 0) { - return newFilters.filter((_, i) => i !== groupIndex); + const handleRemoveFilter = useCallback( + (groupIndex: number, value: string) => { + const group = activeFiltersRef.current[groupIndex]; + if (group) { + onTrackEvent('filter_remove', { category: group.category, value }); } - newFilters[groupIndex] = { ...grp, values: updatedValues }; - return newFilters; - }); - }, [onTrackEvent]); + setActiveFilters((prev) => { + const newFilters = [...prev]; + const grp = newFilters[groupIndex]; + if (!grp) return prev; + + const updatedValues = grp.values.filter((v) => v !== value); + if (updatedValues.length === 0) { + return newFilters.filter((_, i) => i !== groupIndex); + } + newFilters[groupIndex] = { ...grp, values: updatedValues }; + return newFilters; + }); + }, + [onTrackEvent] + ); // Remove entire group by index - const handleRemoveGroup = useCallback((groupIndex: number) => { - const group = activeFiltersRef.current[groupIndex]; - if (group) { - onTrackEvent('filter_remove', { category: group.category, value: group.values.join(',') }); - } - setActiveFilters((prev) => prev.filter((_, i) => i !== groupIndex)); - }, [onTrackEvent]); + const handleRemoveGroup = useCallback( + (groupIndex: number) => { + const group = activeFiltersRef.current[groupIndex]; + if (group) { + onTrackEvent('filter_remove', { category: group.category, value: group.values.join(',') }); + } + setActiveFilters((prev) => prev.filter((_, i) => i !== groupIndex)); + }, + [onTrackEvent] + ); // Random filter - replaces last filter slot (or adds first one) const handleRandom = useCallback( @@ -225,8 +193,7 @@ export function useFilterState({ if (availableCategories.length === 0) return; - const randomCategory = - availableCategories[Math.floor(Math.random() * availableCategories.length)]; + const randomCategory = availableCategories[Math.floor(Math.random() * availableCategories.length)]; const values = Object.keys(countsToUse[randomCategory]); if (values.length === 0) return; @@ -261,89 +228,6 @@ export function useFilterState({ [filterCounts, globalCounts, onTrackEvent] ); - // Update URL when filters change - useEffect(() => { - const newUrl = buildFilterUrl(activeFilters); - window.history.replaceState({}, '', newUrl); - - // Update document title - const filterParts = activeFilters - .filter((f) => f.values.length > 0) - .map((f) => `${f.category}:${f.values.join(',')}`) - .join(' '); - - document.title = filterParts ? `${filterParts} | pyplots.ai` : 'pyplots.ai'; - 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 () => { - setLoading(true); - - try { - // Build query string from filters - const params = new URLSearchParams(); - activeFilters.forEach(({ category, values }) => { - if (values.length > 0) { - params.append(category, values.join(',')); - } - }); - - const queryString = params.toString(); - const url = `${API_URL}/plots/filter${queryString ? `?${queryString}` : ''}`; - - const response = await fetch(url, { signal: abortController.signal }); - if (!response.ok) throw new Error('Failed to fetch filtered plots'); - - const data = await response.json(); - - if (abortController.signal.aborted) return; - - // Update filter counts - setFilterCounts(data.counts); - setGlobalCounts(data.globalCounts || data.counts); - setOrCounts(data.orCounts || []); - - // Shuffle images randomly on each load - const shuffled = shuffleArray(data.images || []); - setAllImages(shuffled); - - // Initial display count - setDisplayedImages(shuffled.slice(0, BATCH_SIZE)); - setHasMore(shuffled.length > BATCH_SIZE); - } catch (err) { - if (abortController.signal.aborted) return; - setError(`Error loading images: ${err}`); - } finally { - if (!abortController.signal.aborted) { - setLoading(false); - } - } - }; - - fetchFilteredImages(); - - return () => abortController.abort(); - }, [activeFilters]); - return { // State activeFilters, diff --git a/app/src/hooks/useUrlSync.ts b/app/src/hooks/useUrlSync.ts new file mode 100644 index 0000000000..119e8700e5 --- /dev/null +++ b/app/src/hooks/useUrlSync.ts @@ -0,0 +1,74 @@ +/** + * Hook for URL synchronization with filter state. + * + * Handles URL parsing, building, and document title updates. + */ + +import { useEffect } from 'react'; + +import type { FilterCategory, ActiveFilters } from '../types'; +import { FILTER_CATEGORIES } from '../types'; + +/** + * Parse URL params into ActiveFilters. + * URL format: ?lib=matplotlib&lib=seaborn (AND) or ?lib=matplotlib,seaborn (OR within group) + */ +export function parseUrlFilters(): ActiveFilters { + const params = new URLSearchParams(window.location.search); + const filters: ActiveFilters = []; + + FILTER_CATEGORIES.forEach((category) => { + const allValues = params.getAll(category); + allValues.forEach((value) => { + if (value) { + const values = value + .split(',') + .map((v) => v.trim()) + .filter(Boolean); + if (values.length > 0) { + filters.push({ category, values }); + } + } + }); + }); + + return filters; +} + +/** + * Build URL from ActiveFilters. + */ +export function buildFilterUrl(filters: ActiveFilters): string { + const params = new URLSearchParams(); + filters.forEach(({ category, values }) => { + if (values.length > 0) { + params.append(category, values.join(',')); + } + }); + const queryString = params.toString(); + return queryString ? `?${queryString}` : '/'; +} + +interface UseUrlSyncOptions { + activeFilters: ActiveFilters; + onTrackPageview: () => void; +} + +/** + * Hook to synchronize active filters with URL and document title. + */ +export function useUrlSync({ activeFilters, onTrackPageview }: UseUrlSyncOptions): void { + useEffect(() => { + const newUrl = buildFilterUrl(activeFilters); + window.history.replaceState({}, '', newUrl); + + // Update document title + const filterParts = activeFilters + .filter((f) => f.values.length > 0) + .map((f) => `${f.category}:${f.values.join(',')}`) + .join(' '); + + document.title = filterParts ? `${filterParts} | pyplots.ai` : 'pyplots.ai'; + onTrackPageview(); + }, [activeFilters, onTrackPageview]); +} diff --git a/app/src/pages/CatalogPage.tsx b/app/src/pages/CatalogPage.tsx index 366017eb94..db5548887a 100644 --- a/app/src/pages/CatalogPage.tsx +++ b/app/src/pages/CatalogPage.tsx @@ -10,7 +10,7 @@ import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; import { API_URL, GITHUB_URL } from '../constants'; import { useAnalytics } from '../hooks'; import { useAppData, useHomeState } from '../components/Layout'; -import { Footer } from '../components'; +import { Breadcrumb, Footer } from '../components'; import type { PlotImage } from '../types'; interface CatalogSpec { @@ -153,60 +153,26 @@ export function CatalogPage() { {/* Breadcrumb navigation */} - - {/* Breadcrumb links */} - + trackEvent('suggest_spec')} sx={{ - color: '#3776AB', + color: '#9ca3af', textDecoration: 'none', - '&:hover': { textDecoration: 'underline' }, + '&:hover': { color: '#3776AB' }, }} > - pyplots.ai + suggest spec - - - catalog - - - - {/* Suggest spec link */} - trackEvent('suggest_spec')} - sx={{ - color: '#9ca3af', - textDecoration: 'none', - '&:hover': { color: '#3776AB' }, - }} - > - suggest spec - - + } + sx={{ mb: 3, position: 'sticky', top: 0, zIndex: 100 }} + /> {/* Title */} {/* Breadcrumb */} - - - pyplots.ai - - - - debug - - + {/* Stats */} diff --git a/app/src/pages/InteractivePage.tsx b/app/src/pages/InteractivePage.tsx index 0f0564c7a6..23eb08cd4a 100644 --- a/app/src/pages/InteractivePage.tsx +++ b/app/src/pages/InteractivePage.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef, useCallback } from 'react'; -import { useParams, useNavigate, Link } from 'react-router-dom'; +import { useParams, useNavigate } from 'react-router-dom'; import { Helmet } from 'react-helmet-async'; import Box from '@mui/material/Box'; import IconButton from '@mui/material/IconButton'; @@ -10,6 +10,7 @@ import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import { API_URL } from '../constants'; import { useAnalytics } from '../hooks'; +import { Breadcrumb } from '../components'; // Initial dimensions - will be updated via postMessage from iframe const INITIAL_WIDTH = 1600; @@ -208,78 +209,23 @@ export function InteractivePage() { }} > {/* Breadcrumb navigation */} - - - - pyplots.ai - - - - catalog - - - - {specId} - - - - {library} - - - - interactive - - - - - - - - + + + + + + } + sx={{ mx: 0, mt: 0, mb: 0 }} + /> {/* Fullscreen iframe - scaled to fit container */} ; - review_verdict?: string; - // Implementation-level tags - impl_tags?: Record; -} +import { Breadcrumb, Footer, SpecOverview, SpecDetailView } from '../components'; +import type { Implementation } from '../types'; interface SpecDetail { id: string; @@ -68,7 +41,7 @@ export function SpecPage() { const [error, setError] = useState(null); const [imageLoaded, setImageLoaded] = useState(false); const [descExpanded, setDescExpanded] = useState(false); - const [codeCopied, setCodeCopied] = useState(null); // library_id or null + const [codeCopied, setCodeCopied] = useState(null); const [openTooltip, setOpenTooltip] = useState(null); // Get library metadata by ID @@ -93,11 +66,7 @@ export function SpecPage() { try { const res = await fetch(`${API_URL}/specs/${specId}`); if (!res.ok) { - if (res.status === 404) { - setError('Spec not found'); - } else { - setError('Failed to load spec'); - } + setError(res.status === 404 ? 'Spec not found' : 'Failed to load spec'); return; } @@ -106,7 +75,6 @@ export function SpecPage() { // Validate library if provided if (urlLibrary && !data.implementations.some((impl) => impl.library_id === urlLibrary)) { - // Invalid library, redirect to overview navigate(`/${specId}`, { replace: true }); } } catch (err) { @@ -151,7 +119,7 @@ export function SpecPage() { trackEvent('back_to_overview', { spec: specId, library: selectedLibrary || undefined }); }, [specId, selectedLibrary, navigate, trackEvent]); - // Handle download (works for both overview and detail mode) + // Handle download const handleDownload = useCallback( (impl: Implementation) => { if (!impl?.preview_url) return; @@ -159,19 +127,28 @@ export function SpecPage() { link.href = impl.preview_url; link.download = `${specId}-${impl.library_id}.png`; link.click(); - trackEvent('download_image', { spec: specId, library: impl.library_id, page: isOverviewMode ? 'spec_overview' : 'spec_detail' }); + trackEvent('download_image', { + spec: specId, + library: impl.library_id, + page: isOverviewMode ? 'spec_overview' : 'spec_detail', + }); }, [specId, trackEvent, isOverviewMode] ); - // Handle copy code (works for both overview and detail mode) + // Handle copy code const handleCopyCode = useCallback( async (impl: Implementation) => { if (!impl?.code) return; try { await navigator.clipboard.writeText(impl.code); setCodeCopied(impl.library_id); - trackEvent('copy_code', { spec: specId, library: impl.library_id, method: 'image', page: isOverviewMode ? 'spec_overview' : 'spec_detail' }); + trackEvent('copy_code', { + spec: specId, + library: impl.library_id, + method: 'image', + page: isOverviewMode ? 'spec_overview' : 'spec_detail', + }); setTimeout(() => setCodeCopied(null), 2000); } catch (err) { console.error('Copy failed:', err); @@ -186,7 +163,6 @@ export function SpecPage() { template: 'report-plot-issue.yml', spec_id: specId || '', }); - return `${GITHUB_URL}/issues/new?${params.toString()}`; }, [specId]); @@ -201,6 +177,7 @@ export function SpecPage() { } }, [specData, isOverviewMode, selectedLibrary, specId, trackEvent]); + // Loading state if (loading) { return ( @@ -214,6 +191,7 @@ export function SpecPage() { ); } + // Error state if (error || !specData) { return ( @@ -227,126 +205,75 @@ export function SpecPage() { ); } - // Sort implementations alphabetically - const sortedImpls = [...specData.implementations].sort((a, b) => a.library_id.localeCompare(b.library_id)); - return ( <> - {isOverviewMode ? `${specData.title} | pyplots.ai` : `${specData.title} - ${selectedLibrary} | pyplots.ai`} + + {isOverviewMode ? `${specData.title} | pyplots.ai` : `${specData.title} - ${selectedLibrary} | pyplots.ai`} + - + {currentImpl?.preview_url && } - + {/* Breadcrumb navigation */} - - {/* Breadcrumb links */} - - - pyplots.ai - - - - catalog - - - {isOverviewMode ? ( - - {specId} - - ) : ( - <> + + trackEvent('report_issue', { spec: specId, library: selectedLibrary || undefined })} + sx={{ + color: '#9ca3af', + textDecoration: 'none', + display: 'flex', + alignItems: 'center', + '&:hover': { color: '#3776AB' }, + }} + > + - {specId} - - - - {selectedLibrary} + report issue - - )} - - - {/* Report issue link - responsive */} - - trackEvent('report_issue', { spec: specId, library: selectedLibrary || undefined })} - sx={{ - color: '#9ca3af', - textDecoration: 'none', - display: 'flex', - alignItems: 'center', - '&:hover': { color: '#3776AB' }, - }} - > - {/* Icon for mobile (xs, sm) */} - - {/* Text for desktop (md+) */} - - report issue - - - + + } + /> {/* Title */} {isOverviewMode ? ( - /* OVERVIEW MODE: Grid of implementations */ + /* OVERVIEW MODE */ <> - {/* Implementation Grid - same style as home page */} - - {sortedImpls.map((impl) => ( - - {/* Card */} - handleImplClick(impl.library_id)} - sx={{ - position: 'relative', - borderRadius: 3, - overflow: 'hidden', - border: '2px solid rgba(55, 118, 171, 0.2)', - boxShadow: '0 2px 8px rgba(0,0,0,0.1)', - cursor: 'pointer', - transition: 'all 0.3s ease', - '&:hover': { - border: '2px solid rgba(55, 118, 171, 0.4)', - boxShadow: '0 8px 30px rgba(0,0,0,0.15)', - transform: 'scale(1.03)', - }, - '&:hover .action-buttons': { - opacity: 1, - }, - }} - > - {impl.preview_thumb || impl.preview_url ? ( - - ) : ( - - )} - - {/* Action Buttons (top-right) */} - e.stopPropagation()} - sx={{ - position: 'absolute', - top: 8, - right: 8, - display: 'flex', - gap: 0.5, - opacity: 0, - transition: 'opacity 0.2s', - }} - > - {impl.code && ( - - handleCopyCode(impl)} - sx={{ - bgcolor: 'rgba(255,255,255,0.9)', - '&:hover': { bgcolor: '#fff' }, - }} - size="small" - > - {codeCopied === impl.library_id ? ( - - ) : ( - - )} - - - )} - - handleDownload(impl)} - sx={{ - bgcolor: 'rgba(255,255,255,0.9)', - '&:hover': { bgcolor: '#fff' }, - }} - size="small" - > - - - - {impl.preview_html && ( - - { - e.stopPropagation(); - trackEvent('open_interactive', { spec: specId, library: impl.library_id }); - }} - sx={{ - bgcolor: 'rgba(255,255,255,0.9)', - '&:hover': { bgcolor: '#fff' }, - }} - size="small" - > - - - - )} - - - {/* Label below card with library tooltip */} - {(() => { - const libMeta = getLibraryMeta(impl.library_id); - const tooltipId = `lib-${impl.library_id}`; - const isTooltipOpen = openTooltip === tooltipId; - - return ( - - isTooltipOpen && setOpenTooltip(null)}> - - - - {libMeta?.description || 'No description available'} - - {libMeta?.documentation_url && ( - e.stopPropagation()} - sx={{ - display: 'inline-flex', - alignItems: 'center', - gap: 0.5, - fontSize: '0.75rem', - color: '#90caf9', - textDecoration: 'underline', - '&:hover': { color: '#fff' }, - }} - > - {libMeta.documentation_url.replace(/^https?:\/\//, '')} - - - )} - - } - arrow - placement="bottom" - open={isTooltipOpen} - disableFocusListener - disableHoverListener - disableTouchListener - slotProps={{ - tooltip: { - sx: { - maxWidth: { xs: '80vw', sm: 400 }, - fontFamily: '"MonoLisa", monospace', - fontSize: '0.8rem', - }, - }, - }} - > - { - e.stopPropagation(); - setOpenTooltip(isTooltipOpen ? null : tooltipId); - }} - sx={{ - fontSize: '0.8rem', - fontWeight: 600, - fontFamily: '"MonoLisa", monospace', - color: isTooltipOpen ? '#3776AB' : '#9ca3af', - textTransform: 'lowercase', - cursor: 'pointer', - '&:hover': { color: '#3776AB' }, - }} - > - {impl.library_id} - - - - - {impl.quality_score && ( - <> - · - - {Math.round(impl.quality_score)} - - - )} - - ); - })()} - - ))} - + - {/* Spec Tabs (without Code/Impl/Quality - just Spec info) */} ) : ( - /* DETAIL MODE: Single implementation view */ + /* DETAIL MODE */ <> - {/* Library Carousel */} - {/* Main Image (clickable to go back to overview) */} - - - {!imageLoaded && ( - - )} - {currentImpl?.preview_url && ( - setImageLoaded(true)} - sx={{ - width: '100%', - height: '100%', - objectFit: 'contain', - display: imageLoaded ? 'block' : 'none', - }} - /> - )} - - {/* Action Buttons (top-right) - stop propagation */} - e.stopPropagation()} - sx={{ - position: 'absolute', - top: 8, - right: 8, - display: 'flex', - gap: 0.5, - }} - > - {currentImpl?.code && ( - - handleCopyCode(currentImpl)} - sx={{ - bgcolor: 'rgba(255,255,255,0.9)', - '&:hover': { bgcolor: '#fff' }, - }} - size="small" - > - {codeCopied === currentImpl.library_id ? : } - - - )} - {currentImpl && ( - - handleDownload(currentImpl)} - sx={{ - bgcolor: 'rgba(255,255,255,0.9)', - '&:hover': { bgcolor: '#fff' }, - }} - size="small" - > - - - - )} - {currentImpl?.preview_html && ( - - { - e.stopPropagation(); - trackEvent('open_interactive', { spec: specId, library: selectedLibrary || undefined }); - }} - sx={{ - bgcolor: 'rgba(255,255,255,0.9)', - '&:hover': { bgcolor: '#fff' }, - }} - size="small" - > - - - - )} - - - {/* Implementation counter (hover) */} - {specData.implementations.length > 1 && ( - - {sortedImpls.findIndex((impl) => impl.library_id === selectedLibrary) + 1} - /{specData.implementations.length} - - )} - - + setImageLoaded(true)} + onImageClick={handleImageClick} + onCopyCode={handleCopyCode} + onDownload={handleDownload} + onTrackEvent={trackEvent} + /> - {/* Tabs */} )} - {/* Footer */}