diff --git a/apps/ui/src/components/blueprint-selector/index.tsx b/apps/ui/src/components/blueprint-selector/index.tsx index c6d6f3a93f..b231fb9ff0 100644 --- a/apps/ui/src/components/blueprint-selector/index.tsx +++ b/apps/ui/src/components/blueprint-selector/index.tsx @@ -1,11 +1,17 @@ +import { EMPTY_SITE_PLAYGROUND_URL } from '@studio/common/constants'; +import { + curateBlueprintsForDisplay, + FEATURED_BLUEPRINT_SLUGS, +} from '@studio/common/lib/blueprint-curation'; import { generateDefaultBlueprintDescription } from '@studio/common/lib/blueprint-settings'; import { validateBlueprintData } from '@studio/common/lib/blueprint-validation'; +import { Spinner } from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; -import { external } from '@wordpress/icons'; -import { Button, Icon } from '@wordpress/ui'; -import { useCallback, useState } from 'react'; -import { FileDropzone } from '@/components/file-dropzone'; +import { seen } from '@wordpress/icons'; +import { Button, Icon, Tooltip } from '@wordpress/ui'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { useConnector } from '@/data/core'; +import { useGridArrowNavigation } from '@/hooks/use-grid-arrow-navigation'; import styles from './style.module.css'; import type { FeaturedBlueprint } from '@/data/core'; import type { BlueprintV1Declaration } from '@wp-playground/blueprints'; @@ -23,21 +29,159 @@ export interface PickedBlueprint { } interface BlueprintSelectorProps { - featured: FeaturedBlueprint[] | undefined; - isFeaturedLoading: boolean; + blueprints: FeaturedBlueprint[] | undefined; + isLoading: boolean; onPick: ( blueprint: PickedBlueprint ) => void; + // Picking the "Empty site" card — callers route this to the plain + // create-site flow rather than running an empty blueprint. + onPickEmpty: () => void; } const FILE_ACCEPT = 'application/json,.json,application/zip,.zip'; +/** + * Preview (eye) button overlaid on a card's image. Rendered as a sibling of + * the card's pick button (inside `cardMediaOverlay`) rather than nested in + * it, so the markup stays valid — nested interactive elements aren't. + */ +function PreviewOverlay( { url, title }: { url: string; title: string } ) { + const connector = useConnector(); + const label = __( 'Preview in your browser' ); + return ( +
+ + + void connector.openExternalUrl( url ) } + aria-label={ sprintf( + // translators: %s is the blueprint title. + __( 'Preview %s in Playground' ), + title + ) } + > + + + } + /> + }> + { label } + + + +
+ ); +} + +function EmptySiteCard( { onPick }: { onPick: () => void } ) { + return ( +
  • + + +
  • + ); +} + +function BlueprintCard( { + blueprint, + onPick, +}: { + blueprint: FeaturedBlueprint; + onPick: ( blueprint: FeaturedBlueprint ) => void; +} ) { + return ( +
  • + + { blueprint.playgroundUrl && ( + + ) } +
  • + ); +} + export function BlueprintSelector( { - featured, - isFeaturedLoading, + blueprints, + isLoading, onPick, + onPickEmpty, }: BlueprintSelectorProps ) { const connector = useConnector(); + const uploadInputRef = useRef< HTMLInputElement | null >( null ); + const handleGridKeyDown = useGridArrowNavigation(); const [ uploadError, setUploadError ] = useState< string | null >( null ); + // The endpoint returns blueprints oldest-first; newest-first reads better + // in the Explore grid (matches the desktop renderer). + const allBlueprints = useMemo( () => ( blueprints ?? [] ).slice().reverse(), [ blueprints ] ); + + const featuredBlueprints = useMemo( + () => + curateBlueprintsForDisplay( + allBlueprints.filter( ( blueprint ) => FEATURED_BLUEPRINT_SLUGS.has( blueprint.slug ) ), + __ + ), + [ allBlueprints ] + ); + const exploreBlueprints = useMemo( + () => allBlueprints.filter( ( blueprint ) => ! FEATURED_BLUEPRINT_SLUGS.has( blueprint.slug ) ), + [ allBlueprints ] + ); + const handleFeaturedClick = useCallback( ( item: FeaturedBlueprint ) => { setUploadError( null ); @@ -51,18 +195,6 @@ export function BlueprintSelector( { [ onPick ] ); - const handlePreviewClick = useCallback( - ( event: React.MouseEvent< HTMLButtonElement >, item: FeaturedBlueprint ) => { - // Stop the click from bubbling to the card's pick handler — the - // user explicitly asked to preview, not to select. - event.stopPropagation(); - if ( item.playgroundUrl ) { - void connector.openExternalUrl( item.playgroundUrl ); - } - }, - [ connector ] - ); - /** * Validates parsed blueprint JSON and hands a `PickedBlueprint` to the * parent. Returns `true` on success so callers can tell whether to clean @@ -170,64 +302,94 @@ export function BlueprintSelector( { [ acceptParsedBlueprint, connector ] ); + // Callers advertise "drop in your own", so the whole selector accepts + // blueprint drops; any validation error renders next to the Upload + // button above the explore grid. + const handleRootDrop = useCallback( + ( event: React.DragEvent< HTMLDivElement > ) => { + if ( event.defaultPrevented ) { + return; + } + event.preventDefault(); + const file = event.dataTransfer.files[ 0 ]; + if ( ! file ) { + return; + } + void handleFile( file ); + }, + [ handleFile ] + ); + return ( -
    +
    event.preventDefault() } + onDrop={ handleRootDrop } + >
    -

    { __( 'Upload your own' ) }

    - void handleFile( file ) } - error={ uploadError } - /> +
      + + { isLoading && ( +
    • + +
    • + ) } + { ! isLoading && allBlueprints.length === 0 && ( +
    • { __( 'Could not load templates.' ) }
    • + ) } + { featuredBlueprints.map( ( item ) => ( + + ) ) } +
    -
    -

    { __( 'Featured blueprints' ) }

    - { isFeaturedLoading && ( -

    { __( 'Loading featured blueprints…' ) }

    - ) } - { ! isFeaturedLoading && ( ! featured || featured.length === 0 ) && ( -

    - { __( 'No featured blueprints available right now.' ) } +

    +
    +

    { __( 'More blueprints' ) }

    +

    + { __( 'Get started quickly with a one of our blueprints, or' ) }{ ' ' } + + { '.' } +

    +
    + { uploadError && ( +

    + { uploadError }

    ) } - { featured && featured.length > 0 && ( -
      - { featured.map( ( item ) => ( -
    • - - { item.playgroundUrl && ( - - ) } -
    • + { + const file = event.target.files?.[ 0 ]; + if ( file ) { + void handleFile( file ); + } + // Reset so re-picking the same file after an error re-fires + // `change`. + event.target.value = ''; + } } + /> + { exploreBlueprints.length > 0 && ( +
        + { exploreBlueprints.map( ( item ) => ( + ) ) }
      ) } diff --git a/apps/ui/src/components/blueprint-selector/style.module.css b/apps/ui/src/components/blueprint-selector/style.module.css index 6ee1545b9d..a95aba945b 100644 --- a/apps/ui/src/components/blueprint-selector/style.module.css +++ b/apps/ui/src/components/blueprint-selector/style.module.css @@ -16,12 +16,6 @@ margin: 0; } -.featuredHint { - margin: 0; - color: var(--wpds-color-fg-content-neutral-weak, #666); - font-size: 0.875rem; -} - .grid { list-style: none; margin: 0; @@ -39,6 +33,98 @@ } } +/* The featured row (Empty site + the curated trio) fits four across on the + wide layout and folds to a 2x2 grid when the window narrows. */ +.gridFeatured { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +@media (max-width: 880px) { + .gridFeatured { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +/* Extra room between the featured row and the explore section so the + curated cards stand apart from the long tail. */ +.exploreSection { + margin-top: 40px; +} + +/* Centered intro for the blueprints long tail: heading, one-line blurb, + then the search + upload controls. */ +.exploreHeader { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + margin-bottom: 24px; +} + +.exploreSubtitle { + margin: 0; + color: var(--wpds-color-fg-content-neutral-weak, #666); + font-size: 0.875rem; +} + +/* Inline "upload a blueprint" link inside the subtitle — strip the button + box so it flows with the sentence. */ +.uploadLink { + padding: 0; + height: auto; + min-height: 0; + font-size: inherit; +} + +/* Matches the FileDropzone error pill the upload section used to render. */ +.uploadError { + padding: 8px 12px; + border-radius: 4px; + background: #fce8e8; + color: #7a1a1a; + font-size: 13px; + margin: 0; +} + +.hiddenInput { + display: none; +} + +/* Denser columns + tighter type than the featured row, so the open-by- + default explore grid reads as secondary to the curated cards. */ +.gridCompact { + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; +} + +@media (max-width: 880px) { + .gridCompact { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +.gridCompact .cardBody { + padding: 10px 12px 12px; + gap: 4px; +} + +.gridCompact .cardTitle { + font-size: 0.8125rem; +} + +.gridCompact .cardExcerpt { + font-size: 0.75rem; + -webkit-line-clamp: 2; +} + +.gridStatus { + display: flex; + align-items: center; + justify-content: center; + color: var(--wpds-color-fg-content-neutral-weak, #666); + font-size: 0.875rem; +} + .cardWrapper { position: relative; } @@ -47,6 +133,7 @@ display: flex; flex-direction: column; width: 100%; + height: 100%; padding: 0; border: 1px solid var(--wpds-color-stroke-surface-neutral, #ddd); border-radius: 8px; @@ -55,20 +142,71 @@ overflow: hidden; text-align: left; color: inherit; + transition: border-color 0.15s ease; } .card:hover { - border-color: var(--wpds-color-stroke-interactive-neutral, #bbb); + border-color: var(--wpds-color-stroke-interactive-brand, #3858e9); +} + +/* Keyboard focus mirrors the hover affordance, plus an offset ring. */ +.card:focus-visible { + outline: 2px solid var(--wpds-color-stroke-interactive-brand, #3858e9); + outline-offset: 2px; + border-color: var(--wpds-color-stroke-interactive-brand, #3858e9); +} + +/* Anchors the Live Preview pill to the card's media area. Sits outside the + pick button (siblings, not nested) so the markup stays valid; the wrapper + itself ignores pointer events so card clicks pass through. */ +.cardMediaOverlay { + position: absolute; + top: 0; + left: 0; + right: 0; + aspect-ratio: 16 / 9; + pointer-events: none; } .previewButton { position: absolute; - top: 8px; + bottom: 8px; right: 8px; + pointer-events: auto; + display: inline-flex; + align-items: center; gap: 4px; + padding: 4px 8px; + border: 1px solid rgba(255, 255, 255, 0.9); + border-radius: 4px; background: rgba(255, 255, 255, 0.92); + color: #1e1e1e; + font-size: 11px; + line-height: 1; + white-space: nowrap; + cursor: pointer; backdrop-filter: blur(6px); - border-radius: 4px; + /* Hairline dark ring outside the light border so the pill separates + from both light and dark imagery — a contrast edge, not elevation. */ + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35); + /* Revealed on card hover (the button sits inside the hovered wrapper, + so pointing at its corner shows it too) or on keyboard focus. */ + opacity: 0; + transition: opacity 0.15s ease; +} + +.cardWrapper:hover .previewButton, +.previewButton:focus-visible { + opacity: 1; +} + +.previewButton:focus-visible { + outline: 2px solid var(--wpds-color-stroke-interactive-brand, #3858e9); + outline-offset: 2px; +} + +.previewButton:hover { + background: #fff; } .cardImage { @@ -78,6 +216,40 @@ background: #f0f0f0; } +.cardImageFallback { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + aspect-ratio: 16 / 9; + background: var(--wpds-color-bg-surface-neutral, #f0f0f0); + color: var(--wpds-color-fg-content-neutral-weak, #666); + font-size: 0.8125rem; +} + +/* Thumbnail for the Empty Site card — a fixed dark slate with a faint grid + and a document glyph, identical in both color schemes by design. */ +.emptyMedia { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + aspect-ratio: 16 / 9; + background: #1f1f1f; + color: #fff; + overflow: hidden; +} + +.emptyMediaGrid { + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(255, 255, 255, 0.06) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.06) 1px, transparent 1px); + background-size: 32px 32px; +} + .cardBody { display: flex; flex-direction: column; @@ -88,6 +260,9 @@ .cardTitle { font-size: 0.95rem; font-weight: 600; + /* The global h3 rule pins line-height to the fixed 28px lg token, which + reads double-spaced at this reduced font size once a title wraps. */ + line-height: 1.3; margin: 0; } diff --git a/apps/ui/src/components/busy-overlay/index.tsx b/apps/ui/src/components/busy-overlay/index.tsx new file mode 100644 index 0000000000..cacaddc34e --- /dev/null +++ b/apps/ui/src/components/busy-overlay/index.tsx @@ -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 diff --git a/tools/common/constants.ts b/tools/common/constants.ts index 7b7aa7eb19..85e04e8055 100644 --- a/tools/common/constants.ts +++ b/tools/common/constants.ts @@ -36,6 +36,7 @@ export const CERT_UNTRUSTED_ROOT = 'CERT_TRUST_IS_UNTRUSTED_ROOT'; // Windows AP export const DEFAULT_CUSTOM_DOMAIN_SUFFIX = '.wp.local'; // WordPress constants +export const EMPTY_SITE_PLAYGROUND_URL = 'https://playground.wordpress.net/'; export const MINIMUM_WORDPRESS_VERSION = '6.2.1' as const; // https://wordpress.github.io/wordpress-playground/blueprints/examples/#load-an-older-wordpress-version export const DEFAULT_WORDPRESS_VERSION = 'latest' as const; export const DEFAULT_PHP_VERSION: typeof RecommendedPHPVersion = RecommendedPHPVersion; diff --git a/tools/common/lib/blueprint-curation.ts b/tools/common/lib/blueprint-curation.ts new file mode 100644 index 0000000000..a1fe39f1d0 --- /dev/null +++ b/tools/common/lib/blueprint-curation.ts @@ -0,0 +1,56 @@ +/** + * Display curation for the public blueprints gallery, shared by the desktop + * renderer's Add Site flow and the apps/ui onboarding flow so the two can't + * drift. The wpcom blueprints endpoint returns raw titles/excerpts; these + * helpers rename the featured trio for display, override their excerpts, + * and pin their order. + */ + +type TranslateFn = ( text: string ) => string; + +export const FEATURED_BLUEPRINT_SLUGS: ReadonlySet< string > = new Set( [ + 'woo-shop', + 'development', + 'quick-start', +] ); + +const BLUEPRINT_DISPLAY_NAMES: Record< string, string > = { + 'Quick Start': 'WordPress.com', + Development: 'WordPress Dev', + Commerce: 'WooCommerce', +}; + +const BLUEPRINT_ORDER: Record< string, number > = { + 'Quick Start': 1, + Commerce: 2, + Development: 3, +}; + +// Takes the translate function as a parameter (rather than importing the +// global `__`) so callers using a scoped i18n instance — like the desktop +// renderer's I18nProvider — still get translated strings. +function getBlueprintExcerptOverrides( __: TranslateFn ): Record< string, string > { + return { + 'Quick Start': __( + 'A WordPress.com-like environment with Business plan plugins and themes pre-installed.' + ), + Commerce: __( + 'Create your next online store with WooCommerce and its companion plugins pre-installed.' + ), + Development: __( 'A streamlined environment for building and testing themes or plugins.' ), + }; +} + +export function curateBlueprintsForDisplay< T extends { title: string; excerpt: string } >( + blueprints: T[], + __: TranslateFn +): T[] { + const excerptOverrides = getBlueprintExcerptOverrides( __ ); + return [ ...blueprints ] + .sort( ( a, b ) => ( BLUEPRINT_ORDER[ a.title ] ?? 99 ) - ( BLUEPRINT_ORDER[ b.title ] ?? 99 ) ) + .map( ( item ) => ( { + ...item, + excerpt: excerptOverrides[ item.title ] || item.excerpt, + title: BLUEPRINT_DISPLAY_NAMES[ item.title ] || item.title, + } ) ); +}