diff --git a/.tmp/proof/showcase-answer-engine.png b/.tmp/proof/showcase-answer-engine.png new file mode 100644 index 0000000..a6987de Binary files /dev/null and b/.tmp/proof/showcase-answer-engine.png differ diff --git a/.tmp/proof/showcase-full.png b/.tmp/proof/showcase-full.png new file mode 100644 index 0000000..7fdb82c Binary files /dev/null and b/.tmp/proof/showcase-full.png differ diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index c1f0a32..ae1f34c 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -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, @@ -200,6 +201,7 @@ const routes = ( } /> } /> } /> + } /> ); diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index e6fe8d7..4bf735e 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -19,6 +19,7 @@ import { ChecklistIcon, PulseIcon, TelescopeIcon, + StarIcon, } from '@primer/octicons-react'; import { Sidebar, @@ -73,6 +74,7 @@ const RocketOcticon: IconComponent = () => ; const ChecklistOcticon: IconComponent = () => ; const PulseOcticon: IconComponent = () => ; const TelescopeOcticon: IconComponent = () => ; +const StarOcticon: IconComponent = () => ; export const Root = ({ children }: PropsWithChildren<{}>) => { if ( @@ -114,6 +116,7 @@ export const Root = ({ children }: PropsWithChildren<{}>) => { + { + const theme = useTheme(); + const color = showcaseCategoryColors[item.category]; + + return ( + + + + + {item.name} + + {item.isRecommended && ( + + )} + + + + {item.description} + + + {item.tags.slice(0, 4).map(tag => ( + + ))} + + + + + + + ); +}; diff --git a/packages/app/src/components/showcase/ShowcasePage.tsx b/packages/app/src/components/showcase/ShowcasePage.tsx new file mode 100644 index 0000000..4054dd3 --- /dev/null +++ b/packages/app/src/components/showcase/ShowcasePage.tsx @@ -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 ( + +
+ + + + What this portal can do + + + 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. + + + + + setSearchTerm(e.target.value)} + size="small" + sx={{ minWidth: 300, maxWidth: 400 }} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + {filtered.length} item{filtered.length !== 1 ? 's' : ''} available + + + + + setSelectedCategory('all')} + variant={selectedCategory === 'all' ? 'filled' : 'outlined'} + color={selectedCategory === 'all' ? 'primary' : 'default'} + sx={{ fontWeight: selectedCategory === 'all' ? 600 : 400 }} + /> + {categories.map(category => ( + setSelectedCategory(category)} + variant={selectedCategory === category ? 'filled' : 'outlined'} + color={selectedCategory === category ? 'primary' : 'default'} + sx={{ + fontWeight: selectedCategory === category ? 600 : 400, + }} + /> + ))} + + + {filtered.length > 0 ? ( + + {filtered.map(item => ( + + + + ))} + + ) : ( + + + No showcase items found matching "{searchTerm}" + {selectedCategory !== 'all' && + ` in ${showcaseCategoryLabels[selectedCategory]}`} + + + )} + + + ); +}; diff --git a/packages/app/src/components/showcase/index.ts b/packages/app/src/components/showcase/index.ts new file mode 100644 index 0000000..2781fcb --- /dev/null +++ b/packages/app/src/components/showcase/index.ts @@ -0,0 +1,11 @@ +export { ShowcasePage } from './ShowcasePage'; +export { ShowcaseCard } from './ShowcaseCard'; +export { + showcaseItems, + showcaseCategoryLabels, + showcaseCategoryColors, + filterShowcaseItems, + countByCategory, + type ShowcaseItem, + type ShowcaseCategory, +} from './showcaseConfig'; diff --git a/packages/app/src/components/showcase/showcaseConfig.test.ts b/packages/app/src/components/showcase/showcaseConfig.test.ts new file mode 100644 index 0000000..d35d96e --- /dev/null +++ b/packages/app/src/components/showcase/showcaseConfig.test.ts @@ -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); + }); +}); diff --git a/packages/app/src/components/showcase/showcaseConfig.ts b/packages/app/src/components/showcase/showcaseConfig.ts new file mode 100644 index 0000000..a5c4e69 --- /dev/null +++ b/packages/app/src/components/showcase/showcaseConfig.ts @@ -0,0 +1,216 @@ +export type ShowcaseCategory = + | 'templates' + | 'github' + | 'answer-engine' + | 'platform'; + +export interface ShowcaseItem { + id: string; + name: string; + description: string; + category: ShowcaseCategory; + link: string; + linkLabel: string; + tags: string[]; + isRecommended?: boolean; +} + +export const showcaseCategoryLabels: Record = { + templates: 'Code Templates', + github: 'GitHub', + 'answer-engine': 'Answer Engine', + platform: 'Platform', +}; + +export const showcaseCategoryColors: Record = { + templates: '#0969da', + github: '#1f2328', + 'answer-engine': '#8250df', + platform: '#1a7f37', +}; + +export const showcaseItems: ShowcaseItem[] = [ + { + id: 'template-ai-hub-service', + name: 'AI Hub Service', + description: + 'Scaffold a provider-agnostic gateway in front of your organisation LLM providers, with auth, rate limiting, cost tracking and audit logging.', + category: 'templates', + link: '/self-service', + linkLabel: 'Use template', + tags: ['recommended', 'ai', 'llm', 'github'], + isRecommended: true, + }, + { + id: 'template-answer-engine-worker', + name: 'Answer Engine Worker', + description: + 'Scaffold a queue consumer that retrieves context from a document index and produces cited answers, with an indexer and query API.', + category: 'templates', + link: '/self-service', + linkLabel: 'Use template', + tags: ['recommended', 'ai', 'rag', 'github'], + isRecommended: true, + }, + { + id: 'template-mcp-server', + name: 'MCP Server', + description: + 'Scaffold a Model Context Protocol server that AI assistants connect to for domain-specific tools and context.', + category: 'templates', + link: '/self-service', + linkLabel: 'Use template', + tags: ['recommended', 'ai', 'mcp', 'github'], + isRecommended: true, + }, + { + id: 'template-github-blank-repo', + name: 'GitHub Blank Repo', + description: + 'Create a new blank repository in GitHub from a local skeleton and register it in the catalog.', + category: 'templates', + link: '/self-service', + linkLabel: 'Use template', + tags: ['recommended', 'github', 'repo'], + isRecommended: true, + }, + { + id: 'template-github-pull-request', + name: 'GitHub Pull Request Docs', + description: + 'Create a documentation site in a GitHub repository and open it as a pull request for review.', + category: 'templates', + link: '/self-service', + linkLabel: 'Use template', + tags: ['recommended', 'github', 'techdocs'], + isRecommended: true, + }, + { + id: 'template-gitlab-blank-repo', + name: 'GitLab Blank Repo', + description: + 'Create a new blank repository in GitLab from a local skeleton and register it in the catalog.', + category: 'templates', + link: '/self-service', + linkLabel: 'Use template', + tags: ['recommended', 'gitlab', 'repo'], + isRecommended: true, + }, + { + id: 'template-gitlab-merge-request', + name: 'GitLab Merge Request Docs', + description: + 'Create a documentation site in a GitLab repository and open it as a merge request for review.', + category: 'templates', + link: '/self-service', + linkLabel: 'Use template', + tags: ['recommended', 'gitlab', 'techdocs'], + isRecommended: true, + }, + { + id: 'github-catalog', + name: 'GitHub Catalog Sync', + description: + 'Browse services, APIs and docs discovered from GitHub in the software catalog.', + category: 'github', + link: '/catalog', + linkLabel: 'Open catalog', + tags: ['github', 'catalog', 'discovery'], + }, + { + id: 'github-scaffolder', + name: 'GitHub Scaffolder Actions', + description: + 'Publish new repositories and pull requests from templates with the GitHub scaffolder actions.', + category: 'github', + link: '/self-service', + linkLabel: 'Create component', + tags: ['github', 'scaffolder', 'automation'], + }, + { + id: 'github-api-docs', + name: 'API Explorer', + description: + 'Explore OpenAPI definitions synced from GitHub repositories alongside the services that own them.', + category: 'github', + link: '/api-docs', + linkLabel: 'Explore APIs', + tags: ['github', 'openapi', 'api'], + }, + { + id: 'answer-engine-search', + name: 'Answer Engine Search', + description: + 'Ask questions across the catalog and docs and get cited answers grounded in your own content.', + category: 'answer-engine', + link: '/search', + linkLabel: 'Try search', + tags: ['ai', 'search', 'rag'], + isRecommended: true, + }, + { + id: 'answer-engine-worker-card', + name: 'Answer Engine Worker Template', + description: + 'Start your own answer engine worker with retrieval, citation handling and catalog registration built in.', + category: 'answer-engine', + link: '/self-service', + linkLabel: 'Use template', + tags: ['ai', 'rag', 'template'], + isRecommended: true, + }, + { + id: 'platform-techdocs', + name: 'TechDocs', + description: + 'Read documentation that lives with the code, published from the same repository as each service.', + category: 'platform', + link: '/docs', + linkLabel: 'Read docs', + tags: ['docs', 'techdocs'], + }, + { + id: 'platform-catalog-graph', + name: 'Catalog Graph', + description: + 'See how services, APIs, systems and teams relate to each other in one graph view.', + category: 'platform', + link: '/catalog-graph', + linkLabel: 'View graph', + tags: ['catalog', 'graph'], + }, +]; + +export function filterShowcaseItems( + items: ShowcaseItem[], + category: ShowcaseCategory | 'all', + searchTerm: string, +): ShowcaseItem[] { + const term = searchTerm.trim().toLowerCase(); + return items.filter(item => { + const matchesCategory = category === 'all' || item.category === category; + if (!matchesCategory) return false; + if (!term) return true; + return ( + item.name.toLowerCase().includes(term) || + item.description.toLowerCase().includes(term) || + item.tags.some(tag => tag.toLowerCase().includes(term)) + ); + }); +} + +export function countByCategory( + items: ShowcaseItem[], +): Record { + const counts: Record = { + all: items.length, + templates: 0, + github: 0, + 'answer-engine': 0, + platform: 0, + }; + items.forEach(item => { + counts[item.category] += 1; + }); + return counts; +}