diff --git a/src/App.js b/src/App.js index d436039c..260ea4e9 100644 --- a/src/App.js +++ b/src/App.js @@ -22,7 +22,7 @@ import CategoriesSidebar from './components/Sidebar/Categories'; import CommentsSidebar from './components/Sidebar/Comments'; import PagesSidebar from './components/Sidebar/Pages'; import PostsSidebar from './components/Sidebar/Posts'; -import SuperMenu from './components/SuperMenu'; +import SuperOverlay from './components/Super'; import { RenderPlugins } from './plugins'; import './App.css'; @@ -36,6 +36,7 @@ class App extends Component { isShowingWritePost: false, showChanges: false, }; + this.headerRef = React.createRef(); } onLogOut() { window.location.href = '/wp-login.php?action=logout'; @@ -71,6 +72,19 @@ class App extends Component { } } + onToggleSuper = () => { + if ( this.props.showingSuper ) { + this.props.onHideSuperSidebar(); + } else { + this.props.onShowSuperSidebar(); + } + } + + onShowChanges = () => { + this.setState( { showChanges: true } ); + this.props.onHideSuperSidebar(); + } + onClickWritePost() { this.setState( { isShowingWritePost: true } ); } @@ -144,21 +158,25 @@ class App extends Component { return (
- this.onSearch( search ) } - onShowChanges={ () => { - this.setState( { showChanges: true } ); - this.props.onHideSuperSidebar(); - } } - /> -
this.onLogOut() } - onWritePost={ () => this.onClickWritePost() } - onSearch={ search => this.onSearch( search ) } - onShowSuper={ this.props.onShowSuperSidebar } - /> + { /* Keep the header above the overlay, so its search input stays usable. */ } +
+
this.onLogOut() } + onWritePost={ () => this.onClickWritePost() } + onSearch={ search => this.onSearch( search ) } + onShowSuper={ this.onToggleSuper } + /> +
+ { this.props.showingSuper && ( + + ) }
{ this.renderSidebar() } diff --git a/src/_print.scss b/src/_print.scss index 3a2ec2fe..53f738a0 100644 --- a/src/_print.scss +++ b/src/_print.scss @@ -4,7 +4,7 @@ .Sidebar-Container, .Header, .Overlay, - .SuperMenu, + .Super, .Post__actions, .Post-Summary-actions button, .Post-Summary-people, diff --git a/src/actions.js b/src/actions.js index ec6d568f..df30576c 100644 --- a/src/actions.js +++ b/src/actions.js @@ -8,6 +8,7 @@ export const SHOW_SIDEBAR_POSTS = 'SHOW_SIDEBAR_POSTS'; export const HIDE_SIDEBAR = 'HIDE_SIDEBAR'; export const SHOW_SUPER_SIDEBAR = 'SHOW_SUPER_SIDEBAR'; export const HIDE_SUPER_SIDEBAR = 'HIDE_SUPER_SIDEBAR'; +export const SET_SUPER_SEARCH = 'SET_SUPER_SEARCH'; export const ENABLE_BETA_FEATURE = 'ENABLE_BETA_FEATURE'; export const DISABLE_BETA_FEATURE = 'DISABLE_BETA_FEATURE'; export const SET_DEFAULT_POST_VIEW = 'SET_DEFAULT_POST_VIEW'; @@ -39,6 +40,17 @@ export const showMetaSidebar = () => ( { type: SHOW_META_SIDEBAR } ); export const showSuperSidebar = () => ( { type: SHOW_SUPER_SIDEBAR } ); export const hideSuperSidebar = () => ( { type: HIDE_SUPER_SIDEBAR } ); +/** + * Set the term for network-wide search in the super overlay. + * + * @param {string} search Search term. + * @returns {object} Action. + */ +export const setSuperSearch = search => ( { + type: SET_SUPER_SEARCH, + search, +} ); + export const enableBetaFeature = feature => ( { type: ENABLE_BETA_FEATURE, feature, diff --git a/src/changelog.js b/src/changelog.js index 058896c9..6f0186cc 100644 --- a/src/changelog.js +++ b/src/changelog.js @@ -125,6 +125,21 @@ export const changes = [ ), }, + { + date: '2026-09-03', + title: 'Switch Sites & Search the Network', + content: () => ( + +

The button in the top left now opens an overview of + every site on your network, so you can jump between + them with a single click.

+

While it's open, the search bar searches posts and + comments across all of your sites at once. Narrow the + results down by site, type or date, and sort them by + relevance or by date.

+
+ ), + }, ]; export function getChanges( lastView ) { diff --git a/src/components/Header/index.js b/src/components/Header/index.js index 63971fe7..81edffc4 100644 --- a/src/components/Header/index.js +++ b/src/components/Header/index.js @@ -4,34 +4,69 @@ import { Slot } from 'react-slot-fill'; import { withCurrentUser } from '../../hocs'; import SearchInput from '../SearchInput'; +import NetworkSearchInput from '../Super/SearchInput'; +import { getNetwork } from '../Super/util'; import CurrentUser from './CurrentUser'; -import HeaderButton from './HeaderButton'; import Logo from './Logo'; +const SUPER_BUTTON_CLASSES = [ + 'w-[min(30%,360px)] text-white p-0 pr-5 flex items-center border-none font-inherit text-base cursor-pointer', + 'hover:border-none hover:text-white/60 focus:border-none focus:text-white/60', + '[&:hover_.hm-logo]:opacity-60 [&:focus_.hm-logo]:opacity-60', +].join( ' ' ); + export class Header extends Component { render() { + const { superActive } = this.props; + const network = getNetwork(); + + // Tint the header with the brand colour while the search is network-wide. + // The tint sits on white, as the header needs to stay opaque over the overlay. + const networkSearch = superActive && !! network; return ( -
-
+
+
- + { networkSearch ? ( + + ) : ( + + ) } @@ -53,13 +88,16 @@ export class Header extends Component { Header.defaultProps = { searchValue: '', + superActive: false, }; Header.propTypes = { searchValue: PropTypes.string, + superActive: PropTypes.bool, onLogOut: PropTypes.func.isRequired, onWritePost: PropTypes.func.isRequired, onSearch: PropTypes.func.isRequired, + onShowSuper: PropTypes.func.isRequired, }; export default withCurrentUser( Header ); diff --git a/src/components/Header/index.stories.js b/src/components/Header/index.stories.js index 37579555..da6203e3 100644 --- a/src/components/Header/index.stories.js +++ b/src/components/Header/index.stories.js @@ -28,3 +28,23 @@ export const Default = () => ( { ...defaultProps } /> ); + +export const SuperActive = () => ( +
+); + +export const OnNetwork = () => { + window.H2Data.network = { + name: 'Storybook Network', + }; + + return ( +
+ ); +}; diff --git a/src/components/SearchInput.js b/src/components/SearchInput.js index 6409982b..06467738 100644 --- a/src/components/SearchInput.js +++ b/src/components/SearchInput.js @@ -9,6 +9,24 @@ import { decodeEntities } from '../util'; import Link from './Link'; +export const FORM_CLASSES = 'SearchInput flex-1 relative self-stretch'; +export const INPUT_CLASSES = 'w-full text-lg border-2 border-hm-border-color rounded-sm px-2 py-1 md:py-2 md:leading-5 placeholder:italic'; + +/** + * Get the classes for the wrapper around the search input. + * + * The background is left to the caller: the site search needs an opaque one + * to cover its results dropdown, while the network search shows the header's. + * + * @param {boolean} small Whether the input is in a small (unpadded) context. + * @returns {string} Class names. + */ +export const getWrapClasses = small => [ + 'SearchInput__wrap', + 'flex items-center h-full relative z-11', + small ? '' : 'px-5', +].filter( Boolean ).join( ' ' ); + export class Results extends React.Component { state = { selected: -1, @@ -204,18 +222,12 @@ class SearchInput extends React.Component { return (
-
+
this.inputEl = ref } diff --git a/src/components/Super/Facets.js b/src/components/Super/Facets.js new file mode 100644 index 00000000..100e562b --- /dev/null +++ b/src/components/Super/Facets.js @@ -0,0 +1,193 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +import { isCurrentSite } from './util'; + +export const TYPES = [ + { + value: 'all', + label: 'Posts and comments', + }, + { + value: 'post', + label: 'Posts only', + }, + { + value: 'comment', + label: 'Comments only', + }, +]; + +export const ORDERS = [ + { + value: 'relevance', + label: 'Most relevant', + }, + { + value: 'date', + label: 'Newest first', + }, +]; + +export const PERIODS = [ + { + value: 'any', + label: 'Any time', + }, + { + value: 'week', + label: 'Past week', + days: 7, + }, + { + value: 'month', + label: 'Past month', + days: 30, + }, + { + value: 'year', + label: 'Past year', + days: 365, + }, +]; + +const LABEL_CLASSES = 'flex items-center gap-2 cursor-pointer py-0.5 hover:text-hm-vibrant-blue'; +const CONTROL_CLASSES = 'm-0 shrink-0 size-4 cursor-pointer accent-hm-vibrant-blue'; + +function Facet( props ) { + return ( +
+ + { props.title } + + { props.children } +
+ ); +} + +function RadioFacet( props ) { + const { name, options, title, value, onChange } = props; + + return ( + +
    + { options.map( option => ( +
  • + +
  • + ) ) } +
+
+ ); +} + +/** + * Filters for network search. + * + * An empty `selectedSites` list means every site. + * + * @param {object} props Current filter values, available sites, and change callbacks. + * @returns {React.ReactNode} Filter controls. + */ +export default function Facets( props ) { + const { orderby, period, selectedSites, sites, type } = props; + + const toggleSite = id => { + if ( selectedSites.includes( id ) ) { + props.onChangeSites( selectedSites.filter( selected => selected !== id ) ); + } else { + props.onChangeSites( [ ...selectedSites, id ] ); + } + }; + + return ( + + ); +} + +Facets.propTypes = { + orderby: PropTypes.oneOf( ORDERS.map( order => order.value ) ).isRequired, + period: PropTypes.oneOf( PERIODS.map( period => period.value ) ).isRequired, + selectedSites: PropTypes.arrayOf( PropTypes.number ).isRequired, + sites: PropTypes.shape( { + data: PropTypes.array, + isLoading: PropTypes.bool, + } ).isRequired, + type: PropTypes.oneOf( TYPES.map( type => type.value ) ).isRequired, + onChangeOrderby: PropTypes.func.isRequired, + onChangePeriod: PropTypes.func.isRequired, + onChangeSites: PropTypes.func.isRequired, + onChangeType: PropTypes.func.isRequired, +}; diff --git a/src/components/Super/Result.js b/src/components/Super/Result.js new file mode 100644 index 00000000..c3196927 --- /dev/null +++ b/src/components/Super/Result.js @@ -0,0 +1,193 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +import { decodeEntities } from '../../util'; +import Avatar from '../Avatar'; +import FormattedDate from '../FormattedDate'; +import Link from '../Link'; + +import { getPlainText, isCurrentSite } from './util'; + +const SNIPPET_CLASSES = [ + 'Super-result-snippet', + 'mt-2 text-sm text-black/70 leading-normal', + '[&_p]:m-0 [&_p+p]:mt-1', + '[&_mark]:bg-[#fff3a3] [&_mark]:text-black [&_mark]:rounded-xs [&_mark]:px-0.5', +].join( ' ' ); + +const TITLE_LINK_CLASSES = 'text-black! hover:text-hm-vibrant-blue! [&_mark]:bg-transparent [&_mark]:text-inherit [&_mark]:underline [&_mark]:decoration-hm-vibrant-blue [&_mark]:decoration-2'; + +const POST_TYPE_LABELS = { + post: 'Post', + page: 'Page', +}; + +/** + * Get the label for a post type. + * + * @param {string} postType Post type slug. + * @returns {string} Known label, or a readable version of the slug. + */ +function getPostTypeLabel( postType ) { + if ( ! postType ) { + return POST_TYPE_LABELS.post; + } + if ( POST_TYPE_LABELS[ postType ] ) { + return POST_TYPE_LABELS[ postType ]; + } + + const words = postType.replace( /[-_]+/g, ' ' ); + return words.charAt( 0 ).toUpperCase() + words.slice( 1 ); +} + +/** + * Get the details to show for a post or comment. + * + * Posts and comments use the shapes of their regular REST API endpoints, with + * related objects embedded. + * + * @param {string} type Type of result, `post` or `comment`. + * @param {object} object Post or comment from the search API. + * @returns {object} Label, title, author and fallback excerpt for the result. + */ +function getDetails( type, object ) { + const embedded = object._embedded || {}; + const author = embedded.author && embedded.author[0]; + + if ( type === 'comment' ) { + const post = embedded.up && embedded.up[0]; + + return { + authorName: object.author_name, + avatarUrls: object.author_avatar_urls, + // Comments have no excerpt, so fall back to the text of the comment. + excerptText: getPlainText( object.content.rendered ), + label: 'Comment', + title: post ? post.title.rendered : '', + }; + } + + return { + authorName: author && author.name, + avatarUrls: author && author.avatar_urls, + excerptHtml: object.excerpt.rendered, + // `post` results cover every post type, so label by the post's own type. + label: getPostTypeLabel( object.type ), + title: object.title.rendered, + }; +} + +/** + * A single network search result. + * + * Results link to their own site; only results on the current site are routed + * within the app. + * + * @param {object} props Props, with the `result` from the search API. + * @returns {React.ReactNode} List item for the result. + */ +export default function Result( props ) { + const { highlight = {}, result: object, site, type } = props.result; + const isComment = type === 'comment'; + const highlightedTitle = highlight.title && highlight.title[0]; + const snippets = highlight.content; + const details = getDetails( type, object ); + const { authorName, excerptHtml, excerptText, label, title } = details; + const avatarUrls = details.avatarUrls || {}; + const avatarUrl = avatarUrls['48'] || avatarUrls['96'] || window.H2Data.site.default_avatar; + + // The router can only handle links on the current site. + const Anchor = isCurrentSite( site ) ? Link : 'a'; + const anchorProps = Anchor === Link ? { disablePreviews: true } : {}; + + return ( +
  • +
    + + { site.name } + + { label } + + + +
    + +

    + + { isComment && ( + Comment on + ) } + { highlightedTitle ? ( + + ) : ( + decodeEntities( title ) + ) } + +

    + + { authorName && ( +
    + + { authorName } +
    + ) } + + { snippets && snippets.length ? ( +
    + { snippets.map( ( snippet, index ) => ( +

    + ) ) } +

    + ) : excerptHtml ? ( +
    + ) : excerptText ? ( +
    + { excerptText } +
    + ) : null } +
  • + ); +} + +const Rendered = PropTypes.shape( { + rendered: PropTypes.string.isRequired, +} ); + +Result.propTypes = { + result: PropTypes.shape( { + type: PropTypes.string.isRequired, + site: PropTypes.shape( { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + url: PropTypes.string.isRequired, + } ).isRequired, + highlight: PropTypes.objectOf( PropTypes.arrayOf( PropTypes.string ) ), + // Post or comment, in the shape of its regular REST API endpoint. + result: PropTypes.shape( { + id: PropTypes.number.isRequired, + date_gmt: PropTypes.string.isRequired, + type: PropTypes.string, + link: PropTypes.string.isRequired, + title: Rendered, + excerpt: Rendered, + content: Rendered, + author_name: PropTypes.string, + author_avatar_urls: PropTypes.object, + _embedded: PropTypes.object, + } ).isRequired, + } ).isRequired, +}; diff --git a/src/components/Super/Results.js b/src/components/Super/Results.js new file mode 100644 index 00000000..83cb436a --- /dev/null +++ b/src/components/Super/Results.js @@ -0,0 +1,132 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +import LinkButton from '../LinkButton'; +import Notification from '../Notification'; + +import Result from './Result'; +import { formatTotal } from './util'; + +function Pagination( props ) { + const { page, totalPages, onChangePage } = props; + if ( totalPages <= 1 ) { + return null; + } + + return ( + + ); +} + +/** + * List of network search results. + * + * @param {object} props Results, request state, and paging callbacks. + * @returns {React.ReactNode} Results section. + */ +export default function Results( props ) { + const { error, loading, page, results, term, total, totalPages } = props; + + let heading; + if ( error ) { + heading = `Search for “${ term }”`; + } else if ( loading && ! results ) { + heading = `Searching for “${ term }”…`; + } else { + heading = `${ formatTotal( total ) } for “${ term }”`; + } + + return ( +
    +
    +

    + { heading } +

    + { loading && results && ( + Updating… + ) } +
    + + { error ? ( + + { error.message || 'The search request failed.' } + + ) : results && ! results.length ? ( +

    + Nothing matched “{ term }”. Try different words, or widen the filters. +

    + ) : results ? ( +
      + { results.map( result => ( + + ) ) } +
    + ) : null } + + { ! error && ( + + ) } +
    + ); +} + +Results.propTypes = { + error: PropTypes.object, + loading: PropTypes.bool.isRequired, + page: PropTypes.number.isRequired, + results: PropTypes.array, + term: PropTypes.string.isRequired, + total: PropTypes.number.isRequired, + totalPages: PropTypes.number.isRequired, + onChangePage: PropTypes.func.isRequired, +}; diff --git a/src/components/Super/Search.js b/src/components/Super/Search.js new file mode 100644 index 00000000..ad54610b --- /dev/null +++ b/src/components/Super/Search.js @@ -0,0 +1,303 @@ +import PropTypes from 'prop-types'; +import qs from 'qs'; +import React, { Component } from 'react'; + +import api from '../../api'; +import { withSites } from '../../hocs'; + +import Facets, { PERIODS } from './Facets'; +import Results from './Results'; + +/** + * Delay before searching after the term changes, in milliseconds. + */ +const DEBOUNCE = 250; + +export const PER_PAGE = 10; + +/** + * Build the query parameters for a search. + * + * @param {object} options Search options. + * @returns {object} Parameters for the `h2/v1/search` endpoint. + */ +export function buildQuery( options ) { + const { orderby, page, period, selectedSites, term, type } = options; + const params = { + search: term, + page, + per_page: PER_PAGE, + }; + + if ( selectedSites.length ) { + params.sites = selectedSites; + } + if ( type !== 'all' ) { + params.type = [ type ]; + } + if ( orderby === 'date' ) { + params.orderby = 'date'; + params.order = 'desc'; + } + + const periodOption = PERIODS.find( option => option.value === period ); + if ( periodOption && periodOption.days ) { + const after = new Date( Date.now() - periodOption.days * 24 * 60 * 60 * 1000 ); + params.after = after.toISOString().replace( /\.\d{3}Z$/, 'Z' ); + } + + return params; +} + +/** + * Get a string identifying the query represented by the search state. + * + * Unlike buildQuery(), this is stable over time, so can be used to detect + * changes to the query. + * + * @param {object} state Search state. + * @returns {string} Query signature. + */ +function getSignature( state ) { + const { orderby, page, period, selectedSites, term, type } = state; + return qs.stringify( { + term, + page, + sites: selectedSites, + type, + period, + orderby, + } ); +} + +/** + * Run a network search. + * + * @param {object} params Query parameters, from buildQuery(). + * @param {object} options Additional fetch options (e.g. `signal`). + * @returns {Promise} Results, with `results`, `total` and `totalPages`. + */ +export function fetchResults( params, options = {} ) { + const query = qs.stringify( params, { + arrayFormat: 'brackets', + } ); + + return api.fetch( `/h2/v1/search?${ query }`, options ).then( response => { + return response.json().catch( () => { + throw new Error( 'The search request failed.' ); + } ).then( data => { + if ( ! response.ok ) { + const error = new Error( data.message || 'The search request failed.' ); + error.code = data.code; + throw error; + } + + return { + results: data, + total: Number( response.headers.get( 'X-WP-Total' ) ) || 0, + totalPages: Number( response.headers.get( 'X-WP-TotalPages' ) ) || 0, + }; + } ); + } ); +} + +/** + * Presentational layout for search: facets on the left, results on the right. + * + * @param {object} props Search state and change callbacks; see Search. + * @returns {React.ReactNode} Search layout. + */ +export function SearchView( props ) { + const { orderby, period, selectedSites, sites, type } = props; + + return ( +
    + + +
    + ); +} + +SearchView.propTypes = { + error: PropTypes.object, + loading: PropTypes.bool.isRequired, + orderby: PropTypes.string.isRequired, + page: PropTypes.number.isRequired, + period: PropTypes.string.isRequired, + results: PropTypes.array, + selectedSites: PropTypes.arrayOf( PropTypes.number ).isRequired, + sites: PropTypes.object.isRequired, + term: PropTypes.string.isRequired, + total: PropTypes.number.isRequired, + totalPages: PropTypes.number.isRequired, + type: PropTypes.string.isRequired, + onChangeOrderby: PropTypes.func.isRequired, + onChangePage: PropTypes.func.isRequired, + onChangePeriod: PropTypes.func.isRequired, + onChangeSites: PropTypes.func.isRequired, + onChangeType: PropTypes.func.isRequired, +}; + +/** + * Network-wide search. + * + * Searches every site the user can access via the H2 Network plugin. The term + * from the input is debounced into state, and every change to the query + * (term, filters, page) triggers a fresh request, cancelling any in flight. + */ +export class Search extends Component { + constructor( props ) { + super( props ); + + this.state = { + error: null, + loading: true, + orderby: 'relevance', + page: 1, + period: 'any', + results: null, + selectedSites: [], + term: props.term, + total: 0, + totalPages: 0, + type: 'all', + }; + } + + componentDidMount() { + this.load(); + } + + componentDidUpdate( prevProps, prevState ) { + if ( prevProps.term !== this.props.term ) { + // Wait for a pause in typing before searching. + window.clearTimeout( this.timer ); + this.timer = window.setTimeout( () => { + this.setState( { + page: 1, + term: this.props.term, + } ); + }, DEBOUNCE ); + } + + if ( getSignature( this.state ) !== getSignature( prevState ) ) { + this.load(); + } + } + + componentWillUnmount() { + window.clearTimeout( this.timer ); + this.abort(); + } + + abort() { + if ( this.controller ) { + this.controller.abort(); + this.controller = null; + } + } + + load() { + this.abort(); + + const options = {}; + if ( typeof AbortController !== 'undefined' ) { + this.controller = new AbortController(); + options.signal = this.controller.signal; + } + const controller = this.controller; + + this.setState( { + error: null, + loading: true, + } ); + + fetchResults( buildQuery( this.state ), options ).then( data => { + if ( controller && controller !== this.controller ) { + // Superseded by a newer request. + return; + } + + this.setState( { + loading: false, + results: data.results, + total: data.total, + totalPages: data.totalPages, + } ); + } ).catch( error => { + if ( error.name === 'AbortError' || ( controller && controller !== this.controller ) ) { + return; + } + + this.setState( { + error, + loading: false, + results: null, + total: 0, + totalPages: 0, + } ); + } ); + } + + /** + * Change a filter, returning to the first page of results. + * + * @param {object} changes State changes. + */ + onChangeFilter( changes ) { + this.setState( { + ...changes, + page: 1, + } ); + } + + render() { + return ( + this.onChangeFilter( { orderby } ) } + onChangePage={ page => this.setState( { page } ) } + onChangePeriod={ period => this.onChangeFilter( { period } ) } + onChangeSites={ selectedSites => this.onChangeFilter( { selectedSites } ) } + onChangeType={ type => this.onChangeFilter( { type } ) } + /> + ); + } +} + +Search.propTypes = { + sites: PropTypes.object.isRequired, + term: PropTypes.string.isRequired, +}; + +export default withSites( Search ); diff --git a/src/components/Super/SearchInput.js b/src/components/Super/SearchInput.js new file mode 100644 index 00000000..c02627c7 --- /dev/null +++ b/src/components/Super/SearchInput.js @@ -0,0 +1,64 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { connect } from 'react-redux'; + +import { setSuperSearch } from '../../actions'; +import { FORM_CLASSES, getWrapClasses, INPUT_CLASSES } from '../SearchInput'; + +import { getNetwork } from './util'; + +/** + * Search input for network-wide search. + * + * Replaces the regular site search while the super overlay is open. The term + * is held in the store so that the header and overlay inputs stay in sync. + * + * @param {object} props Current value and change callback. + * @returns {React.ReactNode} Search form. + */ +export function NetworkSearchInput( props ) { + const { autoFocus, className, small, value, onChange } = props; + const network = getNetwork(); + + return ( + e.preventDefault() } + > +
    + onChange( e.target.value ) } + /> +
    + + ); +} + +NetworkSearchInput.propTypes = { + autoFocus: PropTypes.bool, + className: PropTypes.string, + small: PropTypes.bool, + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, +}; + +NetworkSearchInput.defaultProps = { + autoFocus: false, + small: false, +}; + +export default connect( + state => ( { + value: state.ui.superSearch, + } ), + dispatch => ( { + onChange: value => dispatch( setSuperSearch( value ) ), + } ) +)( NetworkSearchInput ); diff --git a/src/components/Super/SiteSwitcher.js b/src/components/Super/SiteSwitcher.js new file mode 100644 index 00000000..372049b3 --- /dev/null +++ b/src/components/Super/SiteSwitcher.js @@ -0,0 +1,122 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +import { withSites } from '../../hocs'; +import Notification from '../Notification'; + +import { displayUrl, isCurrentSite } from './util'; + +const CARD_CLASSES = [ + 'group block h-full p-5 rounded-sm', + 'border border-solid border-hm-beige bg-hm-beige/20', + 'text-black! no-underline!', + 'transition-colors duration-150', + 'hover:bg-hm-beige/50 hover:border-hm-warm-grey/40', + 'focus:bg-hm-beige/50 focus:border-hm-vibrant-blue focus:outline-hidden', +].join( ' ' ); + +export function SiteCard( props ) { + const { current, site } = props; + + return ( +
  • + +
    +

    + { site.name } +

    + { current ? ( + + Current + + ) : ( + + View → + + ) } +
    +

    + { site.description || No description yet. } +

    +

    + { displayUrl( site.url ) } +

    +
    +
  • + ); +} + +SiteCard.propTypes = { + current: PropTypes.bool, + site: PropTypes.shape( { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + description: PropTypes.string, + url: PropTypes.string.isRequired, + } ).isRequired, +}; + +/** + * Grid of every site the user can switch to. + * + * @param {object} props Props, with `sites` from the site switcher API. + * @returns {React.ReactNode} Site grid, or a loading/error state. + */ +export function SiteSwitcher( props ) { + const { sites } = props; + + if ( sites.isLoading ) { + return

    Loading sites…

    ; + } + + if ( sites.error ) { + return ( + + Could not load sites: { sites.error.message } + + ); + } + + const list = sites.data || []; + if ( ! list.length ) { + return ( + + No sites have been added to the site switcher yet. + + ); + } + + return ( +
    +

    + { list.length === 1 ? '1 site' : `${ list.length } sites` } +

    +
      + { list.map( site => ( + + ) ) } +
    +
    + ); +} + +SiteSwitcher.propTypes = { + sites: PropTypes.shape( { + data: PropTypes.array, + error: PropTypes.object, + isLoading: PropTypes.bool, + } ).isRequired, +}; + +export default withSites( SiteSwitcher ); diff --git a/src/components/Super/index.js b/src/components/Super/index.js new file mode 100644 index 00000000..77c3a901 --- /dev/null +++ b/src/components/Super/index.js @@ -0,0 +1,168 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; + +import { hideSuperSidebar } from '../../actions'; +import { getChangesForUser } from '../../changelog'; +import { withCurrentUser } from '../../hocs'; +import Button from '../Button'; +import Notification from '../Notification'; +import Overlay from '../Overlay'; +import Shortcuts from '../Shortcuts'; + +import Search from './Search'; +import NetworkSearchInput from './SearchInput'; +import SiteSwitcher, { SiteCard } from './SiteSwitcher'; +import { getNetwork } from './util'; + +// Invert along with the button, so the count stays readable on hover. +const BADGE_CLASSES = [ + 'inline-block ml-2 rounded-full h-4 min-w-4 px-1 align-middle', + 'text-xs leading-4 text-center font-normal', + 'bg-hm-vibrant-blue text-white transition-colors duration-200 ease-in-out', + 'group-hover:bg-white group-hover:text-hm-vibrant-blue', + 'group-focus:bg-white group-focus:text-hm-vibrant-blue', +].join( ' ' ); + +/** + * Full-screen overlay for switching sites and searching the network. + * + * Opens beneath the header, so that the header's search input stays available + * and becomes a network-wide search while the overlay is open. + */ +export class SuperOverlay extends Component { + state = { + top: 0, + } + + componentDidMount() { + this.updateOffset(); + window.addEventListener( 'resize', this.updateOffset ); + } + + componentWillUnmount() { + window.removeEventListener( 'resize', this.updateOffset ); + } + + /** + * Position the overlay directly beneath the header. + */ + updateOffset = () => { + const header = this.props.headerRef && this.props.headerRef.current; + const top = header ? Math.max( 0, Math.round( header.getBoundingClientRect().bottom ) ) : 0; + if ( top !== this.state.top ) { + this.setState( { top } ); + } + } + + renderSingleSite() { + const { site } = window.H2Data; + + return ( +
    + + This site isn't part of a network, so there are no other sites to switch to. + +
      + +
    +
    + ); + } + + render() { + const { currentUser, search, onClose } = this.props; + const network = getNetwork(); + const term = network ? search.trim() : ''; + const newChanges = ( currentUser && getChangesForUser( currentUser ) ) || []; + + return ( +
    + + + +
    +
    +

    + { network ? network.name : window.H2Data.site.name } +

    + + + + + + { network && ( + + ) } +
    + + { ! network ? ( + this.renderSingleSite() + ) : term ? ( + + ) : ( + + ) } +
    +
    + ); + } +} + +SuperOverlay.propTypes = { + currentUser: PropTypes.object, + headerRef: PropTypes.shape( { + current: PropTypes.any, + } ), + search: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + onShowChanges: PropTypes.func.isRequired, +}; + +export default connect( + state => ( { + search: state.ui.superSearch, + } ), + dispatch => ( { + onClose: () => dispatch( hideSuperSidebar() ), + } ) +)( withCurrentUser( SuperOverlay ) ); diff --git a/src/components/Super/index.stories.js b/src/components/Super/index.stories.js new file mode 100644 index 00000000..0c94068d --- /dev/null +++ b/src/components/Super/index.stories.js @@ -0,0 +1,226 @@ +import { action } from '@storybook/addon-actions'; +import React from 'react'; + +import { withPadding, withStore } from '../../stories/decorators'; +import { user, users } from '../../stories/stubs'; + +import { SearchView } from './Search'; +import { SiteSwitcher } from './SiteSwitcher'; + +export default { + title: 'Interface|Super Overlay', + decorators: [ + withStore(), + withPadding(), + ], +}; + +const sites = [ + { + id: 1, + name: 'Storybook Site', + description: 'The site you are currently viewing.', + url: 'http://example.com', + }, + { + id: 2, + name: 'Engineering', + description: 'Technical discussion, incident reports and release notes.', + url: 'https://example.com/eng', + }, + { + id: 3, + name: 'Updates', + description: '', + url: 'https://updates.example.com', + }, + { + id: 4, + name: 'Design', + description: 'Critique, research and the design system.', + url: 'https://example.com/design', + }, +]; + +const author = ( site, data = user ) => ( { + id: data.id, + name: data.name, + slug: data.slug, + link: `${ site.url }/author/${ data.slug }/`, + avatar_urls: data.avatar_urls, +} ); + +const results = [ + { + type: 'post', + site: sites[1], + score: 4.2, + highlight: { + title: [ 'Deploy checklist' ], + content: [ + 'Run through the checklist before pushing the button. If anything on the checklist fails, stop', + ], + }, + result: { + id: 123, + date: '2026-09-01T10:00:00', + date_gmt: '2026-09-01T09:00:00', + type: 'post', + link: 'https://example.com/eng/2026/09/01/deploy-checklist/', + title: { + rendered: 'Deploy checklist', + }, + excerpt: { + rendered: '

    Before every deploy, run through the checklist.

    ', + }, + author: user.id, + _embedded: { + author: [ author( sites[1] ) ], + }, + }, + }, + { + type: 'comment', + site: sites[0], + score: 2.1, + highlight: { + content: [ 'Have we added the new service to the checklist yet?' ], + }, + result: { + id: 456, + post: 1, + author: users[1].id, + author_name: users[1].name, + date: '2026-08-30T15:20:00', + date_gmt: '2026-08-30T14:20:00', + content: { + rendered: '

    Have we added the new service to the checklist yet?

    ', + }, + link: 'http://example.com/2018/01/01/tecum-optime/#comment-456', + author_avatar_urls: users[1].avatar_urls, + _embedded: { + author: [ author( sites[0], users[1] ) ], + up: [ + { + id: 1, + link: 'http://example.com/2018/01/01/tecum-optime/', + title: { + rendered: 'Tecum optime, deinde etiam cum mediocri amico.', + }, + }, + ], + }, + }, + }, + { + type: 'post', + site: sites[3], + score: 1.4, + highlight: {}, + result: { + id: 789, + date: '2026-07-12T09:00:00', + date_gmt: '2026-07-12T08:00:00', + type: 'page', + link: 'https://example.com/design/component-review-checklist/', + title: { + rendered: 'Component review checklist', + }, + excerpt: { + rendered: '

    Everything a component needs before it ships in the design system.

    ', + }, + author: user.id, + _embedded: { + author: [ author( sites[3] ) ], + }, + }, + }, +]; + +const searchProps = { + error: null, + loading: false, + orderby: 'relevance', + page: 1, + period: 'any', + results, + selectedSites: [], + sites: { + data: sites, + isLoading: false, + }, + term: 'checklist', + total: 23, + totalPages: 3, + type: 'all', + onChangeOrderby: action( 'onChangeOrderby' ), + onChangePage: action( 'onChangePage' ), + onChangePeriod: action( 'onChangePeriod' ), + onChangeSites: action( 'onChangeSites' ), + onChangeType: action( 'onChangeType' ), +}; + +export const Sites = () => ( + +); + +export const SitesLoading = () => ( + +); + +export const SearchResults = () => ( + +); + +export const SearchFiltered = () => ( + result.site.id === 2 ) } + selectedSites={ [ 2 ] } + total={ 1 } + totalPages={ 1 } + type="post" + /> +); + +export const SearchLoading = () => ( + +); + +export const SearchEmpty = () => ( + +); + +export const SearchError = () => ( + +); diff --git a/src/components/Super/util.js b/src/components/Super/util.js new file mode 100644 index 00000000..f62c550b --- /dev/null +++ b/src/components/Super/util.js @@ -0,0 +1,69 @@ +/** + * Get the network the site belongs to, if any. + * + * `H2Data.network` is provided by the H2 Network plugin: unset when the plugin + * isn't active, `false` on single-site installs, and otherwise an object + * describing the network. + * + * @returns {object|null} Network data, or null when not part of a network. + */ +export function getNetwork() { + return ( window.H2Data && window.H2Data.network ) || null; +} + +/** + * Normalise a URL for comparison. + * + * @param {string} url URL to normalise. + * @returns {string} URL without scheme or trailing slashes. + */ +const normaliseUrl = url => ( url || '' ).replace( /^https?:\/\//, '' ).replace( /\/+$/, '' ).toLowerCase(); + +/** + * Check whether a site from the network API is the site being viewed. + * + * @param {object} site Site data, with a `url`. + * @returns {boolean} True if the site is the current one. + */ +export function isCurrentSite( site ) { + return normaliseUrl( site.url ) === normaliseUrl( window.H2Data.site.home ); +} + +/** + * Format a URL for display. + * + * @param {string} url Full URL. + * @returns {string} URL without the scheme or trailing slash. + */ +export function displayUrl( url ) { + return ( url || '' ).replace( /^https?:\/\//, '' ).replace( /\/+$/, '' ); +} + +/** + * Get the text of an HTML string. + * + * The HTML is parsed into an inert document, so nothing in it is run or loaded. + * + * @param {string} html HTML to get the text of. + * @returns {string} Text content. + */ +export function getPlainText( html ) { + const doc = new DOMParser().parseFromString( html || '', 'text/html' ); + return ( doc.body.textContent || '' ).trim(); +} + +/** + * Format a count of results. + * + * Elasticsearch stops counting at 10,000 results. + * + * @param {number} total Total results. + * @returns {string} Formatted count. + */ +export function formatTotal( total ) { + if ( total >= 10000 ) { + return '10,000+ results'; + } + + return total === 1 ? '1 result' : `${ total.toLocaleString() } results`; +} diff --git a/src/components/SuperMenu.js b/src/components/SuperMenu.js deleted file mode 100644 index 335dd9cd..00000000 --- a/src/components/SuperMenu.js +++ /dev/null @@ -1,134 +0,0 @@ -import React from 'react'; - -import { getChangesForUser } from '../changelog'; -import { withCategories, withCurrentUser, withSites } from '../hocs'; - -import HeaderLabel from './Header/HeaderLabel'; -import Logo from './Header/Logo'; -import Link from './Link'; -import Overlay from './Overlay'; -import SearchInput from './SearchInput'; - -const Category = ( { all, category } ) => { - const childCategories = all.filter( cat => cat.parent === category.id ); - - return ( -
  • - - { category.name } - - - { childCategories && ( - - ) } -
  • - ); -}; - -const CategoryList = ( { all, categories } ) => ( -
      - { categories && categories.map( category => ( - - ) ) } -
    -); - -export class SuperMenu extends React.Component { - render() { - const { categories, visible } = this.props; - const sites = this.props.sites && this.props.sites.data; - - const classes = [ - 'SuperMenu', - 'bg-hm-light-grey absolute w-[300px] h-screen overflow-auto z-30 p-4 px-5 shadow-[0_1px_3px_rgba(0,0,0,0.15)] transition-transform duration-150 flex flex-col', - visible ? 'SuperMenu--visible translate-x-0' : '-translate-x-[300px]', - ]; - - const newChanges = this.props.currentUser ? getChangesForUser( this.props.currentUser ) : []; - - const newLabel = ( - - What's New? - { ' ' } - { newChanges.length >= 0 ? { newChanges.length + 8 } : null } - - ); - - return ( - - ); - } -} - -export default withSites( withCategories( withCurrentUser( SuperMenu ) ) ); diff --git a/src/components/SuperMenu.stories.js b/src/components/SuperMenu.stories.js deleted file mode 100644 index 233d6ed1..00000000 --- a/src/components/SuperMenu.stories.js +++ /dev/null @@ -1,46 +0,0 @@ -import { action } from '@storybook/addon-actions'; -import React from 'react'; - -import { withStore } from '../stories/decorators'; - -import { SuperMenu } from './SuperMenu'; - -export default { - title: 'Interface|Super Menu', - decorators: [ - withStore(), - ], -}; - -const sites = [ - { - id: 1, - url: 'https://example.com/updates/', - name: 'Updates', - }, - { - id: 2, - url: 'https://example.com/dev/', - name: 'Dev', - }, -]; - -const commonProps = { - categories: [], - sites: [], - visible: true, - onClose: action( 'onClose' ), -}; - -export const Empty = () => ( - -); - -export const WithSites = () => ( - -); diff --git a/src/reducers/ui.js b/src/reducers/ui.js index 73b586ed..e2b7444b 100644 --- a/src/reducers/ui.js +++ b/src/reducers/ui.js @@ -8,6 +8,7 @@ import { HIDE_SIDEBAR, SHOW_SUPER_SIDEBAR, HIDE_SUPER_SIDEBAR, + SET_SUPER_SEARCH, SET_DEFAULT_POST_VIEW, } from '../actions'; @@ -15,6 +16,7 @@ const DEFAULT_STATE = { defaultPostView: 'expanded', showingSuper: false, sidebarProfile: null, + superSearch: '', }; export default function ui( state = DEFAULT_STATE, action ) { @@ -74,6 +76,13 @@ export default function ui( state = DEFAULT_STATE, action ) { return { ...state, showingSuper: false, + superSearch: '', + }; + + case SET_SUPER_SEARCH: + return { + ...state, + superSearch: action.search, }; case SET_DEFAULT_POST_VIEW: diff --git a/src/tailwind.css b/src/tailwind.css index 1cb30674..a7d1e553 100644 --- a/src/tailwind.css +++ b/src/tailwind.css @@ -26,6 +26,15 @@ --animate-hovercard-in: hovercard-fade-in-up 0.1s ease forwards; --animate-hovercard-out: hovercard-fade-out-down 0.1s ease forwards; + --animate-super-in: super-fade-in 0.15s ease-out; + @keyframes super-fade-in { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } + } @keyframes hovercard-fade-in-up { 0% { opacity: 0;