initial
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Site Switcher for Command Palette
|
||||
* Adds a dynamic "Switch to Site" command that searches across all user's WordPress.com sites
|
||||
*
|
||||
* Requires WordPress 6.9+ for admin-wide command palette support
|
||||
*
|
||||
* @package
|
||||
*/
|
||||
|
||||
import apiFetch from '@wordpress/api-fetch';
|
||||
import { useCommandLoader } from '@wordpress/commands';
|
||||
import { useMemo, useState, useEffect } from '@wordpress/element';
|
||||
import { sprintf, __ } from '@wordpress/i18n';
|
||||
import { siteLogo } from '@wordpress/icons';
|
||||
|
||||
const userId = window.jetpackSiteSwitcherConfig?.userId || 0;
|
||||
const CACHE_KEY = `jetpack_site_switcher_sites_${ userId }`;
|
||||
const CACHE_DURATION = 3600000; // 1 hour in milliseconds
|
||||
|
||||
/**
|
||||
* Get cached sites from localStorage
|
||||
*/
|
||||
function getCachedSites() {
|
||||
try {
|
||||
const cached = localStorage.getItem( CACHE_KEY );
|
||||
if ( ! cached ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { sites, timestamp } = JSON.parse( cached );
|
||||
|
||||
// Check if cache is still valid
|
||||
if ( Date.now() - timestamp < CACHE_DURATION ) {
|
||||
return sites;
|
||||
}
|
||||
|
||||
// Cache expired, remove it
|
||||
localStorage.removeItem( CACHE_KEY );
|
||||
return null;
|
||||
} catch {
|
||||
// If localStorage is not available or JSON parsing fails, return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save sites to localStorage cache
|
||||
*/
|
||||
function setCachedSites( sites ) {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
CACHE_KEY,
|
||||
JSON.stringify( {
|
||||
sites,
|
||||
timestamp: Date.now(),
|
||||
} )
|
||||
);
|
||||
} catch {
|
||||
// Silently fail if localStorage is not available (e.g., private browsing)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch compact sites list from WordPress.com API
|
||||
*/
|
||||
async function fetchSitesFromWordPressCom() {
|
||||
// Check localStorage cache first
|
||||
const cachedSites = getCachedSites();
|
||||
if ( cachedSites ) {
|
||||
return cachedSites;
|
||||
}
|
||||
|
||||
const apiPath = window.jetpackSiteSwitcherConfig?.apiPath;
|
||||
|
||||
try {
|
||||
const data = await apiFetch( {
|
||||
path: apiPath,
|
||||
method: 'GET',
|
||||
global: true,
|
||||
} );
|
||||
|
||||
const sites = data.sites || [];
|
||||
|
||||
setCachedSites( sites );
|
||||
|
||||
return sites;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extract hostname from a URL string
|
||||
*
|
||||
* @param {string} urlString - The URL to parse
|
||||
* @return {string} The hostname, or empty string if invalid
|
||||
*/
|
||||
function getHostnameFromURL( urlString ) {
|
||||
if ( ! urlString ) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return new URL( urlString ).hostname;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove trailing slash from a URL string
|
||||
*
|
||||
* @param {string} url - The URL to process
|
||||
* @return {string} URL without trailing slash
|
||||
*/
|
||||
function untrailingslashit( url ) {
|
||||
return url ? url.replace( /\/+$/, '' ) : url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special regex characters in a string
|
||||
*
|
||||
* @param {string} str - String to escape
|
||||
* @return {string} Escaped string safe for use in RegExp
|
||||
*/
|
||||
function escapeRegex( str ) {
|
||||
return str.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to load site-switching commands based on search term
|
||||
*
|
||||
* @param {Object} props - Hook properties
|
||||
* @param {string} props.search - Search term to filter sites
|
||||
* @return {Object} Object containing commands array and loading state
|
||||
*/
|
||||
function useSiteSwitcherCommandLoader( { search } ) {
|
||||
const [ sites, setSites ] = useState( [] );
|
||||
const [ isLoading, setIsLoading ] = useState( true );
|
||||
|
||||
// Fetch sites on mount
|
||||
useEffect( () => {
|
||||
fetchSitesFromWordPressCom()
|
||||
.then( fetchedSites => {
|
||||
setSites( fetchedSites );
|
||||
setIsLoading( false );
|
||||
} )
|
||||
.catch( () => {
|
||||
setIsLoading( false );
|
||||
} );
|
||||
}, [] );
|
||||
|
||||
// Generate and filter commands based on search term
|
||||
const commands = useMemo( () => {
|
||||
if ( ! sites || sites.length === 0 ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const searchLower = search ? search.toLowerCase() : '';
|
||||
|
||||
// Strip generic keywords from search to allow queries like "site dean" to find sites with "dean"
|
||||
const genericKeywords = [
|
||||
__( 'site', 'jetpack' ).toLowerCase(),
|
||||
__( 'switch', 'jetpack' ).toLowerCase(),
|
||||
__( 'switch site', 'jetpack' ).toLowerCase(),
|
||||
];
|
||||
|
||||
let cleanedSearch = searchLower;
|
||||
genericKeywords.forEach( keyword => {
|
||||
cleanedSearch = cleanedSearch.replace(
|
||||
new RegExp( `\\b${ escapeRegex( keyword ) }\\b`, 'g' ),
|
||||
' '
|
||||
);
|
||||
} );
|
||||
cleanedSearch = cleanedSearch.trim().replace( /\s+/g, ' ' );
|
||||
|
||||
// Check if the search is a prefix of any generic keyword (e.g., "swit" matches "switch")
|
||||
// If so, treat it as a generic search and show all sites
|
||||
const isGenericKeywordPrefix =
|
||||
cleanedSearch && genericKeywords.some( keyword => keyword.startsWith( cleanedSearch ) );
|
||||
|
||||
// If search is empty after stripping generic keywords, or is a prefix of a generic keyword, show all sites
|
||||
const filteredSites =
|
||||
! cleanedSearch || isGenericKeywordPrefix
|
||||
? sites
|
||||
: sites.filter( site => {
|
||||
const domain = getHostnameFromURL( site.URL );
|
||||
return (
|
||||
( site.name && site.name.toLowerCase().includes( cleanedSearch ) ) ||
|
||||
domain.toLowerCase().includes( cleanedSearch )
|
||||
);
|
||||
} );
|
||||
|
||||
// Filter out sites with invalid URLs (can't navigate to them anyway)
|
||||
const validSites = filteredSites.filter( site => {
|
||||
return site.URL && getHostnameFromURL( site.URL ) !== '';
|
||||
} );
|
||||
|
||||
// Exclude the current site from the list
|
||||
const currentURL = untrailingslashit( window.location.href.toLowerCase() );
|
||||
const otherSites = validSites.filter( site => {
|
||||
// Normalize site URL for comparison
|
||||
const siteURL = untrailingslashit( site.URL.toLowerCase() );
|
||||
// Check if current URL starts with site URL (handles multisite subdirectory installs)
|
||||
// e.g., current: example.com/site1/wp-admin matches site: example.com/site1
|
||||
return ! currentURL.startsWith( siteURL );
|
||||
} );
|
||||
|
||||
return otherSites.map( site => {
|
||||
// Extract domain from URL for display - don't want to display the protocol.
|
||||
const domain = getHostnameFromURL( site.URL );
|
||||
|
||||
const iconElement = site.icon?.img ? <img src={ site.icon.img } alt="" /> : siteLogo;
|
||||
|
||||
// Use site name if available, otherwise just show domain
|
||||
const label = site.name
|
||||
? sprintf(
|
||||
/* translators: %1$s: site name, %2$s: site domain */
|
||||
__( 'Switch to %1$s (%2$s)', 'jetpack' ),
|
||||
site.name,
|
||||
domain
|
||||
)
|
||||
: sprintf(
|
||||
/* translators: %s: site domain */
|
||||
__( 'Switch to %s', 'jetpack' ),
|
||||
domain
|
||||
);
|
||||
|
||||
return {
|
||||
name: `jetpack/switch-to-site-${ domain }`,
|
||||
label,
|
||||
icon: iconElement,
|
||||
callback: ( { close } ) => {
|
||||
try {
|
||||
window.location.href = new URL( '/wp-admin', site.URL ).href;
|
||||
} catch {
|
||||
// If URL is malformed, don't navigate
|
||||
}
|
||||
close();
|
||||
},
|
||||
keywords: [
|
||||
site.name,
|
||||
domain,
|
||||
__( 'site', 'jetpack' ),
|
||||
__( 'switch site', 'jetpack' ),
|
||||
].filter( Boolean ),
|
||||
};
|
||||
} );
|
||||
}, [ sites, search ] );
|
||||
|
||||
return {
|
||||
commands,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that registers the site switcher command loader
|
||||
*/
|
||||
function JetpackSiteSwitcher() {
|
||||
useCommandLoader( {
|
||||
name: 'jetpack/site-switcher',
|
||||
hook: useSiteSwitcherCommandLoader,
|
||||
} );
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Render the site switcher into wp-admin
|
||||
// This works with WordPress 6.9+ admin-wide command palette
|
||||
if ( typeof window !== 'undefined' && window.wp && window.wp.element && window.wp.commands ) {
|
||||
const { createRoot, createElement } = window.wp.element;
|
||||
|
||||
// Create a container for our site switcher
|
||||
const container = document.createElement( 'div' );
|
||||
container.id = 'jetpack-site-switcher';
|
||||
container.style.display = 'none'; // Hidden, as we only need the hooks to run
|
||||
|
||||
// Wait for DOM to be ready
|
||||
if ( document.readyState === 'loading' ) {
|
||||
document.addEventListener( 'DOMContentLoaded', () => {
|
||||
document.body.appendChild( container );
|
||||
createRoot( container ).render( createElement( JetpackSiteSwitcher ) );
|
||||
} );
|
||||
} else {
|
||||
document.body.appendChild( container );
|
||||
createRoot( container ).render( createElement( JetpackSiteSwitcher ) );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user