add_action( 'init', $this, 'register_block' ); } public function register_block(): void { if ( ! function_exists( 'register_block_type' ) ) { return; } wp_register_script( 'acc-block-stat-counters', false, [ 'wp-blocks', 'wp-element', 'wp-server-side-render', 'wp-block-editor', 'wp-components', 'wp-i18n' ], ACADEMICCREW_VERSION, true ); wp_add_inline_script( 'acc-block-stat-counters', $this->editor_script() ); register_block_type( 'academiccrew/stat-counters', [ 'api_version' => 2, 'title' => __( 'AcademicCrew: Stat Counters', 'academiccrew' ), 'description' => __( 'Animated big-number counters for the site totals (courses, projects, people).', 'academiccrew' ), 'category' => 'academiccrew', 'icon' => 'chart-bar', 'supports' => [ 'html' => false, 'align' => [ 'wide', 'full' ] ], 'attributes' => [ 'show' => [ 'type' => 'string', 'default' => 'courses,projects,people' ], 'animate' => [ 'type' => 'boolean', 'default' => true ], 'columns' => [ 'type' => 'integer', 'default' => 3 ], 'duration' => [ 'type' => 'integer', 'default' => 2000 ], 'courses' => [ 'type' => 'string', 'default' => 'all' ], 'projects' => [ 'type' => 'string', 'default' => 'all' ], 'membersExclude' => [ 'type' => 'string', 'default' => '' ], 'numSize' => [ 'type' => 'string', 'default' => '' ], 'labelSize' => [ 'type' => 'string', 'default' => '' ], 'numColor' => [ 'type' => 'string', 'default' => '' ], 'labelColor' => [ 'type' => 'string', 'default' => '' ], 'round' => [ 'type' => 'integer', 'default' => 0 ], 'suffix' => [ 'type' => 'string', 'default' => '+' ], 'coursesRound' => [ 'type' => 'string', 'default' => '' ], 'coursesSuffix' => [ 'type' => 'string', 'default' => '' ], 'projectsRound' => [ 'type' => 'string', 'default' => '' ], 'projectsSuffix' => [ 'type' => 'string', 'default' => '' ], 'peopleRound' => [ 'type' => 'string', 'default' => '' ], 'peopleSuffix' => [ 'type' => 'string', 'default' => '' ], 'custom' => [ 'type' => 'string', 'default' => '' ], ], 'render_callback' => [ $this, 'render' ], 'editor_script' => 'acc-block-stat-counters', ] ); } /** * @param array $atts */ public function render( $atts ): string { $atts = (array) $atts; return ( new StatCountersShortcode() )->render( [ 'show' => (string) ( $atts['show'] ?? 'courses,projects,people' ), 'animate' => empty( $atts['animate'] ) ? 'no' : 'yes', 'columns' => (int) ( $atts['columns'] ?? 3 ), 'duration' => (int) ( $atts['duration'] ?? 2000 ), 'courses' => (string) ( $atts['courses'] ?? 'all' ), 'projects' => (string) ( $atts['projects'] ?? 'all' ), 'members_exclude' => (string) ( $atts['membersExclude'] ?? '' ), 'num_size' => (string) ( $atts['numSize'] ?? '' ), 'label_size' => (string) ( $atts['labelSize'] ?? '' ), 'num_color' => (string) ( $atts['numColor'] ?? '' ), 'label_color' => (string) ( $atts['labelColor'] ?? '' ), 'round' => (int) ( $atts['round'] ?? 0 ), 'suffix' => (string) ( $atts['suffix'] ?? '+' ), 'courses_round' => (string) ( $atts['coursesRound'] ?? '' ), 'courses_suffix' => (string) ( $atts['coursesSuffix'] ?? '' ), 'projects_round' => (string) ( $atts['projectsRound'] ?? '' ), 'projects_suffix' => (string) ( $atts['projectsSuffix'] ?? '' ), 'people_round' => (string) ( $atts['peopleRound'] ?? '' ), 'people_suffix' => (string) ( $atts['peopleSuffix'] ?? '' ), 'custom' => (string) ( $atts['custom'] ?? '' ), ] ); } private function editor_script(): string { return <<<'JS' (function (blocks, el, SSR, blockEditor, components, i18n) { var __ = i18n.__; var InspectorControls = blockEditor.InspectorControls; var useBlockProps = blockEditor.useBlockProps; var PanelBody = components.PanelBody; var ToggleControl = components.ToggleControl; var RangeControl = components.RangeControl; var CheckboxControl = components.CheckboxControl; var SelectControl = components.SelectControl; var FormTokenField = components.FormTokenField; var TextControl = components.TextControl; var TextareaControl = components.TextareaControl; var METRICS = [ { key: 'courses', label: __('Courses', 'academiccrew') }, { key: 'projects', label: __('Projects', 'academiccrew') }, { key: 'people', label: __('People', 'academiccrew') } ]; var ROLE_SUGGESTIONS = [ 'former', 'full-professor', 'associate-professor', 'assistant-professor', 'researcher', 'postdoc', 'phd-student', 'research-assistant', 'visiting-researcher', 'collaborator', 'former-professor', 'former-researcher', 'former-postdoc', 'former-phd', 'former-research-assistant', 'alumni' ]; blocks.registerBlockType('academiccrew/stat-counters', { edit: function (props) { var a = props.attributes, setA = props.setAttributes; var blockProps = useBlockProps({ style: { position: 'relative' } }); var chosen = (a.show || '').split(',').map(function (s) { return s.trim(); }).filter(Boolean); function toggleMetric(key, on) { var next = chosen.filter(function (k) { return k !== key; }); if (on) { next = METRICS.map(function (m) { return m.key; }).filter(function (k) { return k === key || chosen.indexOf(k) !== -1; }); } setA({ show: next.join(',') }); } // Per-counter round + suffix override (falls back to the widget-wide // "round"/"suffix" default below when left blank). function roundSuffix(roundKey, suffixKey) { var roundVal = (a[roundKey] === '' || typeof a[roundKey] === 'undefined') ? 0 : parseInt(a[roundKey], 10); return [ el(RangeControl, { key: roundKey, label: __('Round down to nearest', 'academiccrew'), help: __('Blank/0 uses the default round below.', 'academiccrew'), value: roundVal, min: 0, max: 100, step: 5, onChange: function (v) { var patch = {}; patch[roundKey] = v ? String(v) : ''; setA(patch); } }), (roundVal > 0) && el(TextControl, { key: suffixKey, label: __('Suffix', 'academiccrew'), value: a[suffixKey] || '', placeholder: a.suffix || '+', onChange: function (v) { var patch = {}; patch[suffixKey] = v; setA(patch); } }) ]; } return el('div', blockProps, el(InspectorControls, { key: 'c' }, el(PanelBody, { title: __('Counters', 'academiccrew'), initialOpen: true }, METRICS.map(function (m) { return el(CheckboxControl, { key: m.key, label: m.label, checked: chosen.indexOf(m.key) !== -1, onChange: function (on) { toggleMetric(m.key, on); } }); }), el(ToggleControl, { label: __('Animate count-up', 'academiccrew'), checked: !!a.animate, onChange: function (v) { setA({ animate: v }); } }), el(RangeControl, { label: __('Columns', 'academiccrew'), value: a.columns, min: 1, max: 4, step: 1, onChange: function (v) { setA({ columns: v }); } }), a.animate && el(RangeControl, { label: __('Animation duration (ms)', 'academiccrew'), value: a.duration, min: 500, max: 6000, step: 100, onChange: function (v) { setA({ duration: v }); } }) ), el(PanelBody, { title: __('What to count', 'academiccrew'), initialOpen: false }, el(SelectControl, { label: __('Courses', 'academiccrew'), value: a.courses || 'all', options: [ { label: __('All courses', 'academiccrew'), value: 'all' }, { label: __('Current academic year only', 'academiccrew'), value: 'current' } ], onChange: function (v) { setA({ courses: v }); } }), chosen.indexOf('courses') !== -1 && roundSuffix('coursesRound', 'coursesSuffix'), el(SelectControl, { label: __('Projects', 'academiccrew'), value: a.projects || 'all', options: [ { label: __('All projects', 'academiccrew'), value: 'all' }, { label: __('Active (ongoing) only', 'academiccrew'), value: 'active' }, { label: __('Past (completed) only', 'academiccrew'), value: 'past' } ], onChange: function (v) { setA({ projects: v }); } }), chosen.indexOf('projects') !== -1 && roundSuffix('projectsRound', 'projectsSuffix'), el(FormTokenField, { label: __('People — exclude these roles', 'academiccrew'), value: (a.membersExclude || '').split(',').map(function (s) { return s.trim(); }).filter(Boolean), suggestions: ROLE_SUGGESTIONS, __experimentalExpandOnFocus: true, onChange: function (tokens) { setA({ membersExclude: tokens.join(',') }); } }), el('p', { style: { fontSize: '11px', color: '#757575', margin: '4px 0 0' } }, __('Tip: the "former" token excludes every former-* role and alumni.', 'academiccrew')), chosen.indexOf('people') !== -1 && roundSuffix('peopleRound', 'peopleSuffix') ), el(PanelBody, { title: __('Number, size & color', 'academiccrew'), initialOpen: false }, el(TextControl, { label: __('Number font size', 'academiccrew'), help: __('Any CSS size, e.g. 3rem or 48px. Blank = default.', 'academiccrew'), value: a.numSize || '', onChange: function (v) { setA({ numSize: v }); } }), el(TextControl, { label: __('Number font color', 'academiccrew'), help: __('e.g. #c41230 or rebeccapurple. Blank = theme default.', 'academiccrew'), value: a.numColor || '', onChange: function (v) { setA({ numColor: v }); } }), el(TextControl, { label: __('Label font size', 'academiccrew'), help: __('e.g. 12px. Blank = default.', 'academiccrew'), value: a.labelSize || '', onChange: function (v) { setA({ labelSize: v }); } }), el(TextControl, { label: __('Label font color', 'academiccrew'), help: __('e.g. #8893a6. Blank = theme default.', 'academiccrew'), value: a.labelColor || '', onChange: function (v) { setA({ labelColor: v }); } }), el(RangeControl, { label: __('Default round down to nearest', 'academiccrew'), help: __('0 = exact number. e.g. 10 shows 48 as 40. Used by any counter that has no override of its own.', 'academiccrew'), value: a.round || 0, min: 0, max: 100, step: 5, onChange: function (v) { setA({ round: v }); } }), (a.round > 0) && el(TextControl, { label: __('Default suffix after a rounded number', 'academiccrew'), value: (typeof a.suffix === 'string') ? a.suffix : '+', onChange: function (v) { setA({ suffix: v }); } }) ), el(PanelBody, { title: __('Custom counters', 'academiccrew'), initialOpen: false }, el(TextareaControl, { label: __('One per line: Label | Value | Round | Suffix', 'academiccrew'), help: __('Round and Suffix are optional and fall back to the default round/suffix above. Example: "Patents | 34" or "Awards | 128 | 10 | +".', 'academiccrew'), value: a.custom || '', rows: 5, onChange: function (v) { setA({ custom: v }); } }) ) ), el('div', { key: 'p', style: { pointerEvents: 'none' } }, el(SSR, { block: 'academiccrew/stat-counters', attributes: a }) ) ); }, save: function () { return null; } }); }(wp.blocks, wp.element.createElement, wp.serverSideRender, wp.blockEditor, wp.components, wp.i18n)); JS; } }