diff --git a/.build-script b/.build-script index 793861fd..16be26f0 100755 --- a/.build-script +++ b/.build-script @@ -1,23 +1,25 @@ #!/bin/bash -set -e +set -euo pipefail if [ -d "$HOME/.nvm" ]; then export NVM_DIR="$HOME/.nvm" [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" - NODE_VERSION=`nvm current` + NODE_VERSION="$( nvm current )" - nvm install v12 - nvm use v12 + nvm install + nvm use fi -npm ci +# iltorb is not compatible with current Node releases and is not required for +# the webpack build. +npm ci --ignore-scripts npm run build # clean-up rm -rf node_modules -if command -v nvm; then - nvm use $NODE_VERSION +if command -v nvm && [[ "$NODE_VERSION" != "none" && "$NODE_VERSION" != "N/A" ]]; then + nvm use "$NODE_VERSION" fi diff --git a/.nvmrc b/.nvmrc index dae199ae..54c65116 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v12 +v24 diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html index e63e5342..e944fb52 100644 --- a/.storybook/preview-head.html +++ b/.storybook/preview-head.html @@ -10,6 +10,9 @@ }, preload: {}, features: {}, + has: { + notifications: false, + }, }; window.wpApiSettings = {}; diff --git a/inc/namespace.php b/inc/namespace.php index 4b736584..7fbc4a17 100644 --- a/inc/namespace.php +++ b/inc/namespace.php @@ -362,6 +362,7 @@ function get_script_data() { 'asset_url' => get_theme_file_uri( 'build/' ), 'site' => [ 'name' => get_bloginfo( 'name' ), + 'description' => get_bloginfo( 'description' ), 'url' => site_url(), 'home' => home_url(), 'api' => rest_url(), @@ -376,6 +377,9 @@ function get_script_data() { 'emoji' => apply_filters( 'h2.custom_emoji', [] ), ], 'features' => [], + 'has' => [ + 'notifications' => defined( 'FALCON_PATH' ), + ], 'plugins' => [ 'reactions' => \class_exists( 'H2\\Reactions\\Reaction' ), ], @@ -402,7 +406,7 @@ function get_script_data() { } } - return $data; + return apply_filters( 'h2.script_data', $data ); } /** @@ -536,6 +540,7 @@ function register_rest_routes() { 'methods' => 'POST', 'callback' => __NAMESPACE__ . '\\render_preview', 'permission_callback' => function () { + // Anyone who can comment or write can preview. return is_user_logged_in(); }, 'args' => [ diff --git a/src/App.css b/src/App.css index 8c9e9650..3b6647da 100644 --- a/src/App.css +++ b/src/App.css @@ -11,15 +11,12 @@ } .Outer { - margin: 0 auto; - display: flex; - max-width: 1200px; + /* display: flex; */ } .Inner { flex-direction: column; - width: 65%; - max-width: 800px; + /* width: 65%; */ } @media ( min-width: 800px ) { @@ -27,10 +24,8 @@ position: sticky; top: 0; height: 100vh; - margin-left: 5%; overflow: hidden; overflow-y: scroll; - width: 30%; flex-shrink: 0; } } diff --git a/src/App.js b/src/App.js index aef2ba96..8ee32dc4 100644 --- a/src/App.js +++ b/src/App.js @@ -18,9 +18,13 @@ import WritePost from './components/Post/Write'; import Profile from './components/Profile'; import SessionExpiredWarning from './components/SessionExpiredWarning'; import Sidebar from './components/Sidebar'; +import CategoriesSidebar from './components/Sidebar/Categories'; import CommentsSidebar from './components/Sidebar/Comments'; -import SuperMenu from './components/SuperMenu'; +import PagesSidebar from './components/Sidebar/Pages'; +import PostsSidebar from './components/Sidebar/Posts'; +import SuperOverlay from './components/Super'; import { RenderPlugins } from './plugins'; +import { posts } from './types'; import './App.css'; @@ -33,6 +37,7 @@ class App extends Component { isShowingWritePost: false, showChanges: false, }; + this.headerRef = React.createRef(); } onLogOut() { window.location.href = '/wp-login.php?action=logout'; @@ -68,6 +73,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 } ); } @@ -80,9 +98,10 @@ class App extends Component { this.props.history.push( string ? `/search/${ encodeURIComponent( string ) }` : '/' ); } - onDidCreatePost( post ) { + onDidCreatePost() { this.setState( { isShowingWritePost: false } ); - this.props.history.push( post.link.replace( /^(?:\/\/|[^/]+)*\//, '/' ) ); + this.props.history.push( '/' ); + this.props.onRefreshPostStream(); } renderSidebar() { @@ -111,6 +130,27 @@ class App extends Component { /> ); + case 'categories': + return ( + + ); + + case 'pages': + return ( + + ); + + case 'posts': + return ( + + ); + default: return ; } @@ -120,24 +160,29 @@ 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() } +
{ this.state.isShowingWritePost ? ( this.onDidCreatePost( post ) } @@ -146,6 +191,15 @@ class App extends Component { ) : null } + ( + this.onDidCreatePost( post ) } + /> + ) } + />
- { this.renderSidebar() }
{ this.state.showChanges ? ( { onDismissSidebar: () => dispatch( hideSidebar() ), onHideSuperSidebar: () => dispatch( hideSuperSidebar() ), onShowSuperSidebar: () => dispatch( showSuperSidebar() ), + onRefreshPostStream: () => dispatch( posts.fetchArchive( '' ) ), }; }; 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 cdf50b09..df30576c 100644 --- a/src/actions.js +++ b/src/actions.js @@ -2,9 +2,13 @@ export const REGISTER_PLUGIN = 'REGISTER_PLUGIN'; export const SHOW_META_SIDEBAR = 'SHOW_META_SIDEBAR'; export const SHOW_SIDEBAR_PROFILE = 'SHOW_SIDEBAR_PROFILE'; export const SHOW_SIDEBAR_COMMENTS = 'SHOW_SIDEBAR_COMMENTS'; +export const SHOW_SIDEBAR_CATEGORIES = 'SHOW_SIDEBAR_CATEGORIES'; +export const SHOW_SIDEBAR_PAGES = 'SHOW_SIDEBAR_PAGES'; +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'; @@ -25,6 +29,10 @@ export const showSidebarComments = id => { }; }; +export const showSidebarCategories = () => ( { type: SHOW_SIDEBAR_CATEGORIES } ); +export const showSidebarPages = () => ( { type: SHOW_SIDEBAR_PAGES } ); +export const showSidebarPosts = () => ( { type: SHOW_SIDEBAR_POSTS } ); + export const hideSidebar = () => ( { type: HIDE_SIDEBAR } ); export const showMetaSidebar = () => ( { type: SHOW_META_SIDEBAR } ); @@ -32,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/Editor/index.js b/src/components/Editor/index.js index 5b8a0e05..93b374a8 100644 --- a/src/components/Editor/index.js +++ b/src/components/Editor/index.js @@ -119,6 +119,7 @@ class Editor extends React.PureComponent { count: 0, hasFocus: false, height: null, + isPublishing: false, mode: 'edit', uploading: [], }; @@ -134,6 +135,10 @@ class Editor extends React.PureComponent { } warnBeforeLeaving = e => { + if ( this.state.isPublishing || this.props.isSubmitting ) { + return; + } + if ( this.state.content === '' || ( this.props.initialValue && this.state.content === this.props.initialValue ) ) { return; } @@ -142,7 +147,12 @@ class Editor extends React.PureComponent { return navigateWarning; } - componentDidUpdate() { + componentDidUpdate( prevProps ) { + if ( this.state.isPublishing && prevProps.isSubmitting && ! this.props.isSubmitting ) { + window.addEventListener( 'beforeunload', this.warnBeforeLeaving ); + this.setState( { isPublishing: false } ); + } + if ( ! this.textarea ) { return; } @@ -266,6 +276,10 @@ class Editor extends React.PureComponent { onSubmit( e ) { e.preventDefault(); + // Removing this synchronously is important: React state updates do not + // necessarily flush before the successful submit navigates away. + window.removeEventListener( 'beforeunload', this.warnBeforeLeaving ); + this.setState( { isPublishing: true } ); this.props.onSubmit( compileMarkdown( this.state.content ), this.state.content ); } @@ -518,7 +532,7 @@ class Editor extends React.PureComponent { ) } @@ -584,6 +598,7 @@ Editor.propTypes = { className: PropTypes.string, previewComponent: PropTypes.func, saveText: PropTypes.string, + isSubmitting: PropTypes.bool, submitText: PropTypes.string, onCancel: PropTypes.func, onSubmit: PropTypes.func.isRequired, diff --git a/src/components/Header/Logo.js b/src/components/Header/Logo.js index f28904b0..83b625c4 100644 --- a/src/components/Header/Logo.js +++ b/src/components/Header/Logo.js @@ -1,6 +1,6 @@ import React from 'react'; -export default function ( props ) { +export default function Logo( props ) { const customLogo = window.H2Data?.site?.logo; return ( diff --git a/src/components/Header/index.js b/src/components/Header/index.js index 7af564f9..81edffc4 100644 --- a/src/components/Header/index.js +++ b/src/components/Header/index.js @@ -4,41 +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 ? ( + + ) : ( + + ) } @@ -60,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/Post/Write.js b/src/components/Post/Write.js index 90acdfb5..14fd62c5 100644 --- a/src/components/Post/Write.js +++ b/src/components/Post/Write.js @@ -110,9 +110,8 @@ export class WritePost extends Component { const onDoSave = body.id ? this.props.onUpdate : this.props.onCreate; onDoSave( body ) - .then( id => { - const data = posts.getSingle( this.props.posts, id ); - this.props.onDidCreatePost( data ); + .then( () => { + this.props.onDidCreatePost(); } ) .catch( error => { this.setState( { @@ -212,6 +211,7 @@ export class WritePost extends Component { lastSave={ this.state.lastSave } previewComponent={ props => } saveText={ this.state.isSaving ? 'Saving…' : 'Save' } + isSubmitting={ this.state.isSubmitting } submitText={ this.state.isSubmitting ? 'Publishing...' : 'Publish' } onCancel={ this.props.onCancel } onSave={ this.onSave } @@ -250,7 +250,7 @@ export class WritePost extends Component { } WritePost.propTypes = { - onCancel: PropTypes.func.isRequired, + onCancel: PropTypes.func, onDidCreatePost: PropTypes.func.isRequired, }; diff --git a/src/components/RemotePreview.js b/src/components/RemotePreview.js index 82f91aae..69036e9b 100644 --- a/src/components/RemotePreview.js +++ b/src/components/RemotePreview.js @@ -37,6 +37,7 @@ export default class RemotePreview extends React.Component { credentials: 'same-origin', headers: { 'Content-Type': 'application/json', + 'X-WP-Nonce': window.wpApiSettings.nonce || undefined, }, body: JSON.stringify( body ), } ) 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/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 ( + + ); +}; + +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 ( +
    + + + { open && ( +
    +

    + Email notifications +

    +
      + { MODES.map( option => ( +
    • + +
    • + ) ) } +
    +
    + ) } +
    + ); +} + +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 ( +
    +
      + { props.posts.map( post => ( +
    • + +
      + { decodeEntities( post.title.rendered ) } + { Object.prototype.hasOwnProperty.call( post, 'date' ) && ( + + + + ) } +
      + + → + + +
    • + ) ) } +
    + + +
    + ); +} + +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 }

    - +
    +
    +

    + + { window.H2Data.site.name } + +

    +
    +
    +

    { props.title }

    + +
    ); } 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/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( /\/$/, '' );