From 9afbba91a7959a5b23431b765e2cd109aa2127e7 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Thu, 9 Apr 2026 18:53:36 +0100 Subject: [PATCH 01/25] Move all the sidebars to the left --- src/App.css | 9 +-- src/App.js | 6 +- src/components/Sidebar.js | 117 +++++++++++++++++++++------- src/components/Sidebar/Container.js | 5 +- src/components/Sidebar/TitleBar.js | 11 +-- 5 files changed, 104 insertions(+), 44 deletions(-) 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..9a1dacef 100644 --- a/src/App.js +++ b/src/App.js @@ -136,8 +136,9 @@ class App extends Component { onShowSuper={ this.props.onShowSuperSidebar } /> -
-
+
+ { this.renderSidebar() } +
{ this.state.isShowingWritePost ? ( this.onDidCreatePost( post ) } @@ -183,7 +184,6 @@ class App extends Component { />
- { this.renderSidebar() }
{ this.state.showChanges ? ( this.setState( { active: true } ) } - onMouseOut={ () => this.setState( { active: false } ) } +const NavLink = ( { children, icon, internal = true, to } ) => { + const Linker = internal ? Link : 'a'; + const hrefProps = internal ? { to } : { href: to }; + return ( +
  • + - + { icon && ( + + ) } + { children } + + → + + +
  • + ); +}; +const NavButton = ( { children, icon, internal = true, onClick } ) => { + return ( +
  • + +
  • + ); +}; + +export function Sidebar( props ) { + const { dispatch, widgets } = props; + const site = window.H2Data.site; + + return ( + - ); - } + + + ); } -export default withWidgets( Sidebar ); +export default withWidgets( connect()( Sidebar ) ); 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/TitleBar.js b/src/components/Sidebar/TitleBar.js index 5fb51cbe..d4a06e3b 100644 --- a/src/components/Sidebar/TitleBar.js +++ b/src/components/Sidebar/TitleBar.js @@ -7,16 +7,17 @@ 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', + 'px-4 py-2', ].join( ' ' ); export default function TitleBar( props ) { return (
    -

    { props.title }

    -
    From bf2d97ab8ff345385eccae571ff783e63ce0e172 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Thu, 9 Apr 2026 19:30:01 +0100 Subject: [PATCH 02/25] Pass site description to frontend --- inc/namespace.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/inc/namespace.php b/inc/namespace.php index 8c6121cf..ebdfba9d 100644 --- a/inc/namespace.php +++ b/inc/namespace.php @@ -358,6 +358,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(), @@ -398,7 +399,7 @@ function get_script_data() { } } - return $data; + return apply_filters( 'h2.script_data', $data ); } /** From a1a236b988c526034a9234a68448e8951f81935d Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Thu, 9 Apr 2026 19:30:37 +0100 Subject: [PATCH 03/25] Display category list in the sidebar --- src/App.js | 8 ++++ src/actions.js | 3 ++ src/components/Sidebar.js | 41 +++++++++++----- src/components/Sidebar/Categories.js | 70 ++++++++++++++++++++++++++++ src/components/Sidebar/TitleBar.js | 36 ++++++++------ src/hocs.js | 2 +- src/reducers/ui.js | 7 +++ 7 files changed, 140 insertions(+), 27 deletions(-) create mode 100644 src/components/Sidebar/Categories.js diff --git a/src/App.js b/src/App.js index 9a1dacef..65bfde86 100644 --- a/src/App.js +++ b/src/App.js @@ -18,6 +18,7 @@ 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 { RenderPlugins } from './plugins'; @@ -111,6 +112,13 @@ class App extends Component { /> ); + case 'categories': + return ( + + ); + default: return ; } diff --git a/src/actions.js b/src/actions.js index cdf50b09..bc3d30f9 100644 --- a/src/actions.js +++ b/src/actions.js @@ -2,6 +2,7 @@ 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 HIDE_SIDEBAR = 'HIDE_SIDEBAR'; export const SHOW_SUPER_SIDEBAR = 'SHOW_SUPER_SIDEBAR'; export const HIDE_SUPER_SIDEBAR = 'HIDE_SUPER_SIDEBAR'; @@ -25,6 +26,8 @@ export const showSidebarComments = id => { }; }; +export const showSidebarCategories = () => ( { type: SHOW_SIDEBAR_CATEGORIES } ); + export const hideSidebar = () => ( { type: HIDE_SIDEBAR } ); export const showMetaSidebar = () => ( { type: SHOW_META_SIDEBAR } ); diff --git a/src/components/Sidebar.js b/src/components/Sidebar.js index 10f962b5..7bc4710a 100644 --- a/src/components/Sidebar.js +++ b/src/components/Sidebar.js @@ -1,6 +1,9 @@ -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 } from '../actions'; import { withWidgets } from '../hocs'; import RecentPostsWidget from './Widgets/RecentPosts'; @@ -44,6 +47,10 @@ const NavButton = ( { children, icon, internal = true, onClick } ) => { return (
  • +
    +
    +

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

    +
    +
    +

    { props.title }

    + +
    ); } diff --git a/src/hocs.js b/src/hocs.js index e930e1ee..57dfe535 100644 --- a/src/hocs.js +++ b/src/hocs.js @@ -43,6 +43,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..ec8372b2 100644 --- a/src/reducers/ui.js +++ b/src/reducers/ui.js @@ -1,5 +1,6 @@ import { SHOW_META_SIDEBAR, + SHOW_SIDEBAR_CATEGORIES, SHOW_SIDEBAR_COMMENTS, SHOW_SIDEBAR_PROFILE, HIDE_SIDEBAR, @@ -29,6 +30,12 @@ export default function ui( state = DEFAULT_STATE, action ) { sidebarProfile: action.id, }; + case SHOW_SIDEBAR_CATEGORIES: + return { + ...state, + sidebarView: 'categories', + }; + case SHOW_SIDEBAR_PROFILE: return { ...state, From 3dd69b36ed458595280fdd5f28608962aba6438d Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Fri, 10 Apr 2026 13:07:58 +0100 Subject: [PATCH 04/25] Add sidebar posts/pages --- src/App.js | 16 +++++ src/actions.js | 4 ++ src/components/Sidebar.js | 6 +- src/components/Sidebar/Pages.js | 108 ++++++++++++++++++++++++++++++++ src/components/Sidebar/Posts.js | 104 ++++++++++++++++++++++++++++++ src/reducers/ui.js | 14 +++++ 6 files changed, 249 insertions(+), 3 deletions(-) create mode 100644 src/components/Sidebar/Pages.js create mode 100644 src/components/Sidebar/Posts.js diff --git a/src/App.js b/src/App.js index 65bfde86..03c719f9 100644 --- a/src/App.js +++ b/src/App.js @@ -20,6 +20,8 @@ import SessionExpiredWarning from './components/SessionExpiredWarning'; import Sidebar from './components/Sidebar'; import CategoriesSidebar from './components/Sidebar/Categories'; import CommentsSidebar from './components/Sidebar/Comments'; +import PagesSidebar from './components/Sidebar/Pages'; +import PostsSidebar from './components/Sidebar/Posts'; import SuperMenu from './components/SuperMenu'; import { RenderPlugins } from './plugins'; @@ -119,6 +121,20 @@ class App extends Component { /> ); + case 'pages': + return ( + + ); + + case 'posts': + return ( + + ); + default: return ; } diff --git a/src/actions.js b/src/actions.js index bc3d30f9..ec6d568f 100644 --- a/src/actions.js +++ b/src/actions.js @@ -3,6 +3,8 @@ 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'; @@ -27,6 +29,8 @@ 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 } ); diff --git a/src/components/Sidebar.js b/src/components/Sidebar.js index 7bc4710a..1f180b7d 100644 --- a/src/components/Sidebar.js +++ b/src/components/Sidebar.js @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; import { Link } from 'react-router-dom'; import { Slot } from 'react-slot-fill'; -import { showSidebarCategories } from '../actions'; +import { showSidebarCategories, showSidebarPages, showSidebarPosts } from '../actions'; import { withWidgets } from '../hocs'; import RecentPostsWidget from './Widgets/RecentPosts'; @@ -104,12 +104,12 @@ export function Sidebar( props ) { Categories {} } + onClick={ () => dispatch( showSidebarPosts() ) } > Posts {} } + onClick={ () => dispatch( showSidebarPages() ) } > Pages diff --git a/src/components/Sidebar/Pages.js b/src/components/Sidebar/Pages.js new file mode 100644 index 00000000..94341d13 --- /dev/null +++ b/src/components/Sidebar/Pages.js @@ -0,0 +1,108 @@ +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. + */ +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..e58d52f1 --- /dev/null +++ b/src/components/Sidebar/Posts.js @@ -0,0 +1,104 @@ +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 Link from '../Link'; +import Container from './Container'; +import Pagination from './Pagination'; +import FormattedDate from "../FormattedDate"; + +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 ) } + { post.hasOwnProperty( '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/reducers/ui.js b/src/reducers/ui.js index ec8372b2..73b586ed 100644 --- a/src/reducers/ui.js +++ b/src/reducers/ui.js @@ -2,6 +2,8 @@ import { SHOW_META_SIDEBAR, SHOW_SIDEBAR_CATEGORIES, SHOW_SIDEBAR_COMMENTS, + SHOW_SIDEBAR_PAGES, + SHOW_SIDEBAR_POSTS, SHOW_SIDEBAR_PROFILE, HIDE_SIDEBAR, SHOW_SUPER_SIDEBAR, @@ -36,6 +38,18 @@ export default function ui( state = DEFAULT_STATE, action ) { sidebarView: 'categories', }; + case SHOW_SIDEBAR_PAGES: + return { + ...state, + sidebarView: 'pages', + }; + + case SHOW_SIDEBAR_POSTS: + return { + ...state, + sidebarView: 'posts', + }; + case SHOW_SIDEBAR_PROFILE: return { ...state, From e6cd849ce1c80e6bc15374e7e8543781cfa1c919 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Fri, 10 Apr 2026 13:10:33 +0100 Subject: [PATCH 05/25] Move new post to standalone screen --- src/App.js | 9 +++++++++ src/components/Header/index.js | 11 ++--------- src/components/Post/Write.js | 2 +- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/App.js b/src/App.js index 03c719f9..d436039c 100644 --- a/src/App.js +++ b/src/App.js @@ -171,6 +171,15 @@ class App extends Component { ) : null } + ( + this.onDidCreatePost( post ) } + /> + ) } + />
    - - Date: Wed, 2 Sep 2026 14:33:26 +0100 Subject: [PATCH 06/25] Fix up linting --- src/components/Sidebar/Categories.js | 4 ++-- src/components/Sidebar/Pages.js | 4 ++++ src/components/Sidebar/Posts.js | 5 +++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/components/Sidebar/Categories.js b/src/components/Sidebar/Categories.js index 0ae2b123..952da708 100644 --- a/src/components/Sidebar/Categories.js +++ b/src/components/Sidebar/Categories.js @@ -19,7 +19,7 @@ const Category = ( { all, category } ) => { { category.name } - { category.hasOwnProperty( 'count' ) && ( + { Object.prototype.hasOwnProperty.call( category, 'count' ) && ( ({ category.count }) @@ -41,7 +41,7 @@ const Category = ( { all, category } ) => { }; const CategoryList = ( { all, categories } ) => ( -
      +
        { categories && categories.map( category => ( link.replace( window.H2Data.site.home, '' ); @@ -49,6 +50,9 @@ const PageList = ( { all, pages: pageItems } ) => ( /** * 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; diff --git a/src/components/Sidebar/Posts.js b/src/components/Sidebar/Posts.js index e58d52f1..05343e25 100644 --- a/src/components/Sidebar/Posts.js +++ b/src/components/Sidebar/Posts.js @@ -5,10 +5,11 @@ 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'; -import FormattedDate from "../FormattedDate"; function PostList( props ) { if ( props.loading || props.loadingMore ) { @@ -33,7 +34,7 @@ function PostList( props ) { >
        { decodeEntities( post.title.rendered ) } - { post.hasOwnProperty( 'date' ) && ( + { Object.prototype.hasOwnProperty.call( post, 'date' ) && ( Date: Wed, 2 Sep 2026 12:32:48 -0400 Subject: [PATCH 07/25] Build H2 Next with Node 24 --- .build-script | 16 +++++++++------- .nvmrc | 2 +- 2 files changed, 10 insertions(+), 8 deletions(-) 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 From 464bf2ee441465b7e1d5e22ae7d54637fb0ef21e Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Wed, 2 Sep 2026 18:59:05 +0100 Subject: [PATCH 08/25] Require authentication for previews This is needed as some previewing is context specific (eg unfiltered_html). --- inc/namespace.php | 4 ++++ src/components/RemotePreview.js | 1 + 2 files changed, 5 insertions(+) diff --git a/inc/namespace.php b/inc/namespace.php index ebdfba9d..da6fe566 100644 --- a/inc/namespace.php +++ b/inc/namespace.php @@ -532,6 +532,10 @@ function register_rest_routes() { register_rest_route( 'h2', 'v1/preview', [ 'methods' => 'POST', 'callback' => __NAMESPACE__ . '\\render_preview', + 'permission_callback' => function () { + // Anyone who can comment or write can preview. + return is_user_logged_in(); + }, 'args' => [ 'html' => [ 'type' => 'text', 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 ), } ) From a701f9a6233ee5763a6551275c85fbec8888cae8 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Tue, 29 Sep 2026 17:32:36 +0100 Subject: [PATCH 09/25] Add new super overlay to replace super menu --- src/App.js | 50 ++-- src/_print.scss | 2 +- src/actions.js | 12 + src/changelog.js | 15 ++ src/components/Header/index.js | 42 +++- src/components/Header/index.stories.js | 20 ++ src/components/SearchInput.js | 27 ++- src/components/Super/Facets.js | 193 ++++++++++++++++ src/components/Super/Result.js | 121 ++++++++++ src/components/Super/Results.js | 132 +++++++++++ src/components/Super/Search.js | 303 +++++++++++++++++++++++++ src/components/Super/SearchInput.js | 64 ++++++ src/components/Super/SiteSwitcher.js | 118 ++++++++++ src/components/Super/index.js | 170 ++++++++++++++ src/components/Super/index.stories.js | 198 ++++++++++++++++ src/components/Super/util.js | 56 +++++ src/components/SuperMenu.js | 134 ----------- src/components/SuperMenu.stories.js | 46 ---- src/reducers/ui.js | 9 + src/tailwind.css | 9 + 20 files changed, 1506 insertions(+), 215 deletions(-) create mode 100644 src/components/Super/Facets.js create mode 100644 src/components/Super/Result.js create mode 100644 src/components/Super/Results.js create mode 100644 src/components/Super/Search.js create mode 100644 src/components/Super/SearchInput.js create mode 100644 src/components/Super/SiteSwitcher.js create mode 100644 src/components/Super/index.js create mode 100644 src/components/Super/index.stories.js create mode 100644 src/components/Super/util.js delete mode 100644 src/components/SuperMenu.js delete mode 100644 src/components/SuperMenu.stories.js diff --git a/src/App.js b/src/App.js index d436039c..260ea4e9 100644 --- a/src/App.js +++ b/src/App.js @@ -22,7 +22,7 @@ import CategoriesSidebar from './components/Sidebar/Categories'; import CommentsSidebar from './components/Sidebar/Comments'; import PagesSidebar from './components/Sidebar/Pages'; import PostsSidebar from './components/Sidebar/Posts'; -import SuperMenu from './components/SuperMenu'; +import SuperOverlay from './components/Super'; import { RenderPlugins } from './plugins'; import './App.css'; @@ -36,6 +36,7 @@ class App extends Component { isShowingWritePost: false, showChanges: false, }; + this.headerRef = React.createRef(); } onLogOut() { window.location.href = '/wp-login.php?action=logout'; @@ -71,6 +72,19 @@ class App extends Component { } } + onToggleSuper = () => { + if ( this.props.showingSuper ) { + this.props.onHideSuperSidebar(); + } else { + this.props.onShowSuperSidebar(); + } + } + + onShowChanges = () => { + this.setState( { showChanges: true } ); + this.props.onHideSuperSidebar(); + } + onClickWritePost() { this.setState( { isShowingWritePost: true } ); } @@ -144,21 +158,25 @@ class App extends Component { return (
        - this.onSearch( search ) } - onShowChanges={ () => { - this.setState( { showChanges: true } ); - this.props.onHideSuperSidebar(); - } } - /> -
        this.onLogOut() } - onWritePost={ () => this.onClickWritePost() } - onSearch={ search => this.onSearch( search ) } - onShowSuper={ this.props.onShowSuperSidebar } - /> + { /* Keep the header above the overlay, so its search input stays usable. */ } +
        +
        this.onLogOut() } + onWritePost={ () => this.onClickWritePost() } + onSearch={ search => this.onSearch( search ) } + onShowSuper={ this.onToggleSuper } + /> +
        + { this.props.showingSuper && ( + + ) }
        { this.renderSidebar() } diff --git a/src/_print.scss b/src/_print.scss index 3a2ec2fe..53f738a0 100644 --- a/src/_print.scss +++ b/src/_print.scss @@ -4,7 +4,7 @@ .Sidebar-Container, .Header, .Overlay, - .SuperMenu, + .Super, .Post__actions, .Post-Summary-actions button, .Post-Summary-people, diff --git a/src/actions.js b/src/actions.js index ec6d568f..df30576c 100644 --- a/src/actions.js +++ b/src/actions.js @@ -8,6 +8,7 @@ export const SHOW_SIDEBAR_POSTS = 'SHOW_SIDEBAR_POSTS'; export const HIDE_SIDEBAR = 'HIDE_SIDEBAR'; export const SHOW_SUPER_SIDEBAR = 'SHOW_SUPER_SIDEBAR'; export const HIDE_SUPER_SIDEBAR = 'HIDE_SUPER_SIDEBAR'; +export const SET_SUPER_SEARCH = 'SET_SUPER_SEARCH'; export const ENABLE_BETA_FEATURE = 'ENABLE_BETA_FEATURE'; export const DISABLE_BETA_FEATURE = 'DISABLE_BETA_FEATURE'; export const SET_DEFAULT_POST_VIEW = 'SET_DEFAULT_POST_VIEW'; @@ -39,6 +40,17 @@ export const showMetaSidebar = () => ( { type: SHOW_META_SIDEBAR } ); export const showSuperSidebar = () => ( { type: SHOW_SUPER_SIDEBAR } ); export const hideSuperSidebar = () => ( { type: HIDE_SUPER_SIDEBAR } ); +/** + * Set the term for network-wide search in the super overlay. + * + * @param {string} search Search term. + * @returns {object} Action. + */ +export const setSuperSearch = search => ( { + type: SET_SUPER_SEARCH, + search, +} ); + export const enableBetaFeature = feature => ( { type: ENABLE_BETA_FEATURE, feature, diff --git a/src/changelog.js b/src/changelog.js index 058896c9..6f0186cc 100644 --- a/src/changelog.js +++ b/src/changelog.js @@ -125,6 +125,21 @@ export const changes = [ ), }, + { + date: '2026-09-03', + title: 'Switch Sites & Search the Network', + content: () => ( + +

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

        +

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

        +
        + ), + }, ]; export function getChanges( lastView ) { diff --git a/src/components/Header/index.js b/src/components/Header/index.js index 63971fe7..8939fd38 100644 --- a/src/components/Header/index.js +++ b/src/components/Header/index.js @@ -4,34 +4,55 @@ 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(); return ( -
        +
        - + { superActive && network ? ( + + ) : ( + + ) } @@ -53,13 +74,16 @@ export class Header extends Component { Header.defaultProps = { searchValue: '', + superActive: false, }; Header.propTypes = { searchValue: PropTypes.string, + superActive: PropTypes.bool, onLogOut: PropTypes.func.isRequired, onWritePost: PropTypes.func.isRequired, onSearch: PropTypes.func.isRequired, + onShowSuper: PropTypes.func.isRequired, }; export default withCurrentUser( Header ); diff --git a/src/components/Header/index.stories.js b/src/components/Header/index.stories.js index 37579555..da6203e3 100644 --- a/src/components/Header/index.stories.js +++ b/src/components/Header/index.stories.js @@ -28,3 +28,23 @@ export const Default = () => ( { ...defaultProps } /> ); + +export const SuperActive = () => ( +
        +); + +export const OnNetwork = () => { + window.H2Data.network = { + name: 'Storybook Network', + }; + + return ( +
        + ); +}; diff --git a/src/components/SearchInput.js b/src/components/SearchInput.js index 6409982b..e99c4490 100644 --- a/src/components/SearchInput.js +++ b/src/components/SearchInput.js @@ -9,6 +9,21 @@ 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. + * + * @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 bg-hm-light-grey relative z-11', + small ? '' : 'px-5', +].filter( Boolean ).join( ' ' ); + export class Results extends React.Component { state = { selected: -1, @@ -204,18 +219,12 @@ class SearchInput extends React.Component { return (
        -
        +
        this.inputEl = ref } diff --git a/src/components/Super/Facets.js b/src/components/Super/Facets.js new file mode 100644 index 00000000..100e562b --- /dev/null +++ b/src/components/Super/Facets.js @@ -0,0 +1,193 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +import { isCurrentSite } from './util'; + +export const TYPES = [ + { + value: 'all', + label: 'Posts and comments', + }, + { + value: 'post', + label: 'Posts only', + }, + { + value: 'comment', + label: 'Comments only', + }, +]; + +export const ORDERS = [ + { + value: 'relevance', + label: 'Most relevant', + }, + { + value: 'date', + label: 'Newest first', + }, +]; + +export const PERIODS = [ + { + value: 'any', + label: 'Any time', + }, + { + value: 'week', + label: 'Past week', + days: 7, + }, + { + value: 'month', + label: 'Past month', + days: 30, + }, + { + value: 'year', + label: 'Past year', + days: 365, + }, +]; + +const LABEL_CLASSES = 'flex items-center gap-2 cursor-pointer py-0.5 hover:text-hm-vibrant-blue'; +const CONTROL_CLASSES = 'm-0 shrink-0 size-4 cursor-pointer accent-hm-vibrant-blue'; + +function Facet( props ) { + return ( +
        + + { props.title } + + { props.children } +
        + ); +} + +function RadioFacet( props ) { + const { name, options, title, value, onChange } = props; + + return ( + +
          + { options.map( option => ( +
        • + +
        • + ) ) } +
        +
        + ); +} + +/** + * Filters for network search. + * + * An empty `selectedSites` list means every site. + * + * @param {object} props Current filter values, available sites, and change callbacks. + * @returns {React.ReactNode} Filter controls. + */ +export default function Facets( props ) { + const { orderby, period, selectedSites, sites, type } = props; + + const toggleSite = id => { + if ( selectedSites.includes( id ) ) { + props.onChangeSites( selectedSites.filter( selected => selected !== id ) ); + } else { + props.onChangeSites( [ ...selectedSites, id ] ); + } + }; + + return ( + + ); +} + +Facets.propTypes = { + orderby: PropTypes.oneOf( ORDERS.map( order => order.value ) ).isRequired, + period: PropTypes.oneOf( PERIODS.map( period => period.value ) ).isRequired, + selectedSites: PropTypes.arrayOf( PropTypes.number ).isRequired, + sites: PropTypes.shape( { + data: PropTypes.array, + isLoading: PropTypes.bool, + } ).isRequired, + type: PropTypes.oneOf( TYPES.map( type => type.value ) ).isRequired, + onChangeOrderby: PropTypes.func.isRequired, + onChangePeriod: PropTypes.func.isRequired, + onChangeSites: PropTypes.func.isRequired, + onChangeType: PropTypes.func.isRequired, +}; diff --git a/src/components/Super/Result.js b/src/components/Super/Result.js new file mode 100644 index 00000000..986fa723 --- /dev/null +++ b/src/components/Super/Result.js @@ -0,0 +1,121 @@ +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 { 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'; + +/** + * 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 { result } = props; + const isComment = result.type === 'comment'; + const highlight = result.highlight || {}; + const highlightedTitle = highlight.title && highlight.title[0]; + const snippets = highlight.content; + + // The router can only handle links on the current site. + const Anchor = isCurrentSite( result.site ) ? Link : 'a'; + const anchorProps = Anchor === Link ? { disablePreviews: true } : {}; + + const avatarUrls = ( result.author && result.author.avatar_urls ) || {}; + const avatarUrl = avatarUrls['48'] || avatarUrls['96'] || window.H2Data.site.default_avatar; + + return ( +
      • +
        + + { result.site.name } + + { isComment ? 'Comment' : 'Post' } + + + +
        + +

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

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

        + ) ) } +

        + ) : result.excerpt ? ( +
        + ) : null } +
      • + ); +} + +Result.propTypes = { + result: PropTypes.shape( { + id: PropTypes.number.isRequired, + type: PropTypes.string.isRequired, + site: PropTypes.shape( { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + url: PropTypes.string.isRequired, + } ).isRequired, + title: PropTypes.string.isRequired, + excerpt: PropTypes.string, + link: PropTypes.string.isRequired, + date_gmt: PropTypes.string.isRequired, + author: PropTypes.shape( { + name: PropTypes.string.isRequired, + avatar_urls: PropTypes.object, + } ), + highlight: PropTypes.objectOf( PropTypes.arrayOf( PropTypes.string ) ), + } ).isRequired, +}; diff --git a/src/components/Super/Results.js b/src/components/Super/Results.js new file mode 100644 index 00000000..90f2749a --- /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..d343e955 --- /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..65483fa4 --- /dev/null +++ b/src/components/Super/SiteSwitcher.js @@ -0,0 +1,118 @@ +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 + + ) } +
        +

        + { 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..64f8da09 --- /dev/null +++ b/src/components/Super/index.js @@ -0,0 +1,170 @@ +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 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'; + +const TOOL_BUTTON_CLASSES = [ + 'flex items-center gap-2 shrink-0', + 'bg-transparent border border-solid border-hm-beige rounded-sm', + 'px-3 py-1.5 text-sm text-black cursor-pointer', + 'hover:bg-hm-beige/50 focus:bg-hm-beige/50 focus:outline-hidden', +].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..ba8a6064 --- /dev/null +++ b/src/components/Super/index.stories.js @@ -0,0 +1,198 @@ +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 => ( { + id: user.id, + name: user.name, + slug: user.slug, + link: `${ site.url }/author/${ user.slug }/`, + avatar_urls: user.avatar_urls, +} ); + +const results = [ + { + id: 123, + type: 'post', + site: sites[1], + title: 'Deploy checklist', + excerpt: '

        Before every deploy, run through the checklist.

        ', + link: 'https://example.com/eng/2026/09/01/deploy-checklist/', + date: '2026-09-01T10:00:00', + date_gmt: '2026-09-01T09:00:00', + author: author( 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', + ], + }, + }, + { + id: 456, + type: 'comment', + site: sites[0], + title: 'Tecum optime, deinde etiam cum mediocri amico.', + excerpt: '

        Have we added the new service to the checklist yet?

        ', + link: 'http://example.com/2018/01/01/tecum-optime/#comment-456', + date: '2026-08-30T15:20:00', + date_gmt: '2026-08-30T14:20:00', + author: { + ...author( sites[0] ), + id: users[1].id, + name: users[1].name, + avatar_urls: users[1].avatar_urls, + }, + post: { + id: 1, + title: 'Tecum optime, deinde etiam cum mediocri amico.', + link: 'http://example.com/2018/01/01/tecum-optime/', + }, + score: 2.1, + highlight: { + content: [ 'Have we added the new service to the checklist yet?' ], + }, + }, + { + id: 789, + type: 'post', + site: sites[3], + title: 'Component review checklist', + excerpt: '

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

        ', + link: 'https://example.com/design/2026/07/12/component-review-checklist/', + date: '2026-07-12T09:00:00', + date_gmt: '2026-07-12T08:00:00', + author: author( sites[3] ), + score: 1.4, + highlight: {}, + }, +]; + +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..2c6b43db --- /dev/null +++ b/src/components/Super/util.js @@ -0,0 +1,56 @@ +/** + * 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( /\/+$/, '' ); +} + +/** + * Format a count of results. + * + * Elasticsearch stops counting at 10,000 results. + * + * @param {number} total Total results. + * @returns {string} Formatted count. + */ +export function formatTotal( total ) { + if ( total >= 10000 ) { + return '10,000+ results'; + } + + return total === 1 ? '1 result' : `${ total.toLocaleString() } results`; +} diff --git a/src/components/SuperMenu.js b/src/components/SuperMenu.js deleted file mode 100644 index 335dd9cd..00000000 --- a/src/components/SuperMenu.js +++ /dev/null @@ -1,134 +0,0 @@ -import React from 'react'; - -import { getChangesForUser } from '../changelog'; -import { withCategories, withCurrentUser, withSites } from '../hocs'; - -import HeaderLabel from './Header/HeaderLabel'; -import Logo from './Header/Logo'; -import Link from './Link'; -import Overlay from './Overlay'; -import SearchInput from './SearchInput'; - -const Category = ( { all, category } ) => { - const childCategories = all.filter( cat => cat.parent === category.id ); - - return ( -
      • - - { category.name } - - - { childCategories && ( - - ) } -
      • - ); -}; - -const CategoryList = ( { all, categories } ) => ( -
          - { categories && categories.map( category => ( - - ) ) } -
        -); - -export class SuperMenu extends React.Component { - render() { - const { categories, visible } = this.props; - const sites = this.props.sites && this.props.sites.data; - - const classes = [ - 'SuperMenu', - 'bg-hm-light-grey absolute w-[300px] h-screen overflow-auto z-30 p-4 px-5 shadow-[0_1px_3px_rgba(0,0,0,0.15)] transition-transform duration-150 flex flex-col', - visible ? 'SuperMenu--visible translate-x-0' : '-translate-x-[300px]', - ]; - - const newChanges = this.props.currentUser ? getChangesForUser( this.props.currentUser ) : []; - - const newLabel = ( - - What's New? - { ' ' } - { newChanges.length >= 0 ? { newChanges.length + 8 } : null } - - ); - - return ( - - ); - } -} - -export default withSites( withCategories( withCurrentUser( SuperMenu ) ) ); diff --git a/src/components/SuperMenu.stories.js b/src/components/SuperMenu.stories.js deleted file mode 100644 index 233d6ed1..00000000 --- a/src/components/SuperMenu.stories.js +++ /dev/null @@ -1,46 +0,0 @@ -import { action } from '@storybook/addon-actions'; -import React from 'react'; - -import { withStore } from '../stories/decorators'; - -import { SuperMenu } from './SuperMenu'; - -export default { - title: 'Interface|Super Menu', - decorators: [ - withStore(), - ], -}; - -const sites = [ - { - id: 1, - url: 'https://example.com/updates/', - name: 'Updates', - }, - { - id: 2, - url: 'https://example.com/dev/', - name: 'Dev', - }, -]; - -const commonProps = { - categories: [], - sites: [], - visible: true, - onClose: action( 'onClose' ), -}; - -export const Empty = () => ( - -); - -export const WithSites = () => ( - -); diff --git a/src/reducers/ui.js b/src/reducers/ui.js index 73b586ed..e2b7444b 100644 --- a/src/reducers/ui.js +++ b/src/reducers/ui.js @@ -8,6 +8,7 @@ import { HIDE_SIDEBAR, SHOW_SUPER_SIDEBAR, HIDE_SUPER_SIDEBAR, + SET_SUPER_SEARCH, SET_DEFAULT_POST_VIEW, } from '../actions'; @@ -15,6 +16,7 @@ const DEFAULT_STATE = { defaultPostView: 'expanded', showingSuper: false, sidebarProfile: null, + superSearch: '', }; export default function ui( state = DEFAULT_STATE, action ) { @@ -74,6 +76,13 @@ export default function ui( state = DEFAULT_STATE, action ) { return { ...state, showingSuper: false, + superSearch: '', + }; + + case SET_SUPER_SEARCH: + return { + ...state, + superSearch: action.search, }; case SET_DEFAULT_POST_VIEW: diff --git a/src/tailwind.css b/src/tailwind.css index 1cb30674..a7d1e553 100644 --- a/src/tailwind.css +++ b/src/tailwind.css @@ -26,6 +26,15 @@ --animate-hovercard-in: hovercard-fade-in-up 0.1s ease forwards; --animate-hovercard-out: hovercard-fade-out-down 0.1s ease forwards; + --animate-super-in: super-fade-in 0.15s ease-out; + @keyframes super-fade-in { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } + } @keyframes hovercard-fade-in-up { 0% { opacity: 0; From 55ce668e5b4cf88e21c0611e42567016a05f9917 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Thu, 1 Oct 2026 16:43:36 +0100 Subject: [PATCH 10/25] Switch super overlay buttons to standard --- src/components/Super/index.js | 36 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/src/components/Super/index.js b/src/components/Super/index.js index 64f8da09..1e3f792f 100644 --- a/src/components/Super/index.js +++ b/src/components/Super/index.js @@ -5,6 +5,7 @@ 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'; @@ -14,11 +15,13 @@ import NetworkSearchInput from './SearchInput'; import SiteSwitcher, { SiteCard } from './SiteSwitcher'; import { getNetwork } from './util'; -const TOOL_BUTTON_CLASSES = [ - 'flex items-center gap-2 shrink-0', - 'bg-transparent border border-solid border-hm-beige rounded-sm', - 'px-3 py-1.5 text-sm text-black cursor-pointer', - 'hover:bg-hm-beige/50 focus:bg-hm-beige/50 focus:outline-hidden', +// 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( ' ' ); /** @@ -104,29 +107,24 @@ export class SuperOverlay extends Component { { network ? network.name : window.H2Data.site.name } - + - + Close + { network && ( Date: Thu, 1 Oct 2026 16:47:53 +0100 Subject: [PATCH 11/25] Use the brand colour when super overlay is open --- src/components/Header/index.js | 20 +++++++++++++++++--- src/components/SearchInput.js | 7 +++++-- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/components/Header/index.js b/src/components/Header/index.js index 8939fd38..38d3c831 100644 --- a/src/components/Header/index.js +++ b/src/components/Header/index.js @@ -21,9 +21,23 @@ export class Header extends Component { 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 ( -
        -
        +
        +

        { site.description || No description yet. }

        -

        +

        { displayUrl( site.url ) }

        From 5c46caa38992dc6b0028a4fbd06d36d2a40cc078 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Fri, 2 Oct 2026 17:49:42 +0100 Subject: [PATCH 14/25] Fix nitpick --- src/components/Super/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Super/index.js b/src/components/Super/index.js index 1e3f792f..77c3a901 100644 --- a/src/components/Super/index.js +++ b/src/components/Super/index.js @@ -101,7 +101,7 @@ export class SuperOverlay extends Component { } } /> -
        +

        { network ? network.name : window.H2Data.site.name } From cbcf1713bdcc63ae91f8f2c40bb4e4ac1be7f3ff Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Fri, 2 Oct 2026 18:22:14 +0100 Subject: [PATCH 15/25] Update search to new schema --- src/components/Super/Result.js | 95 +++++++++++++++++------- src/components/Super/Results.js | 2 +- src/components/Super/index.stories.js | 100 ++++++++++++++++---------- src/components/Super/util.js | 13 ++++ 4 files changed, 147 insertions(+), 63 deletions(-) diff --git a/src/components/Super/Result.js b/src/components/Super/Result.js index 986fa723..a53536d9 100644 --- a/src/components/Super/Result.js +++ b/src/components/Super/Result.js @@ -6,7 +6,7 @@ import Avatar from '../Avatar'; import FormattedDate from '../FormattedDate'; import Link from '../Link'; -import { isCurrentSite } from './util'; +import { getPlainText, isCurrentSite } from './util'; const SNIPPET_CLASSES = [ 'Super-result-snippet', @@ -17,6 +17,40 @@ const SNIPPET_CLASSES = [ 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'; +/** + * 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} 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 ), + title: post ? post.title.rendered : '', + }; + } + + return { + authorName: author && author.name, + avatarUrls: author && author.avatar_urls, + excerptHtml: object.excerpt.rendered, + title: object.title.rendered, + }; +} + /** * A single network search result. * @@ -27,28 +61,28 @@ const TITLE_LINK_CLASSES = 'text-black! hover:text-hm-vibrant-blue! [&_mark]:bg- * @returns {React.ReactNode} List item for the result. */ export default function Result( props ) { - const { result } = props; - const isComment = result.type === 'comment'; - const highlight = result.highlight || {}; + 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, 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( result.site ) ? Link : 'a'; + const Anchor = isCurrentSite( site ) ? Link : 'a'; const anchorProps = Anchor === Link ? { disablePreviews: true } : {}; - const avatarUrls = ( result.author && result.author.avatar_urls ) || {}; - const avatarUrl = avatarUrls['48'] || avatarUrls['96'] || window.H2Data.site.default_avatar; - return (
      • - { result.site.name } + { site.name } { isComment ? 'Comment' : 'Post' } - +
        @@ -56,7 +90,7 @@ export default function Result( props ) { { isComment && ( Comment on @@ -64,19 +98,19 @@ export default function Result( props ) { { highlightedTitle ? ( ) : ( - decodeEntities( result.title ) + decodeEntities( title ) ) }
      • - { result.author && ( + { authorName && (
        - { result.author.name } + { authorName }
        ) } @@ -89,33 +123,44 @@ export default function Result( props ) { /> ) ) }
        - ) : result.excerpt ? ( + ) : excerptHtml ? (
        + ) : excerptText ? ( +
        + { excerptText } +
        ) : null } ); } +const Rendered = PropTypes.shape( { + rendered: PropTypes.string.isRequired, +} ); + Result.propTypes = { result: PropTypes.shape( { - id: PropTypes.number.isRequired, type: PropTypes.string.isRequired, site: PropTypes.shape( { id: PropTypes.number.isRequired, name: PropTypes.string.isRequired, url: PropTypes.string.isRequired, } ).isRequired, - title: PropTypes.string.isRequired, - excerpt: PropTypes.string, - link: PropTypes.string.isRequired, - date_gmt: PropTypes.string.isRequired, - author: PropTypes.shape( { - name: PropTypes.string.isRequired, - avatar_urls: PropTypes.object, - } ), 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, + 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 index 90f2749a..83cb436a 100644 --- a/src/components/Super/Results.js +++ b/src/components/Super/Results.js @@ -102,7 +102,7 @@ export default function Results( props ) { > { results.map( result => ( ) ) } diff --git a/src/components/Super/index.stories.js b/src/components/Super/index.stories.js index ba8a6064..4d385415 100644 --- a/src/components/Super/index.stories.js +++ b/src/components/Super/index.stories.js @@ -42,25 +42,18 @@ const sites = [ }, ]; -const author = site => ( { - id: user.id, - name: user.name, - slug: user.slug, - link: `${ site.url }/author/${ user.slug }/`, - avatar_urls: user.avatar_urls, +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 = [ { - id: 123, type: 'post', site: sites[1], - title: 'Deploy checklist', - excerpt: '

        Before every deploy, run through the checklist.

        ', - link: 'https://example.com/eng/2026/09/01/deploy-checklist/', - date: '2026-09-01T10:00:00', - date_gmt: '2026-09-01T09:00:00', - author: author( sites[1] ), score: 4.2, highlight: { title: [ 'Deploy checklist' ], @@ -68,44 +61,77 @@ const results = [ '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', + 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] ) ], + }, + }, }, { - id: 456, type: 'comment', site: sites[0], - title: 'Tecum optime, deinde etiam cum mediocri amico.', - excerpt: '

        Have we added the new service to the checklist yet?

        ', - link: 'http://example.com/2018/01/01/tecum-optime/#comment-456', - date: '2026-08-30T15:20:00', - date_gmt: '2026-08-30T14:20:00', - author: { - ...author( sites[0] ), - id: users[1].id, - name: users[1].name, - avatar_urls: users[1].avatar_urls, - }, - post: { - id: 1, - title: 'Tecum optime, deinde etiam cum mediocri amico.', - link: 'http://example.com/2018/01/01/tecum-optime/', - }, 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.', + }, + }, + ], + }, + }, }, { - id: 789, type: 'post', site: sites[3], - title: 'Component review checklist', - excerpt: '

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

        ', - link: 'https://example.com/design/2026/07/12/component-review-checklist/', - date: '2026-07-12T09:00:00', - date_gmt: '2026-07-12T08:00:00', - author: author( sites[3] ), score: 1.4, highlight: {}, + result: { + id: 789, + date: '2026-07-12T09:00:00', + date_gmt: '2026-07-12T08:00:00', + link: 'https://example.com/design/2026/07/12/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] ) ], + }, + }, }, ]; diff --git a/src/components/Super/util.js b/src/components/Super/util.js index 2c6b43db..f62c550b 100644 --- a/src/components/Super/util.js +++ b/src/components/Super/util.js @@ -39,6 +39,19 @@ 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. * From 580a36a03b94f343eba47a82327db4fc1e60d47e Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Fri, 2 Oct 2026 18:25:28 +0100 Subject: [PATCH 16/25] Label posts and pages differently --- src/components/Super/Result.js | 33 ++++++++++++++++++++++++--- src/components/Super/index.stories.js | 4 +++- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/components/Super/Result.js b/src/components/Super/Result.js index a53536d9..c3196927 100644 --- a/src/components/Super/Result.js +++ b/src/components/Super/Result.js @@ -17,6 +17,29 @@ const SNIPPET_CLASSES = [ 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. * @@ -25,7 +48,7 @@ const TITLE_LINK_CLASSES = 'text-black! hover:text-hm-vibrant-blue! [&_mark]:bg- * * @param {string} type Type of result, `post` or `comment`. * @param {object} object Post or comment from the search API. - * @returns {object} Title, author and fallback excerpt for the result. + * @returns {object} Label, title, author and fallback excerpt for the result. */ function getDetails( type, object ) { const embedded = object._embedded || {}; @@ -39,6 +62,7 @@ function getDetails( type, object ) { 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 : '', }; } @@ -47,6 +71,8 @@ function getDetails( type, object ) { 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, }; } @@ -66,7 +92,7 @@ export default function Result( props ) { const highlightedTitle = highlight.title && highlight.title[0]; const snippets = highlight.content; const details = getDetails( type, object ); - const { authorName, excerptHtml, excerptText, title } = details; + const { authorName, excerptHtml, excerptText, label, title } = details; const avatarUrls = details.avatarUrls || {}; const avatarUrl = avatarUrls['48'] || avatarUrls['96'] || window.H2Data.site.default_avatar; @@ -80,7 +106,7 @@ export default function Result( props ) { { site.name } - { isComment ? 'Comment' : 'Post' } + { label } @@ -154,6 +180,7 @@ Result.propTypes = { result: PropTypes.shape( { id: PropTypes.number.isRequired, date_gmt: PropTypes.string.isRequired, + type: PropTypes.string, link: PropTypes.string.isRequired, title: Rendered, excerpt: Rendered, diff --git a/src/components/Super/index.stories.js b/src/components/Super/index.stories.js index 4d385415..0c94068d 100644 --- a/src/components/Super/index.stories.js +++ b/src/components/Super/index.stories.js @@ -65,6 +65,7 @@ const results = [ 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', @@ -120,7 +121,8 @@ const results = [ id: 789, date: '2026-07-12T09:00:00', date_gmt: '2026-07-12T08:00:00', - link: 'https://example.com/design/2026/07/12/component-review-checklist/', + type: 'page', + link: 'https://example.com/design/component-review-checklist/', title: { rendered: 'Component review checklist', }, From 7ca785a872297007e594147fa4bb190f48237354 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Mon, 5 Oct 2026 17:48:04 +0100 Subject: [PATCH 17/25] Add ability to manage notifications from sidebar --- .storybook/preview-head.html | 3 + inc/namespace.php | 3 + src/components/Sidebar.js | 8 +- .../Sidebar/NotificationSettings.js | 269 ++++++++++++++++++ 4 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 src/components/Sidebar/NotificationSettings.js 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 da6fe566..4f1a6701 100644 --- a/inc/namespace.php +++ b/inc/namespace.php @@ -373,6 +373,9 @@ function get_script_data() { 'emoji' => apply_filters( 'h2.custom_emoji', [] ), ], 'features' => [], + 'has' => [ + 'notifications' => defined( 'FALCON_PATH' ), + ], 'plugins' => [ 'reactions' => \class_exists( 'H2\\Reactions\\Reaction' ), ], diff --git a/src/components/Sidebar.js b/src/components/Sidebar.js index 1f180b7d..521324c2 100644 --- a/src/components/Sidebar.js +++ b/src/components/Sidebar.js @@ -6,6 +6,7 @@ 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'; @@ -74,8 +75,8 @@ export function Sidebar( props ) { className="Sidebar border-r border-solid border-hm-beige" >
        -
        -

        +
        +

        + { window.H2Data.has && window.H2Data.has.notifications && ( + + ) }

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

        diff --git a/src/components/Sidebar/NotificationSettings.js b/src/components/Sidebar/NotificationSettings.js new file mode 100644 index 00000000..9f4e3eb8 --- /dev/null +++ b/src/components/Sidebar/NotificationSettings.js @@ -0,0 +1,269 @@ +import React, { useEffect, useRef, useState } from 'react'; + +// Falcon stores preferences per-connector; H2 uses the WordPress connector. +const CONNECTOR = 'wordpress'; + +const MODES = [ + { + 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, "none", 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; + } + + if ( ! prefs.post && ! prefs.comment ) { + return 'none'; + } + + // Set to a combination we don't offer (e.g. via the profile screen). + return 'custom'; +}; + +const getModeLabel = mode => { + switch ( mode ) { + case 'none': + return 'Off'; + + case 'custom': + return 'Custom'; + + default: + return MODES.find( m => m.id === mode ).label; + } +}; + +/** + * Make a request for the current user's preferences. + * + * @param {object|null} data Preferences to update, or null to fetch. + * @returns {Promise} Falcon preferences for the user. + */ +const request = ( data = null ) => { + const url = new URL( `${ window.wpApiSettings.root }wp/v2/users/me`, window.location.href ); + url.searchParams.set( 'context', 'edit' ); + url.searchParams.set( '_fields', 'falcon_preferences' ); + + const options = { + credentials: 'same-origin', + headers: { + Accept: 'application/json', + 'X-WP-Nonce': window.wpApiSettings.nonce, + }, + }; + if ( data ) { + options.method = 'POST'; + options.headers['Content-Type'] = 'application/json'; + options.body = JSON.stringify( { + falcon_preferences: data, + } ); + } + + return fetch( url, options ) + .then( response => response.json().then( body => { + if ( ! response.ok ) { + throw new Error( body.message || 'Could not load notification settings.' ); + } + return body.falcon_preferences; + } ) ); +}; + +const BellIcon = ( { mode } ) => { + const strokeProps = { + fill: 'none', + stroke: 'currentColor', + strokeWidth: 2, + strokeLinecap: 'round', + strokeLinejoin: 'round', + }; + + if ( mode === 'none' ) { + return ( + + ); + } + + return ( + + ); +}; + +export default function NotificationSettings() { + const [ prefs, setPrefs ] = useState( null ); + const [ error, setError ] = useState( null ); + const [ saving, setSaving ] = useState( false ); + const [ open, setOpen ] = useState( false ); + const container = useRef( null ); + + useEffect( () => { + let cancelled = false; + request().then( + data => ! cancelled && setPrefs( data[ CONNECTOR ] || null ), + err => ! cancelled && setError( err.message ) + ); + return () => { + cancelled = true; + }; + }, [] ); + + // 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 ] ); + + 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 => { + const previous = prefs; + setPrefs( { + ...prefs, + ...next.value, + } ); + setError( null ); + setSaving( true ); + + request( { [ CONNECTOR ]: next.value } ).then( + data => { + setSaving( false ); + setPrefs( data[ CONNECTOR ] || null ); + }, + err => { + setSaving( false ); + setPrefs( previous ); + setError( err.message ); + } + ); + }; + + return ( +
        + + + { open && ( +
        +

        + Email notifications +

        +
          + { MODES.map( option => ( +
        • + +
        • + ) ) } +
        + { error && ( +

        + { error } +

        + ) } +
        + ) } +
        + ); +} From 6378fcdb40cdc977b427b1fb245aca4521fccff2 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Mon, 5 Oct 2026 17:59:13 +0100 Subject: [PATCH 18/25] Add none option for notifications --- .../Sidebar/NotificationSettings.js | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/components/Sidebar/NotificationSettings.js b/src/components/Sidebar/NotificationSettings.js index 9f4e3eb8..a0504804 100644 --- a/src/components/Sidebar/NotificationSettings.js +++ b/src/components/Sidebar/NotificationSettings.js @@ -31,13 +31,22 @@ const MODES = [ comment: 'all', }, }, + { + id: 'none', + label: 'None', + description: 'Unsubscribe from all email notifications.', + value: { + post: '', + comment: '', + }, + }, ]; /** * Find the mode matching the user's current preferences. * * @param {object} prefs Falcon preferences for the connector. - * @returns {string} Mode ID, "none", or "custom". + * @returns {string} Mode ID, or "custom". */ const getMode = prefs => { const mode = MODES.find( mode => ( @@ -47,25 +56,16 @@ const getMode = prefs => { return mode.id; } - if ( ! prefs.post && ! prefs.comment ) { - return 'none'; - } - // Set to a combination we don't offer (e.g. via the profile screen). return 'custom'; }; const getModeLabel = mode => { - switch ( mode ) { - case 'none': - return 'Off'; - - case 'custom': - return 'Custom'; - - default: - return MODES.find( m => m.id === mode ).label; + if ( mode === 'custom' ) { + return 'Custom'; } + + return MODES.find( m => m.id === mode ).label; }; /** From ce138e749547531f0bc92819aadf70ae2eb39590 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Mon, 5 Oct 2026 18:00:31 +0100 Subject: [PATCH 19/25] Reorder "none" to the top --- src/components/Sidebar/NotificationSettings.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/Sidebar/NotificationSettings.js b/src/components/Sidebar/NotificationSettings.js index a0504804..717e519c 100644 --- a/src/components/Sidebar/NotificationSettings.js +++ b/src/components/Sidebar/NotificationSettings.js @@ -4,6 +4,15 @@ import React, { useEffect, useRef, useState } from 'react'; const CONNECTOR = 'wordpress'; const MODES = [ + { + id: 'none', + label: 'None', + description: 'Unsubscribe from all email notifications.', + value: { + post: '', + comment: '', + }, + }, { id: 'posts', label: 'Just posts', @@ -31,15 +40,6 @@ const MODES = [ comment: 'all', }, }, - { - id: 'none', - label: 'None', - description: 'Unsubscribe from all email notifications.', - value: { - post: '', - comment: '', - }, - }, ]; /** From 0665bd4c72fe0a1b7373386612348ef71f8b4dd3 Mon Sep 17 00:00:00 2001 From: Tom Willmot Date: Thu, 8 Oct 2026 16:20:11 +0200 Subject: [PATCH 20/25] Make profile maps optional in H2 Next --- src/components/Profile.js | 2 +- src/components/UserHovercard.js | 14 ++++++++------ src/components/UserHovercard.stories.js | 16 ++++++++++++++++ 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/components/Profile.js b/src/components/Profile.js index 229a7d29..b70a2bd4 100644 --- a/src/components/Profile.js +++ b/src/components/Profile.js @@ -86,7 +86,7 @@ export class Profile extends React.Component { - { user.facts && ( + { user.facts && user.facts.location && window.H2Data.site.mapbox_key && ( { user.facts.short_bio }

        - + { user.facts.location && window.H2Data.site.mapbox_key && ( + + ) } ); } @@ -92,7 +94,7 @@ export default class UserHovercard extends React.Component { render() { const { children, user } = this.props; - if ( ! user.facts || ! window.H2Data.site.mapbox_key ) { + if ( ! user.facts ) { return children; } 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 = () => ( + + + +); From 7b5f2f7f9196cd88a4318c36a9ce6c7bc353d8a1 Mon Sep 17 00:00:00 2001 From: Joe Hoyle Date: Thu, 8 Oct 2026 11:56:27 -0400 Subject: [PATCH 21/25] Return to stream after publishing a post --- src/App.js | 4 ++-- src/components/Post/Write.js | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/App.js b/src/App.js index d436039c..a8eb7d0a 100644 --- a/src/App.js +++ b/src/App.js @@ -83,9 +83,9 @@ 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( '/' ); } renderSidebar() { diff --git a/src/components/Post/Write.js b/src/components/Post/Write.js index 7ace0c5a..49aa0372 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( { From 5bc91181bf696e7d5e0e928666e4b487ce6ff7cd Mon Sep 17 00:00:00 2001 From: Joe Hoyle Date: Thu, 8 Oct 2026 12:12:10 -0400 Subject: [PATCH 22/25] Skip unsaved-content warning after publishing --- src/components/Editor/index.js | 5 +++++ src/components/Post/Write.js | 1 + 2 files changed, 6 insertions(+) diff --git a/src/components/Editor/index.js b/src/components/Editor/index.js index 5b8a0e05..a91f02ad 100644 --- a/src/components/Editor/index.js +++ b/src/components/Editor/index.js @@ -134,6 +134,10 @@ class Editor extends React.PureComponent { } warnBeforeLeaving = e => { + if ( this.props.isSubmitting ) { + return; + } + if ( this.state.content === '' || ( this.props.initialValue && this.state.content === this.props.initialValue ) ) { return; } @@ -584,6 +588,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/Post/Write.js b/src/components/Post/Write.js index 49aa0372..14fd62c5 100644 --- a/src/components/Post/Write.js +++ b/src/components/Post/Write.js @@ -211,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 } From fccd76e9a1ece3a1aa75d0cbd7ec31c199dd8c0d Mon Sep 17 00:00:00 2001 From: Joe Hoyle Date: Thu, 8 Oct 2026 12:20:06 -0400 Subject: [PATCH 23/25] Refresh the stream after publishing a post --- src/App.js | 3 +++ src/types.js | 1 + 2 files changed, 4 insertions(+) diff --git a/src/App.js b/src/App.js index a8eb7d0a..519242b3 100644 --- a/src/App.js +++ b/src/App.js @@ -24,6 +24,7 @@ import PagesSidebar from './components/Sidebar/Pages'; import PostsSidebar from './components/Sidebar/Posts'; import SuperMenu from './components/SuperMenu'; import { RenderPlugins } from './plugins'; +import { posts } from './types'; import './App.css'; @@ -86,6 +87,7 @@ class App extends Component { onDidCreatePost() { this.setState( { isShowingWritePost: false } ); this.props.history.push( '/' ); + this.props.onRefreshPostStream(); } renderSidebar() { @@ -244,6 +246,7 @@ const mapDispatchToProps = dispatch => { onDismissSidebar: () => dispatch( hideSidebar() ), onHideSuperSidebar: () => dispatch( hideSuperSidebar() ), onShowSuperSidebar: () => dispatch( showSuperSidebar() ), + onRefreshPostStream: () => dispatch( posts.fetchArchive( '' ) ), }; }; diff --git a/src/types.js b/src/types.js index 5888287e..3afc7479 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', From 7293788076b728929965a0969c4c211ace30f262 Mon Sep 17 00:00:00 2001 From: Joe Hoyle Date: Thu, 8 Oct 2026 14:41:48 -0400 Subject: [PATCH 24/25] Fix publish navigation guard --- src/components/Editor/index.js | 16 +++++++++++++--- src/components/Header/Logo.js | 2 +- src/reducers/users.js | 2 +- src/wordpress-rest-api-cookie-auth.js | 2 +- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/components/Editor/index.js b/src/components/Editor/index.js index a91f02ad..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,7 +135,7 @@ class Editor extends React.PureComponent { } warnBeforeLeaving = e => { - if ( this.props.isSubmitting ) { + if ( this.state.isPublishing || this.props.isSubmitting ) { return; } @@ -146,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; } @@ -270,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 ); } @@ -522,7 +532,7 @@ class Editor extends React.PureComponent { ) } 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/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/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( /\/$/, '' ); From 0a3ad2495fb0ec20338928bb7be4b10c9f36fb76 Mon Sep 17 00:00:00 2001 From: Ryan McCue Date: Tue, 13 Oct 2026 22:30:28 +0200 Subject: [PATCH 25/25] Switch notification settings into Redux --- .../Sidebar/NotificationSettings.js | 103 ++++++------------ src/hocs.js | 2 + src/types.js | 8 +- 3 files changed, 40 insertions(+), 73 deletions(-) diff --git a/src/components/Sidebar/NotificationSettings.js b/src/components/Sidebar/NotificationSettings.js index 717e519c..522e6a1b 100644 --- a/src/components/Sidebar/NotificationSettings.js +++ b/src/components/Sidebar/NotificationSettings.js @@ -1,5 +1,9 @@ +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'; @@ -68,41 +72,6 @@ const getModeLabel = mode => { return MODES.find( m => m.id === mode ).label; }; -/** - * Make a request for the current user's preferences. - * - * @param {object|null} data Preferences to update, or null to fetch. - * @returns {Promise} Falcon preferences for the user. - */ -const request = ( data = null ) => { - const url = new URL( `${ window.wpApiSettings.root }wp/v2/users/me`, window.location.href ); - url.searchParams.set( 'context', 'edit' ); - url.searchParams.set( '_fields', 'falcon_preferences' ); - - const options = { - credentials: 'same-origin', - headers: { - Accept: 'application/json', - 'X-WP-Nonce': window.wpApiSettings.nonce, - }, - }; - if ( data ) { - options.method = 'POST'; - options.headers['Content-Type'] = 'application/json'; - options.body = JSON.stringify( { - falcon_preferences: data, - } ); - } - - return fetch( url, options ) - .then( response => response.json().then( body => { - if ( ! response.ok ) { - throw new Error( body.message || 'Could not load notification settings.' ); - } - return body.falcon_preferences; - } ) ); -}; - const BellIcon = ( { mode } ) => { const strokeProps = { fill: 'none', @@ -138,23 +107,21 @@ const BellIcon = ( { mode } ) => { ); }; -export default function NotificationSettings() { - const [ prefs, setPrefs ] = useState( null ); - const [ error, setError ] = useState( null ); - const [ saving, setSaving ] = useState( false ); +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( () => { - let cancelled = false; - request().then( - data => ! cancelled && setPrefs( data[ CONNECTOR ] || null ), - err => ! cancelled && setError( err.message ) - ); - return () => { - cancelled = true; - }; - }, [] ); + 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( () => { @@ -180,6 +147,7 @@ export default function NotificationSettings() { }; }, [ 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; @@ -189,25 +157,11 @@ export default function NotificationSettings() { const label = `Email notifications: ${ getModeLabel( mode ) }`; const onSelect = next => { - const previous = prefs; - setPrefs( { - ...prefs, - ...next.value, - } ); - setError( null ); - setSaving( true ); - - request( { [ CONNECTOR ]: next.value } ).then( - data => { - setSaving( false ); - setPrefs( data[ CONNECTOR ] || null ); + props.onUpdateCurrentUser( { + falcon_preferences: { + [ CONNECTOR ]: next.value, }, - err => { - setSaving( false ); - setPrefs( previous ); - setError( err.message ); - } - ); + } ); }; return ( @@ -241,7 +195,7 @@ export default function NotificationSettings() {