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
Binary file added .tmp/proof/showcase-answer-engine.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .tmp/proof/showcase-full.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions packages/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { PrimerDemoPage } from './components/PrimerDemoPage';
import { ScorecardPage } from './components/scorecard';
import { PulseCheckPage } from './components/pulse-check';
import { ExplorePage } from './components/explore';
import { ShowcasePage } from './components/showcase';

const app = createApp({
apis,
Expand Down Expand Up @@ -200,6 +201,7 @@ const routes = (
<Route path="/scorecard" element={<ScorecardPage />} />
<Route path="/pulse-check" element={<PulseCheckPage />} />
<Route path="/explore" element={<ExplorePage />} />
<Route path="/showcase" element={<ShowcasePage />} />
</FlatRoutes>
);

Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/components/Root/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
ChecklistIcon,
PulseIcon,
TelescopeIcon,
StarIcon,
} from '@primer/octicons-react';
import {
Sidebar,
Expand Down Expand Up @@ -73,6 +74,7 @@ const RocketOcticon: IconComponent = () => <RocketIcon size={20} />;
const ChecklistOcticon: IconComponent = () => <ChecklistIcon size={20} />;
const PulseOcticon: IconComponent = () => <PulseIcon size={20} />;
const TelescopeOcticon: IconComponent = () => <TelescopeIcon size={20} />;
const StarOcticon: IconComponent = () => <StarIcon size={20} />;

export const Root = ({ children }: PropsWithChildren<{}>) => {
if (
Expand Down Expand Up @@ -114,6 +116,7 @@ export const Root = ({ children }: PropsWithChildren<{}>) => {
<SidebarItem icon={ChecklistOcticon} to="scorecard" text="Scorecard" />
<SidebarItem icon={PulseOcticon} to="pulse-check" text="Pulse Check" />
<SidebarItem icon={TelescopeOcticon} to="explore" text="Explore" />
<SidebarItem icon={StarOcticon} to="showcase" text="Showcase" />
<SidebarSpace />
<SidebarDivider />
<SidebarGroup
Expand Down
104 changes: 104 additions & 0 deletions packages/app/src/components/showcase/ShowcaseCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import CardActions from '@mui/material/CardActions';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import { useTheme, alpha } from '@mui/material/styles';
import { Link as RouterLink } from 'react-router-dom';
import {
ShowcaseItem,
showcaseCategoryColors,
showcaseCategoryLabels,
} from './showcaseConfig';

export const ShowcaseCard = ({ item }: { item: ShowcaseItem }) => {
const theme = useTheme();
const color = showcaseCategoryColors[item.category];

return (
<Card
data-testid={`showcase-card-${item.id}`}
sx={{
borderRadius: 2,
height: '100%',
display: 'flex',
flexDirection: 'column',
transition: 'transform 0.2s, box-shadow 0.2s',
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: theme.shadows[8],
},
}}
>
<CardContent sx={{ flex: 1 }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
mb: 1,
}}
>
<Typography variant="h6" sx={{ mb: 0 }}>
{item.name}
</Typography>
{item.isRecommended && (
<Chip
label="Recommended"
size="small"
color="primary"
sx={{ height: 20, fontSize: '0.65rem' }}
/>
)}
</Box>
<Chip
label={showcaseCategoryLabels[item.category]}
size="small"
sx={{
mb: 1.5,
backgroundColor: alpha(color, 0.12),
color: theme.palette.mode === 'dark' ? theme.palette.text.primary : color,
fontWeight: 500,
}}
/>
<Typography
variant="body2"
color="text.secondary"
sx={{
mb: 1.5,
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{item.description}
</Typography>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{item.tags.slice(0, 4).map(tag => (
<Chip
key={tag}
label={tag}
size="small"
variant="outlined"
sx={{ height: 20, fontSize: '0.65rem' }}
/>
))}
</Box>
</CardContent>
<CardActions sx={{ px: 2, pb: 2, pt: 0 }}>
<Button
component={RouterLink}
to={item.link}
variant="outlined"
size="small"
fullWidth
>
{item.linkLabel}
</Button>
</CardActions>
</Card>
);
};
126 changes: 126 additions & 0 deletions packages/app/src/components/showcase/ShowcasePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import TextField from '@mui/material/TextField';
import InputAdornment from '@mui/material/InputAdornment';
import Chip from '@mui/material/Chip';
import Grid from '@mui/material/Grid';
import { Page, Header, Content } from '@backstage/core-components';
import SearchIcon from '@mui/icons-material/Search';
import {
ShowcaseCategory,
showcaseCategoryLabels,
showcaseItems,
filterShowcaseItems,
countByCategory,
} from './showcaseConfig';
import { ShowcaseCard } from './ShowcaseCard';

const categories: ShowcaseCategory[] = [
'templates',
'github',
'answer-engine',
'platform',
];

export const ShowcasePage = () => {
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState<
ShowcaseCategory | 'all'
>('all');

const counts = useMemo(() => countByCategory(showcaseItems), []);
const filtered = useMemo(
() => filterShowcaseItems(showcaseItems, selectedCategory, searchTerm),
[selectedCategory, searchTerm],
);

return (
<Page themeId="tool">
<Header
title="Plugin Showcase"
subtitle="Templates, GitHub integration and the answer engine, curated from what this portal already ships"
/>
<Content>
<Box sx={{ mb: 4 }}>
<Typography variant="h5" sx={{ mb: 1 }}>
What this portal can do
</Typography>
<Typography variant="body2" color="text.secondary">
Start a new service from a proven template, publish it to GitHub,
and make it findable through the catalog and answer engine. Pick a
card to jump straight to the live surface.
</Typography>
</Box>

<Box
sx={{
display: 'flex',
gap: 2,
flexWrap: 'wrap',
alignItems: 'center',
mb: 3,
}}
>
<TextField
placeholder="Search showcase..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
size="small"
sx={{ minWidth: 300, maxWidth: 400 }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
/>
<Typography variant="body2" color="text.secondary">
{filtered.length} item{filtered.length !== 1 ? 's' : ''} available
</Typography>
</Box>

<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', mb: 4 }}>
<Chip
label={`All (${counts.all})`}
onClick={() => setSelectedCategory('all')}
variant={selectedCategory === 'all' ? 'filled' : 'outlined'}
color={selectedCategory === 'all' ? 'primary' : 'default'}
sx={{ fontWeight: selectedCategory === 'all' ? 600 : 400 }}
/>
{categories.map(category => (
<Chip
key={category}
label={`${showcaseCategoryLabels[category]} (${counts[category]})`}
onClick={() => setSelectedCategory(category)}
variant={selectedCategory === category ? 'filled' : 'outlined'}
color={selectedCategory === category ? 'primary' : 'default'}
sx={{
fontWeight: selectedCategory === category ? 600 : 400,
}}
/>
))}
</Box>

{filtered.length > 0 ? (
<Grid container spacing={3}>
{filtered.map(item => (
<Grid item xs={12} sm={6} md={4} lg={3} key={item.id}>
<ShowcaseCard item={item} />
</Grid>
))}
</Grid>
) : (
<Box sx={{ textAlign: 'center', py: 8 }}>
<Typography color="text.secondary">
No showcase items found matching &quot;{searchTerm}&quot;
{selectedCategory !== 'all' &&
` in ${showcaseCategoryLabels[selectedCategory]}`}
</Typography>
</Box>
)}
</Content>
</Page>
);
};
11 changes: 11 additions & 0 deletions packages/app/src/components/showcase/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export { ShowcasePage } from './ShowcasePage';
export { ShowcaseCard } from './ShowcaseCard';
export {
showcaseItems,
showcaseCategoryLabels,
showcaseCategoryColors,
filterShowcaseItems,
countByCategory,
type ShowcaseItem,
type ShowcaseCategory,
} from './showcaseConfig';
73 changes: 73 additions & 0 deletions packages/app/src/components/showcase/showcaseConfig.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import {
showcaseItems,
filterShowcaseItems,
countByCategory,
} from './showcaseConfig';

describe('showcaseConfig', () => {
it('includes all seven software templates', () => {
const templates = showcaseItems.filter(i => i.category === 'templates');
expect(templates).toHaveLength(7);
expect(templates.map(t => t.id)).toEqual(
expect.arrayContaining([
'template-ai-hub-service',
'template-answer-engine-worker',
'template-mcp-server',
'template-github-blank-repo',
'template-github-pull-request',
'template-gitlab-blank-repo',
'template-gitlab-merge-request',
]),
);
});

it('includes GitHub and answer engine items', () => {
const counts = countByCategory(showcaseItems);
expect(counts.github).toBeGreaterThanOrEqual(3);
expect(counts['answer-engine']).toBeGreaterThanOrEqual(2);
expect(counts.all).toBe(showcaseItems.length);
});

it('every item links to a live portal surface', () => {
const allowed = ['/self-service', '/catalog', '/api-docs', '/search', '/docs', '/catalog-graph'];
showcaseItems.forEach(item => {
expect(allowed).toContain(item.link);
expect(item.linkLabel.length).toBeGreaterThan(0);
expect(item.description.length).toBeGreaterThan(20);
});
});

it('filters by category', () => {
const github = filterShowcaseItems(showcaseItems, 'github', '');
expect(github.every(i => i.category === 'github')).toBe(true);
expect(github.length).toBeGreaterThan(0);

const answer = filterShowcaseItems(showcaseItems, 'answer-engine', '');
expect(answer.every(i => i.category === 'answer-engine')).toBe(true);
});

it('filters by search term across name, description and tags', () => {
expect(
filterShowcaseItems(showcaseItems, 'all', 'mcp').map(i => i.id),
).toContain('template-mcp-server');
expect(
filterShowcaseItems(showcaseItems, 'all', 'rag').length,
).toBeGreaterThan(0);
expect(filterShowcaseItems(showcaseItems, 'all', 'zzzz-nothing')).toEqual(
[],
);
});

it('combines category and search', () => {
const result = filterShowcaseItems(showcaseItems, 'templates', 'github');
expect(result.length).toBeGreaterThan(0);
expect(result.every(i => i.category === 'templates')).toBe(true);
});

it('countByCategory sums to the total', () => {
const counts = countByCategory(showcaseItems);
expect(
counts.templates + counts.github + counts['answer-engine'] + counts.platform,
).toBe(counts.all);
});
});
Loading
Loading