diff --git a/apps/ui/src/components/site-overview-view/about-section.tsx b/apps/ui/src/components/site-overview-view/about-section.tsx new file mode 100644 index 0000000000..ade97fbfaa --- /dev/null +++ b/apps/ui/src/components/site-overview-view/about-section.tsx @@ -0,0 +1,77 @@ +import { __, sprintf } from '@wordpress/i18n'; +import { external, Icon } from '@wordpress/icons'; +import { useConnector } from '@/data/core'; +import { useSiteThumbnail } from '@/data/queries/use-site-thumbnail'; +import { useIsSiteStarting, useStartSite } from '@/data/queries/use-sites'; +import styles from './cards.module.css'; +import { CardSection } from './overview-card'; +import type { SiteDetails } from '@/data/core'; + +export function AboutSection( { site, wpVersion }: { site: SiteDetails; wpVersion?: string } ) { + const connector = useConnector(); + const startSite = useStartSite(); + const isStarting = useIsSiteStarting( site.id ); + const themeName = site.themeDetails?.name || site.themeDetails?.slug || '—'; + const thumbnail = useSiteThumbnail( site.id ); + const wpLabel = wpVersion + ? sprintf( + /* translators: %s: WordPress version number */ + __( 'WP v%s' ), + wpVersion + ) + : __( 'WP —' ); + const phpLabel = sprintf( + /* translators: %s: PHP version number */ + __( 'PHP v%s' ), + site.phpVersion + ); + const openSiteInBrowser = async () => { + try { + if ( ! site.running ) { + await startSite.mutateAsync( site.id ); + } + await connector.openSiteUrl( site.id, '/', { autoLogin: false } ); + } catch ( error ) { + console.error( 'Failed to open site in browser:', error ); + } + }; + + return ( + +
+ +
+ { __( 'Theme' ) } + { themeName } + + { wpLabel } + + { phpLabel } + +
+
+
+ ); +} diff --git a/apps/ui/src/components/site-overview-view/cards.module.css b/apps/ui/src/components/site-overview-view/cards.module.css new file mode 100644 index 0000000000..6eae92f8f9 --- /dev/null +++ b/apps/ui/src/components/site-overview-view/cards.module.css @@ -0,0 +1,105 @@ +.card { + display: flex; + flex-direction: column; + box-sizing: border-box; + border: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-neutral); + border-radius: var(--wpds-border-radius-md); + background: var(--wpds-color-bg-surface-neutral); + min-width: 0; +} + +.cardSection { + display: flex; + flex-direction: column; + gap: var(--wpds-dimension-padding-md); + padding: var(--wpds-dimension-padding-lg); + min-width: 0; +} + +.themeSummary { + display: flex; + align-items: center; + gap: var(--wpds-dimension-padding-md); + min-width: 0; +} + +.thumbnail { + position: relative; + appearance: none; + width: 88px; + aspect-ratio: 4 / 3; + overflow: hidden; + flex: 0 0 auto; + padding: 0; + border: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-surface-neutral); + border-radius: var(--wpds-border-radius-sm); + background: var(--wpds-color-bg-surface-neutral-strong); + cursor: var(--wpds-cursor-control); +} + +.thumbnail img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.thumbnailOverlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: var(--wpds-dimension-padding-xs); + background: var(--wpds-color-bg-interactive-neutral-weak-active); + color: var(--wpds-color-fg-content-neutral); + font-size: var(--wpds-typography-font-size-xs); + font-weight: var(--wpds-typography-font-weight-medium); + opacity: 0; + transition: opacity 120ms ease; +} + +.thumbnail:hover .thumbnailOverlay, +.thumbnail:focus-visible .thumbnailOverlay, +.thumbnail[data-empty] .thumbnailOverlay { + opacity: 0.94; +} + +.thumbnail:focus-visible { + outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus-brand); + outline-offset: 2px; +} + +.thumbnail:disabled { + cursor: not-allowed; +} + +.themeDetails { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.themeValue { + overflow-wrap: anywhere; + font-size: var(--wpds-typography-font-size-sm); + line-height: var(--wpds-typography-line-height-sm); + color: var(--wpds-color-fg-content-neutral); +} + +.themeMeta { + display: flex; + align-items: center; + flex-wrap: wrap; + column-gap: var(--wpds-dimension-padding-xs); + font-size: var(--wpds-typography-font-size-xs); + line-height: var(--wpds-typography-line-height-xs); + color: var(--wpds-color-fg-content-neutral-weak); +} + +.tileLabel { + font-size: var(--wpds-typography-font-size-xs); + line-height: var(--wpds-typography-line-height-xs); + color: var(--wpds-color-fg-content-neutral-weak); +} diff --git a/apps/ui/src/components/site-overview-view/index.test.tsx b/apps/ui/src/components/site-overview-view/index.test.tsx index aaae92b8e0..a9f1bd13e7 100644 --- a/apps/ui/src/components/site-overview-view/index.test.tsx +++ b/apps/ui/src/components/site-overview-view/index.test.tsx @@ -5,6 +5,7 @@ import { useConnector } from '@/data/core'; import { useAgenticFeatures } from '@/data/queries/use-agentic-features'; import { useLogin } from '@/data/queries/use-auth-user'; import { useExistingCustomDomains } from '@/data/queries/use-create-site-helpers'; +import { useSiteThumbnail } from '@/data/queries/use-site-thumbnail'; import { useCopySite, useExportDatabase, @@ -94,6 +95,10 @@ vi.mock( '@/data/queries/use-sites', () => ( { useXdebugEnabledSite: vi.fn(), } ) ); +vi.mock( '@/data/queries/use-site-thumbnail', () => ( { + useSiteThumbnail: vi.fn(), +} ) ); + vi.mock( '@/data/queries/use-user-preferences', () => ( { useUserPreferences: vi.fn(), } ) ); @@ -124,6 +129,7 @@ const useExportDatabaseMock = vi.mocked( useExportDatabase, { partial: true } ); const useExportFullSiteMock = vi.mocked( useExportFullSite, { partial: true } ); const useIsSiteStartingMock = vi.mocked( useIsSiteStarting ); const useIsSiteStoppingMock = vi.mocked( useIsSiteStopping ); +const useSiteThumbnailMock = vi.mocked( useSiteThumbnail, { partial: true } ); const useSitesMock = vi.mocked( useSites, { partial: true } ); const useStartSiteMock = vi.mocked( useStartSite, { partial: true } ); const useUpdateSiteMock = vi.mocked( useUpdateSite, { partial: true } ); @@ -188,6 +194,9 @@ describe( 'SiteOverviewView', () => { data: [ createSite( { running: true } ) ], isLoading: false, } ); + useSiteThumbnailMock.mockReturnValue( { + data: 'data:image/png;base64,site-thumbnail', + } ); useIsSiteStartingMock.mockReturnValue( false ); useIsSiteStoppingMock.mockReturnValue( false ); useStartSiteMock.mockReturnValue( { @@ -221,7 +230,7 @@ describe( 'SiteOverviewView', () => { ); } - it( 'renders the tab strip with the customize and manage sections', () => { + it( 'renders the tab strip with the about, customize, and manage sections', () => { renderView(); expect( siteDropdownMock ).toHaveBeenCalledWith( @@ -230,6 +239,7 @@ describe( 'SiteOverviewView', () => { expect( screen.getByRole( 'tab', { name: 'Overview' } ) ).toBeVisible(); expect( screen.getByRole( 'tab', { name: 'Settings' } ) ).toBeVisible(); expect( screen.getByRole( 'tab', { name: 'Debugging' } ) ).toBeVisible(); + expect( screen.getByRole( 'heading', { name: 'About' } ) ).toBeVisible(); expect( screen.getByRole( 'heading', { name: 'Customize' } ) ).toBeVisible(); expect( screen.getByRole( 'heading', { name: 'Manage' } ) ).toBeVisible(); expect( screen.getByText( 'Site Editor' ) ).toBeVisible(); @@ -243,6 +253,37 @@ describe( 'SiteOverviewView', () => { expect( screen.queryByDisplayValue( 'Demo Site' ) ).not.toBeInTheDocument(); } ); + it( 'summarizes the site theme and runtime versions', async () => { + useWpVersionMock.mockReturnValue( { data: '6.8.2' } ); + + renderView(); + + expect( screen.getByText( 'Theme' ) ).toBeVisible(); + expect( screen.getByText( 'Twenty Twenty-Six' ) ).toBeVisible(); + expect( screen.getByText( 'WP v6.8.2' ) ).toBeVisible(); + expect( screen.getByText( 'PHP v8.4' ) ).toBeVisible(); + expect( await screen.findByRole( 'img', { name: 'Screenshot of Demo Site' } ) ).toHaveAttribute( + 'src', + 'data:image/png;base64,site-thumbnail' + ); + + fireEvent.click( screen.getByRole( 'button', { name: 'Open site in browser' } ) ); + await waitFor( () => + expect( openSiteUrl ).toHaveBeenCalledWith( 'site-1', '/', { autoLogin: false } ) + ); + } ); + + it( 'keeps the browser action available without a cached thumbnail', () => { + useSiteThumbnailMock.mockReturnValue( { data: null } ); + + renderView(); + + expect( screen.getByRole( 'button', { name: 'Open site in browser' } ) ).toBeVisible(); + expect( + screen.queryByRole( 'img', { name: 'Screenshot of Demo Site' } ) + ).not.toBeInTheDocument(); + } ); + it( 'offsets the site menu below macOS traffic lights when the sidebar is collapsed', () => { useSidebarCollapsedMock.mockReturnValue( true ); useTrafficLightSpaceMock.mockReturnValue( { start: true, end: false } ); diff --git a/apps/ui/src/components/site-overview-view/index.tsx b/apps/ui/src/components/site-overview-view/index.tsx index caceaff26e..27b237d273 100644 --- a/apps/ui/src/components/site-overview-view/index.tsx +++ b/apps/ui/src/components/site-overview-view/index.tsx @@ -28,10 +28,13 @@ import * as Tabs from '@/components/tabs'; import { useConnector } from '@/data/core'; import { useIsSiteStarting, useIsSiteStopping, useSites } from '@/data/queries/use-sites'; import { useUserPreferences } from '@/data/queries/use-user-preferences'; +import { useWpVersion } from '@/data/queries/use-wordpress-versions'; import { useOpenSiteUrl } from '@/hooks/use-open-site-url'; import { useSidebarCollapsed } from '@/hooks/use-sidebar-collapsed'; import { useSiteManagementActions } from '@/hooks/use-site-management-actions'; import { useTrafficLightSpace } from '@/hooks/use-traffic-light-space'; +import { AboutSection } from './about-section'; +import { OverviewCard } from './overview-card'; import styles from './style.module.css'; import type { SiteSettingsTabId } from '@/components/site-settings-view'; import type { SiteDetails } from '@/data/core'; @@ -230,6 +233,7 @@ function SiteOverviewBody( { const busy = isStarting || isStopping; const themeDetails = site.themeDetails; const isBlockTheme = themeDetails?.isBlockTheme === true; + const { data: wpVersion } = useWpVersion( site.id ); // Opens WordPress screens in the in-app preview panel (starting the site // first when needed) rather than the external browser. @@ -261,6 +265,12 @@ function SiteOverviewBody( { +
+

{ __( 'About' ) }

+ + + +
{ isBlockTheme ? ( diff --git a/apps/ui/src/components/site-overview-view/overview-card.tsx b/apps/ui/src/components/site-overview-view/overview-card.tsx new file mode 100644 index 0000000000..b88a6f2172 --- /dev/null +++ b/apps/ui/src/components/site-overview-view/overview-card.tsx @@ -0,0 +1,10 @@ +import styles from './cards.module.css'; +import type { ReactNode } from 'react'; + +export function OverviewCard( { children }: { children: ReactNode } ) { + return
{ children }
; +} + +export function CardSection( { children }: { children: ReactNode } ) { + return
{ children }
; +} diff --git a/apps/ui/src/components/site-overview-view/style.module.css b/apps/ui/src/components/site-overview-view/style.module.css index d75c84207c..1eafd5f4cf 100644 --- a/apps/ui/src/components/site-overview-view/style.module.css +++ b/apps/ui/src/components/site-overview-view/style.module.css @@ -1,4 +1,7 @@ .root { + --overview-content-width: 1120px; + --overview-card-column-width: 320px; + /* Anchor for the footer toolbar + its progressive blur. */ position: relative; display: flex; @@ -62,11 +65,21 @@ .tabsBarInner { min-width: 0; - max-width: 760px; + max-width: var(--overview-content-width); } .panel { outline: none; + display: grid; + grid-template-columns: minmax(0, var(--overview-card-column-width)) minmax(0, 1fr); + align-items: start; + gap: var(--wpds-dimension-padding-3xl); + width: 100%; + min-width: 0; +} + +.panel > :not(.cardColumn, .actionsColumn) { + grid-column: 1 / -1; } .scroll { @@ -109,9 +122,12 @@ } .content { + container-type: inline-size; + container-name: overview; + box-sizing: border-box; width: 100%; - max-width: 760px; + max-width: var(--overview-content-width); /* Extra bottom padding keeps the last content clear of the overlaid footer toolbar. */ padding: var(--wpds-dimension-padding-xl) var(--wpds-dimension-padding-xl) @@ -126,6 +142,32 @@ color: var(--wpds-color-fg-content-neutral); } +.cardColumn { + display: flex; + flex-direction: column; + gap: var(--wpds-dimension-padding-md); + min-width: 0; +} + +@container overview (max-width: 760px) { + .panel { + grid-template-columns: minmax(0, 1fr); + gap: var(--wpds-dimension-padding-2xl); + } + + .panel > :not(.cardColumn, .actionsColumn) { + order: 0; + } + + .cardColumn { + order: 2; + } + + .actionsColumn { + order: 1; + } +} + /* Each section is a full-width row (Customize, Open in…, Manage), stacked. */ .actionsColumn { display: flex; diff --git a/apps/ui/src/data/queries/use-site-thumbnail.ts b/apps/ui/src/data/queries/use-site-thumbnail.ts new file mode 100644 index 0000000000..7276d2f6cb --- /dev/null +++ b/apps/ui/src/data/queries/use-site-thumbnail.ts @@ -0,0 +1,15 @@ +import { useQuery } from '@tanstack/react-query'; +import { useConnector } from '@/data/core'; + +export const siteThumbnailQueryKey = ( siteId: string ) => + [ 'site-preview-thumbnail', siteId ] as const; + +export function useSiteThumbnail( siteId: string ) { + const connector = useConnector(); + + return useQuery( { + queryKey: siteThumbnailQueryKey( siteId ), + queryFn: () => connector.getSiteThumbnail( siteId ), + meta: { persist: false }, + } ); +}