From a929fb8a29c1cad32b7e6d76959a2b236eb0c278 Mon Sep 17 00:00:00 2001 From: Tony Arcangelini Date: Wed, 6 May 2026 10:03:34 +0200 Subject: [PATCH 1/4] Podcast: scaffold empty package + module behind jetpack_podcast_untangle filter --- pnpm-lock.yaml | 2 + projects/packages/podcast/.gitattributes | 12 ++++ projects/packages/podcast/.gitignore | 4 ++ projects/packages/podcast/.phan/baseline.php | 17 +++++ projects/packages/podcast/.phan/config.php | 13 ++++ projects/packages/podcast/.phpcs.dir.xml | 24 +++++++ projects/packages/podcast/CHANGELOG.md | 6 ++ projects/packages/podcast/changelog/.gitkeep | 0 .../podcast/changelog/add-podcast-package | 4 ++ projects/packages/podcast/composer.json | 54 +++++++++++++++ projects/packages/podcast/package.json | 17 +++++ projects/packages/podcast/phpunit.11.xml.dist | 36 ++++++++++ projects/packages/podcast/phpunit.12.xml.dist | 36 ++++++++++ projects/packages/podcast/phpunit.8.xml.dist | 17 +++++ projects/packages/podcast/phpunit.9.xml.dist | 17 +++++ .../packages/podcast/src/class-podcast.php | 67 +++++++++++++++++++ .../packages/podcast/tests/.phpcs.dir.xml | 4 ++ .../podcast/tests/php/Podcast_Test.php | 36 ++++++++++ .../packages/podcast/tests/php/bootstrap.php | 12 ++++ .../jetpack/changelog/add-podcast-package | 4 ++ projects/plugins/jetpack/composer.json | 1 + projects/plugins/jetpack/composer.lock | 57 +++++++++++++++- projects/plugins/jetpack/load-jetpack.php | 4 ++ 23 files changed, 443 insertions(+), 1 deletion(-) create mode 100644 projects/packages/podcast/.gitattributes create mode 100644 projects/packages/podcast/.gitignore create mode 100644 projects/packages/podcast/.phan/baseline.php create mode 100644 projects/packages/podcast/.phan/config.php create mode 100644 projects/packages/podcast/.phpcs.dir.xml create mode 100644 projects/packages/podcast/CHANGELOG.md create mode 100644 projects/packages/podcast/changelog/.gitkeep create mode 100644 projects/packages/podcast/changelog/add-podcast-package create mode 100644 projects/packages/podcast/composer.json create mode 100644 projects/packages/podcast/package.json create mode 100644 projects/packages/podcast/phpunit.11.xml.dist create mode 100644 projects/packages/podcast/phpunit.12.xml.dist create mode 100644 projects/packages/podcast/phpunit.8.xml.dist create mode 100644 projects/packages/podcast/phpunit.9.xml.dist create mode 100644 projects/packages/podcast/src/class-podcast.php create mode 100644 projects/packages/podcast/tests/.phpcs.dir.xml create mode 100644 projects/packages/podcast/tests/php/Podcast_Test.php create mode 100644 projects/packages/podcast/tests/php/bootstrap.php create mode 100644 projects/plugins/jetpack/changelog/add-podcast-package diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba1e667b449d..d22383b48e89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3554,6 +3554,8 @@ importers: specifier: 6.0.1 version: 6.0.1(webpack@5.105.2) + projects/packages/podcast: {} + projects/packages/post-list: dependencies: '@wordpress/i18n': diff --git a/projects/packages/podcast/.gitattributes b/projects/packages/podcast/.gitattributes new file mode 100644 index 000000000000..b3028ba45f00 --- /dev/null +++ b/projects/packages/podcast/.gitattributes @@ -0,0 +1,12 @@ +# Files not needed to be distributed in the package. +.gitattributes export-ignore +.github/ export-ignore +package.json export-ignore + +# Files to exclude from the mirror repo, but included in the monorepo. +# Remember to end all directories with `/**` to properly tag every file. +.gitignore production-exclude +changelog/** production-exclude +.phpcs.dir.xml production-exclude +tests/** production-exclude +.phpcsignore production-exclude diff --git a/projects/packages/podcast/.gitignore b/projects/packages/podcast/.gitignore new file mode 100644 index 000000000000..8424444d7508 --- /dev/null +++ b/projects/packages/podcast/.gitignore @@ -0,0 +1,4 @@ +vendor/ +node_modules/ +.cache/ +composer.lock diff --git a/projects/packages/podcast/.phan/baseline.php b/projects/packages/podcast/.phan/baseline.php new file mode 100644 index 000000000000..3df50068147a --- /dev/null +++ b/projects/packages/podcast/.phan/baseline.php @@ -0,0 +1,17 @@ + [ + ], + // 'directory_suppressions' => ['src/directory_name' => ['PhanIssueName1', 'PhanIssueName2']] can be manually added if needed. + // (directory_suppressions will currently be ignored by subsequent calls to --save-baseline, but may be preserved in future Phan releases) +]; diff --git a/projects/packages/podcast/.phan/config.php b/projects/packages/podcast/.phan/config.php new file mode 100644 index 000000000000..86a515290b91 --- /dev/null +++ b/projects/packages/podcast/.phan/config.php @@ -0,0 +1,13 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/projects/packages/podcast/CHANGELOG.md b/projects/packages/podcast/CHANGELOG.md new file mode 100644 index 000000000000..03a962f457f6 --- /dev/null +++ b/projects/packages/podcast/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). diff --git a/projects/packages/podcast/changelog/.gitkeep b/projects/packages/podcast/changelog/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/projects/packages/podcast/changelog/add-podcast-package b/projects/packages/podcast/changelog/add-podcast-package new file mode 100644 index 000000000000..c8cda5a6d4a9 --- /dev/null +++ b/projects/packages/podcast/changelog/add-podcast-package @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Initial scaffolding for the Jetpack Podcast package. Loads on Simple and Atomic only, gated behind the `jetpack_podcast_untangle` filter (default off) so it stays inert while the legacy podcasting code keeps running. diff --git a/projects/packages/podcast/composer.json b/projects/packages/podcast/composer.json new file mode 100644 index 000000000000..40f7df0dcd38 --- /dev/null +++ b/projects/packages/podcast/composer.json @@ -0,0 +1,54 @@ +{ + "name": "automattic/jetpack-podcast", + "description": "Jetpack Podcast functionality (Simple and Atomic only).", + "type": "jetpack-library", + "license": "GPL-2.0-or-later", + "require": { + "php": ">=7.2", + "automattic/jetpack-status": "@dev" + }, + "require-dev": { + "yoast/phpunit-polyfills": "^4.0.0", + "automattic/jetpack-test-environment": "@dev", + "automattic/phpunit-select-config": "@dev" + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "scripts": { + "phpunit": [ + "phpunit-select-config phpunit.#.xml.dist --colors=always" + ], + "test-php": [ + "@composer phpunit" + ] + }, + "repositories": [ + { + "type": "path", + "url": "../../packages/*", + "options": { + "monorepo": true + } + } + ], + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "autorelease": true, + "autotagger": true, + "mirror-repo": "Automattic/jetpack-podcast", + "branch-alias": { + "dev-trunk": "0.1.x-dev" + }, + "textdomain": "jetpack-podcast", + "version-constants": { + "::PACKAGE_VERSION": "src/class-podcast.php" + }, + "changelogger": { + "link-template": "https://github.com/Automattic/jetpack-podcast/compare/v${old}...v${new}" + } + } +} diff --git a/projects/packages/podcast/package.json b/projects/packages/podcast/package.json new file mode 100644 index 000000000000..51bdce16ea03 --- /dev/null +++ b/projects/packages/podcast/package.json @@ -0,0 +1,17 @@ +{ + "name": "@automattic/jetpack-podcast", + "version": "0.1.0-alpha", + "private": true, + "description": "Jetpack Podcast functionality (Simple and Atomic only).", + "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/packages/podcast/#readme", + "bugs": { + "url": "https://github.com/Automattic/jetpack/labels/[Package] Podcast" + }, + "repository": { + "type": "git", + "url": "https://github.com/Automattic/jetpack.git", + "directory": "projects/packages/podcast" + }, + "license": "GPL-2.0-or-later", + "author": "Automattic" +} diff --git a/projects/packages/podcast/phpunit.11.xml.dist b/projects/packages/podcast/phpunit.11.xml.dist new file mode 100644 index 000000000000..f7418373829b --- /dev/null +++ b/projects/packages/podcast/phpunit.11.xml.dist @@ -0,0 +1,36 @@ + + + + + tests/php + + + + + + + + src + + + + + diff --git a/projects/packages/podcast/phpunit.12.xml.dist b/projects/packages/podcast/phpunit.12.xml.dist new file mode 100644 index 000000000000..f7418373829b --- /dev/null +++ b/projects/packages/podcast/phpunit.12.xml.dist @@ -0,0 +1,36 @@ + + + + + tests/php + + + + + + + + src + + + + + diff --git a/projects/packages/podcast/phpunit.8.xml.dist b/projects/packages/podcast/phpunit.8.xml.dist new file mode 100644 index 000000000000..3965963c485e --- /dev/null +++ b/projects/packages/podcast/phpunit.8.xml.dist @@ -0,0 +1,17 @@ + + + + + tests/php + + + diff --git a/projects/packages/podcast/phpunit.9.xml.dist b/projects/packages/podcast/phpunit.9.xml.dist new file mode 100644 index 000000000000..3965963c485e --- /dev/null +++ b/projects/packages/podcast/phpunit.9.xml.dist @@ -0,0 +1,17 @@ + + + + + tests/php + + + diff --git a/projects/packages/podcast/src/class-podcast.php b/projects/packages/podcast/src/class-podcast.php new file mode 100644 index 000000000000..528ef1dc3f4c --- /dev/null +++ b/projects/packages/podcast/src/class-podcast.php @@ -0,0 +1,67 @@ +is_wpcom_simple() && ! $host->is_woa_site() ) { + return; + } + + /** + * Master switch for the Podcast untangle. + * + * While the legacy podcasting code is still the source of truth on + * Simple and Atomic sites, this filter stays false. Subsequent PRs + * layer the new wp-admin SPA, REST integration, and feed + * customization on top of this gate. + * + * @since 0.1.0 + * + * @param bool $enabled Whether to enable the new Podcast package. + */ + if ( ! apply_filters( 'jetpack_podcast_untangle', false ) ) { + return; + } + + // Subsequent PRs in the untangle train wire the new package up here. + } +} diff --git a/projects/packages/podcast/tests/.phpcs.dir.xml b/projects/packages/podcast/tests/.phpcs.dir.xml new file mode 100644 index 000000000000..46951fe77b37 --- /dev/null +++ b/projects/packages/podcast/tests/.phpcs.dir.xml @@ -0,0 +1,4 @@ + + + + diff --git a/projects/packages/podcast/tests/php/Podcast_Test.php b/projects/packages/podcast/tests/php/Podcast_Test.php new file mode 100644 index 000000000000..bdfc34356c9f --- /dev/null +++ b/projects/packages/podcast/tests/php/Podcast_Test.php @@ -0,0 +1,36 @@ +expectNotToPerformAssertions(); + } + + /** + * `PACKAGE_VERSION` is exposed for the changelogger version-constants + * mapping declared in `composer.json`. + */ + public function test_package_version_constant_is_defined() { + $this->assertNotEmpty( Podcast::PACKAGE_VERSION ); + } +} diff --git a/projects/packages/podcast/tests/php/bootstrap.php b/projects/packages/podcast/tests/php/bootstrap.php new file mode 100644 index 000000000000..fe7ccf4c45ad --- /dev/null +++ b/projects/packages/podcast/tests/php/bootstrap.php @@ -0,0 +1,12 @@ +=7.2" + }, + "require-dev": { + "automattic/jetpack-test-environment": "@dev", + "automattic/phpunit-select-config": "@dev", + "yoast/phpunit-polyfills": "^4.0.0" + }, + "type": "jetpack-library", + "extra": { + "autorelease": true, + "autotagger": true, + "mirror-repo": "Automattic/jetpack-podcast", + "branch-alias": { + "dev-trunk": "0.1.x-dev" + }, + "textdomain": "jetpack-podcast", + "version-constants": { + "::PACKAGE_VERSION": "src/class-podcast.php" + }, + "changelogger": { + "link-template": "https://github.com/Automattic/jetpack-podcast/compare/v${old}...v${new}" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "scripts": { + "phpunit": [ + "phpunit-select-config phpunit.#.xml.dist --colors=always" + ], + "test-php": [ + "@composer phpunit" + ] + }, + "license": [ + "GPL-2.0-or-later" + ], + "description": "Jetpack Podcast functionality (Simple and Atomic only).", + "transport-options": { + "relative": true + } + }, { "name": "automattic/jetpack-post-list", "version": "dev-trunk", @@ -5855,6 +5909,7 @@ "automattic/jetpack-newsletter": 20, "automattic/jetpack-paypal-payments": 20, "automattic/jetpack-plugins-installer": 20, + "automattic/jetpack-podcast": 20, "automattic/jetpack-post-list": 20, "automattic/jetpack-post-media": 20, "automattic/jetpack-publicize": 20, diff --git a/projects/plugins/jetpack/load-jetpack.php b/projects/plugins/jetpack/load-jetpack.php index 57cf7abfbebc..86d9617ef733 100644 --- a/projects/plugins/jetpack/load-jetpack.php +++ b/projects/plugins/jetpack/load-jetpack.php @@ -71,6 +71,10 @@ function jetpack_should_use_minified_assets() { \Automattic\Jetpack\Plugin\Jetpack_Script_Data::configure(); } +// Initialize the Podcast package (Simple + Atomic only, gated behind the +// `jetpack_podcast_untangle` filter — see Automattic\Jetpack\Podcast\Podcast). +\Automattic\Jetpack\Podcast\Podcast::init(); + // Play nice with https://wp-cli.org/. if ( defined( 'WP_CLI' ) && WP_CLI ) { require_once JETPACK__PLUGIN_DIR . 'class.jetpack-cli.php'; From 61dd1d3fcdb00a8a918674d436970321ce35afc9 Mon Sep 17 00:00:00 2001 From: Tony Arcangelini Date: Wed, 6 May 2026 10:14:08 +0200 Subject: [PATCH 2/4] Podcast: add wp-build dashboard scaffold + Jetpack > Podcast menu --- pnpm-lock.yaml | 59 ++++- .../changelog/add-podcast-wp-build-scaffold | 4 + .../packages/jetpack-mu-wpcom/composer.json | 1 + .../wpcom-admin-menu/wpcom-admin-menu.php | 25 +- projects/packages/podcast/.gitattributes | 5 + projects/packages/podcast/.gitignore | 1 + projects/packages/podcast/.phpcsignore | 1 + .../podcast/changelog/add-wp-build-scaffold | 4 + projects/packages/podcast/composer.json | 9 +- projects/packages/podcast/package.json | 38 ++- .../podcast/routes/dashboard/package.json | 14 ++ .../podcast/routes/dashboard/route.tsx | 1 + .../podcast/routes/dashboard/stage.tsx | 6 + .../packages/podcast/src/class-podcast.php | 8 +- .../packages/podcast/src/class-settings.php | 232 ++++++++++++++++++ .../changelog/add-podcast-wp-build-scaffold | 4 + projects/plugins/jetpack/composer.lock | 13 +- .../changelog/add-podcast-wp-build-scaffold | 4 + .../plugins/mu-wpcom-plugin/composer.lock | 68 ++++- .../changelog/add-podcast-wp-build-scaffold | 4 + projects/plugins/wpcomsh/composer.lock | 68 ++++- 21 files changed, 553 insertions(+), 16 deletions(-) create mode 100644 projects/packages/jetpack-mu-wpcom/changelog/add-podcast-wp-build-scaffold create mode 100644 projects/packages/podcast/.phpcsignore create mode 100644 projects/packages/podcast/changelog/add-wp-build-scaffold create mode 100644 projects/packages/podcast/routes/dashboard/package.json create mode 100644 projects/packages/podcast/routes/dashboard/route.tsx create mode 100644 projects/packages/podcast/routes/dashboard/stage.tsx create mode 100644 projects/packages/podcast/src/class-settings.php create mode 100644 projects/plugins/jetpack/changelog/add-podcast-wp-build-scaffold create mode 100644 projects/plugins/mu-wpcom-plugin/changelog/add-podcast-wp-build-scaffold create mode 100644 projects/plugins/wpcomsh/changelog/add-podcast-wp-build-scaffold diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d22383b48e89..ae9d3b412ea3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3554,7 +3554,42 @@ importers: specifier: 6.0.1 version: 6.0.1(webpack@5.105.2) - projects/packages/podcast: {} + projects/packages/podcast: + devDependencies: + '@automattic/jetpack-wp-build-polyfills': + specifier: workspace:* + version: link:../wp-build-polyfills + '@babel/core': + specifier: 7.29.0 + version: 7.29.0 + '@babel/runtime': + specifier: 7.29.2 + version: 7.29.2 + '@types/react': + specifier: 18.3.28 + version: 18.3.28 + '@wordpress/browserslist-config': + specifier: 6.44.0 + version: 6.44.0 + '@wordpress/build': + specifier: 0.13.0 + version: 0.13.0(@babel/core@7.29.0)(browserslist@4.28.2) + '@wordpress/element': + specifier: 6.44.0 + version: 6.44.0 + '@wordpress/i18n': + specifier: 6.17.0 + version: 6.17.0 + browserslist: + specifier: ^4.24.0 + version: 4.28.2 + optionalDependencies: + react: + specifier: 18.3.1 + version: 18.3.1 + react-dom: + specifier: 18.3.1 + version: 18.3.1(react@18.3.1) projects/packages/post-list: dependencies: @@ -24019,6 +24054,28 @@ snapshots: - browserslist - supports-color + '@wordpress/build@0.13.0(@babel/core@7.29.0)(browserslist@4.28.2)': + dependencies: + '@emotion/babel-plugin': 11.13.5 + autoprefixer: 10.4.27(postcss@8.5.10) + browserslist-to-esbuild: 2.1.1(browserslist@4.28.2) + change-case: 4.1.2 + chokidar: 4.0.3 + cssnano: 7.1.4(postcss@8.5.10) + esbuild: 0.27.4 + esbuild-plugin-babel: 0.2.3(@babel/core@7.29.0) + esbuild-sass-plugin: 3.3.1(esbuild@0.27.4)(sass-embedded@1.97.3) + fast-glob: 3.3.3 + moment-timezone: 0.5.48 + postcss: 8.5.10 + postcss-modules: 6.0.1(postcss@8.5.10) + rtlcss: 4.3.0 + sass-embedded: 1.97.3 + transitivePeerDependencies: + - '@babel/core' + - browserslist + - supports-color + '@wordpress/commands@1.44.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@wordpress/base-styles': 6.20.0 diff --git a/projects/packages/jetpack-mu-wpcom/changelog/add-podcast-wp-build-scaffold b/projects/packages/jetpack-mu-wpcom/changelog/add-podcast-wp-build-scaffold new file mode 100644 index 000000000000..ade1d101ec05 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/add-podcast-wp-build-scaffold @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +wpcom-admin-menu: when the `jetpack_podcast_untangle` filter is on, register the new in-admin "Jetpack > Podcast" page from the jetpack-podcast package instead of the legacy Calypso "Podcasting" link. Default behavior (filter off) is unchanged. diff --git a/projects/packages/jetpack-mu-wpcom/composer.json b/projects/packages/jetpack-mu-wpcom/composer.json index 832f38ddb5ca..6da6a23e1ef0 100644 --- a/projects/packages/jetpack-mu-wpcom/composer.json +++ b/projects/packages/jetpack-mu-wpcom/composer.json @@ -17,6 +17,7 @@ "automattic/jetpack-google-analytics": "@dev", "automattic/jetpack-masterbar": "@dev", "automattic/jetpack-newsletter": "@dev", + "automattic/jetpack-podcast": "@dev", "automattic/jetpack-redirect": "@dev", "automattic/jetpack-rtc": "@dev", "automattic/jetpack-stats-admin": "@dev", diff --git a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-menu/wpcom-admin-menu.php b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-menu/wpcom-admin-menu.php index f299e5fdf496..bc47a843d3e8 100644 --- a/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-menu/wpcom-admin-menu.php +++ b/projects/packages/jetpack-mu-wpcom/src/features/wpcom-admin-menu/wpcom-admin-menu.php @@ -401,15 +401,21 @@ function () { $subscribers_dashboard->add_wp_admin_submenu(); } - // Jetpack > Podcasting - add_submenu_page( - 'jetpack', - __( 'Podcasting', 'jetpack-mu-wpcom' ), - __( 'Podcasting', 'jetpack-mu-wpcom' ), - 'manage_options', - 'https://wordpress.com/settings/podcasting/' . $domain, - null // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal -- Core should ideally document null for no-callback arg. https://core.trac.wordpress.org/ticket/52539. - ); + // Jetpack > Podcast(ing). When the `jetpack_podcast_untangle` filter is on, + // the new in-admin page from the jetpack-podcast package owns the submenu. + // Otherwise we keep the legacy Calypso link unchanged. + if ( apply_filters( 'jetpack_podcast_untangle', false ) ) { + \Automattic\Jetpack\Podcast\Settings::add_wp_admin_submenu(); + } else { + add_submenu_page( + 'jetpack', + __( 'Podcasting', 'jetpack-mu-wpcom' ), + __( 'Podcasting', 'jetpack-mu-wpcom' ), + 'manage_options', + 'https://wordpress.com/settings/podcasting/' . $domain, + null // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal -- Core should ideally document null for no-callback arg. https://core.trac.wordpress.org/ticket/52539. + ); + } if ( $is_simple_site ) { // Jetpack > Newsletter. @@ -459,6 +465,7 @@ function () { 'search', 'subscribers', 'newsletter', + 'jetpack-podcast', 'podcasting', 'traffic', 'jetpack#/settings', diff --git a/projects/packages/podcast/.gitattributes b/projects/packages/podcast/.gitattributes index b3028ba45f00..1b9fa596c937 100644 --- a/projects/packages/podcast/.gitattributes +++ b/projects/packages/podcast/.gitattributes @@ -3,6 +3,10 @@ .github/ export-ignore package.json export-ignore +# Files to include in the mirror repo, but excluded via gitignore +# Remember to end all directories with `/**` to properly tag every file. +/build/** production-include + # Files to exclude from the mirror repo, but included in the monorepo. # Remember to end all directories with `/**` to properly tag every file. .gitignore production-exclude @@ -10,3 +14,4 @@ changelog/** production-exclude .phpcs.dir.xml production-exclude tests/** production-exclude .phpcsignore production-exclude +routes/**/*.tsx production-exclude diff --git a/projects/packages/podcast/.gitignore b/projects/packages/podcast/.gitignore index 8424444d7508..cf368200ac9d 100644 --- a/projects/packages/podcast/.gitignore +++ b/projects/packages/podcast/.gitignore @@ -1,4 +1,5 @@ vendor/ node_modules/ .cache/ +build/ composer.lock diff --git a/projects/packages/podcast/.phpcsignore b/projects/packages/podcast/.phpcsignore new file mode 100644 index 000000000000..567609b1234a --- /dev/null +++ b/projects/packages/podcast/.phpcsignore @@ -0,0 +1 @@ +build/ diff --git a/projects/packages/podcast/changelog/add-wp-build-scaffold b/projects/packages/podcast/changelog/add-wp-build-scaffold new file mode 100644 index 000000000000..f46a7cb45596 --- /dev/null +++ b/projects/packages/podcast/changelog/add-wp-build-scaffold @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Add an empty wp-build dashboard scaffold and the "Jetpack > Podcast" wp-admin entry, gated behind the `jetpack_podcast_untangle` filter. With the filter off (default), nothing changes; with it on, a placeholder Podcast page renders inside the standard wp-admin chrome. diff --git a/projects/packages/podcast/composer.json b/projects/packages/podcast/composer.json index 40f7df0dcd38..63b9939134ee 100644 --- a/projects/packages/podcast/composer.json +++ b/projects/packages/podcast/composer.json @@ -5,7 +5,8 @@ "license": "GPL-2.0-or-later", "require": { "php": ">=7.2", - "automattic/jetpack-status": "@dev" + "automattic/jetpack-status": "@dev", + "automattic/jetpack-wp-build-polyfills": "@dev" }, "require-dev": { "yoast/phpunit-polyfills": "^4.0.0", @@ -18,6 +19,12 @@ ] }, "scripts": { + "build-development": "pnpm run build", + "build-production": "pnpm run build-production", + "watch": [ + "Composer\\Config::disableProcessTimeout", + "pnpm run watch" + ], "phpunit": [ "phpunit-select-config phpunit.#.xml.dist --colors=always" ], diff --git a/projects/packages/podcast/package.json b/projects/packages/podcast/package.json index 51bdce16ea03..fd66f8c84edc 100644 --- a/projects/packages/podcast/package.json +++ b/projects/packages/podcast/package.json @@ -13,5 +13,41 @@ "directory": "projects/packages/podcast" }, "license": "GPL-2.0-or-later", - "author": "Automattic" + "author": "Automattic", + "type": "module", + "scripts": { + "build": "pnpm run clean && pnpm run build:wp-build && pnpm run build:boot-asset", + "build:boot-asset": "provide-boot-asset-file", + "build:wp-build": "wp-build", + "build-production": "pnpm run clean && pnpm run build:wp-build && pnpm run build:boot-asset", + "clean": "rm -rf build/", + "watch": "wp-build --watch" + }, + "browserslist": [ + "extends @wordpress/browserslist-config" + ], + "devDependencies": { + "@automattic/jetpack-wp-build-polyfills": "workspace:*", + "@babel/core": "7.29.0", + "@babel/runtime": "7.29.2", + "@types/react": "18.3.28", + "@wordpress/browserslist-config": "6.44.0", + "@wordpress/build": "0.13.0", + "@wordpress/element": "6.44.0", + "@wordpress/i18n": "6.17.0", + "browserslist": "^4.24.0" + }, + "optionalDependencies": { + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "wpPlugin": { + "name": "jetpack_podcast", + "scriptGlobal": "jetpackPodcast", + "packageNamespace": "jetpack-podcast", + "handlePrefix": "jetpack-podcast", + "pages": [ + "jetpack-podcast-dashboard" + ] + } } diff --git a/projects/packages/podcast/routes/dashboard/package.json b/projects/packages/podcast/routes/dashboard/package.json new file mode 100644 index 000000000000..7dab7404e236 --- /dev/null +++ b/projects/packages/podcast/routes/dashboard/package.json @@ -0,0 +1,14 @@ +{ + "name": "_@jetpack-podcast/dashboard-route", + "version": "1.0.0", + "private": true, + "dependencies": { + "@types/react": "18.3.28", + "@wordpress/element": "6.44.0", + "@wordpress/i18n": "6.17.0" + }, + "route": { + "path": "/", + "page": "jetpack-podcast-dashboard" + } +} diff --git a/projects/packages/podcast/routes/dashboard/route.tsx b/projects/packages/podcast/routes/dashboard/route.tsx new file mode 100644 index 000000000000..91499ada4b32 --- /dev/null +++ b/projects/packages/podcast/routes/dashboard/route.tsx @@ -0,0 +1 @@ +export const route = {}; diff --git a/projects/packages/podcast/routes/dashboard/stage.tsx b/projects/packages/podcast/routes/dashboard/stage.tsx new file mode 100644 index 000000000000..4cee550f9434 --- /dev/null +++ b/projects/packages/podcast/routes/dashboard/stage.tsx @@ -0,0 +1,6 @@ +const Stage = () => { + // "Podcast" is a product name, do not translate. + return

Podcast

; +}; + +export { Stage as stage }; diff --git a/projects/packages/podcast/src/class-podcast.php b/projects/packages/podcast/src/class-podcast.php index 528ef1dc3f4c..0d8c76110662 100644 --- a/projects/packages/podcast/src/class-podcast.php +++ b/projects/packages/podcast/src/class-podcast.php @@ -62,6 +62,12 @@ public static function init() { return; } - // Subsequent PRs in the untangle train wire the new package up here. + // Wire the wp-admin entry point. Settings::init() registers the + // "Jetpack > Podcast" submenu and stages the wp-build dashboard. + // On Simple sites, wpcom-admin-menu.php drives the menu directly via + // Settings::add_wp_admin_submenu() at priority 999999. + if ( is_admin() ) { + Settings::init(); + } } } diff --git a/projects/packages/podcast/src/class-settings.php b/projects/packages/podcast/src/class-settings.php new file mode 100644 index 000000000000..f030d9afd31d --- /dev/null +++ b/projects/packages/podcast/src/class-settings.php @@ -0,0 +1,232 @@ + Podcast" wp-admin screen on Simple and Atomic when the + * `jetpack_podcast_untangle` filter is enabled. Until that filter flips, every + * entry point here is a no-op so the legacy podcasting experience keeps + * running unchanged. + * + * On Simple sites, the canonical entry point is `wpcom-admin-menu.php` (in the + * `jetpack-mu-wpcom` package), which calls `add_wp_admin_submenu()` at + * priority 999999 — late enough that the Jetpack parent menu already exists. + * Atomic and standalone Jetpack run through the standard `admin_menu` hook. + */ +class Settings { + + const ADMIN_PAGE_SLUG = 'jetpack-podcast'; + const WP_BUILD_PAGE_SLUG = 'jetpack-podcast-dashboard'; + + /** + * Whether the class has already wired its admin hooks. + * + * @var bool + */ + private static $initialized = false; + + /** + * Wire the admin hooks. Called from `Podcast::init()` once the + * `jetpack_podcast_untangle` filter and host gates have been satisfied. + */ + public static function init() { + if ( self::$initialized ) { + return; + } + self::$initialized = true; + + // Defer wp-build loading to admin_menu (priority 1) so the + // `jetpack_podcast_untangle` filter has been applied before we read it + // and the wp-build render function is in place before any menu callback + // runs (priority 999 on standalone Jetpack, 999999 on Simple via + // wpcom-admin-menu.php → add_wp_admin_submenu). + add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 ); + + // On Simple sites, the Jetpack parent menu doesn't exist until + // wpcom-admin-menu.php runs at priority 999999, so we let it call + // `add_wp_admin_submenu()` directly. On Atomic + standalone Jetpack we + // register at priority 999, before `Admin_Menu::admin_menu_hook_callback` + // processes queued items at priority 1000. + $host = new Host(); + if ( $host->is_wpcom_simple() ) { + return; + } + + add_action( 'admin_menu', array( __CLASS__, 'add_wp_admin_menu' ), 999 ); + } + + /** + * Register the Podcast submenu under Jetpack on Atomic + standalone Jetpack. + */ + public static function add_wp_admin_menu() { + if ( ! self::is_enabled() ) { + return; + } + + $page_suffix = add_submenu_page( + 'jetpack', + /** "Podcast" is a product name, do not translate. */ + 'Podcast', + 'Podcast', + 'manage_options', + self::ADMIN_PAGE_SLUG, + self::get_render_callback() + ); + + if ( $page_suffix ) { + add_action( 'load-' . $page_suffix, array( __CLASS__, 'admin_init' ) ); + } + } + + /** + * Register the Podcast submenu under Jetpack on Simple sites. + * + * Called from `wpcom-admin-menu.php` at priority 999999 once the Jetpack + * parent menu exists. Bails when the untangle filter is off so the legacy + * "Podcasting" Calypso link in `wpcom-admin-menu.php` keeps rendering. + */ + public static function add_wp_admin_submenu() { + if ( ! self::is_enabled() ) { + return; + } + + $page_suffix = add_submenu_page( + 'jetpack', + /** "Podcast" is a product name, do not translate. */ + 'Podcast', + 'Podcast', + 'manage_options', + self::ADMIN_PAGE_SLUG, + self::get_render_callback() + ); + + if ( $page_suffix ) { + add_action( 'load-' . $page_suffix, array( __CLASS__, 'admin_init' ) ); + } + } + + /** + * Wire admin-init actions once we know the Podcast page is loading. + */ + public static function admin_init() { + // Subsequent PRs in the untangle train layer script-data + Tracks + // here. The wp-build dashboard manages its own enqueue pipeline. + } + + /** + * Load the wp-build entry on Podcast admin requests when the untangle + * filter is on. Hooked at `admin_menu` priority 1 so the render function + * is defined before `add_wp_admin_menu` / `add_wp_admin_submenu` register + * the menu callback. + */ + public static function maybe_load_wp_build() { + if ( ! self::is_enabled() || ! self::is_podcast_admin_request() ) { + return; + } + + self::load_wp_build(); + add_action( 'current_screen', array( __CLASS__, 'alias_screen_id_for_wp_build' ) ); + } + + /** + * Resolve the menu render callback, preferring the wp-build–generated + * function when the build artifact is in place. + * + * @return callable + */ + private static function get_render_callback() { + $wp_build_render = 'jetpack_podcast_jetpack_podcast_dashboard_wp_admin_render_page'; + + if ( function_exists( $wp_build_render ) ) { + return $wp_build_render; + } + + return array( __CLASS__, 'render' ); + } + + /** + * Default render callback. Used as a fallback when the wp-build artifact is + * missing — for example, on a fresh checkout before `pnpm build` has run. + */ + public static function render() { + ?> +
+

Podcast

+
+ -wp-admin` enqueue + * callback fires on our `?page=jetpack-podcast` URL. Wp-build expects the + * screen ID to match the wp-build page slug (`jetpack-podcast-dashboard`), + * but we keep the user-facing slug as `jetpack-podcast`. + * + * Hooked only when the untangle filter is on AND we're on the Podcast + * page, so this never affects any other request. + * + * @param \WP_Screen|null $screen The current screen object (passed by WP). + */ + public static function alias_screen_id_for_wp_build( $screen ) { + if ( ! is_object( $screen ) ) { + return; + } + + $screen->id = self::WP_BUILD_PAGE_SLUG; + } + + /** + * Whether the Podcast untangle is enabled. Mirrors the gate in + * `Podcast::init()` so callbacks invoked outside that flow (e.g. + * `add_wp_admin_submenu()` from wpcom-admin-menu.php) still bail. + */ + private static function is_enabled() { + /** This filter is documented in src/class-podcast.php. */ + return (bool) apply_filters( 'jetpack_podcast_untangle', false ); + } + + /** + * Whether the current request targets the Podcast admin page. + * + * `$_GET['page']` is populated by `wp-admin/admin.php` before any of our + * hooks fire, so this is reliable from `admin_menu` priority 1 onwards. + */ + private static function is_podcast_admin_request() { + if ( ! is_admin() || ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return false; + } + + return sanitize_text_field( wp_unslash( $_GET['page'] ) ) === self::ADMIN_PAGE_SLUG; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + } +} diff --git a/projects/plugins/jetpack/changelog/add-podcast-wp-build-scaffold b/projects/plugins/jetpack/changelog/add-podcast-wp-build-scaffold new file mode 100644 index 000000000000..27f0b79515bc --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-podcast-wp-build-scaffold @@ -0,0 +1,4 @@ +Significance: patch +Type: other + +Pull in the wp-build dashboard scaffold from the jetpack-podcast package. Has no runtime effect unless the `jetpack_podcast_untangle` filter is enabled. diff --git a/projects/plugins/jetpack/composer.lock b/projects/plugins/jetpack/composer.lock index 83e12b050ec3..1a804f58ed4c 100644 --- a/projects/plugins/jetpack/composer.lock +++ b/projects/plugins/jetpack/composer.lock @@ -2574,10 +2574,11 @@ "dist": { "type": "path", "url": "../../packages/podcast", - "reference": "acaddca67e3e73d705207800e2ca4172ad7d88a9" + "reference": "c7060e529dfe4b9e4835d392d4b140eeac7ac5ff" }, "require": { "automattic/jetpack-status": "@dev", + "automattic/jetpack-wp-build-polyfills": "@dev", "php": ">=7.2" }, "require-dev": { @@ -2607,6 +2608,16 @@ ] }, "scripts": { + "build-development": [ + "pnpm run build" + ], + "build-production": [ + "pnpm run build-production" + ], + "watch": [ + "Composer\\Config::disableProcessTimeout", + "pnpm run watch" + ], "phpunit": [ "phpunit-select-config phpunit.#.xml.dist --colors=always" ], diff --git a/projects/plugins/mu-wpcom-plugin/changelog/add-podcast-wp-build-scaffold b/projects/plugins/mu-wpcom-plugin/changelog/add-podcast-wp-build-scaffold new file mode 100644 index 000000000000..fd949094eb4b --- /dev/null +++ b/projects/plugins/mu-wpcom-plugin/changelog/add-podcast-wp-build-scaffold @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Updated composer.lock to pull in the new jetpack-podcast wp-build scaffold transitively (via jetpack-mu-wpcom). diff --git a/projects/plugins/mu-wpcom-plugin/composer.lock b/projects/plugins/mu-wpcom-plugin/composer.lock index d9a5f0cd723a..704793485055 100644 --- a/projects/plugins/mu-wpcom-plugin/composer.lock +++ b/projects/plugins/mu-wpcom-plugin/composer.lock @@ -1227,7 +1227,7 @@ "dist": { "type": "path", "url": "../../packages/jetpack-mu-wpcom", - "reference": "8ccf42e3bb801d5edfc343c3648afe1976f7d6fc" + "reference": "5ac3b4268d352de7e901a9ce29ebab28ce26b950" }, "require": { "automattic/block-delimiter": "@dev", @@ -1240,6 +1240,7 @@ "automattic/jetpack-google-analytics": "@dev", "automattic/jetpack-masterbar": "@dev", "automattic/jetpack-newsletter": "@dev", + "automattic/jetpack-podcast": "@dev", "automattic/jetpack-redirect": "@dev", "automattic/jetpack-rtc": "@dev", "automattic/jetpack-stats-admin": "@dev", @@ -1504,6 +1505,71 @@ "relative": true } }, + { + "name": "automattic/jetpack-podcast", + "version": "dev-trunk", + "dist": { + "type": "path", + "url": "../../packages/podcast", + "reference": "c7060e529dfe4b9e4835d392d4b140eeac7ac5ff" + }, + "require": { + "automattic/jetpack-status": "@dev", + "automattic/jetpack-wp-build-polyfills": "@dev", + "php": ">=7.2" + }, + "require-dev": { + "automattic/jetpack-test-environment": "@dev", + "automattic/phpunit-select-config": "@dev", + "yoast/phpunit-polyfills": "^4.0.0" + }, + "type": "jetpack-library", + "extra": { + "autorelease": true, + "autotagger": true, + "mirror-repo": "Automattic/jetpack-podcast", + "branch-alias": { + "dev-trunk": "0.1.x-dev" + }, + "textdomain": "jetpack-podcast", + "version-constants": { + "::PACKAGE_VERSION": "src/class-podcast.php" + }, + "changelogger": { + "link-template": "https://github.com/Automattic/jetpack-podcast/compare/v${old}...v${new}" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "scripts": { + "build-development": [ + "pnpm run build" + ], + "build-production": [ + "pnpm run build-production" + ], + "watch": [ + "Composer\\Config::disableProcessTimeout", + "pnpm run watch" + ], + "phpunit": [ + "phpunit-select-config phpunit.#.xml.dist --colors=always" + ], + "test-php": [ + "@composer phpunit" + ] + }, + "license": [ + "GPL-2.0-or-later" + ], + "description": "Jetpack Podcast functionality (Simple and Atomic only).", + "transport-options": { + "relative": true + } + }, { "name": "automattic/jetpack-post-media", "version": "dev-trunk", diff --git a/projects/plugins/wpcomsh/changelog/add-podcast-wp-build-scaffold b/projects/plugins/wpcomsh/changelog/add-podcast-wp-build-scaffold new file mode 100644 index 000000000000..fd949094eb4b --- /dev/null +++ b/projects/plugins/wpcomsh/changelog/add-podcast-wp-build-scaffold @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Updated composer.lock to pull in the new jetpack-podcast wp-build scaffold transitively (via jetpack-mu-wpcom). diff --git a/projects/plugins/wpcomsh/composer.lock b/projects/plugins/wpcomsh/composer.lock index 744f7df88324..2afc54e451af 100644 --- a/projects/plugins/wpcomsh/composer.lock +++ b/projects/plugins/wpcomsh/composer.lock @@ -1434,7 +1434,7 @@ "dist": { "type": "path", "url": "../../packages/jetpack-mu-wpcom", - "reference": "8ccf42e3bb801d5edfc343c3648afe1976f7d6fc" + "reference": "5ac3b4268d352de7e901a9ce29ebab28ce26b950" }, "require": { "automattic/block-delimiter": "@dev", @@ -1447,6 +1447,7 @@ "automattic/jetpack-google-analytics": "@dev", "automattic/jetpack-masterbar": "@dev", "automattic/jetpack-newsletter": "@dev", + "automattic/jetpack-podcast": "@dev", "automattic/jetpack-redirect": "@dev", "automattic/jetpack-rtc": "@dev", "automattic/jetpack-stats-admin": "@dev", @@ -1711,6 +1712,71 @@ "relative": true } }, + { + "name": "automattic/jetpack-podcast", + "version": "dev-trunk", + "dist": { + "type": "path", + "url": "../../packages/podcast", + "reference": "c7060e529dfe4b9e4835d392d4b140eeac7ac5ff" + }, + "require": { + "automattic/jetpack-status": "@dev", + "automattic/jetpack-wp-build-polyfills": "@dev", + "php": ">=7.2" + }, + "require-dev": { + "automattic/jetpack-test-environment": "@dev", + "automattic/phpunit-select-config": "@dev", + "yoast/phpunit-polyfills": "^4.0.0" + }, + "type": "jetpack-library", + "extra": { + "autorelease": true, + "autotagger": true, + "mirror-repo": "Automattic/jetpack-podcast", + "branch-alias": { + "dev-trunk": "0.1.x-dev" + }, + "textdomain": "jetpack-podcast", + "version-constants": { + "::PACKAGE_VERSION": "src/class-podcast.php" + }, + "changelogger": { + "link-template": "https://github.com/Automattic/jetpack-podcast/compare/v${old}...v${new}" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "scripts": { + "build-development": [ + "pnpm run build" + ], + "build-production": [ + "pnpm run build-production" + ], + "watch": [ + "Composer\\Config::disableProcessTimeout", + "pnpm run watch" + ], + "phpunit": [ + "phpunit-select-config phpunit.#.xml.dist --colors=always" + ], + "test-php": [ + "@composer phpunit" + ] + }, + "license": [ + "GPL-2.0-or-later" + ], + "description": "Jetpack Podcast functionality (Simple and Atomic only).", + "transport-options": { + "relative": true + } + }, { "name": "automattic/jetpack-post-list", "version": "dev-trunk", From 99a0f0dab93e6586e7ab7388ffe4b4b2c2ba6ac8 Mon Sep 17 00:00:00 2001 From: Tony Arcangelini Date: Wed, 6 May 2026 10:18:53 +0200 Subject: [PATCH 3/4] Podcast: scaffold AdminPage chrome + empty tabs in dashboard route --- pnpm-lock.yaml | 56 ++++++++++++++--- .../podcast/changelog/add-admin-tabs-scaffold | 4 ++ projects/packages/podcast/package.json | 8 ++- .../podcast/routes/dashboard/package.json | 4 +- .../podcast/routes/dashboard/stage.tsx | 61 ++++++++++++++++++- 5 files changed, 118 insertions(+), 15 deletions(-) create mode 100644 projects/packages/podcast/changelog/add-admin-tabs-scaffold diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae9d3b412ea3..a40e8380f81f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3555,6 +3555,19 @@ importers: version: 6.0.1(webpack@5.105.2) projects/packages/podcast: + dependencies: + '@wordpress/components': + specifier: 32.6.0 + version: 32.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/element': + specifier: 6.44.0 + version: 6.44.0 + '@wordpress/i18n': + specifier: 6.17.0 + version: 6.17.0 + '@wordpress/ui': + specifier: 0.11.0 + version: 0.11.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) devDependencies: '@automattic/jetpack-wp-build-polyfills': specifier: workspace:* @@ -3574,12 +3587,6 @@ importers: '@wordpress/build': specifier: 0.13.0 version: 0.13.0(@babel/core@7.29.0)(browserslist@4.28.2) - '@wordpress/element': - specifier: 6.44.0 - version: 6.44.0 - '@wordpress/i18n': - specifier: 6.17.0 - version: 6.17.0 browserslist: specifier: ^4.24.0 version: 4.28.2 @@ -19393,6 +19400,7 @@ snapshots: use-sync-external-store: 1.6.0(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 + optional: true '@base-ui/react@1.4.0(@date-fns/tz@1.4.1)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: @@ -19405,6 +19413,21 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) use-sync-external-store: 1.6.0(react@18.3.1) + optional: true + + '@base-ui/react@1.4.1(@date-fns/tz@1.4.1)(@types/react@18.3.28)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.2 + '@base-ui/utils': 0.2.8(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@floating-ui/react-dom': 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@floating-ui/utils': 0.2.11 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@date-fns/tz': 1.4.1 + '@types/react': 18.3.28 + date-fns: 4.1.0 '@base-ui/react@1.4.1(@date-fns/tz@1.4.1)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: @@ -19429,6 +19452,7 @@ snapshots: use-sync-external-store: 1.6.0(react@18.3.1) optionalDependencies: '@types/react': 18.3.28 + optional: true '@base-ui/utils@0.2.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: @@ -19438,6 +19462,18 @@ snapshots: react-dom: 18.3.1(react@18.3.1) reselect: 5.1.1 use-sync-external-store: 1.6.0(react@18.3.1) + optional: true + + '@base-ui/utils@0.2.8(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.29.2 + '@floating-ui/utils': 0.2.11 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + reselect: 5.1.1 + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 '@base-ui/utils@0.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: @@ -24161,7 +24197,7 @@ snapshots: '@wordpress/base-styles': 6.20.0 '@wordpress/compose': 7.44.0(react@18.3.1) '@wordpress/date': 5.44.0 - '@wordpress/deprecated': 4.44.0 + '@wordpress/deprecated': 4.45.0 '@wordpress/dom': 4.44.0 '@wordpress/element': 6.44.0 '@wordpress/escape-html': 3.44.0 @@ -24169,7 +24205,7 @@ snapshots: '@wordpress/html-entities': 4.44.0 '@wordpress/i18n': 6.17.0 '@wordpress/icons': 12.2.0(react@18.3.1) - '@wordpress/is-shallow-equal': 5.44.0 + '@wordpress/is-shallow-equal': 5.45.0 '@wordpress/keycodes': 4.44.0 '@wordpress/primitives': 4.44.0(react@18.3.1) '@wordpress/private-apis': 1.44.0 @@ -25864,7 +25900,7 @@ snapshots: '@wordpress/ui@0.11.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@base-ui/react': 1.4.0(@date-fns/tz@1.4.1)(@types/react@18.3.28)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@base-ui/react': 1.4.1(@date-fns/tz@1.4.1)(@types/react@18.3.28)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@date-fns/tz': 1.4.1 '@wordpress/a11y': 4.44.0 '@wordpress/compose': 7.44.0(react@18.3.1) @@ -25886,7 +25922,7 @@ snapshots: '@wordpress/ui@0.11.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@base-ui/react': 1.4.0(@date-fns/tz@1.4.1)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@base-ui/react': 1.4.1(@date-fns/tz@1.4.1)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@date-fns/tz': 1.4.1 '@wordpress/a11y': 4.44.0 '@wordpress/compose': 7.44.0(react@18.3.1) diff --git a/projects/packages/podcast/changelog/add-admin-tabs-scaffold b/projects/packages/podcast/changelog/add-admin-tabs-scaffold new file mode 100644 index 000000000000..de1a7d28cc7f --- /dev/null +++ b/projects/packages/podcast/changelog/add-admin-tabs-scaffold @@ -0,0 +1,4 @@ +Significance: patch +Type: changed + +Replace the wp-build placeholder with page chrome (title, tagline) plus tab navigation (Welcome, Settings, Episodes, Distribution). Each tab panel is still empty — PR 4 in the untangle train fills them in. diff --git a/projects/packages/podcast/package.json b/projects/packages/podcast/package.json index fd66f8c84edc..f3eeedc12add 100644 --- a/projects/packages/podcast/package.json +++ b/projects/packages/podcast/package.json @@ -26,6 +26,12 @@ "browserslist": [ "extends @wordpress/browserslist-config" ], + "dependencies": { + "@wordpress/components": "32.6.0", + "@wordpress/element": "6.44.0", + "@wordpress/i18n": "6.17.0", + "@wordpress/ui": "0.11.0" + }, "devDependencies": { "@automattic/jetpack-wp-build-polyfills": "workspace:*", "@babel/core": "7.29.0", @@ -33,8 +39,6 @@ "@types/react": "18.3.28", "@wordpress/browserslist-config": "6.44.0", "@wordpress/build": "0.13.0", - "@wordpress/element": "6.44.0", - "@wordpress/i18n": "6.17.0", "browserslist": "^4.24.0" }, "optionalDependencies": { diff --git a/projects/packages/podcast/routes/dashboard/package.json b/projects/packages/podcast/routes/dashboard/package.json index 7dab7404e236..f130ca6154aa 100644 --- a/projects/packages/podcast/routes/dashboard/package.json +++ b/projects/packages/podcast/routes/dashboard/package.json @@ -4,8 +4,10 @@ "private": true, "dependencies": { "@types/react": "18.3.28", + "@wordpress/components": "32.6.0", "@wordpress/element": "6.44.0", - "@wordpress/i18n": "6.17.0" + "@wordpress/i18n": "6.17.0", + "@wordpress/ui": "0.11.0" }, "route": { "path": "/", diff --git a/projects/packages/podcast/routes/dashboard/stage.tsx b/projects/packages/podcast/routes/dashboard/stage.tsx index 4cee550f9434..dbda37f6ce3d 100644 --- a/projects/packages/podcast/routes/dashboard/stage.tsx +++ b/projects/packages/podcast/routes/dashboard/stage.tsx @@ -1,6 +1,63 @@ +/** + * Podcast dashboard stage: page chrome + tab navigation. + * + * Placeholder scaffolding only — each tab panel renders a stub. PR 4 in the + * untangle train wires the full AdminPage + jetpack-components integration + * along with the real tab contents. + */ + +import { useState, useCallback } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Tabs } from '@wordpress/ui'; + +const TAB_VALUES = [ 'welcome', 'settings', 'episodes', 'distribution' ] as const; +type TabName = ( typeof TAB_VALUES )[ number ]; + +const isValidTab = ( value: string | null ): value is TabName => + !! value && ( TAB_VALUES as readonly string[] ).includes( value ); + const Stage = () => { - // "Podcast" is a product name, do not translate. - return

Podcast

; + const [ activeTab, setActiveTab ] = useState< TabName >( 'welcome' ); + + const handleTabChange = useCallback( ( value: string | null ) => { + if ( isValidTab( value ) ) { + setActiveTab( value ); + } + }, [] ); + + return ( +
+ { /* "Podcast" is a product name, do not translate. */ } +

Podcast

+

+ { __( + 'Publish a podcast and reach your fans, anywhere they listen.', + 'jetpack-podcast' + ) } +

+ + + + { __( 'Welcome', 'jetpack-podcast' ) } + { __( 'Settings', 'jetpack-podcast' ) } + { __( 'Episodes', 'jetpack-podcast' ) } + { __( 'Distribution', 'jetpack-podcast' ) } + + +

{ __( 'Welcome — placeholder.', 'jetpack-podcast' ) }

+
+ +

{ __( 'Settings — placeholder.', 'jetpack-podcast' ) }

+
+ +

{ __( 'Episodes — placeholder.', 'jetpack-podcast' ) }

+
+ +

{ __( 'Distribution — placeholder.', 'jetpack-podcast' ) }

+
+
+
+ ); }; export { Stage as stage }; From 1922e3540da8b3d0b70adc8de21eec8e85e634b6 Mon Sep 17 00:00:00 2001 From: Tony Arcangelini Date: Wed, 6 May 2026 10:26:05 +0200 Subject: [PATCH 4/4] Podcast: move SPA, REST settings, and feed customization into the package --- pnpm-lock.yaml | 209 ++++--- projects/packages/podcast/.gitattributes | 6 +- projects/packages/podcast/.phpcsignore | 1 - projects/packages/podcast/babel.config.js | 10 + .../podcast/changelog/add-business-code | 4 + projects/packages/podcast/composer.json | 8 +- projects/packages/podcast/package.json | 50 +- .../podcast/routes/dashboard/package.json | 16 - .../podcast/routes/dashboard/route.tsx | 1 - .../podcast/routes/dashboard/stage.tsx | 63 --- .../packages/podcast/src/class-podcast.php | 135 ++++- .../packages/podcast/src/class-settings.php | 240 +++------ .../packages/podcast/src/dashboard/api.ts | 323 +++++++++++ .../packages/podcast/src/dashboard/app.tsx | 190 +++++++ .../components/cover-image-control.tsx | 119 ++++ .../src/dashboard/components/submit-modal.tsx | 363 +++++++++++++ .../dashboard/hooks/use-categories-query.ts | 39 ++ .../hooks/use-episode-stats-query.ts | 30 ++ .../src/dashboard/hooks/use-episodes-query.ts | 23 + .../dashboard/hooks/use-podcast-settings.ts | 79 +++ .../packages/podcast/src/dashboard/index.tsx | 12 + .../src/dashboard/podcast-apps/amazon.tsx | 47 ++ .../src/dashboard/podcast-apps/apple.tsx | 63 +++ .../src/dashboard/podcast-apps/index.ts | 28 + .../dashboard/podcast-apps/pocketcasts.tsx | 48 ++ .../dashboard/podcast-apps/podcastindex.tsx | 31 ++ .../src/dashboard/podcast-apps/spotify.tsx | 35 ++ .../src/dashboard/podcast-apps/types.ts | 44 ++ .../src/dashboard/podcast-apps/youtube.tsx | 31 ++ .../podcast/src/dashboard/script-data.ts | 38 ++ .../packages/podcast/src/dashboard/style.scss | 298 ++++++++++ .../src/dashboard/tabs/distribution.tsx | 177 ++++++ .../podcast/src/dashboard/tabs/episodes.tsx | 275 ++++++++++ .../podcast/src/dashboard/tabs/settings.tsx | 509 ++++++++++++++++++ .../podcast/src/dashboard/tabs/welcome.tsx | 143 +++++ .../packages/podcast/src/dashboard/topics.ts | 357 ++++++++++++ .../packages/podcast/src/dashboard/types.ts | 87 +++ .../podcast/src/feed/class-app-detection.php | 77 +++ .../podcast/src/feed/class-customize-feed.php | 268 +++++++++ .../podcast/src/feed/class-feed-detection.php | 90 ++++ .../podcast/src/rest/class-settings-rest.php | 403 ++++++++++++++ .../podcast/tests/php/Podcast_Test.php | 43 +- projects/packages/podcast/tsconfig.json | 4 + projects/packages/podcast/webpack.config.js | 64 +++ .../changelog/add-podcast-business-code | 4 + projects/plugins/jetpack/composer.lock | 8 +- .../changelog/add-podcast-business-code | 4 + .../plugins/mu-wpcom-plugin/composer.lock | 8 +- .../changelog/add-podcast-business-code | 4 + projects/plugins/wpcomsh/composer.lock | 8 +- 50 files changed, 4698 insertions(+), 419 deletions(-) delete mode 100644 projects/packages/podcast/.phpcsignore create mode 100644 projects/packages/podcast/babel.config.js create mode 100644 projects/packages/podcast/changelog/add-business-code delete mode 100644 projects/packages/podcast/routes/dashboard/package.json delete mode 100644 projects/packages/podcast/routes/dashboard/route.tsx delete mode 100644 projects/packages/podcast/routes/dashboard/stage.tsx create mode 100644 projects/packages/podcast/src/dashboard/api.ts create mode 100644 projects/packages/podcast/src/dashboard/app.tsx create mode 100644 projects/packages/podcast/src/dashboard/components/cover-image-control.tsx create mode 100644 projects/packages/podcast/src/dashboard/components/submit-modal.tsx create mode 100644 projects/packages/podcast/src/dashboard/hooks/use-categories-query.ts create mode 100644 projects/packages/podcast/src/dashboard/hooks/use-episode-stats-query.ts create mode 100644 projects/packages/podcast/src/dashboard/hooks/use-episodes-query.ts create mode 100644 projects/packages/podcast/src/dashboard/hooks/use-podcast-settings.ts create mode 100644 projects/packages/podcast/src/dashboard/index.tsx create mode 100644 projects/packages/podcast/src/dashboard/podcast-apps/amazon.tsx create mode 100644 projects/packages/podcast/src/dashboard/podcast-apps/apple.tsx create mode 100644 projects/packages/podcast/src/dashboard/podcast-apps/index.ts create mode 100644 projects/packages/podcast/src/dashboard/podcast-apps/pocketcasts.tsx create mode 100644 projects/packages/podcast/src/dashboard/podcast-apps/podcastindex.tsx create mode 100644 projects/packages/podcast/src/dashboard/podcast-apps/spotify.tsx create mode 100644 projects/packages/podcast/src/dashboard/podcast-apps/types.ts create mode 100644 projects/packages/podcast/src/dashboard/podcast-apps/youtube.tsx create mode 100644 projects/packages/podcast/src/dashboard/script-data.ts create mode 100644 projects/packages/podcast/src/dashboard/style.scss create mode 100644 projects/packages/podcast/src/dashboard/tabs/distribution.tsx create mode 100644 projects/packages/podcast/src/dashboard/tabs/episodes.tsx create mode 100644 projects/packages/podcast/src/dashboard/tabs/settings.tsx create mode 100644 projects/packages/podcast/src/dashboard/tabs/welcome.tsx create mode 100644 projects/packages/podcast/src/dashboard/topics.ts create mode 100644 projects/packages/podcast/src/dashboard/types.ts create mode 100644 projects/packages/podcast/src/feed/class-app-detection.php create mode 100644 projects/packages/podcast/src/feed/class-customize-feed.php create mode 100644 projects/packages/podcast/src/feed/class-feed-detection.php create mode 100644 projects/packages/podcast/src/rest/class-settings-rest.php create mode 100644 projects/packages/podcast/tsconfig.json create mode 100644 projects/packages/podcast/webpack.config.js create mode 100644 projects/plugins/jetpack/changelog/add-podcast-business-code create mode 100644 projects/plugins/mu-wpcom-plugin/changelog/add-podcast-business-code create mode 100644 projects/plugins/wpcomsh/changelog/add-podcast-business-code diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a40e8380f81f..25d25f2e92e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3556,22 +3556,61 @@ importers: projects/packages/podcast: dependencies: + '@automattic/jetpack-components': + specifier: workspace:* + version: link:../../js-packages/components + '@automattic/jetpack-script-data': + specifier: workspace:* + version: link:../../js-packages/script-data + '@tanstack/react-query': + specifier: 5.96.1 + version: 5.96.1(react@18.3.1) + '@wordpress/api-fetch': + specifier: 7.44.0 + version: 7.44.0 '@wordpress/components': specifier: 32.6.0 version: 32.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/compose': + specifier: 7.44.0 + version: 7.44.0(react@18.3.1) + '@wordpress/data': + specifier: 10.44.0 + version: 10.44.0(react@18.3.1) + '@wordpress/dataviews': + specifier: 14.1.0 + version: 14.1.0(@types/react@18.3.28)(react@18.3.1) '@wordpress/element': specifier: 6.44.0 version: 6.44.0 + '@wordpress/html-entities': + specifier: 4.44.0 + version: 4.44.0 '@wordpress/i18n': specifier: 6.17.0 version: 6.17.0 + '@wordpress/icons': + specifier: 12.2.0 + version: 12.2.0(react@18.3.1) + '@wordpress/media-utils': + specifier: 5.44.0 + version: 5.44.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/notices': + specifier: 5.44.0 + version: 5.44.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@wordpress/ui': specifier: 0.11.0 version: 0.11.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/url': + specifier: 4.44.0 + version: 4.44.0 devDependencies: - '@automattic/jetpack-wp-build-polyfills': + '@automattic/babel-plugin-replace-textdomain': specifier: workspace:* - version: link:../wp-build-polyfills + version: link:../../js-packages/babel-plugin-replace-textdomain + '@automattic/jetpack-webpack-config': + specifier: workspace:* + version: link:../../js-packages/webpack-config '@babel/core': specifier: 7.29.0 version: 7.29.0 @@ -3581,15 +3620,33 @@ importers: '@types/react': specifier: 18.3.28 version: 18.3.28 + '@types/react-dom': + specifier: 18.3.7 + version: 18.3.7(@types/react@18.3.28) + '@typescript/native-preview': + specifier: 7.0.0-dev.20260225.1 + version: 7.0.0-dev.20260225.1 '@wordpress/browserslist-config': specifier: 6.44.0 version: 6.44.0 - '@wordpress/build': - specifier: 0.13.0 - version: 0.13.0(@babel/core@7.29.0)(browserslist@4.28.2) - browserslist: - specifier: ^4.24.0 - version: 4.28.2 + jest: + specifier: 30.3.0 + version: 30.3.0 + postcss: + specifier: 8.5.10 + version: 8.5.10 + sass-embedded: + specifier: 1.97.3 + version: 1.97.3 + sass-loader: + specifier: 16.0.5 + version: 16.0.5(sass-embedded@1.97.3)(webpack@5.105.2) + webpack: + specifier: 5.105.2 + version: 5.105.2(webpack-cli@6.0.1) + webpack-cli: + specifier: 6.0.1 + version: 6.0.1(webpack@5.105.2) optionalDependencies: react: specifier: 18.3.1 @@ -7247,19 +7304,6 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@base-ui/react@1.4.0': - resolution: {integrity: sha512-QcqdVbr/+ba2/RAKJIV1PV6S02Q5+r6a4Eym8ndBw+ZbBILkkmQAyRxXCg/pArrHnkrGeU8goe26aw0h6eE8pg==} - engines: {node: '>=14.0.0'} - peerDependencies: - '@date-fns/tz': ^1.2.0 - '@types/react': ^17 || ^18 || ^19 - date-fns: ^4.0.0 - react: ^17 || ^18 || ^19 - react-dom: ^17 || ^18 || ^19 - peerDependenciesMeta: - '@types/react': - optional: true - '@base-ui/react@1.4.1': resolution: {integrity: sha512-Ab5/LIhcmL8BQcsBUYiOfkSDRdLpvgUBzMK30cu684JPcLclYlztharvCZyNNgzJtbAiREzI9q0pI5erHCMgCw==} engines: {node: '>=14.0.0'} @@ -7277,16 +7321,6 @@ packages: date-fns: optional: true - '@base-ui/utils@0.2.7': - resolution: {integrity: sha512-nXYKhiL/0JafyJE8PfcflipGftOftlIwKd72rU15iZ1M5yqgg5J9P8NHU71GReDuXco5MJA/eVQqUT5WRqX9sA==} - peerDependencies: - '@types/react': ^17 || ^18 || ^19 - react: ^17 || ^18 || ^19 - react-dom: ^17 || ^18 || ^19 - peerDependenciesMeta: - '@types/react': - optional: true - '@base-ui/utils@0.2.8': resolution: {integrity: sha512-jvOi+c+ftGlGotNcKnzPVg2IhCaDTB6/6R3JeqdjdXktuAJi3wKH9T7+svuaKh1mmfVU11UWzUZVH74JDfi/wQ==} peerDependencies: @@ -9685,6 +9719,9 @@ packages: '@tanstack/query-core@5.90.8': resolution: {integrity: sha512-4E0RP/0GJCxSNiRF2kAqE/LQkTJVlL/QNU7gIJSptaseV9HP6kOuA+N11y4bZKZxa3QopK3ZuewwutHx6DqDXQ==} + '@tanstack/query-core@5.96.1': + resolution: {integrity: sha512-u1yBgtavSy+N8wgtW3PiER6UpxcplMje65yXnnVgiHTqiMwLlxiw4WvQDrXyn+UD6lnn8kHaxmerJUzQcV/MMg==} + '@tanstack/query-devtools@5.90.1': resolution: {integrity: sha512-GtINOPjPUH0OegJExZ70UahT9ykmAhmtNVcmtdnOZbxLwT7R5OmRztR5Ahe3/Cu7LArEmR6/588tAycuaWb1xQ==} @@ -9699,6 +9736,11 @@ packages: peerDependencies: react: ^18 || ^19 + '@tanstack/react-query@5.96.1': + resolution: {integrity: sha512-2X7KYK5KKWUKGeWCVcqxXAkYefJtrKB7tSKWgeG++b0H6BRHxQaLSSi8AxcgjmUnnosHuh9WsFZqvE16P1WCzA==} + peerDependencies: + react: ^18 || ^19 + '@tanstack/react-router@1.167.1': resolution: {integrity: sha512-hjBvkqXAQBligGekD6wYidl0jlXYwigYMcVkBQz3kXdWQ9fP/Ifbwu5w8zKnlRbuFHF90k1vY9UHjaWdsY3ILA==} engines: {node: '>=20.19'} @@ -18403,11 +18445,11 @@ snapshots: '@automattic/number-formatters': 1.1.5 '@automattic/oauth-token': 1.0.1 '@automattic/shopping-cart': 2.0.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@tanstack/react-query': 5.90.8(react@18.3.1) + '@tanstack/react-query': 5.96.1(react@18.3.1) '@wordpress/api-fetch': 7.44.0 '@wordpress/data': 10.44.0(react@18.3.1) '@wordpress/data-controls': 4.44.0(react@18.3.1) - '@wordpress/deprecated': 4.44.0 + '@wordpress/deprecated': 4.45.0 '@wordpress/element': 6.44.0 '@wordpress/i18n': 6.17.0 '@wordpress/primitives': 4.44.0(react@18.3.1) @@ -19387,34 +19429,6 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@base-ui/react@1.4.0(@date-fns/tz@1.4.1)(@types/react@18.3.28)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.29.2 - '@base-ui/utils': 0.2.7(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@date-fns/tz': 1.4.1 - '@floating-ui/react-dom': 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@floating-ui/utils': 0.2.11 - date-fns: 4.1.0 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - use-sync-external-store: 1.6.0(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - optional: true - - '@base-ui/react@1.4.0(@date-fns/tz@1.4.1)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.29.2 - '@base-ui/utils': 0.2.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@date-fns/tz': 1.4.1 - '@floating-ui/react-dom': 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@floating-ui/utils': 0.2.11 - date-fns: 4.1.0 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - use-sync-external-store: 1.6.0(react@18.3.1) - optional: true - '@base-ui/react@1.4.1(@date-fns/tz@1.4.1)(@types/react@18.3.28)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.29.2 @@ -19442,28 +19456,6 @@ snapshots: '@date-fns/tz': 1.4.1 date-fns: 4.1.0 - '@base-ui/utils@0.2.7(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.29.2 - '@floating-ui/utils': 0.2.11 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - reselect: 5.1.1 - use-sync-external-store: 1.6.0(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.28 - optional: true - - '@base-ui/utils@0.2.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@babel/runtime': 7.29.2 - '@floating-ui/utils': 0.2.11 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - reselect: 5.1.1 - use-sync-external-store: 1.6.0(react@18.3.1) - optional: true - '@base-ui/utils@0.2.8(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@babel/runtime': 7.29.2 @@ -22329,6 +22321,8 @@ snapshots: '@tanstack/query-core@5.90.8': {} + '@tanstack/query-core@5.96.1': {} + '@tanstack/query-devtools@5.90.1': {} '@tanstack/react-query-devtools@5.90.2(@tanstack/react-query@5.90.8(react@18.3.1))(react@18.3.1)': @@ -22342,6 +22336,11 @@ snapshots: '@tanstack/query-core': 5.90.8 react: 18.3.1 + '@tanstack/react-query@5.96.1(react@18.3.1)': + dependencies: + '@tanstack/query-core': 5.96.1 + react: 18.3.1 + '@tanstack/react-router@1.167.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@tanstack/history': 1.161.5 @@ -24090,28 +24089,6 @@ snapshots: - browserslist - supports-color - '@wordpress/build@0.13.0(@babel/core@7.29.0)(browserslist@4.28.2)': - dependencies: - '@emotion/babel-plugin': 11.13.5 - autoprefixer: 10.4.27(postcss@8.5.10) - browserslist-to-esbuild: 2.1.1(browserslist@4.28.2) - change-case: 4.1.2 - chokidar: 4.0.3 - cssnano: 7.1.4(postcss@8.5.10) - esbuild: 0.27.4 - esbuild-plugin-babel: 0.2.3(@babel/core@7.29.0) - esbuild-sass-plugin: 3.3.1(esbuild@0.27.4)(sass-embedded@1.97.3) - fast-glob: 3.3.3 - moment-timezone: 0.5.48 - postcss: 8.5.10 - postcss-modules: 6.0.1(postcss@8.5.10) - rtlcss: 4.3.0 - sass-embedded: 1.97.3 - transitivePeerDependencies: - - '@babel/core' - - browserslist - - supports-color - '@wordpress/commands@1.44.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@wordpress/base-styles': 6.20.0 @@ -24238,13 +24215,13 @@ snapshots: '@wordpress/compose@7.44.0(react@18.3.1)': dependencies: '@types/mousetrap': 1.6.15 - '@wordpress/deprecated': 4.44.0 + '@wordpress/deprecated': 4.45.0 '@wordpress/dom': 4.44.0 '@wordpress/element': 6.44.0 - '@wordpress/is-shallow-equal': 5.44.0 + '@wordpress/is-shallow-equal': 5.45.0 '@wordpress/keycodes': 4.44.0 - '@wordpress/priority-queue': 3.44.0 - '@wordpress/undo-manager': 1.44.0 + '@wordpress/priority-queue': 3.45.0 + '@wordpress/undo-manager': 1.45.0 change-case: 4.1.2 mousetrap: 1.6.5 react: 18.3.1 @@ -24371,10 +24348,10 @@ snapshots: '@wordpress/data@10.44.0(react@18.3.1)': dependencies: '@wordpress/compose': 7.44.0(react@18.3.1) - '@wordpress/deprecated': 4.44.0 + '@wordpress/deprecated': 4.45.0 '@wordpress/element': 6.44.0 - '@wordpress/is-shallow-equal': 5.44.0 - '@wordpress/priority-queue': 3.44.0 + '@wordpress/is-shallow-equal': 5.45.0 + '@wordpress/priority-queue': 3.45.0 '@wordpress/private-apis': 1.44.0 '@wordpress/redux-routine': 5.44.0(redux@5.0.1) deepmerge: 4.3.1 @@ -24394,7 +24371,7 @@ snapshots: '@wordpress/components': 32.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@wordpress/compose': 7.44.0(react@18.3.1) '@wordpress/data': 10.44.0(react@18.3.1) - '@wordpress/deprecated': 4.44.0 + '@wordpress/deprecated': 4.45.0 '@wordpress/element': 6.44.0 '@wordpress/i18n': 6.17.0 '@wordpress/icons': 12.2.0(react@18.3.1) @@ -24407,7 +24384,7 @@ snapshots: react: 18.3.1 remove-accents: 0.5.0 optionalDependencies: - '@base-ui/react': 1.4.0(@date-fns/tz@1.4.1)(@types/react@18.3.28)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@base-ui/react': 1.4.1(@date-fns/tz@1.4.1)(@types/react@18.3.28)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@emotion/cache': 11.14.0 '@emotion/css': 11.13.5 '@emotion/react': 11.14.0(@types/react@18.3.28)(react@18.3.1) @@ -24445,7 +24422,7 @@ snapshots: '@wordpress/components': 32.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@wordpress/compose': 7.44.0(react@18.3.1) '@wordpress/data': 10.44.0(react@18.3.1) - '@wordpress/deprecated': 4.44.0 + '@wordpress/deprecated': 4.45.0 '@wordpress/element': 6.44.0 '@wordpress/i18n': 6.17.0 '@wordpress/icons': 12.2.0(react@18.3.1) @@ -24458,7 +24435,7 @@ snapshots: react: 18.3.1 remove-accents: 0.5.0 optionalDependencies: - '@base-ui/react': 1.4.0(@date-fns/tz@1.4.1)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@base-ui/react': 1.4.1(@date-fns/tz@1.4.1)(date-fns@4.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@emotion/cache': 11.14.0 '@emotion/css': 11.13.5 '@emotion/react': 11.14.0(react@18.3.1) diff --git a/projects/packages/podcast/.gitattributes b/projects/packages/podcast/.gitattributes index 1b9fa596c937..841674d19825 100644 --- a/projects/packages/podcast/.gitattributes +++ b/projects/packages/podcast/.gitattributes @@ -14,4 +14,8 @@ changelog/** production-exclude .phpcs.dir.xml production-exclude tests/** production-exclude .phpcsignore production-exclude -routes/**/*.tsx production-exclude +tsconfig.json production-exclude +global.d.ts production-exclude +src/**/*.scss production-exclude +src/**/*.tsx production-exclude +src/**/*.ts production-exclude diff --git a/projects/packages/podcast/.phpcsignore b/projects/packages/podcast/.phpcsignore deleted file mode 100644 index 567609b1234a..000000000000 --- a/projects/packages/podcast/.phpcsignore +++ /dev/null @@ -1 +0,0 @@ -build/ diff --git a/projects/packages/podcast/babel.config.js b/projects/packages/podcast/babel.config.js new file mode 100644 index 000000000000..aa21158cf844 --- /dev/null +++ b/projects/packages/podcast/babel.config.js @@ -0,0 +1,10 @@ +const config = { + presets: [ + [ + '@automattic/jetpack-webpack-config/babel/preset', + { pluginReplaceTextdomain: { textdomain: 'jetpack-podcast' } }, + ], + ], +}; + +export default config; diff --git a/projects/packages/podcast/changelog/add-business-code b/projects/packages/podcast/changelog/add-business-code new file mode 100644 index 000000000000..eb57723e8776 --- /dev/null +++ b/projects/packages/podcast/changelog/add-business-code @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Move the Podcast business code (RSS feed customization, REST settings integration, and the wp-admin SPA covering Welcome / Settings / Episodes / Distribution) into the package. Replaces the wp-build placeholder dashboard from earlier in the untangle train; the SPA bundles via webpack alongside the wp-admin enqueue pipeline. Still gated behind the `jetpack_podcast_untangle` filter — Simple/Atomic only and no behavior change while the filter is off. diff --git a/projects/packages/podcast/composer.json b/projects/packages/podcast/composer.json index 63b9939134ee..e3bc8cb6a7ff 100644 --- a/projects/packages/podcast/composer.json +++ b/projects/packages/podcast/composer.json @@ -5,8 +5,9 @@ "license": "GPL-2.0-or-later", "require": { "php": ">=7.2", - "automattic/jetpack-status": "@dev", - "automattic/jetpack-wp-build-polyfills": "@dev" + "automattic/jetpack-admin-ui": "@dev", + "automattic/jetpack-assets": "@dev", + "automattic/jetpack-status": "@dev" }, "require-dev": { "yoast/phpunit-polyfills": "^4.0.0", @@ -30,7 +31,8 @@ ], "test-php": [ "@composer phpunit" - ] + ], + "typecheck": "pnpm run typecheck" }, "repositories": [ { diff --git a/projects/packages/podcast/package.json b/projects/packages/podcast/package.json index f3eeedc12add..565ff9618215 100644 --- a/projects/packages/podcast/package.json +++ b/projects/packages/podcast/package.json @@ -16,42 +16,56 @@ "author": "Automattic", "type": "module", "scripts": { - "build": "pnpm run clean && pnpm run build:wp-build && pnpm run build:boot-asset", - "build:boot-asset": "provide-boot-asset-file", - "build:wp-build": "wp-build", - "build-production": "pnpm run clean && pnpm run build:wp-build && pnpm run build:boot-asset", + "build": "pnpm run clean && pnpm run build-client", + "build-client": "pnpm webpack --config webpack.config.js", + "build-js": "webpack --config webpack.config.js", + "build-production": "pnpm run clean && pnpm run build-production-js && pnpm run validate", + "build-production-js": "NODE_ENV=production BABEL_ENV=production pnpm run build-js", "clean": "rm -rf build/", - "watch": "wp-build --watch" + "test": "jest --config=tests/jest.config.js --passWithNoTests", + "typecheck": "tsgo --noEmit", + "validate": "pnpm exec validate-es build/", + "watch": "pnpm run build && pnpm webpack watch" }, "browserslist": [ "extends @wordpress/browserslist-config" ], "dependencies": { + "@automattic/jetpack-components": "workspace:*", + "@automattic/jetpack-script-data": "workspace:*", + "@tanstack/react-query": "5.96.1", + "@wordpress/api-fetch": "7.44.0", "@wordpress/components": "32.6.0", + "@wordpress/compose": "7.44.0", + "@wordpress/data": "10.44.0", + "@wordpress/dataviews": "14.1.0", "@wordpress/element": "6.44.0", + "@wordpress/html-entities": "4.44.0", "@wordpress/i18n": "6.17.0", - "@wordpress/ui": "0.11.0" + "@wordpress/icons": "12.2.0", + "@wordpress/media-utils": "5.44.0", + "@wordpress/notices": "5.44.0", + "@wordpress/ui": "0.11.0", + "@wordpress/url": "4.44.0" }, "devDependencies": { - "@automattic/jetpack-wp-build-polyfills": "workspace:*", + "@automattic/babel-plugin-replace-textdomain": "workspace:*", + "@automattic/jetpack-webpack-config": "workspace:*", "@babel/core": "7.29.0", "@babel/runtime": "7.29.2", "@types/react": "18.3.28", + "@types/react-dom": "18.3.7", + "@typescript/native-preview": "7.0.0-dev.20260225.1", "@wordpress/browserslist-config": "6.44.0", - "@wordpress/build": "0.13.0", - "browserslist": "^4.24.0" + "jest": "30.3.0", + "postcss": "8.5.10", + "sass-embedded": "1.97.3", + "sass-loader": "16.0.5", + "webpack": "5.105.2", + "webpack-cli": "6.0.1" }, "optionalDependencies": { "react": "18.3.1", "react-dom": "18.3.1" - }, - "wpPlugin": { - "name": "jetpack_podcast", - "scriptGlobal": "jetpackPodcast", - "packageNamespace": "jetpack-podcast", - "handlePrefix": "jetpack-podcast", - "pages": [ - "jetpack-podcast-dashboard" - ] } } diff --git a/projects/packages/podcast/routes/dashboard/package.json b/projects/packages/podcast/routes/dashboard/package.json deleted file mode 100644 index f130ca6154aa..000000000000 --- a/projects/packages/podcast/routes/dashboard/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "_@jetpack-podcast/dashboard-route", - "version": "1.0.0", - "private": true, - "dependencies": { - "@types/react": "18.3.28", - "@wordpress/components": "32.6.0", - "@wordpress/element": "6.44.0", - "@wordpress/i18n": "6.17.0", - "@wordpress/ui": "0.11.0" - }, - "route": { - "path": "/", - "page": "jetpack-podcast-dashboard" - } -} diff --git a/projects/packages/podcast/routes/dashboard/route.tsx b/projects/packages/podcast/routes/dashboard/route.tsx deleted file mode 100644 index 91499ada4b32..000000000000 --- a/projects/packages/podcast/routes/dashboard/route.tsx +++ /dev/null @@ -1 +0,0 @@ -export const route = {}; diff --git a/projects/packages/podcast/routes/dashboard/stage.tsx b/projects/packages/podcast/routes/dashboard/stage.tsx deleted file mode 100644 index dbda37f6ce3d..000000000000 --- a/projects/packages/podcast/routes/dashboard/stage.tsx +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Podcast dashboard stage: page chrome + tab navigation. - * - * Placeholder scaffolding only — each tab panel renders a stub. PR 4 in the - * untangle train wires the full AdminPage + jetpack-components integration - * along with the real tab contents. - */ - -import { useState, useCallback } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; -import { Tabs } from '@wordpress/ui'; - -const TAB_VALUES = [ 'welcome', 'settings', 'episodes', 'distribution' ] as const; -type TabName = ( typeof TAB_VALUES )[ number ]; - -const isValidTab = ( value: string | null ): value is TabName => - !! value && ( TAB_VALUES as readonly string[] ).includes( value ); - -const Stage = () => { - const [ activeTab, setActiveTab ] = useState< TabName >( 'welcome' ); - - const handleTabChange = useCallback( ( value: string | null ) => { - if ( isValidTab( value ) ) { - setActiveTab( value ); - } - }, [] ); - - return ( -
- { /* "Podcast" is a product name, do not translate. */ } -

Podcast

-

- { __( - 'Publish a podcast and reach your fans, anywhere they listen.', - 'jetpack-podcast' - ) } -

- - - - { __( 'Welcome', 'jetpack-podcast' ) } - { __( 'Settings', 'jetpack-podcast' ) } - { __( 'Episodes', 'jetpack-podcast' ) } - { __( 'Distribution', 'jetpack-podcast' ) } - - -

{ __( 'Welcome — placeholder.', 'jetpack-podcast' ) }

-
- -

{ __( 'Settings — placeholder.', 'jetpack-podcast' ) }

-
- -

{ __( 'Episodes — placeholder.', 'jetpack-podcast' ) }

-
- -

{ __( 'Distribution — placeholder.', 'jetpack-podcast' ) }

-
-
-
- ); -}; - -export { Stage as stage }; diff --git a/projects/packages/podcast/src/class-podcast.php b/projects/packages/podcast/src/class-podcast.php index 0d8c76110662..260c1d609731 100644 --- a/projects/packages/podcast/src/class-podcast.php +++ b/projects/packages/podcast/src/class-podcast.php @@ -7,16 +7,13 @@ namespace Automattic\Jetpack\Podcast; +use Automattic\Jetpack\Podcast\Feed\Customize_Feed; +use Automattic\Jetpack\Podcast\Feed\Feed_Detection; +use Automattic\Jetpack\Podcast\REST\Settings_REST; use Automattic\Jetpack\Status\Host; /** - * Loads Jetpack Podcast on Simple and Atomic sites, gated behind the - * `jetpack_podcast_untangle` feature filter. - * - * Until the filter returns true, `init()` is a no-op so the legacy podcasting - * code (`Automattic_Podcasting` from the wpcom mu-plugin and the - * at-pressable-podcasting bridge plugin) keeps running unchanged. Subsequent - * PRs in the untangle train fill this in. + * Loads Jetpack Podcast on Simple and Atomic sites. */ class Podcast { @@ -30,10 +27,15 @@ class Podcast { private static $initialized = false; /** - * Initialize the package. + * Initialize the package. Bails on hosts other than Simple and Atomic. * - * Bails on hosts other than Simple and Atomic, and again unless the - * `jetpack_podcast_untangle` filter returns true. + * When the legacy podcast code (`Automattic_Podcasting` from the wpcom + * mu-plugin or the at-pressable-podcasting bridge plugin) is active, only + * the new wp-admin page is registered — feed customization and REST + * settings filters defer to the legacy code so we don't double-register + * `rss2_*` hooks (which would emit duplicate iTunes/Google Play tags) or + * stack the wpcom site-settings filters. Deleting either legacy entry + * point is what flips this package into full-control mode. */ public static function init() { if ( self::$initialized ) { @@ -49,10 +51,11 @@ public static function init() { /** * Master switch for the Podcast untangle. * - * While the legacy podcasting code is still the source of truth on - * Simple and Atomic sites, this filter stays false. Subsequent PRs - * layer the new wp-admin SPA, REST integration, and feed - * customization on top of this gate. + * Until this filter returns true, the package stays inert so the + * legacy podcasting experience (`Automattic_Podcasting` in the wpcom + * mu-plugin and the at-pressable-podcasting bridge) keeps running + * unchanged. Flipping it on hands the in-admin SPA, REST integration, + * and feed customization over to this package. * * @since 0.1.0 * @@ -62,12 +65,108 @@ public static function init() { return; } - // Wire the wp-admin entry point. Settings::init() registers the - // "Jetpack > Podcast" submenu and stages the wp-build dashboard. - // On Simple sites, wpcom-admin-menu.php drives the menu directly via - // Settings::add_wp_admin_submenu() at priority 999999. + $legacy_active = class_exists( 'Automattic_Podcasting', false ); + + // `register_setting()` always runs — it's the only path that exposes + // `podcasting_*` keys via `/wp/v2/settings` on Atomic, and the legacy + // wpcom mu-plugin / at-pressable-podcasting bridge don't register them + // there. Skipping it would leave the SPA with no way to read or write + // settings on Atomic. The wpcom-only `site_settings_endpoint_get` / + // `rest_api_update_site_settings` filters are a different story — + // those `do` overlap with the legacy code, so we skip them when the + // legacy code is loaded. + Settings_REST::init( ! $legacy_active ); + + // Admin page registration is only relevant in wp-admin contexts. Always + // registered (even alongside legacy) so the new SPA is the canonical + // entry point during the migration. if ( is_admin() ) { Settings::init(); } + + // Feed customization is wired only when the podcast feed is being + // served, and only when we own the feed (no legacy code present). + // The actual `rss2_*` hook plumbing (and the matching `remove_action` + // calls) lives in Customize_Feed::init() so it's only registered when + // a feed request is in flight, not on every page load. + if ( ! $legacy_active && self::is_enabled() ) { + add_action( 'after_setup_theme', array( __CLASS__, 'add_post_thumbnail_support' ), 20 ); + + if ( ! is_admin() ) { + add_action( 'wp', array( __CLASS__, 'maybe_load_feed_customization' ) ); + } + } + } + + /** + * Load feed customization only when the podcast category feed is requested. + * + * Also runs the podcatcher detector here — same gate (single feed + * request), guaranteed to run before the response goes out, and cheap + * enough that piggybacking is fine. + */ + public static function maybe_load_feed_customization() { + if ( is_feed() && is_category( self::get_category_id() ) ) { + Feed_Detection::detect_and_record(); + Customize_Feed::init(); + } + } + + /** + * Episode-level feed images rely on post thumbnails. + */ + public static function add_post_thumbnail_support() { + add_theme_support( 'post-thumbnails' ); + } + + /** + * Resolve the configured podcast category ID, falling back to the legacy slug option. + * + * @return int|false + */ + public static function get_category_id() { + $cat_id = get_option( 'podcasting_category_id', false ); + + if ( false !== $cat_id ) { + $category = get_category( $cat_id ); + if ( ! $category || ! isset( $category->term_id ) ) { + return false; + } + return (int) $category->term_id; + } + + $archive_slug = get_option( 'podcasting_archive', false ); + if ( false === $archive_slug ) { + return false; + } + + $category = get_term_by( 'slug', $archive_slug, 'category' ); + if ( ! $category || ! isset( $category->term_id ) ) { + return false; + } + + return (int) $category->term_id; + } + + /** + * Podcast is enabled when a category has been chosen. + * + * @return bool + */ + public static function is_enabled() { + return (bool) self::get_category_id(); + } + + /** + * Resolve the podcast cover image URL, preferring an attachment if one is set. + * + * @return string + */ + public static function get_image_url() { + $image_id = get_option( 'podcasting_image_id', false ); + if ( $image_id && is_numeric( $image_id ) && wp_attachment_is_image( $image_id ) ) { + return (string) wp_get_attachment_url( $image_id ); + } + return (string) get_option( 'podcasting_image', '' ); } } diff --git a/projects/packages/podcast/src/class-settings.php b/projects/packages/podcast/src/class-settings.php index f030d9afd31d..ceecb24c003a 100644 --- a/projects/packages/podcast/src/class-settings.php +++ b/projects/packages/podcast/src/class-settings.php @@ -1,232 +1,138 @@ Podcast" wp-admin screen on Simple and Atomic when the - * `jetpack_podcast_untangle` filter is enabled. Until that filter flips, every - * entry point here is a no-op so the legacy podcasting experience keeps - * running unchanged. + * Adds the Jetpack > Podcast wp-admin screen. * - * On Simple sites, the canonical entry point is `wpcom-admin-menu.php` (in the - * `jetpack-mu-wpcom` package), which calls `add_wp_admin_submenu()` at - * priority 999999 — late enough that the Jetpack parent menu already exists. - * Atomic and standalone Jetpack run through the standard `admin_menu` hook. + * On Simple and Atomic the canonical entry point is `wpcom-admin-menu.php` + * (in the `jetpack-mu-wpcom` package), which calls `add_wp_admin_submenu()` + * at priority 999999 — late enough that the Jetpack parent menu is already + * registered. We do not register our own `admin_menu` hook here; doing so on + * Atomic would race with the wpcom-admin-menu callback and duplicate the + * "Podcasting" item that used to redirect to Calypso. */ class Settings { - const ADMIN_PAGE_SLUG = 'jetpack-podcast'; - const WP_BUILD_PAGE_SLUG = 'jetpack-podcast-dashboard'; + const MENU_SLUG = 'jetpack-podcast'; /** - * Whether the class has already wired its admin hooks. + * Whether the admin-init hooks have been wired. * * @var bool */ - private static $initialized = false; + private static $admin_init_wired = false; /** - * Wire the admin hooks. Called from `Podcast::init()` once the - * `jetpack_podcast_untangle` filter and host gates have been satisfied. + * Init Podcast Settings. + * + * Currently a no-op kept for symmetry with the rest of the package — the + * actual menu registration happens via `add_wp_admin_submenu()`, called + * by `wpcom-admin-menu.php`. */ public static function init() { - if ( self::$initialized ) { - return; - } - self::$initialized = true; - - // Defer wp-build loading to admin_menu (priority 1) so the - // `jetpack_podcast_untangle` filter has been applied before we read it - // and the wp-build render function is in place before any menu callback - // runs (priority 999 on standalone Jetpack, 999999 on Simple via - // wpcom-admin-menu.php → add_wp_admin_submenu). - add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 ); - - // On Simple sites, the Jetpack parent menu doesn't exist until - // wpcom-admin-menu.php runs at priority 999999, so we let it call - // `add_wp_admin_submenu()` directly. On Atomic + standalone Jetpack we - // register at priority 999, before `Admin_Menu::admin_menu_hook_callback` - // processes queued items at priority 1000. - $host = new Host(); - if ( $host->is_wpcom_simple() ) { - return; - } - - add_action( 'admin_menu', array( __CLASS__, 'add_wp_admin_menu' ), 999 ); + // Intentionally empty: see class docblock. } /** - * Register the Podcast submenu under Jetpack on Atomic + standalone Jetpack. - */ - public static function add_wp_admin_menu() { - if ( ! self::is_enabled() ) { - return; - } - - $page_suffix = add_submenu_page( - 'jetpack', - /** "Podcast" is a product name, do not translate. */ - 'Podcast', - 'Podcast', - 'manage_options', - self::ADMIN_PAGE_SLUG, - self::get_render_callback() - ); - - if ( $page_suffix ) { - add_action( 'load-' . $page_suffix, array( __CLASS__, 'admin_init' ) ); - } - } - - /** - * Register the Podcast submenu under Jetpack on Simple sites. + * Register the Podcast submenu directly under the Jetpack menu. * - * Called from `wpcom-admin-menu.php` at priority 999999 once the Jetpack - * parent menu exists. Bails when the untangle filter is off so the legacy - * "Podcasting" Calypso link in `wpcom-admin-menu.php` keeps rendering. + * Called from wpcom-admin-menu.php at priority 999999 (Simple + Atomic) + * once the Jetpack menu is in place. The host gate happens earlier in + * `Podcast::init()` so by the time this runs we know we're on a host we + * support. */ public static function add_wp_admin_submenu() { - if ( ! self::is_enabled() ) { - return; - } - $page_suffix = add_submenu_page( 'jetpack', /** "Podcast" is a product name, do not translate. */ 'Podcast', 'Podcast', 'manage_options', - self::ADMIN_PAGE_SLUG, - self::get_render_callback() + self::MENU_SLUG, + array( __CLASS__, 'render' ) ); - if ( $page_suffix ) { + if ( $page_suffix && ! self::$admin_init_wired ) { + self::$admin_init_wired = true; add_action( 'load-' . $page_suffix, array( __CLASS__, 'admin_init' ) ); } } /** - * Wire admin-init actions once we know the Podcast page is loading. + * Admin init actions. Triggered only when the Podcast page is being loaded. */ public static function admin_init() { - // Subsequent PRs in the untangle train layer script-data + Tracks - // here. The wp-build dashboard manages its own enqueue pipeline. + add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'add_script_data' ) ); + add_action( 'admin_enqueue_scripts', array( __CLASS__, 'load_admin_scripts' ) ); } /** - * Load the wp-build entry on Podcast admin requests when the untangle - * filter is on. Hooked at `admin_menu` priority 1 so the render function - * is defined before `add_wp_admin_menu` / `add_wp_admin_submenu` register - * the menu callback. - */ - public static function maybe_load_wp_build() { - if ( ! self::is_enabled() || ! self::is_podcast_admin_request() ) { - return; - } - - self::load_wp_build(); - add_action( 'current_screen', array( __CLASS__, 'alias_screen_id_for_wp_build' ) ); - } - - /** - * Resolve the menu render callback, preferring the wp-build–generated - * function when the build artifact is in place. + * Inject podcast-specific data into the global JetpackScriptData object. * - * @return callable + * @param array $data Existing script data. + * @return array */ - private static function get_render_callback() { - $wp_build_render = 'jetpack_podcast_jetpack_podcast_dashboard_wp_admin_render_page'; - - if ( function_exists( $wp_build_render ) ) { - return $wp_build_render; - } - - return array( __CLASS__, 'render' ); - } + public static function add_script_data( $data ) { + $current_user = wp_get_current_user(); + $host = new Host(); + $blog_id = (int) $host->get_wpcom_site_id(); + $category_id = Podcast::get_category_id(); + $feed_url = $category_id ? get_term_feed_link( $category_id, 'category', 'rss2' ) : ''; + + $data['site']['wpcom']['blog_id'] = $blog_id; + + $data['podcast'] = array( + 'categoryId' => $category_id ? (int) $category_id : 0, + 'feedUrl' => $feed_url ? $feed_url : '', + 'siteUrl' => get_site_url(), + 'adminUrl' => admin_url(), + 'editPostUrlBase' => admin_url( 'post.php?action=edit&post=' ), + 'newPostUrl' => admin_url( 'post-new.php' ), + 'mediaLibraryUrl' => admin_url( 'upload.php' ), + 'userEmail' => $current_user->user_email, + 'dateFormat' => (string) get_option( 'date_format', 'F j, Y' ), + ); - /** - * Default render callback. Used as a fallback when the wp-build artifact is - * missing — for example, on a fresh checkout before `pnpm build` has run. - */ - public static function render() { - ?> -
-

Podcast

-
- true, + 'textdomain' => 'jetpack-podcast', + 'enqueue' => true, + 'dependencies' => array( 'jetpack-script-data' ), ) ); } /** - * Alias the current screen ID so wp-build's `-wp-admin` enqueue - * callback fires on our `?page=jetpack-podcast` URL. Wp-build expects the - * screen ID to match the wp-build page slug (`jetpack-podcast-dashboard`), - * but we keep the user-facing slug as `jetpack-podcast`. - * - * Hooked only when the untangle filter is on AND we're on the Podcast - * page, so this never affects any other request. - * - * @param \WP_Screen|null $screen The current screen object (passed by WP). + * Render the Podcast SPA mount point. */ - public static function alias_screen_id_for_wp_build( $screen ) { - if ( ! is_object( $screen ) ) { - return; - } - - $screen->id = self::WP_BUILD_PAGE_SLUG; - } - - /** - * Whether the Podcast untangle is enabled. Mirrors the gate in - * `Podcast::init()` so callbacks invoked outside that flow (e.g. - * `add_wp_admin_submenu()` from wpcom-admin-menu.php) still bail. - */ - private static function is_enabled() { - /** This filter is documented in src/class-podcast.php. */ - return (bool) apply_filters( 'jetpack_podcast_untangle', false ); - } - - /** - * Whether the current request targets the Podcast admin page. - * - * `$_GET['page']` is populated by `wp-admin/admin.php` before any of our - * hooks fire, so this is reliable from `admin_menu` priority 1 onwards. - */ - private static function is_podcast_admin_request() { - if ( ! is_admin() || ! isset( $_GET['page'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended - return false; - } - - return sanitize_text_field( wp_unslash( $_GET['page'] ) ) === self::ADMIN_PAGE_SLUG; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + public static function render() { + ?> +
+ = [ + 'podcasting_category_id', + 'podcasting_title', + 'podcasting_talent_name', + 'podcasting_summary', + 'podcasting_copyright', + 'podcasting_explicit', + 'podcasting_image', + 'podcasting_image_id', + 'podcasting_category_1', + 'podcasting_category_2', + 'podcasting_category_3', + 'podcasting_email', + 'podcasting_show_urls', +]; + +// Keep this in sync with `PodcatcherId` in types.ts and `SHOW_URL_HOSTS` +// in src/rest/class-settings-rest.php. Defines the canonical key order +// and lets us pad missing keys with empty strings server-side or client-side. +const PODCATCHER_IDS: readonly PodcatcherId[] = [ + 'pocketcasts', + 'apple', + 'spotify', + 'youtube', + 'amazon', + 'podcastindex', +] as const; + +const normalizeShowUrls = ( raw: unknown ): PodcastShowUrls => { + const source = ( raw && typeof raw === 'object' ? raw : {} ) as Record< string, unknown >; + const out = {} as PodcastShowUrls; + for ( const id of PODCATCHER_IDS ) { + const value = source[ id ]; + out[ id ] = typeof value === 'string' ? value : ''; + } + return out; +}; + +const getBlogId = (): number => Number( getSiteData()?.wpcom?.blog_id ?? 0 ); + +const pickPodcastFields = ( raw: Record< string, unknown > ): PodcastSettings => { + const numericKey = ( key: keyof PodcastSettings ) => + key === 'podcasting_category_id' || key === 'podcasting_image_id'; + + const toString = ( value: unknown ): string => { + if ( typeof value === 'string' ) { + return value; + } + if ( value == null ) { + return ''; + } + return String( value ); + }; + + const out: Record< string, unknown > = {}; + for ( const key of PODCAST_KEYS ) { + const value = raw[ key ]; + if ( numericKey( key ) ) { + out[ key ] = typeof value === 'number' ? value : Number( value ?? 0 ) || 0; + } else if ( key === 'podcasting_explicit' ) { + out[ key ] = value === 'yes' || value === 'clean' ? value : 'no'; + } else if ( key === 'podcasting_show_urls' ) { + out[ key ] = normalizeShowUrls( value ); + } else { + out[ key ] = toString( value ); + } + } + return out as unknown as PodcastSettings; +}; + +/** + * Fetch the podcasting_* options from the right host's settings endpoint. + * + * @return The current settings, with all PodcastSettings keys present. + */ +export async function fetchSettings(): Promise< PodcastSettings > { + const blogId = getBlogId(); + + if ( isSimpleSite() && blogId ) { + const result = ( await apiFetch( { + path: `/rest/v1.4/sites/${ blogId }/settings`, + method: 'GET', + } ) ) as { settings?: Record< string, unknown > }; + return pickPodcastFields( ( result.settings || result ) as Record< string, unknown > ); + } + + const result = ( await apiFetch( { + path: '/wp/v2/settings', + method: 'GET', + } ) ) as Record< string, unknown >; + return pickPodcastFields( result ); +} + +/** + * Persist a partial settings update. + * + * @param updates - Subset of PodcastSettings to write. + * @return The merged settings as the server now sees them. + */ +export async function updateSettings( updates: PodcastSettingsUpdate ): Promise< PodcastSettings > { + const blogId = getBlogId(); + + if ( isSimpleSite() && blogId ) { + const result = ( await apiFetch( { + path: `/rest/v1.4/sites/${ blogId }/settings`, + method: 'POST', + data: updates, + } ) ) as { updated?: Record< string, unknown > }; + return pickPodcastFields( ( result.updated || result ) as Record< string, unknown > ); + } + + const result = ( await apiFetch( { + path: '/wp/v2/settings', + method: 'POST', + data: updates, + } ) ) as Record< string, unknown >; + return pickPodcastFields( result ); +} + +/** + * Fetch every category term, paging through 100 at a time. + * + * @return All category terms on the site. + */ +export async function fetchCategories(): Promise< CategoryTerm[] > { + const blogId = getBlogId(); + + if ( isSimpleSite() && blogId ) { + const out: CategoryTerm[] = []; + let page = 1; + + while ( true ) { + const result = ( await apiFetch( { + path: `/rest/v1.1/sites/${ blogId }/taxonomies/category/terms?page=${ page }&number=100`, + method: 'GET', + } ) ) as { + terms?: Array< { ID: number; name: string; slug: string } >; + found?: number; + }; + const terms = result.terms || []; + out.push( ...terms.map( t => ( { id: t.ID, name: t.name, slug: t.slug } ) ) ); + if ( out.length >= ( result.found || 0 ) || terms.length === 0 ) { + break; + } + page++; + } + return out; + } + + const out: CategoryTerm[] = []; + let page = 1; + + while ( true ) { + const response = ( await apiFetch( { + path: addQueryArgs( '/wp/v2/categories', { per_page: 100, page } ), + method: 'GET', + parse: false, + } ) ) as Response; + const data = ( await response.json() ) as Array< { id: number; name: string; slug: string } >; + out.push( ...data.map( t => ( { id: t.id, name: t.name, slug: t.slug } ) ) ); + const totalPages = parseInt( response.headers.get( 'X-WP-TotalPages' ) || '1', 10 ); + if ( page >= totalPages || data.length === 0 ) { + break; + } + page++; + } + return out; +} + +/** + * Create a new category term. + * + * @param name - Display name for the new category. + * @return The created term (id, name, slug). + */ +export async function createCategory( name: string ): Promise< CategoryTerm > { + const blogId = getBlogId(); + + if ( isSimpleSite() && blogId ) { + const result = ( await apiFetch( { + path: `/rest/v1.1/sites/${ blogId }/taxonomies/category/new`, + method: 'POST', + data: { name }, + } ) ) as { ID: number; name: string; slug: string }; + return { id: result.ID, name: result.name, slug: result.slug }; + } + + const result = ( await apiFetch( { + path: '/wp/v2/categories', + method: 'POST', + data: { name }, + } ) ) as { id: number; name: string; slug: string }; + return { id: result.id, name: result.name, slug: result.slug }; +} + +/** + * Fetch a page of posts in the podcast category. + * + * @param args - Pagination, sort, search, and status filter args. + * @return The posts for the requested page plus pagination metadata. + */ +export async function fetchEpisodes( args: EpisodesQueryArgs ): Promise< EpisodesPage > { + const { + categoryId, + page = 1, + perPage = 20, + orderBy = 'date', + order = 'desc', + search = '', + status = 'any', + } = args; + + const query: Record< string, string | number > = { + categories: categoryId, + page, + per_page: perPage, + orderby: orderBy, + order, + _embed: 'wp:featuredmedia', + }; + if ( search ) { + query.search = search; + } + if ( status ) { + query.status = status; + } + + const response = ( await apiFetch( { + path: addQueryArgs( '/wp/v2/posts', query ), + method: 'GET', + parse: false, + } ) ) as Response; + + const episodes = ( await response.json() ) as Episode[]; + const total = parseInt( response.headers.get( 'X-WP-Total' ) || '0', 10 ); + const totalPages = parseInt( response.headers.get( 'X-WP-TotalPages' ) || '1', 10 ); + + return { episodes, total, totalPages }; +} + +/** + * Fetch per-episode plays + duration. Chunked to 50 IDs per request to match + * the wpcom endpoint's max page size. + * + * @param postIds - Episode post IDs to look up stats for. + * @return Stats for each post that had data; missing posts are omitted. + */ +export async function fetchEpisodeStats( postIds: number[] ): Promise< EpisodeStats[] > { + if ( postIds.length === 0 ) { + return []; + } + + const blogId = getBlogId(); + if ( ! blogId ) { + return []; + } + + const out: EpisodeStats[] = []; + for ( let i = 0; i < postIds.length; i += 50 ) { + const chunk = postIds.slice( i, i + 50 ); + const result = ( await apiFetch( { + path: addQueryArgs( `/wpcom/v2/sites/${ blogId }/podcast-stats/episode-totals`, { + post_ids: chunk.join( ',' ), + } ), + method: 'GET', + } ) ) as { episodes?: EpisodeStats[] } | EpisodeStats[]; + + if ( Array.isArray( result ) ) { + out.push( ...result ); + } else if ( result.episodes ) { + out.push( ...result.episodes ); + } + } + return out; +} diff --git a/projects/packages/podcast/src/dashboard/app.tsx b/projects/packages/podcast/src/dashboard/app.tsx new file mode 100644 index 000000000000..9d508bdeee84 --- /dev/null +++ b/projects/packages/podcast/src/dashboard/app.tsx @@ -0,0 +1,190 @@ +/** + * Jetpack Podcast top-level app: AdminPage chrome + tab navigation. + */ + +import { AdminPage, Container, Col, GlobalNotices } from '@automattic/jetpack-components'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { Spinner } from '@wordpress/components'; +import { lazy, Suspense, useCallback, useEffect, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Tabs } from '@wordpress/ui'; +import { usePodcastSettings } from './hooks/use-podcast-settings'; +import type { TabName } from './types'; + +// Tabs are lazy-loaded so a visit to the page only pulls down the active tab's +// bundle (and its hooks). DataViews + the Apple Podcasts topics list are the +// two largest chunks; both stay out of the main bundle until needed. +const WelcomeTab = lazy( + () => import( /* webpackChunkName: "podcast-welcome" */ './tabs/welcome' ) +); +const SettingsTab = lazy( + () => import( /* webpackChunkName: "podcast-settings" */ './tabs/settings' ) +); +const EpisodesTab = lazy( + () => import( /* webpackChunkName: "podcast-episodes" */ './tabs/episodes' ) +); +const DistributionTab = lazy( + () => import( /* webpackChunkName: "podcast-distribution" */ './tabs/distribution' ) +); + +const TabFallback = () => ( +
+ +
+); + +const VALID_TABS: readonly TabName[] = [ 'welcome', 'settings', 'episodes', 'distribution' ]; + +const isValidTab = ( value: string | null ): value is TabName => + !! value && ( VALID_TABS as readonly string[] ).includes( value ); + +/** + * Resolve the initial tab. Order of preference: URL hash (e.g. `#episodes`) + * so deep links and reloads stick; `welcome` if podcasting isn't set up yet; + * `settings` once a category is configured (matches Calypso's onboarding flow). + * + * @param isSetUp - Whether the site already has a podcast category configured. + * @return The tab to land on. + */ +const resolveInitialTab = ( isSetUp: boolean ): TabName => { + const hash = typeof window !== 'undefined' ? window.location.hash.replace( /^#/, '' ) : ''; + if ( isValidTab( hash ) ) { + return hash; + } + return isSetUp ? 'settings' : 'welcome'; +}; + +const queryClient = new QueryClient( { + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: 1, + staleTime: 30_000, + }, + }, +} ); + +const PodcastApp = () => { + const { data: settings, isLoading } = usePodcastSettings(); + const isSetUp = !! settings && settings.podcasting_category_id > 0; + + const [ activeTab, setActiveTab ] = useState< TabName >( () => resolveInitialTab( false ) ); + + // Settle the default tab once data resolves — `welcome` for new users, + // `settings` for sites already configured. Skipped if the URL hash already + // pinned a tab. + useEffect( () => { + if ( isLoading ) { + return; + } + const hash = window.location.hash.replace( /^#/, '' ); + if ( isValidTab( hash ) ) { + return; + } + setActiveTab( isSetUp ? 'settings' : 'welcome' ); + }, [ isLoading, isSetUp ] ); + + // Mirror the active tab to the URL hash for deep links and reload-stickiness. + useEffect( () => { + const next = `#${ activeTab }`; + if ( window.location.hash !== next ) { + window.history.replaceState( null, '', next ); + } + }, [ activeTab ] ); + + // React to back/forward navigation between tabs. + useEffect( () => { + const onHashChange = () => { + const hash = window.location.hash.replace( /^#/, '' ); + if ( isValidTab( hash ) ) { + setActiveTab( hash ); + } + }; + window.addEventListener( 'hashchange', onHashChange ); + return () => window.removeEventListener( 'hashchange', onHashChange ); + }, [] ); + + const handleTabChange = useCallback( ( value: string | null ) => { + if ( isValidTab( value ) ) { + setActiveTab( value ); + } + }, [] ); + + const handleWelcomeGetStarted = useCallback( () => { + setActiveTab( 'settings' ); + }, [] ); + + return ( + + + + + { isLoading ? ( +
+ +
+ ) : ( + +
+ + { __( 'Welcome', 'jetpack-podcast' ) } + { __( 'Settings', 'jetpack-podcast' ) } + + { __( 'Episodes', 'jetpack-podcast' ) } + + + { __( 'Distribution', 'jetpack-podcast' ) } + + +
+ +
+ }> + + +
+
+ +
+ }> + + +
+
+ +
+ }> + + +
+
+ +
+ }> + + +
+
+
+ ) } + +
+
+ ); +}; + +const App = () => ( + + + +); + +export default App; diff --git a/projects/packages/podcast/src/dashboard/components/cover-image-control.tsx b/projects/packages/podcast/src/dashboard/components/cover-image-control.tsx new file mode 100644 index 000000000000..139df9cd2371 --- /dev/null +++ b/projects/packages/podcast/src/dashboard/components/cover-image-control.tsx @@ -0,0 +1,119 @@ +/** + * Cover image picker for the podcast Settings tab. + * + * Wraps `@wordpress/media-utils`'s `MediaUpload` (the standalone wp-admin + * version, not the block-editor one) so editors can pick an existing + * attachment or upload a new one. Apple Podcasts requires a square cover + * between 1400×1400 and 3000×3000 — we surface that as a soft warning rather + * than a hard block, since stock photo services often deliver close-but-not- + * exactly-square assets. + */ + +import { Button } from '@wordpress/components'; +import { useCallback, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { MediaUpload } from '@wordpress/media-utils'; + +interface CoverImageControlProps { + imageUrl: string; + imageId: number; + onSelect: ( imageId: number, imageUrl: string ) => void; + onRemove: () => void; + disabled?: boolean; +} + +interface MediaUploadAttachment { + id: number; + url: string; + width?: number; + height?: number; +} + +const COVER_MIN = 1400; +const COVER_MAX = 3000; + +const validate = ( att: MediaUploadAttachment ): string | null => { + if ( ! att.width || ! att.height ) { + return null; + } + if ( att.width !== att.height ) { + return __( + 'Apple Podcasts requires a square image. Crop your image to a 1:1 ratio for the best results.', + 'jetpack-podcast' + ); + } + if ( att.width < COVER_MIN || att.width > COVER_MAX ) { + return __( + 'For best results, use an image between 1400×1400 and 3000×3000 pixels.', + 'jetpack-podcast' + ); + } + return null; +}; + +const CoverImageControl = ( { + imageUrl, + imageId, + onSelect, + onRemove, + disabled, +}: CoverImageControlProps ) => { + const [ warning, setWarning ] = useState< string | null >( null ); + + const hasImage = !! imageUrl || imageId > 0; + + // Pre-resolve the two button labels separately so the i18n-check-webpack-plugin + // validator sees two distinct __() calls in the bundled output. Inlining the + // ternary inside __() (or even between two __() calls in JSX) lets terser fold + // them into __(cond?'a':'b'), which the validator rejects. + const changeLabel = __( 'Change cover', 'jetpack-podcast' ); + const setLabel = __( 'Set cover image', 'jetpack-podcast' ); + const noImageLabel = __( 'No image set', 'jetpack-podcast' ); + const triggerLabel = hasImage ? changeLabel : setLabel; + + const handleSelect = useCallback( + ( att: MediaUploadAttachment ) => { + setWarning( validate( att ) ); + onSelect( att.id, att.url ); + }, + [ onSelect ] + ); + + const renderTrigger = useCallback( + ( { open }: { open: () => void } ) => ( + + ), + [ disabled, triggerLabel ] + ); + + return ( +
+
+ { imageUrl ? ( + { + ) : ( + { noImageLabel } + ) } +
+
+ + { hasImage && ( + + ) } +
+ { warning &&

{ warning }

} +
+ ); +}; + +export default CoverImageControl; diff --git a/projects/packages/podcast/src/dashboard/components/submit-modal.tsx b/projects/packages/podcast/src/dashboard/components/submit-modal.tsx new file mode 100644 index 000000000000..f106ad25d706 --- /dev/null +++ b/projects/packages/podcast/src/dashboard/components/submit-modal.tsx @@ -0,0 +1,363 @@ +/** + * Default 3-step "submit your feed" modal launched from the Distribution tab. + * + * Conforms to `PodcastAppModalProps` so an app's `Modal` field can swap in a + * custom flow without distribution.tsx caring. Apps that fit this pattern + * leave `Modal` unset and ride this default; ones with diverging flows (e.g. + * one-click API submission) ship their own component instead. + * + * The submitted-show URL is persisted on the `podcasting_show_urls` site + * setting. The host allowlist is enforced server-side; we mirror it here + * (PodcastApp.showHosts) so we can fail-fast on the client and surface an + * inline error rather than silently dropping the user's input. + */ + +import { + Button, + ExternalLink, + Icon, + Modal, + Notice, + TextControl, + VisuallyHidden, + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalHStack as HStack, + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalText as Text, + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalVStack as VStack, +} from '@wordpress/components'; +import { useCopyToClipboard } from '@wordpress/compose'; +import { useCallback, useEffect, useRef, useState } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { check, external, link } from '@wordpress/icons'; +import { prependHTTPS } from '@wordpress/url'; +import { usePodcastSettings, useUpdatePodcastSettings } from '../hooks/use-podcast-settings'; +import type { PodcastAppModalProps } from '../podcast-apps'; +import type { FormEvent } from 'react'; + +// Mirrors SHOW_URL_MAX_LENGTH in src/rest/class-settings-rest.php. +const SHOW_URL_MAX_LENGTH = 2048; + +// `prependHTTPS` adds the scheme for bare hosts but leaves an existing +// `http://` alone — the backend rejects non-https, so upgrade ourselves. +const normalizeShowUrl = ( raw: string ): string => + prependHTTPS( raw.trim() ).replace( /^http:\/\//i, 'https://' ); + +// Mirrors the per-podcatcher allowlist + esc_url_raw + wp_http_validate_url +// gauntlet the backend runs each save through. Empty input is rejected here +// so the modal never silently deletes a stored entry by clearing the field. +const isValidShowUrl = ( url: string, allowedHosts: readonly string[] ): boolean => { + if ( url === '' || url.length > SHOW_URL_MAX_LENGTH ) { + return false; + } + let parsed: URL; + try { + parsed = new URL( url ); + } catch { + return false; + } + if ( parsed.protocol !== 'https:' ) { + return false; + } + const host = parsed.hostname.toLowerCase().replace( /^www\./, '' ); + return allowedHosts.includes( host ); +}; + +const SubmitModal = ( { app, feedUrl, onClose }: PodcastAppModalProps ) => { + const { data: settings } = usePodcastSettings(); + const { mutate: saveSettings, isPending: isSaving } = useUpdatePodcastSettings(); + + const storedUrl = settings?.podcasting_show_urls?.[ app.id ] ?? ''; + const [ draftUrl, setDraftUrl ] = useState( storedUrl ); + const [ hasCopied, setHasCopied ] = useState( false ); + const [ isEditing, setIsEditing ] = useState( false ); + const [ saveError, setSaveError ] = useState< string | null >( null ); + const inputContainerRef = useRef< HTMLDivElement >( null ); + // `storedUrl` may be empty on mount if settings haven't hydrated yet; + // once it lands, mirror it into the draft. Flipped to true the moment + // the draft is touched (sync, typing, or Replace) so late hydration + // can never clobber input the user has already started. + const hasInitializedDraft = useRef( !! storedUrl ); + // Set when Replace is clicked so the post-render effect knows to focus + // the now-mounted input. Don't trigger on every isEditing flip — typing + // also flips it, and stealing focus mid-keystroke is disruptive. + const shouldFocusInputRef = useRef( false ); + + useEffect( () => { + if ( ! hasInitializedDraft.current && storedUrl ) { + hasInitializedDraft.current = true; + setDraftUrl( storedUrl ); + } + }, [ storedUrl ] ); + + useEffect( () => { + if ( ! shouldFocusInputRef.current || ! isEditing ) { + return; + } + shouldFocusInputRef.current = false; + const input = inputContainerRef.current?.querySelector( 'input' ); + input?.focus(); + input?.select(); + }, [ isEditing ] ); + + const copyRef = useCopyToClipboard< HTMLButtonElement >( feedUrl, () => { + setHasCopied( true ); + setTimeout( () => setHasCopied( false ), 2000 ); + } ); + + const handleReplace = useCallback( () => { + hasInitializedDraft.current = true; + shouldFocusInputRef.current = true; + setDraftUrl( storedUrl ); + setSaveError( null ); + setIsEditing( true ); + }, [ storedUrl ] ); + + const handleDraftChange = useCallback( ( value: string ) => { + hasInitializedDraft.current = true; + // Pin the form open so a late `storedUrl` hydration can't swap us + // back to the saved/read-only view mid-keystroke. + setIsEditing( true ); + setDraftUrl( value ); + setSaveError( null ); + }, [] ); + + const handleDismissError = useCallback( () => setSaveError( null ), [] ); + + const normalizedDraft = normalizeShowUrl( draftUrl ); + const isUnchanged = draftUrl === storedUrl; + + const handleSave = useCallback( + ( event: FormEvent< HTMLFormElement > ) => { + event.preventDefault(); + if ( ! isValidShowUrl( normalizedDraft, app.showHosts ) ) { + setSaveError( + sprintf( + /* translators: %s: podcast directory name (e.g. "Apple Podcasts"). */ + __( 'Enter a valid %s URL.', 'jetpack-podcast' ), + app.name + ) + ); + return; + } + setSaveError( null ); + saveSettings( + { podcasting_show_urls: { [ app.id ]: normalizedDraft } }, + { + onSuccess: () => { + setIsEditing( false ); + onClose(); + }, + onError: () => { + setSaveError( + sprintf( + /* translators: %s: podcast directory name. */ + __( 'We couldn’t save your %s URL. Please try again.', 'jetpack-podcast' ), + app.name + ) + ); + }, + } + ); + }, + [ normalizedDraft, app.showHosts, app.id, app.name, saveSettings, onClose ] + ); + + // Pre-resolve so the i18n-check-webpack-plugin validator sees two distinct + // __() calls in the bundled output instead of __(cond?'a':'b'). + const copiedLabel = __( 'Copied!', 'jetpack-podcast' ); + const copyLinkLabel = __( 'Copy link', 'jetpack-podcast' ); + + const titleText = sprintf( + /* translators: %s: podcast directory name (e.g. "Apple Podcasts"). */ + __( 'Submit to %s', 'jetpack-podcast' ), + app.name + ); + + const showSavedReadOnly = !! storedUrl && ! isEditing; + + return ( + + + +

+ { __( 'Step 1: Copy your RSS feed URL', 'jetpack-podcast' ) } +

+ + { feedUrl + ? sprintf( + /* translators: %s: podcast directory name. */ + __( + 'Click the button below to copy your RSS feed URL. %s will require this URL to list your podcast.', + 'jetpack-podcast' + ), + app.name + ) + : __( + 'Set your podcast category in the Settings tab to generate your RSS feed URL.', + 'jetpack-podcast' + ) } + + { feedUrl && ( + + ) } +
+ + +

+ { sprintf( + /* translators: %s: podcast directory name. */ + __( 'Step 2: Submit your podcast to %s', 'jetpack-podcast' ), + app.name + ) } +

+ + { sprintf( + /* translators: %s: podcast directory name. */ + __( + 'Click the button below to visit %s and complete their sign up flow.', + 'jetpack-podcast' + ), + app.name + ) } + + { app.learnMoreUrl && ( + + + { __( 'Learn more', 'jetpack-podcast' ) } + + + ) } + { app.step2Extra } + +
+ + +

+ { sprintf( + /* translators: %s: podcast directory name. */ + __( 'Step 3: Enter your %s URL', 'jetpack-podcast' ), + app.name + ) } +

+ + { sprintf( + /* translators: %s: podcast directory name. */ + __( + 'Paste your new %s URL into the field below and we’ll use it for your sharing buttons.', + 'jetpack-podcast' + ), + app.name + ) } + + { showSavedReadOnly ? ( + + + + + + ) : ( +
+ +
+ +
+ +
+ { saveError && ( + + { saveError } + + ) } +
+ ) } +
+
+
+ ); +}; + +export default SubmitModal; diff --git a/projects/packages/podcast/src/dashboard/hooks/use-categories-query.ts b/projects/packages/podcast/src/dashboard/hooks/use-categories-query.ts new file mode 100644 index 000000000000..b71e5a64fcb7 --- /dev/null +++ b/projects/packages/podcast/src/dashboard/hooks/use-categories-query.ts @@ -0,0 +1,39 @@ +/** + * TanStack Query hook for fetching all category terms. + */ + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { createCategory, fetchCategories, type CategoryTerm } from '../api'; + +const QUERY_KEY = [ 'jetpack-podcast', 'categories' ] as const; + +/** + * Read every category term on the site as a single cached query. + * + * @return Query result; `data` is the array of category terms once loaded. + */ +export function useCategoriesQuery() { + return useQuery< CategoryTerm[] >( { + queryKey: QUERY_KEY, + queryFn: fetchCategories, + staleTime: 60_000, + } ); +} + +/** + * Mutation that creates a category term and adds it to the cached list. + * + * @return TanStack mutation; call `mutateAsync(name)` to create. + */ +export function useCreateCategory() { + const queryClient = useQueryClient(); + + return useMutation< CategoryTerm, Error, string >( { + mutationFn: createCategory, + onSuccess: term => { + queryClient.setQueryData< CategoryTerm[] >( QUERY_KEY, prev => + prev ? [ ...prev, term ] : [ term ] + ); + }, + } ); +} diff --git a/projects/packages/podcast/src/dashboard/hooks/use-episode-stats-query.ts b/projects/packages/podcast/src/dashboard/hooks/use-episode-stats-query.ts new file mode 100644 index 000000000000..39db8b1e14ea --- /dev/null +++ b/projects/packages/podcast/src/dashboard/hooks/use-episode-stats-query.ts @@ -0,0 +1,30 @@ +/** + * TanStack Query hook for fetching per-episode play stats and durations. + * + * The wpcom `/wpcom/v2/sites/{id}/podcast-stats/episode-totals` endpoint caches + * for 5 minutes server-side; mirror that on the client so we don't refetch on + * every tab change. + */ + +import { useQuery } from '@tanstack/react-query'; +import { fetchEpisodeStats } from '../api'; +import type { EpisodeStats } from '../types'; + +/** + * Read plays + duration for a set of episode post IDs. Sort the IDs first so + * the cache key is stable regardless of the order they arrived from the + * episodes query. + * + * @param postIds - Episode post IDs (from the visible page of the table). + * @return Query result; `data` is the per-episode stats array. + */ +export function useEpisodeStatsQuery( postIds: number[] ) { + const sortedIds = [ ...postIds ].sort( ( a, b ) => a - b ); + + return useQuery< EpisodeStats[] >( { + queryKey: [ 'jetpack-podcast', 'episode-stats', sortedIds ], + queryFn: () => fetchEpisodeStats( sortedIds ), + enabled: sortedIds.length > 0, + staleTime: 5 * 60 * 1000, + } ); +} diff --git a/projects/packages/podcast/src/dashboard/hooks/use-episodes-query.ts b/projects/packages/podcast/src/dashboard/hooks/use-episodes-query.ts new file mode 100644 index 000000000000..731f66188d50 --- /dev/null +++ b/projects/packages/podcast/src/dashboard/hooks/use-episodes-query.ts @@ -0,0 +1,23 @@ +/** + * TanStack Query hook for fetching podcast episodes. + */ + +import { useQuery, keepPreviousData } from '@tanstack/react-query'; +import { fetchEpisodes, type EpisodesQueryArgs, type EpisodesPage } from '../api'; + +/** + * Read a page of podcast episodes. Disabled until a category is configured; + * keeps the previous page visible during pagination so the table doesn't flash + * empty on each navigation. + * + * @param args - Pagination, sort, search, and status filter args. + * @return Query result with `data.episodes` and pagination metadata. + */ +export function useEpisodesQuery( args: EpisodesQueryArgs ) { + return useQuery< EpisodesPage >( { + queryKey: [ 'jetpack-podcast', 'episodes', args ], + queryFn: () => fetchEpisodes( args ), + enabled: args.categoryId > 0, + placeholderData: keepPreviousData, + } ); +} diff --git a/projects/packages/podcast/src/dashboard/hooks/use-podcast-settings.ts b/projects/packages/podcast/src/dashboard/hooks/use-podcast-settings.ts new file mode 100644 index 000000000000..a75a96696c96 --- /dev/null +++ b/projects/packages/podcast/src/dashboard/hooks/use-podcast-settings.ts @@ -0,0 +1,79 @@ +/** + * TanStack Query hook for fetching and mutating podcast settings. + */ + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { dispatch } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; +import { fetchSettings, updateSettings } from '../api'; +import type { PodcastSettings, PodcastSettingsUpdate } from '../types'; + +const QUERY_KEY = [ 'jetpack-podcast', 'settings' ] as const; + +/** + * Read the current podcasting_* options as a single TanStack Query object. + * + * @return Query result; `data` is the resolved settings once loaded. + */ +export function usePodcastSettings() { + return useQuery< PodcastSettings >( { + queryKey: QUERY_KEY, + queryFn: fetchSettings, + staleTime: 60_000, + } ); +} + +/** + * Mutation for persisting a partial settings update with optimistic UI: + * the cache is patched immediately, rolled back on error, and a snackbar + * notice (success or failure) is dispatched. + * + * @return TanStack mutation; call `mutate(partial)` to save. + */ +export function useUpdatePodcastSettings() { + const queryClient = useQueryClient(); + + return useMutation< + PodcastSettings, + Error, + PodcastSettingsUpdate, + { previous?: PodcastSettings } + >( { + mutationFn: updateSettings, + onMutate: async updates => { + await queryClient.cancelQueries( { queryKey: QUERY_KEY } ); + const previous = queryClient.getQueryData< PodcastSettings >( QUERY_KEY ); + if ( previous ) { + // Deep-merge `podcasting_show_urls` so a partial patch (e.g. + // { apple: 'url' }) doesn't blow away the other directories' + // URLs in the optimistic snapshot. Server merges the same way. + const optimistic: PodcastSettings = { + ...previous, + ...updates, + podcasting_show_urls: { + ...previous.podcasting_show_urls, + ...( updates.podcasting_show_urls ?? {} ), + }, + }; + queryClient.setQueryData< PodcastSettings >( QUERY_KEY, optimistic ); + } + return { previous }; + }, + onError: ( _error, _updates, context ) => { + if ( context?.previous ) { + queryClient.setQueryData( QUERY_KEY, context.previous ); + } + dispatch( noticesStore ).createErrorNotice( + __( 'Could not save your podcast settings. Please try again.', 'jetpack-podcast' ), + { type: 'snackbar' } + ); + }, + onSuccess: data => { + queryClient.setQueryData< PodcastSettings >( QUERY_KEY, data ); + dispatch( noticesStore ).createSuccessNotice( __( 'Settings saved.', 'jetpack-podcast' ), { + type: 'snackbar', + } ); + }, + } ); +} diff --git a/projects/packages/podcast/src/dashboard/index.tsx b/projects/packages/podcast/src/dashboard/index.tsx new file mode 100644 index 000000000000..d1004e89e9da --- /dev/null +++ b/projects/packages/podcast/src/dashboard/index.tsx @@ -0,0 +1,12 @@ +/** + * Podcast SPA bootstrap. + */ + +import { createRoot } from '@wordpress/element'; +import App from './app'; +import './style.scss'; + +const container = document.getElementById( 'jetpack-podcast-root' ); +if ( container ) { + createRoot( container ).render( ); +} diff --git a/projects/packages/podcast/src/dashboard/podcast-apps/amazon.tsx b/projects/packages/podcast/src/dashboard/podcast-apps/amazon.tsx new file mode 100644 index 000000000000..fd5c30c1305a --- /dev/null +++ b/projects/packages/podcast/src/dashboard/podcast-apps/amazon.tsx @@ -0,0 +1,47 @@ +/** + * Amazon Music directory entry. + */ + +import type { PodcastApp } from './types'; + +const AmazonLogo = () => ( + +); + +export const amazon: PodcastApp = { + id: 'amazon', + name: 'Amazon Music', + Logo: AmazonLogo, + submitUrl: 'https://podcasters.amazon.com', + showHosts: [ + 'music.amazon.com', + 'music.amazon.co.uk', + 'music.amazon.de', + 'music.amazon.co.jp', + 'music.amazon.com.au', + 'music.amazon.fr', + 'music.amazon.ca', + 'music.amazon.es', + ], +}; diff --git a/projects/packages/podcast/src/dashboard/podcast-apps/apple.tsx b/projects/packages/podcast/src/dashboard/podcast-apps/apple.tsx new file mode 100644 index 000000000000..e2a89b2f589e --- /dev/null +++ b/projects/packages/podcast/src/dashboard/podcast-apps/apple.tsx @@ -0,0 +1,63 @@ +/** + * Apple Podcasts directory entry. + */ + +import { useId } from '@wordpress/element'; +import type { PodcastApp } from './types'; + +const AppleLogo = () => { + const gradientId = useId(); + return ( + + ); +}; + +export const apple: PodcastApp = { + id: 'apple', + name: 'Apple Podcasts', + Logo: AppleLogo, + submitUrl: 'https://podcastsconnect.apple.com/', + learnMoreUrl: 'https://podcasters.apple.com/support/897-submit-a-show', + showHosts: [ 'podcasts.apple.com' ], +}; diff --git a/projects/packages/podcast/src/dashboard/podcast-apps/index.ts b/projects/packages/podcast/src/dashboard/podcast-apps/index.ts new file mode 100644 index 000000000000..213179d3a084 --- /dev/null +++ b/projects/packages/podcast/src/dashboard/podcast-apps/index.ts @@ -0,0 +1,28 @@ +/** + * Podcast directory app registry. + * + * Each entry is a `PodcastApp` config that combines the directory's metadata + * with its logo and any per-app UI overrides (extra step content, full modal + * replacement, etc.). Entries are ordered as they appear in the Distribution + * tab — alphabetical except Pocket Casts surfaces first since it's + * historically the smoothest one-click submit on wpcom. + */ + +import { amazon } from './amazon'; +import { apple } from './apple'; +import { pocketcasts } from './pocketcasts'; +import { podcastindex } from './podcastindex'; +import { spotify } from './spotify'; +import { youtube } from './youtube'; +import type { PodcastApp } from './types'; + +export const PODCAST_APPS: readonly PodcastApp[] = [ + pocketcasts, + apple, + spotify, + youtube, + amazon, + podcastindex, +] as const; + +export type { PodcastApp, PodcastAppModalProps } from './types'; diff --git a/projects/packages/podcast/src/dashboard/podcast-apps/pocketcasts.tsx b/projects/packages/podcast/src/dashboard/podcast-apps/pocketcasts.tsx new file mode 100644 index 000000000000..b327700b8942 --- /dev/null +++ b/projects/packages/podcast/src/dashboard/podcast-apps/pocketcasts.tsx @@ -0,0 +1,48 @@ +/** + * Pocket Casts directory entry. + * + * Carries a `step2Extra` slot reminding listeners to choose the Public option + * during submission. When the one-click API submission lands, swap the + * `Modal` field in to replace the default 3-step flow entirely. + */ + +// eslint-disable-next-line @wordpress/no-unsafe-wp-apis +import { __experimentalText as Text } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import type { PodcastApp } from './types'; + +const PocketCastsLogo = () => ( + +); + +export const pocketcasts: PodcastApp = { + id: 'pocketcasts', + name: 'Pocket Casts', + Logo: PocketCastsLogo, + submitUrl: 'https://pocketcasts.com/submit', + learnMoreUrl: 'https://support.pocketcasts.com/knowledge-base/submitting-podcasts/', + showHosts: [ 'pca.st', 'pocketcasts.com' ], + step2Extra: ( + + { __( + 'Choose the Public option, since this feed is for your listeners.', + 'jetpack-podcast' + ) } + + ), +}; diff --git a/projects/packages/podcast/src/dashboard/podcast-apps/podcastindex.tsx b/projects/packages/podcast/src/dashboard/podcast-apps/podcastindex.tsx new file mode 100644 index 000000000000..84694de99478 --- /dev/null +++ b/projects/packages/podcast/src/dashboard/podcast-apps/podcastindex.tsx @@ -0,0 +1,31 @@ +/** + * Podcast Index directory entry. + */ + +import type { PodcastApp } from './types'; + +const PodcastIndexLogo = () => ( + +); + +export const podcastindex: PodcastApp = { + id: 'podcastindex', + name: 'Podcast Index', + Logo: PodcastIndexLogo, + submitUrl: 'https://podcastindex.org/add', + showHosts: [ 'podcastindex.org' ], +}; diff --git a/projects/packages/podcast/src/dashboard/podcast-apps/spotify.tsx b/projects/packages/podcast/src/dashboard/podcast-apps/spotify.tsx new file mode 100644 index 000000000000..2a6c2e9dfb02 --- /dev/null +++ b/projects/packages/podcast/src/dashboard/podcast-apps/spotify.tsx @@ -0,0 +1,35 @@ +/** + * Spotify directory entry. + */ + +import type { PodcastApp } from './types'; + +const SpotifyLogo = () => ( + +); + +export const spotify: PodcastApp = { + id: 'spotify', + name: 'Spotify', + Logo: SpotifyLogo, + submitUrl: 'https://creators.spotify.com/', + learnMoreUrl: + 'https://support.spotify.com/creators/article/claiming-your-podcast-on-spotify-for-creators/', + showHosts: [ 'open.spotify.com' ], +}; diff --git a/projects/packages/podcast/src/dashboard/podcast-apps/types.ts b/projects/packages/podcast/src/dashboard/podcast-apps/types.ts new file mode 100644 index 000000000000..8290e8ee2106 --- /dev/null +++ b/projects/packages/podcast/src/dashboard/podcast-apps/types.ts @@ -0,0 +1,44 @@ +/** + * Shared types for the podcast directory app registry. + * + * Each podcast app (Apple, Spotify, etc.) lives in its own file under + * `podcast-apps/` and exports a `PodcastApp` config. The data-only fields + * cover what every app needs (id, name, logo, submit URL); the optional + * fields let an app contribute UI without forking the whole flow. + * + * `step2Extra` is extra JSX rendered inside Step 2 of the default modal + * (e.g. "Choose the Public option" for Pocket Casts). `Modal` is a full + * replacement for the default 3-step submit modal, used when an app's + * flow doesn't fit "copy feed URL, visit page, paste URL back" (e.g. + * one-click API submission). + */ + +import type { PodcatcherId } from '../types'; +import type { ComponentType, ReactNode } from 'react'; + +export interface PodcastApp { + id: PodcatcherId; + name: string; + Logo: ComponentType; + /** External submission page; ignored when `Modal` is set. */ + submitUrl: string; + /** Optional "learn more" link shown inside the default modal. */ + learnMoreUrl?: string; + /** + * Hostnames a saved show URL is allowed to live on (lowercase, no `www.`). + * Mirrors `SHOW_URL_HOSTS` in src/rest/class-settings-rest.php so the + * client can reject obviously-wrong URLs before round-tripping; the + * server enforces the same allowlist authoritatively. + */ + showHosts: readonly string[]; + /** Optional content rendered below the standard Step 2 copy. */ + step2Extra?: ReactNode; + /** Override the default 3-step submit modal entirely. */ + Modal?: ComponentType< PodcastAppModalProps >; +} + +export interface PodcastAppModalProps { + app: PodcastApp; + feedUrl: string; + onClose: () => void; +} diff --git a/projects/packages/podcast/src/dashboard/podcast-apps/youtube.tsx b/projects/packages/podcast/src/dashboard/podcast-apps/youtube.tsx new file mode 100644 index 000000000000..a41e45c68d5b --- /dev/null +++ b/projects/packages/podcast/src/dashboard/podcast-apps/youtube.tsx @@ -0,0 +1,31 @@ +/** + * YouTube directory entry. + */ + +import type { PodcastApp } from './types'; + +const YouTubeLogo = () => ( + +); + +export const youtube: PodcastApp = { + id: 'youtube', + name: 'YouTube', + Logo: YouTubeLogo, + submitUrl: 'https://studio.youtube.com', + learnMoreUrl: 'https://support.google.com/youtube/answer/13973017', + showHosts: [ 'youtube.com', 'm.youtube.com', 'youtu.be', 'music.youtube.com' ], +}; diff --git a/projects/packages/podcast/src/dashboard/script-data.ts b/projects/packages/podcast/src/dashboard/script-data.ts new file mode 100644 index 000000000000..f1c62965b82d --- /dev/null +++ b/projects/packages/podcast/src/dashboard/script-data.ts @@ -0,0 +1,38 @@ +/** + * Reads the `podcast` slice injected into `JetpackScriptData` by class-settings.php. + * + * The script data is static for the lifetime of the page, so we resolve it once + * on first read and hand back the same frozen object on every subsequent call. + */ + +import { getScriptData } from '@automattic/jetpack-script-data'; +import type { PodcastScriptData } from './types'; + +const DEFAULTS: PodcastScriptData = { + categoryId: 0, + feedUrl: '', + siteUrl: '', + adminUrl: '', + editPostUrlBase: '', + newPostUrl: '', + mediaLibraryUrl: '', + userEmail: '', + dateFormat: 'F j, Y', +}; + +let cached: PodcastScriptData | null = null; + +/** + * Resolve the podcast script-data slice on first call and return the same + * frozen object on every subsequent call. + * + * @return The page's podcast script data, with defaults filled in for missing keys. + */ +export function getPodcastScriptData(): PodcastScriptData { + if ( cached ) { + return cached; + } + const data = getScriptData() as { podcast?: Partial< PodcastScriptData > }; + cached = Object.freeze( { ...DEFAULTS, ...data.podcast } ) as PodcastScriptData; + return cached; +} diff --git a/projects/packages/podcast/src/dashboard/style.scss b/projects/packages/podcast/src/dashboard/style.scss new file mode 100644 index 000000000000..5f67bbf19676 --- /dev/null +++ b/projects/packages/podcast/src/dashboard/style.scss @@ -0,0 +1,298 @@ +/** + * Jetpack Podcast SPA styles. + * + * Adapted from `client/my-sites/podcast/style.scss` in Calypso. Calypso-only + * design tokens (`--wpds-*` / `--studio-*`) have been replaced with literal + * fallbacks so the styles work in plain wp-admin without any token plumbing. + * Class names are preserved (`podcast__*`) so the hand-off back to Calypso (if + * any) remains a near-no-op. + */ + +@use "@wordpress/dataviews/build-style/style.css"; + +.podcast__loading { + display: flex; + justify-content: center; + padding: 48px 0; +} + +.podcast__tabs-bar { + border-block-end: 1px solid #dcdcde; + margin-block-end: 24px; +} + +.podcast__tabs [role="tab"] { + padding-inline: 16px; +} + +.podcast__tab-content { + padding-block: 8px; +} + +.podcast__section-header { + display: flex; + flex-direction: column; + gap: 4px; + margin-block-end: 24px; +} + +.podcast__section-heading { + margin: 0; + font-size: 24px; + font-weight: 400; + line-height: 1.2; + color: #1e1e1e; +} + +.podcast__section-description { + margin: 0; + font-size: 13px; + line-height: 1.4; + color: #757575; +} + +.podcast__card-title { + margin: 0; + font-size: 15px; + font-weight: 600; + color: #1e1e1e; +} + +.podcast__settings { + max-inline-size: 800px; +} + +.podcast__settings-issues { + margin: 4px 0 0; + padding-inline-start: 18px; + list-style: disc; +} + +.podcast__cover-control { + display: grid; + grid-template-columns: 220px 1fr; + column-gap: 16px; + row-gap: 8px; + align-items: start; +} + +.podcast__cover-preview { + grid-column: 1; + grid-row: 1 / span 2; + width: 220px; + height: 220px; + overflow: hidden; + border: 1px solid #dcdcde; + border-radius: 4px; + background: #f6f7f7; + display: flex; + align-items: center; + justify-content: center; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.podcast__cover-placeholder { + font-size: 12px; + color: #757575; +} + +.podcast__cover-actions { + grid-column: 2; + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.podcast__cover-warning { + grid-column: 2; + margin: 0; + font-size: 12px; + color: #b26200; +} + +.podcast__directory-list { + list-style: none; + margin: 0; + padding: 0; +} + +.podcast__directory-row { + padding-block: 12px; + border-block-end: 1px solid #dcdcde; + + &:last-child { + border-block-end: none; + } +} + +.podcast__feed-copy { + display: flex; + align-items: center; + gap: 8px; +} + +.podcast__feed-copy-input { + flex: 1; + min-width: 0; + padding: 6px 10px; + font-family: ui-monospace, SFMono-Regular, monospace; + font-size: 13px; + border: 1px solid #dcdcde; + border-radius: 4px; + background: #f6f7f7; + color: #1e1e1e; +} + +.podcast__welcome-hero { + padding: 40px; + border-radius: 8px; + background: linear-gradient(135deg, #f0f6fc 0%, #fdf3fc 100%); +} + +.podcast__welcome-title { + margin: 0; + font-size: 32px; + font-weight: 400; + line-height: 1.15; + letter-spacing: -0.01em; +} + +.podcast__welcome-hero-copy { + max-width: 640px; +} + +.podcast__welcome-benefit-icon { + display: inline-flex; + align-items: center; + color: #1e1e1e; + + svg { + inline-size: 24px; + block-size: 24px; + fill: currentColor; + } +} + +.podcast__welcome-steps { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr)); + gap: 24px; +} + +.podcast__welcome-step { + display: flex; + flex-direction: column; + gap: 8px; +} + +.podcast__welcome-step-circle { + display: grid; + place-items: center; + inline-size: 32px; + block-size: 32px; + border-radius: 50%; + background: #2c3338; + color: #fff; + font-size: 14px; + font-weight: 600; +} + +.podcast__episode-thumb { + width: 56px; + height: 56px; + object-fit: cover; + border-radius: 4px; +} + +.podcast__episode-thumb--placeholder { + background: #f0f0f1; + border: 1px dashed #dcdcde; +} + +.podcast__empty-state { + padding: 48px 16px; + text-align: center; + color: #757575; +} + +.podcast__submit-modal .components-modal__content { + max-width: 520px; +} + +.podcast__submit-steps { + list-style: none; + margin: 0; + padding: 0; +} + +.podcast__submit-step { + padding: 16px; + background: #f6f7f7; + border-radius: 8px; +} + +.podcast__submit-step-title { + margin: 0; + font-size: 14px; + font-weight: 600; + color: #1e1e1e; +} + +.podcast__submit-step-row { + + @media ( max-width: 600px ) { + flex-direction: column; + align-items: stretch; + } +} + +.podcast__submit-step-field { + flex: 1; + min-width: 0; +} + +.podcast__submit-copy-button { + min-inline-size: 8rem; + align-self: flex-start; + justify-content: flex-start; +} + +.podcast__submit-step-notice { + margin-block: 8px 0; + margin-inline: 0; +} + +.podcast__submit-step-saved { + flex: 1; + min-width: 0; + padding: 8px 12px; + background: #fff; + border: 1px solid #ddd; + border-radius: 4px; +} + +.podcast__submit-step-saved-icon { + flex-shrink: 0; + color: #007a3d; + + // Gutenberg's Icon paths use fill="currentColor"; force any stray fill="none" + // or hard-coded colors to inherit so the success token actually wins. + svg { + fill: currentColor; + } +} + +.podcast__submit-step-saved-url { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} diff --git a/projects/packages/podcast/src/dashboard/tabs/distribution.tsx b/projects/packages/podcast/src/dashboard/tabs/distribution.tsx new file mode 100644 index 000000000000..f02a1d15101f --- /dev/null +++ b/projects/packages/podcast/src/dashboard/tabs/distribution.tsx @@ -0,0 +1,177 @@ +/** + * Distribution tab — copy-the-feed CTA + per-directory submit buttons. + * + * Each podcast directory ("podcast app") lives in its own self-contained + * file under `../podcast-apps/`. This tab just renders the registry and + * delegates the submission flow to either the default 3-step `SubmitModal` + * or, when an app sets `Modal`, that app's custom modal. + */ + +import { + Button, + Card, + CardBody, + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalHStack as HStack, + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalText as Text, + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalVStack as VStack, +} from '@wordpress/components'; +import { useCopyToClipboard } from '@wordpress/compose'; +import { useCallback, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { check, copy } from '@wordpress/icons'; +import SubmitModal from '../components/submit-modal'; +import { usePodcastSettings } from '../hooks/use-podcast-settings'; +import { PODCAST_APPS } from '../podcast-apps'; +import { getPodcastScriptData } from '../script-data'; +import type { PodcatcherId } from '../types'; +import type { FocusEvent } from 'react'; + +const selectOnFocus = ( event: FocusEvent< HTMLInputElement > ) => { + event.currentTarget.select(); +}; + +// Pre-resolved so the i18n-check-webpack-plugin validator sees two distinct +// __() calls in the bundled output instead of __(cond?'a':'b'). Hoisted out of +// the component since these strings don't depend on props or state. +const COPIED_LABEL = __( 'Copied!', 'jetpack-podcast' ); +const COPY_LINK_LABEL = __( 'Copy link', 'jetpack-podcast' ); + +const FeedCopyField = ( { value }: { value: string } ) => { + const [ copied, setCopied ] = useState( false ); + + const copyRef = useCopyToClipboard< HTMLButtonElement >( value, () => { + setCopied( true ); + setTimeout( () => setCopied( false ), 2000 ); + } ); + + return ( + + + + + ); +}; + +const DistributionTab = () => { + const { data: settings } = usePodcastSettings(); + const scriptData = getPodcastScriptData(); + const feedUrl = scriptData.feedUrl; + const isEnabled = !! settings?.podcasting_category_id; + + const [ activeId, setActiveId ] = useState< PodcatcherId | null >( null ); + const activeApp = PODCAST_APPS.find( a => a.id === activeId ) ?? null; + + const handleClose = useCallback( () => { + setActiveId( null ); + }, [] ); + + const ActiveModal = activeApp?.Modal ?? SubmitModal; + + return ( + <> +
+

{ __( 'Distribution', 'jetpack-podcast' ) }

+

+ { __( + 'Submit your feed to podcast directories and track where your show is listed.', + 'jetpack-podcast' + ) } +

+
+ + + + + + +

{ __( 'RSS feed', 'jetpack-podcast' ) }

+ + { __( + 'Copy this URL, then submit it to each directory below to publish your podcast.', + 'jetpack-podcast' + ) } + +
+ { isEnabled && feedUrl ? ( + + ) : ( + + { __( + 'Set your podcast category to generate the feed URL you can submit to directories.', + 'jetpack-podcast' + ) } + + ) } +
+ + + +

+ { __( 'Podcast directories', 'jetpack-podcast' ) } +

+ + { __( + 'Submit your podcast to the directories below where you want it to appear. Most take a few days to go live.', + 'jetpack-podcast' + ) } + +
+ + { PODCAST_APPS.map( app => { + const { Logo } = app; + return ( + + + + { app.name } + + + + ); + } ) } + +
+
+
+
+ + { activeApp && } + + ); +}; + +export default DistributionTab; diff --git a/projects/packages/podcast/src/dashboard/tabs/episodes.tsx b/projects/packages/podcast/src/dashboard/tabs/episodes.tsx new file mode 100644 index 000000000000..bf5c938b3794 --- /dev/null +++ b/projects/packages/podcast/src/dashboard/tabs/episodes.tsx @@ -0,0 +1,275 @@ +/** + * Episodes tab — DataViews list of posts in the configured podcast category. + * + * Server-side ordering covers `date` and `title`; duration and plays come from + * the wpcom `podcast-stats/episode-totals` endpoint and are merged client-side, + * so those columns are display-only (not sortable). + */ + +import { DataViews, type Action, type View, type ViewTable } from '@wordpress/dataviews'; +import { useMemo, useState } from '@wordpress/element'; +import { decodeEntities } from '@wordpress/html-entities'; +import { __ } from '@wordpress/i18n'; +import { useEpisodeStatsQuery } from '../hooks/use-episode-stats-query'; +import { useEpisodesQuery } from '../hooks/use-episodes-query'; +import { usePodcastSettings } from '../hooks/use-podcast-settings'; +import { getPodcastScriptData } from '../script-data'; +import type { EpisodeStats } from '../types'; + +interface EpisodeRow { + id: number; + title: string; + date: string; + status: string; + link: string; + featuredMediaUrl: string; + playsAll: number; + durationSeconds: number | null; +} + +const formatDuration = ( seconds: number | null ): string => { + if ( seconds == null || seconds <= 0 ) { + return '—'; + } + const h = Math.floor( seconds / 3600 ); + const m = Math.floor( ( seconds % 3600 ) / 60 ); + const s = seconds % 60; + const pad = ( n: number ) => String( n ).padStart( 2, '0' ); + return h > 0 ? `${ h }:${ pad( m ) }:${ pad( s ) }` : `${ m }:${ pad( s ) }`; +}; + +const defaultView: ViewTable = { + type: 'table', + titleField: 'title', + mediaField: 'media', + showTitle: true, + showMedia: true, + fields: [ 'duration', 'plays', 'date', 'status' ], + page: 1, + perPage: 20, + sort: { field: 'date', direction: 'desc' }, + layout: { + styles: { + media: { width: '72px' }, + title: { width: 'auto', minWidth: '260px' }, + duration: { width: '110px' }, + plays: { width: '120px' }, + date: { width: '150px' }, + status: { width: '140px' }, + }, + }, +}; + +const getEpisodeRowId = ( item: EpisodeRow ) => String( item.id ); + +const STATUS_LABELS: Record< string, string > = { + publish: __( 'Published', 'jetpack-podcast' ), + future: __( 'Scheduled', 'jetpack-podcast' ), + draft: __( 'Draft', 'jetpack-podcast' ), + pending: __( 'Pending review', 'jetpack-podcast' ), + private: __( 'Private', 'jetpack-podcast' ), +}; + +const EpisodesTab = () => { + const { data: settings } = usePodcastSettings(); + const categoryId = settings?.podcasting_category_id ?? 0; + const scriptData = getPodcastScriptData(); + + const [ view, setView ] = useState< View >( defaultView ); + + const queryArgs = useMemo( () => { + const sortField = view.sort?.field; + const orderBy = sortField === 'title' || sortField === 'date' ? sortField : 'date'; + const order = view.sort?.direction === 'asc' ? 'asc' : 'desc'; + const statusFilter = view.filters?.find( filter => filter.field === 'status' ); + const status = + typeof statusFilter?.value === 'string' && statusFilter.value ? statusFilter.value : 'any'; + + return { + categoryId, + page: view.page ?? 1, + perPage: view.perPage ?? 20, + orderBy: orderBy as 'date' | 'title', + order: order as 'asc' | 'desc', + search: view.search ?? '', + status, + }; + }, [ categoryId, view ] ); + + const { data: episodesPage, isLoading } = useEpisodesQuery( queryArgs ); + // Wrap in useMemo so the array identity is stable when episodesPage hasn't + // changed — otherwise the `?? []` fallback creates a new array on every + // render and invalidates dependent useMemos below. + const posts = useMemo( () => episodesPage?.episodes ?? [], [ episodesPage ] ); + + const postIds = useMemo( () => posts.map( p => p.id ), [ posts ] ); + const { data: stats = [] } = useEpisodeStatsQuery( postIds ); + + const statsByPostId = useMemo( () => { + const m = new Map< number, EpisodeStats >(); + for ( const s of stats ) { + m.set( s.post_id, s ); + } + return m; + }, [ stats ] ); + + const rows = useMemo< EpisodeRow[] >( () => { + return posts.map( post => { + const media = post._embedded?.[ 'wp:featuredmedia' ]?.[ 0 ]; + const sizes = media?.media_details?.sizes; + const thumbnail = + sizes?.thumbnail?.source_url ?? sizes?.medium?.source_url ?? media?.source_url ?? ''; + const stat = statsByPostId.get( post.id ); + return { + id: post.id, + title: decodeEntities( post.title?.rendered ?? '' ), + date: post.date, + status: post.status, + link: post.link, + featuredMediaUrl: thumbnail, + playsAll: stat?.plays_all_time ?? 0, + durationSeconds: stat?.duration_seconds ?? null, + }; + } ); + }, [ posts, statsByPostId ] ); + + const fields = useMemo( + () => [ + { + id: 'media', + label: __( 'Featured image', 'jetpack-podcast' ), + getValue: ( { item }: { item: EpisodeRow } ) => item.featuredMediaUrl, + render: ( { item }: { item: EpisodeRow } ) => + item.featuredMediaUrl ? ( + + ) : ( +