diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b7f282dcfc6..55f9d050a604 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3545,6 +3545,107 @@ 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/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/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': @@ -9506,6 +9607,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==} @@ -9520,6 +9624,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'} @@ -18135,7 +18244,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) @@ -18206,7 +18315,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) @@ -22010,6 +22119,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)': @@ -22023,6 +22134,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/jetpack-mu-wpcom/changelog/wire-podcast-admin-page b/projects/packages/jetpack-mu-wpcom/changelog/wire-podcast-admin-page new file mode 100644 index 000000000000..ca348488f541 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/wire-podcast-admin-page @@ -0,0 +1,4 @@ +Significance: minor +Type: changed + +Jetpack > Podcast: replace the Calypso redirect with the in-admin Podcast SPA when the Jetpack Podcast package is loaded. 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..61f2d901cd6b 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,8 @@ 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 + \Automattic\Jetpack\Podcast\Settings::add_wp_admin_submenu(); if ( $is_simple_site ) { // Jetpack > Newsletter. @@ -459,6 +452,7 @@ function () { 'search', 'subscribers', 'newsletter', + 'jetpack-podcast', 'podcasting', 'traffic', 'jetpack#/settings', 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/.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/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/.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..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..e3bc8cb6a7ff --- /dev/null +++ b/projects/packages/podcast/composer.json @@ -0,0 +1,63 @@ +{ + "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}" + } + } +} diff --git a/projects/packages/podcast/package.json b/projects/packages/podcast/package.json new file mode 100644 index 000000000000..565ff9618215 --- /dev/null +++ b/projects/packages/podcast/package.json @@ -0,0 +1,71 @@ +{ + "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/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/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/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..b5f195475494 --- /dev/null +++ b/projects/packages/podcast/src/class-podcast.php @@ -0,0 +1,155 @@ +is_wpcom_simple() && ! $host->is_woa_site() ) { + return; + } + + $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 new file mode 100644 index 000000000000..ceecb24c003a --- /dev/null +++ b/projects/packages/podcast/src/class-settings.php @@ -0,0 +1,138 @@ + Podcast wp-admin screen. + * + * 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 MENU_SLUG = 'jetpack-podcast'; + + /** + * Whether the admin-init hooks have been wired. + * + * @var bool + */ + private static $admin_init_wired = false; + + /** + * 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() { + // Intentionally empty: see class docblock. + } + + /** + * Register the Podcast submenu directly under the Jetpack menu. + * + * 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() { + $page_suffix = add_submenu_page( + 'jetpack', + /** "Podcast" is a product name, do not translate. */ + 'Podcast', + 'Podcast', + 'manage_options', + self::MENU_SLUG, + array( __CLASS__, 'render' ) + ); + + if ( $page_suffix && ! self::$admin_init_wired ) { + self::$admin_init_wired = true; + add_action( 'load-' . $page_suffix, array( __CLASS__, 'admin_init' ) ); + } + } + + /** + * Admin init actions. Triggered only when the Podcast page is being loaded. + */ + public static function admin_init() { + add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'add_script_data' ) ); + add_action( 'admin_enqueue_scripts', array( __CLASS__, 'load_admin_scripts' ) ); + } + + /** + * Inject podcast-specific data into the global JetpackScriptData object. + * + * @param array $data Existing script data. + * @return array + */ + 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' ), + ); + + 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 static 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 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 ? ( + + ) : ( +