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 bc992195d9df008147eacdd4e4b7c529c91121e7..61f3b86a70c37cd6d62574aa6201437055f9af3a 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 8f09f54ad44d9c0dcd544fc72a8415271ce0b958..365726e6f7e48508c10b772e60781945232e3c0f 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 6474642ee9b3d3a1b09cdbe4df9a70ca388e4e19..60f89c448b442a8fcfaa0520763d019def99aa9f 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 9e58b46105c211c06a6de88bc268ab19092200a1..bf1b64b7690990bd5532d135cf3fa468be265738 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 067b4dfbacb024910c769678fc11b7a181ca940c..6bef701359a52988cf7607ae7f00e8f384f15e57 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 f4b0a6020d744f320cf6faaf9de7f58bab60d322..e69e9e87e85e5881f5c97fc6b4c3f36004377b8b 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 1c8a666755def9f6f3c84e4617038858fa8061e3..f218f47b86934fec32ba198c9750a5138374f1dc 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 169acfc8de0e58bd011325c2a6f57146f6adc9de..8a5fd8c647ec796855d1c8020022f58120a826c6 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 0000000000000000000000000000000000000000..e01de7fbd9b6a27a9c7fd7ad5c0185abe6aa9d08 --- /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 eceef962dc9e75661b6dd35962434aa3dd18549f..fc539d27818e96f33c5e2247c04c4f237b1ef153 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 4bde002ef731d8f98cb5265039f342e524b17932..b3ffcd13aefdb8001ba3184ba1f2fd7290303616 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 619c56228715f22fb62426d1bc2b84967b69d3ff..18c961c2ad9daed1bcc847acdc86fe4a5bba3256 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 8a18b7b39573c5476d38ae2ca60295969ed0ff4d..e08138773edbf686d074a1c9fce805d7db0c6114 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 b200910a19860013f38a554c531590e970d633db..bd98e67192582b01f431b302b5fd315a68886c55 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 e82ff8ca8b5b8ba3696f0f66d5f25cf67d1d69ef..539f6b89118818d7f6ee5c938f1f58f25c7e8b9f 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 2561b8766768e5ff907834836905bea73ab5ec0c..19a022b563dc2d89f4ee502adc18fa92111905dc 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 72e6d31472b5e9be2a42cfe0f755d779a45a89e5..0ca59e9c46430fed62fa26e236eb60e914c153b6 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 621e46fec09278ecd25bdcfac7d5acb1eafb9f5d..5d25b970128af7fc832e73607c4855efdce482c5 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 e3a5e22351bc51b84acde96242a32ee8ac64139f..50cc50495efbcae0a2f1ab4d7601d5e84889411a 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 56af8dfd4ef07ad0559aa71c4ab359375363f3ab..216d1e70e84f1df1b301a0843878674384c4662b 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 61a60330fd08d323ff724a7f56ab119ffdbe2714..1479777591c77b7665ab334d6e154de1e301ecac 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 3d4250e3fc514b0e162e74fcdb3fde426b602200..6cfc85b0e864f560c374ccf1808bc46297e0798c 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 0fa9ae27c19dac842332cde1449df8768b105dd3..7e903568929eca8f93bba51f3bcfc2ff85fd249f 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 0000000000000000000000000000000000000000..8469551fec49cf3e3e03b74f6824317c5ad7dc42 --- /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 0000000000000000000000000000000000000000..1cca91b9b28dcdfc8276adaf07205a2cd40f2ea5 --- /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() ?>