Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions api/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 =====


Expand Down Expand Up @@ -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)
33 changes: 27 additions & 6 deletions api/routers/plots.py
Original file line number Diff line number Diff line change
@@ -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"])


Expand Down Expand Up @@ -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)
Expand All @@ -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
88 changes: 88 additions & 0 deletions app/src/components/Breadcrumb.tsx
Original file line number Diff line number Diff line change
@@ -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<Theme>;
}

/**
* Breadcrumb navigation component.
*
* @example
* // Simple: pyplots.ai > catalog
* <Breadcrumb items={[{ label: 'pyplots.ai', to: '/' }, { label: 'catalog' }]} />
*
* @example
* // With right action
* <Breadcrumb
* items={[{ label: 'pyplots.ai', to: '/' }, { label: 'catalog' }]}
* rightAction={<Link to="/suggest">suggest spec</Link>}
* />
*/
export function Breadcrumb({ items, rightAction, sx }: BreadcrumbProps) {
return (
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mx: { xs: -2, sm: -4, md: -8, lg: -12 },
mt: -5,
px: 2,
py: 1,
mb: 2,
bgcolor: '#f3f4f6',
borderBottom: '1px solid #e5e7eb',
fontFamily: '"MonoLisa", monospace',
fontSize: '0.85rem',
...sx,
}}
>
{/* Breadcrumb links */}
<Box sx={{ display: 'flex', alignItems: 'center' }}>
{items.map((item, index) => (
<Box key={index} sx={{ display: 'flex', alignItems: 'center' }}>
{index > 0 && (
<Box component="span" sx={{ mx: 1, color: '#9ca3af' }}>
</Box>
)}
{item.to ? (
<Box
component={Link}
to={item.to}
sx={{
color: '#3776AB',
textDecoration: 'none',
'&:hover': { textDecoration: 'underline' },
}}
>
{item.label}
</Box>
) : (
<Box component="span" sx={{ color: '#4b5563' }}>
{item.label}
</Box>
)}
</Box>
))}
</Box>

{/* Right action slot */}
{rightAction}
</Box>
);
}
189 changes: 189 additions & 0 deletions app/src/components/SpecDetailView.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>) => 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 (
<Box
sx={{
maxWidth: { xs: '100%', md: 1200, lg: 1400, xl: 1600 },
mx: 'auto',
}}
>
<Box
onClick={onImageClick}
sx={{
position: 'relative',
borderRadius: 2,
overflow: 'hidden',
bgcolor: '#fff',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
aspectRatio: '16/9',
cursor: 'pointer',
'&:hover .impl-counter': {
opacity: 1,
},
}}
>
{!imageLoaded && (
<Skeleton
variant="rectangular"
sx={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
}}
/>
)}
{currentImpl?.preview_url && (
<Box
component="img"
src={currentImpl.preview_url}
alt={`${specTitle} - ${selectedLibrary}`}
onLoad={onImageLoad}
sx={{
width: '100%',
height: '100%',
objectFit: 'contain',
display: imageLoaded ? 'block' : 'none',
}}
/>
)}

{/* Action Buttons (top-right) - stop propagation */}
<Box
onClick={(e) => e.stopPropagation()}
sx={{
position: 'absolute',
top: 8,
right: 8,
display: 'flex',
gap: 0.5,
}}
>
{currentImpl?.code && (
<Tooltip title={codeCopied === currentImpl.library_id ? 'Copied!' : 'Copy Code'}>
<IconButton
onClick={() => onCopyCode(currentImpl)}
sx={{
bgcolor: 'rgba(255,255,255,0.9)',
'&:hover': { bgcolor: '#fff' },
}}
size="small"
>
{codeCopied === currentImpl.library_id ? (
<CheckIcon fontSize="small" color="success" />
) : (
<ContentCopyIcon fontSize="small" />
)}
</IconButton>
</Tooltip>
)}
{currentImpl && (
<Tooltip title="Download PNG">
<IconButton
onClick={() => onDownload(currentImpl)}
sx={{
bgcolor: 'rgba(255,255,255,0.9)',
'&:hover': { bgcolor: '#fff' },
}}
size="small"
>
<DownloadIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{currentImpl?.preview_html && (
<Tooltip title="Open Interactive">
<IconButton
component={Link}
to={`/interactive/${specId}/${selectedLibrary}`}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
onTrackEvent('open_interactive', { spec: specId, library: selectedLibrary });
}}
sx={{
bgcolor: 'rgba(255,255,255,0.9)',
'&:hover': { bgcolor: '#fff' },
}}
size="small"
>
<OpenInNewIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>

{/* Implementation counter (hover) */}
{implementations.length > 1 && (
<Box
className="impl-counter"
sx={{
position: 'absolute',
bottom: 8,
right: 8,
px: 1,
py: 0.25,
bgcolor: 'rgba(0,0,0,0.6)',
borderRadius: 1,
fontSize: '0.75rem',
fontFamily: '"MonoLisa", monospace',
color: '#fff',
opacity: 0,
transition: 'opacity 0.2s',
}}
>
{currentIndex + 1}/{implementations.length}
</Box>
)}
</Box>
</Box>
);
}
Loading