From 3d363f2efe99836ac05c305a2fa683e342f06561 Mon Sep 17 00:00:00 2001
From: Ola Hneini <ola.hneini@gmail.com>
Date: Tue, 5 Jul 2022 16:39:20 +0000
Subject: [PATCH] feat: add publish feature for podcasts and set draft by
 default

closes #128, #220
---
 .../2020-05-30-101500_add_podcasts.php        |   4 +
 app/Database/Seeds/AuthSeeder.php             |   2 +-
 app/Entities/Episode.php                      |   2 +
 app/Entities/Podcast.php                      |  24 ++
 app/Helpers/components_helper.php             |  59 ++-
 app/Helpers/misc_helper.php                   |  27 ++
 app/Models/EpisodeModel.php                   |   2 +-
 app/Models/PodcastModel.php                   |   9 +-
 app/Resources/icons/warning.svg               |   6 +
 app/Resources/styles/custom.css               |  10 +
 modules/Admin/Config/Routes.php               |  43 +++
 .../Admin/Controllers/EpisodeController.php   | 144 ++++---
 .../Admin/Controllers/PodcastController.php   | 361 ++++++++++++++++++
 .../Controllers/PodcastImportController.php   |  15 +
 modules/Admin/Language/en/Episode.php         |  14 +-
 modules/Admin/Language/en/Podcast.php         |  39 +-
 .../WebSub/Controllers/WebSubController.php   |   1 +
 themes/cp_admin/_layout.php                   |   3 +
 themes/cp_admin/episode/publish.php           |  40 +-
 themes/cp_admin/episode/publish_edit.php      |  38 +-
 themes/cp_admin/podcast/_card.php             |  10 +-
 themes/cp_admin/podcast/create.php            |   2 +-
 themes/cp_admin/podcast/edit.php              |   2 +-
 themes/cp_admin/podcast/publish.php           |  80 ++++
 themes/cp_admin/podcast/publish_edit.php      |  81 ++++
 25 files changed, 909 insertions(+), 109 deletions(-)
 create mode 100644 app/Resources/icons/warning.svg
 create mode 100644 themes/cp_admin/podcast/publish.php
 create mode 100644 themes/cp_admin/podcast/publish_edit.php

diff --git a/app/Database/Migrations/2020-05-30-101500_add_podcasts.php b/app/Database/Migrations/2020-05-30-101500_add_podcasts.php
index bc992195d9..61f3b86a70 100644
--- a/app/Database/Migrations/2020-05-30-101500_add_podcasts.php
+++ b/app/Database/Migrations/2020-05-30-101500_add_podcasts.php
@@ -177,6 +177,10 @@ class AddPodcasts extends Migration
                 'type' => 'INT',
                 'unsigned' => true,
             ],
+            'published_at' => [
+                'type' => 'DATETIME',
+                'null' => true,
+            ],
             'created_at' => [
                 'type' => 'DATETIME',
             ],
diff --git a/app/Database/Seeds/AuthSeeder.php b/app/Database/Seeds/AuthSeeder.php
index 8f09f54ad4..365726e6f7 100644
--- a/app/Database/Seeds/AuthSeeder.php
+++ b/app/Database/Seeds/AuthSeeder.php
@@ -168,7 +168,7 @@ class AuthSeeder extends Seeder
             [
                 'name' => 'manage_publications',
                 'description' =>
-                    'Publish / unpublish episodes & posts of a podcast',
+                    'Publish a podcast and publish / unpublish its episodes & posts',
                 'has_permission' => ['podcast_admin'],
             ],
             [
diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php
index 6474642ee9..60f89c448b 100644
--- a/app/Entities/Episode.php
+++ b/app/Entities/Episode.php
@@ -541,6 +541,8 @@ class Episode extends Entity
         if ($this->publication_status === null) {
             if ($this->published_at === null) {
                 $this->publication_status = 'not_published';
+            } elseif ($this->getPodcast()->publication_status !== 'published') {
+                $this->publication_status = 'with_podcast';
             } elseif ($this->published_at->isBefore(Time::now())) {
                 $this->publication_status = 'published';
             } else {
diff --git a/app/Entities/Podcast.php b/app/Entities/Podcast.php
index 9e58b46105..bf1b64b769 100644
--- a/app/Entities/Podcast.php
+++ b/app/Entities/Podcast.php
@@ -79,6 +79,8 @@ use RuntimeException;
  * @property string|null $partner_image_url
  * @property int $created_by
  * @property int $updated_by
+ * @property string $publication_status;
+ * @property Time|null $published_at;
  * @property Time $created_at;
  * @property Time $updated_at;
  *
@@ -147,6 +149,13 @@ class Podcast extends Entity
 
     protected string $custom_rss_string;
 
+    protected ?string $publication_status = null;
+
+    /**
+     * @var string[]
+     */
+    protected $dates = ['published_at', 'created_at', 'updated_at'];
+
     /**
      * @var array<string, string>
      */
@@ -459,6 +468,21 @@ class Podcast extends Entity
         return $this->description;
     }
 
+    public function getPublicationStatus(): string
+    {
+        if ($this->publication_status === null) {
+            if ($this->published_at === null) {
+                $this->publication_status = 'not_published';
+            } elseif ($this->published_at->isBefore(Time::now())) {
+                $this->publication_status = 'published';
+            } else {
+                $this->publication_status = 'scheduled';
+            }
+        }
+
+        return $this->publication_status;
+    }
+
     /**
      * Returns the podcast's podcasting platform links
      *
diff --git a/app/Helpers/components_helper.php b/app/Helpers/components_helper.php
index 067b4dfbac..6bef701359 100644
--- a/app/Helpers/components_helper.php
+++ b/app/Helpers/components_helper.php
@@ -116,18 +116,27 @@ if (! function_exists('publication_pill')) {
         $class = match ($publicationStatus) {
             'published' => 'text-pine-500 border-pine-500 bg-pine-50',
             'scheduled' => 'text-red-600 border-red-600 bg-red-50',
+            'with_podcast' => 'text-blue-600 border-blue-600 bg-blue-50',
             'not_published' => 'text-gray-600 border-gray-600 bg-gray-50',
             default => 'text-gray-600 border-gray-600 bg-gray-50',
         };
 
+        $title = match ($publicationStatus) {
+            'published', 'scheduled' => (string) $publicationDate,
+            'with_podcast' => lang('Episode.with_podcast_hint'),
+            'not_published' => '',
+            default => '',
+        };
+
         $label = lang('Episode.publication_status.' . $publicationStatus);
 
-        return '<span ' . ($publicationDate === null ? '' : 'title="' . $publicationDate . '"') . ' class="px-1 font-semibold border rounded ' .
+        return '<span ' . ($title === '' ? '' : 'title="' . $title . '"') . ' class="flex items-center px-1 font-semibold border rounded w-max ' .
             $class .
             ' ' .
             $customClass .
             '">' .
             $label .
+            ($publicationStatus === 'with_podcast' ? '<Icon glyph="warning" class="flex-shrink-0 ml-1 text-lg" />' : '') .
             '</span>';
     }
 }
@@ -136,7 +145,7 @@ if (! function_exists('publication_pill')) {
 
 if (! function_exists('publication_button')) {
     /**
-     * Publication button component
+     * Publication button component for episodes
      *
      * Displays the appropriate publication button depending on the publication post.
      */
@@ -149,6 +158,7 @@ if (! function_exists('publication_button')) {
                 $variant = 'primary';
                 $iconLeft = 'upload-cloud';
                 break;
+            case 'with_podcast':
             case 'scheduled':
                 $label = lang('Episode.publish_edit');
                 $route = route_to('episode-publish_edit', $podcastId, $episodeId);
@@ -177,6 +187,51 @@ if (! function_exists('publication_button')) {
 
 // ------------------------------------------------------------------------
 
+if (! function_exists('publication_status_banner')) {
+    /**
+     * Publication status banner component for podcasts
+     *
+     * Displays the appropriate banner depending on the podcast's publication status.
+     */
+    function publication_status_banner(?Time $publicationDate, int $podcastId, string $publicationStatus): string
+    {
+        switch ($publicationStatus) {
+            case 'not_published':
+                $bannerDisclaimer = lang('Podcast.publication_status_banner.draft_mode');
+                $bannerText = lang('Podcast.publication_status_banner.not_published');
+                $linkRoute = route_to('podcast-publish', $podcastId);
+                $linkLabel = lang('Podcast.publish');
+                break;
+            case 'scheduled':
+                $bannerDisclaimer = lang('Podcast.publication_status_banner.draft_mode');
+                $bannerText = lang('Podcast.publication_status_banner.scheduled', [
+                    'publication_date' => local_time($publicationDate),
+                ], null, false);
+                $linkRoute = route_to('podcast-publish_edit', $podcastId);
+                $linkLabel = lang('Podcast.publish_edit');
+                break;
+            default:
+                $bannerDisclaimer = '';
+                $bannerText = '';
+                $linkRoute = '';
+                $linkLabel = '';
+                break;
+        }
+
+        return <<<CODE_SAMPLE
+        <div class="flex items-center px-12 py-1 border-b bg-stripes-gray border-subtle" role="alert">
+            <p class="text-gray-900">
+                <span class="text-xs font-semibold tracking-wide uppercase">{$bannerDisclaimer}</span>
+                <span class="ml-3 text-sm">{$bannerText}</span>
+            </p>
+            <a href="{$linkRoute}" class="ml-1 text-sm font-semibold underline shadow-xs text-accent-base hover:text-accent-hover hover:no-underline">{$linkLabel}</a>
+        </div>
+        CODE_SAMPLE;
+    }
+}
+
+// ------------------------------------------------------------------------
+
 if (! function_exists('episode_numbering')) {
     /**
      * Returns relevant translated episode numbering.
diff --git a/app/Helpers/misc_helper.php b/app/Helpers/misc_helper.php
index f4b0a6020d..e69e9e87e8 100644
--- a/app/Helpers/misc_helper.php
+++ b/app/Helpers/misc_helper.php
@@ -8,6 +8,8 @@ declare(strict_types=1);
  * @link       https://castopod.org/
  */
 
+use CodeIgniter\I18n\Time;
+
 if (! function_exists('get_browser_language')) {
     /**
      * Gets the browser default language using the request header key `HTTP_ACCEPT_LANGUAGE`
@@ -292,3 +294,28 @@ if (! function_exists('format_bytes')) {
         return round($bytes, $precision) . $units[$pow];
     }
 }
+
+if (! function_exists('local_time')) {
+    function local_time(Time $time): string
+    {
+        $formatter = new IntlDateFormatter(service(
+            'request'
+        )->getLocale(), IntlDateFormatter::MEDIUM, IntlDateFormatter::LONG);
+        $translatedDate = $time->toLocalizedString($formatter->getPattern());
+        $datetime = $time->format(DateTime::ISO8601);
+
+        return <<<CODE_SAMPLE
+            <local-time datetime="{$datetime}" 
+                weekday="long" 
+                month="long"
+                day="numeric"
+                year="numeric"
+                hour="numeric"
+                minute="numeric">
+                <time
+                    datetime="{$datetime}"
+                    title="{$time}">{$translatedDate}</time>
+            </local-time>
+        CODE_SAMPLE;
+    }
+}
diff --git a/app/Models/EpisodeModel.php b/app/Models/EpisodeModel.php
index 1c8a666755..f218f47b86 100644
--- a/app/Models/EpisodeModel.php
+++ b/app/Models/EpisodeModel.php
@@ -142,7 +142,7 @@ class EpisodeModel extends Model
                 ->join('podcasts', 'podcasts.id = episodes.podcast_id')
                 ->where('slug', $episodeSlug)
                 ->where('podcasts.handle', $podcastHandle)
-                ->where('`published_at` <= UTC_TIMESTAMP()', null, false)
+                ->where('`' . $this->db->getPrefix() . 'episodes`.`published_at` <= UTC_TIMESTAMP()', null, false)
                 ->first();
 
             cache()
diff --git a/app/Models/PodcastModel.php b/app/Models/PodcastModel.php
index 169acfc8de..8a5fd8c647 100644
--- a/app/Models/PodcastModel.php
+++ b/app/Models/PodcastModel.php
@@ -64,6 +64,7 @@ class PodcastModel extends Model
         'partner_id',
         'partner_link_url',
         'partner_image_url',
+        'published_at',
         'created_by',
         'updated_by',
     ];
@@ -92,6 +93,7 @@ class PodcastModel extends Model
         'owner_email' => 'required|valid_email',
         'new_feed_url' => 'valid_url_strict|permit_empty',
         'type' => 'required',
+        'published_at' => 'valid_date|permit_empty',
         'created_by' => 'required',
         'updated_by' => 'required',
     ];
@@ -128,6 +130,7 @@ class PodcastModel extends Model
         $cacheName = "podcast-{$podcastHandle}";
         if (! ($found = cache($cacheName))) {
             $found = $this->where('handle', $podcastHandle)
+                ->where('`published_at` <= UTC_TIMESTAMP()', null, false)
                 ->first();
             cache()
                 ->save("podcast-{$podcastHandle}", $found, DECADE);
@@ -168,9 +171,9 @@ class PodcastModel extends Model
      */
     public function getAllPodcasts(string $orderBy = null): array
     {
-        if ($orderBy === 'activity') {
-            $prefix = $this->db->getPrefix();
+        $prefix = $this->db->getPrefix();
 
+        if ($orderBy === 'activity') {
             $fediverseTablePrefix = $prefix . config('Fediverse')
                 ->tablesPrefix;
             $this->builder()
@@ -195,7 +198,7 @@ class PodcastModel extends Model
             $this->orderBy('created_at', 'ASC');
         }
 
-        return $this->findAll();
+        return $this->where('`' . $prefix . 'podcasts`.`published_at` <= UTC_TIMESTAMP()', null, false)->findAll();
     }
 
     /**
diff --git a/app/Resources/icons/warning.svg b/app/Resources/icons/warning.svg
new file mode 100644
index 0000000000..e01de7fbd9
--- /dev/null
+++ b/app/Resources/icons/warning.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+    <g>
+        <path fill="none" d="M0 0h24v24H0z"/>
+        <path d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-1-7v2h2v-2h-2zm0-8v6h2V7h-2z"/>
+    </g>
+</svg>
\ No newline at end of file
diff --git a/app/Resources/styles/custom.css b/app/Resources/styles/custom.css
index eceef962dc..fc539d2781 100644
--- a/app/Resources/styles/custom.css
+++ b/app/Resources/styles/custom.css
@@ -42,4 +42,14 @@
       hsla(0 0% 0% / 0.8) 100%
     );
   }
+
+  .bg-stripes-gray {
+    background-image: repeating-linear-gradient(
+      -45deg,
+      #f3f4f6,
+      #f3f4f6 10px,
+      #e5e7eb 10px,
+      #e5e7eb 20px
+    );
+  }
 }
diff --git a/modules/Admin/Config/Routes.php b/modules/Admin/Config/Routes.php
index 4bde002ef7..b3ffcd13ae 100644
--- a/modules/Admin/Config/Routes.php
+++ b/modules/Admin/Config/Routes.php
@@ -119,6 +119,49 @@ $routes->group(
                 $routes->post('edit', 'PodcastController::attemptEdit/$1', [
                     'filter' => 'permission:podcast-edit',
                 ]);
+                $routes->get(
+                    'publish',
+                    'PodcastController::publish/$1',
+                    [
+                        'as' => 'podcast-publish',
+                        'filter' =>
+                            'permission:podcast-manage_publications',
+                    ],
+                );
+                $routes->post(
+                    'publish',
+                    'PodcastController::attemptPublish/$1',
+                    [
+                        'filter' =>
+                            'permission:podcast-manage_publications',
+                    ],
+                );
+                $routes->get(
+                    'publish-edit',
+                    'PodcastController::publishEdit/$1',
+                    [
+                        'as' => 'podcast-publish_edit',
+                        'filter' =>
+                            'permission:podcast-manage_publications',
+                    ],
+                );
+                $routes->post(
+                    'publish-edit',
+                    'PodcastController::attemptPublishEdit/$1',
+                    [
+                        'filter' =>
+                            'permission:podcast-manage_publications',
+                    ],
+                );
+                $routes->get(
+                    'publish-cancel',
+                    'PodcastController::publishCancel/$1',
+                    [
+                        'as' => 'podcast-publish-cancel',
+                        'filter' =>
+                            'permission:podcast-manage_publications',
+                    ],
+                );
                 $routes->get('edit/delete-banner', 'PodcastController::deleteBanner/$1', [
                     'as' => 'podcast-banner-delete',
                     'filter' => 'permission:podcast-edit',
diff --git a/modules/Admin/Controllers/EpisodeController.php b/modules/Admin/Controllers/EpisodeController.php
index 619c562287..18c961c2ad 100644
--- a/modules/Admin/Controllers/EpisodeController.php
+++ b/modules/Admin/Controllers/EpisodeController.php
@@ -440,17 +440,19 @@ class EpisodeController extends BaseController
 
     public function attemptPublish(): RedirectResponse
     {
-        $rules = [
-            'publication_method' => 'required',
-            'scheduled_publication_date' =>
-                'valid_date[Y-m-d H:i]|permit_empty',
-        ];
+        if ($this->podcast->publication_status === 'published') {
+            $rules = [
+                'publication_method' => 'required',
+                'scheduled_publication_date' =>
+                    'valid_date[Y-m-d H:i]|permit_empty',
+            ];
 
-        if (! $this->validate($rules)) {
-            return redirect()
-                ->back()
-                ->withInput()
-                ->with('errors', $this->validator->getErrors());
+            if (! $this->validate($rules)) {
+                return redirect()
+                    ->back()
+                    ->withInput()
+                    ->with('errors', $this->validator->getErrors());
+            }
         }
 
         $db = db_connect();
@@ -463,22 +465,29 @@ class EpisodeController extends BaseController
             'created_by' => user_id(),
         ]);
 
-        $publishMethod = $this->request->getPost('publication_method');
-        if ($publishMethod === 'schedule') {
-            $scheduledPublicationDate = $this->request->getPost('scheduled_publication_date');
-            if ($scheduledPublicationDate) {
-                $this->episode->published_at = Time::createFromFormat(
-                    'Y-m-d H:i',
-                    $scheduledPublicationDate,
-                    $this->request->getPost('client_timezone'),
-                )->setTimezone(app_timezone());
+        if ($this->podcast->publication_status === 'published') {
+            $publishMethod = $this->request->getPost('publication_method');
+            if ($publishMethod === 'schedule') {
+                $scheduledPublicationDate = $this->request->getPost('scheduled_publication_date');
+                if ($scheduledPublicationDate) {
+                    $this->episode->published_at = Time::createFromFormat(
+                        'Y-m-d H:i',
+                        $scheduledPublicationDate,
+                        $this->request->getPost('client_timezone'),
+                    )->setTimezone(app_timezone());
+                } else {
+                    $db->transRollback();
+                    return redirect()
+                        ->back()
+                        ->withInput()
+                        ->with('error', lang('Episode.messages.scheduleDateError'));
+                }
             } else {
-                $db->transRollback();
-                return redirect()
-                    ->back()
-                    ->withInput()
-                    ->with('error', 'Schedule date must be set!');
+                $this->episode->published_at = Time::now();
             }
+        } elseif ($this->podcast->publication_status === 'scheduled') {
+            // podcast publication date has already been set
+            $this->episode->published_at = $this->podcast->published_at->addSeconds(1);
         } else {
             $this->episode->published_at = Time::now();
         }
@@ -505,12 +514,17 @@ class EpisodeController extends BaseController
 
         $db->transComplete();
 
-        return redirect()->route('episode-view', [$this->podcast->id, $this->episode->id]);
+        return redirect()->route('episode-view', [$this->podcast->id, $this->episode->id])->with(
+            'message',
+            lang('Episode.messages.publishSuccess', [
+                'publication_status' => $this->episode->publication_status,
+            ])
+        );
     }
 
     public function publishEdit(): string | RedirectResponse
     {
-        if ($this->episode->publication_status === 'scheduled') {
+        if (in_array($this->episode->publication_status, ['scheduled', 'with_podcast'], true)) {
             helper(['form']);
 
             $data = [
@@ -539,39 +553,48 @@ class EpisodeController extends BaseController
 
     public function attemptPublishEdit(): RedirectResponse
     {
-        $rules = [
-            'post_id' => 'required',
-            'publication_method' => 'required',
-            'scheduled_publication_date' =>
-                'valid_date[Y-m-d H:i]|permit_empty',
-        ];
+        if ($this->podcast->publication_status === 'published') {
+            $rules = [
+                'post_id' => 'required',
+                'publication_method' => 'required',
+                'scheduled_publication_date' =>
+                    'valid_date[Y-m-d H:i]|permit_empty',
+            ];
 
-        if (! $this->validate($rules)) {
-            return redirect()
-                ->back()
-                ->withInput()
-                ->with('errors', $this->validator->getErrors());
+            if (! $this->validate($rules)) {
+                return redirect()
+                    ->back()
+                    ->withInput()
+                    ->with('errors', $this->validator->getErrors());
+            }
         }
 
         $db = db_connect();
         $db->transStart();
 
-        $publishMethod = $this->request->getPost('publication_method');
-        if ($publishMethod === 'schedule') {
-            $scheduledPublicationDate = $this->request->getPost('scheduled_publication_date');
-            if ($scheduledPublicationDate) {
-                $this->episode->published_at = Time::createFromFormat(
-                    'Y-m-d H:i',
-                    $scheduledPublicationDate,
-                    $this->request->getPost('client_timezone'),
-                )->setTimezone(app_timezone());
+        if ($this->podcast->publication_status === 'published') {
+            $publishMethod = $this->request->getPost('publication_method');
+            if ($publishMethod === 'schedule') {
+                $scheduledPublicationDate = $this->request->getPost('scheduled_publication_date');
+                if ($scheduledPublicationDate) {
+                    $this->episode->published_at = Time::createFromFormat(
+                        'Y-m-d H:i',
+                        $scheduledPublicationDate,
+                        $this->request->getPost('client_timezone'),
+                    )->setTimezone(app_timezone());
+                } else {
+                    $db->transRollback();
+                    return redirect()
+                        ->back()
+                        ->withInput()
+                        ->with('error', lang('Episode.messages.scheduleDateError'));
+                }
             } else {
-                $db->transRollback();
-                return redirect()
-                    ->back()
-                    ->withInput()
-                    ->with('error', 'Schedule date must be set!');
+                $this->episode->published_at = Time::now();
             }
+        } elseif ($this->podcast->publication_status === 'scheduled') {
+            // podcast publication date has already been set
+            $this->episode->published_at = $this->podcast->published_at->addSeconds(1);
         } else {
             $this->episode->published_at = Time::now();
         }
@@ -603,12 +626,17 @@ class EpisodeController extends BaseController
 
         $db->transComplete();
 
-        return redirect()->route('episode-view', [$this->podcast->id, $this->episode->id]);
+        return redirect()->route('episode-view', [$this->podcast->id, $this->episode->id])->with(
+            'message',
+            lang('Episode.messages.publishSuccess', [
+                'publication_status' => $this->episode->publication_status,
+            ])
+        );
     }
 
     public function publishCancel(): RedirectResponse
     {
-        if ($this->episode->publication_status === 'scheduled') {
+        if (in_array($this->episode->publication_status, ['scheduled', 'with_podcast'], true)) {
             $db = db_connect();
             $db->transStart();
 
@@ -634,13 +662,13 @@ class EpisodeController extends BaseController
 
             $db->transComplete();
 
-            return redirect()->route('episode-view', [$this->podcast->id, $this->episode->id]);
+            return redirect()->route('episode-view', [$this->podcast->id, $this->episode->id])->with(
+                'message',
+                lang('Episode.messages.publishCancelSuccess')
+            );
         }
 
-        return redirect()->route('episode-view', [$this->podcast->id, $this->episode->id])->with(
-            'message',
-            lang('Episode.messages.publishCancelSuccess')
-        );
+        return redirect()->route('episode-view', [$this->podcast->id, $this->episode->id]);
     }
 
     public function unpublish(): string | RedirectResponse
diff --git a/modules/Admin/Controllers/PodcastController.php b/modules/Admin/Controllers/PodcastController.php
index 8a18b7b395..e08138773e 100644
--- a/modules/Admin/Controllers/PodcastController.php
+++ b/modules/Admin/Controllers/PodcastController.php
@@ -12,14 +12,17 @@ namespace Modules\Admin\Controllers;
 
 use App\Entities\Location;
 use App\Entities\Podcast;
+use App\Entities\Post;
 use App\Models\ActorModel;
 use App\Models\CategoryModel;
 use App\Models\EpisodeModel;
 use App\Models\LanguageModel;
 use App\Models\MediaModel;
 use App\Models\PodcastModel;
+use App\Models\PostModel;
 use CodeIgniter\Exceptions\PageNotFoundException;
 use CodeIgniter\HTTP\RedirectResponse;
+use CodeIgniter\I18n\Time;
 use Config\Services;
 use Modules\Analytics\Models\AnalyticsPodcastByCountryModel;
 use Modules\Analytics\Models\AnalyticsPodcastByEpisodeModel;
@@ -237,6 +240,7 @@ class PodcastController extends BaseController
             'is_locked' => $this->request->getPost('lock') === 'yes',
             'created_by' => user_id(),
             'updated_by' => user_id(),
+            'published_at' => null,
         ]);
 
         $podcastModel = new PodcastModel();
@@ -604,4 +608,361 @@ class PodcastController extends BaseController
                 'podcast_handle' => $this->podcast->handle,
             ]));
     }
+
+    public function publish(): string | RedirectResponse
+    {
+        helper(['form']);
+
+        $data = [
+            'podcast' => $this->podcast,
+        ];
+
+        replace_breadcrumb_params([
+            0 => $this->podcast->title,
+        ]);
+
+        return view('podcast/publish', $data);
+    }
+
+    public function attemptPublish(): RedirectResponse
+    {
+        if ($this->podcast->publication_status !== 'not_published') {
+            return redirect()->route('podcast-view', [$this->podcast->id])->with(
+                'error',
+                lang('Podcast.messages.publishError')
+            );
+        }
+
+        $rules = [
+            'publication_method' => 'required',
+            'scheduled_publication_date' =>
+            'valid_date[Y-m-d H:i]|permit_empty',
+        ];
+
+        if (! $this->validate($rules)) {
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('errors', $this->validator->getErrors());
+        }
+
+        $db = db_connect();
+        $db->transStart();
+
+        $publishMethod = $this->request->getPost('publication_method');
+        if ($publishMethod === 'schedule') {
+            $scheduledPublicationDate = $this->request->getPost('scheduled_publication_date');
+            if ($scheduledPublicationDate) {
+                $this->podcast->published_at = Time::createFromFormat(
+                    'Y-m-d H:i',
+                    $scheduledPublicationDate,
+                    $this->request->getPost('client_timezone'),
+                )->setTimezone(app_timezone());
+            } else {
+                $db->transRollback();
+                return redirect()
+                    ->back()
+                    ->withInput()
+                    ->with('error', lang('Podcast.messages.scheduleDateError'));
+            }
+        } else {
+            $this->podcast->published_at = Time::now();
+        }
+
+        $message = $this->request->getPost('message');
+        // only create post if message is not empty
+        if ($message !== '') {
+            $newPost = new Post([
+                'actor_id' => $this->podcast->actor_id,
+                'message' => $message,
+                'created_by' => user_id(),
+            ]);
+
+            $newPost->published_at = $this->podcast->published_at;
+
+            $postModel = new PostModel();
+            if (! $postModel->addPost($newPost)) {
+                $db->transRollback();
+                return redirect()
+                    ->back()
+                    ->withInput()
+                    ->with('errors', $postModel->errors());
+            }
+        }
+
+        $episodes = (new EpisodeModel())
+            ->where('podcast_id', $this->podcast->id)
+            ->where('published_at !=', null)
+            ->findAll();
+
+        foreach ($episodes as $episode) {
+            $episode->published_at = $this->podcast->published_at->addSeconds(1);
+
+            $episodeModel = new EpisodeModel();
+            if (! $episodeModel->update($episode->id, $episode)) {
+                $db->transRollback();
+                return redirect()
+                    ->back()
+                    ->withInput()
+                    ->with('errors', $episodeModel->errors());
+            }
+
+            $post = (new PostModel())->where('episode_id', $episode->id)
+                ->first();
+
+            if ($post !== null) {
+                $post->published_at = $episode->published_at;
+                $postModel = new PostModel();
+                if (! $postModel->update($post->id, $post)) {
+                    $db->transRollback();
+                    return redirect()
+                        ->back()
+                        ->withInput()
+                        ->with('errors', $postModel->errors());
+                }
+            }
+        }
+
+        $podcastModel = new PodcastModel();
+        if (! $podcastModel->update($this->podcast->id, $this->podcast)) {
+            $db->transRollback();
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('errors', $podcastModel->errors());
+        }
+
+        $db->transComplete();
+
+        return redirect()->route('podcast-view', [$this->podcast->id]);
+    }
+
+    public function publishEdit(): string | RedirectResponse
+    {
+        helper(['form']);
+
+        $data = [
+            'podcast' => $this->podcast,
+            'post' => (new PostModel())
+                ->where([
+                    'actor_id' => $this->podcast->actor_id,
+                    'episode_id' => null,
+                ])
+                ->first(),
+        ];
+
+        replace_breadcrumb_params([
+            0 => $this->podcast->title,
+        ]);
+
+        return view('podcast/publish_edit', $data);
+    }
+
+    public function attemptPublishEdit(): RedirectResponse
+    {
+        if ($this->podcast->publication_status !== 'scheduled') {
+            return redirect()->route('podcast-view', [$this->podcast->id])->with(
+                'error',
+                lang('Podcast.messages.publishEditError')
+            );
+        }
+
+        $rules = [
+            'publication_method' => 'required',
+            'scheduled_publication_date' =>
+                'valid_date[Y-m-d H:i]|permit_empty',
+        ];
+
+        if (! $this->validate($rules)) {
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('errors', $this->validator->getErrors());
+        }
+
+        $db = db_connect();
+        $db->transStart();
+
+        $publishMethod = $this->request->getPost('publication_method');
+        if ($publishMethod === 'schedule') {
+            $scheduledPublicationDate = $this->request->getPost('scheduled_publication_date');
+            if ($scheduledPublicationDate) {
+                $this->podcast->published_at = Time::createFromFormat(
+                    'Y-m-d H:i',
+                    $scheduledPublicationDate,
+                    $this->request->getPost('client_timezone'),
+                )->setTimezone(app_timezone());
+            } else {
+                $db->transRollback();
+                return redirect()
+                    ->back()
+                    ->withInput()
+                    ->with('error', lang('Podcast.messages.scheduleDateError'));
+            }
+        } else {
+            $this->podcast->published_at = Time::now();
+        }
+
+        $post = (new PostModel())
+            ->where([
+                'actor_id' => $this->podcast->actor_id,
+                'episode_id' => null,
+            ])
+            ->first();
+
+        $newPostMessage = $this->request->getPost('message');
+
+        if ($post !== null) {
+            if ($newPostMessage !== '') {
+                // edit post if post exists and message is not empty
+                $post->message = $newPostMessage;
+                $post->published_at = $this->podcast->published_at;
+
+                $postModel = new PostModel();
+                if (! $postModel->editPost($post)) {
+                    $db->transRollback();
+                    return redirect()
+                        ->back()
+                        ->withInput()
+                        ->with('errors', $postModel->errors());
+                }
+            } else {
+                // remove post if post exists and message is empty
+                $postModel = new PostModel();
+                $post = $postModel
+                    ->where([
+                        'actor_id' => $this->podcast->actor_id,
+                        'episode_id' => null,
+                    ])
+                    ->first();
+                $postModel->removePost($post);
+            }
+        } elseif ($newPostMessage !== '') {
+            // create post if there is no post and message is not empty
+            $newPost = new Post([
+                'actor_id' => $this->podcast->actor_id,
+                'message' => $newPostMessage,
+                'created_by' => user_id(),
+            ]);
+
+            $newPost->published_at = $this->podcast->published_at;
+
+            $postModel = new PostModel();
+            if (! $postModel->addPost($newPost)) {
+                $db->transRollback();
+                return redirect()
+                    ->back()
+                    ->withInput()
+                    ->with('errors', $postModel->errors());
+            }
+        }
+
+        $episodes = (new EpisodeModel())
+            ->where('podcast_id', $this->podcast->id)
+            ->where('published_at !=', null)
+            ->findAll();
+
+        foreach ($episodes as $episode) {
+            $episode->published_at = $this->podcast->published_at->addSeconds(1);
+
+            $episodeModel = new EpisodeModel();
+            if (! $episodeModel->update($episode->id, $episode)) {
+                $db->transRollback();
+                return redirect()
+                    ->back()
+                    ->withInput()
+                    ->with('errors', $episodeModel->errors());
+            }
+
+            $post = (new PostModel())->where('episode_id', $episode->id)
+                ->first();
+
+            if ($post !== null) {
+                $post->published_at = $episode->published_at;
+                $postModel = new PostModel();
+                if (! $postModel->update($post->id, $post)) {
+                    $db->transRollback();
+                    return redirect()
+                        ->back()
+                        ->withInput()
+                        ->with('errors', $postModel->errors());
+                }
+            }
+        }
+
+        $podcastModel = new PodcastModel();
+        if (! $podcastModel->update($this->podcast->id, $this->podcast)) {
+            $db->transRollback();
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('errors', $podcastModel->errors());
+        }
+
+        $db->transComplete();
+
+        return redirect()->route('podcast-view', [$this->podcast->id]);
+    }
+
+    public function publishCancel(): RedirectResponse
+    {
+        if ($this->podcast->publication_status !== 'scheduled') {
+            return redirect()->route('podcast-view', [$this->podcast->id]);
+        }
+
+        $db = db_connect();
+        $db->transStart();
+
+        $postModel = new PostModel();
+        $post = $postModel
+            ->where([
+                'actor_id' => $this->podcast->actor_id,
+                'episode_id' => null,
+            ])
+            ->first();
+        if ($post !== null) {
+            $postModel->removePost($post);
+        }
+
+        $episodes = (new EpisodeModel())
+            ->where('podcast_id', $this->podcast->id)
+            ->where('published_at !=', null)
+            ->findAll();
+
+        foreach ($episodes as $episode) {
+            $episode->published_at = null;
+
+            $episodeModel = new EpisodeModel();
+            if (! $episodeModel->update($episode->id, $episode)) {
+                $db->transRollback();
+                return redirect()
+                    ->back()
+                    ->withInput()
+                    ->with('errors', $episodeModel->errors());
+            }
+
+            $postModel = new PostModel();
+            $post = $postModel->where('episode_id', $episode->id)
+                ->first();
+            $postModel->removePost($post);
+        }
+
+        $this->podcast->published_at = null;
+
+        $podcastModel = new PodcastModel();
+        if (! $podcastModel->update($this->podcast->id, $this->podcast)) {
+            $db->transRollback();
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('errors', $podcastModel->errors());
+        }
+
+        $db->transComplete();
+
+        return redirect()->route('podcast-view', [$this->podcast->id])->with(
+            'message',
+            lang('Podcast.messages.publishCancelSuccess')
+        );
+    }
 }
diff --git a/modules/Admin/Controllers/PodcastImportController.php b/modules/Admin/Controllers/PodcastImportController.php
index b200910a19..bd98e67192 100644
--- a/modules/Admin/Controllers/PodcastImportController.php
+++ b/modules/Admin/Controllers/PodcastImportController.php
@@ -450,12 +450,27 @@ class PodcastImportController extends BaseController
                         ->with('errors', $episodePersonModel->errors());
                 }
             }
+
+            if ($itemNumber === 1) {
+                $firstEpisodePublicationDate = strtotime((string) $item->pubDate);
+            }
         }
 
         // set interact as the newly imported podcast actor
         $importedPodcast = (new PodcastModel())->getPodcastById($newPodcastId);
         set_interact_as_actor($importedPodcast->actor_id);
 
+        // set podcast publication date
+        $importedPodcast->published_at = $firstEpisodePublicationDate ?? $importedPodcast->created_at;
+        $podcastModel = new PodcastModel();
+        if (! $podcastModel->update($importedPodcast->id, $importedPodcast)) {
+            $db->transRollback();
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('errors', $podcastModel->errors());
+        }
+
         $db->transComplete();
 
         return redirect()->route('podcast-view', [$newPodcastId]);
diff --git a/modules/Admin/Language/en/Episode.php b/modules/Admin/Language/en/Episode.php
index e82ff8ca8b..539f6b8911 100644
--- a/modules/Admin/Language/en/Episode.php
+++ b/modules/Admin/Language/en/Episode.php
@@ -34,9 +34,11 @@ return [
     'create' => 'Add an episode',
     'publication_status' => [
         'published' => 'Published',
+        'with_podcast' => 'Published',
         'scheduled' => 'Scheduled',
         'not_published' => 'Not published',
     ],
+    'with_podcast_hint' => 'To be published at the same time as the podcast',
     'list' => [
         'search' => [
             'placeholder' => 'Search for an episode',
@@ -55,8 +57,15 @@ return [
     'messages' => [
         'createSuccess' => 'Episode has been successfully created!',
         'editSuccess' => 'Episode has been successfully updated!',
+        'publishSuccess' => '{publication_status, select,
+            published {Episode successfully published!}
+            scheduled {Episode publication successfully scheduled!}
+            with_podcast {This episode will be published at the same time as the podcast.}
+            other {This episode is not published.}
+        }',
         'publishCancelSuccess' => 'Episode publication successfully cancelled!',
         'unpublishBeforeDeleteTip' => 'You must unpublish the episode before deleting it.',
+        'scheduleDateError' => 'Schedule date must be set!',
         'deletePublishedEpisodeError' => 'Please unpublish the episode before deleting it.',
         'deleteSuccess' => 'Episode successfully deleted!',
         'deleteError' => 'Failed to delete episode {type, select,
@@ -138,9 +147,9 @@ return [
             'If you need RSS tags that Castopod does not handle, set them here.',
         'custom_rss' => 'Custom RSS tags for the episode',
         'custom_rss_hint' => 'This will be injected within the ❬item❭ tag.',
-        'block' => 'Episode should be hidden from all platforms',
+        'block' => 'Episode should be hidden from public catalogues',
         'block_hint' =>
-            'The episode show or hide post. If you want this episode removed from the Apple directory, toggle this on.',
+            'The episode show or hide status: toggling this on prevents the episode from appearing in Apple Podcasts, Google Podcasts, and any third party apps that pull shows from these directories. (Not guaranteed)',
         'submit_create' => 'Create episode',
         'submit_edit' => 'Save episode',
     ],
@@ -154,6 +163,7 @@ return [
         'publication_method' => [
             'now' => 'Now',
             'schedule' => 'Schedule',
+            'with_podcast' => 'Publish alongside podcast',
         ],
         'scheduled_publication_date' => 'Scheduled publication date',
         'scheduled_publication_date_clear' => 'Clear publication date',
diff --git a/modules/Admin/Language/en/Podcast.php b/modules/Admin/Language/en/Podcast.php
index 2561b87667..19a022b563 100644
--- a/modules/Admin/Language/en/Podcast.php
+++ b/modules/Admin/Language/en/Podcast.php
@@ -16,14 +16,17 @@ return [
     'new_episode' => 'New Episode',
     'view' => 'View podcast',
     'edit' => 'Edit podcast',
+    'publish' => 'Publish podcast',
+    'publish_edit' => 'Edit publication',
     'delete' => 'Delete podcast',
     'see_episodes' => 'See episodes',
     'see_contributors' => 'See contributors',
     'go_to_page' => 'Go to page',
     'latest_episodes' => 'Latest episodes',
     'see_all_episodes' => 'See all episodes',
+    'draft' => 'Draft',
     'messages' => [
-        'createSuccess' => 'Podcast has been successfully created!',
+        'createSuccess' => 'Podcast successfully created!',
         'editSuccess' => 'Podcast has been successfully updated!',
         'importSuccess' => 'Podcast has been successfully imported!',
         'deleteSuccess' => 'Podcast @{podcast_handle} successfully deleted!',
@@ -46,6 +49,10 @@ return [
         } added to the podcast!',
         'podcastFeedUpToDate' => 'Podcast is already up to date.',
         'podcastNotImported' => 'Podcast could not be updated as it was not imported.',
+        'publishError' => 'This podcast is either already published or scheduled for publication.',
+        'publishEditError' => 'This podcast is not scheduled for publication.',
+        'publishCancelSuccess' => 'Podcast publication successfully cancelled!',
+        'scheduleDateError' => 'Schedule date must be set!',
     ],
     'form' => [
         'identity_section_title' => 'Podcast identity',
@@ -121,7 +128,9 @@ return [
         'partner_link_url_hint' => 'The generic partner link address',
         'partner_image_url_hint' => 'The generic partner image address',
         'status_section_title' => 'Status',
-        'block' => 'Podcast should be hidden from all platforms',
+        'block' => 'Podcast should be hidden from public catalogues',
+        'block_hint' =>
+            'The podcast show or hide status: toggling this on prevents the entire podcast from appearing in Apple Podcasts, Google Podcasts, and any third party apps that pull shows from these directories. (Not guaranteed)',
         'complete' => 'Podcast will not be having new episodes',
         'lock' => 'Prevent podcast from being copied',
         'lock_hint' =>
@@ -242,6 +251,32 @@ return [
         'film_reviews' => 'Film Reviews',
         'tv_reviews' => 'TV Reviews',
     ],
+    'publish_form' => [
+        'back_to_podcast_dashboard' => 'Back to podcast dashboard',
+        'post' => 'Your announcement post',
+        'post_hint' =>
+            "Write a message to announce the publication of your podcast. The message will be featured in your podcast's homepage.",
+        'message_placeholder' => 'Write your message…',
+        'submit' => 'Publish',
+        'publication_date' => 'Publication date',
+        'publication_method' => [
+            'now' => 'Now',
+            'schedule' => 'Schedule',
+        ],
+        'scheduled_publication_date' => 'Scheduled publication date',
+        'scheduled_publication_date_hint' =>
+            'You can schedule the podcast release by setting a future publication date. This field must be formatted as YYYY-MM-DD HH:mm',
+        'submit_edit' => 'Edit publication',
+        'cancel_publication' => 'Cancel publication',
+        'message_warning' => 'You did not write a message for your announcement post!',
+        'message_warning_hint' => 'Having a message increases social engagement, resulting in a better visibility for your podcast.',
+        'message_warning_submit' => 'Publish anyway',
+    ],
+    'publication_status_banner' => [
+        'draft_mode' => 'draft mode',
+        'not_published' => 'This podcast is not yet published.',
+        'scheduled' => 'This podcast is scheduled for publication on {publication_date}.',
+    ],
     'delete_form' => [
         'disclaimer' =>
             "Deleting the podcast will delete all episodes, media files, posts and analytics associated with it. This action is irreversible, you will not be able to retrieve them afterwards.",
diff --git a/modules/WebSub/Controllers/WebSubController.php b/modules/WebSub/Controllers/WebSubController.php
index 72e6d31472..0ca59e9c46 100644
--- a/modules/WebSub/Controllers/WebSubController.php
+++ b/modules/WebSub/Controllers/WebSubController.php
@@ -32,6 +32,7 @@ class WebSubController extends Controller
             ->select('podcasts.*')
             ->join('episodes', 'podcasts.id = episodes.podcast_id', 'left outer')
             ->where('podcasts.is_published_on_hubs', false)
+            ->where('`' . $podcastModel->db->getPrefix() . 'podcasts`.`published_at` <= UTC_TIMESTAMP()', null, false)
             ->orGroupStart()
             ->where('episodes.is_published_on_hubs', false)
             ->where('`' . $podcastModel->db->getPrefix() . 'episodes`.`published_at` <= UTC_TIMESTAMP()', null, false)
diff --git a/themes/cp_admin/_layout.php b/themes/cp_admin/_layout.php
index 621e46fec0..5d25b97012 100644
--- a/themes/cp_admin/_layout.php
+++ b/themes/cp_admin/_layout.php
@@ -39,6 +39,9 @@
                 </div>
             </div>
         </header>
+        <?php if (isset($podcast) && $podcast->publication_status !== 'published'): ?>
+                <?= publication_status_banner($podcast->published_at, $podcast->id, $podcast->publication_status) ?>
+        <?php endif ?>
         <div class="px-2 py-8 mx-auto md:px-12">
             <?= view('_message_block') ?>
             <?= $this->renderSection('content') ?>
diff --git a/themes/cp_admin/episode/publish.php b/themes/cp_admin/episode/publish.php
index e3a5e22351..50cc50495e 100644
--- a/themes/cp_admin/episode/publish.php
+++ b/themes/cp_admin/episode/publish.php
@@ -69,28 +69,30 @@
     </footer>
 </div>
 
-<fieldset class="flex flex-col">
-<legend class="text-lg font-semibold"><?= lang(
+<?php if ($podcast->publication_status === 'published'): ?>
+    <fieldset class="flex flex-col">
+    <legend class="text-lg font-semibold"><?= lang(
         'Episode.publish_form.publication_date',
     ) ?></legend>
-    <Forms.Radio value="now" name="publication_method" isChecked="<?= old('publication_method') ? old('publish') === 'now' : true ?>"><?= lang('Episode.publish_form.publication_method.now') ?></Forms.Radio>
-    <div class="inline-flex flex-wrap items-center radio-toggler">
-        <input
-            class="w-6 h-6 border-contrast text-accent-base border-3 focus:ring-accent"
-            type="radio" id="schedule" name="publication_method" value="schedule" <?= old('publication_method') && old('publication_method') === 'schedule' ? 'checked' : '' ?> />
-        <Label for="schedule" class="pl-2 leading-8"><?= lang('Episode.publish_form.publication_method.schedule') ?></label>
-        <div class="w-full mt-2 radio-toggler-element">
-            <Forms.Field
-                as="DatetimePicker"
-                name="scheduled_publication_date"
-                label="<?= lang('Episode.publish_form.scheduled_publication_date') ?>"
-                hint="<?= lang('Episode.publish_form.scheduled_publication_date_hint') ?>"
-                value="<?= $episode->published_at ?>"
-            />
+        <Forms.Radio value="now" name="publication_method" isChecked="<?= old('publication_method') ? old('publish') === 'now' : true ?>"><?= lang('Episode.publish_form.publication_method.now') ?></Forms.Radio>
+        <div class="inline-flex flex-wrap items-center radio-toggler">
+            <input
+                class="w-6 h-6 border-contrast text-accent-base border-3 focus:ring-accent"
+                type="radio" id="schedule" name="publication_method" value="schedule" <?= old('publication_method') && old('publication_method') === 'schedule' ? 'checked' : '' ?> />
+            <Label for="schedule" class="pl-2 leading-8"><?= lang('Episode.publish_form.publication_method.schedule') ?></label>
+            <div class="w-full mt-2 radio-toggler-element">
+                <Forms.Field
+                    as="DatetimePicker"
+                    name="scheduled_publication_date"
+                    label="<?= lang('Episode.publish_form.scheduled_publication_date') ?>"
+                    hint="<?= lang('Episode.publish_form.scheduled_publication_date_hint') ?>"
+                    value="<?= $episode->published_at ?>"
+                />
+            </div>
         </div>
-    </div>
-</fieldset>
-
+    </fieldset>
+<?php endif ?>
+    
 <Alert id="publish-warning" variant="warning" glyph="alert" class="hidden mt-2" title="<?= lang('Episode.publish_form.message_warning') ?>"><?= lang('Episode.publish_form.message_warning_hint') ?></Alert>
 
 <div class="flex items-center justify-between w-full mt-4">
diff --git a/themes/cp_admin/episode/publish_edit.php b/themes/cp_admin/episode/publish_edit.php
index 56af8dfd4e..216d1e70e8 100644
--- a/themes/cp_admin/episode/publish_edit.php
+++ b/themes/cp_admin/episode/publish_edit.php
@@ -73,27 +73,29 @@
     </footer>
 </div>
 
-<fieldset class="flex flex-col">
-<legend class="text-lg font-semibold"><?= lang(
+<?php if ($podcast->publication_status === 'published'): ?>
+    <fieldset class="flex flex-col">
+    <legend class="text-lg font-semibold"><?= lang(
                     'Episode.publish_form.publication_date',
                 ) ?></legend>
-    <Forms.Radio value="now" name="publication_method" isChecked="<?= old('publication_method') && old('publish') === 'now' ?>"><?= lang('Episode.publish_form.publication_method.now') ?></Forms.Radio>
-    <div class="inline-flex flex-wrap items-center radio-toggler">
-        <input
-            class="w-6 h-6 border-contrast text-accent-base border-3 focus:ring-accent"
-            type="radio" id="schedule" name="publication_method" value="schedule" <?= old('publication_method') ? old('publication_method') === 'schedule' : 'checked' ?> />
-        <Label for="schedule" class="pl-2 leading-8"><?= lang('Episode.publish_form.publication_method.schedule') ?></label>
-        <div class="w-full mt-2 radio-toggler-element">
-            <Forms.Field
-                as="DatetimePicker"
-                name="scheduled_publication_date"
-                label="<?= lang('Episode.publish_form.scheduled_publication_date') ?>"
-                hint="<?= lang('Episode.publish_form.scheduled_publication_date_hint') ?>"
-                value="<?= $episode->published_at ?>"
-            />
+        <Forms.Radio value="now" name="publication_method" isChecked="<?= old('publication_method') && old('publish') === 'now' ?>"><?= lang('Episode.publish_form.publication_method.now') ?></Forms.Radio>
+        <div class="inline-flex flex-wrap items-center radio-toggler">
+            <input
+                class="w-6 h-6 border-contrast text-accent-base border-3 focus:ring-accent"
+                type="radio" id="schedule" name="publication_method" value="schedule" <?= old('publication_method') ? old('publication_method') === 'schedule' : 'checked' ?> />
+            <Label for="schedule" class="pl-2 leading-8"><?= lang('Episode.publish_form.publication_method.schedule') ?></label>
+            <div class="w-full mt-2 radio-toggler-element">
+                <Forms.Field
+                    as="DatetimePicker"
+                    name="scheduled_publication_date"
+                    label="<?= lang('Episode.publish_form.scheduled_publication_date') ?>"
+                    hint="<?= lang('Episode.publish_form.scheduled_publication_date_hint') ?>"
+                    value="<?= $episode->published_at ?>"
+                />
+            </div>
         </div>
-    </div>
-</fieldset>
+    </fieldset>
+<?php endif ?>
 
 <Alert id="publish-warning" variant="warning" glyph="alert" class="hidden mt-2" title="<?= lang('Episode.publish_form.message_warning') ?>"><?= lang('Episode.publish_form.message_warning_hint') ?></Alert>
 
diff --git a/themes/cp_admin/podcast/_card.php b/themes/cp_admin/podcast/_card.php
index 61a60330fd..1479777591 100644
--- a/themes/cp_admin/podcast/_card.php
+++ b/themes/cp_admin/podcast/_card.php
@@ -1,11 +1,19 @@
 <article class="relative h-full overflow-hidden transition shadow bg-elevated border-3 border-subtle group rounded-xl hover:shadow-xl focus-within:shadow-xl focus-within:ring-accent">
     <a href="<?= route_to('podcast-view', $podcast->id) ?>" class="flex flex-col justify-end w-full h-full text-white group">
         <div class="absolute bottom-0 left-0 z-10 w-full h-full backdrop-gradient mix-blend-multiply"></div>
-        <div class="w-full h-full overflow-hidden bg-header">
+        <div class="<?= 'w-full h-full overflow-hidden bg-header' . ($podcast->publication_status !== 'published' ? ' grayscale group-hover:grayscale-[60%]' : '') ?>">
             <img
             alt="<?= esc($podcast->title) ?>"
             src="<?= $podcast->cover->medium_url ?>" class="object-cover w-full h-full transition duration-200 ease-in-out transform aspect-square group-focus:scale-105 group-hover:scale-105" loading="lazy" />
         </div>
+        <?php if ($podcast->publication_status !== 'published'): ?>
+            <span class="absolute top-0 left-0 flex items-center px-1 mt-2 ml-2 text-sm font-semibold text-gray-600 border border-gray-600 rounded bg-gray-50">
+                <?= lang('Podcast.draft') ?>
+                <?php if ($podcast->publication_status === 'scheduled'): ?>
+                    <Icon glyph="timer" class="flex-shrink-0 ml-1 text-lg" />
+                <?php endif ?>
+            </span>
+        <?php endif ?>
         <div class="absolute z-20 w-full px-4 pb-4 transition duration-75 ease-out translate-y-6 group-focus:translate-y-0 group-hover:translate-y-0">
             <h2 class="font-bold leading-none truncate font-display"><?= esc($podcast->title) ?></h2>
             <p class="text-sm transition duration-150 opacity-0 group-focus:opacity-100 group-hover:opacity-100">@<?= esc($podcast->handle) ?></p>
diff --git a/themes/cp_admin/podcast/create.php b/themes/cp_admin/podcast/create.php
index 3d4250e3fc..6cfc85b0e8 100644
--- a/themes/cp_admin/podcast/create.php
+++ b/themes/cp_admin/podcast/create.php
@@ -204,7 +204,7 @@
     <Forms.Toggler class="mb-2" name="lock" value="yes" checked="true" hint="<?= lang('Podcast.form.lock_hint') ?>">
         <?= lang('Podcast.form.lock') ?>
     </Forms.Toggler>
-    <Forms.Toggler class="mb-2" name="block" value="yes" checked="false">
+    <Forms.Toggler class="mb-2" name="block" value="yes" checked="false" hint="<?= lang('Podcast.form.block_hint') ?>">
         <?= lang('Podcast.form.block') ?>
     </Forms.Toggler>
     <Forms.Toggler name="complete" value="yes" checked="false">
diff --git a/themes/cp_admin/podcast/edit.php b/themes/cp_admin/podcast/edit.php
index 0fa9ae27c1..7e90356892 100644
--- a/themes/cp_admin/podcast/edit.php
+++ b/themes/cp_admin/podcast/edit.php
@@ -244,7 +244,7 @@
     <Forms.Toggler class="mb-2" name="lock" value="yes" checked="<?= $podcast->is_locked ? 'true' : 'false' ?>" hint="<?= lang('Podcast.form.lock_hint') ?>">
         <?= lang('Podcast.form.lock') ?>
     </Forms.Toggler>
-    <Forms.Toggler class="mb-2" name="block" value="yes" checked="<?= $podcast->is_blocked ? 'true' : 'false'  ?>">
+    <Forms.Toggler class="mb-2" name="block" value="yes" checked="<?= $podcast->is_blocked ? 'true' : 'false'  ?>" hint="<?= lang('Podcast.form.block_hint') ?>">
         <?= lang('Podcast.form.block') ?>
     </Forms.Toggler>
     <Forms.Toggler name="complete" value="yes" checked="<?= $podcast->is_completed ? 'true' : 'false' ?>">
diff --git a/themes/cp_admin/podcast/publish.php b/themes/cp_admin/podcast/publish.php
new file mode 100644
index 0000000000..8469551fec
--- /dev/null
+++ b/themes/cp_admin/podcast/publish.php
@@ -0,0 +1,80 @@
+<?= $this->extend('_layout') ?>
+
+<?= $this->section('title') ?>
+<?= lang('Podcast.publish') ?>
+<?= $this->endSection() ?>
+
+<?= $this->section('pageTitle') ?>
+<?= lang('Podcast.publish') ?>
+<?= $this->endSection() ?>
+
+<?= $this->section('content') ?>
+
+<?= anchor(
+    route_to('podcast-view', $podcast->id),
+    icon('arrow-left', 'mr-2 text-lg') . lang('Podcast.publish_form.back_to_podcast_dashboard'),
+    [
+        'class' => 'inline-flex items-center font-semibold mr-4 text-sm focus:ring-accent',
+    ],
+) ?>
+
+<form action="<?= route_to('podcast-publish', $podcast->id) ?>" method="POST" class="flex flex-col items-start w-full max-w-lg mx-auto mt-4" data-submit="validate-message">
+<?= csrf_field() ?>
+<input type="hidden" name="client_timezone" value="UTC" />
+
+<label for="message" class="text-lg font-semibold"><?= lang(
+    'Podcast.publish_form.post',
+) ?></label>
+<small class="max-w-md mb-2 text-skin-muted"><?= lang('Podcast.publish_form.post_hint') ?></small>
+<div class="mb-8 overflow-hidden shadow-md bg-elevated rounded-xl">
+    <div class="flex px-4 py-3 gap-x-2">
+        <img src="<?= $podcast->actor->avatar_image_url ?>" alt="<?= esc($podcast->actor->display_name) ?>" class="w-10 h-10 rounded-full aspect-square" loading="lazy" />
+        <div class="flex flex-col min-w-0">
+            <p class="flex items-baseline min-w-0">
+                <span class="mr-2 font-semibold truncate"><?= esc($podcast->actor->display_name) ?></span>
+                <span class="text-sm truncate text-skin-muted">@<?= esc($podcast->actor->username) ?></span>
+            </p>
+        </div>
+    </div>
+    <div class="px-4 mb-2">
+        <Forms.Textarea name="message" placeholder="<?= lang('Podcast.publish_form.message_placeholder') ?>" autofocus="" rows="2" />
+    </div>
+    <footer class="flex justify-around px-6 py-3">
+        <span class="inline-flex items-center"><Icon glyph="chat" class="mr-1 text-xl opacity-40" />0</span>
+        <span class="inline-flex items-center"><Icon glyph="repeat" class="mr-1 text-xl opacity-40" />0</span>
+        <span class="inline-flex items-center"><Icon glyph="heart" class="mr-1 text-xl opacity-40" />0</span>
+    </footer>
+</div>
+
+<fieldset class="flex flex-col">
+    <legend class="text-lg font-semibold"><?= lang(
+    'Podcast.publish_form.publication_date',
+) ?></legend>
+    <Forms.Radio value="now" name="publication_method" isChecked="<?= old('publication_method') ? old('publish') === 'now' : true ?>"><?= lang('Podcast.publish_form.publication_method.now') ?></Forms.Radio>
+    <div class="inline-flex flex-wrap items-center radio-toggler">
+        <input
+            class="w-6 h-6 border-contrast text-accent-base border-3 focus:ring-accent"
+            type="radio" id="schedule" name="publication_method" value="schedule" <?= old('publication_method') && old('publication_method') === 'schedule' ? 'checked' : '' ?> />
+        <Label for="schedule" class="pl-2 leading-8"><?= lang('Podcast.publish_form.publication_method.schedule') ?></label>
+        <div class="w-full mt-2 radio-toggler-element">
+            <Forms.Field
+                as="DatetimePicker"
+                name="scheduled_publication_date"
+                label="<?= lang('Podcast.publish_form.scheduled_publication_date') ?>"
+                hint="<?= lang('Podcast.publish_form.scheduled_publication_date_hint') ?>"
+                value="<?= $podcast->published_at ?>"
+            />
+        </div>
+    </div>
+</fieldset>
+
+<Alert id="publish-warning" variant="warning" glyph="alert" class="hidden mt-2" title="<?= lang('Podcast.publish_form.message_warning') ?>"><?= lang('Podcast.publish_form.message_warning_hint') ?></Alert>
+
+<div class="flex items-center justify-between w-full mt-4">
+    <Button uri="<?= route_to('podcast-publish-cancel', $podcast->id) ?>" variant="danger"><?= lang('Podcast.publish_form.cancel_publication') ?></Button>
+    <Button variant="primary" type="submit" data-btn-text-warning="<?= lang('Podcast.publish_form.message_warning_submit') ?>" data-btn-text="<?= lang('Podcast.publish_form.submit') ?>"><?= lang('Podcast.publish_form.submit') ?></Button>
+</div>
+
+</form>
+
+<?= $this->endSection() ?>
diff --git a/themes/cp_admin/podcast/publish_edit.php b/themes/cp_admin/podcast/publish_edit.php
new file mode 100644
index 0000000000..1cca91b9b2
--- /dev/null
+++ b/themes/cp_admin/podcast/publish_edit.php
@@ -0,0 +1,81 @@
+<?= $this->extend('_layout') ?>
+
+<?= $this->section('title') ?>
+<?= lang('Podcast.publish_edit') ?>
+<?= $this->endSection() ?>
+
+<?= $this->section('pageTitle') ?>
+<?= lang('Podcast.publish_edit') ?>
+<?= $this->endSection() ?>
+
+<?= $this->section('content') ?>
+
+<?= anchor(
+    route_to('podcast-view', $podcast->id),
+    icon('arrow-left', 'mr-2 text-lg') . lang('Podcast.publish_form.back_to_podcast_dashboard'),
+    [
+        'class' => 'inline-flex items-center font-semibold mr-4 text-sm',
+    ],
+) ?>
+
+<form action="<?= route_to('podcast-publish_edit', $podcast->id) ?>" method="POST" class="flex flex-col items-start w-full max-w-lg mx-auto mt-4" data-submit="validate-message">
+<?= csrf_field() ?>
+<input type="hidden" name="client_timezone" value="UTC" />
+
+<label for="message" class="text-lg font-semibold"><?= lang(
+    'Podcast.publish_form.post',
+) ?></label>
+<small class="max-w-md mb-2 text-skin-muted"><?= lang('Podcast.publish_form.post_hint') ?></small>
+<div class="mb-8 overflow-hidden shadow-md bg-elevated rounded-xl">
+    <div class="flex px-4 py-3 gap-x-2">
+        <img src="<?= $podcast->actor->avatar_image_url ?>" alt="<?= esc($podcast->actor->display_name) ?>" class="w-10 h-10 rounded-full aspect-square" loading="lazy" />
+        <div class="flex flex-col min-w-0">
+            <p class="flex items-baseline min-w-0">
+                <span class="mr-2 font-semibold truncate"><?= esc($podcast->actor->display_name) ?></span>
+                <span class="text-sm truncate text-skin-muted">@<?= esc($podcast->actor->username) ?></span>
+            </p>
+            <?= relative_time($podcast->published_at, 'text-xs text-skin-muted') ?>
+        </div>
+    </div>
+    <div class="px-4 mb-2">
+        <Forms.Textarea name="message" placeholder="<?= lang('Podcast.publish_form.message_placeholder') ?>" autofocus="" value="<?= $post !== null ? esc($post->message) : '' ?>" rows="2" />
+    </div>
+    <footer class="flex justify-around px-6 py-3">
+        <span class="inline-flex items-center"><Icon glyph="chat" class="mr-1 text-xl opacity-40" />0</span>
+        <span class="inline-flex items-center"><Icon glyph="repeat" class="mr-1 text-xl opacity-40" />0</span>
+        <span class="inline-flex items-center"><Icon glyph="heart" class="mr-1 text-xl opacity-40" />0</span>
+    </footer>
+</div>
+
+<fieldset class="flex flex-col">
+<legend class="text-lg font-semibold"><?= lang(
+    'Podcast.publish_form.publication_date',
+) ?></legend>
+    <Forms.Radio value="now" name="publication_method" isChecked="<?= old('publication_method') && old('publish') === 'now' ?>"><?= lang('Podcast.publish_form.publication_method.now') ?></Forms.Radio>
+    <div class="inline-flex flex-wrap items-center radio-toggler">
+        <input
+            class="w-6 h-6 border-contrast text-accent-base border-3 focus:ring-accent"
+            type="radio" id="schedule" name="publication_method" value="schedule" <?= old('publication_method') ? old('publication_method') === 'schedule' : 'checked' ?> />
+        <Label for="schedule" class="pl-2 leading-8"><?= lang('Podcast.publish_form.publication_method.schedule') ?></label>
+        <div class="w-full mt-2 radio-toggler-element">
+            <Forms.Field
+                as="DatetimePicker"
+                name="scheduled_publication_date"
+                label="<?= lang('Podcast.publish_form.scheduled_publication_date') ?>"
+                hint="<?= lang('Podcast.publish_form.scheduled_publication_date_hint') ?>"
+                value="<?= $podcast->published_at ?>"
+            />
+        </div>
+    </div>
+</fieldset>
+
+<Alert id="publish-warning" variant="warning" glyph="alert" class="hidden mt-2" title="<?= lang('Episode.publish_form.message_warning') ?>"><?= lang('Podcast.publish_form.message_warning_hint') ?></Alert>
+
+<div class="flex items-center justify-between w-full mt-4">
+    <Button uri="<?= route_to('podcast-publish-cancel', $podcast->id) ?>" variant="danger"><?= lang('Podcast.publish_form.cancel_publication') ?></Button>
+    <Button variant="primary" type="submit" data-btn-text-warning="<?= lang('Podcast.publish_form.message_warning_submit') ?>" data-btn-text="<?= lang('Podcast.publish_form.submit_edit') ?>"><?= lang('Podcast.publish_form.submit_edit') ?></Button>
+</div>
+
+</form>
+
+<?= $this->endSection() ?>
-- 
GitLab