Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/studio/src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ export {
downloadSyncBackup,
exportSiteForPush,
fetchSyncableWpcomSites,
fetchSyncableWpcomSitesPage,
getConnectedWpcomSites,
pauseSyncUpload,
pullSiteFromLive,
Expand Down
25 changes: 23 additions & 2 deletions apps/studio/src/modules/sync/lib/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import {
} from '@studio/common/lib/connected-sites';
import { isErrnoException } from '@studio/common/lib/is-errno-exception';
import { getCurrentUserId } from '@studio/common/lib/shared-config';
import { fetchSyncableSites } from '@studio/common/lib/sync/sync-api';
import {
fetchSyncableSites,
fetchSyncableSitesPage,
type SyncableSitesPage,
} from '@studio/common/lib/sync/sync-api';
import wpcomFactory from '@studio/common/lib/wpcom-factory';
import wpcomXhrRequest from '@studio/common/lib/wpcom-xhr-request-factory';
import { SyncSite } from '@studio/common/types/sync';
Expand Down Expand Up @@ -524,7 +528,24 @@ export async function fetchSyncableWpcomSites( _event: IpcMainInvokeEvent ): Pro
if ( ! token?.accessToken ) {
throw new Error( 'Authentication required to fetch WordPress.com sites.' );
}
return fetchSyncableSites( token.accessToken );
// Pass the already-connected remote IDs so the transform can mark those
// sites 'already-connected' instead of offering them as syncable again.
const connectedSites = await getAllConnectedWpcomSitesForCurrentUser();
const connectedSiteIds = connectedSites.map( ( site ) => site.id );
return fetchSyncableSites( token.accessToken, { connectedSiteIds } );
}

export async function fetchSyncableWpcomSitesPage(
_event: IpcMainInvokeEvent,
options: { page?: number; perPage?: number; search?: string } = {}
): Promise< SyncableSitesPage > {
const token = await getAuthenticationToken();
if ( ! token?.accessToken ) {
throw new Error( 'Authentication required to fetch WordPress.com sites.' );
}
const connectedSites = await getAllConnectedWpcomSitesForCurrentUser();
const connectedSiteIds = connectedSites.map( ( site ) => site.id );
return fetchSyncableSitesPage( token.accessToken, { ...options, connectedSiteIds } );
}

export async function getConnectedWpcomSites(
Expand Down
2 changes: 2 additions & 0 deletions apps/studio/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ const api: IpcApi = {
getConnectedWpcomSites: ( localSiteId ) =>
ipcRendererInvoke( 'getConnectedWpcomSites', localSiteId ),
fetchSyncableWpcomSites: () => ipcRendererInvoke( 'fetchSyncableWpcomSites' ),
fetchSyncableWpcomSitesPage: ( options ) =>
ipcRendererInvoke( 'fetchSyncableWpcomSitesPage', options ),
pullSiteFromLive: ( siteFolder, remoteSiteId ) =>
ipcRendererInvoke( 'pullSiteFromLive', siteFolder, remoteSiteId ),
addSyncOperation: ( id, status ) => ipcRendererSend( 'addSyncOperation', id, status ),
Expand Down
1 change: 1 addition & 0 deletions apps/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@tanstack/react-query": "^5.75.5",
"@tanstack/react-query-persist-client": "^5.96.2",
"@tanstack/react-router": "^1.120.14",
"@wordpress/a11y": "^4.47.0",
"@wordpress/api-fetch": "^7.47.0",
"@wordpress/components": "^34.0.0",
"@wordpress/core-data": "^7.47.0",
Expand Down
15 changes: 15 additions & 0 deletions apps/ui/src/components/busy-overlay/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import styles from './style.module.css';

/**
* Transparent full-window shield that blocks pointer interaction while a
* long-running action (site creation, connect-and-pull) finishes. Pair it
* with disabled/loading states on the triggering button — it deliberately
* covers everything, including the onboarding close button, so a stray
* click can't interrupt the work mid-flight.
*/
export function BusyOverlay( { active }: { active: boolean } ) {
if ( ! active ) {
return null;
}
return <div aria-hidden="true" className={ styles.overlay } />;
}
7 changes: 7 additions & 0 deletions apps/ui/src/components/busy-overlay/style.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.overlay {
position: fixed;
inset: 0;
/* Above the onboarding close button (7) and footer actions (6). */
z-index: 8;
cursor: progress;
}
19 changes: 19 additions & 0 deletions apps/ui/src/components/onboarding-footer/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import styles from './style.module.css';
import type { ReactNode } from 'react';

/**
* Fixed action bar docked to the bottom of the onboarding flow, with a
* progressive-blur scrim so content scrolls away underneath it — the
* apps/ui counterpart of the desktop renderer's Add Site stepper.
* `CreateSiteForm` renders one around its actions; selector-style routes
* render their own. The first child (Back) is pinned bottom-left and the
* remaining actions sit bottom-right.
*/
export function OnboardingFooter( { children }: { children: ReactNode } ) {
return (
<>
<div aria-hidden="true" className={ styles.scrim } />
<div className={ styles.actions }>{ children }</div>
</>
);
}
45 changes: 45 additions & 0 deletions apps/ui/src/components/onboarding-footer/style.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/* Progressive blur scrim: the backdrop blur fades out toward the top via
the mask, and the background gradient keeps the action bar legible over
content scrolling underneath — mirrors the desktop renderer's stepper. */
.scrim {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 88px;
pointer-events: none;
z-index: 5;
/* Own snapshot group so the router's view transition doesn't fade/rise
the fixed bar along with the page content. */
view-transition-name: onboarding-footer-scrim;
background: linear-gradient(
to top,
var(--wpds-color-bg-surface-neutral, #fff) 10%,
color-mix(in srgb, var(--wpds-color-bg-surface-neutral, #fff) 80%, transparent) 55%,
transparent
);
backdrop-filter: blur(10px);
-webkit-mask-image: linear-gradient(to top, black 45%, transparent);
mask-image: linear-gradient(to top, black 45%, transparent);
}

.actions {
position: fixed;
bottom: 20px;
left: 20px;
right: 20px;
z-index: 6;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
/* Own snapshot group — see .scrim. The persistent Back button reads as
stationary chrome across steps instead of animating with the page. */
view-transition-name: onboarding-footer-actions;
}

/* The first action is always Back/Cancel — pin it bottom-left and keep the
primary action(s) bottom-right. */
.actions > :first-child {
margin-right: auto;
}
11 changes: 8 additions & 3 deletions apps/ui/src/components/onboarding-layout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ interface OnboardingLayoutProps {
/**
* Content width variant. Defaults to a narrow column (`'default'`) sized
* for forms and short cards; `'wide'` is used by pages that host grids of
* content (e.g. the blueprint selector).
* content (e.g. the blueprint selector); `'full'` is used by richer picker
* pages that need room for responsive card grids.
*/
width?: 'default' | 'wide';
width?: 'default' | 'wide' | 'full';
}

export function OnboardingLayout( {
Expand All @@ -38,7 +39,11 @@ export function OnboardingLayout( {
onClick={ onClose }
/>
) }
<div className={ `${ styles.content } ${ width === 'wide' ? styles.contentWide : '' }` }>
<div
className={ `${ styles.content } ${
width === 'full' ? styles.contentFull : width === 'wide' ? styles.contentWide : ''
}` }
>
{ children }
</div>
</Stack>
Expand Down
4 changes: 4 additions & 0 deletions apps/ui/src/components/onboarding-layout/style.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
max-width: 820px;
}

.contentFull {
max-width: min(1180px, 100vw);
}

.close {
position: absolute;
top: 16px;
Expand Down
21 changes: 19 additions & 2 deletions apps/ui/src/data/core/connectors/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
AiSessionSummary,
AiSessionPlacementUpdatedEvent,
AuthUser,
AvailableSitePath,
ColorScheme,
Connector,
DeskConfig,
Expand Down Expand Up @@ -192,8 +193,8 @@ export function createIpcConnector(): Connector {
};
},

async authenticate(): Promise< void > {
await ipcApi.authenticate( false );
async authenticate( signup = false ): Promise< void > {
await ipcApi.authenticate( signup );
},

async logout(): Promise< void > {
Expand Down Expand Up @@ -221,6 +222,7 @@ export function createIpcConnector(): Connector {
adminPassword,
adminEmail,
blueprint,
skipStart,
} = params;
return ( await ipcApi.createSite( path, {
siteName: name,
Expand All @@ -232,6 +234,7 @@ export function createIpcConnector(): Connector {
adminPassword,
adminEmail,
blueprint,
noStart: skipStart,
} ) ) as SiteDetails;
},

Expand All @@ -258,6 +261,16 @@ export function createIpcConnector(): Connector {
return ( await ipcApi.generateSiteNameFromList( usedSites ) ) as string;
},

async findAvailableSitePath( baseName ): Promise< AvailableSitePath > {
// The main process resolves the numbered-name collision search in a
// single call (checking both existing site names and non-empty site
// folders) — same helper `copySite` uses above.
const sites = ( await ipcApi.getSiteDetails() ) as SiteDetails[];
const name = ( await ipcApi.generateNumberedNameFromList( baseName, sites ) ) as string;
const { path } = ( await ipcApi.generateProposedSitePath( name ) ) as { path: string };
return { name, path };
},

async generateProposedSitePath( siteName ): Promise< ProposedSitePath > {
const response = ( await ipcApi.generateProposedSitePath( siteName ) ) as {
path: string;
Expand Down Expand Up @@ -466,6 +479,10 @@ export function createIpcConnector(): Connector {
return ( await ipcApi.fetchSyncableWpcomSites() ) as SyncSite[];
},

async fetchSyncableWpcomSitesPage( options ) {
return await ipcApi.fetchSyncableWpcomSitesPage( options );
},

async connectWpcomSite( localSiteId, site ): Promise< void > {
await ipcApi.connectWpcomSites( [ { sites: [ site ], localSiteId } ] );
},
Expand Down
2 changes: 2 additions & 0 deletions apps/ui/src/data/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export type {
AiModelId,
AiSessionSummary,
AuthUser,
AvailableSitePath,
ColorScheme,
Connector,
CreateSiteParams,
Expand Down Expand Up @@ -36,6 +37,7 @@ export type {
SupportedLocale,
SupportedTerminal,
SyncSite,
SyncableWpcomSitesPage,
UserPreferences,
WritableUserPreferences,
} from './types';
Expand Down
34 changes: 33 additions & 1 deletion apps/ui/src/data/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,15 @@ export interface AuthUser {
displayName: string;
}

export interface SyncableWpcomSitesPage {
sites: SyncSite[];
total: number;
page: number;
perPage: number;
hasMore: boolean;
nextPage: number | null;
}

export interface Connector {
/**
* Optional hook for connector-specific setup that must run after the
Expand All @@ -102,7 +111,9 @@ export interface Connector {
requiresAuth: boolean;
isAuthenticated(): Promise< boolean >;
getAuthUser(): Promise< AuthUser | null >;
authenticate(): Promise< void >;
// Starts the WordPress.com OAuth flow in the browser. Pass `signup` to
// land on account creation instead of login.
authenticate( signup?: boolean ): Promise< void >;
logout(): Promise< void >;
onAuthStateChanged?( listener: () => void ): () => void;

Expand Down Expand Up @@ -143,6 +154,11 @@ export interface Connector {
// and domain lookups).
generateProposedSitePath( siteName: string ): Promise< ProposedSitePath >;
generateProposedSiteName( usedSites: SiteDetails[] ): Promise< string >;
// Resolves a base name to one that doesn't collide with an existing site
// name or a non-empty site folder ("My Site", "My Site 2", ...), returning
// it with its proposed directory. The collision search runs in the main
// process so callers pay a constant number of IPC round-trips.
findAvailableSitePath( baseName: string ): Promise< AvailableSitePath >;
selectSiteFolder( defaultPath: string ): Promise< SelectedSiteFolder | null >;
comparePaths( path1: string, path2: string ): Promise< boolean >;
getAllCustomDomains(): Promise< string[] >;
Expand Down Expand Up @@ -188,6 +204,13 @@ export interface Connector {
// of which (if any) local site they're already connected to. The publish
// picker filters this list to sites that aren't connected anywhere yet.
fetchSyncableWpcomSites(): Promise< SyncSite[] >;
// Paged variant used by picker flows so large accounts don't fetch every
// site and start every thumbnail preview at once.
fetchSyncableWpcomSitesPage( options: {
page?: number;
perPage?: number;
search?: string;
} ): Promise< SyncableWpcomSitesPage >;
// Persists a new local↔live connection so the dropdown picks it up via
// `getConnectedWpcomSites`. Safe to call with the minimal `SyncSite` we
// receive from a sync-connect-site deep link — later fetches backfill the
Expand Down Expand Up @@ -352,6 +375,10 @@ export interface CreateSiteParams {
adminUsername?: string;
adminPassword?: string;
adminEmail?: string;
// Skips starting the site server after creation. Used by flows that
// immediately overwrite the fresh install (pulling a connected
// WordPress.com site), where the sync handler restarts the server itself.
skipStart?: boolean;
// Optional blueprint payload. When present, `blueprint` is the parsed
// blueprint JSON; `slug` is set for featured blueprints (used for stats);
// `filePath` points at the extracted `blueprint.json` inside a ZIP bundle
Expand All @@ -377,6 +404,11 @@ export interface ProposedSitePath {
isNameTooLong?: boolean;
}

export interface AvailableSitePath {
name: string;
path: string;
}

export interface SelectedSiteFolder {
path: string;
name: string;
Expand Down
23 changes: 22 additions & 1 deletion apps/ui/src/data/queries/use-create-site-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { useQuery } from '@tanstack/react-query';
import { __ } from '@wordpress/i18n';
import { useCallback } from 'react';
import { useConnector } from '@/data/core';
import type { ProposedSitePath, SelectedSiteFolder, SiteDetails } from '@/data/core';
import type {
AvailableSitePath,
ProposedSitePath,
SelectedSiteFolder,
SiteDetails,
} from '@/data/core';

const CUSTOM_DOMAINS_QUERY_KEY = [ 'customDomains' ] as const;
const PROPOSED_SITE_NAME_QUERY_KEY = [ 'proposedSiteName' ] as const;
Expand Down Expand Up @@ -34,6 +39,22 @@ export function useProposedSiteName( sites: SiteDetails[] | undefined ) {
} );
}

/**
* Finds a site name that doesn't collide with an existing site or a
* non-empty site folder by appending an incrementing suffix ("My Site",
* "My Site 2", ...), and returns it together with its proposed directory.
* Used when a flow seeds the name from an external source rather than user
* input. The search runs in the main process in one round-trip.
*/
export function useFindAvailableSiteName() {
const connector = useConnector();
return useCallback(
( baseName: string ): Promise< AvailableSitePath > =>
connector.findAvailableSitePath( baseName ),
[ connector ]
);
}

/**
* Returns two imperative helpers the create form uses to validate paths:
*
Expand Down
Loading