diff --git a/src/css/common/_badges.scss b/src/css/common/_badges.scss index cd0849693..f70c32cb6 100644 --- a/src/css/common/_badges.scss +++ b/src/css/common/_badges.scss @@ -78,6 +78,7 @@ $badges: ( core: #0ca0a9, pro: #f7e8e3 #df9279, revisions: #50575e, + ai: #dfc5ef #6e249c, cloud: #009fb4, bundles: #50575e, cloud_search: #d27c00, diff --git a/src/css/common/_theme.scss b/src/css/common/_theme.scss index 1ead0310f..5affb5ab5 100644 --- a/src/css/common/_theme.scss +++ b/src/css/common/_theme.scss @@ -18,8 +18,10 @@ --cs-color-border-subtle: #dcdcde; --cs-color-info: #72aee6; --cs-color-success: #00a32a; + --cs-color-success-text: #007017; --cs-color-warning: #dba617; --cs-color-error: #d63638; + --cs-color-highlight: #fcecb4; --cs-cloud-status-public-color: #64baba; --cs-cloud-status-private-color: #cc96fb; --cs-cloud-status-unverified-color: #ea835e; diff --git a/src/css/common/_toolbar.scss b/src/css/common/_toolbar.scss index 03acfb063..77a57a2c8 100644 --- a/src/css/common/_toolbar.scss +++ b/src/css/common/_toolbar.scss @@ -1,4 +1,5 @@ @use 'upsell'; +@use 'universal-search'; #wpbody { padding-block-start: var(--cs-toolbar-block-size); @@ -25,6 +26,7 @@ .code-snippets-toolbar-upper { justify-content: space-between; + gap: 28px; block-size: 76px; box-sizing: border-box; padding-block: 16px; diff --git a/src/css/common/_universal-search.scss b/src/css/common/_universal-search.scss new file mode 100644 index 000000000..0a10fab62 --- /dev/null +++ b/src/css/common/_universal-search.scss @@ -0,0 +1,523 @@ +@use 'theme'; +@use 'badges'; + +// Below this width the header cannot fit the search field beside its links, +// so the field collapses to a button that opens it over them. +$collapse-width: 1160px; + +.universal-search { + position: relative; + flex: 1; + display: flex; + justify-content: center; + min-inline-size: 0; + font-size: var(--cs-font-size-body); + + mark { + background: var(--cs-color-highlight); + color: inherit; + border-radius: 2px; + } +} + +.universal-search-field { + position: relative; + display: flex; + align-items: center; + inline-size: 100%; + max-inline-size: 500px; + + > .dashicons-search { + position: absolute; + inset-inline-start: 10px; + color: var(--cs-color-text-muted); + pointer-events: none; + } + + // The shared control styles in `_wp-admin.scss` set the border, radius and focus + // ring; this selector outranks them only to make room for the icon and key hint. + .code-snippets-toolbar & input.universal-search-input[type='search'] { + inline-size: 100%; + margin: 0; + padding-inline: 36px 40px; + background: var(--cs-color-surface); + line-height: normal; + } + + &:focus-within > .dashicons-search { + color: var(--cs-color-accent); + } + + kbd { + position: absolute; + inset-inline-end: 10px; + padding-block: 1px; + padding-inline: 6px; + border: 1px solid var(--cs-color-border); + border-radius: 3px; + background: none; + color: var(--cs-color-text-muted); + font-size: 11px; + line-height: 1.4; + pointer-events: none; + } +} + +.universal-search-toggle, +.universal-search-collapse { + display: none; + align-items: center; + justify-content: center; + inline-size: var(--cs-control-height); + block-size: var(--cs-control-height); + padding: 0; + border: 0; + border-radius: var(--cs-control-radius); + background: none; + color: var(--cs-color-text); + cursor: pointer; + + &:hover { + color: var(--cs-color-accent); + } + + &:focus-visible { + outline: 2px solid var(--cs-color-accent); + } +} + +.universal-search-dropdown { + position: absolute; + inset-block-start: calc(100% + 8px); + inset-inline-start: 50%; + transform: translateX(calc(-50% * var(--cs-direction-multiplier))); + z-index: 1000; + display: flex; + flex-direction: column; + inline-size: 700px; + max-inline-size: calc(100vw - 48px); + overflow: hidden; + border: 1px solid var(--cs-color-border); + border-radius: var(--cs-card-radius); + background: var(--cs-color-surface); + box-shadow: 0 12px 32px rgb(0 0 0 / 16%); + color: var(--cs-color-text); + + &[hidden] { + display: none; + } +} + +.universal-search-scopes { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding-block: 12px; + padding-inline: 16px; + border-block-end: 1px solid var(--cs-color-surface-muted); +} + +.universal-search-scope { + display: flex; + gap: 5px; + padding-block: 3px; + padding-inline: 10px; + border: 1px solid var(--cs-color-border); + border-radius: 999px; + background: var(--cs-color-surface); + color: var(--cs-color-text); + font-size: 12px; + line-height: 1.5; + cursor: pointer; + + &.is-active { + border-color: var(--cs-color-accent); + background: var(--cs-color-accent); + color: var(--cs-color-surface); + } + + &:focus-visible { + outline: 2px solid var(--cs-color-accent); + outline-offset: 1px; + } + + .universal-search-count { + opacity: 0.8; + } +} + +.universal-search-results { + max-block-size: min(560px, calc(100vh - 260px)); + overflow-y: auto; + padding-block-end: 6px; +} + +.universal-search-group-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-block: 12px 4px; + padding-inline: 16px; + color: var(--cs-color-text-muted); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + + .universal-search-count { + margin-inline-start: 6px; + color: var(--cs-color-text-disabled); + } +} + +.code-snippets-toolbar-upper .universal-search-option { + display: flex; + gap: 12px; + align-items: flex-start; + margin-block-start: -1px; + padding-block: 8px; + padding-inline: 16px; + border-block: 1px solid var(--cs-color-border-subtle); + color: var(--cs-color-text); + scroll-margin-block: 8px; + + &.is-active, + &:hover { + background: var(--cs-color-surface-info); + color: var(--cs-color-text); + } + + .badge { + flex: none; + min-block-size: 20px; + margin-block-start: 1px; + padding-inline: 6px; + font-size: 11px; + } +} + +.universal-search-option-text { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + min-inline-size: 0; +} + +.universal-search-option-title, +.universal-search-option-description { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.universal-search-option-title { + font-size: 14px; + font-weight: 500; +} + +.universal-search-option-description { + color: var(--cs-color-text-muted); + font-size: 12.5px; +} + +// The preview only shows what matched, so it never takes the pointer from the row beneath it. +.universal-search-code-popover { + pointer-events: none; + + .components-popover__content { + padding: 0; + overflow: hidden; + border-radius: var(--cs-control-radius); + } +} + +.universal-search-code-preview { + display: grid; + max-inline-size: min(560px, 80vw); + padding-block: 6px; + background: var(--cs-color-surface-subtle); + color: var(--cs-color-text-secondary); + font-family: Menlo, Consolas, monospace; + font-size: 12px; + line-height: 1.7; + + // The popover is rendered outside the search bar, so it needs its own highlight. + mark { + border-radius: 2px; + background: var(--cs-color-highlight); + color: inherit; + } +} + +.universal-search-code-preview-text { + overflow: hidden; + text-overflow: ellipsis; +} + +.universal-search-code-preview-line { + display: flex; + gap: 12px; + padding-inline: 12px; + white-space: pre; + + &.is-context .universal-search-code-preview-text { + filter: blur(2.5px); + user-select: none; + } +} + +.universal-search-code-preview-number { + flex: none; + min-inline-size: 2ch; + color: var(--cs-color-text-disabled); + text-align: end; + user-select: none; +} + +.universal-search-pending { + display: flex; + gap: 12px; + align-items: center; + padding-block: 12px 8px; + padding-inline: 16px; + color: var(--cs-color-text-subtle); + font-size: 12px; + animation: universal-search-pulse 1.2s infinite; + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +.universal-search-pending-badge, +.universal-search-pending-bar { + background: var(--cs-color-surface-muted); +} + +.universal-search-pending-badge { + inline-size: 44px; + block-size: 18px; + border-radius: 4px; +} + +.universal-search-pending-bar { + flex: 1; + max-inline-size: 320px; + block-size: 10px; + border-radius: 5px; +} + +@keyframes universal-search-pulse { + 0%, + 100% { + opacity: 0.45; + } + + 50% { + opacity: 1; + } +} + +.universal-search-empty { + margin: 0; + padding-block: 28px; + padding-inline: 16px; + color: var(--cs-color-text-muted); + font-size: 14px; + text-align: center; +} + +.universal-search-cloud-notice { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-block: 10px 4px; + margin-inline: 16px; + padding-block: 10px; + padding-inline: 12px; + border: 1px dashed var(--cs-color-border); + border-radius: 6px; + color: var(--cs-color-text-secondary); + font-size: 13px; + line-height: 1.5; + + a { + flex: none; + font-weight: 600; + } + + // Matches the upgrade modal's gradient, with even spacing above and below so + // it sits centred in the space between the results and the footer. + &.is-upsell { + justify-content: flex-start; + margin-block: 12px; + padding-block: 12px; + padding-inline: 14px; + border: 1px solid rgb(212 111 77 / 18%); + border-radius: 8px; + background: linear-gradient(116.04deg, #edfcff -0.75%, #fcdfd4 93.04%); + color: #1c3f41; + font-weight: 500; + + .badge { + flex: none; + } + + > span:not(.badge) { + flex: 1; + } + } +} + +.universal-search-footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding-block: 9px; + padding-inline: 16px; + border-block-start: 1px solid var(--cs-color-border-subtle); + background: var(--cs-color-surface-subtle); + color: var(--cs-color-text-muted); + font-size: 12px; + + a { + font-size: 13px; + font-weight: 600; + } +} + +// The header's own link colours would otherwise apply inside the dropdown, +// which is nested in it; these selectors outrank them. +.code-snippets-toolbar-upper .universal-search-group-header a { + font-size: 12px; + font-weight: 500; + letter-spacing: 0; + text-transform: none; +} + +.code-snippets-toolbar-upper .universal-search-group-header a, +.code-snippets-toolbar-upper .universal-search-footer a, +.code-snippets-toolbar-upper .universal-search-cloud-notice a { + color: var(--cs-color-accent); + + &:hover { + color: var(--cs-color-accent-hover); + } +} + +// The upgrade link reads as a button, in the toolbar's Upgrade gradient. +.code-snippets-toolbar-upper .universal-search-cloud-notice.is-upsell a { + padding-block: 6px; + padding-inline: 12px; + border-radius: 999px; + background: linear-gradient(135deg, var(--cs-color-secondary) 0%, #e2953f 100%); + color: #fff; + line-height: 1; + text-decoration: none; + + &:hover, + &:focus { + color: #fff; + filter: brightness(1.05); + } +} + +@media (width <= $collapse-width) { + .code-snippets-toolbar-upper { + position: relative; + } + + .universal-search { + position: static; + flex: none; + margin-inline-start: auto; + } + + .code-snippets-toolbar-upper .universal-search + nav { + margin-inline-start: 0; + } + + .universal-search-toggle { + display: flex; + } + + .universal-search-field { + display: none; + } + + .universal-search.is-expanded { + .universal-search-field { + position: absolute; + inset-block-start: 50%; + inset-inline: 16px; + z-index: 3; + display: flex; + inline-size: auto; + max-inline-size: none; + transform: translateY(-50%); + background: var(--cs-color-surface); + + input.universal-search-input { + padding-inline-end: 12px; + } + + kbd { + display: none; + } + } + + .universal-search-collapse { + display: flex; + flex: none; + margin-inline-start: 4px; + } + + // The field now covers the header, so its backdrop hides the links beneath it. + &::before { + content: ''; + position: absolute; + inset: 0; + z-index: 2; + background: var(--cs-color-surface); + } + } + + .universal-search-dropdown { + inset-block-start: calc(100% - 10px); + inset-inline: 16px; + inline-size: auto; + max-inline-size: none; + transform: none; + } +} + +// Screens reached from a search result land on the element the result points +// at, which is picked out briefly so it can be found on the page. +.code-snippets-settings tr[id^='setting-']:target, +.stats-chart-card:target, +.code-snippets-welcome :target { + scroll-margin-block-start: 48px; + animation: universal-search-target 2.4s ease-out; + + @media (prefers-reduced-motion: reduce) { + outline: 2px solid var(--cs-color-accent); + outline-offset: 4px; + animation: none; + } +} + +@keyframes universal-search-target { + 0%, + 60% { + background-color: var(--cs-color-highlight); + box-shadow: 0 0 0 6px var(--cs-color-highlight); + } + + 100% { + background-color: transparent; + box-shadow: 0 0 0 6px transparent; + } +} diff --git a/src/css/manage.scss b/src/css/manage.scss index 5102b84b9..7d51e7597 100644 --- a/src/css/manage.scss +++ b/src/css/manage.scss @@ -14,6 +14,7 @@ @use 'manage/cloud-community'; @use 'manage/cloud-library'; @use 'manage/blueprints'; +@use 'manage/search-results'; @use 'common/page-header'; @use 'common/kebab-menu'; @use 'common/demo'; diff --git a/src/css/manage/_search-results.scss b/src/css/manage/_search-results.scss new file mode 100644 index 000000000..1fd8744a8 --- /dev/null +++ b/src/css/manage/_search-results.scss @@ -0,0 +1,276 @@ +.search-results-page { + color: var(--cs-color-text); + + mark { + border-radius: 2px; + background: var(--cs-color-highlight); + color: inherit; + } + + .snippets-page-header { + justify-content: flex-start; + align-items: baseline; + gap: 12px; + } +} + +.search-results-total { + color: var(--cs-color-text-muted); + font-size: 14px; +} + +.search-results-fields { + display: flex; + flex-wrap: wrap; + gap: 8px 20px; + align-items: center; + margin-block: 4px 16px; + padding: 0; + border: 0; + + legend { + float: inline-start; + margin-inline-end: 4px; + padding: 0; + color: var(--cs-color-text-secondary); + font-weight: 600; + } +} + +.search-results-page .universal-search-cloud-notice { + margin-block: 0 16px; + margin-inline: 0; +} + +.search-results-upsell { + box-sizing: border-box; + inline-size: 100%; + max-inline-size: none; +} + +.search-results-ai-intro { + margin-block: 0 16px; + color: var(--cs-color-text-secondary); +} + +.search-results-main { + display: flex; + flex-direction: column; + gap: 20px; +} + +.search-results-group { + overflow: hidden; + border: 1px solid var(--cs-color-border-subtle); + border-radius: var(--cs-card-radius); + background: var(--cs-color-surface); +} + +.search-results-group-heading { + display: flex; + gap: 8px; + align-items: baseline; + margin: 0; + padding-block: 18px 14px; + padding-inline: 24px; + border-block-end: 1px solid var(--cs-color-border-subtle); + font-size: 18px; + font-weight: 600; +} + +.search-results-count { + color: var(--cs-color-text-muted); + font-size: 14px; + font-weight: 600; +} + +.search-results-list { + margin: 0; +} + +.search-result { + display: flex; + gap: 16px; + align-items: flex-start; + margin: 0; + padding-block: 16px; + padding-inline: 24px; + + & + & { + border-block-start: 1px solid var(--cs-color-border-subtle); + } + + // Wide enough for the longest type label, so the type column lines up across rows. + .badge { + flex: none; + min-inline-size: 72px; + margin-block-start: 1px; + } + + .row-actions { + padding-block-start: 2px; + color: var(--cs-color-text-subtle); + } + + .button-link { + font-size: 13px; + } + + .dashicons-external { + inline-size: 14px; + block-size: 14px; + margin-inline-start: 2px; + font-size: 14px; + vertical-align: text-bottom; + } +} + +.search-result-body { + flex: 1; + min-inline-size: 0; +} + +.search-result-title { + margin: 0; + font-size: 14px; + font-weight: 600; + line-height: 1.5; +} + +.search-result-title a, +.search-result .row-actions a, +.search-result .row-actions .button-link { + text-decoration: none; +} + +// Long descriptions are clipped to three lines so results stay scannable. +.search-result-description { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + line-clamp: 3; + margin-block: 2px 0; + color: var(--cs-color-text-secondary); + font-size: 13px; + line-height: 1.5; +} + +// A compact bar under the description rather than a button, opening the +// matched line of code in a preview. +.search-result-code-bar { + display: inline-flex; + gap: 6px; + align-items: center; + margin-block-start: 8px; + padding-block: 4px; + padding-inline: 8px 10px; + border: 1px solid var(--cs-color-border-subtle); + border-radius: 4px; + background: var(--cs-color-surface-subtle); + color: var(--cs-color-text-secondary); + font: inherit; + font-size: 12px; + line-height: 1.5; + cursor: pointer; + + .dashicons { + inline-size: 16px; + block-size: 16px; + font-size: 16px; + } + + .search-result-code-bar-open { + color: var(--cs-color-text-subtle); + } + + &:hover, + &[aria-expanded='true'] { + border-color: var(--cs-color-accent); + color: var(--cs-color-accent); + + .search-result-code-bar-open { + color: inherit; + } + } + + &:focus-visible { + outline: 2px solid var(--cs-color-accent); + outline-offset: 1px; + } +} + +// Each result reads as a row of the snippets table: switch, details, type, actions. +.search-result-switch { + flex: none; + display: flex; + inline-size: 36px; + min-block-size: 21px; + align-items: center; +} + +.search-result .search-result-actions { + flex: none; + min-inline-size: 110px; + max-inline-size: 260px; + padding-block-start: 0; + text-align: end; + line-height: 21px; +} + +// Where a result is found: a settings tab, a docs category, or who shared a cloud snippet. +.search-result-meta { + margin-block: 4px 0; + color: var(--cs-color-text-subtle); + font-size: 12px; + line-height: 1.5; +} + +.search-result-error { + margin-inline-start: 8px; + color: var(--cs-color-error); +} + +.search-results-empty, +.search-results-pending { + margin: 0; + padding: 24px; + border: 1px solid var(--cs-color-border-subtle); + border-radius: var(--cs-card-radius); + background: var(--cs-color-surface); + color: var(--cs-color-text-muted); + font-size: 14px; +} + +.search-results-pending { + padding-block: 16px; + font-size: 13px; + animation: universal-search-pulse 1.2s infinite; + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +.search-results-truncated { + margin: 0; + padding-block: 12px; + padding-inline: 24px; + border-block-start: 1px solid var(--cs-color-border-subtle); + background: var(--cs-color-surface-subtle); + color: var(--cs-color-text-muted); + font-size: 12px; +} + +@media (width <= 782px) { + .search-result { + flex-wrap: wrap; + } + + .search-result .search-result-actions { + inline-size: 100%; + max-inline-size: none; + padding-inline-start: 52px; + text-align: start; + } +} diff --git a/src/js/components/ManageMenu/SearchResults/SearchResultActions.tsx b/src/js/components/ManageMenu/SearchResults/SearchResultActions.tsx new file mode 100644 index 000000000..3be74a3f5 --- /dev/null +++ b/src/js/components/ManageMenu/SearchResults/SearchResultActions.tsx @@ -0,0 +1,176 @@ +import { __, sprintf } from '@wordpress/i18n' +import React, { useState } from 'react' +import { useSnippetsAPI } from '../../../hooks/useSnippetsAPI' +import { handleUnknownError } from '../../../utils/errors' +import { isLicensed } from '../../../utils/screen' +import { getSnippetEditUrl, isProSnippet } from '../../../utils/snippets/snippets' +import { Button } from '../../common/Button' +import { useCloudSnippetDownload } from '../../common/cloud/CloudSnippetDownloadButton' +import { CloudSnippetPreviewModal, SnippetPreviewModal } from '../../common/snippets/SnippetPreviewModal' +import { SnippetActionButton } from '../SnippetsTable/RowActions' +import type { ReactNode } from 'react' +import type { CloudSnippetSchema } from '../../../types/schema/CloudSnippetSchema' +import type { SearchItem, SearchSnippetSummary } from '../../../types/Search' +import type { Snippet } from '../../../types/Snippet' + +/** + * Separate a result's actions with a bar, as the row actions of the snippets table are. + */ +const joinActions = (actions: ReactNode[]): ReactNode => + actions + .filter(action => action) + .reduce((joined, action) => null === joined ? action : <>{joined} | {action}, null) + +interface SnippetPreviewActionProps { + summary: SearchSnippetSummary +} + +/** + * Search results never carry a snippet's full code, so it is fetched when the preview is asked for. + */ +const SnippetPreviewAction: React.FC = ({ summary }) => { + const api = useSnippetsAPI() + const [snippet, setSnippet] = useState() + const [isOpen, setIsOpen] = useState(false) + + return ( + <> + api.fetch(summary.id, summary.network).then(fetched => { + setSnippet(fetched) + setIsOpen(true) + })} + /> + + {isOpen && snippet && } + + ) +} + +interface SnippetActionsProps { + item: SearchItem + summary: SearchSnippetSummary +} + +// As in the snippets table, a shared network snippet can only be opened by those who manage the network. +const SnippetActions: React.FC = ({ item, summary }) => + summary.shared_network && !window.CODE_SNIPPETS_MANAGE?.hasNetworkCap ? null : joinActions([ + + {summary.locked ? __('View', 'code-snippets') : __('Edit', 'code-snippets')} + , + + ]) + +export interface SnippetActivationSwitchProps { + item: SearchItem + summary: SearchSnippetSummary + isActive: boolean + setIsActive: (isActive: boolean) => void +} + +/** + * The same switch the snippets table uses to activate and deactivate a snippet. + */ +export const SnippetActivationSwitch: React.FC = ({ item, summary, isActive, setIsActive }) => { + const api = useSnippetsAPI() + const [isWorking, setIsWorking] = useState(false) + const label = isActive + // translators: %s: snippet name. + ? sprintf(__('Deactivate %s', 'code-snippets'), item.title) + // translators: %s: snippet name. + : sprintf(__('Activate %s', 'code-snippets'), item.title) + + return ( + { + setIsWorking(true) + + ;(isActive ? api.deactivate(summary) : api.activate(summary)) + .then(() => setIsActive(!isActive)) + .catch(handleUnknownError) + .finally(() => setIsWorking(false)) + }} + /> + ) +} + +interface CloudActionsProps { + item: SearchItem + snippet: CloudSnippetSchema +} + +const CloudActions: React.FC = ({ item, snippet }) => { + const [isPreviewOpen, setIsPreviewOpen] = useState(false) + const { isDownloading, localSnippetId, errorMessage, download } = useCloudSnippetDownload({ snippet }) + const canDownload = !isProSnippet(snippet) || isLicensed() + + return ( + <> + {joinActions([ + , + + localSnippetId + ? {__('Edit', 'code-snippets')} + : canDownload && ( + ), + + 'cloud' === item.source && ( + + {__('Open Cloud Dashboard', 'code-snippets')} + {__('(opens in a new tab)', 'code-snippets')} + ) + ])} + + {errorMessage && {errorMessage}} + + {isPreviewOpen && } + + ) +} + +export interface SearchResultActionsProps { + item: SearchItem +} + +export const SearchResultActions: React.FC = ({ item }) => { + if (item.snippet) { + return + } + + if (item.cloud) { + return + } + + switch (item.source) { + case 'settings': + return {__('Open setting', 'code-snippets')} + + case 'stats': + return {__('View stats', 'code-snippets')} + + case 'docs': + return ( + + {__('Read article', 'code-snippets')} + + {__('(opens in a new tab)', 'code-snippets')} + + ) + + default: + return item.url ? {__('Open', 'code-snippets')} : null + } +} diff --git a/src/js/components/ManageMenu/SearchResults/SearchResults.tsx b/src/js/components/ManageMenu/SearchResults/SearchResults.tsx new file mode 100644 index 000000000..27058df70 --- /dev/null +++ b/src/js/components/ManageMenu/SearchResults/SearchResults.tsx @@ -0,0 +1,397 @@ +import { CheckboxControl } from '@wordpress/components' +import { __, sprintf } from '@wordpress/i18n' +import React, { useId, useState } from 'react' +import { WithRestAPIContext } from '../../../hooks/useRestAPI' +import { WithSnippetsAPIContext } from '../../../hooks/useSnippetsAPI' +import { isSearchSourceAvailable, useUniversalSearch } from '../../../hooks/useUniversalSearch' +import { SEARCH_FIELDS, SEARCH_PRO_SOURCES, SEARCH_SOURCES } from '../../../types/Search' +import { isLicensed, shouldShowUpsell } from '../../../utils/screen' +import { buildUrl, fetchQueryParam } from '../../../utils/urls' +import { WithCloudSnippetDownloadsContext } from '../../common/cloud/WithCloudSnippetDownloadsContext' +import { ScreenMetaSlot } from '../../common/ScreenMetaSlot' +import { SubnavTabs, getTabFromQuery } from '../../common/SubnavTabs' +import { + AI_SEARCH_TAB, + CloudSearchNotice, + Highlight, + PRICING_URL, + SEARCH_LOADING_LABELS, + SEARCH_SOURCE_LABELS, + SearchCodePreview, + SearchItemBadge, + formatResultCount, + getSearchItemUrl, + opensInNewTab +} from '../../common/UniversalSearch/SearchResultParts' +import { SearchResultActions, SnippetActivationSwitch } from './SearchResultActions' +import type { UniversalSearch } from '../../../hooks/useUniversalSearch' +import type { SearchCodeExcerpt, SearchField, SearchGroup, SearchItem, SearchSource } from '../../../types/Search' +import type { SubnavTab } from '../../common/SubnavTabs' + +type SourceFilter = 'all' | SearchSource | typeof AI_SEARCH_TAB + +const SOURCE_QUERY_PARAM = 'source' + +const RESULTS_PER_SOURCE = 50 + +const AI_SOURCES: readonly SearchSource[] = ['community'] + +const FIELD_LABELS: Record = { + title: __('Title', 'code-snippets'), + description: __('Description', 'code-snippets'), + code: __('Code', 'code-snippets') +} + +/** + * A Pro source is listed without a licence only to show what it offers, which + * is left out when upgrade prompts are turned off. + */ +const isSourceListed = (source: SearchSource): boolean => + isSearchSourceAvailable(source) || SEARCH_PRO_SOURCES.includes(source) && shouldShowUpsell() + +const getSourceTabs = (): SubnavTab[] => [ + { name: 'all', label: __('All results', 'code-snippets') }, + ...SEARCH_SOURCES + .filter(isSourceListed) + .map(source => ({ name: source, label: SEARCH_SOURCE_LABELS[source], pro: SEARCH_PRO_SOURCES.includes(source) })), + { name: AI_SEARCH_TAB, label: __('Search with AI', 'code-snippets') } +] + +const isLockedTab = (filter: SourceFilter): boolean => + 'all' !== filter && AI_SEARCH_TAB !== filter && SEARCH_PRO_SOURCES.includes(filter) && !isLicensed() + +const isPending = (loadingSources: SearchSource[], filter: SourceFilter): boolean => + loadingSources.some(source => 'all' === filter || source === filter) + +const getPendingLabels = (loadingSources: SearchSource[], filter: SourceFilter): string[] => + loadingSources + .filter(source => 'all' === filter || source === filter) + .flatMap(source => SEARCH_LOADING_LABELS[source] ?? []) + +const countResults = (groups: SearchGroup[]): number => + groups.reduce((total, group) => total + group.total, 0) + +interface CodeMatchBarProps { + code: SearchCodeExcerpt + term: string +} + +/** + * Shows the line of code a result matched on, in the same preview as the search bar. + */ +const CodeMatchBar: React.FC = ({ code, term }) => { + const [anchor, setAnchor] = useState(null) + const [isOpen, setIsOpen] = useState(false) + + return ( + <> + + + {isOpen && anchor && } + + ) +} + +interface SearchResultProps { + item: SearchItem + term: string + fields: readonly SearchField[] +} + +const SearchResult: React.FC = ({ item, term, fields }) => { + const [isActive, setIsActive] = useState(item.snippet?.active ?? false) + const url = getSearchItemUrl(item, term) + + // Only the fields being matched in are highlighted, so a highlight always marks a match. + const highlightIn = (field: SearchField) => fields.includes(field) ? term : '' + + // Every result shares the snippets table's row layout: switch, details, type, actions. Only + // local snippets can be switched on, so the other rows leave that column empty to stay aligned. + return ( +
  • +
    + {item.snippet && 'cond' !== item.type && ( + )} +
    + +
    +

    + {item.url + ? + + + : } +

    + + {item.description && ( +

    + +

    )} + + {/* A local snippet's path is only its type, which the badge already shows. */} + {!item.snippet && item.path &&

    {item.path}

    } + + {item.code && } +
    + + + +
    + +
    +
  • + ) +} + +interface SearchResultGroupProps { + group: SearchGroup + term: string + fields: readonly SearchField[] +} + +const SearchResultGroup: React.FC = ({ group, term, fields }) => { + const headingId = useId() + + return ( +
    +

    + {SEARCH_SOURCE_LABELS[group.source]} + {group.total.toLocaleString()} +

    + +
      + {group.items.map(item => )} +
    + + {group.total > group.items.length && ( +

    + {sprintf( + // translators: 1: number of results shown, 2: total number of results. + __('Showing the first %1$s of %2$s results. Search for something more specific to narrow them down.', 'code-snippets'), + group.items.length.toLocaleString(), + group.total.toLocaleString() + )} +

    )} +
    + ) +} + +interface FieldFiltersProps { + fields: readonly SearchField[] + setFields: (fields: SearchField[]) => void +} + +const FieldFilters: React.FC = ({ fields, setFields }) => +
    + {__('Match in:', 'code-snippets')} + {SEARCH_FIELDS.map(field => + setFields(SEARCH_FIELDS.filter(other => + other === field ? checked : fields.includes(other)))} + />)} +
    + +interface SearchResultsListProps { + search: UniversalSearch + filter: SourceFilter + fields: readonly SearchField[] +} + +const SearchResultsList: React.FC = ({ search, filter, fields }) => { + const { term, isSearchable, groups, loadingSources, hasFailed } = search + const visibleGroups = groups.filter(group => 'all' === filter || group.source === filter) + const pendingLabels = getPendingLabels(loadingSources, filter) + + return ( +
    +
    + {isSearchable && !hasFailed && (0 < pendingLabels.length + ? pendingLabels.join(' ') + : formatResultCount(countResults(visibleGroups)))} +
    + + {!isSearchable && ( +

    + {0 < fields.length + ? __('Type at least two characters in the search bar above to search snippets, settings, docs and cloud.', 'code-snippets') + : __('Choose at least one place to match in.', 'code-snippets')} +

    )} + + {hasFailed && ( +

    + {__('Snippets and settings could not be searched. Reload the page to try again.', 'code-snippets')} +

    )} + + {isSearchable && !hasFailed && 0 === visibleGroups.length && !isPending(loadingSources, filter) && ( +

    + {__('Nothing matched. Try a different term or another source.', 'code-snippets')} +

    )} + + {visibleGroups.map(group => + )} + + {pendingLabels.map(label => +

    {label}

    )} +
    + ) +} + +interface CloudLibraryUpsellProps { + query: string +} + +/** + * Shown in place of Cloud Library results on a site without Pro. + */ +const CloudLibraryUpsell: React.FC = ({ query }) => +
    +

    + {// translators: %s: search query. + sprintf(__('Search your own Cloud Library for “%s”', 'code-snippets'), query.trim())} +

    + +

    + {__('With Code Snippets Pro, the search bar also looks through every snippet saved to your Cloud Library, including private snippets and those synced from your other sites, so you can find and download them from here.', 'code-snippets')} +

    + + +
    + +const AiSearchIntro = () => +

    + {__('AI search finds community snippets by what they do, so describe what you need in your own words.', 'code-snippets')} +

    + +const SearchResultsPage = () => { + const [query] = useState(() => fetchQueryParam('s') ?? '') + const [tabs] = useState(getSourceTabs) + const [currentTab, setCurrentTab] = useState(() => getTabFromQuery(tabs, SOURCE_QUERY_PARAM)) + const [fields, setFields] = useState(SEARCH_FIELDS) + const filter = currentTab.name + const isAiTab = AI_SEARCH_TAB === filter + const search = useUniversalSearch({ query, fields, perSource: RESULTS_PER_SOURCE, excerpts: false }) + + // An AI search takes longer and asks more of the cloud, so it only runs once its tab is opened. + const aiSearch = useUniversalSearch({ + query, + perSource: RESULTS_PER_SOURCE, + excerpts: false, + sources: AI_SOURCES, + method: 'ai', + enabled: isAiTab + }) + + const shownSearch = isAiTab ? aiSearch : search + const visibleGroups = isAiTab + ? aiSearch.groups + : search.groups.filter(group => 'all' === filter || group.source === filter) + + const getTabCount = (tab: SubnavTab): number | undefined => { + if (AI_SEARCH_TAB === tab.name) { + return isAiTab && 0 === aiSearch.loadingSources.length ? aiSearch.total : undefined + } + + if (isLockedTab(tab.name)) { + return undefined + } + + if ('all' === tab.name) { + return 0 === search.loadingSources.length ? search.total : undefined + } + + return search.loadingSources.includes(tab.name) + ? undefined + : search.groups.find(group => group.source === tab.name)?.total ?? 0 + } + + return ( +
    + + + + +
    +

    + {search.isSearchable + // translators: %s: search query. + ? sprintf(__('Search results for “%s”', 'code-snippets'), search.term) + : __('Search', 'code-snippets')} +

    + + {search.isSearchable && !isLockedTab(filter) && !isPending(shownSearch.loadingSources, isAiTab ? 'all' : filter) && ( + {formatResultCount(countResults(visibleGroups))})} +
    + +
    + + {isLockedTab(filter) && } + + {isAiTab && } + + {!isAiTab && !isLockedTab(filter) && <> + + {'all' === filter && isLicensed() && } + } + + {!isLockedTab(filter) && ( + )} +
    + ) +} + +export const SearchResults = () => + + + + + + + diff --git a/src/js/components/ManageMenu/SnippetsTable/RowActions.tsx b/src/js/components/ManageMenu/SnippetsTable/RowActions.tsx index ae320c95f..70bcb25ae 100644 --- a/src/js/components/ManageMenu/SnippetsTable/RowActions.tsx +++ b/src/js/components/ManageMenu/SnippetsTable/RowActions.tsx @@ -35,7 +35,7 @@ const PreviewButton: React.FC = ({ snippet }) => { ) } -interface SnippetActionButtonProps { +export interface SnippetActionButtonProps { action: () => Promise label: ReactNode workingLabel?: string @@ -45,7 +45,7 @@ interface SnippetActionButtonProps { enum SnippetActionStatus { Ready, Working, Errored } -const SnippetActionButton: React.FC = ({ action, label, workingLabel, failedLabel, className }) => { +export const SnippetActionButton: React.FC = ({ action, label, workingLabel, failedLabel, className }) => { const [status, setStatus] = useState(SnippetActionStatus.Ready) return ( diff --git a/src/js/components/ManageMenu/subpages.tsx b/src/js/components/ManageMenu/subpages.tsx index a052b744d..4c60ee0c5 100644 --- a/src/js/components/ManageMenu/subpages.tsx +++ b/src/js/components/ManageMenu/subpages.tsx @@ -2,10 +2,11 @@ import { AiAgentDemo } from './AiAgentDemo/AiAgentDemo' import { BlueprintsDemo } from './BlueprintsDemo/BlueprintsDemo' import { CloudLibraryDemo } from './CloudLibraryDemo/CloudLibraryDemo' import { CommunityCloud } from './CommunityCloud/CommunityCloud' +import { SearchResults } from './SearchResults/SearchResults' import { SnippetsTable } from './SnippetsTable' import type React from 'react' -export const SUBPAGES = ['snippets', 'blueprints', 'cloud-community', 'cloud-library', 'ai-agent'] as const +export const SUBPAGES = ['snippets', 'blueprints', 'cloud-community', 'cloud-library', 'ai-agent', 'search'] as const export type SubpageName = typeof SUBPAGES[number] @@ -38,4 +39,5 @@ export const SUBPAGE_ENTRIES: Record = { 'cloud-community': { Component: CommunityCloud }, 'cloud-library': { Component: CloudLibraryDemo, demo: 'quiet' }, 'ai-agent': { Component: AiAgentDemo, demo: 'announce' }, + 'search': { Component: SearchResults }, } diff --git a/src/js/components/StatsMenu/StatsCharts.tsx b/src/js/components/StatsMenu/StatsCharts.tsx index d8bd09b40..b0f06e91b 100644 --- a/src/js/components/StatsMenu/StatsCharts.tsx +++ b/src/js/components/StatsMenu/StatsCharts.tsx @@ -142,6 +142,7 @@ export const StatsChart = ({ views }: StatsChartProps) =>
    = ({ chart, count, label }) => -
    +
    {count} {label} diff --git a/src/js/components/StatsMenu/StatsMenu.tsx b/src/js/components/StatsMenu/StatsMenu.tsx index c629ecb55..b5aaef899 100644 --- a/src/js/components/StatsMenu/StatsMenu.tsx +++ b/src/js/components/StatsMenu/StatsMenu.tsx @@ -1,5 +1,6 @@ -import React from 'react' +import React, { useEffect } from 'react' import { WithRestAPIContext } from '../../hooks/useRestAPI' +import { revealLocationHash } from '../../utils/urls' import { ScreenMetaSlot } from '../common/ScreenMetaSlot' import { Toolbar } from '../common/Toolbar' import { StatsDashboard } from './StatsDashboard' @@ -7,6 +8,8 @@ import { StatsDashboard } from './StatsDashboard' export const StatsMenu: React.FC = () => { const summary = window.CODE_SNIPPETS_STATS + useEffect(revealLocationHash, []) + return ( <> diff --git a/src/js/components/WelcomeMenu/Changelog.tsx b/src/js/components/WelcomeMenu/Changelog.tsx index cc10a5308..1dd414bc4 100644 --- a/src/js/components/WelcomeMenu/Changelog.tsx +++ b/src/js/components/WelcomeMenu/Changelog.tsx @@ -55,7 +55,7 @@ const ChangelogSection: React.FC = ({ section, entries }) export const Changelog = () => -
    +

    {__('Latest changes', 'code-snippets')}

    = ({ partners }) => <> -

    {__('Exclusive deals from our partners', 'code-snippets')}

    +

    {__('Exclusive deals from our partners', 'code-snippets')}

      {partners.map(({ title, follow_url, image_url }) =>
    • @@ -76,7 +77,7 @@ interface ArticlesProps { const Articles: React.FC = ({ articles }) => <> -

      {__('Helpful articles', 'code-snippets')}

      +

      {__('Helpful articles', 'code-snippets')}

        {articles.map(({ title, follow_url, image_url, description, category }) =>
      • @@ -98,26 +99,31 @@ const Articles: React.FC = ({ articles }) =>
      -export const WelcomeMenu = () => - <> - +export const WelcomeMenu = () => { + useEffect(revealLocationHash, []) - + return ( + <> + -
      -

      {__('Resources and Updates', 'code-snippets')}

      -
      + -
      +
      +

      {__('Resources and Updates', 'code-snippets')}

      +
      -
      +
      -
      - - -
      +
      - {DATA?.features && } - {DATA?.partners && } -
      - +
      + + +
      + + {DATA?.features && } + {DATA?.partners && } +
      + + ) +} diff --git a/src/js/components/common/Badge.tsx b/src/js/components/common/Badge.tsx index 235a57751..653816ed4 100644 --- a/src/js/components/common/Badge.tsx +++ b/src/js/components/common/Badge.tsx @@ -4,6 +4,7 @@ import type { ReactNode } from 'react' import type { SnippetType } from '../../types/Snippet' export type BadgeName = SnippetType | 'core' | 'pro' | 'ai' | 'cloud' | 'bundles' | 'cloud_search' | 'beta' + | 'neutral' const badgeIcons: Partial> = { cond: 'randomize', diff --git a/src/js/components/common/Toolbar.tsx b/src/js/components/common/Toolbar.tsx index 3813dceeb..f578ce147 100644 --- a/src/js/components/common/Toolbar.tsx +++ b/src/js/components/common/Toolbar.tsx @@ -9,8 +9,10 @@ import { AiAgentIcon } from './icons/AiAgentIcon' import { BlueprintIcon } from './icons/BlueprintIcon' import { CommunityIcon } from './icons/CommunityIcon' import { LibraryIcon } from './icons/LibraryIcon' +import { SearchIcon } from './icons/SearchIcon' import { SettingsIcon } from './icons/SettingsIcon' import { SnippetsIcon } from './icons/SnippetsIcon' +import { UniversalSearch } from './UniversalSearch/UniversalSearch' import { UpsellDialog } from './UpsellDialog' import type { SVGProps } from 'react' @@ -115,6 +117,8 @@ const UpperNav: React.FC = () => {
      {__('Code Snippets', 'code-snippets')}
    + +