diff --git a/config/webpack/webpack-js.ts b/config/webpack/webpack-js.ts index 5c88861e8..1fd7741ae 100644 --- a/config/webpack/webpack-js.ts +++ b/config/webpack/webpack-js.ts @@ -28,6 +28,7 @@ export const jsWebpackConfig: Configuration = { 'edit': { import: `${SOURCE_DIR}/edit.ts`, dependOn: 'editor' }, 'editor': `${SOURCE_DIR}/editor.ts`, 'import': `${SOURCE_DIR}/import.ts`, + 'insights': `${SOURCE_DIR}/insights.ts`, 'manage': `${SOURCE_DIR}/manage.ts`, 'mce': `${SOURCE_DIR}/mce.ts`, 'prism': `${SOURCE_DIR}/prism.ts`, diff --git a/src/css/insights.scss b/src/css/insights.scss new file mode 100644 index 000000000..e2542c5ea --- /dev/null +++ b/src/css/insights.scss @@ -0,0 +1,211 @@ +@use 'common/theme'; +@use 'common/toolbar'; +@use 'common/wp-admin'; + +.code-snippets-insights { + margin-block: 50px; + margin-inline: auto; + color: #2c3337; + + h1 { + padding: 0; + margin: 0; + font-size: 32px; + font-weight: 510; + line-height: 1.25; + } +} + +.insights-chart-grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-block: 16px; +} + +.insights-chart-card { + box-sizing: border-box; + border: 1px solid #dcdcde; + border-radius: 8px; + background: #fff; + padding: 24px; + + .insights-chart-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-block-end: 20px; + } + + h2 { + margin: 0; + font-size: 18px; + } +} + +.insights-number-chart { + display: grid; + gap: 4px; + justify-items: center; + padding-block: 12px; + text-align: center; +} + +.insights-number-chart-value { + font-size: 140px; + font-variant-numeric: tabular-nums; + line-height: 1; +} + +.insights-number-chart-label { + color: #646970; + font-size: 13px; +} + +.insights-chart-view-toggle { + display: flex; + align-items: center; + gap: 6px; + + .insights-chart-view-toggle-option { + display: flex; + align-items: center; + justify-content: center; + inline-size: 26px; + block-size: 26px; + box-sizing: border-box; + padding: 0; + margin: 0; + background: #f0f0f1; + color: #646970; + border: none; + border-radius: 4.5px; + cursor: pointer; + + > .dashicons { + font-size: 18px; + } + + &:hover, + &:focus { + color: #2c3337; + } + + &:focus-visible { + outline: 2px solid theme.$accent; + outline-offset: -2px; + } + + &:focus:not(:focus-visible) { + outline: none; + } + + &.active-view { + background: theme.$accent; + color: #fff; + + &:hover, + &:focus { + color: #fff; + } + } + } +} + +.insights-bar-chart, +.insights-pie-chart-legend { + padding: 0; + margin: 0; + list-style: none; +} + +.insights-bar-chart { + display: grid; + gap: 10px; + + li { + display: grid; + grid-template-columns: minmax(110px, 1fr) minmax(80px, 3fr) auto; + align-items: center; + gap: 10px; + margin: 0; + } + + strong { + min-inline-size: 2ch; + text-align: end; + } +} + +.insights-bar-track { + overflow: hidden; + block-size: 10px; + border-radius: 999px; + background: #e2e5e5; +} + +.insights-bar-fill { + block-size: 100%; + border-radius: inherit; +} + +.insights-pie-chart-content { + display: flex; + align-items: center; + gap: 24px; +} + +.insights-pie-chart { + position: relative; + flex: 0 0 auto; + inline-size: 112px; + block-size: 112px; + border-radius: 50%; + background: #e2e5e5; + + &::after { + position: absolute; + inset: 25px; + border-radius: 50%; + background: #fff; + content: ''; + } +} + +.insights-pie-chart-legend { + display: grid; + gap: 12px; + inline-size: 100%; + + li { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + margin: 0; + } + + span { + display: flex; + align-items: center; + } + + i { + display: inline-block; + inline-size: 10px; + block-size: 10px; + border-radius: 50%; + margin-inline-end: 8px; + } +} + +@media (width <= 782px) { + .insights-chart-grid { + grid-template-columns: 1fr; + } + + .insights-bar-chart li { + grid-template-columns: minmax(90px, 1fr) minmax(64px, 2fr) auto; + } +} diff --git a/src/js/components/EditMenu/SnippetForm/fields/SnippetLocationInput.tsx b/src/js/components/EditMenu/SnippetForm/fields/SnippetLocationInput.tsx index 9eac9bb8b..bd29379f1 100644 --- a/src/js/components/EditMenu/SnippetForm/fields/SnippetLocationInput.tsx +++ b/src/js/components/EditMenu/SnippetForm/fields/SnippetLocationInput.tsx @@ -3,40 +3,10 @@ import React, { useId } from 'react' import Select from 'react-select' import { useSnippetForm } from '../WithSnippetFormContext' import { SNIPPET_TYPE_SCOPES } from '../../../../types/Snippet' -import { getSnippetType, isCondition } from '../../../../utils/snippets/snippets' +import { SNIPPET_SCOPE_DESCRIPTIONS, SNIPPET_SCOPE_ICONS, getSnippetType, isCondition } from '../../../../utils/snippets/snippets' import type { SnippetCodeScope } from '../../../../types/Snippet' import type { SelectOption } from '../../../../types/SelectOption' -const SCOPE_ICONS: Record = { - 'global': 'admin-site', - 'admin': 'admin-tools', - 'front-end': 'admin-appearance', - 'single-use': 'clock', - 'content': 'shortcode', - 'head-content': 'editor-code', - 'body-content': 'editor-code', - 'footer-content': 'editor-code', - 'admin-css': 'dashboard', - 'site-css': 'admin-customizer', - 'site-head-js': 'media-code', - 'site-footer-js': 'media-code' -} - -const SCOPE_DESCRIPTIONS: Record = { - 'global': __('Run everywhere', 'code-snippets'), - 'admin': __('Only run in administration area', 'code-snippets'), - 'front-end': __('Only run on site front-end', 'code-snippets'), - 'single-use': __('Only run once', 'code-snippets'), - 'content': __('Where inserted in editor', 'code-snippets'), - 'head-content': __('In site header ( section)', 'code-snippets'), - 'body-content': __('In site content (start of )', 'code-snippets'), - 'footer-content': __('In site footer (end of )', 'code-snippets'), - 'site-css': __('Site front-end', 'code-snippets'), - 'admin-css': __('Administration area', 'code-snippets'), - 'site-head-js': __('In site header ( section)', 'code-snippets'), - 'site-footer-js': __('In site footer (end of )', 'code-snippets') -} - export const SnippetLocationInput: React.FC = () => { const { snippet, setSnippet, isReadOnly } = useSnippetForm() const locationId = useId() @@ -46,7 +16,7 @@ export const SnippetLocationInput: React.FC = () => { .map(scope => ({ key: scope, value: scope, - label: SCOPE_DESCRIPTIONS[scope] + label: SNIPPET_SCOPE_DESCRIPTIONS[scope] })) return isCondition(snippet) @@ -69,7 +39,7 @@ export const SnippetLocationInput: React.FC = () => { value={options.find(option => option.value === snippet.scope)} formatOptionLabel={({ label, value }) => <> - {` ${label}`} + {` ${label}`} } onChange={option => diff --git a/src/js/components/InsightsMenu/InsightsChartViewToggle.tsx b/src/js/components/InsightsMenu/InsightsChartViewToggle.tsx new file mode 100644 index 000000000..56fe17ee5 --- /dev/null +++ b/src/js/components/InsightsMenu/InsightsChartViewToggle.tsx @@ -0,0 +1,39 @@ +import { __, sprintf } from '@wordpress/i18n' +import classnames from 'classnames' +import React from 'react' +import type { InsightsChartView } from '../../types/Insights' + +export interface InsightsChartViewToggleProps { + title: string + view: InsightsChartView + setView: (view: InsightsChartView) => void +} + +export const InsightsChartViewToggle: React.FC = ({ title, view, setView }) => +
+ + + +
diff --git a/src/js/components/InsightsMenu/InsightsCharts.tsx b/src/js/components/InsightsMenu/InsightsCharts.tsx new file mode 100644 index 000000000..eee246f2d --- /dev/null +++ b/src/js/components/InsightsMenu/InsightsCharts.tsx @@ -0,0 +1,154 @@ +import classnames from 'classnames' +import React from 'react' +import { InsightsChartViewToggle } from './InsightsChartViewToggle' +import type { InsightsChartEntry, InsightsChartKey, InsightsChartView } from '../../types/Insights' +import type { SnippetCodeScope, SnippetType } from '../../types/Snippet' + +const PERCENTAGE_MAX = 100 + +export const INSIGHTS_TYPE_COLORS: Readonly> = { + php: '#2271b1', + html: '#cd4510', + css: '#9b59b6', + js: '#f7d67a', + cond: '#22826f' +} + +export const INSIGHTS_ACTIVATION_COLORS: Readonly> = { + active: '#118822', + inactive: '#cd4510' +} + +export const INSIGHTS_LOCATION_COLORS: Readonly> = { + 'global': '#ff9800', + 'admin': '#03c7d2', + 'front-end': '#d46f4d', + 'single-use': '#00bcd4', + 'content': '#2271b1', + 'head-content': '#5865f2', + 'body-content': '#3b5998', + 'footer-content': '#9b59b6', + 'admin-css': '#22826f', + 'site-css': '#cd4510', + 'site-head-js': '#f7d67a', + 'site-footer-js': '#2b71a3' +} + +interface InsightsChartProps { + chart: InsightsChartKey + entries: Readonly> + title: string + view: InsightsChartView + setView?: (view: InsightsChartView) => void + colors: Readonly> + total?: InsightsChartEntry +} + +const getCount = (entry: InsightsChartEntry): number => Number(entry.count) + +const getLargestCount = (entries: Readonly>): number => + Math.max(1, ...Object.values(entries).map(getCount)) + +const getColor = (colors: Readonly>, key: string): string => + colors[key] ?? '#646970' + +const getTotalCount = (entries: Readonly>): number => + Object.values(entries).reduce((count, entry) => count + getCount(entry), 0) + +const getPieBackground = ( + entries: Readonly>, + colors: Readonly>, + totalCount: number +): string => { + if (0 === totalCount) { + return '#e2e5e5' + } + + let start = 0 + const segments = Object.entries(entries) + .filter(([, entry]) => 0 < getCount(entry)) + .map(([key, entry]) => { + const end = start + getCount(entry) / totalCount * PERCENTAGE_MAX + const segment = `${getColor(colors, key)} ${start}% ${end}%` + + start = end + return segment + }) + + return `conic-gradient(${segments.join(', ')})` +} + +interface BarChartProps extends Pick { + largestCount: number +} + +const BarChart: React.FC = ({ colors, entries, largestCount }) => +
    + {Object.entries(entries).map(([key, entry]) => +
  • + {entry.label} +
  • )} +
+ +interface PieChartProps extends Pick { + totalCount: number +} + +const PieChart: React.FC = ({ colors, entries, totalCount }) => +
+