diff --git a/webui/CHANGELOG.md b/webui/CHANGELOG.md index c9b4b4779..52d6460f2 100644 --- a/webui/CHANGELOG.md +++ b/webui/CHANGELOG.md @@ -6,11 +6,20 @@ This change log covers only the frontend library (webui) of Open VSX. ### Added +- Add a home page with hero search, popular searches, a category browser, curated extension rows and get-involved cards +- Add a dedicated search page under `/search` with query, category, sort field and sort order synced to the URL +- Add global keyboard shortcuts +- Add keyboard navigation of search results: `↑`/`↓` +- Add a structured footer +- Add scroll-to-top on forward navigation - Support searching users and managing their roles in the admin dashboard ([#1847](https://github.com/eclipse-openvsx/openvsx/pull/1847)) - Added an extension details page to admin dashboard and user settings ([#1939](https://github.com/eclipse-openvsx/openvsx/pull/1939)) ### Changed +- Redesign the web UI: new navbar with integrated search field, new theme, extension cards, category pills and page layout +- Improve accessibility: visible focus outlines on interactive controls +- Morph the hero search into the navbar search field using the View Transitions API - Token display in generate-token dialog now uses a masked input with show/hide toggle and copy button ([#1966](https://github.com/eclipse-openvsx/openvsx/pull/1966) - Migrate admin dashboard to use `@tanstack/react-query` ([#1917](https://github.com/eclipse-openvsx/openvsx/pull/1917) - Replace formatting from `stylistic` with `prettier` ([#1916](https://github.com/eclipse-openvsx/openvsx/pull/1916)) diff --git a/webui/index.html b/webui/index.html index 0a097be03..821ee92b4 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3,9 +3,6 @@ - - - diff --git a/webui/package.json b/webui/package.json index c32d2edf0..eb43f9128 100644 --- a/webui/package.json +++ b/webui/package.json @@ -41,6 +41,9 @@ "dependencies": { "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", + "@fontsource-variable/geist": "^5.2.9", + "@fontsource-variable/geist-mono": "^5.2.8", + "@fontsource/roboto": "^5.2.10", "@mdit/plugin-alert": "^0.22.3", "@mui/base": "^5.0.0-beta.9", "@mui/icons-material": "^5.15.14", diff --git a/webui/src/components/banner.tsx b/webui/src/components/banner.tsx index 9b199555d..f821f6a60 100644 --- a/webui/src/components/banner.tsx +++ b/webui/src/components/banner.tsx @@ -9,53 +9,75 @@ ********************************************************************************/ import { FunctionComponent, PropsWithChildren } from 'react'; -import { Paper, Grid, Button, Collapse } from '@mui/material'; +import { Box, Button, Collapse, IconButton } from '@mui/material'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded'; +import CloseIcon from '@mui/icons-material/Close'; + +const VARIANTS = { + info: { Icon: InfoOutlinedIcon, bg: 'accentSoft', accent: 'secondary.light' }, + warning: { Icon: WarningAmberRoundedIcon, bg: 'warningSoft', accent: 'warningAccent' } +} as const; export const Banner: FunctionComponent> = props => { - const cardColor = props.theme === 'dark' ? '#fff' : '#000'; - const cardBackground = `${props.color}.${props.theme}`; + const { color = 'info', open, showDismissButton, dismissButtonLabel, dismissButtonOnClick, children } = props; + const { Icon, bg, accent } = VARIANTS[color]; return ( - - + - - - {props.children} - - {props.showDismissButton && ( - + + + {children} + + + {showDismissButton && + (dismissButtonLabel ? ( + - - )} - - + borderRadius: `${theme.shape.borderRadius}px`, + '&:hover': { backgroundColor: 'color-mix(in srgb, currentColor 8%, transparent)' } + })}> + {dismissButtonLabel} + + ) : ( + + + + ))} + ); }; @@ -66,5 +88,4 @@ interface BannerProps { dismissButtonLabel?: string; dismissButtonOnClick?: () => void; color?: 'info' | 'warning'; - theme?: 'light' | 'dark'; } diff --git a/webui/src/components/categories.ts b/webui/src/components/categories.ts new file mode 100644 index 000000000..8beda38e8 --- /dev/null +++ b/webui/src/components/categories.ts @@ -0,0 +1,65 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { FunctionComponent } from 'react'; +import { SvgIconProps } from '@mui/material'; +import DataObjectIcon from '@mui/icons-material/DataObject'; +import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; +import SpellcheckIcon from '@mui/icons-material/Spellcheck'; +import FormatAlignLeftIcon from '@mui/icons-material/FormatAlignLeft'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import PaletteIcon from '@mui/icons-material/Palette'; +import BugReportIcon from '@mui/icons-material/BugReport'; +import AccountTreeIcon from '@mui/icons-material/AccountTree'; +import KeyboardIcon from '@mui/icons-material/Keyboard'; +import ExtensionIcon from '@mui/icons-material/Extension'; +import BarChartIcon from '@mui/icons-material/BarChart'; +import TranslateIcon from '@mui/icons-material/Translate'; +import InsightsIcon from '@mui/icons-material/Insights'; +import ModelTrainingIcon from '@mui/icons-material/ModelTraining'; +import MenuBookIcon from '@mui/icons-material/MenuBook'; +import GridViewIcon from '@mui/icons-material/GridView'; +import { CATEGORIES, ExtensionCategory } from '../extension-registry-types'; + +export const DefaultCategoryIcon: FunctionComponent = GridViewIcon; + +// The category list itself lives in extension-registry-types; this record is +// compile-checked to stay exhaustive when categories are added or removed. +export const CATEGORY_ICONS: Record> = { + AI: AutoAwesomeIcon, + 'Programming Languages': DataObjectIcon, + Snippets: ContentCopyIcon, + Linters: SpellcheckIcon, + Themes: PaletteIcon, + Debuggers: BugReportIcon, + Formatters: FormatAlignLeftIcon, + Keymaps: KeyboardIcon, + 'SCM Providers': AccountTreeIcon, + Other: GridViewIcon, + 'Extension Packs': ExtensionIcon, + 'Language Packs': TranslateIcon, + 'Data Science': InsightsIcon, + 'Machine Learning': ModelTrainingIcon, + Visualization: BarChartIcon, + Notebooks: MenuBookIcon +}; + +const SORTED_CATEGORIES: ExtensionCategory[] = [...CATEGORIES].sort((a, b) => { + if (a === 'Other') return 1; + if (b === 'Other') return -1; + return a.localeCompare(b); +}); + +export function useCategories(): ExtensionCategory[] { + return SORTED_CATEGORIES; +} diff --git a/webui/src/components/category-card.tsx b/webui/src/components/category-card.tsx new file mode 100644 index 000000000..05265ebf6 --- /dev/null +++ b/webui/src/components/category-card.tsx @@ -0,0 +1,63 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { FunctionComponent } from 'react'; +import { Box, ButtonBase, SvgIconProps, Typography } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { cardHoverLift, cardSurface, focusOutline } from './page-primitives'; + +const Root = styled(ButtonBase)(({ theme }) => ({ + ...cardSurface(theme), + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-start', + textAlign: 'left', + overflow: 'hidden', + flexGrow: 1, + maxWidth: '16rem', + gap: '0.625rem', + padding: '0.625rem 0.875rem 0.625rem 0.75rem', + color: theme.palette.text.primary, + transition: 'border-color 0.15s, box-shadow 0.15s, transform 0.15s', + ...cardHoverLift(theme), + ...focusOutline(theme), + '& .MuiTouchRipple-root': { color: theme.palette.secondary.main } +})); + +export interface CategoryCardProps { + label: string; + icon: FunctionComponent; + onClick: () => void; +} + +export const CategoryCard: FunctionComponent = ({ label, icon: Icon, onClick }) => ( + + + + + + {label} + + +); diff --git a/webui/src/components/category-list-item.tsx b/webui/src/components/category-list-item.tsx new file mode 100644 index 000000000..b5cb70832 --- /dev/null +++ b/webui/src/components/category-list-item.tsx @@ -0,0 +1,64 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { FunctionComponent } from 'react'; +import { ButtonBase, SvgIconProps } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { focusOutline } from './page-primitives'; + +const Root = styled(ButtonBase, { + shouldForwardProp: prop => prop !== 'isSelected' +})<{ isSelected: boolean }>(({ theme, isSelected }) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-start', + gap: '0.5rem', + width: '100%', + textAlign: 'left', + padding: '0.4375rem 0.625rem', + borderRadius: theme.shape.borderRadius, + overflow: 'hidden', + fontSize: '0.8125rem', + fontWeight: isSelected ? 600 : 400, + color: isSelected ? theme.palette.secondary.light : theme.palette.text.secondary, + backgroundColor: isSelected ? theme.palette.accentSoft : 'transparent', + marginBottom: '1px', + fontFamily: 'inherit', + transition: 'background 0.14s, color 0.14s', + '&:hover': isSelected + ? {} + : { + backgroundColor: theme.palette.surface3, + color: theme.palette.text.primary + }, + ...focusOutline(theme) +})); + +export interface CategoryListItemProps { + label: string; + icon: FunctionComponent; + isSelected: boolean; + onClick: () => void; +} + +export const CategoryListItem: FunctionComponent = ({ + label, + icon: Icon, + isSelected, + onClick +}) => ( + + + {label} + +); diff --git a/webui/src/components/category-pill.tsx b/webui/src/components/category-pill.tsx new file mode 100644 index 000000000..bf260e67f --- /dev/null +++ b/webui/src/components/category-pill.tsx @@ -0,0 +1,64 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { FunctionComponent, useEffect, useRef } from 'react'; +import { ButtonBase, SvgIconProps } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { accentHover, focusOutline } from './page-primitives'; + +const Root = styled(ButtonBase, { + shouldForwardProp: prop => prop !== 'isSelected' +})<{ isSelected?: boolean }>(({ theme, isSelected }) => ({ + display: 'inline-flex', + alignItems: 'center', + gap: '0.4375rem', + flexShrink: 0, + overflow: 'hidden', + backgroundColor: isSelected ? theme.palette.accentSoft : theme.palette.surface2, + border: `1px solid ${isSelected ? theme.palette.secondary.main : theme.palette.divider}`, + color: isSelected ? theme.palette.secondary.light : theme.palette.text.secondary, + fontSize: '0.8125rem', + fontWeight: isSelected ? 600 : 500, + padding: '0.4375rem 0.8125rem', + borderRadius: '999px', + whiteSpace: 'nowrap', + fontFamily: 'inherit', + transition: 'border-color 0.14s, color 0.14s', + ...(isSelected ? {} : accentHover(theme)), + ...focusOutline(theme) +})); + +export interface CategoryPillProps { + label: string; + icon: FunctionComponent; + isSelected?: boolean; + onClick: () => void; +} + +export const CategoryPill: FunctionComponent = ({ label, icon: Icon, isSelected, onClick }) => { + const ref = useRef(null); + + // Keep the selected pill visible when the row overflows (deep links, home tiles). + useEffect(() => { + if (isSelected) { + ref.current?.scrollIntoView({ block: 'nearest', inline: 'center', behavior: 'smooth' }); + } + }, [isSelected]); + + return ( + + + {label} + + ); +}; diff --git a/webui/src/components/extension-card.tsx b/webui/src/components/extension-card.tsx new file mode 100644 index 000000000..476f72960 --- /dev/null +++ b/webui/src/components/extension-card.tsx @@ -0,0 +1,232 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { forwardRef, FunctionComponent, memo } from 'react'; +import { Link as RouteLink } from 'react-router-dom'; +import { Paper, Typography, Box, Fade, Skeleton } from '@mui/material'; +import { CSSObject, styled, Theme } from '@mui/material/styles'; +import SaveAltIcon from '@mui/icons-material/SaveAlt'; +import { ExtensionDetailRoutes } from '../pages/extension-detail/extension-detail-routes'; +import { SearchEntry } from '../extension-registry-types'; +import { ExtensionIcon } from './extension/extension-icon'; +import { ExtensionRatingStars } from '../pages/extension-detail/extension-rating-stars'; +import { createRoute, formatCompactNumber } from '../utils'; +import { MONO_FONT } from '../default/theme'; +import { GridItemProps } from '../hooks/use-grid-cursor'; +import { cardHoverLift, cardSurface, focusRing } from './page-primitives'; + +// Shared surface + footprint so the card and its skeleton occupy identical space. +const cardLayout = (theme: Theme): CSSObject => ({ + ...cardSurface(theme), + padding: '1.375rem 1rem', + [theme.breakpoints.down('sm')]: { padding: '0.875rem 0.625rem' }, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + height: '100%', + minHeight: '12.875rem' +}); + +const CardRoot = styled(Paper)(({ theme }) => ({ + ...cardLayout(theme), + textAlign: 'center', + cursor: 'pointer', + transition: 'border-color 0.15s, box-shadow 0.15s, transform 0.15s', + ...cardHoverLift(theme), + // Keyboard focus ring mirrors the search field's :focus-within style. + // Ring when the card link is keyboard-focused, or when it is the grid + // cursor and the cursor is visible (see useGridCursor). + 'a:focus-visible &, [data-cursor-visible] a[data-active] &': focusRing(theme) +})); + +const SkeletonRoot = styled(Paper)(({ theme }) => cardLayout(theme)); + +// Only the unknown parts are skeletons; the stars' empty state looks the same loaded or not. +const SkeletonContent: FunctionComponent = () => ( + <> + + + + + + + + + + + + + +); + +/** Loading placeholder matching {@link ExtensionCard}'s footprint. */ +export const ExtensionCardSkeleton: FunctionComponent = () => ( + + + +); + +// The grid cursor props are optional: cards also render outside a cursor grid +// (curated sections, namespace detail). +export interface ExtensionCardProps extends Partial> { + /** + * The extension, or `undefined` for a loading skeleton. Keep a stable key + * across the swap so the fade plays once instead of restarting. + */ + extension?: SearchEntry; + /** Delay before the card fades in, so grids can stagger their cards. */ + fadeDelayMs?: number; + /** When false, the card shows immediately without its entrance fade (e.g. restored from cache on back-nav). */ + appear?: boolean; +} + +export const ExtensionCard = memo( + forwardRef(function ExtensionCard( + { extension, fadeDelayMs = 0, appear = true, ...linkProps }, + ref + ) { + const title = extension?.displayName ?? extension?.name; + const downloadCount = extension ? formatCompactNumber(extension.downloadCount ?? 0) : undefined; + + // One Fade over both states so it runs once and carries through the skeleton → card swap. + return ( + + + {extension ? ( + + + + + + + {title} + + + + {extension.namespace} + + + {extension.version} + + + + + + + {downloadCount !== '0' && ( + + + {downloadCount} + + )} + + + + ) : ( + + + + )} + + + ); + }) +); diff --git a/webui/src/components/extension-list.tsx b/webui/src/components/extension-list.tsx new file mode 100644 index 000000000..0e63a2d54 --- /dev/null +++ b/webui/src/components/extension-list.tsx @@ -0,0 +1,101 @@ +/******************************************************************************** + * Copyright (c) 2019 TypeFox and others + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { FunctionComponent, useContext, useEffect, useMemo, useRef } from 'react'; +import InfiniteScroll from 'react-infinite-scroller'; +import { Box } from '@mui/material'; +import { ExtensionCard } from './extension-card'; +import { ExtensionFilter } from '../extension-registry-service'; +import { useExtensionResultsCursor } from '../hooks/use-extension-results-cursor'; +import { useInfiniteSearch } from '../hooks/use-infinite-search'; +import { MainContext } from '../context'; + +export const ExtensionList: FunctionComponent = ({ filter, onUpdate }) => { + const { handleError } = useContext(MainContext); + const { data, error, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = useInfiniteSearch(filter); + + const extensions = useMemo(() => data?.pages.flatMap(page => page.extensions) ?? [], [data]); + const totalSize = data?.pages[0]?.totalSize ?? 0; + const grid = useExtensionResultsCursor(extensions.length); + + // Report the result count to the parent (shown in the search header). + useEffect(() => { + onUpdate(totalSize); + }, [totalSize, onUpdate]); + + // Surface fetch failures through the app's error handler. + useEffect(() => { + if (error) { + handleError(error); + } + }, [error, handleError]); + + // Fresh filter: put the cursor back on the first card so Enter opens it. + useEffect(() => { + grid.reset(); + }, [filter.query, filter.category, filter.sortBy, filter.sortOrder, grid.reset]); + + const pageSize = filter.size; + const loading = isLoading || isFetchingNextPage; + + // Cards already in the query cache when this list mounts (e.g. returning via + // browser back) render in place without replaying the entrance fade, so the + // page looks as if it was never unmounted. Cards that load afterwards still fade. + const restoredCount = useRef(loading ? 0 : extensions.length).current; + + // Index-keyed slots (the list only appends): loading slots render as + // skeletons that become cards in place, so the fade isn't restarted. + const slotCount = extensions.length + (loading ? pageSize : 0); + const cards = Array.from({ length: slotCount }, (_, idx) => { + const extension = extensions[idx]; + return ( + = restoredCount} + fadeDelayMs={Math.min(idx % pageSize, 5) * 200} + {...(extension ? grid.itemProps(idx) : {})} + /> + ); + }); + + return ( + { + if (hasNextPage && !isFetchingNextPage) { + void fetchNextPage(); + } + }} + hasMore={hasNextPage && !isFetchingNextPage} + threshold={200}> + + {cards} + + + ); +}; + +export interface ExtensionListProps { + filter: ExtensionFilter; + onUpdate: (resultNumber: number) => void; +} diff --git a/webui/src/components/extension-searchfield.tsx b/webui/src/components/extension-searchfield.tsx new file mode 100644 index 000000000..eaa5a0de0 --- /dev/null +++ b/webui/src/components/extension-searchfield.tsx @@ -0,0 +1,171 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { ChangeEvent, ForwardedRef, forwardRef, KeyboardEvent, useCallback, useId, useRef } from 'react'; +import SearchIcon from '@mui/icons-material/Search'; +import ClearIcon from '@mui/icons-material/Close'; +import { IconButton, InputBase, InputBaseComponentProps, Box } from '@mui/material'; +import { alpha, styled } from '@mui/material/styles'; +import { MONO_FONT } from '../default/theme'; +import { focusRing } from './page-primitives'; + +interface ExtensionSearchfieldProps { + onSearchChanged: (s: string) => void; + onSearchSubmit?: (s: string) => void; + searchQuery?: string; + placeholder: string; + hideIconButton?: boolean; + error?: boolean; + autoFocus?: boolean; + viewTransitionName?: string; + inputProps?: InputBaseComponentProps; +} + +const SearchWrap = styled(Box, { + shouldForwardProp: prop => prop !== 'hasError' +})<{ hasError?: boolean }>(({ theme, hasError }) => ({ + flex: 2, + display: 'flex', + alignItems: 'center', + gap: '0.625rem', + border: hasError ? '2px solid' : '1px solid', + borderColor: hasError ? theme.palette.error.main : theme.palette.divider, + borderRadius: '11px', + height: '2.8125rem', + padding: '0 0.8125rem', + backgroundColor: alpha(theme.palette.surface2, 0.7), + backdropFilter: 'blur(2px)', + transition: 'border-color 0.18s, box-shadow 0.18s', + '&:focus-within': focusRing(theme) +})); + +const MonoSlash = styled('span')(({ theme }) => ({ + fontFamily: MONO_FONT, + color: theme.palette.secondary.light, + fontSize: '1.0625rem', + lineHeight: 1, + flexShrink: 0, + userSelect: 'none' +})); + +const SearchInput = styled(InputBase)(({ theme }) => ({ + flex: 1, + fontFamily: MONO_FONT, + fontSize: '0.9375rem', + color: theme.palette.text.primary, + // iOS Safari zooms the viewport on focus when the field's font-size is < 16px; + // keep it at 16px on mobile to suppress that (desktop stays compact at 15px). + [theme.breakpoints.down('sm')]: { fontSize: '1rem' }, + '& input::placeholder': { color: theme.palette.text.primary, opacity: 0.7 }, + '& input::-webkit-search-cancel-button': { display: 'none' } +})); + +export const ExtensionSearchfield = forwardRef( + (props: ExtensionSearchfieldProps, ref: ForwardedRef) => { + const inputRef = useRef(null); + const inputId = useId(); + + // Keep the forwarded ref and the internal ref (used by the clear button) in sync. + const setInputRef = useCallback( + (node: HTMLInputElement | null) => { + inputRef.current = node; + if (typeof ref === 'function') { + ref(node); + } else if (ref) { + ref.current = node; + } + }, + [ref] + ); + + const handleSearchChange = (event: ChangeEvent) => { + props.onSearchChanged(event.target.value); + }; + + const handleSearchButtonClick = () => { + if (props.onSearchSubmit) { + props.onSearchSubmit(props.searchQuery ?? ''); + } + }; + + const handleClear = () => { + props.onSearchChanged(''); + inputRef.current?.focus(); + }; + + // Merged into inputProps because InputBase spreads inputProps after its own + // onKeyDown, so a caller-supplied handler would silently replace Enter-submit. + const handleKeyDown = (event: KeyboardEvent) => { + props.inputProps?.onKeyDown?.(event); + if (event.key === 'Enter' && props.onSearchSubmit && !event.defaultPrevented) { + props.onSearchSubmit(props.searchQuery ?? ''); + } + }; + + return ( + + / + + + {props.searchQuery && ( + + + + )} + {!props.hideIconButton && ( + + + + )} + + ); + } +); + +ExtensionSearchfield.displayName = 'ExtensionSearchfield'; diff --git a/webui/src/components/extension/extension-icon.tsx b/webui/src/components/extension/extension-icon.tsx index 533d31b5a..8078e87f1 100644 --- a/webui/src/components/extension/extension-icon.tsx +++ b/webui/src/components/extension/extension-icon.tsx @@ -12,18 +12,26 @@ *****************************************************************************/ import { FunctionComponent, useContext } from 'react'; -import { Box, SxProps, Theme } from '@mui/material'; +import { Box, Skeleton, SxProps, Theme } from '@mui/material'; import { MainContext } from '../../context'; import { Extension, SearchEntry } from '../../extension-registry-types'; import { useExtensionIcon } from './use-extension-icon'; -/** - * Renders an extension's icon, falling back to the configured default icon - * while the real one loads or when the extension has none. - */ +/** Renders an extension's icon: a skeleton while loading, then the icon or the configured default. */ export const ExtensionIcon: FunctionComponent = ({ extension, alt, sx }) => { const { pageSettings } = useContext(MainContext); - const icon = useExtensionIcon(extension); + const { data: icon, isLoading } = useExtensionIcon(extension); + + if (isLoading) { + // Reset the Skeleton's default height so `aspectRatio` squares it from + // the width; an explicit height in `sx` still wins. + return ( + + ); + } return ( { +export const useExtensionIcon = (extension: Extension | SearchEntry) => { const { service } = useContext(MainContext); const targetPlatform = 'targetPlatform' in extension ? extension.targetPlatform : undefined; - const { data } = useQuery({ + return useQuery({ queryKey: [ 'extension-icon', extension.namespace, @@ -40,27 +39,6 @@ export const useExtensionIcon = (extension: Extension | SearchEntry): string | u const icon = await service.getExtensionIcon(controllerFromSignal(signal), extension); // useQuery forbids `undefined`; normalise the "no icon" case to null. return icon ?? null; - }, - gcTime: 0, - staleTime: 0 - }); - - const previousUrl = useRef(null); - useEffect(() => { - if (previousUrl.current && previousUrl.current !== data) { - URL.revokeObjectURL(previousUrl.current); } - previousUrl.current = data ?? null; - }, [data]); - - useEffect( - () => () => { - if (previousUrl.current) { - URL.revokeObjectURL(previousUrl.current); - } - }, - [] - ); - - return data ?? undefined; + }); }; diff --git a/webui/src/components/kbd-key.tsx b/webui/src/components/kbd-key.tsx new file mode 100644 index 000000000..c4b92da35 --- /dev/null +++ b/webui/src/components/kbd-key.tsx @@ -0,0 +1,50 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { FunctionComponent, PropsWithChildren } from 'react'; +import { Box } from '@mui/material'; +import { MONO_FONT } from '../default/theme'; + +export const KbdKey: FunctionComponent = ({ children }) => ( + + {children} + +); diff --git a/webui/src/components/openvsx-mark.tsx b/webui/src/components/openvsx-mark.tsx new file mode 100644 index 000000000..24d44d862 --- /dev/null +++ b/webui/src/components/openvsx-mark.tsx @@ -0,0 +1,21 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { CSSProperties, FunctionComponent } from 'react'; + +export const OpenVsxMark: FunctionComponent<{ style?: CSSProperties }> = ({ style }) => ( + + + + +); diff --git a/webui/src/components/page-primitives.tsx b/webui/src/components/page-primitives.tsx new file mode 100644 index 000000000..27a4101c9 --- /dev/null +++ b/webui/src/components/page-primitives.tsx @@ -0,0 +1,88 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { Box, Typography } from '@mui/material'; +import { alpha, styled, Theme } from '@mui/material/styles'; + +/** Max width of the centered content column shared by every page section. */ +export const CONTENT_MAX_WIDTH = 1320; + +/** + * Horizontally-centered content column with responsive gutters. The single + * source of truth for page width so sections stay aligned across the app. + */ +export const Section = styled(Box)(({ theme }) => ({ + maxWidth: CONTENT_MAX_WIDTH, + marginInline: 'auto', + paddingInline: '1.75rem', + [theme.breakpoints.down('sm')]: { + paddingInline: '1rem' + } +})); + +/** Small uppercase label used to head sections, columns and sidebars. */ +export const Eyebrow = styled(Typography)(({ theme }) => ({ + fontSize: '0.75rem', + fontWeight: 700, + textTransform: 'uppercase', + letterSpacing: '0.08em', + color: theme.palette.text.disabled +})) as typeof Typography; + +/** + * Elevated surface shared by cards (extensions, categories, panels): paper + * background, hairline border and the card corner radius from the theme. + */ +export const cardSurface = (theme: Theme) => ({ + backgroundColor: theme.palette.background.paper, + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadiusCard +}); + +/** Accent focus ring shared by the search fields and card links. */ +export const focusRing = (theme: Theme, extraShadow?: string) => ({ + borderColor: theme.palette.secondary.main, + boxShadow: `0 0 0 3px ${alpha(theme.palette.secondary.main, 0.16)}${extraShadow ? `, ${extraShadow}` : ''}` +}); + +/** + * Keyboard-focus outline for buttons, pills and links. ButtonBase resets the + * native outline, so anything built on it needs this to be reachable by keyboard. + */ +export const focusOutline = (theme: Theme) => ({ + '&.Mui-focusVisible, &:focus-visible': { + outline: `2px solid ${theme.palette.secondary.main}`, + outlineOffset: '2px' + } +}); + +/** Hover treatment for chips and pills: accent border and text color. Suppressed on touch devices. */ +export const accentHover = (theme: Theme) => ({ + '@media (hover: hover)': { + '&:hover': { + borderColor: theme.palette.secondary.main, + color: theme.palette.secondary.light + } + } +}); + +/** Hover treatment for interactive cards: accent border, shadow and lift. Suppressed on touch devices. */ +export const cardHoverLift = (theme: Theme) => ({ + '@media (hover: hover)': { + '&:hover': { + borderColor: theme.palette.secondary.main, + boxShadow: 'var(--shadow)', + transform: 'translateY(-2px)' + } + } +}); diff --git a/webui/src/components/sanitized-markdown.tsx b/webui/src/components/sanitized-markdown.tsx index 09228c535..98a49513d 100644 --- a/webui/src/components/sanitized-markdown.tsx +++ b/webui/src/components/sanitized-markdown.tsx @@ -18,6 +18,11 @@ import linkIcon from './link-icon'; import { useLocation } from 'react-router-dom'; const Markdown = styled('div')(({ theme }: { theme: Theme }) => ({ + // The container owns the leading gap; the first block shouldn't add its own + // heading/paragraph margin on top of it. + '& > :first-of-type': { + marginTop: 0 + }, '& a': { textDecoration: 'none', color: theme.palette.secondary.main, @@ -66,6 +71,8 @@ const Markdown = styled('div')(({ theme }: { theme: Theme }) => ({ '& table': { borderCollapse: 'collapse', borderSpacing: 0, + display: 'block', + overflowX: 'auto', '& tr, & td, & th': { border: '1px solid #ddd' }, diff --git a/webui/src/components/shortcuts-modal.tsx b/webui/src/components/shortcuts-modal.tsx new file mode 100644 index 000000000..25ad5d0b4 --- /dev/null +++ b/webui/src/components/shortcuts-modal.tsx @@ -0,0 +1,72 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { FunctionComponent } from 'react'; +import { Dialog, DialogTitle, DialogContent, Box, Typography, IconButton } from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; +import { KbdKey } from './kbd-key'; +import { useKeyboardShortcuts } from '../context/keyboard-shortcuts-context'; + +interface ShortcutsModalProps { + open: boolean; + onClose: () => void; +} + +export const ShortcutsModal: FunctionComponent = ({ open, onClose }) => { + const { shortcuts } = useKeyboardShortcuts(); + + return ( + + + Keyboard shortcuts + + + + + {/* This dialog exists to display keys, so it re-shows the globally touch-hidden chips. */} + + {shortcuts.map((s, i) => ( + + {s.label} + {s.key} + + ))} + + + ); +}; diff --git a/webui/src/context/extension-tint-context.tsx b/webui/src/context/extension-tint-context.tsx new file mode 100644 index 000000000..6b48ff499 --- /dev/null +++ b/webui/src/context/extension-tint-context.tsx @@ -0,0 +1,50 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { createContext, FunctionComponent, ReactNode, useContext, useMemo, useState } from 'react'; + +/** + * The tint region an extension detail page declares while mounted. The page + * only describes it; the nav bar compares the depth against its own scroll + * position to decide when to wear the color. + */ +export interface ExtensionTint { + // Gallery color the nav wears while the region backs it, flipping its + // content to the contrast color. Null for default-colored bands, which + // keep the nav on theme colors. + color: string | null; + // Document offset where the region ends; scrolled past it the nav returns + // to theme colors. + depth: number; +} + +const ExtensionTintContext = createContext<{ + tint: ExtensionTint | null; + setTint: (tint: ExtensionTint | null) => void; +}>({ tint: null, setTint: () => {} }); + +// eslint-disable-next-line react-refresh/only-export-components +export function useExtensionTint(): ExtensionTint | null { + return useContext(ExtensionTintContext).tint; +} + +// eslint-disable-next-line react-refresh/only-export-components +export function useSetExtensionTint(): (tint: ExtensionTint | null) => void { + return useContext(ExtensionTintContext).setTint; +} + +export const ExtensionTintProvider: FunctionComponent<{ children: ReactNode }> = ({ children }) => { + const [tint, setTint] = useState(null); + const value = useMemo(() => ({ tint, setTint }), [tint]); + return {children}; +}; diff --git a/webui/src/context/keyboard-shortcuts-context.tsx b/webui/src/context/keyboard-shortcuts-context.tsx new file mode 100644 index 000000000..89de8278e --- /dev/null +++ b/webui/src/context/keyboard-shortcuts-context.tsx @@ -0,0 +1,153 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { + createContext, + FunctionComponent, + PropsWithChildren, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState +} from 'react'; + +export interface ShortcutInfo { + key: string; + label: string; + order: number; +} + +interface ShortcutEntry extends ShortcutInfo { + callback: () => void; +} + +interface KeyboardShortcutsContextValue { + shortcuts: ShortcutInfo[]; + register: (entry: ShortcutEntry) => void; + unregister: (key: string) => void; +} + +const KeyboardShortcutsContext = createContext({ + shortcuts: [], + register: () => {}, + unregister: () => {} +}); + +// eslint-disable-next-line react-refresh/only-export-components +export const useKeyboardShortcuts = () => useContext(KeyboardShortcutsContext); + +export const KeyboardShortcutsProvider: FunctionComponent = ({ children }) => { + const registryRef = useRef>(new Map()); + const [shortcuts, setShortcuts] = useState([]); + + const sync = () => + setShortcuts( + Array.from(registryRef.current.values()) + .map(({ key, label, order }) => ({ key, label, order })) + .sort((a, b) => a.order - b.order) + ); + + const register = useCallback((entry: ShortcutEntry) => { + registryRef.current.set(entry.key, entry); + sync(); + }, []); + + const unregister = useCallback((key: string) => { + registryRef.current.delete(key); + sync(); + }, []); + + // Chord detection: only fire a shortcut when the key was pressed in isolation. + // + // Two subtleties drive the implementation: + // + // 1. Key identity: Shift changes e.key (Shift+/ → e.key='?'), but e.code is + // always the physical key ('Slash'). We track by e.code and store e.key at + // keydown so the correct character is available at keyup even if Shift was + // released in between. + // + // 2. Browser defaults: some browsers handle '/' as a find shortcut on keydown, + // before our keyup fires. When a solo registered key goes down we eagerly + // call e.preventDefault() to block that, then fire the callback on keyup. + // + // 3. Missed keyups: browsers don't always deliver keyup. On macOS a held Meta + // swallows the keyup of the other key (Cmd+C, Cmd+T, …), and losing window + // focus (Alt+Tab, opening a tab) sends the keyup elsewhere. Either leaves + // stale entries in `pressed`/`dirty` that permanently break detection, so we + // flush the transient state on window blur and when Meta is released. + useEffect(() => { + const pressed = new Map(); // code → key captured at keydown + const dirty = new Set(); // codes involved in a chord + + const reset = () => { + pressed.clear(); + dirty.clear(); + }; + + // Ignore keys typed into inputs and keys pressed while a popup owns focus + // (MUI selects/menus/dialogs render as listbox/menu/dialog roles, not native elements). + const shouldIgnore = (target: EventTarget | null) => { + const el = target as HTMLElement; + return ( + el.tagName === 'INPUT' || + el.tagName === 'TEXTAREA' || + el.tagName === 'SELECT' || + el.isContentEditable || + Boolean(el.closest?.('[role="listbox"], [role="menu"], [role="dialog"]')) + ); + }; + + const onKeydown = (e: KeyboardEvent) => { + if (e.repeat || e.key === 'Shift') return; + if (pressed.size > 0) { + pressed.forEach((_, code) => dirty.add(code)); + dirty.add(e.code); + } else if (!shouldIgnore(e.target) && registryRef.current.has(e.key)) { + e.preventDefault(); + } + pressed.set(e.code, e.key); + }; + + const onKeyup = (e: KeyboardEvent) => { + if (e.key === 'Shift') return; + // Meta chords swallow the keyup of the other key, so once Meta is + // released flush anything still held to avoid stale state. + if (e.key === 'Meta') return reset(); + const isDirty = dirty.has(e.code); + const key = pressed.get(e.code) ?? e.key; + pressed.delete(e.code); + dirty.delete(e.code); + if (isDirty || shouldIgnore(e.target)) return; + const entry = registryRef.current.get(key); + if (entry) entry.callback(); + }; + + document.addEventListener('keydown', onKeydown); + document.addEventListener('keyup', onKeyup); + window.addEventListener('blur', reset); + return () => { + document.removeEventListener('keydown', onKeydown); + document.removeEventListener('keyup', onKeyup); + window.removeEventListener('blur', reset); + }; + }, []); + + return ( + ({ shortcuts, register, unregister }), [shortcuts, register, unregister])}> + {children} + + ); +}; diff --git a/webui/src/context/search/page-search-bar-context.tsx b/webui/src/context/search/page-search-bar-context.tsx new file mode 100644 index 000000000..657289297 --- /dev/null +++ b/webui/src/context/search/page-search-bar-context.tsx @@ -0,0 +1,110 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { + createContext, + FunctionComponent, + ReactNode, + RefObject, + useCallback, + useContext, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react'; + +/** + * Tracks whether a page-level search bar (e.g. the home hero) is active — the + * nav bar derives its own field's visibility from it. + */ + +export interface PageSearchBarValue { + // A page-level search bar is registered — the nav bar hides its own field. + hasPageSearchBar: boolean; + // Synchronous read — `hasPageSearchBar` lags one render when a bar (un)registers + // in the same commit that emits a focus signal. + isPageSearchBarActive: () => boolean; + registerPageSearchBar: () => () => void; +} + +const PageSearchBarContext = createContext({ + hasPageSearchBar: false, + isPageSearchBarActive: () => false, + registerPageSearchBar: () => () => {} +}); + +// eslint-disable-next-line react-refresh/only-export-components +export function usePageSearchBar(): PageSearchBarValue { + return useContext(PageSearchBarContext); +} + +/** + * Marks the calling component as the page's search bar, so the nav bar hides its + * own field and hands focus requests over. Registered while mounted, or — when + * `visibilityRef` is given — only while that element is in the viewport. Returns + * whether the bar is currently registered; callers gate focus handling and their + * view-transition name on it. + */ +// eslint-disable-next-line react-refresh/only-export-components +export function useRegisterPageSearchBar(visibilityRef?: RefObject): boolean { + const { registerPageSearchBar } = usePageSearchBar(); + const [registered, setRegistered] = useState(true); + // Layout effect so the count is updated before other layout effects in the same commit. + useLayoutEffect(() => { + const el = visibilityRef?.current; + if (!el) { + return registerPageSearchBar(); + } + let unregister: (() => void) | undefined; + const update = (visible: boolean) => { + if (visible && !unregister) { + unregister = registerPageSearchBar(); + } else if (!visible && unregister) { + unregister(); + unregister = undefined; + } + setRegistered(visible); + }; + // Seed synchronously — the observer's first callback is async and the nav field would flash. + const rect = el.getBoundingClientRect(); + update(rect.bottom > 0 && rect.top < window.innerHeight && rect.right > 0 && rect.left < window.innerWidth); + const observer = new IntersectionObserver(entries => update(entries[entries.length - 1].isIntersecting)); + observer.observe(el); + return () => { + observer.disconnect(); + unregister?.(); + }; + }, [registerPageSearchBar, visibilityRef]); + return registered; +} + +export const PageSearchBarProvider: FunctionComponent<{ children: ReactNode }> = ({ children }) => { + // Ref for synchronous reads during a commit; the state mirror drives re-renders. + const pageSearchBarCount = useRef(0); + const [hasPageSearchBar, setHasPageSearchBar] = useState(false); + const registerPageSearchBar = useCallback(() => { + pageSearchBarCount.current++; + setHasPageSearchBar(true); + return () => { + pageSearchBarCount.current--; + setHasPageSearchBar(pageSearchBarCount.current > 0); + }; + }, []); + const isPageSearchBarActive = useCallback(() => pageSearchBarCount.current > 0, []); + const value = useMemo( + () => ({ hasPageSearchBar, isPageSearchBarActive, registerPageSearchBar }), + [hasPageSearchBar, isPageSearchBarActive, registerPageSearchBar] + ); + return {children}; +}; diff --git a/webui/src/context/search/search-context.tsx b/webui/src/context/search/search-context.tsx new file mode 100644 index 000000000..3f969a342 --- /dev/null +++ b/webui/src/context/search/search-context.tsx @@ -0,0 +1,62 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { createContext, FunctionComponent, ReactNode, useContext, useEffect, useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { SearchFocusProvider } from './search-focus-context'; +import { PageSearchBarProvider } from './page-search-bar-context'; + +export interface SearchContextValue { + query: string; + setQuery: (q: string) => void; +} + +// eslint-disable-next-line react-refresh/only-export-components +export const SearchContext = createContext({ + query: '', + setQuery: () => {} +}); + +/** + * The draft query shared by the search fields. Only the fields themselves should + * read this (it changes per keystroke); pages read the applied filter from the + * URL via useSearch instead. + */ +// eslint-disable-next-line react-refresh/only-export-components +export function useSearchQuery(): SearchContextValue { + return useContext(SearchContext); +} + +// Mounts the whole search feature: the persisted query (so the search fields stay +// in sync as the user navigates) plus the focus and page-bar contexts. Separate +// contexts keep re-render scopes tight — the query changes per keystroke, the +// others rarely. The `search` action lives in useSearch. +export const SearchProvider: FunctionComponent<{ children: ReactNode }> = ({ children }) => { + const [query, setQuery] = useState(''); + const [searchParams] = useSearchParams(); + const urlQuery = searchParams.get('q') ?? ''; + + // Keep the fields in sync with URL query changes (back/forward, shared links, category tiles). + useEffect(() => { + setQuery(urlQuery); + }, [urlQuery]); + + const value = useMemo(() => ({ query, setQuery }), [query]); + return ( + + + {children} + + + ); +}; diff --git a/webui/src/context/search/search-focus-context.tsx b/webui/src/context/search/search-focus-context.tsx new file mode 100644 index 000000000..5aa98de16 --- /dev/null +++ b/webui/src/context/search/search-focus-context.tsx @@ -0,0 +1,59 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { createContext, FunctionComponent, ReactNode, useContext, useMemo, useState } from 'react'; +import { GridStep } from '../../hooks/use-grid-cursor'; +import { Signal, useSignal } from '../../hooks/use-signal'; + +/** + * Focus coordination between the search fields and the results grid. Signals are + * emitted by whichever component owns the intent; subscribers react via + * useSignalEffect and focus their own element, so no entry point needs a global + * DOM lookup. + */ + +// Step the results cursor from the search field, or open the card under it. +export type ResultsNavAction = GridStep | 'open'; + +export interface SearchFocusValue { + // Ask the active search field (the page search bar when one is registered, nav bar otherwise) to focus. + searchFocusSignal: Signal; + // Drive the results grid's cursor while focus stays in the search field. + resultsNavigationSignal: Signal; + // Whether the search field has focus — the grid only shows its cursor then. + searchFocused: boolean; + setSearchFocused: (focused: boolean) => void; +} + +const SearchFocusContext = createContext({ + searchFocusSignal: { signal: 0, emit: () => {} }, + resultsNavigationSignal: { signal: 0, emit: () => {} }, + searchFocused: false, + setSearchFocused: () => {} +}); + +// eslint-disable-next-line react-refresh/only-export-components +export function useSearchFocus(): SearchFocusValue { + return useContext(SearchFocusContext); +} + +export const SearchFocusProvider: FunctionComponent<{ children: ReactNode }> = ({ children }) => { + const searchFocusSignal = useSignal(); + const resultsNavigationSignal = useSignal(); + const [searchFocused, setSearchFocused] = useState(false); + const value = useMemo( + () => ({ searchFocusSignal, resultsNavigationSignal, searchFocused, setSearchFocused }), + [searchFocusSignal, resultsNavigationSignal, searchFocused] + ); + return {children}; +}; diff --git a/webui/src/default/default-app.tsx b/webui/src/default/default-app.tsx index 8976c20e4..6db67b827 100644 --- a/webui/src/default/default-app.tsx +++ b/webui/src/default/default-app.tsx @@ -8,6 +8,13 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ +import '@fontsource-variable/geist/index.css'; +import '@fontsource-variable/geist-mono/index.css'; +import '@fontsource/roboto/300.css'; +import '@fontsource/roboto/400.css'; +import '@fontsource/roboto/500.css'; +import '@fontsource/roboto/700.css'; +import '../main.css'; import { createRoot } from 'react-dom/client'; import { useMemo } from 'react'; import { HelmetProvider } from 'react-helmet-async'; @@ -39,8 +46,7 @@ const service = new ExtensionRegistryService(`${location.protocol}//${serverHost export const App = () => { const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)'); const theme = useMemo(() => createDefaultTheme(prefersDarkMode ? 'dark' : 'light'), [prefersDarkMode]); - - const pageSettings = createPageSettings(prefersDarkMode, service.serverUrl); + const pageSettings = useMemo(() => createPageSettings(prefersDarkMode, service.serverUrl), [prefersDarkMode]); return ( diff --git a/webui/src/default/menu-content.tsx b/webui/src/default/menu-content.tsx index e078ad297..aa742baef 100644 --- a/webui/src/default/menu-content.tsx +++ b/webui/src/default/menu-content.tsx @@ -8,30 +8,22 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import { FunctionComponent, PropsWithChildren, useContext, useRef } from 'react'; -import { - Typography, - MenuItem, - Link, - Button, - IconButton, - Accordion, - AccordionSummary, - Avatar, - AccordionDetails -} from '@mui/material'; -import { useLocation, Link as RouteLink } from 'react-router-dom'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import { FunctionComponent, PropsWithChildren, useContext, useRef, useState } from 'react'; +import { Typography, MenuItem, Link, Button, IconButton, Avatar, Menu } from '@mui/material'; +import { useLocation, useNavigate, Link as RouteLink } from 'react-router-dom'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; import GitHubIcon from '@mui/icons-material/GitHub'; import MenuBookIcon from '@mui/icons-material/MenuBook'; -import ForumIcon from '@mui/icons-material/Forum'; import InfoIcon from '@mui/icons-material/Info'; import PublishIcon from '@mui/icons-material/Publish'; import AccountBoxIcon from '@mui/icons-material/AccountBox'; import { UserAvatar } from '../pages/user/avatar'; import { UserSettingsRoutes } from '../pages/user/user-settings-routes'; -import { styled, Theme } from '@mui/material/styles'; +import { alpha, styled, Theme } from '@mui/material/styles'; import { MainContext } from '../context'; +import { KbdKey } from '../components/kbd-key'; +import { useShortcut } from '../hooks/use-shortcut'; +import { focusOutline } from '../components/page-primitives'; import SettingsIcon from '@mui/icons-material/Settings'; import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; import LogoutIcon from '@mui/icons-material/Logout'; @@ -39,6 +31,10 @@ import { AdminDashboardRoutes } from '../pages/admin-dashboard/admin-dashboard-r import { LogoutForm } from '../pages/user/logout'; import { LoginComponent } from './login'; +// Shared destinations so a menu item's link, its keyboard shortcut, and its hint +// can't drift apart. External docs open in the same tab, matching the link click. +const DOCS_URL = 'https://github.com/eclipse/openvsx/wiki'; + //-------------------- Mobile View --------------------// // eslint-disable-next-line react-refresh/only-export-components export const itemIcon = { @@ -59,36 +55,44 @@ export const MenuItemText: FunctionComponent = ({ children }) }; export const MobileUserAvatar: FunctionComponent = () => { - const context = useContext(MainContext); - const user = context.user; + const { user } = useContext(MainContext); const logoutFormRef = useRef(null); + const anchorRef = useRef(null); + const [open, setOpen] = useState(false); + const close = () => setOpen(false); if (!user) { return null; } return ( - - } aria-controls='user-actions' id='user-avatar'> + <> + setOpen(true)} aria-haspopup='menu' aria-expanded={open}> {user.loginName} - - - + + + + {user.loginName} - + Settings {user.role === 'admin' ? ( - + Admin Dashboard @@ -103,8 +107,8 @@ export const MobileUserAvatar: FunctionComponent = () => { - - + + ); }; @@ -131,7 +135,7 @@ export const MobileMenuContent: FunctionComponent = () => { /> ))} {loginProviders && !location.pathname.startsWith(UserSettingsRoutes.ROOT) && ( - + Publish Extension @@ -144,20 +148,12 @@ export const MobileMenuContent: FunctionComponent = () => { Source Code - + Documentation - - - - Slack Workspace - - @@ -172,17 +168,25 @@ export const MobileMenuContent: FunctionComponent = () => { // eslint-disable-next-line react-refresh/only-export-components export const headerItem = ({ theme }: { theme: Theme }) => ({ - margin: theme.spacing(2.5), - color: theme.palette.text.primary, + margin: theme.spacing(0, 0.5), + padding: theme.spacing(1, 1.5), + // Inherited color so the items follow the nav's content color over page + // bands; opacity stands in for the secondary/primary text pair. + color: 'inherit', + opacity: 0.78, textDecoration: 'none', - fontSize: '1.1rem', + fontSize: '0.875rem', fontFamily: theme.typography.fontFamily, - fontWeight: theme.typography.fontWeightLight, - letterSpacing: 1, + fontWeight: 500, + letterSpacing: 0, + borderRadius: `${theme.shape.borderRadius}px`, + transition: 'background 0.14s, opacity 0.14s', '&:hover': { - color: theme.palette.secondary.main, + opacity: 1, + backgroundColor: 'color-mix(in srgb, currentcolor 8%, transparent)', textDecoration: 'none' - } + }, + ...focusOutline(theme) }); // eslint-disable-next-line react-refresh/only-export-components @@ -192,17 +196,46 @@ export const MenuRouteLink = styled(RouteLink)(headerItem); export const DefaultMenuContent: FunctionComponent = () => { const { user, loginProviders } = useContext(MainContext); + const navigate = useNavigate(); + + // Register each shortcut next to the control it drives, sharing the same + // destination so the hint, the click, and the keypress stay in sync. + useShortcut({ key: 'd', label: 'Documentation', order: 2, callback: () => window.location.assign(DOCS_URL) }); + useShortcut({ + key: 'p', + label: 'Publish', + order: 3, + callback: () => navigate(UserSettingsRoutes.EXTENSIONS), + enabled: !!loginProviders + }); + return ( <> - Documentation - - Slack Workspace + + Documentation + d About {loginProviders && ( <> - {user ? ( @@ -210,15 +243,20 @@ export const DefaultMenuContent: FunctionComponent = () => { { + // The default `action.active` gray disappears on tinted chromes. if (href) { return ( - + ); } else { return ( - + ); diff --git a/webui/src/default/openvsx-registry-logo.tsx b/webui/src/default/openvsx-registry-logo.tsx index ee5e5147a..24c027416 100644 --- a/webui/src/default/openvsx-registry-logo.tsx +++ b/webui/src/default/openvsx-registry-logo.tsx @@ -13,7 +13,7 @@ import { FunctionComponent } from 'react'; const OpenVSXLogo: FunctionComponent<{ width: string; height: string; - marginTop: string; + marginTop?: string; prefersDarkMode: boolean; }> = props => { return ( @@ -22,7 +22,8 @@ const OpenVSXLogo: FunctionComponent<{ ); diff --git a/webui/src/default/page-settings.tsx b/webui/src/default/page-settings.tsx index 82051bd10..c23131a12 100644 --- a/webui/src/default/page-settings.tsx +++ b/webui/src/default/page-settings.tsx @@ -8,83 +8,141 @@ * SPDX-License-Identifier: EPL-2.0 ********************************************************************************/ -import { FunctionComponent, ReactNode, useContext } from 'react'; +import { FunctionComponent, ReactNode } from 'react'; import { Helmet } from 'react-helmet-async'; -import { styled, Theme } from '@mui/material/styles'; -import { Link, Typography, Box } from '@mui/material'; +import { Typography, Box } from '@mui/material'; import { Link as RouteLink, Route, useParams } from 'react-router-dom'; import GitHubIcon from '@mui/icons-material/GitHub'; +import CallSplitIcon from '@mui/icons-material/CallSplit'; +import BugReportIcon from '@mui/icons-material/BugReport'; +import MenuBookIcon from '@mui/icons-material/MenuBook'; import { Extension, NamespaceDetails } from '../extension-registry-types'; import { PageSettings } from '../page-settings'; import { ExtensionListRoutes } from '../pages/extension-list/extension-list-routes'; import { DefaultMenuContent, MobileMenuContent } from './menu-content'; +import { OpenVsxMark } from '../components/openvsx-mark'; import OpenVSXLogo from './openvsx-registry-logo'; import About from './about'; import { createAbsoluteURL } from '../utils'; -import { MainContext } from '../context'; + +const WIKI_URL = 'https://github.com/eclipse-openvsx/openvsx/wiki'; +const REPO_URL = 'https://github.com/eclipse-openvsx/openvsx'; +const ISSUES_URL = `${REPO_URL}/issues`; export default function createPageSettings(prefersDarkMode: boolean, serverUrl: string): PageSettings { const toolbarContent: FunctionComponent = () => ( - - + + ); - const link = ({ theme }: { theme: Theme }) => ({ - color: theme.palette.text.primary, - textDecoration: 'none', - '&:hover': { - color: theme.palette.secondary.main, - textDecoration: 'none' - } - }); - - const StyledRouteLink = styled(RouteLink)(link); + const footer: PageSettings['elements']['footer'] = { + brand: { + logo: , + name: 'Open VSX Registry', + description: 'An open-source, vendor-neutral registry for VS Code–compatible extensions.' + }, + columns: [ + { + heading: 'Resources', + links: [{ label: 'Documentation', href: WIKI_URL }] + }, + { + heading: 'Community', + links: [ + { label: 'GitHub', href: REPO_URL, external: true }, + { label: 'About This Service', href: '/about' } + ] + } + ], + social: [{ title: 'GitHub', href: REPO_URL, icon: }], + copyright: 'Copyright © Eclipse Foundation, AISBL. All Rights Reserved.' + }; - const ServerVersion: FunctionComponent = () => { - const { version } = useContext(MainContext); - if (!version) { - return
Loading version...
; + const home: PageSettings['elements']['home'] = { + popularSearches: ['python', 'git', 'docker', 'prettier', 'eslint', 'rust', 'java'], + involvement: { + heading: 'Get Involved', + cards: [ + { + icon: , + title: 'Contribute', + description: 'Open VSX is fully open source. Help build the registry the ecosystem depends on.', + href: REPO_URL, + label: 'View on GitHub →' + }, + { + icon: , + title: 'Report an issue', + description: 'Found a bug or have a feature request? Open an issue and help improve the registry.', + href: ISSUES_URL, + label: 'Open an issue →' + }, + { + icon: , + title: 'Read the docs', + description: 'Learn how to publish, claim namespaces, and consume extensions via the API.', + href: WIKI_URL, + label: 'View documentation →' + } + ] } - return ( - - Server Version: {version.version} - - ); }; - const footerContent: FunctionComponent<{ expanded: boolean }> = () => ( - - ({ - ...link({ theme }), - display: 'flex', + const searchHeader: FunctionComponent = () => ( + + - -  eclipse/openvsx - - - About This Service + gap: '0.5rem', + px: '0.8125rem', + py: '0.375rem', + borderRadius: '999px', + bgcolor: 'accentSoft', + color: 'secondary.light', + fontSize: '0.75rem', + fontWeight: 600, + mb: 3 + }}> + + Open-source registry for VS Code–compatible editors + + + Find the right extension, +
+ for any editor. +
+ + Browse community-published extensions.
+ Free, open, and vendor-neutral. +
); - const searchHeader: FunctionComponent = () => ( - - Extensions for VS Code Compatible Editors - - ); - const additionalRoutes: ReactNode = } />; const headTags: FunctionComponent<{ title: string }> = props => { @@ -128,12 +186,8 @@ export default function createPageSettings(prefersDarkMode: boolean, serverUrl: toolbarContent, defaultMenuContent: DefaultMenuContent, mobileMenuContent: MobileMenuContent, - footer: { - content: footerContent, - props: { - footerHeight: 69 // Maximal height reached for small screens - } - }, + footer, + home, searchHeader, additionalRoutes, mainHeadTags, diff --git a/webui/src/default/theme.tsx b/webui/src/default/theme.tsx index 935d9003d..a04365e2c 100644 --- a/webui/src/default/theme.tsx +++ b/webui/src/default/theme.tsx @@ -10,8 +10,23 @@ import { CSSProperties } from 'react'; import { createTheme, Theme } from '@mui/material'; +import type {} from '@mui/x-data-grid/themeAugmentation'; + +export const MONO_FONT = "'Geist Mono', monospace"; +export const NAVBAR_HEIGHT = '3.875rem'; +// Pixel twin for scroll math (rem values assume the 16px root font size). +export const NAVBAR_HEIGHT_PX = parseFloat(NAVBAR_HEIGHT) * 16; + +// Shared look of floating surfaces (menus, popovers, dialogs). The nested selector +// outranks MuiPaper's own rounded style, so no !important is needed. +const floatingPaper = (theme: Theme) => ({ + border: `1px solid ${theme.palette.divider}`, + boxShadow: 'var(--shadow-lg)', + backgroundColor: theme.palette.background.paper, + backgroundImage: 'none', + '&.MuiPaper-rounded': { borderRadius: theme.shape.borderRadiusCard } +}); -// Shared type definitions for palette extensions type Color = CSSProperties['color']; interface StatusColors { @@ -48,7 +63,6 @@ interface UnenforcedColors { stripe: string; } -// Shared shape for custom palette properties interface CustomPaletteColors { neutral: NeutralColors; textHint: Color; @@ -64,6 +78,14 @@ interface CustomPaletteColors { scanBackground: ScanBackgroundColors; gray: GrayColors; unenforced: UnenforcedColors; + // Surface tiers not covered by MUI's background.paper + surface2: string; + surface3: string; + border2: string; + accentSoft: string; + warningSoft: string; + warningAccent: string; + bg2: string; } declare module '@mui/material/styles/createPalette' { @@ -74,52 +96,93 @@ declare module '@mui/material/styles/createPalette' { textHint: Color; } } + +declare module '@mui/system/createTheme/shape' { + interface Shape { + borderRadiusCard: number; + } +} + export default function createDefaultTheme(themeType: 'light' | 'dark'): Theme { + const dark = themeType === 'dark'; return createTheme({ + typography: { + fontFamily: "'Geist', 'Roboto', system-ui, -apple-system, sans-serif" + }, + shape: { + borderRadius: 9, + borderRadiusCard: 14 + }, + mixins: { + toolbar: { minHeight: NAVBAR_HEIGHT } + }, palette: { + mode: themeType, + // Standard MUI palette — these replace the matching CSS vars + background: { + default: dark ? '#0c0c11' : '#ffffff', + paper: dark ? '#15151d' : '#ffffff' + }, + text: { + primary: dark ? '#ededf2' : '#16161c', + secondary: dark ? '#b2b2bf' : '#54545f', + disabled: dark ? '#7a7a87' : '#8c8c98' + }, + divider: dark ? '#262630' : '#e9e9ee', primary: { - main: themeType === 'dark' ? '#eeeeee' : '#444', - dark: themeType === 'dark' ? '#f4f4f4' : '#565157' + main: dark ? '#ededf2' : '#16161c', + dark: dark ? '#f4f4f4' : '#0d0d11' }, secondary: { - main: themeType === 'dark' ? '#c160ef' : '#a60ee5', - contrastText: '#edf5ea' + main: dark ? '#a855f7' : '#8b1fd6', + dark: dark ? '#9333ea' : '#7916bd', + light: dark ? '#c084fc' : '#8b1fd6', // accent-fg + contrastText: '#ffffff' }, + // Custom surface tiers + bg2: dark ? '#101016' : '#f7f7f9', + surface2: dark ? '#1a1a23' : '#fafafa', + surface3: dark ? '#20202b' : '#f2f2f5', + border2: dark ? '#1d1d26' : '#f0f0f3', + accentSoft: dark ? '#291a3d' : '#f4e9fd', + warningSoft: dark ? '#3a2c14' : '#fdf3e2', + warningAccent: dark ? '#fbbf24' : '#d97706', + // Legacy admin palette neutral: { - light: themeType === 'dark' ? '#000' : '#e6e6e6', - dark: themeType === 'dark' ? '#151515' : '#fff' + light: dark ? '#000' : '#e6e6e6', + dark: dark ? '#151515' : '#fff' }, textHint: 'rgba(0, 0, 0, 0.38)', - checkboxUnchecked: themeType === 'dark' ? 'rgba(255, 255, 255, 0.23)' : 'rgba(0, 0, 0, 0.23)', + checkboxUnchecked: dark ? 'rgba(255, 255, 255, 0.23)' : 'rgba(0, 0, 0, 0.23)', passed: { - dark: themeType === 'dark' ? '#2e5c32' : '#4db052', - light: themeType === 'dark' ? '#a5d6a7' : '#c8e6c9' + dark: dark ? '#2e5c32' : '#4db052', + light: dark ? '#a5d6a7' : '#c8e6c9' }, quarantined: { - dark: themeType === 'dark' ? '#8e5518' : '#e09030', - light: themeType === 'dark' ? '#ffcc80' : '#ffe0b2' + dark: dark ? '#8e5518' : '#e09030', + light: dark ? '#ffcc80' : '#ffe0b2' }, rejected: { - dark: themeType === 'dark' ? '#7d2e2e' : '#d63c3c', - light: themeType === 'dark' ? '#ef9a9a' : '#ffcdd2' + dark: dark ? '#7d2e2e' : '#d63c3c', + light: dark ? '#ef9a9a' : '#ffcdd2' }, errorStatus: { - dark: themeType === 'dark' ? '#5a5a5a' : '#8a8a8a', - light: themeType === 'dark' ? '#b0b0b0' : '#e0e0e0' + dark: dark ? '#5a5a5a' : '#8a8a8a', + light: dark ? '#b0b0b0' : '#e0e0e0' }, allowed: '#4caf50', blocked: '#f44336', review: '#e6a800', selected: { border: '#c160ef', - background: themeType === 'dark' ? '#3d1b4d' : '#f3e5f9', - backgroundHover: themeType === 'dark' ? '#4d2360' : '#e9d5f5', - hover: themeType === 'dark' ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.04)' + background: dark ? '#3d1b4d' : '#f3e5f9', + backgroundHover: dark ? '#4d2360' : '#e9d5f5', + hover: dark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.04)' }, scanBackground: { - default: themeType === 'dark' ? '#1e1e1e' : '#f5f5f5', - light: themeType === 'dark' ? '#2d2d2d' : '#f0f0f0', - dark: themeType === 'dark' ? '#0a0a0a' : '#fafafa' + default: dark ? '#1e1e1e' : '#f5f5f5', + light: dark ? '#2d2d2d' : '#f0f0f0', + dark: dark ? '#0a0a0a' : '#fafafa' }, gray: { start: '#888888', @@ -128,20 +191,131 @@ export default function createDefaultTheme(themeType: 'light' | 'dark'): Theme { gradient: 'linear-gradient(90deg, #888888 0%, #cccccc 50%, #888888 100%)' }, unenforced: { - stripe: - themeType === 'dark' - ? 'repeating-linear-gradient(-45deg, transparent, transparent 4px, rgba(255, 255, 255, 0.12) 4px, rgba(255, 255, 255, 0.12) 8px)' - : 'repeating-linear-gradient(-45deg, transparent, transparent 4px, rgba(0, 0, 0, 0.12) 4px, rgba(0, 0, 0, 0.12) 8px)' - }, - mode: themeType + stripe: dark + ? 'repeating-linear-gradient(-45deg, transparent, transparent 4px, rgba(255, 255, 255, 0.12) 4px, rgba(255, 255, 255, 0.12) 8px)' + : 'repeating-linear-gradient(-45deg, transparent, transparent 4px, rgba(0, 0, 0, 0.12) 4px, rgba(0, 0, 0, 0.12) 8px)' + } }, breakpoints: { - values: { - xs: 340, - sm: 550, - md: 800, - lg: 1040, - xl: 1240 + values: { xs: 0, sm: 550, md: 800, lg: 1040, xl: 1240 } + }, + components: { + MuiAccordion: { + styleOverrides: { + root: { + border: 0, + boxShadow: 'none', + background: 'transparent', + '&:before': { display: 'none' } + } + } + }, + MuiButton: { + styleOverrides: { + root: { textTransform: 'none' } + } + }, + // MUI X derives the grid's borders from `divider` via lighten/darken, which + // almost erases our already-light opaque divider. Feed the grid's design + // tokens directly; `&.MuiDataGrid-root` outranks the injected + // `.MuiDataGridVariables-*` class that carries the derived defaults. + MuiDataGrid: { + styleOverrides: { + root: ({ theme }) => ({ + '&.MuiDataGrid-root': { + '--DataGrid-t-color-border-base': theme.palette.divider, + '--DataGrid-t-header-background-base': theme.palette.bg2, + '--DataGrid-t-radius-base': `${theme.shape.borderRadiusCard}px`, + '--DataGrid-t-color-interactive-focus': theme.palette.secondary.main, + '--DataGrid-t-color-interactive-selected': theme.palette.secondary.main, + '--DataGrid-t-color-foreground-accent': theme.palette.secondary.light + } + }), + columnHeaderTitle: ({ theme }) => ({ + fontSize: '0.8125rem', + fontWeight: 600, + color: theme.palette.text.secondary + }) + } + }, + MuiTableCell: { + styleOverrides: { + root: ({ theme }) => ({ borderBottomColor: theme.palette.divider }) + } + }, + MuiTabs: { + styleOverrides: { + indicator: ({ theme }) => ({ + backgroundColor: theme.palette.secondary.main, + height: '2px' + }) + } + }, + MuiTab: { + styleOverrides: { + root: ({ theme }) => ({ + fontSize: '0.875rem', + fontWeight: 600, + textTransform: 'none', + color: theme.palette.text.disabled, + minHeight: '3.25rem', + padding: '0.9375rem 1rem', + '&.Mui-selected': { color: theme.palette.text.primary } + }) + } + }, + // Menu and Select popups inherit the floating look from MuiPopover. + MuiMenu: { + styleOverrides: { + paper: { marginTop: '0.375rem' }, + list: { padding: '0.375rem' } + } + }, + MuiMenuItem: { + styleOverrides: { + root: ({ theme }) => ({ + borderRadius: theme.shape.borderRadius, + fontSize: '0.875rem', + fontWeight: 500, + minHeight: '2.25rem' + }) + } + }, + MuiTypography: { + styleOverrides: { + button: { + textTransform: 'none', + fontWeight: 500, + letterSpacing: 0 + }, + overline: { + textTransform: 'none', + letterSpacing: 0, + lineHeight: 1.4 + } + } + }, + MuiPopover: { + styleOverrides: { + paper: ({ theme }) => floatingPaper(theme) + } + }, + MuiDialog: { + styleOverrides: { + paper: ({ theme }) => floatingPaper(theme) + } + }, + MuiDivider: { + styleOverrides: { + root: ({ theme }) => ({ borderColor: theme.palette.divider }) + } + }, + MuiOutlinedInput: { + styleOverrides: { + notchedOutline: ({ theme }) => ({ + borderColor: theme.palette.divider + }) + } } } }); diff --git a/webui/src/extension-registry-service.ts b/webui/src/extension-registry-service.ts index d96f9a08a..8191333ab 100644 --- a/webui/src/extension-registry-service.ts +++ b/webui/src/extension-registry-service.ts @@ -9,9 +9,10 @@ ********************************************************************************/ import { + CATEGORIES, Extension, - UserData, ExtensionCategory, + UserData, ExtensionReviewList, PersonalAccessToken, SearchResult, @@ -157,7 +158,8 @@ export class ExtensionRegistryService { if (filter.sortOrder) query.push({ key: 'sortOrder', value: filter.sortOrder }); } const endpoint = createAbsoluteURL([this.serverUrl, 'api', '-', 'search'], query); - return sendRequest({ abortController, endpoint }); + // Non-retriable: retries are owned by the TanStack query that calls this. + return sendNonRetriableRequest({ abortController, endpoint }); } async getExtensionDetail( @@ -204,24 +206,9 @@ export class ExtensionRegistryService { }); } + /** @deprecated Use the CATEGORIES constant from extension-registry-types instead. */ getCategories(): ExtensionCategory[] { - return [ - 'Programming Languages', - 'Snippets', - 'Linters', - 'Themes', - 'Debuggers', - 'Formatters', - 'Keymaps', - 'SCM Providers', - 'Other', - 'Extension Packs', - 'Language Packs', - 'Data Science', - 'Machine Learning', - 'Visualization', - 'Notebooks' - ]; + return [...CATEGORIES]; } async getExtensionReviews( @@ -1486,7 +1473,7 @@ export class AdminServiceImpl implements AdminService { export interface ExtensionFilter { query: string; - category: ExtensionCategory | ''; + category: string; size: number; offset: number; sortBy: SortBy; diff --git a/webui/src/extension-registry-types.ts b/webui/src/extension-registry-types.ts index 85fd9975c..4e0243ee4 100644 --- a/webui/src/extension-registry-types.ts +++ b/webui/src/extension-registry-types.ts @@ -206,22 +206,26 @@ export interface PersonalAccessToken { deleteTokenUrl: UrlString; } -export type ExtensionCategory = - | 'Programming Languages' - | 'Snippets' - | 'Linters' - | 'Themes' - | 'Debuggers' - | 'Formatters' - | 'Keymaps' - | 'SCM Providers' - | 'Other' - | 'Extension Packs' - | 'Language Packs' - | 'Data Science' - | 'Machine Learning' - | 'Visualization' - | 'Notebooks'; +export const CATEGORIES = [ + 'AI', + 'Programming Languages', + 'Snippets', + 'Linters', + 'Themes', + 'Debuggers', + 'Formatters', + 'Keymaps', + 'SCM Providers', + 'Other', + 'Extension Packs', + 'Language Packs', + 'Data Science', + 'Machine Learning', + 'Visualization', + 'Notebooks' +] as const; + +export type ExtensionCategory = (typeof CATEGORIES)[number]; export interface CsrfTokenJson { value: string; diff --git a/webui/src/header-menu.tsx b/webui/src/header-menu.tsx index ab4fa6746..6cc81c5a2 100644 --- a/webui/src/header-menu.tsx +++ b/webui/src/header-menu.tsx @@ -44,6 +44,8 @@ export const MobileHeaderMenu: FunctionComponent = props { setAnchorEl(event.currentTarget); setOpen(!open); diff --git a/webui/src/hooks/use-debounced-callback.ts b/webui/src/hooks/use-debounced-callback.ts index 51bff5d83..5b55a3efd 100644 --- a/webui/src/hooks/use-debounced-callback.ts +++ b/webui/src/hooks/use-debounced-callback.ts @@ -11,26 +11,48 @@ * SPDX-License-Identifier: EPL-2.0 *****************************************************************************/ -import { useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; const DEFAULT_DELAY_MS = 300; +export interface DebouncedCallback void> { + (...args: Parameters): void; + // Drop any pending invocation. + cancel: () => void; +} + /** * Returns a stable function that delays invoking `callback` until `delay` ms * have elapsed since the last call. Always invokes the most recent `callback`. + * The returned function also exposes `cancel` to drop a pending invocation. */ export function useDebouncedCallback void>( callback: T, delay: number = DEFAULT_DELAY_MS -): (...args: Parameters) => void { +): DebouncedCallback { const callbackRef = useRef(callback); callbackRef.current = callback; - return useMemo(() => { + const debounced = useMemo(() => { let timer: ReturnType | undefined; - return (...args: Parameters) => { + + const fn = ((...args: Parameters) => { + clearTimeout(timer); + timer = setTimeout(() => { + timer = undefined; + callbackRef.current(...args); + }, delay); + }) as DebouncedCallback; + + fn.cancel = () => { clearTimeout(timer); - timer = setTimeout(() => callbackRef.current(...args), delay); + timer = undefined; }; + + return fn; }, [delay]); + + useEffect(() => debounced.cancel, [debounced]); + + return debounced; } diff --git a/webui/src/hooks/use-extension-results-cursor.ts b/webui/src/hooks/use-extension-results-cursor.ts new file mode 100644 index 000000000..fc7e2c3fe --- /dev/null +++ b/webui/src/hooks/use-extension-results-cursor.ts @@ -0,0 +1,48 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { useCallback } from 'react'; +import { ResultsNavAction, useSearchFocus } from '../context/search/search-focus-context'; +import { GridCursor, useGridCursor } from './use-grid-cursor'; +import { useSignalEffect } from './use-signal-effect'; + +/** + * Wires the generic grid cursor to the search experience: the cursor ring is + * visible while the search field has focus, the field's arrow keys drive the + * cursor through `resultsNavigationSignal` ('open' clicks the card under it), + * and ArrowUp from the first row hands focus back to the search field. + */ +export function useExtensionResultsCursor(itemCount: number): GridCursor { + const { searchFocusSignal, resultsNavigationSignal, searchFocused } = useSearchFocus(); + const cursor = useGridCursor(itemCount, { + cursorVisible: searchFocused, + onExitTop: searchFocusSignal.emit + }); + + const { move, openActive } = cursor; + useSignalEffect( + resultsNavigationSignal, + useCallback( + (action: ResultsNavAction) => { + if (action === 'open') { + openActive(); + } else { + move(action); + } + }, + [move, openActive] + ) + ); + + return cursor; +} diff --git a/webui/src/hooks/use-grid-cursor.ts b/webui/src/hooks/use-grid-cursor.ts new file mode 100644 index 000000000..9af21548c --- /dev/null +++ b/webui/src/hooks/use-grid-cursor.ts @@ -0,0 +1,208 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { FocusEvent, KeyboardEvent, RefCallback, RefObject, useCallback, useRef, useState } from 'react'; + +type GridDirection = 'up' | 'down' | 'left' | 'right'; + +/** Linear cursor steps in reading order, for driving the cursor from outside the grid. */ +export type GridStep = 'next' | 'previous'; + +const ARROW_KEY_DIRECTIONS: Record = { + ArrowUp: 'up', + ArrowDown: 'down', + ArrowLeft: 'left', + ArrowRight: 'right' +}; + +function getNextIndex(direction: GridDirection, current: number, cols: number, last: number): number { + switch (direction) { + case 'right': + return Math.min(current + 1, last); + case 'left': + return Math.max(current - 1, 0); + case 'down': + return Math.min(current + cols, last); + case 'up': + return current < cols ? current : current - cols; + } +} + +export interface GridCursorOptions { + /** Show the cursor ring on the active item (sets `data-cursor-visible` on the container). */ + cursorVisible?: boolean; + /** Called when ArrowUp is pressed on the first row (e.g. to hand focus back to a search field). */ + onExitTop?: () => void; +} + +export interface GridContainerProps { + ref: RefObject; + onKeyDown: (event: KeyboardEvent) => void; + onFocus: (event: FocusEvent) => void; + 'data-cursor-visible'?: string; +} + +export interface GridItemProps { + ref: RefCallback; + tabIndex: number; + 'data-active'?: string; +} + +export interface GridCursor { + /** Spread onto the CSS grid container. */ + containerProps: GridContainerProps; + /** Spread onto the focusable element of the item at `index`. */ + itemProps: (index: number) => GridItemProps; + /** Step the cursor one item in reading order without moving focus (e.g. while a search field keeps focus). */ + move: (step: GridStep) => void; + /** Click the item under the cursor. */ + openActive: () => void; + /** Put the cursor back on the first item (e.g. when the list is replaced). */ + reset: () => void; +} + +/** + * Cursor for a CSS grid of focusable items. + * + * The active item is both the visible cursor (`data-active`, styled while the + * container has `data-cursor-visible`) and the grid's only tab stop (roving + * tabindex). It moves two ways: arrow keys inside the grid move real focus + * across both axes, while `move()` steps the cursor one item at a time in + * reading order with focus staying wherever it is. The column count is read + * from the container's computed grid tracks, so it adapts to viewport width. + */ +export function useGridCursor(itemCount: number, options: GridCursorOptions = {}): GridCursor { + const { cursorVisible = false, onExitTop } = options; + const containerRef = useRef(null); + const itemRefs = useRef(new Map()); + const refCallbacks = useRef(new Map>()); + const [activeIndex, setActiveIndex] = useState(0); + + // Clamp instead of resetting state so the cursor survives the list shrinking. + const active = itemCount === 0 ? 0 : Math.min(activeIndex, itemCount - 1); + const activeRef = useRef(active); + activeRef.current = active; + + const getColumnCount = useCallback((): number => { + const container = containerRef.current; + if (!container) return 1; + const tracks = getComputedStyle(container).gridTemplateColumns; + return tracks === 'none' ? 1 : tracks.split(' ').length; + }, []); + + const moveTo = useCallback((index: number, opts: { focus: boolean }): void => { + setActiveIndex(index); + const item = itemRefs.current.get(index); + if (!item) return; + if (opts.focus) { + item.focus(); + } else { + item.scrollIntoView({ block: 'nearest' }); + } + }, []); + + const move = useCallback( + (step: GridStep): void => { + if (itemCount === 0) return; + const delta = step === 'next' ? 1 : -1; + const next = Math.min(Math.max(activeRef.current + delta, 0), itemCount - 1); + if (next !== activeRef.current) { + moveTo(next, { focus: false }); + } + }, + [itemCount, moveTo] + ); + + const openActive = useCallback((): void => { + itemRefs.current.get(activeRef.current)?.click(); + }, []); + + const reset = useCallback((): void => setActiveIndex(0), []); + + const onKeyDown = useCallback( + (event: KeyboardEvent): void => { + if (itemCount === 0) return; + const current = activeRef.current; + const last = itemCount - 1; + const cols = getColumnCount(); + + if (event.key === 'ArrowUp' && current < cols && onExitTop) { + event.preventDefault(); + onExitTop(); + return; + } + let next: number; + if (event.key === 'Home') { + next = 0; + } else if (event.key === 'End') { + next = last; + } else { + const direction = ARROW_KEY_DIRECTIONS[event.key]; + if (!direction) return; + next = getNextIndex(direction, current, cols, last); + } + if (next !== current) { + event.preventDefault(); + moveTo(next, { focus: true }); + } + }, + [itemCount, getColumnCount, moveTo, onExitTop] + ); + + // Keep the cursor on whichever item receives real focus (Tab, click). + const onFocus = useCallback((event: FocusEvent): void => { + for (const [index, item] of itemRefs.current) { + if (item === event.target) { + setActiveIndex(index); + return; + } + } + }, []); + + const itemProps = useCallback( + (index: number): GridItemProps => { + let ref = refCallbacks.current.get(index); + if (!ref) { + // Cached per index so a memoized item only re-renders when its + // tabIndex / data-active actually change. + ref = element => { + if (element) { + itemRefs.current.set(index, element); + } else { + itemRefs.current.delete(index); + } + }; + refCallbacks.current.set(index, ref); + } + return { + ref, + tabIndex: index === active ? 0 : -1, + 'data-active': index === active ? '' : undefined + }; + }, + [active] + ); + + return { + containerProps: { + ref: containerRef, + onKeyDown, + onFocus, + 'data-cursor-visible': cursorVisible ? '' : undefined + }, + itemProps, + move, + openActive, + reset + }; +} diff --git a/webui/src/hooks/use-infinite-search.ts b/webui/src/hooks/use-infinite-search.ts new file mode 100644 index 000000000..98bf61464 --- /dev/null +++ b/webui/src/hooks/use-infinite-search.ts @@ -0,0 +1,61 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { useContext } from 'react'; +import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'; +import { MainContext } from '../context'; +import { isError, SearchResult } from '../extension-registry-types'; +import { ExtensionFilter } from '../extension-registry-service'; +import { controllerFromSignal } from '../query-client'; + +/** The filter fields that identify a result set; `offset` is driven by the pages. */ +export type SearchQuery = Omit; + +export const searchKeys = { + list: (query: SearchQuery) => ['search', query] as const +}; + +/** + * Loads search results one offset-paged page at a time. The pages stay in the + * query cache keyed by the filter, so returning to a scrolled search restores + * every loaded page (and thus its scroll position) instead of refetching from + * the first page. `keepPreviousData` keeps the current results on screen while + * a changed filter loads. + */ +export const useInfiniteSearch = (filter: ExtensionFilter) => { + const { service } = useContext(MainContext); + const { query, category, size, sortBy, sortOrder } = filter; + return useInfiniteQuery({ + queryKey: searchKeys.list({ query, category, size, sortBy, sortOrder }), + queryFn: async ({ pageParam, signal }): Promise => { + const result = await service.search(controllerFromSignal(signal), { + query, + category, + size, + sortBy, + sortOrder, + offset: pageParam + }); + if (isError(result)) { + throw result; + } + return result as SearchResult; + }, + initialPageParam: 0, + getNextPageParam: (lastPage, allPages) => { + const loaded = allPages.reduce((sum, page) => sum + page.extensions.length, 0); + return lastPage.extensions.length > 0 && loaded < lastPage.totalSize ? loaded : undefined; + }, + placeholderData: keepPreviousData + }); +}; diff --git a/webui/src/hooks/use-search.ts b/webui/src/hooks/use-search.ts new file mode 100644 index 000000000..2880360d8 --- /dev/null +++ b/webui/src/hooks/use-search.ts @@ -0,0 +1,76 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { useCallback, useMemo } from 'react'; +import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; +import { ExtensionListRoutes } from '../pages/extension-list/extension-list-routes'; +import { ExtensionCategory, SortBy, SortOrder } from '../extension-registry-types'; + +export interface SearchFilter { + query: string; + category: (ExtensionCategory | '') & string; + sortBy: SortBy; + sortOrder: SortOrder; +} + +/** Debounce for the extension search field — short enough to feel instant while still batching fast typing. */ +export const SEARCH_DEBOUNCE_MS = 150; + +// Write only non-default values so shared links stay clean. +function filterToParams({ query, category, sortBy, sortOrder }: SearchFilter): Record { + const params: Record = {}; + if (query) params.q = query; + if (category) params.category = category; + if (sortBy !== 'relevance') params.sortBy = sortBy; + if (sortOrder !== 'desc') params.sortOrder = sortOrder; + return params; +} + +/** The applied search filter (from the URL) and the `search` navigation action. */ +export function useSearch() { + const navigate = useNavigate(); + const { pathname } = useLocation(); + const [searchParams, setSearchParams] = useSearchParams(); + + // Memoized so `filter` and `search` keep their identity between renders of the same URL. + const filter: SearchFilter = useMemo( + () => ({ + query: searchParams.get('q') ?? '', + category: (searchParams.get('category') as ExtensionCategory) ?? '', + sortBy: (searchParams.get('sortBy') as SortBy) ?? 'relevance', + sortOrder: (searchParams.get('sortOrder') as SortOrder) ?? 'desc' + }), + [searchParams] + ); + + // On the search page: patch URL params in place (replace, no new history entry). + // From anywhere else: push a navigation to the search route. + const search = useCallback( + (patch: Partial) => { + const next = { ...filter, ...patch }; + // Trim here so every entry point (nav field, hero, chips) searches the same way. + next.query = next.query.trim(); + const params = filterToParams(next); + + if (pathname === ExtensionListRoutes.SEARCH) { + setSearchParams(params, { replace: true }); + return; + } + + navigate({ pathname: ExtensionListRoutes.SEARCH, search: new URLSearchParams(params).toString() }); + }, + [navigate, pathname, filter, setSearchParams] + ); + + return { search, filter }; +} diff --git a/webui/src/hooks/use-shortcut.ts b/webui/src/hooks/use-shortcut.ts new file mode 100644 index 000000000..c8dfb59a6 --- /dev/null +++ b/webui/src/hooks/use-shortcut.ts @@ -0,0 +1,42 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { useEffect, useRef } from 'react'; +import { useKeyboardShortcuts } from '../context/keyboard-shortcuts-context'; + +export interface UseShortcutOptions { + key: string; + label: string; + callback: () => void; + order?: number; + enabled?: boolean; +} + +/** + * Register a keyboard shortcut for the lifetime of the calling component. + * The shortcut is automatically unregistered when the component unmounts. + * Callback is kept fresh via a ref — no re-registration on re-render. + */ +export function useShortcut({ key, label, callback, order = 99, enabled = true }: UseShortcutOptions): void { + const { register, unregister } = useKeyboardShortcuts(); + + // Keep callback fresh without forcing re-registration on every render + const callbackRef = useRef(callback); + callbackRef.current = callback; + + useEffect(() => { + if (!enabled) return; + register({ key, label, order, callback: () => callbackRef.current() }); + return () => unregister(key); + }, [key, label, order, enabled]); +} diff --git a/webui/src/hooks/use-signal-effect.ts b/webui/src/hooks/use-signal-effect.ts new file mode 100644 index 000000000..3a3ad5f4e --- /dev/null +++ b/webui/src/hooks/use-signal-effect.ts @@ -0,0 +1,38 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { useLayoutEffect, useRef } from 'react'; +import { Signal } from './use-signal'; + +/** + * Runs `effect` whenever `signal` is emitted, skipping the initial mount. Used + * to coordinate imperative actions (e.g. moving focus) across components — a + * component emits the signal to "send" a request and subscribers react without + * any global DOM lookups. If the signal carries a payload, the latest emitted + * value is passed to `effect`. + * + * Uses a layout effect so focus moves synchronously after a `flushSync` + * navigation (keeping the mobile keyboard open during the hero → nav morph). + * Wrap `effect` in `useCallback` so it only re-subscribes when its deps change. + */ +export function useSignalEffect(signal: Signal, effect: (payload: T) => void): void { + const last = useRef(signal.signal); + useLayoutEffect(() => { + if (signal.signal === last.current) { + return; + } + last.current = signal.signal; + // Only reached after an emit, so a carried payload is always set. + effect(signal.payload as T); + }, [signal, effect]); +} diff --git a/webui/src/hooks/use-signal.ts b/webui/src/hooks/use-signal.ts new file mode 100644 index 000000000..24502bf55 --- /dev/null +++ b/webui/src/hooks/use-signal.ts @@ -0,0 +1,36 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { useCallback, useMemo, useState } from 'react'; + +export interface Signal { + /** Monotonically increasing counter; bumped on every emit. */ + signal: number; + /** Value passed to the latest emit, if the signal carries one. */ + payload?: T; + /** Broadcast the signal to subscribers. */ + emit: (payload: T) => void; +} + +/** + * Creates a one-off broadcast channel backed by a monotonically increasing + * counter. A component holding the signal calls `emit()` to fire it, and any + * number of subscribers react through `useSignalEffect` — coordinating + * imperative actions (e.g. moving focus) across components without global DOM + * lookups. Share the returned value through context to reach other components. + */ +export function useSignal(): Signal { + const [state, setState] = useState<{ signal: number; payload?: T }>({ signal: 0 }); + const emit = useCallback((payload: T) => setState(s => ({ signal: s.signal + 1, payload })), []); + return useMemo(() => ({ signal: state.signal, payload: state.payload, emit }), [state, emit]); +} diff --git a/webui/src/index.ts b/webui/src/index.ts index 1b754e9e3..ea90f90c1 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -13,6 +13,42 @@ export * from './page-settings'; export * from './extension-registry-service'; export * from './extension-registry-types'; export * from './pages/extension-detail/extension-detail'; -export * from './pages/extension-list/extension-list'; +export * from './components/extension-list'; export * from './pages/namespace-detail/namespace-detail'; export * from './pages/user/user-settings'; + +export * from './components/kbd-key'; +export * from './components/openvsx-mark'; + +// Building blocks of the built-in home page, for composing custom home pages. +export { HeroSearch, type HeroSearchProps } from './pages/home/hero-search'; +export { BrowseCategories, type BrowseCategoriesProps } from './pages/home/browse-categories'; +export { CuratedSections, type CuratedSectionsProps } from './pages/home/curated-sections'; +export { GetInvolved, type GetInvolvedProps } from './pages/home/get-involved'; +export { + useHomeCategories, + useCuratedRows, + type CuratedRow, + DEFAULT_CURATED_SECTIONS +} from './pages/home/use-home-data'; +export { ExtensionCard, type ExtensionCardProps } from './components/extension-card'; +export * from './components/page-primitives'; +// Leaf hook modules keep their helpers private, so `export *` exposes only the +// public hook plus its types (e.g. useSearch + SearchFilter). +export * from './hooks/use-search'; +export { useRegisterPageSearchBar } from './context/search/page-search-bar-context'; + +// Keyboard shortcuts: register shortcuts from custom pages/components. The hook +// only takes effect below a KeyboardShortcutsProvider — the built-in AppLayout +// mounts one, so custom layouts need to mount their own. +export { useShortcut, type UseShortcutOptions } from './hooks/use-shortcut'; +export { + KeyboardShortcutsProvider, + useKeyboardShortcuts, + type ShortcutInfo +} from './context/keyboard-shortcuts-context'; + +// Signal: lightweight cross-component coordination primitive. Create one with +// useSignal, subscribe with useSignalEffect. +export * from './hooks/use-signal'; +export * from './hooks/use-signal-effect'; diff --git a/webui/src/layout/app-footer.tsx b/webui/src/layout/app-footer.tsx new file mode 100644 index 000000000..d553a81d9 --- /dev/null +++ b/webui/src/layout/app-footer.tsx @@ -0,0 +1,222 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { FunctionComponent, useContext, useState } from 'react'; +import { Box, Typography } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import type { Theme } from '@mui/material/styles'; +import { Link as RouteLink } from 'react-router-dom'; +import { KbdKey } from '../components/kbd-key'; +import { useShortcut } from '../hooks/use-shortcut'; +import { Section, Eyebrow, accentHover, focusOutline } from '../components/page-primitives'; +import { MONO_FONT } from '../default/theme'; +import { CustomFooterSettings, StructuredFooterSettings } from '../page-settings'; +import { MainContext } from '../context'; + +const footerLinkStyles = ({ theme }: { theme: Theme }) => ({ + fontSize: '0.8125rem', + color: theme.palette.text.secondary, + textDecoration: 'none', + display: 'block', + '&:hover': { color: theme.palette.secondary.light }, + ...focusOutline(theme) +}); + +const FooterLink = styled('a')(footerLinkStyles); +const FooterRouteLink = styled(RouteLink)(footerLinkStyles); + +const SocialIconButton = styled('a')(({ theme }) => ({ + width: 34, + height: 34, + borderRadius: theme.shape.borderRadius, + border: `1px solid ${theme.palette.divider}`, + backgroundColor: theme.palette.background.paper, + color: theme.palette.text.secondary, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + textDecoration: 'none', + transition: 'border-color 0.14s, color 0.14s', + ...accentHover(theme), + ...focusOutline(theme) +})); + +const ShortcutsButton = styled('button')(({ theme }) => ({ + background: 'none', + border: 'none', + cursor: 'pointer', + padding: 0, + display: 'flex', + alignItems: 'center', + gap: '0.5rem', + fontSize: '0.75rem', + color: theme.palette.text.disabled, + '&:hover': { color: theme.palette.secondary.light }, + ...focusOutline(theme) +})); + +/** Legacy footer chrome: fixed to the viewport bottom as on pre-redesign layouts, hover toggles `expanded`. */ +const LegacyFooterRoot = styled('footer')(({ theme }) => ({ + position: 'fixed', + bottom: 0, + zIndex: 50, + width: '100%', + padding: `${theme.spacing(1)} ${theme.spacing(2)}`, + backgroundColor: theme.palette.background.paper, + boxShadow: '0px -2px 6px 0px rgba(0, 0, 0, 0.5)', + backgroundImage: + theme.palette.mode === 'dark' + ? 'linear-gradient(rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.08))' + : undefined +})); + +const LegacyFooter: FunctionComponent<{ footer: CustomFooterSettings }> = ({ footer }) => { + const Content = footer.content; + const [expanded, setExpanded] = useState(false); + return ( + setExpanded(true)} onMouseLeave={() => setExpanded(false)}> + + + ); +}; + +export interface AppFooterProps { + onOpenShortcuts: () => void; +} + +export const AppFooter: FunctionComponent = ({ onOpenShortcuts }) => { + const { pageSettings, version } = useContext(MainContext); + const footer = pageSettings.elements.footer; + + // The shortcuts button (and its ? hint) only renders in the structured footer, + // so register the shortcut here and disable it for custom/legacy footers. + const isStructuredFooter = typeof footer !== 'function' && !(footer && 'content' in footer); + useShortcut({ + key: '?', + label: 'Show keyboard shortcuts', + order: 0, + callback: onOpenShortcuts, + enabled: isStructuredFooter + }); + + if (typeof footer === 'function') { + const CustomFooter = footer; + return ; + } + if (footer && 'content' in footer) { + return ; + } + + return ; +}; + +interface StructuredFooterProps { + footer?: StructuredFooterSettings; + version: string | null; + onOpenShortcuts: () => void; +} + +const StructuredFooter: FunctionComponent = ({ footer, version, onOpenShortcuts }) => ( + + {footer && (footer.brand || footer.columns) && ( +
+ {footer.brand && ( + + + {footer.brand.logo} + {footer.brand.name} + + {footer.brand.description && ( + + {footer.brand.description} + + )} + {footer.social && footer.social.length > 0 && ( + + {footer.social.map(s => ( + + {s.icon} + + ))} + + )} + + )} + {footer.columns?.map((column, ci) => ( + + {column.heading} + + {column.links.map((l, li) => + !l.external && l.href.startsWith('/') && !l.href.startsWith('//') ? ( + + {l.label} + + ) : ( + + {l.label} + + ) + )} + + + ))} +
+ )} +
+ {footer?.copyright} + + {footer?.extra} + + Keyboard shortcuts + ? + + {version && ( + + {version} + + )} + +
+
+); diff --git a/webui/src/layout/app-layout.tsx b/webui/src/layout/app-layout.tsx new file mode 100644 index 000000000..1c2eb70d5 --- /dev/null +++ b/webui/src/layout/app-layout.tsx @@ -0,0 +1,153 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { FunctionComponent, lazy, Suspense, useContext, useEffect, useState } from 'react'; +import { Routes, Route, useNavigate } from 'react-router-dom'; +import { Box } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { Banner } from '../components/banner'; +import { ShortcutsModal } from '../components/shortcuts-modal'; +import { MainContext } from '../context'; +import { SearchProvider } from '../context/search/search-context'; +import { KeyboardShortcutsProvider } from '../context/keyboard-shortcuts-context'; +import { ExtensionTintProvider } from '../context/extension-tint-context'; +import { useShortcut } from '../hooks/use-shortcut'; +import { getCookieValueByKey, setCookie } from '../utils'; +import { ExtensionListRoutes } from '../pages/extension-list/extension-list-routes'; +import { UserSettingsRoutes } from '../pages/user/user-settings-routes'; +import { NamespaceDetailRoutes } from '../pages/namespace-detail/namespace-detail-routes'; +import { ExtensionDetailRoutes } from '../pages/extension-detail/extension-detail-routes'; +import { ExtensionDetail } from '../pages/extension-detail/extension-detail'; +import { HomePage } from '../pages/home/home-page'; +import { SearchPage } from '../pages/search/search-page'; +import { NamespaceDetail } from '../pages/namespace-detail/namespace-detail'; +import { NotFound } from '../not-found'; +import { NAVBAR_HEIGHT } from '../default/theme'; +import { AppNavbar } from './app-navbar'; +import { AppFooter } from './app-footer'; +import { ScrollToTop } from './scroll-to-top'; + +const UserSettings = lazy(() => import('../pages/user/user-settings').then(m => ({ default: m.UserSettings }))); + +const Wrapper = styled(Box)({ + display: 'flex', + flexDirection: 'column', + position: 'relative', + minHeight: '100vh' +}); + +const AppLayoutContent: FunctionComponent = props => { + const { pageSettings } = useContext(MainContext); + const { additionalRoutes: AdditionalRoutes, banner: BannerComponent, footer } = pageSettings.elements; + // The legacy footer is fixed to the viewport bottom; pad the content to stay clear of it. + const legacyFooterHeight = typeof footer === 'object' && 'content' in footer ? footer.props?.footerHeight : 0; + + const navigate = useNavigate(); + const [isBannerOpen, setIsBannerOpen] = useState(false); + const [shortcutsOpen, setShortcutsOpen] = useState(false); + + useEffect(() => { + const banner = pageSettings.elements.banner; + if (!banner) return; + if (banner.cookie && getCookieValueByKey(banner.cookie.key) === banner.cookie.value) return; + // Start collapsed on load, then slide open a beat later for a gentle entrance. + const timer = setTimeout(() => setIsBannerOpen(true), 600); + return () => clearTimeout(timer); + }, []); + + // Global navigation shortcuts with no on-screen affordance. Shortcuts tied to a + // visible control (?, d, p) are registered where that control renders, so a + // customized footer/menu brings its own instead of inheriting ours. + useShortcut({ + key: 'h', + label: 'Go to home', + order: 4, + callback: () => navigate('/') + }); + useShortcut({ + key: 's', + label: 'Go to search', + order: 5, + callback: () => navigate('/search') + }); + + const onDismissBannerButtonClick = () => { + const onClose = pageSettings.elements.banner?.props?.onClose; + if (onClose) onClose(); + const cookie = pageSettings.elements.banner?.cookie; + if (cookie) setCookie(cookie); + setIsBannerOpen(false); + }; + + return ( + + + {BannerComponent ? ( + + + + ) : null} + + {/* Mobile: fill the viewport so the (screen-tall) footer stays below the fold. + Desktop: flexGrow alone sticks the footer to the viewport bottom. */} + + + + } /> + } /> + } + /> + } + /> + } /> + } /> + } /> + {AdditionalRoutes ?? null} + } /> + + + + setShortcutsOpen(true)} /> + setShortcutsOpen(false)} /> + + ); +}; + +export const AppLayout: FunctionComponent = props => ( + + + + + + + +); + +export interface AppLayoutProps { + userLoading: boolean; +} diff --git a/webui/src/layout/app-navbar.tsx b/webui/src/layout/app-navbar.tsx new file mode 100644 index 000000000..7a774453b --- /dev/null +++ b/webui/src/layout/app-navbar.tsx @@ -0,0 +1,250 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { FunctionComponent, useContext, useEffect, useRef, useState } from 'react'; +import { AppBar, Box, Toolbar } from '@mui/material'; +import { alpha, styled, useTheme } from '@mui/material/styles'; +import { Link as RouteLink } from 'react-router-dom'; +import { HeaderMenu } from '../header-menu'; +import { MainContext } from '../context'; +import { usePageSearchBar } from '../context/search/page-search-bar-context'; +import { useExtensionTint } from '../context/extension-tint-context'; +import { OpenVsxMark } from '../components/openvsx-mark'; +import { NavSearchField } from './nav-search-field'; +import { NAVBAR_HEIGHT_PX } from '../default/theme'; + +const ToolbarItem = styled(Box)({ + display: 'flex', + alignItems: 'center' +}); + +// On-color logo for page bands: brand purple can't be trusted against an +// arbitrary band, so the marks take the inherited content color. +const LOGO_ON_BAND_SX = { + '& svg path': { fill: 'currentColor' }, + '& svg path:first-of-type': { fillOpacity: 0.72 } +}; + +// Fade stops sampled from smootherstep — a plain linear fade ends with an +// abrupt slope change the eye reads as a line (Mach banding). +const FADE_EASE = [1, 0.984, 0.897, 0.725, 0.5, 0.275, 0.103, 0.016, 0]; + +// Progressive blur: each layer anchors to the top and fades out at a staggered +// depth, so a row's blur compounds from the layers still active there (~6px at +// the toolbar, ~0 at the fan's bottom). Saturation rides in the same filters — +// every extra backdrop-filter layer re-snapshots the backdrop each frame. +const BLUR_LAYERS = [ + { blur: '5.2px', saturate: 1.35, fadeStart: 0 }, + { blur: '2.75px', saturate: 1.25, fadeStart: 30 }, + { blur: '1.2px', saturate: 1.2, fadeStart: 60 } +].map(({ blur, saturate, fadeStart }) => ({ + filter: `blur(${blur}) saturate(${saturate})`, + mask: `linear-gradient(to bottom, ${FADE_EASE.map( + (k, j) => `rgba(0, 0, 0, ${k}) ${fadeStart + (40 * j) / (FADE_EASE.length - 1)}%` + ).join(', ')})` +})); + +// Nav-side view of the page's tint region: `navTint` is the color the nav +// currently wears — the region's color until its depth scrolls past the nav +// midpoint, then null to return to theme colors. `washColor` lags one step so +// the wash gradient (not transitionable) can still fade out in the last color. +const useNavTint = (): { navTint: string | null; washColor: string | null } => { + const tint = useExtensionTint(); + const [pastTint, setPastTint] = useState(false); + useEffect(() => { + if (!tint) { + setPastTint(false); + return; + } + const onScroll = () => setPastTint(window.scrollY > tint.depth - NAVBAR_HEIGHT_PX / 2); + onScroll(); + window.addEventListener('scroll', onScroll, { passive: true }); + return () => window.removeEventListener('scroll', onScroll); + }, [tint]); + const navTint = tint && !pastTint ? tint.color : null; + const lastTint = useRef(navTint); + useEffect(() => { + if (navTint) lastTint.current = navTint; + }, [navTint]); + return { navTint, washColor: navTint ?? lastTint.current }; +}; + +export const AppNavbar: FunctionComponent = () => { + const { pageSettings } = useContext(MainContext); + const { toolbarContent: ToolbarContent } = pageSettings.elements; + const { hasPageSearchBar } = usePageSearchBar(); + const theme = useTheme(); + const baseAlpha = theme.palette.mode === 'dark' ? 0.74 : 0.78; + const navbg = alpha(theme.palette.background.default, baseAlpha); + // Lighter than the solid glass — the blur fan already provides legibility. + const tintAlpha = theme.palette.mode === 'dark' ? 0.3 : 0.35; + const [scrolled, setScrolled] = useState(false); + + useEffect(() => { + const onScroll = () => setScrolled(window.scrollY > 20); + window.addEventListener('scroll', onScroll, { passive: true }); + return () => window.removeEventListener('scroll', onScroll); + }, []); + + const showSolid = !scrolled; + const showFan = scrolled; + + const { navTint, washColor } = useNavTint(); + + const fanBottom = { xs: '-48px', sm: '-80px' }; + const tintBottom = { xs: '-48px', sm: '-150px' }; + + // While a gallery band backs the nav, the chrome wears its color and the + // content flips to the contrast color, so the two surfaces read as one. + const contentColor = navTint ? theme.palette.getContrastText(navTint) : theme.palette.text.primary; + const solidBg = navTint ?? navbg; + const navBorder = navTint ?? theme.palette.divider; + // The theme and tint washes cross-fade as separate layers. + const washGradientOf = (color: string) => + `linear-gradient(to bottom, ${FADE_EASE.map( + (k, i) => `${alpha(color, tintAlpha * k)} ${(i * 100) / (FADE_EASE.length - 1)}%` + ).join(', ')})`; + const themeWash = washGradientOf(theme.palette.background.default); + const tintWash = washColor ? washGradientOf(washColor) : null; + + return ( + + {/* Solid frosted glass — visible when not scrolled */} + + ); +}; diff --git a/webui/src/layout/nav-search-field.tsx b/webui/src/layout/nav-search-field.tsx new file mode 100644 index 000000000..7071529d5 --- /dev/null +++ b/webui/src/layout/nav-search-field.tsx @@ -0,0 +1,191 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { + FocusEvent, + FunctionComponent, + KeyboardEvent, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState +} from 'react'; +import { Box } from '@mui/material'; +import { useLocation } from 'react-router-dom'; +import { ExtensionSearchfield } from '../components/extension-searchfield'; +import { ExtensionListRoutes } from '../pages/extension-list/extension-list-routes'; +import { useSearch, SEARCH_DEBOUNCE_MS } from '../hooks/use-search'; +import { useSearchQuery } from '../context/search/search-context'; +import { useSearchFocus } from '../context/search/search-focus-context'; +import { usePageSearchBar } from '../context/search/page-search-bar-context'; +import { useSignalEffect } from '../hooks/use-signal-effect'; +import { useDebouncedCallback } from '../hooks/use-debounced-callback'; +import { useShortcut } from '../hooks/use-shortcut'; + +export const NavSearchField: FunctionComponent = () => { + const { pathname } = useLocation(); + const { query, setQuery } = useSearchQuery(); + const { search, filter } = useSearch(); + const { searchFocusSignal, resultsNavigationSignal, setSearchFocused } = useSearchFocus(); + const { hasPageSearchBar, isPageSearchBarActive } = usePageSearchBar(); + const inputRef = useRef(null); + + // Suppress the show/hide fade on first paint. The home hero registers its page + // search bar in a layout effect, so this field starts visible and is hidden in + // the same commit — without this it would fade out from opacity 1 on load. + const [fadeReady, setFadeReady] = useState(false); + useEffect(() => { + const id = requestAnimationFrame(() => setFadeReady(true)); + return () => cancelAnimationFrame(id); + }, []); + + // Typing debounces navigation; Enter searches immediately. A route change + // drops any pending navigation (e.g. the user clicked a result mid-debounce). + const debouncedSearch = useDebouncedCallback(search, SEARCH_DEBOUNCE_MS); + useLayoutEffect(() => debouncedSearch.cancel, [pathname, debouncedSearch]); + + // While a page search bar is registered, this field is only an opacity-0 + // placeholder for the view-transition morph; `inert` removes it from the tab + // order and accessibility tree. `pendingFocus` holds a focus request that + // arrived in the commit that unregistered the page search bar (before this + // component re-rendered); it is honored here once the field is visible again. + const fieldRef = useRef(null); + const pendingFocus = useRef(false); + useLayoutEffect(() => { + if (fieldRef.current) { + fieldRef.current.inert = hasPageSearchBar; + } + if (!hasPageSearchBar && pendingFocus.current) { + pendingFocus.current = false; + inputRef.current?.focus({ preventScroll: true }); + } + }, [hasPageSearchBar]); + + // Take focus when requested — unless a page search bar is registered and owns focus instead. + useSignalEffect( + searchFocusSignal, + useCallback(() => { + if (isPageSearchBarActive()) { + return; + } + if (hasPageSearchBar) { + // Stale commit: the bar just unregistered; defer to the layout effect above. + pendingFocus.current = true; + return; + } + inputRef.current?.focus({ preventScroll: true }); + }, [isPageSearchBarActive, hasPageSearchBar]) + ); + + // The '/' shortcut asks whichever search field is active to take focus. + useShortcut({ key: '/', label: 'Focus search', order: 1, callback: searchFocusSignal.emit }); + + const handleNavSearch = useCallback( + (q: string) => { + setQuery(q); + // Emptying the field only re-searches on the search page (where empty + // means "show all"); elsewhere it shouldn't navigate away. + if (!q.trim() && pathname !== ExtensionListRoutes.SEARCH) { + debouncedSearch.cancel(); + return; + } + debouncedSearch({ query: q }); + }, + [setQuery, debouncedSearch, pathname] + ); + + const handleNavSubmit = useCallback( + (q: string) => { + debouncedSearch.cancel(); + // On the search page, Enter on an already-applied query opens the card + // under the cursor; on a fresh query it applies the search first. + if (pathname === ExtensionListRoutes.SEARCH && q.trim() === filter.query) { + resultsNavigationSignal.emit('open'); + return; + } + search({ query: q }); + }, + [debouncedSearch, search, pathname, filter.query, resultsNavigationSignal.emit] + ); + + // Move cursor to end when the input gains focus (e.g. after view-transition morphs + // the hero search into this field — browsers select-all by default on programmatic focus) + const handleInputFocus = useCallback( + (e: FocusEvent) => { + setSearchFocused(true); + const { target } = e; + requestAnimationFrame(() => target.setSelectionRange(target.value.length, target.value.length)); + }, + [setSearchFocused] + ); + + const handleInputBlur = useCallback(() => setSearchFocused(false), [setSearchFocused]); + + // On the search page the input and the results share one cursor: Up/Down + // step it through the results item by item while focus (and the text caret) + // stays in the field, and Enter opens it. Escape blurs the field. + const handleInputKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Escape') { + // preventDefault stops WebKit/Blink from also clearing the search input. + e.preventDefault(); + (e.target as HTMLInputElement).blur(); + return; + } + if (pathname !== ExtensionListRoutes.SEARCH) { + return; + } + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + resultsNavigationSignal.emit(e.key === 'ArrowDown' ? 'next' : 'previous'); + } + }, + [resultsNavigationSignal.emit, pathname] + ); + + return ( + + + + + + ); +}; diff --git a/webui/src/layout/scroll-to-top.tsx b/webui/src/layout/scroll-to-top.tsx new file mode 100644 index 000000000..35f955714 --- /dev/null +++ b/webui/src/layout/scroll-to-top.tsx @@ -0,0 +1,35 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { FunctionComponent, useLayoutEffect } from 'react'; +import { useLocation, useNavigationType } from 'react-router-dom'; + +// BrowserRouter leaves window scroll untouched on navigation, so a page opened +// from deep in a long list would start at that old offset. Reset to the top on +// forward navigations only: POP (back/forward) keeps the browser's native +// scroll restoration, and same-path param updates (e.g. search filters, which +// replace in place) must not jump either — hence keying on pathname alone. +// Links that swap content in place opt out via state `{ preserveScroll: true }`. +export const ScrollToTop: FunctionComponent = () => { + const { pathname, state } = useLocation(); + const navigationType = useNavigationType(); + const preserveScroll = (state as { preserveScroll?: boolean } | null)?.preserveScroll; + + useLayoutEffect(() => { + if (navigationType !== 'POP' && !preserveScroll) { + window.scrollTo({ top: 0, left: 0 }); + } + }, [pathname]); + + return null; +}; diff --git a/webui/src/main.css b/webui/src/main.css index fff817020..bb4a0ae44 100644 --- a/webui/src/main.css +++ b/webui/src/main.css @@ -1,3 +1,17 @@ +/* Box-shadow tokens: referenced by theme.tsx component overrides and component sx props. + Must be CSS vars (not theme values) because the theme itself uses them in string overrides. */ +:root { + --shadow: 0 1px 2px rgba(20, 20, 40, 0.04), 0 4px 14px rgba(20, 20, 40, 0.05); + --shadow-lg: 0 12px 44px rgba(20, 20, 40, 0.14); +} + +@media (prefers-color-scheme: dark) { + :root { + --shadow: 0 1px 2px rgba(0, 0, 0, 0.5), 0 4px 18px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 16px 54px rgba(0, 0, 0, 0.6); + } +} + html, body { height: 100%; @@ -16,12 +30,57 @@ img { overflow: hidden; } -@keyframes fadein { +@keyframes fadeIn { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +@keyframes popIn { 0% { opacity: 0; + transform: scale(0.95) translateY(6px); } 100% { opacity: 1; + transform: scale(1) translateY(0); + } +} + +/* View transition for search bar morph. The moving group carries the morph; + the snapshots swap quickly and without transforms of their own — a full-length + crossfade doubles the field's text, and extra transforms offset the two pills. */ +::view-transition-group(vt-search) { + animation-duration: 200ms; + animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} +::view-transition-old(vt-search) { + animation: 80ms ease-out both vt-fade-out; +} +::view-transition-new(vt-search) { + animation: 120ms ease-out 60ms both vt-fade-in; +} + +/* Staggered fade-through between pages: the outgoing page is mostly gone + before the incoming one appears, so their contents never double up. */ +::view-transition-old(root) { + animation: 90ms ease-out both vt-fade-out; +} +::view-transition-new(root) { + animation: 140ms ease-out 50ms both vt-fade-in; +} + +@keyframes vt-fade-out { + to { + opacity: 0; + } +} +@keyframes vt-fade-in { + from { + opacity: 0; } } diff --git a/webui/src/main.tsx b/webui/src/main.tsx index c20a9cb92..e26094ae7 100644 --- a/webui/src/main.tsx +++ b/webui/src/main.tsx @@ -29,7 +29,7 @@ import { import { MainContext } from './context'; import { PageSettings } from './page-settings'; import { ErrorResponse } from './server-request'; -import { OtherPages } from './other-pages'; +import { AppLayout } from './layout/app-layout'; import '../src/main.css'; @@ -132,7 +132,7 @@ export const Main: FunctionComponent = props => { } /> - } /> + } /> {error ? ( import('./pages/user/user-settings').then(m => ({ default: m.UserSettings }))); - -const ToolbarItem = styled(Box)({ - display: 'flex', - alignItems: 'center' -}); - -const Wrapper = styled(Box)({ - display: 'flex', - flexDirection: 'column', - position: 'relative', - minHeight: '100vh' -}); - -const Footer = styled('footer')(({ theme }: { theme: Theme }) => ({ - position: 'fixed', - bottom: 0, - width: '100%', - padding: `${theme.spacing(1)} ${theme.spacing(2)}`, - backgroundColor: theme.palette.background.paper, - boxShadow: '0px -2px 6px 0px rgba(0, 0, 0, 0.5)', - backgroundImage: - theme.palette.mode == 'dark' - ? 'linear-gradient(rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.08))' - : undefined -})); - -export const OtherPages: FunctionComponent = props => { - const { pageSettings } = useContext(MainContext); - const { - additionalRoutes: AdditionalRoutes, - banner: BannerComponent, - footer: FooterComponent, - toolbarContent: ToolbarContent - } = pageSettings.elements; - - const [isBannerOpen, setIsBannerOpen] = useState(false); - const [isFooterExpanded, setIsFooterExpanded] = useState(false); - - useEffect(() => { - // Check a cookie to determine whether a banner should be shown - const banner = pageSettings.elements.banner; - if (banner) { - let open = true; - if (banner.cookie) { - const bannerClosedCookie = getCookieValueByKey(banner.cookie.key); - if (bannerClosedCookie === banner.cookie.value) { - open = false; - } - } - setIsBannerOpen(open); - } - }, []); - - const onDismissBannerButtonClick = () => { - const onClose = pageSettings.elements.banner?.props?.onClose; - if (onClose) { - onClose(); - } - const cookie = pageSettings.elements.banner?.cookie; - if (cookie) { - setCookie(cookie); - } - setIsBannerOpen(false); - }; - - const getContentPadding = (): number => { - const footerHeight = pageSettings.elements.footer?.props.footerHeight; - return footerHeight ? footerHeight + 24 : 0; - }; - - return ( - - - - {ToolbarContent ? : null} - - - - - - {BannerComponent ? ( - - - - ) : null} - - - - } /> - } - /> - } - /> - } /> - } /> - } /> - {AdditionalRoutes ?? null} - } /> - - - - {FooterComponent ? ( -
setIsFooterExpanded(true)} onMouseLeave={() => setIsFooterExpanded(false)}> - -
- ) : null} -
- ); -}; - -export interface OtherPagesProps { - user?: UserData; - userLoading: boolean; -} diff --git a/webui/src/page-settings.ts b/webui/src/page-settings.ts index 6108f42fc..74f480021 100644 --- a/webui/src/page-settings.ts +++ b/webui/src/page-settings.ts @@ -10,9 +10,100 @@ import { ComponentType, ReactNode } from 'react'; import { SxProps, Theme } from '@mui/material/styles'; -import { Extension, NamespaceDetails } from './extension-registry-types'; +import { Extension, NamespaceDetails, SortBy } from './extension-registry-types'; import { Cookie } from './utils'; +export interface FooterLink { + label: ReactNode; + href: string; + /** Open in a new tab. */ + external?: boolean; +} + +export interface FooterColumn { + heading: ReactNode; + links: FooterLink[]; +} + +export interface FooterSocialLink { + /** Accessible label / tooltip for the icon button. */ + title: string; + href: string; + icon: ReactNode; +} + +/** + * Structured, data-driven footer rendered by the library chrome (brand, link + * columns and social icons). Every field is consumer-provided branding and may + * be a plain string or any React node. + */ +export interface StructuredFooterSettings { + brand?: { + logo?: ReactNode; + name: ReactNode; + description?: ReactNode; + }; + columns?: FooterColumn[]; + social?: FooterSocialLink[]; + copyright?: ReactNode; + /** Extra node appended to the footer's bottom bar, next to the built-in controls. */ + extra?: ReactNode; +} + +/** + * Legacy footer: a fully custom component fixed to the bottom of the viewport, + * receiving an `expanded` flag that is true while hovered. Kept for backward + * compatibility with consumers written before {@link StructuredFooterSettings}. + */ +export interface CustomFooterSettings { + content: ComponentType<{ expanded: boolean }>; + props?: { + /** Collapsed footer height; the page content is bottom-padded by it to stay clear of the fixed footer. */ + footerHeight?: number; + }; +} + +/** + * Footer configuration. Provide {@link StructuredFooterSettings} to feed the + * built-in footer chrome, a component to replace the footer entirely (rendered + * bare at the bottom of the page), or {@link CustomFooterSettings} (with + * `content`) for the legacy hover-expanding chrome. + */ +export type FooterSettings = ComponentType | CustomFooterSettings | StructuredFooterSettings; + +/** A curated row of extensions on the home page, fetched with the given ordering. */ +export interface HomeCuratedSection { + title: string; + subtitle: string; + sortBy: SortBy; +} + +export interface HomeInvolvementCard { + icon: ReactNode; + title: string; + description: string; + href: string; + label: string; +} + +/** Consumer-provided content for the built-in home page (branding and curated data). */ +export interface HomeSettings { + popularSearches?: string[]; + curatedSections?: HomeCuratedSection[]; + involvement?: { + heading?: string; + cards: HomeInvolvementCard[]; + }; +} + +/** + * Home route configuration. Provide {@link HomeSettings} to feed the built-in + * home page, or a component to replace it entirely. The built-in sections are + * exported so a replacement can be composed from them; each boxes itself, so + * don't wrap them in another width-constraining container. + */ +export type HomePageSettings = HomeSettings | ComponentType; + export interface PageSettings { pageTitle: string; themeType?: 'light' | 'dark'; @@ -24,12 +115,8 @@ export interface PageSettings { toolbarContent?: ComponentType; defaultMenuContent?: ComponentType; mobileMenuContent?: ComponentType; - footer?: { - content: ComponentType<{ expanded: boolean }>; - props: { - footerHeight?: number; - }; - }; + footer?: FooterSettings; + home?: HomePageSettings; searchHeader?: ComponentType; reportAbuse?: ComponentType<{ extension: Extension; sx?: SxProps }>; claimNamespace?: ComponentType<{ extension: Extension; sx?: SxProps }>; diff --git a/webui/src/pages/admin-dashboard/extension-admin.tsx b/webui/src/pages/admin-dashboard/extension-admin.tsx index 28be391a3..20fefd974 100644 --- a/webui/src/pages/admin-dashboard/extension-admin.tsx +++ b/webui/src/pages/admin-dashboard/extension-admin.tsx @@ -15,7 +15,6 @@ import { Button, Typography } from '@mui/material'; import { MainContext } from '../../context'; import { SearchListContainer } from './search-list-container'; import { StyledInput } from './namespace-input'; -import { ExtensionListSearchfield } from '../extension-list/extension-list-searchfield'; import { useAdminExtension, useDeleteExtension } from './use-extension-admin'; import { ExtensionDetailView } from '../../components/extension/extension-detail-view'; import { AdminDashboardRoutes } from './admin-dashboard-routes'; @@ -88,12 +87,12 @@ export const ExtensionAdmin: FunctionComponent = () => { hideIconButton={true} autoFocus={true} />, - - + ({ + minHeight: 0, + minWidth: 0, + padding: '0.4375rem 0.8125rem', + borderRadius: '999px', + border: `1px solid ${theme.palette.divider}`, + backgroundColor: alpha(theme.palette.surface2, 0.7), + backdropFilter: 'blur(2px) saturate(1.8)', + color: theme.palette.text.secondary, + fontSize: '0.8125rem', + fontWeight: 500, + transition: 'border-color 0.14s, color 0.14s, background 0.14s', + // Still translucent so the blur fan shows through; the border carries the emphasis. + '&.Mui-selected': { + backgroundColor: alpha(theme.palette.secondary.main, 0.7), + borderColor: theme.palette.secondary.main, + color: theme.palette.secondary.contrastText, + fontWeight: 600 + }, + '&:not(.Mui-selected)': accentHover(theme), + ...focusOutline(theme) +})) as typeof Tab; const inlineLinkStyle = { display: 'contents', @@ -183,22 +213,25 @@ const LicenseLink: FunctionComponent<{ return <>{extension.license || 'Unlicensed'}; }; -const compactNumber = new Intl.NumberFormat(undefined, { - notation: 'compact', - compactDisplay: 'short' -} as Intl.NumberFormatOptions); - const ExtensionHeaderInfo: FunctionComponent<{ extension: Extension; headerTextColor: string; }> = ({ extension, headerTextColor }) => { - const downloadCountFormatted = compactNumber.format(extension.downloadCount || 0); - const reviewCountFormatted = compactNumber.format(extension.reviewCount || 0); + const downloadCountFormatted = formatCompactNumber(extension.downloadCount || 0); + const reviewCountFormatted = formatCompactNumber(extension.reviewCount || 0); return ( - + - + {extension.displayName ?? extension.name} @@ -287,9 +320,37 @@ const ExtensionHeaderInfo: FunctionComponent<{ ); }; +// The luma CSS grayscale() resolves a color to; deprecated bands paint through that filter. +const grayscaleOf = (color: string): string => { + const [r, g, b] = decomposeColor(color).values; + const y = Math.round(0.2126 * r + 0.7152 * g + 0.0722 * b); + return `rgb(${y}, ${y}, ${y})`; +}; + +// Declares the header band as the nav's tint region: its gallery color (null +// for default bands) and how deep it runs. The nav does the switching; the +// resize observer keeps the depth fresh as the band's content reflows. +const useNavTintFromBand = (color: string | null, bandRef: RefObject): void => { + const setTint = useSetExtensionTint(); + useEffect(() => { + const band = bandRef.current; + if (!band) return; + // Fires once on observe, then on size changes. + const observer = new ResizeObserver(() => + setTint({ color, depth: band.getBoundingClientRect().bottom + window.scrollY }) + ); + observer.observe(band); + return () => { + observer.disconnect(); + setTint(null); + }; + }, [color, bandRef, setTint]); +}; + const ExtensionHeader: FunctionComponent<{ extension: Extension; -}> = ({ extension }) => { + bandRef: RefObject; +}> = ({ extension, bandRef }) => { const theme = useTheme(); const { pageSettings } = useContext(MainContext); @@ -304,30 +365,49 @@ const ExtensionHeader: FunctionComponent<{ } const headerTextColor = theme.palette.getContrastText(headerColor); + const usesDefaultBg = !extension.galleryColor; + + // Only real gallery colors tint the nav; over default bands it keeps its + // theme glass, which is visually flush with bg2 already. + useNavTintFromBand(usesDefaultBg ? null : extension.deprecated ? grayscaleOf(headerColor) : headerColor, bandRef); return ( - - + + + - @@ -345,9 +425,29 @@ export const ExtensionDetail: FunctionComponent = () => { const effectiveVersion = isTabSegment(version) ? undefined : version; const activeTab = parseTab(version); + // Tab switches preserve scroll (see ScrollToTop); when scrolled deep, glide + // up so the new panel starts under the pinned pills. + const bandRef = useRef(null); + const prevTab = useRef(activeTab); + useEffect(() => { + if (prevTab.current === activeTab) return; + prevTab.current = activeTab; + const band = bandRef.current; + if (!band) return; + const pin = band.getBoundingClientRect().bottom + window.scrollY - NAVBAR_HEIGHT_PX; + // Rest above the pin point + const target = Math.max(0, pin); + if (window.scrollY > target) { + window.scrollTo({ top: target }); + } + }, [activeTab]); + // React Router v6 returns a possibly undefined type for params, but our route configuration guarantees these will be defined. const { loading, error, extension, reload } = useExtensionDetail(namespace!, name!, target!, effectiveVersion!); + // Tab switches keep the scroll position (the effect above adjusts it). + const goToTab = useCallback((path: string) => navigate(path, { state: { preserveScroll: true } }), [navigate]); + const navigateToVersion = useCallback( (selectedVersion: string) => { if (!namespace || !name) return; @@ -360,11 +460,34 @@ export const ExtensionDetail: FunctionComponent = () => { [navigate, namespace, name, target] ); - if (!namespace || !name) return null; + // Computed before the early return so the shortcut hooks below run unconditionally. + const basePath = namespace && name ? buildExtensionPath(namespace, name, target) : ''; + const changesPath = namespace && name ? buildExtensionPath(namespace, name, target, ExtensionTab.CHANGES) : ''; + const reviewsPath = namespace && name ? buildExtensionPath(namespace, name, target, ExtensionTab.REVIEWS) : ''; + + useShortcut({ + key: 'o', + label: 'Extension overview', + order: 40, + callback: () => goToTab(basePath), + enabled: !!basePath + }); + useShortcut({ + key: 'c', + label: 'Extension changelog', + order: 50, + callback: () => goToTab(changesPath), + enabled: !!basePath + }); + useShortcut({ + key: 'r', + label: 'Extension reviews', + order: 60, + callback: () => goToTab(reviewsPath), + enabled: !!basePath + }); - const basePath = buildExtensionPath(namespace, name, target); - const reviewsPath = buildExtensionPath(namespace, name, target, ExtensionTab.REVIEWS); - const changesPath = buildExtensionPath(namespace, name, target, ExtensionTab.CHANGES); + if (!namespace || !name) return null; let overviewPath = basePath; if (version && !isTabSegment(version)) { @@ -381,39 +504,93 @@ export const ExtensionDetail: FunctionComponent = () => { {extension && ( <> - + - - + + Overviewo + + } component={RouteLink} to={overviewPath} + state={{ preserveScroll: true }} + /> + + Changesc + + } + component={RouteLink} + to={changesPath} + state={{ preserveScroll: true }} /> - - + Ratings & Reviewsr + + } component={RouteLink} to={reviewsPath} + state={{ preserveScroll: true }} /> - - } - /> - } - /> - - } - /> - + {/* Owns the space below the pinned pills for every tab, so the panels + don't each set their own; min-height keeps the preserved scroll + position valid while a panel loads. */} + + + } + /> + } + /> + + } + /> + + )} diff --git a/webui/src/pages/extension-detail/extension-rating-stars.tsx b/webui/src/pages/extension-detail/extension-rating-stars.tsx index 1e2b13ffb..77ae8e56e 100644 --- a/webui/src/pages/extension-detail/extension-rating-stars.tsx +++ b/webui/src/pages/extension-detail/extension-rating-stars.tsx @@ -23,11 +23,11 @@ export const ExtensionRatingStars: FunctionComponent const starsNumber = props.number; const fontSize = props.fontSize ?? 'medium'; if (i <= starsNumber) { - return ; + return ; } if (i > starsNumber && i - 1 < starsNumber) { return ( - + diff --git a/webui/src/pages/extension-list/extension-list-container.tsx b/webui/src/pages/extension-list/extension-list-container.tsx deleted file mode 100644 index b5af72e50..000000000 --- a/webui/src/pages/extension-list/extension-list-container.tsx +++ /dev/null @@ -1,109 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2019 TypeFox and others - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * SPDX-License-Identifier: EPL-2.0 - ********************************************************************************/ - -import { FunctionComponent, useEffect, useState } from 'react'; -import { Box } from '@mui/material'; -import { useLocation } from 'react-router-dom'; -import { addQuery } from '../../utils'; -import { ExtensionCategory, SortOrder, SortBy } from '../../extension-registry-types'; -import { ExtensionList } from './extension-list'; -import { ExtensionListHeader } from './extension-list-header'; - -export const ExtensionListContainer: FunctionComponent = () => { - const [searchQuery, setSearchQuery] = useState(''); - const [category, setCategory] = useState(''); - const [resultNumber, setResultNumber] = useState(0); - const [sortBy, setSortBy] = useState('relevance'); - const [sortOrder, setSortOrder] = useState('desc'); - const [searchDebounceTime, setSearchDebounceTime] = useState(0); - - const { pathname, search } = useLocation(); - - useEffect(() => { - const searchParams = new URLSearchParams(search); - setSearchQuery(searchParams.get('search') ?? ''); - setCategory((searchParams.get('category') as ExtensionCategory) ?? ''); - setSortBy((searchParams.get('sortBy') as SortBy) ?? 'relevance'); - setSortOrder((searchParams.get('sortOrder') as SortOrder) ?? 'desc'); - }, []); - - const onSearchChanged = (searchQuery: string): void => { - setSearchQuery(searchQuery); - setSearchDebounceTime(1000); - updateURL(searchQuery, category, sortBy, sortOrder); - }; - - const onSearchSubmit = (searchQuery: string): void => { - setSearchQuery(searchQuery); - setSearchDebounceTime(0); - }; - - const onCategoryChanged = (category: ExtensionCategory | ''): void => { - setCategory(category); - updateURL(searchQuery, category, sortBy, sortOrder); - }; - - const onSortByChanged = (sortBy: SortBy): void => { - setSortBy(sortBy); - updateURL(searchQuery, category, sortBy, sortOrder); - }; - - const onSortOrderChanged = (sortOrder: SortOrder): void => { - setSortOrder(sortOrder); - updateURL(searchQuery, category, sortBy, sortOrder); - }; - - const updateURL = ( - searchQuery: string, - category: ExtensionCategory | '', - sortBy?: SortBy, - sortOrder?: SortOrder - ): void => { - const queries: { key: string; value: string }[] = []; - if (searchQuery) { - queries.push({ key: 'search', value: searchQuery }); - } - if (category) { - queries.push({ key: 'category', value: category }); - } - if (sortBy) { - queries.push({ key: 'sortBy', value: sortBy }); - } - if (sortOrder) { - queries.push({ key: 'sortOrder', value: sortOrder }); - } - const url = addQuery('', queries) || pathname; - history.replaceState(null, '', url); - }; - - const handleUpdate = (resultNumber: number): void => setResultNumber(resultNumber); - - return ( - - - - - ); -}; diff --git a/webui/src/pages/extension-list/extension-list-header.tsx b/webui/src/pages/extension-list/extension-list-header.tsx deleted file mode 100644 index 793d93885..000000000 --- a/webui/src/pages/extension-list/extension-list-header.tsx +++ /dev/null @@ -1,216 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2019 TypeFox and others - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * SPDX-License-Identifier: EPL-2.0 - ********************************************************************************/ - -import { ChangeEvent, FunctionComponent, KeyboardEvent, useContext, useEffect, useState } from 'react'; -import { Box, Paper, InputBase, Select, MenuItem, Container, SelectChangeEvent } from '@mui/material'; -import { ExtensionCategory, SortBy, SortOrder } from '../../extension-registry-types'; -import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; -import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; -import { ExtensionListSearchfield } from './extension-list-searchfield'; -import { MainContext } from '../../context'; - -export const ExtensionListHeader: FunctionComponent = props => { - const [categories, setCategories] = useState([]); - const [category, setCategory] = useState(''); - const [sortBy, setSortBy] = useState('relevance'); - const [sortOrder, setSortOrder] = useState('desc'); - const context = useContext(MainContext); - - useEffect(() => { - const categories = Array.from(context.service.getCategories()); - categories.sort((a, b) => { - if (a === b) return 0; - if (a === 'Other') return 1; - if (b === 'Other') return -1; - return a.localeCompare(b); - }); - - setCategories(categories); - setCategory(props.category ?? ''); - setSortBy(props.sortBy); - setSortOrder(props.sortOrder); - }, []); - - useEffect(() => { - setCategory(props.category ?? ''); - setSortBy(props.sortBy); - setSortOrder(props.sortOrder); - }, [props.category, props.sortBy, props.sortOrder]); - - const handleCategoryChange = (event: SelectChangeEvent) => { - const category = (event.target.value as ExtensionCategory) ?? ''; - setCategory(category); - props.onCategoryChanged(category); - }; - - const handleSearchChange = (value: string) => { - props.onSearchChanged(value); - }; - - const handleSearchSubmit = (value: string) => { - props.onSearchSubmit(value); - }; - - const handleSortByChange = (event: ChangeEvent) => { - const sortBy = event.target.value as SortBy; - setSortBy(sortBy); - props.onSortByChanged(sortBy); - }; - - const handleSortOrderChange = () => { - const newSortOrder = sortOrder === 'asc' ? 'desc' : 'asc'; - setSortOrder(newSortOrder); - props.onSortOrderChanged(newSortOrder); - }; - - const renderValue = (value: string) => { - return value === '' ? ( - - All Categories - - ) : ( - value - ); - }; - - const SearchHeader = context.pageSettings.elements.searchHeader; - return ( - - - {SearchHeader ? : ''} - - - - - - - - - {`${props.resultNumber} Result${props.resultNumber !== 1 ? 's' : ''}`} - - - Sort by - - - { - if (e.key === 'Enter') { - e.preventDefault(); - handleSortOrderChange(); - } - }} - onClick={handleSortOrderChange}> - {sortOrder === 'asc' ? ( - - ) : ( - - )} - - - - - - - ); -}; - -export interface ExtensionListHeaderProps { - onSearchChanged: (s: string) => void; - onSearchSubmit: (s: string) => void; - onCategoryChanged: (c: ExtensionCategory) => void; - onSortByChanged: (sb: SortBy) => void; - onSortOrderChanged: (so: SortOrder) => void; - searchQuery?: string; - category?: ExtensionCategory | ''; - sortBy: SortBy; - sortOrder: SortOrder; - resultNumber: number; -} diff --git a/webui/src/pages/extension-list/extension-list-item.tsx b/webui/src/pages/extension-list/extension-list-item.tsx deleted file mode 100644 index 435ee34c3..000000000 --- a/webui/src/pages/extension-list/extension-list-item.tsx +++ /dev/null @@ -1,85 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2019 TypeFox and others - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * SPDX-License-Identifier: EPL-2.0 - ********************************************************************************/ - -import { FunctionComponent } from 'react'; -import { Link as RouteLink } from 'react-router-dom'; -import { Paper, Typography, Box, Grid, Fade } from '@mui/material'; -import SaveAltIcon from '@mui/icons-material/SaveAlt'; -import { ExtensionDetailRoutes } from '../extension-detail/extension-detail-routes'; -import { SearchEntry } from '../../extension-registry-types'; -import { ExtensionIcon } from '../../components/extension/extension-icon'; -import { ExtensionRatingStars } from '../extension-detail/extension-rating-stars'; -import { createRoute } from '../../utils'; - -export const ExtensionListItem: FunctionComponent = props => { - const { extension, filterSize, idx } = props; - const route = createRoute([ExtensionDetailRoutes.ROOT, extension.namespace, extension.name]); - const numberFormat = new Intl.NumberFormat(undefined, { notation: 'compact', compactDisplay: 'short' } as any); - const downloadCountFormatted = numberFormat.format(extension.downloadCount ?? 0); - return ( - - - - *': { - '&:not(:last-child)': { - marginBottom: '.5rem' - } - }, - opacity: extension.deprecated ? 0.5 : undefined, - filter: extension.deprecated ? 'grayscale(100%)' : undefined - }}> - - - - - - {extension.displayName ?? extension.name} - - - - - {extension.namespace} - - - {extension.version} - - - - -   - {downloadCountFormatted != '0' && ( - <> - {downloadCountFormatted} - - )} - - - - - - ); -}; - -export interface ExtensionListItemProps { - extension: SearchEntry; - idx: number; - filterSize: number; -} diff --git a/webui/src/pages/extension-list/extension-list-routes.ts b/webui/src/pages/extension-list/extension-list-routes.ts index 3ff30749f..ed80c4674 100644 --- a/webui/src/pages/extension-list/extension-list-routes.ts +++ b/webui/src/pages/extension-list/extension-list-routes.ts @@ -15,4 +15,5 @@ import { createRoute } from '../../utils'; export namespace ExtensionListRoutes { export const MAIN = createRoute([]); + export const SEARCH = createRoute(['search']); } diff --git a/webui/src/pages/extension-list/extension-list-searchfield.tsx b/webui/src/pages/extension-list/extension-list-searchfield.tsx deleted file mode 100644 index 371839533..000000000 --- a/webui/src/pages/extension-list/extension-list-searchfield.tsx +++ /dev/null @@ -1,89 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2020 TypeFox and others - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * SPDX-License-Identifier: EPL-2.0 - ********************************************************************************/ - -import { ChangeEvent, FunctionComponent, KeyboardEvent, useContext } from 'react'; -import SearchIcon from '@mui/icons-material/Search'; -import { Paper, IconButton, InputBase } from '@mui/material'; -import { MainContext } from '../../context'; - -interface ExtensionListSearchfieldProps { - onSearchChanged: (s: string) => void; - onSearchSubmit?: (s: string) => void; - searchQuery?: string; - placeholder: string; - hideIconButton?: boolean; - error?: boolean; - autoFocus?: boolean; -} - -export const ExtensionListSearchfield: FunctionComponent = props => { - const { pageSettings } = useContext(MainContext); - - const handleSearchChange = (event: ChangeEvent) => { - props.onSearchChanged(event.target.value); - }; - - const handleSearchButtonClick = () => { - if (props.onSearchSubmit) { - props.onSearchSubmit(props.searchQuery ?? ''); - } - }; - - const searchIconColor = pageSettings?.themeType === 'dark' ? '#111111' : '#ffffff'; - return ( - - { - if (e.key === 'Enter' && props.onSearchSubmit) { - props.onSearchSubmit(props.searchQuery ?? ''); - } - }} - /> - - {props.hideIconButton ? ( - '' - ) : ( - - - - )} - - ); -}; diff --git a/webui/src/pages/extension-list/extension-list.tsx b/webui/src/pages/extension-list/extension-list.tsx deleted file mode 100644 index b30d5a0fd..000000000 --- a/webui/src/pages/extension-list/extension-list.tsx +++ /dev/null @@ -1,172 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2019 TypeFox and others - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License v. 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0. - * - * SPDX-License-Identifier: EPL-2.0 - ********************************************************************************/ - -import { FunctionComponent, useContext, useEffect, useRef, useState } from 'react'; -import InfiniteScroll from 'react-infinite-scroller'; -import { Box, Grid, CircularProgress, Container } from '@mui/material'; -import { ExtensionListItem } from './extension-list-item'; -import { isError, SearchEntry, SearchResult } from '../../extension-registry-types'; -import { ExtensionFilter } from '../../extension-registry-service'; -import { debounce } from '../../utils'; -import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; -import { MainContext } from '../../context'; - -export const ExtensionList: FunctionComponent = props => { - const abortController = useRef(new AbortController()); - const cancellationToken = useRef<{ timeout?: number }>({}); - const enableLoadMore = useRef(false); - const lastRequestedPage = useRef(0); - const pageOffset = useRef(0); - const filterSize = useRef(props.filter.size ?? 10); - const context = useContext(MainContext); - const [extensions, setExtensions] = useState([]); - const [extensionKeys, setExtensionKeys] = useState>(new Set()); - const [appliedFilter, setAppliedFilter] = useState(); - const [hasMore, setHasMore] = useState(false); - const [loading, setLoading] = useState(true); - - useEffect(() => { - enableLoadMore.current = true; - return () => { - abortController.current.abort(); - clearTimeout(cancellationToken.current.timeout); - enableLoadMore.current = false; - }; - }, []); - - useEffect(() => { - filterSize.current = props.filter.size ?? filterSize.current; - debounce( - async () => { - try { - const result = await context.service.search(abortController.current, props.filter); - if (isError(result)) { - throw result; - } - - const searchResult = result as SearchResult; - props.onUpdate(searchResult.totalSize); - const actualSize = searchResult.extensions.length; - pageOffset.current = lastRequestedPage.current; - const extensionKeys = new Set(); - for (const ext of searchResult.extensions) { - extensionKeys.add(`${ext.namespace}.${ext.name}`); - } - - setExtensions(searchResult.extensions); - setExtensionKeys(extensionKeys); - setAppliedFilter(props.filter); - setHasMore(actualSize < searchResult.totalSize && actualSize > 0); - } catch (err) { - context.handleError(err); - } finally { - setLoading(false); - } - }, - cancellationToken.current, - props.debounceTime - ); - }, [props.filter.category, props.filter.query, props.filter.sortBy, props.filter.sortOrder, props.debounceTime]); - - const loadMore = async (p: number): Promise => { - setLoading(true); - setHasMore(false); - lastRequestedPage.current = p; - const filter = copyFilter(appliedFilter as ExtensionFilter); - if (!isSameFilter(props.filter, filter)) { - return; - } - try { - filter.offset = (p - pageOffset.current) * filterSize.current; - const result = await context.service.search(abortController.current, filter); - if (isError(result)) { - throw result; - } - - const newExtensions: SearchEntry[] = []; - const newExtensionKeys = new Set(); - newExtensions.push(...extensions); - extensionKeys.forEach(key => newExtensionKeys.add(key)); - const searchResult = result as SearchResult; - if (enableLoadMore.current && isSameFilter(props.filter, filter)) { - // Check for duplicate keys to avoid problems due to asynchronous user edit / loadMore call - for (const ext of searchResult.extensions) { - const key = `${ext.namespace}.${ext.name}`; - if (!extensionKeys.has(key)) { - newExtensions.push(ext); - newExtensionKeys.add(key); - } - } - - setExtensions(newExtensions); - setExtensionKeys(newExtensionKeys); - setHasMore(extensions.length < searchResult.totalSize && searchResult.extensions.length > 0); - } - } catch (err) { - context.handleError(err); - } finally { - setLoading(false); - } - }; - - const isSameFilter = (f1: ExtensionFilter, f2: ExtensionFilter): boolean => { - return ( - f1.category === f2.category && - f1.query === f2.query && - f1.sortBy === f2.sortBy && - f1.sortOrder === f2.sortOrder - ); - }; - - const copyFilter = (f: ExtensionFilter): ExtensionFilter => { - return { - query: f.query, - category: f.category || '', - size: f.size, - offset: f.offset, - sortBy: f.sortBy, - sortOrder: f.sortOrder - }; - }; - - const extensionList = extensions.map((ext, idx) => ( - - )); - - const loader = ( - - - - ); - - return ( - <> - - - - - {extensionList} - - - - - ); -}; - -export interface ExtensionListProps { - filter: ExtensionFilter; - debounceTime: number; - onUpdate: (resultNumber: number) => void; -} diff --git a/webui/src/pages/home/browse-categories.tsx b/webui/src/pages/home/browse-categories.tsx new file mode 100644 index 000000000..c03f3de3b --- /dev/null +++ b/webui/src/pages/home/browse-categories.tsx @@ -0,0 +1,82 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { FunctionComponent } from 'react'; +import { Box } from '@mui/material'; +import { ExtensionCategory } from '../../extension-registry-types'; +import { CATEGORY_ICONS, DefaultCategoryIcon } from '../../components/categories'; +import { CategoryPill } from '../../components/category-pill'; +import { CategoryCard } from '../../components/category-card'; +import { Section, Eyebrow } from '../../components/page-primitives'; +import { useSearch } from '../../hooks/use-search'; +import { useHomeCategories } from './use-home-data'; + +export interface BrowseCategoriesProps { + /** Categories to offer; defaults to the registry's browsable home list. */ + categories?: ExtensionCategory[]; + /** Category click handler; defaults to opening the search page filtered to it. */ + onSelect?: (category: ExtensionCategory) => void; +} + +/** "Browse by category" section: a horizontal pill row on mobile, a card grid on desktop. */ +export const BrowseCategories: FunctionComponent = props => { + const homeCategories = useHomeCategories(); + const { search } = useSearch(); + const categories = props.categories ?? homeCategories; + const onSelect = props.onSelect ?? ((category: ExtensionCategory) => search({ query: '', category })); + if (categories.length === 0) { + return null; + } + return ( +
+ Browse by category + + {categories.map(cat => ( + onSelect(cat)} + /> + ))} + + + {categories.map(cat => ( + onSelect(cat)} + /> + ))} + +
+ ); +}; diff --git a/webui/src/pages/home/curated-sections.tsx b/webui/src/pages/home/curated-sections.tsx new file mode 100644 index 000000000..12fe505d9 --- /dev/null +++ b/webui/src/pages/home/curated-sections.tsx @@ -0,0 +1,105 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { FunctionComponent } from 'react'; +import { Box, Typography } from '@mui/material'; +import { ExtensionCard } from '../../components/extension-card'; +import { Section } from '../../components/page-primitives'; +import { HomeCuratedSection } from '../../page-settings'; +import { useSearch } from '../../hooks/use-search'; +import { CURATED_SIZE, DEFAULT_CURATED_SECTIONS, useCuratedRows } from './use-home-data'; + +export interface CuratedSectionsProps { + /** Rows to fetch and render; defaults to "Most downloaded" and "Recently updated". */ + sections?: HomeCuratedSection[]; + /** "See all" click handler; defaults to opening the search page unfiltered. */ + onSeeAll?: () => void; +} + +/** Renders the curated extension rows (e.g. "Most downloaded"), skipping empty/loading ones. */ +export const CuratedSections: FunctionComponent = props => { + const rows = useCuratedRows(props.sections ?? DEFAULT_CURATED_SECTIONS); + const { search } = useSearch(); + return ( + <> + {rows.map(row => { + // Hide rows that loaded empty (e.g. a failed request). + if (!row.loading && row.extensions.length === 0) { + return null; + } + return ( +
+ + + + {row.title} + + + {row.subtitle} + + + search({ query: '', sortBy: row.sortBy }))} + sx={{ + background: 'none', + border: 'none', + color: 'secondary.light', + fontSize: '0.875rem', + fontWeight: 600, + cursor: 'pointer' + }}> + See all → + + + + {/* Fixed index-keyed slots so cards stay mounted across the swap and don't re-fade. */} + {Array.from({ length: row.loading ? CURATED_SIZE : row.extensions.length }, (_, idx) => ( + + ))} + +
+ ); + })} + + ); +}; diff --git a/webui/src/pages/home/get-involved.tsx b/webui/src/pages/home/get-involved.tsx new file mode 100644 index 000000000..d65c5cdfb --- /dev/null +++ b/webui/src/pages/home/get-involved.tsx @@ -0,0 +1,125 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { FunctionComponent, ReactNode } from 'react'; +import { Box, Typography } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import type { Theme } from '@mui/material/styles'; +import { Link as RouteLink } from 'react-router-dom'; +import { HomeInvolvementCard } from '../../page-settings'; +import { Section, Eyebrow, focusOutline } from '../../components/page-primitives'; + +const GetInvolvedCard = styled(Box)(({ theme }) => ({ + backgroundColor: theme.palette.background.paper, + border: `1px solid ${theme.palette.divider}`, + borderRadius: '16px', + padding: '1.5rem', + display: 'flex', + flexDirection: 'column' +})); + +const getInvolvedLinkStyles = ({ theme }: { theme: Theme }) => ({ + fontSize: '0.8125rem', + fontWeight: 600, + color: theme.palette.secondary.light, + textDecoration: 'none', + '&:hover': { textDecoration: 'underline' }, + ...focusOutline(theme) +}); + +const GetInvolvedAnchorLink = styled('a')(getInvolvedLinkStyles); +const GetInvolvedRouteLink = styled(RouteLink)(getInvolvedLinkStyles); + +interface GetInvolvedLinkProps { + href: string; + children: ReactNode; +} + +const GetInvolvedLink: FunctionComponent = ({ href, children }) => { + const external = /^https?:\/\//.test(href); + const internalRoute = href.startsWith('/') && !href.startsWith('//'); + + if (internalRoute) { + return {children}; + } + + return ( + + {children} + + ); +}; + +export interface GetInvolvedProps { + heading?: string; + cards?: HomeInvolvementCard[]; +} + +/** Consumer-configured "Get Involved" cards (contribute, sponsor, etc.). */ +export const GetInvolved: FunctionComponent = ({ heading, cards }) => { + if (!cards || cards.length === 0) { + return null; + } + return ( +
+ + {heading ?? 'Get Involved'} + + + {cards.map(card => { + return ( + + + svg': { fontSize: '1.125rem' } + }}> + {card.icon} + + {card.title} + + + {card.description} + + {card.label} + + ); + })} + +
+ ); +}; diff --git a/webui/src/pages/home/hero-search.tsx b/webui/src/pages/home/hero-search.tsx new file mode 100644 index 000000000..775289258 --- /dev/null +++ b/webui/src/pages/home/hero-search.tsx @@ -0,0 +1,301 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { + ChangeEvent, + ComponentType, + FormEvent, + FunctionComponent, + useCallback, + useLayoutEffect, + useRef, + useState +} from 'react'; +import { Box, ButtonBase, Typography } from '@mui/material'; +import { useLocation } from 'react-router-dom'; +import { flushSync } from 'react-dom'; +import { styled, alpha } from '@mui/material/styles'; +import { accentHover, focusOutline, focusRing, Section } from '../../components/page-primitives'; +import { useSearch, SEARCH_DEBOUNCE_MS } from '../../hooks/use-search'; +import { useSearchQuery } from '../../context/search/search-context'; +import { useSearchFocus } from '../../context/search/search-focus-context'; +import { useRegisterPageSearchBar } from '../../context/search/page-search-bar-context'; +import { useSignalEffect } from '../../hooks/use-signal-effect'; +import { useDebouncedCallback } from '../../hooks/use-debounced-callback'; +import { MONO_FONT } from '../../default/theme'; + +const HeroSearchWrap = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: '0.8125rem', + backgroundColor: theme.palette.surface2, + border: `1px solid ${theme.palette.divider}`, + borderRadius: '15px', + height: '3.875rem', + paddingLeft: '1.25rem', + paddingRight: '0.5rem', + [theme.breakpoints.down('sm')]: { + height: '3.375rem', + paddingLeft: '0.875rem', + gap: '0.625rem' + }, + boxShadow: 'var(--shadow)', + transition: 'border-color 0.2s ease, box-shadow 0.3s ease', + '&:focus-within': focusRing(theme, `0 18px 70px -10px ${alpha(theme.palette.secondary.main, 0.45)}`) +})); + +const HeroInput = styled('input')(({ theme }) => ({ + flex: 1, + height: '100%', + border: 'none', + outline: 'none', + background: 'none', + color: theme.palette.text.primary, + fontSize: '1.0625rem', + fontFamily: MONO_FONT, + '&::placeholder': { color: theme.palette.text.disabled } +})); + +const HeroSubmitButton = styled(ButtonBase)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: '0.5rem', + height: '2.875rem', + padding: '0 1.375rem', + borderRadius: '11px', + overflow: 'hidden', + backgroundColor: theme.palette.secondary.main, + color: theme.palette.secondary.contrastText, + fontSize: '0.9375rem', + fontWeight: 600, + flexShrink: 0, + transition: 'background 0.14s', + [theme.breakpoints.down('sm')]: { + height: '2.5rem', + padding: '0 0.875rem', + borderRadius: `${theme.shape.borderRadius}px` + }, + '&:hover': { backgroundColor: theme.palette.secondary.dark }, + ...focusOutline(theme) +})); + +const PopularChip = styled(ButtonBase)(({ theme }) => ({ + backgroundColor: theme.palette.surface2, + border: `1px solid ${theme.palette.divider}`, + color: theme.palette.text.secondary, + fontSize: '0.8125rem', + fontWeight: 500, + padding: '0.375rem 0.8125rem', + borderRadius: '999px', + overflow: 'hidden', + fontFamily: MONO_FONT, + transition: 'border-color 0.14s, color 0.14s', + ...accentHover(theme), + ...focusOutline(theme) +})); + +export interface HeroSearchProps { + /** Rendered above the search field (headline, tagline, …). */ + searchHeader?: ComponentType; + /** Search terms offered as one-click chips below the field. */ + popularSearches?: string[]; +} + +/** + * The hero search section. Registers itself as the page's search bar while in + * view, so the nav bar hides its own field. Owns a local copy of `query` so + * keystrokes only re-render the field, not the whole homepage; typing debounces + * `search`, submit and popular chips search immediately. + */ +export const HeroSearch: FunctionComponent = ({ + searchHeader: SearchHeader, + popularSearches = [] +}) => { + const { query: contextQuery, setQuery } = useSearchQuery(); + const { search } = useSearch(); + const { searchFocusSignal, searchFocused } = useSearchFocus(); + const [query, setLocalQuery] = useState(''); + const heroInputRef = useRef(null); + const isActiveSearchBar = useRegisterPageSearchBar(heroInputRef); + + // Focus the hero input on request (e.g. the '/' shortcut) — the nav field owns focus while the hero is out of view. + useSignalEffect( + searchFocusSignal, + useCallback(() => { + const el = heroInputRef.current; + if (!el || !isActiveSearchBar) { + return; + } + el.focus(); + // Move cursor to end so the user can keep typing. + requestAnimationFrame(() => el.setSelectionRange(el.value.length, el.value.length)); + }, [isActiveSearchBar]) + ); + + // Reconcile the scroll swap: the field taking over adopts the shared draft and + // takes focus if its counterpart had it (the signal routes to the active bar). + const wasActiveSearchBar = useRef(isActiveSearchBar); + useLayoutEffect(() => { + if (wasActiveSearchBar.current === isActiveSearchBar) { + return; + } + wasActiveSearchBar.current = isActiveSearchBar; + if (isActiveSearchBar) { + setLocalQuery(contextQuery); + if (searchFocused) searchFocusSignal.emit(); + } else { + setQuery(query); + if (document.activeElement === heroInputRef.current) searchFocusSignal.emit(); + } + }, [isActiveSearchBar, searchFocused, contextQuery, query, setQuery, searchFocusSignal.emit]); + + // Focus the search by default when the hero page is the app's landing page. + const { key: locationKey } = useLocation(); + useLayoutEffect(() => { + if (locationKey === 'default') { + searchFocusSignal.emit(); + } + }, [locationKey, searchFocusSignal.emit]); + + // Wrap search calls with a view transition so the hero input morphs into the nav bar. + type ViewTransitionDocument = Document & { + startViewTransition?: (callback: () => void) => { finished: Promise }; + }; + + const searchWithTransition = useCallback( + (q: string) => { + // Sync context immediately so the nav bar input value is ready before the + // navigation commits (the hero input morphs into the nav field mid-transition). + setQuery(q); + const shouldFocus = Boolean(q); + // flushSync inside the transition commits the navigation and the focus signal + // synchronously, so the nav field takes focus while the hero input is still + // focused — keeping the mobile keyboard open across the morph. + const go = () => { + flushSync(() => { + search({ query: q }); + if (shouldFocus) searchFocusSignal.emit(); + }); + }; + const doc = document as ViewTransitionDocument; + if (doc.startViewTransition) { + const transition = doc.startViewTransition(go); + // Re-issue the focus request once the transition settles as a fallback: + // the synchronous focus above can be interrupted by the morph, and by now + // the nav field is fully mounted and interactive. + if (shouldFocus) { + transition.finished.then(searchFocusSignal.emit).catch(() => undefined); + } + } else { + go(); + } + }, + [search, setQuery, searchFocusSignal.emit] + ); + + const debouncedSearch = useDebouncedCallback(searchWithTransition, SEARCH_DEBOUNCE_MS); + + const handleInputChange = (e: ChangeEvent) => { + const val = e.target.value; + setLocalQuery(val); + if (val.trim()) { + debouncedSearch(val.trim()); + } else { + // Clearing the field shouldn't navigate away from the home page. + debouncedSearch.cancel(); + } + }; + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + debouncedSearch.cancel(); + if (query.trim()) searchWithTransition(query.trim()); + }; + + return ( +
+ {SearchHeader && } + + {/* The nav field claims 'vt-search' while the hero is unregistered — duplicate names abort transitions. */} + + + / + + + + + + + + + search + + + + + {popularSearches.length > 0 && ( + + + Popular: + + {popularSearches.map(chip => ( + searchWithTransition(chip)} style={{ flexShrink: 0 }}> + {chip} + + ))} + + )} +
+ ); +}; diff --git a/webui/src/pages/home/home-page.tsx b/webui/src/pages/home/home-page.tsx new file mode 100644 index 000000000..3a1579912 --- /dev/null +++ b/webui/src/pages/home/home-page.tsx @@ -0,0 +1,58 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { FunctionComponent, useContext } from 'react'; +import { Box } from '@mui/material'; +import { Navigate, useSearchParams } from 'react-router-dom'; +import { MainContext } from '../../context'; +import { HomeSettings } from '../../page-settings'; +import { ExtensionListRoutes } from '../extension-list/extension-list-routes'; +import { addQuery } from '../../utils'; +import { HeroSearch } from './hero-search'; +import { BrowseCategories } from './browse-categories'; +import { CuratedSections } from './curated-sections'; +import { GetInvolved } from './get-involved'; + +/** Landing page. Pre-redesign search URLs lived here (/?search=...), so redirect those to /search. */ +export const HomePage: FunctionComponent = () => { + const { pageSettings } = useContext(MainContext); + const [params] = useSearchParams(); + if (['search', 'category', 'sortBy', 'sortOrder'].some(key => params.has(key))) { + const target = addQuery(ExtensionListRoutes.SEARCH, [ + { key: 'q', value: params.get('search') ?? undefined }, + { key: 'category', value: params.get('category') ?? undefined }, + { key: 'sortBy', value: params.get('sortBy') ?? undefined }, + { key: 'sortOrder', value: params.get('sortOrder') ?? undefined } + ]); + return ; + } + const home = pageSettings.elements.home; + if (typeof home === 'function') { + const CustomHome = home; + return ; + } + return ; +}; + +/** The built-in home page: hero search, category browser, curated extension rows and get-involved cards. */ +const HomeContent: FunctionComponent<{ home?: HomeSettings }> = ({ home }) => { + const { pageSettings } = useContext(MainContext); + return ( + + + + + + + ); +}; diff --git a/webui/src/pages/home/use-home-data.ts b/webui/src/pages/home/use-home-data.ts new file mode 100644 index 000000000..78ceecb69 --- /dev/null +++ b/webui/src/pages/home/use-home-data.ts @@ -0,0 +1,95 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { useContext, useMemo } from 'react'; +import { useQueries } from '@tanstack/react-query'; +import { MainContext } from '../../context'; +import { SearchEntry, SearchResult, SortOrder, isError } from '../../extension-registry-types'; +import { ExtensionCategory } from '../../extension-registry-types'; +import { useCategories } from '../../components/categories'; +import { HomeCuratedSection } from '../../page-settings'; +import { controllerFromSignal } from '../../query-client'; + +/** Number of extensions fetched for each curated row. */ +export const CURATED_SIZE = 6; + +/** Categories shown in the home page grid. */ +const HOME_CATEGORIES = new Set([ + 'AI', + 'Programming Languages', + 'Snippets', + 'Linters', + 'Themes', + 'Debuggers', + 'Formatters', + 'Keymaps', + 'SCM Providers', + 'Extension Packs', + 'Language Packs', + 'Data Science', + 'Machine Learning', + 'Visualization', + 'Notebooks' +]); + +/** Curated rows shown when the consumer does not configure `home.curatedSections`. */ +export const DEFAULT_CURATED_SECTIONS: HomeCuratedSection[] = [ + { title: 'Most downloaded', subtitle: 'The extensions developers rely on every day', sortBy: 'downloadCount' }, + { title: 'Recently updated', subtitle: 'Fresh releases from publishers this week', sortBy: 'timestamp' } +]; + +export interface CuratedRow extends HomeCuratedSection { + extensions: SearchEntry[]; + loading: boolean; +} + +/** The browsable home category list: the registry's categories minus a few noisy ones. */ +export function useHomeCategories(): ExtensionCategory[] { + const allCategories = useCategories(); + return useMemo(() => allCategories.filter(c => HOME_CATEGORIES.has(c)), [allCategories]); +} + +/** + * Loads the curated extension rows, each fetched with its configured ordering. + * Rows start in a loading state and fill in as requests resolve; failed rows end + * up empty and are hidden by the consumer. + */ +export function useCuratedRows(curatedSections: HomeCuratedSection[]): CuratedRow[] { + const { service } = useContext(MainContext); + + const results = useQueries({ + queries: curatedSections.map(section => ({ + queryKey: ['home-curated', section.sortBy], + queryFn: async ({ signal }: { signal: AbortSignal }): Promise => { + const result = await service.search(controllerFromSignal(signal), { + query: '', + category: '', + offset: 0, + size: CURATED_SIZE, + sortBy: section.sortBy, + sortOrder: 'desc' as SortOrder + }); + if (isError(result)) { + throw result; + } + return (result as SearchResult).extensions; + } + })) + }); + + return curatedSections.map((section, idx) => ({ + ...section, + extensions: results[idx].data ?? [], + loading: results[idx].isLoading + })); +} diff --git a/webui/src/pages/namespace-detail/namespace-detail.tsx b/webui/src/pages/namespace-detail/namespace-detail.tsx index 4e7e75ee1..fec8f1c0c 100644 --- a/webui/src/pages/namespace-detail/namespace-detail.tsx +++ b/webui/src/pages/namespace-detail/namespace-detail.tsx @@ -9,14 +9,15 @@ ********************************************************************************/ import { FunctionComponent, ReactNode, useContext, useEffect, useState, useRef } from 'react'; -import { Typography, Box, Container, Grid, Link, Divider } from '@mui/material'; +import { Typography, Box, Link, Divider } from '@mui/material'; import GitHubIcon from '@mui/icons-material/GitHub'; import LinkedInIcon from '@mui/icons-material/LinkedIn'; import TwitterIcon from '@mui/icons-material/Twitter'; import { useParams } from 'react-router-dom'; -import { ExtensionListItem } from '../extension-list/extension-list-item'; +import { ExtensionCard } from '../../components/extension-card'; import { MainContext } from '../../context'; import { DelayedLoadIndicator } from '../../components/delayed-load-indicator'; +import { Section } from '../../components/page-primitives'; import { NamespaceDetails, isError, UrlString } from '../../extension-registry-types'; export const NamespaceDetail: FunctionComponent = () => { @@ -106,144 +107,126 @@ export const NamespaceDetail: FunctionComponent = () => { }; const renderNamespaceDetails = (namespaceDetails: NamespaceDetails, truncateReadMore: boolean): ReactNode => { + const { website, supportLink, socialLinks } = namespaceDetails; return ( <> - - - - - + +
+ + + + + {namespaceDetails.displayName} + + {namespaceDetails.description ? ( + + + {namespaceDetails.description} + + {showReadMore ? ( + + Read more + + ) : null} + + ) : null} + {website || supportLink ? ( - - - - - {namespaceDetails.displayName} - - - {namespaceDetails.description ? ( - - - {namespaceDetails.description} - - {showReadMore ? ( - - Read more - - ) : null} - - ) : null} - - - - {namespaceDetails.website ? ( - - - {displayLink(namespaceDetails.website)} - - - ) : null} - {namespaceDetails.website && namespaceDetails.supportLink ? ( - - - - ) : null} - {namespaceDetails.supportLink ? ( - - - {displayLink(namespaceDetails.supportLink)} - - - ) : null} - - - - - {namespaceDetails.socialLinks.linkedin ? ( - - - - - - ) : null} - {namespaceDetails.socialLinks.github ? ( - - - - - - ) : null} - {namespaceDetails.socialLinks.twitter ? ( - - - - - - ) : null} - - - - - + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap', + gap: '0.75rem', + mt: '1rem' + }}> + {website ? ( + + {displayLink(website)} + + ) : null} + {website && supportLink ? : null} + {supportLink ? ( + + {displayLink(supportLink)} + + ) : null} + + ) : null} + {socialLinks.linkedin || socialLinks.github || socialLinks.twitter ? ( + + {socialLinks.linkedin ? ( + + + + ) : null} + {socialLinks.github ? ( + + + + ) : null} + {socialLinks.twitter ? ( + + + + ) : null} + + ) : null} + - +
{namespaceDetails.extensions ? ( - - +
+ {namespaceDetails.extensions.map((ext, idx) => ( - ))} - - + +
) : null} ); diff --git a/webui/src/pages/search/search-header.tsx b/webui/src/pages/search/search-header.tsx new file mode 100644 index 000000000..eb0356868 --- /dev/null +++ b/webui/src/pages/search/search-header.tsx @@ -0,0 +1,115 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { FunctionComponent } from 'react'; +import { Box, IconButton, Select, MenuItem, Typography, SelectChangeEvent } from '@mui/material'; +import { SortBy, SortOrder } from '../../extension-registry-types'; +import { ExtensionCategory } from '../../extension-registry-types'; +import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; +import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; + +export const SearchHeader: FunctionComponent = props => { + const { sortBy, sortOrder, onSortByChanged, onSortOrderChanged } = props; + + const handleSortByChange = (event: SelectChangeEvent) => { + onSortByChanged(event.target.value as SortBy); + }; + + const toggleSortOrder = () => { + onSortOrderChanged(sortOrder === 'asc' ? 'desc' : 'asc'); + }; + + const title = props.searchQuery ? `"${props.searchQuery}"` : props.category || 'All extensions'; + + return ( + + + + {title} + + + {props.resultNumber.toLocaleString()} extensions found + + + + + Sort by + + + + {sortOrder === 'asc' ? ( + + ) : ( + + )} + + + + ); +}; + +export interface SearchHeaderProps { + onSortByChanged: (sb: SortBy) => void; + onSortOrderChanged: (so: SortOrder) => void; + sortBy: SortBy; + sortOrder: SortOrder; + resultNumber: number; + searchQuery?: string; + category?: ExtensionCategory | ''; +} diff --git a/webui/src/pages/search/search-page.tsx b/webui/src/pages/search/search-page.tsx new file mode 100644 index 000000000..2407bcb6c --- /dev/null +++ b/webui/src/pages/search/search-page.tsx @@ -0,0 +1,126 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * https://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + ********************************************************************************/ + +import { FunctionComponent, useState } from 'react'; +import { Box } from '@mui/material'; +import { SortBy, SortOrder } from '../../extension-registry-types'; +import { ExtensionCategory } from '../../extension-registry-types'; +import { ExtensionList } from '../../components/extension-list'; +import { CATEGORY_ICONS, DefaultCategoryIcon } from '../../components/categories'; +import { CategoryPill } from '../../components/category-pill'; +import { CategoryListItem } from '../../components/category-list-item'; +import { Eyebrow, Section } from '../../components/page-primitives'; +import { NAVBAR_HEIGHT } from '../../default/theme'; +import { useSearch } from '../../hooks/use-search'; +import { useCategories } from '../../components/categories'; +import { SearchHeader } from './search-header'; + +export const SearchPage: FunctionComponent = () => { + const { filter, search } = useSearch(); + const { query: searchQuery, category, sortBy, sortOrder } = filter; + const categories = useCategories(); + + const [resultNumber, setResultNumber] = useState(0); + + return ( +
+ {/* Mobile category pills — outside the flex row so negative-margin bleed isn't clipped */} + {categories.length > 0 && ( + + {(['', ...categories] as Array).map(cat => { + const Icon = cat ? CATEGORY_ICONS[cat] : DefaultCategoryIcon; + return ( + search({ category: cat })} + /> + ); + })} + + )} + + + {/* Desktop categories sidebar */} + + Categories + {(['', ...categories] as Array).map(cat => { + const Icon = cat ? CATEGORY_ICONS[cat] : DefaultCategoryIcon; + return ( + search({ category: cat })} + /> + ); + })} + + + {/* Main content */} + + search({ sortBy })} + onSortOrderChanged={(sortOrder: SortOrder) => search({ sortOrder })} + /> + + + +
+ ); +}; diff --git a/webui/src/pages/user/avatar.tsx b/webui/src/pages/user/avatar.tsx index 778fd3399..32f3e5925 100644 --- a/webui/src/pages/user/avatar.tsx +++ b/webui/src/pages/user/avatar.tsx @@ -9,87 +9,134 @@ ********************************************************************************/ import { FunctionComponent, useContext, useRef, useState } from 'react'; -import { Avatar, Menu, Typography, MenuItem, Link, Divider, IconButton } from '@mui/material'; +import { Avatar, Box, IconButton, Link, Menu, MenuItem, Typography } from '@mui/material'; import { Link as RouteLink } from 'react-router-dom'; +import SettingsIcon from '@mui/icons-material/Settings'; +import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; +import LogoutIcon from '@mui/icons-material/Logout'; import { UserSettingsRoutes } from './user-settings-routes'; import { AdminDashboardRoutes } from '../admin-dashboard/admin-dashboard-routes'; import { MainContext } from '../../context'; import { LogoutForm } from './logout'; +// Radius, font and min-height come from the MuiMenuItem theme override. +const menuItemSx = { + py: '0.5rem', + px: '0.625rem', + gap: '0.625rem', + color: 'text.primary', + display: 'flex', + alignItems: 'center' +} as const; + +const iconSx = { fontSize: '1.0625rem', color: 'text.disabled', flexShrink: 0 }; + export const UserAvatar: FunctionComponent = () => { - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(false); const context = useContext(MainContext); - const avatarButton = useRef(); + const anchorRef = useRef(null); const logoutFormRef = useRef(null); - const handleAvatarClick = () => { - setOpen(!open); - }; + const user = context.user; + if (!user) return null; - const handleClose = () => { - setOpen(false); - }; + const initials = user.loginName.slice(0, 2).toUpperCase(); - const user = context.user; - if (!user) { - return null; - } return ( <> (avatarButton.current = ref)}> + aria-label='User menu' + onClick={() => setOpen(true)} + sx={{ p: '0.3125rem' }}> + sx={{ + width: 32, + height: 32, + bgcolor: 'accentSoft', + color: 'secondary.light', + fontSize: '0.75rem', + fontWeight: 700, + borderRadius: '8px' + }}> + {initials} + + onClose={() => setOpen(false)}> + {/* User header */} + + + {initials} + + + + Logged in as + + + setOpen(false)}> + {user.loginName} + + + + - - Logged in as - - - {user.loginName} - + component={RouteLink} + to={UserSettingsRoutes.PROFILE} + onClick={() => setOpen(false)} + sx={{ ...menuItemSx, textDecoration: 'none' }}> + + Settings - - - - Settings - - - {user.role && user.role === 'admin' ? ( - - - Admin Dashboard - + {user.role === 'admin' && ( + setOpen(false)} + sx={{ ...menuItemSx, textDecoration: 'none' }}> + + Admin Dashboard - ) : ( - '' )} - logoutFormRef.current?.submit()}> + logoutFormRef.current?.submit()} sx={menuItemSx}> - - Log Out - + + Log out diff --git a/webui/src/pages/user/logout.tsx b/webui/src/pages/user/logout.tsx index 11cffa8c5..dfca0ff81 100644 --- a/webui/src/pages/user/logout.tsx +++ b/webui/src/pages/user/logout.tsx @@ -9,19 +9,9 @@ * ****************************************************************************** */ import { PropsWithChildren, useContext, useEffect, useRef, useState, forwardRef } from 'react'; -import { Button } from '@mui/material'; -import { styled } from '@mui/material/styles'; import { isError, CsrfTokenJson } from '../../extension-registry-types'; import { MainContext } from '../../context'; -const LogoutButton = styled(Button)({ - cursor: 'pointer', - textDecoration: 'none', - border: 'none', - background: 'none', - padding: 0 -}); - export const LogoutForm = forwardRef(({ children }, ref) => { const [csrf, setCsrf] = useState(); const context = useContext(MainContext); @@ -45,9 +35,9 @@ export const LogoutForm = forwardRef(({ chil }; return ( -
+ {csrf ? : null} - {children} + {children}
); }); diff --git a/webui/src/query-client.ts b/webui/src/query-client.ts index 6dc089c78..33f332fbc 100644 --- a/webui/src/query-client.ts +++ b/webui/src/query-client.ts @@ -44,6 +44,17 @@ export const queryClient = new QueryClient({ } }); +// Extension icons are cached as object URLs; revoke them when their query is +// evicted from the cache so the underlying blobs are freed. +queryClient.getQueryCache().subscribe(event => { + if (event.type === 'removed' && event.query.queryKey[0] === 'extension-icon') { + const url = event.query.state.data; + if (typeof url === 'string' && url.startsWith('blob:')) { + URL.revokeObjectURL(url); + } + } +}); + /** * Bridge between TanStack Query's `AbortSignal` and the `AbortController` that * `ExtensionRegistryService` methods expect. Inside a `queryFn` we get a diff --git a/webui/src/utils.ts b/webui/src/utils.ts index 12bf5cc9a..6b10363f4 100644 --- a/webui/src/utils.ts +++ b/webui/src/utils.ts @@ -40,6 +40,16 @@ export function debounce(task: () => void, token: { timeout?: number }, delay: n token.timeout = window.setTimeout(task, delay); } +const compactNumberFormat = new Intl.NumberFormat(undefined, { + notation: 'compact', + compactDisplay: 'short' +} as Intl.NumberFormatOptions); + +/** Formats large counts compactly, e.g. 12345 -> "12K". */ +export function formatCompactNumber(value: number): string { + return compactNumberFormat.format(value); +} + export function toLocalTime(timestamp?: string): string | undefined { if (!timestamp) { return undefined; diff --git a/webui/yarn.lock b/webui/yarn.lock index 524b8dfdc..3713b0563 100644 --- a/webui/yarn.lock +++ b/webui/yarn.lock @@ -794,6 +794,27 @@ __metadata: languageName: node linkType: hard +"@fontsource-variable/geist-mono@npm:^5.2.8": + version: 5.2.8 + resolution: "@fontsource-variable/geist-mono@npm:5.2.8" + checksum: 10/82c5afc603c0a20f115115490c5b2853fc4fb43af793fef811e71011700fa952d4d4bb382983138352ebca5c1c8381b07902350cbd3f70adee53eac088f83776 + languageName: node + linkType: hard + +"@fontsource-variable/geist@npm:^5.2.9": + version: 5.2.9 + resolution: "@fontsource-variable/geist@npm:5.2.9" + checksum: 10/cd249dc1e60f96277b9b33f6c4626385d1337a6d3c40b659bdf49e95eabff8a38083d2c579446bdbf77a104906a9b2ba15ad98e2177accdad192dc97d06bb980 + languageName: node + linkType: hard + +"@fontsource/roboto@npm:^5.2.10": + version: 5.2.10 + resolution: "@fontsource/roboto@npm:5.2.10" + checksum: 10/2fc9e9db66cfab68ff45b573cc2bf20fe8d7e962f09b6edd32d53681fdcd7bdedd65440012683491785583383a585bdbfb67903171159bf4c3520f2f0d26d92a + languageName: node + linkType: hard + "@humanfs/core@npm:^0.19.1": version: 0.19.1 resolution: "@humanfs/core@npm:0.19.1" @@ -5810,6 +5831,9 @@ __metadata: "@emotion/styled": "npm:^11.11.0" "@eslint/eslintrc": "npm:^3.3.3" "@eslint/js": "npm:^9.39.0" + "@fontsource-variable/geist": "npm:^5.2.9" + "@fontsource-variable/geist-mono": "npm:^5.2.8" + "@fontsource/roboto": "npm:^5.2.10" "@mdit/plugin-alert": "npm:^0.22.3" "@mui/base": "npm:^5.0.0-beta.9" "@mui/icons-material": "npm:^5.15.14"