this.inputEl = ref }
diff --git a/src/components/Sidebar.js b/src/components/Sidebar.js
index edb84d0c..521324c2 100644
--- a/src/components/Sidebar.js
+++ b/src/components/Sidebar.js
@@ -1,8 +1,12 @@
-import React, { Component } from 'react';
+import React from 'react';
+import { connect } from 'react-redux';
+import { Link } from 'react-router-dom';
import { Slot } from 'react-slot-fill';
+import { showSidebarCategories, showSidebarPages, showSidebarPosts } from '../actions';
import { withWidgets } from '../hocs';
+import NotificationSettings from './Sidebar/NotificationSettings';
import RecentPostsWidget from './Widgets/RecentPosts';
import SearchWidget from './Widgets/Search';
@@ -17,40 +21,122 @@ const widgetMap = {
search: SearchWidget,
};
-export class Sidebar extends Component {
- constructor( props ) {
- super( props );
-
- this.state = {
- active: false,
- };
- }
-
- render() {
- const { active } = this.state;
- const activeClasses = active
- ? 'opacity-100 transition-opacity duration-160'
- : 'opacity-10 transition-opacity duration-800 delay-400';
-
- return (
-
+ );
}
-export default withWidgets( Sidebar );
+export default withWidgets( connect()( Sidebar ) );
diff --git a/src/components/Sidebar/Categories.js b/src/components/Sidebar/Categories.js
new file mode 100644
index 00000000..952da708
--- /dev/null
+++ b/src/components/Sidebar/Categories.js
@@ -0,0 +1,70 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+
+import { withCategories } from '../../hocs';
+
+import Container from './Container';
+
+const internalLink = link => link.replace( window.H2Data.site.home, '' );
+
+const Category = ( { all, category } ) => {
+ const childCategories = all.filter( cat => cat.parent === category.id );
+
+ return (
+
+
+
+ { category.name }
+
+ { Object.prototype.hasOwnProperty.call( category, 'count' ) && (
+
+ ({ category.count })
+
+ ) }
+
+
+ →
+
+
+
+ { childCategories && childCategories.length > 0 && (
+
+ ) }
+
+ );
+};
+
+const CategoryList = ( { all, categories } ) => (
+
+ { categories && categories.map( category => (
+
+ ) ) }
+
+);
+
+export function Categories( props ) {
+ return (
+
+
+ cat.parent === 0 ) : [] }
+ />
+
+
+ );
+}
+export default withCategories( Categories );
diff --git a/src/components/Sidebar/Container.js b/src/components/Sidebar/Container.js
index 462bbd9e..0e5d885c 100644
--- a/src/components/Sidebar/Container.js
+++ b/src/components/Sidebar/Container.js
@@ -6,7 +6,6 @@ const BASE_CLASSES = [
// Back-compat:
'Sidebar-Container',
- 'text-[0.8em] mt-[1em] px-[1em]',
'border-l border-r border-solid border-hm-beige',
].join( ' ' );
@@ -22,7 +21,9 @@ export default function Container( props ) {
onClose={ onClose }
/>
- { children }
+
+ { children }
+
);
}
diff --git a/src/components/Sidebar/NotificationSettings.js b/src/components/Sidebar/NotificationSettings.js
new file mode 100644
index 00000000..522e6a1b
--- /dev/null
+++ b/src/components/Sidebar/NotificationSettings.js
@@ -0,0 +1,228 @@
+import PropTypes from 'prop-types';
+import React, { useEffect, useRef, useState } from 'react';
+
+import { withCurrentUser } from '../../hocs';
+import { User } from '../../shapes';
+
+// Falcon stores preferences per-connector; H2 uses the WordPress connector.
+const CONNECTOR = 'wordpress';
+
+const MODES = [
+ {
+ id: 'none',
+ label: 'None',
+ description: 'Unsubscribe from all email notifications.',
+ value: {
+ post: '',
+ comment: '',
+ },
+ },
+ {
+ id: 'posts',
+ label: 'Just posts',
+ description: 'Get an email for every new post.',
+ value: {
+ post: 'all',
+ comment: '',
+ },
+ },
+ {
+ id: 'participant',
+ label: 'Posts & my threads',
+ description: 'New posts, plus comments on posts you’ve commented on.',
+ value: {
+ post: 'all',
+ comment: 'participant',
+ },
+ },
+ {
+ id: 'all',
+ label: 'All posts & comments',
+ description: 'Get an email for every new post and comment.',
+ value: {
+ post: 'all',
+ comment: 'all',
+ },
+ },
+];
+
+/**
+ * Find the mode matching the user's current preferences.
+ *
+ * @param {object} prefs Falcon preferences for the connector.
+ * @returns {string} Mode ID, or "custom".
+ */
+const getMode = prefs => {
+ const mode = MODES.find( mode => (
+ mode.value.post === prefs.post && mode.value.comment === prefs.comment
+ ) );
+ if ( mode ) {
+ return mode.id;
+ }
+
+ // Set to a combination we don't offer (e.g. via the profile screen).
+ return 'custom';
+};
+
+const getModeLabel = mode => {
+ if ( mode === 'custom' ) {
+ return 'Custom';
+ }
+
+ return MODES.find( m => m.id === mode ).label;
+};
+
+const BellIcon = ( { mode } ) => {
+ const strokeProps = {
+ fill: 'none',
+ stroke: 'currentColor',
+ strokeWidth: 2,
+ strokeLinecap: 'round',
+ strokeLinejoin: 'round',
+ };
+
+ if ( mode === 'none' ) {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ { mode === 'participant' && (
+
+ ) }
+
+ );
+};
+
+export function NotificationSettings( props ) {
+ const { currentUser, loadingCurrentUser, savingCurrentUser } = props;
+ const [ open, setOpen ] = useState( false );
+ const container = useRef( null );
+
+ // Preferences are only exposed in the edit context, which the preloaded
+ // user doesn't use, so load them if we don't have them yet.
+ const needsPreferences = currentUser && currentUser.falcon_preferences === undefined;
+ useEffect( () => {
+ if ( needsPreferences && ! loadingCurrentUser ) {
+ props.onLoadCurrentUser( 'edit' );
+ }
+ // Only attempt this once per user, to avoid looping on failure.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [ currentUser && currentUser.id ] );
+
+ // Close when clicking outside, or pressing Escape.
+ useEffect( () => {
+ if ( ! open ) {
+ return;
+ }
+
+ const onClick = e => {
+ if ( container.current && ! container.current.contains( e.target ) ) {
+ setOpen( false );
+ }
+ };
+ const onKeyDown = e => {
+ if ( e.key === 'Escape' ) {
+ setOpen( false );
+ }
+ };
+ document.addEventListener( 'mousedown', onClick );
+ document.addEventListener( 'keydown', onKeyDown );
+ return () => {
+ document.removeEventListener( 'mousedown', onClick );
+ document.removeEventListener( 'keydown', onKeyDown );
+ };
+ }, [ open ] );
+
+ const prefs = currentUser && currentUser.falcon_preferences && currentUser.falcon_preferences[ CONNECTOR ];
+ if ( ! prefs ) {
+ // Still loading, or Falcon isn't exposing preferences for this site.
+ return null;
+ }
+
+ const mode = getMode( prefs );
+ const label = `Email notifications: ${ getModeLabel( mode ) }`;
+
+ const onSelect = next => {
+ props.onUpdateCurrentUser( {
+ falcon_preferences: {
+ [ CONNECTOR ]: next.value,
+ },
+ } );
+ };
+
+ return (
+
+
setOpen( ! open ) }
+ >
+
+
+
+ { open && (
+
+
+ Email notifications
+
+
+ { MODES.map( option => (
+
+ onSelect( option ) }
+ >
+
+ { mode === option.id ? '✓' : '' }
+
+
+ { option.label }
+ { option.description }
+
+
+
+ ) ) }
+
+
+ ) }
+
+ );
+}
+
+NotificationSettings.propTypes = {
+ currentUser: User,
+ loadingCurrentUser: PropTypes.bool,
+ savingCurrentUser: PropTypes.bool,
+ onLoadCurrentUser: PropTypes.func.isRequired,
+ onUpdateCurrentUser: PropTypes.func.isRequired,
+};
+
+export default withCurrentUser( NotificationSettings );
diff --git a/src/components/Sidebar/Pages.js b/src/components/Sidebar/Pages.js
new file mode 100644
index 00000000..93ed851c
--- /dev/null
+++ b/src/components/Sidebar/Pages.js
@@ -0,0 +1,112 @@
+import { withPagedArchive } from '@humanmade/repress';
+import React, { useEffect, useState } from 'react';
+import { connect } from 'react-redux';
+import { Link } from 'react-router-dom';
+
+import { pages } from '../../types';
+import { decodeEntities } from '../../util';
+
+import Container from './Container';
+
+const internalLink = link => link.replace( window.H2Data.site.home, '' );
+
+const Page = ( { all, page } ) => {
+ const childPages = all.filter( p => p.parent === page.id );
+
+ return (
+
+
+ { decodeEntities( page.title.rendered ) }
+
+ →
+
+
+
+ { childPages.length > 0 && (
+
+ ) }
+
+ );
+};
+
+const PageList = ( { all, pages: pageItems } ) => (
+
+ { pageItems.map( page => (
+
+ ) ) }
+
+);
+
+/**
+ * Receives per-page props from withPagedArchive. Automatically advances to the
+ * next page when a page finishes loading, until hasMore is false.
+ *
+ * @param {object} props Props passed down from withPagedArchive.
+ * @returns {React.ReactNode} Loading indicator, or the full page list.
+ */
+function PagesLoader( props ) {
+ const { loading, loadingMore, hasMore, posts, onNextPage } = props;
+
+ // Advance to the next page once the current page has finished loading.
+ useEffect( () => {
+ if ( ! loading && ! loadingMore && posts && hasMore ) {
+ onNextPage();
+ }
+ }, [ loading, loadingMore, posts, hasMore, onNextPage ] );
+
+ if ( loading || loadingMore || hasMore ) {
+ return
Loading…
;
+ }
+
+ const topLevel = ( props.all || [] ).filter( p => p.parent === 0 );
+ return (
+
+ );
+}
+
+const ConnectedPagesLoader = connect(
+ state => ( { all: pages.getArchive( state.pages, 'all' ) } )
+)( PagesLoader );
+
+const mapPropsToArchive = () => {
+ pages.registerArchive( 'all', {
+ per_page: 100,
+ order: 'asc',
+ orderby: 'menu_order',
+ _fields: 'id,link,parent,title',
+ } );
+ return 'all';
+};
+
+const ConnectedLoader = withPagedArchive( pages, state => state.pages, mapPropsToArchive )( ConnectedPagesLoader );
+
+export default function Pages( props ) {
+ const [ page, setPage ] = useState( 1 );
+
+ return (
+
+
+ setPage( p => p + 1 ) }
+ />
+
+
+ );
+}
diff --git a/src/components/Sidebar/Posts.js b/src/components/Sidebar/Posts.js
new file mode 100644
index 00000000..05343e25
--- /dev/null
+++ b/src/components/Sidebar/Posts.js
@@ -0,0 +1,105 @@
+import { withPagedArchive } from '@humanmade/repress';
+import qs from 'qs';
+import React, { useState } from 'react';
+import { Link as InternalLink } from 'react-router-dom';
+
+import { posts } from '../../types';
+import { decodeEntities } from '../../util';
+import FormattedDate from '../FormattedDate';
+import Link from '../Link';
+
+import Container from './Container';
+import Pagination from './Pagination';
+
+function PostList( props ) {
+ if ( props.loading || props.loadingMore ) {
+ return
Loading…
;
+ }
+
+ if ( ! props.posts ) {
+ return
Error: No recent posts found!
;
+ }
+
+ const hasNext = props.hasMore;
+ const hasPrevious = props.page > 1;
+
+ return (
+
+ );
+}
+
+const mapPropsToArchive = props => {
+ const args = {
+ per_page: props.per_page || 10,
+ };
+
+ const id = qs.stringify( args );
+ posts.registerArchive( id, args );
+ return id;
+};
+
+const ConnectedPostList = withPagedArchive( posts, state => state.posts, mapPropsToArchive )( PostList );
+
+export default function RecentPosts( props ) {
+ const { number } = props;
+ const [ page, setPage ] = useState( 1 );
+
+ return (
+
+
+
+
+ View all
+
+ →
+
+
+
+
setPage( page + 1 ) }
+ onPrevious={ () => setPage( page - 1 ) }
+ />
+
+
+ );
+}
diff --git a/src/components/Sidebar/TitleBar.js b/src/components/Sidebar/TitleBar.js
index 5fb51cbe..0fc6484f 100644
--- a/src/components/Sidebar/TitleBar.js
+++ b/src/components/Sidebar/TitleBar.js
@@ -1,24 +1,31 @@
import PropTypes from 'prop-types';
import React from 'react';
+import { Link } from 'react-router-dom';
import Button from '../Button';
-const CLASSES = [
- 'flex items-baseline justify-between overflow-hidden',
- 'sticky top-0 z-2',
- 'bg-hm-beige',
- 'm-0 -mx-[1em] mb-[1em] p-[0.5em]',
- '[&_h2]:normal-case [&_h2]:text-base [&_h2]:leading-[1.4] [&_h2]:m-0',
- '[&_.btn]:m-0',
-].join( ' ' );
-
export default function TitleBar( props ) {
return (
-
- { props.title }
-
- Close
-
+
);
}
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 (
+
+
+
+ );
+}
+
+/**
+ * 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 (
+
+
+ { sites.isLoading ? (
+ Loading sites…
+ ) : (
+
+ ) }
+
+
+
+
+
+
+
+
+ );
+}
+
+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 && (
+
+ ) }
+
+ { 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 (
+
+
+ { page > 1 && (
+ onChangePage( page - 1 ) }
+ >
+ ←
+ Previous
+
+ ) }
+
+ Page { page } of { totalPages }
+
+ { page < totalPages && (
+ onChangePage( page + 1 ) }
+ >
+ Next
+ →
+
+ ) }
+
+
+ );
+}
+
+/**
+ * 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 (
+
+ );
+}
+
+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 }
+
+
+
+ What's New?
+ { newChanges.length > 0 && (
+
+ { newChanges.length }
+
+ ) }
+
+
+
+ Close
+
+
+ { 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 (
-
- { visible && (
-
- ) }
-
-
-
-
-
-
-
Navigation
-
-
-
-
-
- { categories.data && (
-
- Categories
- cat.parent === 0 ) }
- />
-
- ) }
-
- { sites ? (
-
- Sites
-
-
- ) : null }
-
-
- );
- }
-}
-
-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/components/UserHovercard.stories.js b/src/components/UserHovercard.stories.js
index 212841bf..46700b11 100644
--- a/src/components/UserHovercard.stories.js
+++ b/src/components/UserHovercard.stories.js
@@ -31,3 +31,19 @@ export const Basic = () => (
/>
);
+
+export const WithoutLocation = () => (
+
+
+
+);
diff --git a/src/hocs.js b/src/hocs.js
index e930e1ee..a01b82e9 100644
--- a/src/hocs.js
+++ b/src/hocs.js
@@ -8,8 +8,10 @@ export const withCurrentUser = connect(
state => ( {
currentUser: users.getSingle( state.users, state.users.current ),
loadingCurrentUser: users.isPostLoading( state.users, state.users.current ),
+ savingCurrentUser: users.isPostSaving( state.users, state.users.current ),
} ),
dispatch => ( {
+ onLoadCurrentUser: context => dispatch( users.fetchCurrent( context ) ),
onUpdateCurrentUser: data => dispatch( users.updateCurrent( data ) ),
} )
);
@@ -43,6 +45,6 @@ export const withUsers = withArchive(
}
);
-export const withCategories = withApiData( () => ( { categories: '/wp/v2/categories?per_page=100&_fields=id,link,name,slug' } ) );
+export const withCategories = withApiData( () => ( { categories: '/wp/v2/categories?per_page=100&_fields=id,count,link,name,slug,parent' } ) );
export const withSites = withApiData( () => ( { sites: '/h2/v1/site-switcher/sites' } ) );
export const withWidgets = withApiData( () => ( { widgets: '/h2/v1/widgets?sidebar=sidebar' } ) );
diff --git a/src/reducers/ui.js b/src/reducers/ui.js
index 0d930a8e..e2b7444b 100644
--- a/src/reducers/ui.js
+++ b/src/reducers/ui.js
@@ -1,10 +1,14 @@
import {
SHOW_META_SIDEBAR,
+ SHOW_SIDEBAR_CATEGORIES,
SHOW_SIDEBAR_COMMENTS,
+ SHOW_SIDEBAR_PAGES,
+ SHOW_SIDEBAR_POSTS,
SHOW_SIDEBAR_PROFILE,
HIDE_SIDEBAR,
SHOW_SUPER_SIDEBAR,
HIDE_SUPER_SIDEBAR,
+ SET_SUPER_SEARCH,
SET_DEFAULT_POST_VIEW,
} from '../actions';
@@ -12,6 +16,7 @@ const DEFAULT_STATE = {
defaultPostView: 'expanded',
showingSuper: false,
sidebarProfile: null,
+ superSearch: '',
};
export default function ui( state = DEFAULT_STATE, action ) {
@@ -29,6 +34,24 @@ export default function ui( state = DEFAULT_STATE, action ) {
sidebarProfile: action.id,
};
+ case SHOW_SIDEBAR_CATEGORIES:
+ return {
+ ...state,
+ sidebarView: 'categories',
+ };
+
+ case SHOW_SIDEBAR_PAGES:
+ return {
+ ...state,
+ sidebarView: 'pages',
+ };
+
+ case SHOW_SIDEBAR_POSTS:
+ return {
+ ...state,
+ sidebarView: 'posts',
+ };
+
case SHOW_SIDEBAR_PROFILE:
return {
...state,
@@ -53,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/reducers/users.js b/src/reducers/users.js
index 20e05094..7db627d9 100644
--- a/src/reducers/users.js
+++ b/src/reducers/users.js
@@ -5,7 +5,7 @@ const DEFAULT_STATE = {
current: null,
};
-export default function ( state, action ) {
+export default function reduceUsers( state, action ) {
switch ( action.type ) {
case SET_CURRENT_USER:
return {
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;
diff --git a/src/types.js b/src/types.js
index 5888287e..bb3880d5 100644
--- a/src/types.js
+++ b/src/types.js
@@ -9,6 +9,7 @@ export const posts = new handler( {
_fields: 'id,author,categories,content,date,date_gmt,excerpt,link,slug,status,tags,title,unprocessed_content',
},
} );
+posts.registerArchive( '', {} );
export const comments = new handler( {
type: 'comments',
@@ -28,11 +29,17 @@ export const users = new handler( {
nonce: window.wpApiSettings.nonce,
query: {
// Dodges Yoast SEO data computation.
- _fields: 'id,name,facts,link,slug,avatar_urls,meta',
+ // (falcon_preferences is only returned for the current user in the edit context.)
+ _fields: 'id,name,facts,link,slug,avatar_urls,meta,falcon_preferences',
},
} );
users.registerArchive( 'me', state => ( { include: state.users.current } ) );
users.registerArchive( 'all', { per_page: 100 } );
+users.fetchCurrent = ( function ( context = 'view' ) {
+ return ( dispatch, getState ) => {
+ return dispatch( this.fetchSingle( getState().users.current, context ) );
+ };
+} ).bind( users );
users.updateCurrent = ( function ( data ) {
return ( dispatch, getState ) => {
return dispatch(
diff --git a/src/wordpress-rest-api-cookie-auth.js b/src/wordpress-rest-api-cookie-auth.js
index 0acdd548..15c2dfa1 100644
--- a/src/wordpress-rest-api-cookie-auth.js
+++ b/src/wordpress-rest-api-cookie-auth.js
@@ -14,7 +14,7 @@ export const parseResponse = resp => resp.json().then( data => {
throw err;
} );
-export default class {
+export default class WordPressRestApiCookieAuth {
constructor( config ) {
this.url = config.rest_url ? config.rest_url : config.url + 'wp-json';
this.url = this.url.replace( /\/$/, '' );