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
22 changes: 22 additions & 0 deletions apps/ui/src/lib/backup-files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { ACCEPTED_IMPORT_FILE_TYPES } from '@studio/common/constants';

export function isValidBackupFile( file: File ): boolean {
const lower = file.name.toLowerCase();
return ACCEPTED_IMPORT_FILE_TYPES.some( ( ext ) => lower.endsWith( ext ) );
}

/**
* Derives a friendly default site name from a backup filename. Strips the
* archive extension and common "site-backup-2024-01-01" date suffixes so the
* form can seed the site name without the user having to retype it.
*/
export function nameFromFilename( filename: string ): string {
const basename = filename.replace( /^.*[\\/]/, '' );
const lower = basename.toLowerCase();
const ext = ACCEPTED_IMPORT_FILE_TYPES.find( ( candidate ) => lower.endsWith( candidate ) );
return ( ext ? basename.slice( 0, -ext.length ) : basename )
.replace( /[-_](backup|export|wordpress|jetpack)(s)?$/i, '' )
.replace( /[-_]\d{4}[-_]\d{2}[-_]\d{2}.*$/, '' )
.replace( /[-_]+/g, ' ' )
.trim();
}
21 changes: 21 additions & 0 deletions apps/ui/src/lib/pending-backup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { createPendingSlot } from './pending-slot';

/**
* One-slot handoff for a backup archive picked outside the import route's
* own UI — currently the drop-target card on the onboarding home screen.
* The picker stores the file here and navigates to the configure step; the
* route adopts it into component state on arrival and clears the slot.
*/

export interface PendingBackup {
file: File;
// Resolved via `connector.getFilePath` at pick-time so the import route
// doesn't have to await the preload bridge again.
path: string;
}

export const pendingBackupSlot = createPendingSlot< PendingBackup >();

export const setPendingBackup = pendingBackupSlot.set;
export const peekPendingBackup = pendingBackupSlot.peek;
export const clearPendingBackup = pendingBackupSlot.clear;
49 changes: 49 additions & 0 deletions apps/ui/src/lib/pending-slot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* A one-slot handoff between a producer outside a route's own UI (a deep
* link, the onboarding home screen) and the route that consumes the value.
* The producer `set`s the value and navigates; the route adopts it on
* arrival and `clear`s the slot.
*
* `subscribe` notifies on every set/clear so routes can read the slot via
* `useSyncExternalStore` and react to a new value arriving while already
* mounted (e.g. a second deep link mid-configure). Adoption and clearing
* stay split (rather than an atomic take) so React StrictMode's
* double-invoked effects can't consume the value on the first pass and
* bounce the user back on the second.
*/
export interface PendingSlot< T > {
set( value: T ): void;
peek(): T | null;
clear(): void;
subscribe( listener: () => void ): () => void;
}

export function createPendingSlot< T >(): PendingSlot< T > {
let value: T | null = null;
const listeners = new Set< () => void >();
const notify = () => {
for ( const listener of [ ...listeners ] ) {
listener();
}
};
return {
set( next: T ) {
value = next;
notify();
},
peek: () => value,
clear() {
if ( value === null ) {
return;
}
value = null;
notify();
},
subscribe( listener: () => void ) {
listeners.add( listener );
return () => {
listeners.delete( listener );
};
},
};
}
15 changes: 14 additions & 1 deletion apps/ui/src/ui-classic/router/layout-onboarding/style.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@
color: inherit;
text-decoration: none;
background: var(--wpds-color-bg-surface-neutral-strong, #fff);
font: inherit;
}

.card:hover {
.card:hover,
.cardDragging {
border-color: var(--wpds-color-stroke-interactive-neutral, #bbb);
}

Expand All @@ -52,6 +54,13 @@
font-size: 0.875rem;
}

.cardError {
display: block;
margin-top: 8px;
font-size: 0.75rem;
color: var(--wpds-color-fg-content-error, #b32d2e);
}

.cardBadge {
position: absolute;
top: 12px;
Expand All @@ -61,3 +70,7 @@
letter-spacing: 0.04em;
color: var(--wpds-color-fg-content-neutral-weak, #555);
}

.hiddenInput {
display: none;
}
75 changes: 70 additions & 5 deletions apps/ui/src/ui-classic/router/route-onboarding-home/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,41 @@
import { createRoute, Link } from '@tanstack/react-router';
import { ACCEPTED_IMPORT_FILE_TYPES } from '@studio/common/constants';
import { createRoute, Link, useNavigate } from '@tanstack/react-router';
import { __ } from '@wordpress/i18n';
import { useCallback, useRef, useState } from 'react';
import { useConnector } from '@/data/core';
import { isValidBackupFile } from '@/lib/backup-files';
import { setPendingBackup } from '@/lib/pending-backup';
import { onboardingLayoutRoute } from '../layout-onboarding';
import styles from '../layout-onboarding/style.module.css';

function OnboardingHomePage() {
const navigate = useNavigate();
const connector = useConnector();
const fileRef = useRef< HTMLInputElement >( null );
const [ isDragging, setIsDragging ] = useState( false );
const [ error, setError ] = useState< string | null >( null );

const handleBackupFile = useCallback(
async ( file: File | undefined ) => {
if ( ! file ) {
return;
}
if ( ! isValidBackupFile( file ) ) {
setError( __( 'Unsupported file type.' ) );
return;
}
const path = await connector.getFilePath( file );
if ( ! path ) {
setError( __( 'Unable to read the file. Try clicking the card to browse instead.' ) );
return;
}
setError( null );
setPendingBackup( { file, path } );
await navigate( { to: '/onboarding/import' } );
},
[ connector, navigate ]
);

return (
<div className={ styles.page }>
<h1 className={ styles.title }>{ __( 'Start a new site' ) }</h1>
Expand All @@ -25,12 +57,45 @@ function OnboardingHomePage() {
) }
</p>
</Link>
<Link to="/onboarding/import" className={ styles.card }>
<h3 className={ styles.cardTitle }>{ __( 'Bring existing' ) }</h3>
<input
ref={ fileRef }
type="file"
accept={ ACCEPTED_IMPORT_FILE_TYPES.join( ',' ) }
className={ styles.hiddenInput }
onChange={ ( event ) => {
void handleBackupFile( event.target.files?.[ 0 ] );
event.target.value = '';
} }
/>
<button
type="button"
className={ isDragging ? `${ styles.card } ${ styles.cardDragging }` : styles.card }
onClick={ () => fileRef.current?.click() }
onDragOver={ ( event ) => {
event.preventDefault();
setIsDragging( true );
setError( null );
} }
onDragLeave={ ( event ) => {
event.preventDefault();
setIsDragging( false );
} }
onDrop={ ( event ) => {
event.preventDefault();
setIsDragging( false );
void handleBackupFile( event.dataTransfer.files[ 0 ] );
} }
>
<h3 className={ styles.cardTitle }>{ __( 'Import from a backup' ) }</h3>
<p className={ styles.cardBody }>
{ __( 'Import from a Jetpack backup or another full-site export' ) }
{ __( 'Drop a file or click to browse (.zip, .tar.gz, .sql, .wpress)' ) }
</p>
</Link>
{ error && (
<span role="alert" className={ styles.cardError }>
{ error }
</span>
) }
</button>
</div>
</div>
);
Expand Down
Loading