From 05d0c9b22148adf483d7a7c76b57bbb9c2fc849e Mon Sep 17 00:00:00 2001 From: Tony Arcangelini Date: Mon, 4 May 2026 22:23:14 +0200 Subject: [PATCH 01/17] Podcast: new wp-admin module for Simple and Atomic sites --- pnpm-lock.yaml | 114 +++- projects/packages/podcast/.gitattributes | 21 + projects/packages/podcast/.gitignore | 5 + projects/packages/podcast/.phpcs.dir.xml | 24 + projects/packages/podcast/babel.config.js | 10 + .../podcast/changelog/add-podcast-package | 4 + projects/packages/podcast/composer.json | 66 +++ projects/packages/podcast/package.json | 69 +++ projects/packages/podcast/phpunit.11.xml.dist | 1 + projects/packages/podcast/phpunit.12.xml.dist | 1 + projects/packages/podcast/phpunit.8.xml.dist | 1 + projects/packages/podcast/phpunit.9.xml.dist | 17 + .../packages/podcast/src/class-podcast.php | 145 +++++ .../packages/podcast/src/class-settings.php | 181 +++++++ .../packages/podcast/src/dashboard/api.ts | 293 ++++++++++ .../packages/podcast/src/dashboard/app.tsx | 190 +++++++ .../components/cover-image-control.tsx | 130 +++++ .../src/dashboard/components/logos.tsx | 156 ++++++ .../src/dashboard/components/submit-modal.tsx | 246 +++++++++ .../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 | 68 +++ .../src/dashboard/hooks/use-podcatcher-url.ts | 63 +++ .../packages/podcast/src/dashboard/index.tsx | 12 + .../podcast/src/dashboard/script-data.ts | 38 ++ .../packages/podcast/src/dashboard/style.scss | 266 +++++++++ .../src/dashboard/tabs/distribution.tsx | 268 +++++++++ .../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 | 65 +++ .../podcast/src/feed/class-customize-feed.php | 266 +++++++++ .../podcast/src/rest/class-settings-rest.php | 186 +++++++ projects/packages/podcast/tsconfig.json | 4 + projects/packages/podcast/webpack.config.js | 64 +++ .../jetpack/changelog/add-podcast-module | 4 + projects/plugins/jetpack/composer.json | 1 + projects/plugins/jetpack/composer.lock | 75 ++- projects/plugins/jetpack/load-jetpack.php | 4 + 41 files changed, 4431 insertions(+), 3 deletions(-) create mode 100644 projects/packages/podcast/.gitattributes create mode 100644 projects/packages/podcast/.gitignore create mode 100644 projects/packages/podcast/.phpcs.dir.xml create mode 100644 projects/packages/podcast/babel.config.js 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 120000 projects/packages/podcast/phpunit.11.xml.dist create mode 120000 projects/packages/podcast/phpunit.12.xml.dist create mode 120000 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/src/class-settings.php 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/logos.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/hooks/use-podcatcher-url.ts create mode 100644 projects/packages/podcast/src/dashboard/index.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-customize-feed.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-module diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a70330cdfa75..d33f44d4f641 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3548,6 +3548,101 @@ importers: specifier: 6.0.1 version: 6.0.1(webpack@5.105.2) + 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/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)(stylelint@17.7.0) + '@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/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)(stylelint@17.7.0) + '@wordpress/url': + specifier: 4.44.0 + version: 4.44.0 + devDependencies: + '@automattic/babel-plugin-replace-textdomain': + specifier: workspace:* + 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 + '@babel/runtime': + specifier: 7.29.2 + version: 7.29.2 + '@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 + 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 + version: 18.3.1 + react-dom: + specifier: 18.3.1 + version: 18.3.1(react@18.3.1) + projects/packages/post-list: dependencies: '@wordpress/i18n': @@ -9460,6 +9555,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==} @@ -9474,6 +9572,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'} @@ -18089,7 +18192,7 @@ 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) @@ -18160,7 +18263,7 @@ snapshots: '@automattic/i18n-utils': 1.2.3 '@automattic/typography': 1.0.0 '@automattic/viewport': 1.1.0 - '@tanstack/react-query': 5.90.8(react@18.3.1) + '@tanstack/react-query': 5.96.1(react@18.3.1) '@wordpress/base-styles': 6.20.0 '@wordpress/components': 32.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@wordpress/data': 10.44.0(react@18.3.1) @@ -21964,6 +22067,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)': @@ -21977,6 +22082,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 diff --git a/projects/packages/podcast/.gitattributes b/projects/packages/podcast/.gitattributes new file mode 100644 index 000000000000..841674d19825 --- /dev/null +++ b/projects/packages/podcast/.gitattributes @@ -0,0 +1,21 @@ +# Files not needed to be distributed in the package. +.gitattributes export-ignore +.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 +changelog/** production-exclude +.phpcs.dir.xml production-exclude +tests/** production-exclude +.phpcsignore 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/.gitignore b/projects/packages/podcast/.gitignore new file mode 100644 index 000000000000..cf368200ac9d --- /dev/null +++ b/projects/packages/podcast/.gitignore @@ -0,0 +1,5 @@ +vendor/ +node_modules/ +.cache/ +build/ +composer.lock diff --git a/projects/packages/podcast/.phpcs.dir.xml b/projects/packages/podcast/.phpcs.dir.xml new file mode 100644 index 000000000000..ec42c8aea1f0 --- /dev/null +++ b/projects/packages/podcast/.phpcs.dir.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + 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-podcast-package b/projects/packages/podcast/changelog/add-podcast-package new file mode 100644 index 000000000000..9c82a1b6cccb --- /dev/null +++ b/projects/packages/podcast/changelog/add-podcast-package @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Initial release of the Jetpack Podcast package: wp-admin SPA, RSS feed customization, and site-settings REST integration. Available on Simple and Atomic sites only. diff --git a/projects/packages/podcast/composer.json b/projects/packages/podcast/composer.json new file mode 100644 index 000000000000..d2bf7195302f --- /dev/null +++ b/projects/packages/podcast/composer.json @@ -0,0 +1,66 @@ +{ + "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-admin-ui": "@dev", + "automattic/jetpack-assets": "@dev", + "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": { + "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" + ], + "typecheck": "pnpm run typecheck" + }, + "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}" + } + }, + "suggest": { + "automattic/jetpack-autoloader": "Allow for better interoperability with other plugins that use this package." + } +} diff --git a/projects/packages/podcast/package.json b/projects/packages/podcast/package.json new file mode 100644 index 000000000000..16b5f0ac71f7 --- /dev/null +++ b/projects/packages/podcast/package.json @@ -0,0 +1,69 @@ +{ + "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", + "type": "module", + "scripts": { + "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/", + "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/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/icons": "12.2.0", + "@wordpress/notices": "5.44.0", + "@wordpress/ui": "0.11.0", + "@wordpress/url": "4.44.0" + }, + "devDependencies": { + "@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", + "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" + } +} diff --git a/projects/packages/podcast/phpunit.11.xml.dist b/projects/packages/podcast/phpunit.11.xml.dist new file mode 120000 index 000000000000..707bde67863c --- /dev/null +++ b/projects/packages/podcast/phpunit.11.xml.dist @@ -0,0 +1 @@ +phpunit.9.xml.dist \ No newline at end of file diff --git a/projects/packages/podcast/phpunit.12.xml.dist b/projects/packages/podcast/phpunit.12.xml.dist new file mode 120000 index 000000000000..9fdb7a2c745c --- /dev/null +++ b/projects/packages/podcast/phpunit.12.xml.dist @@ -0,0 +1 @@ +phpunit.11.xml.dist \ No newline at end of file diff --git a/projects/packages/podcast/phpunit.8.xml.dist b/projects/packages/podcast/phpunit.8.xml.dist new file mode 120000 index 000000000000..707bde67863c --- /dev/null +++ b/projects/packages/podcast/phpunit.8.xml.dist @@ -0,0 +1 @@ +phpunit.9.xml.dist \ No newline at end of file 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..d02b979e78f3 --- /dev/null +++ b/projects/packages/podcast/src/class-podcast.php @@ -0,0 +1,145 @@ +is_wpcom_simple() && ! $host->is_woa_site() ) { + return; + } + + $legacy_active = class_exists( 'Automattic_Podcasting', false ); + + // REST settings filters need to register early so they're available for any /wp/v2/settings request. + // Skipped when the legacy code is active — it already registers equivalent filters. + if ( ! $legacy_active ) { + Settings_REST::init(); + } + + // 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. + */ + public static function maybe_load_feed_customization() { + if ( is_feed() && is_category( self::get_category_id() ) ) { + 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 new file mode 100644 index 000000000000..ec20cfb01760 --- /dev/null +++ b/projects/packages/podcast/src/class-settings.php @@ -0,0 +1,181 @@ + Podcast wp-admin screen. + */ +class Settings { + + const MENU_SLUG = 'jetpack-podcast'; + + /** + * Whether the class has been initialized. + * + * @var bool + */ + private static $initialized = false; + + /** + * Init Podcast Settings if it wasn't already. + */ + public static function init() { + if ( self::$initialized ) { + return; + } + self::$initialized = true; + ( new self() )->init_hooks(); + } + + /** + * Subscribe to necessary hooks. + */ + public function init_hooks() { + $host = new Host(); + + // On wpcom Simple, the Jetpack menu is created at priority 999999 by wpcom-admin-menu.php. + // Mirror the Newsletter pattern: skip here and let wpcom-admin-menu call add_wp_admin_submenu(). + if ( $host->is_wpcom_simple() ) { + return; + } + + // Priority 999 so we register before Admin_Menu::admin_menu_hook_callback runs at 1000. + add_action( 'admin_menu', array( $this, 'add_wp_admin_menu' ), 999 ); + } + + /** + * Register the Podcast submenu under the Jetpack menu (Atomic / standalone Jetpack path). + * + * Not called on Simple sites — see add_wp_admin_submenu(). + */ + public function add_wp_admin_menu() { + $host = new Host(); + + // Atomic uses native add_submenu_page so the menu nests under the wpcom-managed Jetpack menu. + if ( $host->is_woa_site() ) { + $page_suffix = add_submenu_page( + 'jetpack', + /** "Podcast" is a product name, do not translate. */ + 'Podcast', + 'Podcast', + 'manage_options', + self::MENU_SLUG, + array( $this, 'render' ) + ); + } else { + $page_suffix = Admin_Menu::add_menu( + /** "Podcast" is a product name, do not translate. */ + 'Podcast', + 'Podcast', + 'manage_options', + self::MENU_SLUG, + array( $this, 'render' ), + 12 + ); + } + + if ( $page_suffix ) { + add_action( 'load-' . $page_suffix, array( $this, 'admin_init' ) ); + } + } + + /** + * Add the Podcast submenu directly under the Jetpack menu on Simple sites. + * + * Called from wpcom-admin-menu.php at priority 999999 once the Jetpack menu exists. + */ + public function add_wp_admin_submenu() { + $page_suffix = add_submenu_page( + 'jetpack', + /** "Podcast" is a product name, do not translate. */ + 'Podcast', + 'Podcast', + 'manage_options', + self::MENU_SLUG, + array( $this, 'render' ) + ); + + if ( $page_suffix ) { + add_action( 'load-' . $page_suffix, array( $this, 'admin_init' ) ); + } + } + + /** + * Admin init actions. Triggered only when the Podcast page is being loaded. + */ + public function admin_init() { + add_filter( 'jetpack_admin_js_script_data', array( $this, 'add_script_data' ) ); + add_action( 'admin_enqueue_scripts', array( $this, 'load_admin_scripts' ) ); + } + + /** + * Inject podcast-specific data into the global JetpackScriptData object. + * + * @param array $data Existing script data. + * @return array + */ + public 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' ), + ); + + return $data; + } + + /** + * Enqueue the podcast SPA bundle. + * + * The asset.php manifest emitted by webpack already declares every + * + * @wordpress/* dependency our bundle pulls in, so the only manual entry + * we add here is `jetpack-script-data` (a Jetpack-specific dep webpack + * doesn't know to extract). + */ + public function load_admin_scripts() { + Assets::register_script( + 'jetpack-podcast', + '../build/podcast.js', + __FILE__, + array( + 'in_footer' => true, + 'textdomain' => 'jetpack-podcast', + 'enqueue' => true, + 'dependencies' => array( 'jetpack-script-data' ), + ) + ); + } + + /** + * Render the Podcast SPA mount point. + */ + public 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', +]; + +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 { + 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: Partial< PodcastSettings > +): 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..01a6ad2b1814 --- /dev/null +++ b/projects/packages/podcast/src/dashboard/components/cover-image-control.tsx @@ -0,0 +1,130 @@ +/** + * Cover image picker for the podcast Settings tab. + * + * Wraps the wp-admin media frame (`wp.media`) 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, Spinner } from '@wordpress/components'; +import { useCallback, useEffect, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +interface CoverImageControlProps { + imageUrl: string; + imageId: number; + onSelect: ( imageId: number, imageUrl: string ) => void; + onRemove: () => void; + disabled?: boolean; +} + +interface MediaAttachment { + id: number; + url: string; + width?: number; + height?: number; +} + +interface MediaFrame { + on: ( event: string, handler: ( ...args: unknown[] ) => void ) => void; + open: () => void; + state: () => { get( key: 'selection' ): { first(): { toJSON(): MediaAttachment } } }; +} + +type WpMedia = ( opts: Record< string, unknown > ) => MediaFrame; + +const getWpMedia = (): WpMedia | undefined => { + const wp = ( window as unknown as { wp?: { media?: WpMedia } } ).wp; + return wp?.media; +}; + +const COVER_MIN = 1400; +const COVER_MAX = 3000; + +const validate = ( att: MediaAttachment ): 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 [ frame, setFrame ] = useState< MediaFrame | null >( null ); + const [ warning, setWarning ] = useState< string | null >( null ); + + useEffect( () => { + const wpMedia = getWpMedia(); + if ( ! wpMedia ) { + return; + } + const mediaFrame = wpMedia( { + title: __( 'Select a podcast cover image', 'jetpack-podcast' ), + button: { text: __( 'Use this image', 'jetpack-podcast' ) }, + library: { type: 'image' }, + multiple: false, + } ); + + mediaFrame.on( 'select', () => { + const selection = mediaFrame.state().get( 'selection' ).first().toJSON(); + setWarning( validate( selection ) ); + onSelect( selection.id, selection.url ); + } ); + + setFrame( mediaFrame ); + }, [ onSelect ] ); + + const open = useCallback( () => { + frame?.open(); + }, [ frame ] ); + + const hasImage = !! imageUrl || imageId > 0; + + return ( +
+
+ { imageUrl ? ( + { + ) : ( + + { frame ? __( 'No image set', 'jetpack-podcast' ) : } + + ) } +
+
+ + { hasImage && ( + + ) } +
+ { warning &&

{ warning }

} +
+ ); +}; + +export default CoverImageControl; diff --git a/projects/packages/podcast/src/dashboard/components/logos.tsx b/projects/packages/podcast/src/dashboard/components/logos.tsx new file mode 100644 index 000000000000..a2f660ccc4fb --- /dev/null +++ b/projects/packages/podcast/src/dashboard/components/logos.tsx @@ -0,0 +1,156 @@ +/** + * Brand SVG logos for the podcast Distribution tab. + * + * Lifted unmodified from Calypso's `client/my-sites/podcast/components/logos.tsx`. + * Each logo is rendered at 40×40 inside a flex row; alt text is suppressed via + * aria-hidden because the directory name is rendered alongside. + */ + +import { useId } from '@wordpress/element'; + +export const LogoApple = () => { + const gradientId = useId(); + return ( + + ); +}; + +export const LogoSpotify = () => ( + +); + +export const LogoYouTube = () => ( + +); + +export const LogoAmazon = () => ( + +); + +export const LogoPocketCasts = () => ( + +); + +export const LogoPodcastIndex = () => ( + +); 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..a70722829dc3 --- /dev/null +++ b/projects/packages/podcast/src/dashboard/components/submit-modal.tsx @@ -0,0 +1,246 @@ +/** + * Three-step "submit your feed" modal launched from the Distribution tab. + * + * Ported from Calypso's `client/my-sites/podcast/components/submit-modal.tsx`, + * minus the confetti animation (a Calypso-only component) and the Redux site-id + * lookup (the Jetpack version uses the site URL from script data as a stable + * key for localStorage). + */ + +import { + Button, + ExternalLink, + Modal, + TextControl, + // 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 { useCallback, useEffect, useRef, useState } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { external, link } from '@wordpress/icons'; +import { usePodcatcherUrl } from '../hooks/use-podcatcher-url'; +import type { FormEvent } from 'react'; + +export interface Podcatcher { + id: string; + name: string; + submitUrl: string; + learnMoreUrl?: string; +} + +interface SubmitModalProps { + feedUrl: string; + siteUrl: string; + podcatcher: Podcatcher; + onClose: () => void; +} + +const COPIED_FEEDBACK_MS = 2000; + +const SubmitModal = ( { feedUrl, siteUrl, podcatcher, onClose }: SubmitModalProps ) => { + const [ storedUrl, setStoredUrl ] = usePodcatcherUrl( siteUrl, podcatcher.id ); + const [ draftUrl, setDraftUrl ] = useState( storedUrl ); + const [ hasCopied, setHasCopied ] = useState( false ); + const copyTimeoutRef = useRef< ReturnType< typeof setTimeout > | null >( null ); + + useEffect( () => { + setDraftUrl( storedUrl ); + }, [ storedUrl ] ); + + useEffect( + () => () => { + if ( copyTimeoutRef.current ) { + clearTimeout( copyTimeoutRef.current ); + } + }, + [] + ); + + const handleCopy = useCallback( () => { + if ( ! feedUrl || ! navigator.clipboard?.writeText ) { + return; + } + navigator.clipboard + .writeText( feedUrl ) + .then( () => { + setHasCopied( true ); + if ( copyTimeoutRef.current ) { + clearTimeout( copyTimeoutRef.current ); + } + copyTimeoutRef.current = setTimeout( () => setHasCopied( false ), COPIED_FEEDBACK_MS ); + } ) + .catch( () => { + // Clipboard write rejection is silent — user just keeps seeing the original button label. + } ); + }, [ feedUrl ] ); + + const handleSave = useCallback( + ( event: FormEvent< HTMLFormElement > ) => { + event.preventDefault(); + setStoredUrl( draftUrl.trim() ); + onClose(); + }, + [ draftUrl, setStoredUrl, onClose ] + ); + + const isUnchanged = draftUrl.trim() === storedUrl.trim(); + + const titleText = sprintf( + /* translators: %s: podcast directory name (e.g. "Apple Podcasts"). */ + __( 'Submit to %s', 'jetpack-podcast' ), + podcatcher.name + ); + + const step2Note = + podcatcher.id === 'pocketcasts' + ? __( 'Choose the Public option, since this feed is for your listeners.', 'jetpack-podcast' ) + : null; + + 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' + ), + podcatcher.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' ), + podcatcher.name + ) } +

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

+ { sprintf( + /* translators: %s: podcast directory name. */ + __( 'Step 3: Enter your %s URL', 'jetpack-podcast' ), + podcatcher.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' + ), + podcatcher.name + ) } + +
+ +
+ +
+ +
+
+
+
+
+ ); +}; + +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..58a009de9997 --- /dev/null +++ b/projects/packages/podcast/src/dashboard/hooks/use-podcast-settings.ts @@ -0,0 +1,68 @@ +/** + * 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 } 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, + Partial< PodcastSettings >, + { previous?: PodcastSettings } + >( { + mutationFn: updateSettings, + onMutate: async updates => { + await queryClient.cancelQueries( { queryKey: QUERY_KEY } ); + const previous = queryClient.getQueryData< PodcastSettings >( QUERY_KEY ); + if ( previous ) { + queryClient.setQueryData< PodcastSettings >( QUERY_KEY, { ...previous, ...updates } ); + } + 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/hooks/use-podcatcher-url.ts b/projects/packages/podcast/src/dashboard/hooks/use-podcatcher-url.ts new file mode 100644 index 000000000000..0d6ba559b19a --- /dev/null +++ b/projects/packages/podcast/src/dashboard/hooks/use-podcatcher-url.ts @@ -0,0 +1,63 @@ +/** + * Per-site, per-podcatcher localStorage for "directory submission URL" inputs. + * + * The user pastes the URL their show ends up at after each platform's submit + * flow; we store it so future visits to the Distribution tab show that pretty + * link instead of the raw RSS feed URL again. + */ + +import { useCallback, useEffect, useState } from '@wordpress/element'; + +const STORAGE_PREFIX = 'jetpack-podcast:podcatcher-url'; + +const buildKey = ( siteUrl: string, directoryId: string ) => + `${ STORAGE_PREFIX }:${ siteUrl }:${ directoryId }`; + +const safeRead = ( key: string ): string => { + try { + return window.localStorage.getItem( key ) || ''; + } catch { + return ''; + } +}; + +const safeWrite = ( key: string, value: string ) => { + try { + if ( value ) { + window.localStorage.setItem( key, value ); + } else { + window.localStorage.removeItem( key ); + } + } catch { + // ignore quota / privacy mode errors + } +}; + +/** + * Read and write the user's submitted URL for a single podcatcher (per site). + * + * @param siteUrl - The current site URL, used as a stable cache key prefix. + * @param directoryId - Identifier for the directory (e.g. 'apple', 'spotify'). + * @return Tuple of [currentValue, setValue] backed by localStorage. + */ +export function usePodcatcherUrl( + siteUrl: string, + directoryId: string +): [ string, ( next: string ) => void ] { + const key = buildKey( siteUrl, directoryId ); + const [ value, setValue ] = useState< string >( () => safeRead( key ) ); + + useEffect( () => { + setValue( safeRead( key ) ); + }, [ key ] ); + + const update = useCallback( + ( next: string ) => { + setValue( next ); + safeWrite( key, next ); + }, + [ key ] + ); + + return [ value, update ]; +} 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/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..8d32ba0b45e1 --- /dev/null +++ b/projects/packages/podcast/src/dashboard/style.scss @@ -0,0 +1,266 @@ +/** + * 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; +} 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..dc18e7c2ad94 --- /dev/null +++ b/projects/packages/podcast/src/dashboard/tabs/distribution.tsx @@ -0,0 +1,268 @@ +/** + * Distribution tab — copy-the-feed CTA + per-directory submit buttons. + * + * Mirrors `client/my-sites/podcast/components/distribution.tsx` from Calypso. + * Replaces Calypso's `ClipboardButtonInput` with a small inline copy button so + * we don't pull in any Calypso-only components, and reads the feed URL from + * the script data injected by `class-settings.php` (the same URL produced by + * `get_term_feed_link()`). + */ + +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 { useCallback, useEffect, useRef, useState, type ComponentType } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { check, copy } from '@wordpress/icons'; +import { + LogoAmazon, + LogoApple, + LogoPocketCasts, + LogoPodcastIndex, + LogoSpotify, + LogoYouTube, +} from '../components/logos'; +import SubmitModal from '../components/submit-modal'; +import { usePodcastSettings } from '../hooks/use-podcast-settings'; +import { getPodcastScriptData } from '../script-data'; +import type { FocusEvent } from 'react'; + +interface Directory { + id: string; + name: string; + submitUrl: string; + learnMoreUrl?: string; + Logo: ComponentType; +} + +const DIRECTORIES: Directory[] = [ + { + id: 'pocketcasts', + name: 'Pocket Casts', + submitUrl: 'https://pocketcasts.com/submit', + learnMoreUrl: 'https://support.pocketcasts.com/knowledge-base/submitting-podcasts/', + Logo: LogoPocketCasts, + }, + { + id: 'apple', + name: 'Apple Podcasts', + submitUrl: 'https://podcastsconnect.apple.com/', + learnMoreUrl: 'https://podcasters.apple.com/support/897-submit-a-show', + Logo: LogoApple, + }, + { + id: 'spotify', + name: 'Spotify', + submitUrl: 'https://creators.spotify.com/', + learnMoreUrl: + 'https://support.spotify.com/creators/article/claiming-your-podcast-on-spotify-for-creators/', + Logo: LogoSpotify, + }, + { + id: 'youtube', + name: 'YouTube', + submitUrl: 'https://studio.youtube.com', + learnMoreUrl: 'https://support.google.com/youtube/answer/13973017', + Logo: LogoYouTube, + }, + { + id: 'amazon', + name: 'Amazon Music', + submitUrl: 'https://podcasters.amazon.com', + Logo: LogoAmazon, + }, + { + id: 'podcastindex', + name: 'Podcast Index', + submitUrl: 'https://podcastindex.org/add', + Logo: LogoPodcastIndex, + }, +]; + +const selectOnFocus = ( event: FocusEvent< HTMLInputElement > ) => { + event.currentTarget.select(); +}; + +const FeedCopyField = ( { value }: { value: string } ) => { + const [ copied, setCopied ] = useState( false ); + const timeoutRef = useRef< ReturnType< typeof setTimeout > | null >( null ); + + useEffect( + () => () => { + if ( timeoutRef.current ) { + clearTimeout( timeoutRef.current ); + } + }, + [] + ); + + const onCopy = useCallback( () => { + if ( ! value || ! navigator.clipboard?.writeText ) { + return; + } + navigator.clipboard.writeText( value ).then( () => { + setCopied( true ); + if ( timeoutRef.current ) { + clearTimeout( timeoutRef.current ); + } + timeoutRef.current = setTimeout( () => setCopied( false ), 2000 ); + } ); + }, [ value ] ); + + return ( + + + + + ); +}; + +interface DirectoryRowProps { + directory: Directory; + isEnabled: boolean; + onSelect: ( id: string ) => void; +} + +const DirectoryRow = ( { directory, isEnabled, onSelect }: DirectoryRowProps ) => { + const handleClick = useCallback( () => { + onSelect( directory.id ); + }, [ directory.id, onSelect ] ); + + const { Logo } = directory; + + return ( + + + + { directory.name } + + + + ); +}; + +const DistributionTab = () => { + const { data: settings } = usePodcastSettings(); + const scriptData = getPodcastScriptData(); + const feedUrl = scriptData.feedUrl; + const isEnabled = !! settings?.podcasting_category_id; + + const [ activeId, setActiveId ] = useState< string | null >( null ); + const activeDirectory = DIRECTORIES.find( d => d.id === activeId ) ?? null; + + const handleSelect = useCallback( ( id: string ) => { + setActiveId( id ); + }, [] ); + + const handleClose = useCallback( () => { + setActiveId( null ); + }, [] ); + + 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' + ) } + +
+ + { DIRECTORIES.map( directory => ( + + ) ) } + +
+
+
+
+ + { activeDirectory && ( + + ) } + + ); +}; + +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 ? ( + + ) : ( +