diff --git a/apps/ui/src/lib/backup-files.ts b/apps/ui/src/lib/backup-files.ts new file mode 100644 index 0000000000..369327a160 --- /dev/null +++ b/apps/ui/src/lib/backup-files.ts @@ -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(); +} diff --git a/apps/ui/src/lib/pending-backup.ts b/apps/ui/src/lib/pending-backup.ts new file mode 100644 index 0000000000..8b59d693c7 --- /dev/null +++ b/apps/ui/src/lib/pending-backup.ts @@ -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; diff --git a/apps/ui/src/lib/pending-slot.ts b/apps/ui/src/lib/pending-slot.ts new file mode 100644 index 0000000000..4ffdaf8aec --- /dev/null +++ b/apps/ui/src/lib/pending-slot.ts @@ -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 ); + }; + }, + }; +} diff --git a/apps/ui/src/ui-classic/router/layout-onboarding/style.module.css b/apps/ui/src/ui-classic/router/layout-onboarding/style.module.css index 3bf06f28c5..a7a00665f9 100644 --- a/apps/ui/src/ui-classic/router/layout-onboarding/style.module.css +++ b/apps/ui/src/ui-classic/router/layout-onboarding/style.module.css @@ -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); } @@ -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; @@ -61,3 +70,7 @@ letter-spacing: 0.04em; color: var(--wpds-color-fg-content-neutral-weak, #555); } + +.hiddenInput { + display: none; +} diff --git a/apps/ui/src/ui-classic/router/route-onboarding-home/index.tsx b/apps/ui/src/ui-classic/router/route-onboarding-home/index.tsx index be5ebbe680..256869f218 100644 --- a/apps/ui/src/ui-classic/router/route-onboarding-home/index.tsx +++ b/apps/ui/src/ui-classic/router/route-onboarding-home/index.tsx @@ -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 (
- { __( - 'Drop a backup archive to restore a site locally. Jetpack, All-in-One WP Migration, Local, and Playground exports are supported.' - ) } -
-{ __( 'Pick a name and local folder. The backup will restore on top of this new site.' ) } @@ -202,7 +95,7 @@ function OnboardingImportPage() { initialValues={ initialValues } existingDomainNames={ existingDomainNames ?? [] } onSubmit={ handleSubmit } - onCancel={ () => void navigate( { to: '/onboarding' } ) } + onCancel={ handleBack } isSubmitting={ isSubmitting } submitError={ submitError } submitLabel={ __( 'Import site' ) } @@ -214,12 +107,5 @@ function OnboardingImportPage() { export const onboardingImportRoute = createRoute( { getParentRoute: () => onboardingLayoutRoute, path: '/onboarding/import', - validateSearch: ( search: Record< string, unknown > ): ImportSearch => { - const value = search.step; - if ( value === 'configure' || value === 'select' ) { - return { step: value }; - } - return {}; - }, component: OnboardingImportPage, } ); diff --git a/apps/ui/src/ui-classic/router/route-onboarding-import/style.module.css b/apps/ui/src/ui-classic/router/route-onboarding-import/style.module.css deleted file mode 100644 index 56615bfa21..0000000000 --- a/apps/ui/src/ui-classic/router/route-onboarding-import/style.module.css +++ /dev/null @@ -1,8 +0,0 @@ -.backLink { - display: inline-flex; - align-items: center; - gap: 4px; - align-self: flex-start; - margin-bottom: 16px; - padding: 4px 8px 4px 4px; -}