From 3ef88794183515b73f8029ba68091e55626c2949 Mon Sep 17 00:00:00 2001 From: Evan Bonsignori Date: Tue, 7 Oct 2025 21:22:59 -0700 Subject: [PATCH 1/2] Add new landing page article grid (#57757) --- data/ui.yml | 17 +- src/fixtures/fixtures/data/ui.yml | 17 +- src/frame/middleware/context/generic-toc.ts | 13 +- .../components/bespoke/BespokeLanding.tsx | 2 +- .../LandingArticleGridWithFilter.module.scss | 179 ++++++++++++- .../shared/LandingArticleGridWithFilter.tsx | 244 ++++++++++-------- .../shared/LandingCarousel.module.scss | 10 +- .../components/shared/LandingCarousel.tsx | 4 +- src/landings/pages/product.tsx | 4 +- 9 files changed, 343 insertions(+), 147 deletions(-) diff --git a/data/ui.yml b/data/ui.yml index e7f0dfba8895..e77f87975515 100644 --- a/data/ui.yml +++ b/data/ui.yml @@ -261,18 +261,17 @@ footer: expert_services: Expert services blog: Blog machine: Some of this content may be machine- or AI-translated. -bespoke_landing: - articles: Articles - all_categories: All categories - search_articles: Search articles -discovery_landing: - recommended: Recommended - articles: Articles - all_categories: All categories - search_articles: Search articles journey_landing: articles: '{{ number }} Articles' product_landing: + article_grid: + heading: Articles + all_categories: All categories + search_articles: Search articles + no_articles_found: No articles found matching your criteria. + showing_results: Showing {start}-{end} of {total} + carousel: + recommended: Recommended quickstart: Quickstart reference: Reference overview: Overview diff --git a/src/fixtures/fixtures/data/ui.yml b/src/fixtures/fixtures/data/ui.yml index e7f0dfba8895..e77f87975515 100644 --- a/src/fixtures/fixtures/data/ui.yml +++ b/src/fixtures/fixtures/data/ui.yml @@ -261,18 +261,17 @@ footer: expert_services: Expert services blog: Blog machine: Some of this content may be machine- or AI-translated. -bespoke_landing: - articles: Articles - all_categories: All categories - search_articles: Search articles -discovery_landing: - recommended: Recommended - articles: Articles - all_categories: All categories - search_articles: Search articles journey_landing: articles: '{{ number }} Articles' product_landing: + article_grid: + heading: Articles + all_categories: All categories + search_articles: Search articles + no_articles_found: No articles found matching your criteria. + showing_results: Showing {start}-{end} of {total} + carousel: + recommended: Recommended quickstart: Quickstart reference: Reference overview: Overview diff --git a/src/frame/middleware/context/generic-toc.ts b/src/frame/middleware/context/generic-toc.ts index 948a2d712f51..eec4758bb2d1 100644 --- a/src/frame/middleware/context/generic-toc.ts +++ b/src/frame/middleware/context/generic-toc.ts @@ -106,6 +106,8 @@ export default async function genericToc(req: ExtendedRequest, res: Response, ne recurse: isRecursive, renderIntros, includeHidden, + textOnly: + isNewLandingPageFeature(req) || isNewLandingPage(req.context.currentLayoutName || ''), }) } @@ -120,6 +122,8 @@ export default async function genericToc(req: ExtendedRequest, res: Response, ne ? true : false, includeHidden, + textOnly: + isNewLandingPageFeature(req) || isNewLandingPage(req.context.currentLayoutName || ''), }) } @@ -132,6 +136,7 @@ type Options = { recurse: boolean renderIntros: boolean includeHidden: boolean + textOnly: boolean } async function getTocItems(node: Tree, context: Context, opts: Options): Promise { @@ -154,13 +159,13 @@ async function getTocItems(node: Tree, context: Context, opts: Options): Promise if (page.rawIntro) { // The intro can contain Markdown even though it might not // contain any Liquid. - // Deliberately don't use `textOnly:true` here because we intend - // to display the intro, in a table of contents component, - // with the HTML (dangerouslySetInnerHTML). + // Use textOnly for new landing pages to strip HTML tags. + // For other pages, we intend to display the intro in a table of contents + // component with the HTML (dangerouslySetInnerHTML). intro = await page.renderProp( 'rawIntro', context, - context.currentLayoutName === 'category-landing' ? { textOnly: true } : {}, + opts.textOnly ? { textOnly: true } : {}, ) } } diff --git a/src/landings/components/bespoke/BespokeLanding.tsx b/src/landings/components/bespoke/BespokeLanding.tsx index 0e61f7bf561f..490860a5aaa8 100644 --- a/src/landings/components/bespoke/BespokeLanding.tsx +++ b/src/landings/components/bespoke/BespokeLanding.tsx @@ -20,7 +20,7 @@ export const BespokeLanding = () => {
-
+
diff --git a/src/landings/components/shared/LandingArticleGridWithFilter.module.scss b/src/landings/components/shared/LandingArticleGridWithFilter.module.scss index 3729fb1b82f3..ffac7be779dc 100644 --- a/src/landings/components/shared/LandingArticleGridWithFilter.module.scss +++ b/src/landings/components/shared/LandingArticleGridWithFilter.module.scss @@ -1,6 +1,165 @@ @import "@primer/css/support/variables/layout.scss"; @import "@primer/css/support/mixins/layout.scss"; +.headerTitleText { + font-size: var(--h3-size, 1.25rem); + font-weight: var(--base-text-weight-semibold, 600); + line-height: var(--heading-lineHeight); +} + +.noArticlesContainer { + width: 100%; + text-align: left; +} + +.noArticlesText { + color: var(--fgColor-muted, var(--color-fg-muted, #656d76)); +} + +.articleCardBox { + display: flex; + flex-direction: column; + padding: 1.5rem; + min-height: 7.5rem; + box-shadow: + 0 0.0625rem 0.1875rem 0 rgba(31, 35, 40, 0.08), + 0 0.0625rem 0 0 rgba(31, 35, 40, 0.06); +} + +.cardHeader { + display: flex; + flex-direction: column; +} + +.cardTitle { + margin: 0 0 0.5rem 0; + font-size: 1.1rem; + font-weight: var(--base-text-weight-semibold, 600); +} + +.cardTitleLink { + color: var(--fgColor-accent); + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +.cardIntro { + margin: 0; + color: var(--fgColor-muted); + font-size: 0.9rem; + line-height: 1.4; +} + +.tagsContainer { + margin-bottom: 0.5rem; +} + +.filterHeader { + display: flex; + flex-direction: column; + align-items: stretch; + border-bottom: 1px solid var(--borderColor-default); + padding-bottom: 1rem; + margin-top: 3rem; + margin-bottom: 1rem; + gap: 0.75rem; + + // Medium screens: horizontal layout with tighter spacing + @include breakpoint(md) { + flex-direction: row; + align-items: center; + gap: 0.75rem; + } + + // Large screens: horizontal layout with normal spacing + @include breakpoint(lg) { + gap: 1rem; + } +} + +.titleAndDropdownRow { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.75rem; + flex-shrink: 0; + + // Medium screens and up: maintain tight spacing, don't grow + @include breakpoint(md) { + gap: 1rem; + flex-shrink: 0; + flex-grow: 0; + } +} + +.headerTitle { + flex-shrink: 0; + margin: 0; + font-size: 1.5rem; + font-weight: 600; + text-align: left; + width: auto; + + // All screen sizes: keep title compact + @include breakpoint(md) { + flex-shrink: 0; + width: auto; + } +} + +.categoryDropdown { + min-width: 12rem; + flex: 1; + + button { + width: fit-content; + text-align: left !important; + + span { + justify-content: start !important; + } + + // Medium screens: full width but constrained by container + @include breakpoint(md) { + width: 100%; + } + } + + // Medium screens: smaller min-width and constrained max-width + @include breakpoint(md) { + min-width: 20rem; + max-width: 30%; + } + + // Large screens: larger sizing + @include breakpoint(lg) { + min-width: 20rem; + max-width: 40%; + } +} + +.searchContainer { + margin-left: 0; + width: auto; + + // Medium screens: flexible width with spacing + @include breakpoint(md) { + flex: 0 1 25%; + min-width: 12.5rem; + margin-left: auto; + } + + // Large screens: larger width with auto margin + @include breakpoint(lg) { + width: 30%; + flex: 0 0 30%; + margin-left: auto; + } +} + .articleGrid { display: grid; gap: 1.5rem; @@ -25,12 +184,24 @@ height: 100%; } -.cardContent { +.paginationContainer { display: flex; flex-direction: column; - height: 100%; + justify-content: center; + align-items: center; + margin-top: 1rem; + padding-top: 1rem; + gap: 0.75rem; + + // Medium screens and up: horizontal layout with space between + @include breakpoint(md) { + flex-direction: row; + justify-content: space-between; + gap: 0; + } } -.cardFooter { - margin-top: auto; +.showingResults { + color: var(--fgColor-muted, var(--color-fg-muted, #656d76)); + font-size: 0.875rem; } diff --git a/src/landings/components/shared/LandingArticleGridWithFilter.tsx b/src/landings/components/shared/LandingArticleGridWithFilter.tsx index df6f5798812d..4ab780fe3708 100644 --- a/src/landings/components/shared/LandingArticleGridWithFilter.tsx +++ b/src/landings/components/shared/LandingArticleGridWithFilter.tsx @@ -1,11 +1,11 @@ -import { useState, useRef } from 'react' -import { TextInput, ActionMenu, ActionList, Button, Box } from '@primer/react' +import { useState, useRef, useEffect } from 'react' +import { TextInput, ActionMenu, ActionList, Token, Pagination } from '@primer/react' import { SearchIcon } from '@primer/octicons-react' import cx from 'classnames' import { Link } from '@/frame/components/Link' +import { useTranslation } from '@/languages/components/useTranslation' import { ArticleCardItems, ChildTocItem } from '@/landings/types' -import { getOcticonComponent } from '@/landings/lib/octicons' import styles from './LandingArticleGridWithFilter.module.scss' @@ -13,16 +13,53 @@ type ArticleGridProps = { flatArticles: ArticleCardItems } +const ALL_CATEGORIES = 'all_categories' + +// Hook to get current articles per page based on screen size +const useResponsiveArticlesPerPage = () => { + const [articlesPerPage, setArticlesPerPage] = useState(9) // Default to desktop + + useEffect(() => { + const updateArticlesPerPage = () => { + const width = window.innerWidth + if (width < 768) { + // Mobile: 1 column, show 8 articles per page + setArticlesPerPage(8) + } else if (width < 1012) { + // Tablet: 2 columns, show 8 articles per page (4 rows × 2 columns) + setArticlesPerPage(8) + } else { + // Desktop: 3 columns, show 9 articles per page (3 rows × 3 columns) + setArticlesPerPage(9) + } + } + + updateArticlesPerPage() + window.addEventListener('resize', updateArticlesPerPage) + return () => window.removeEventListener('resize', updateArticlesPerPage) + }, []) + + return articlesPerPage +} + export const ArticleGrid = ({ flatArticles }: ArticleGridProps) => { + const { t } = useTranslation('product_landing') const [searchQuery, setSearchQuery] = useState('') - const [selectedCategory, setSelectedCategory] = useState('All') + const [selectedCategory, setSelectedCategory] = useState(ALL_CATEGORIES) const [selectedCategoryIndex, setSelectedCategoryIndex] = useState(0) + const [currentPage, setCurrentPage] = useState(1) + const articlesPerPage = useResponsiveArticlesPerPage() const inputRef = useRef(null) + // Reset to first page when articlesPerPage changes (screen size changes) + useEffect(() => { + setCurrentPage(1) + }, [articlesPerPage]) + // Extract unique categories from the articles const categories: string[] = [ - 'All', + ALL_CATEGORIES, ...new Set(flatArticles.flatMap((item) => item.category || [])), ] @@ -46,7 +83,7 @@ export const ArticleGrid = ({ flatArticles }: ArticleGridProps) => { }) } - if (selectedCategory !== 'All') { + if (selectedCategory !== ALL_CATEGORIES) { results = results.filter((item) => item.category?.includes(selectedCategory)) } @@ -55,42 +92,72 @@ export const ArticleGrid = ({ flatArticles }: ArticleGridProps) => { const filteredResults = applyFilters() + // Calculate pagination + const totalPages = Math.ceil(filteredResults.length / articlesPerPage) + const startIndex = (currentPage - 1) * articlesPerPage + const paginatedResults = filteredResults.slice(startIndex, startIndex + articlesPerPage) + const handleSearch = (query: string) => { setSearchQuery(query) + setCurrentPage(1) // Reset to first page when searching } const handleFilter = (option: string, index: number) => { setSelectedCategory(option) setSelectedCategoryIndex(index) + setCurrentPage(1) // Reset to first page when filtering } - const handleResetFilter = () => { - setSearchQuery('') - setSelectedCategory('All') - setSelectedCategoryIndex(0) - if (inputRef.current) { - inputRef.current.value = '' + const handlePageChange = (e: React.MouseEvent, pageNumber: number) => { + e.preventDefault() + if (pageNumber >= 1 && pageNumber <= totalPages) { + setCurrentPage(pageNumber) } } return (
-

- TODO: Article grid placeholder -

{/* Filter and Search Controls */} -
-
+
+ {/* Title and Dropdown Row */} +
+ {/* Title */} +

+ {t('article_grid.heading')} +

+ + {/* Category Dropdown */} +
+ + + {categories[selectedCategoryIndex] === ALL_CATEGORIES + ? t('article_grid.all_categories') + : categories[selectedCategoryIndex]} + + + + {categories.map((category, index) => ( + handleFilter(category, index)} + > + {category === ALL_CATEGORIES ? t('article_grid.all_categories') : category} + + ))} + + + +
+
+ + {/* Search */} +
e.preventDefault()}> { @@ -100,55 +167,39 @@ export const ArticleGrid = ({ flatArticles }: ArticleGridProps) => { />
-
- - - - Category: - {' '} - {categories[selectedCategoryIndex]} - - - - {categories.map((category, index) => ( - handleFilter(category, index)} - > - {category} - - ))} - - - - - -
{/* Results Grid */}
- {filteredResults.map((article, index) => ( - + {paginatedResults.map((article, index) => ( + ))} {filteredResults.length === 0 && ( -
-

No articles found matching your criteria.

+
+

{t('article_grid.no_articles_found')}

)}
+ + {/* Pagination */} + {totalPages > 1 && ( +
+
+ {t('article_grid.showing_results') + .replace('{start}', String(startIndex + 1)) + .replace( + '{end}', + String(Math.min(startIndex + articlesPerPage, filteredResults.length)), + ) + .replace('{total}', String(filteredResults.length))} +
+ +
+ )}
) } @@ -158,53 +209,28 @@ type ArticleCardProps = { } const ArticleCard = ({ article }: ArticleCardProps) => { - const IconComponent = getOcticonComponent(article.octicon || undefined) - return ( - -
-
- {IconComponent && ( - - )} -
-

- - {article.title} - -

- {article.intro &&

{article.intro}

} -
-
- - {/* Categories */} - {article.category && article.category.length > 0 && ( -
- {article.category.map((cat, index) => ( - - {cat} - - ))} -
- )} +
+
+ {article.category && + article.category.map((cat) => )} +
- {/* Complexity */} - {article.complexity && article.complexity.length > 0 && ( -
- {article.complexity.map((comp, index) => ( - - {comp} - - ))} -
- )} +

+ + {article.title} + +

-
- - Read article → - -
-
- + {article.intro &&
{article.intro}
} +
) } diff --git a/src/landings/components/shared/LandingCarousel.module.scss b/src/landings/components/shared/LandingCarousel.module.scss index dff1b4f916be..6b8e7b71223e 100644 --- a/src/landings/components/shared/LandingCarousel.module.scss +++ b/src/landings/components/shared/LandingCarousel.module.scss @@ -5,15 +5,11 @@ .header { display: flex; - flex-direction: column; + flex-direction: row; gap: 1rem; margin-bottom: 1.5rem; - - @media (min-width: 768px) { - flex-direction: row; - justify-content: space-between; - align-items: center; - } + justify-content: space-between; + align-items: center; } .heading { diff --git a/src/landings/components/shared/LandingCarousel.tsx b/src/landings/components/shared/LandingCarousel.tsx index aa085d5f0cf2..4a4c81ff1421 100644 --- a/src/landings/components/shared/LandingCarousel.tsx +++ b/src/landings/components/shared/LandingCarousel.tsx @@ -42,8 +42,8 @@ export const LandingCarousel = ({ heading = '', recommended }: LandingCarouselPr const [currentPage, setCurrentPage] = useState(0) const [isAnimating, setIsAnimating] = useState(false) const itemsPerView = useResponsiveItemsPerView() - const { t } = useTranslation('discovery_landing') - const headingText = heading || t('recommended') + const { t } = useTranslation('product_landing') + const headingText = heading || t('carousel.recommended') // Ref to store timeout IDs for cleanup const animationTimeoutRef = useRef(null) diff --git a/src/landings/pages/product.tsx b/src/landings/pages/product.tsx index d4cc1594dc00..c69b7344d7b6 100644 --- a/src/landings/pages/product.tsx +++ b/src/landings/pages/product.tsx @@ -175,7 +175,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => // TODO: TEMP: This is a temporary solution to turn off/on new landing pages while we develop them if (currentLayoutName === 'bespoke-landing' || req.query?.feature === 'bespoke-landing') { props.bespokeContext = await getLandingContextFromRequest(req, 'bespoke') - additionalUINamespaces.push('bespoke_landing', 'product_landing') + additionalUINamespaces.push('product_landing') } else if (currentLayoutName === 'journey-landing' || req.query?.feature === 'journey-landing') { props.journeyContext = await getLandingContextFromRequest(req, 'journey') additionalUINamespaces.push('journey_landing', 'product_landing') @@ -184,7 +184,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => req?.query?.feature === 'discovery-landing' ) { props.discoveryContext = await getLandingContextFromRequest(req, 'discovery') - additionalUINamespaces.push('discovery_landing', 'product_landing') + additionalUINamespaces.push('product_landing') } else if (currentLayoutName === 'product-landing') { props.productLandingContext = await getProductLandingContextFromRequest(req) additionalUINamespaces.push('product_landing') From e49d73e328d6489454b87ee9f6e223f776b7499e Mon Sep 17 00:00:00 2001 From: Evan Bonsignori Date: Tue, 7 Oct 2025 21:55:04 -0700 Subject: [PATCH 2/2] fix casing missed in PR review (#57853) --- src/frame/middleware/context/generic-toc.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/frame/middleware/context/generic-toc.ts b/src/frame/middleware/context/generic-toc.ts index eec4758bb2d1..d5147448cdb0 100644 --- a/src/frame/middleware/context/generic-toc.ts +++ b/src/frame/middleware/context/generic-toc.ts @@ -6,9 +6,9 @@ import findPageInSiteTree from '@/frame/lib/find-page-in-site-tree' function isNewLandingPage(currentLayoutName: string): boolean { return ( currentLayoutName === 'category-landing' || - currentLayoutName === 'bespoke_landing' || - currentLayoutName === 'discovery_landing' || - currentLayoutName === 'journey_landing' + currentLayoutName === 'bespoke-landing' || + currentLayoutName === 'discovery-landing' || + currentLayoutName === 'journey-landing' ) }