diff --git a/app/Resources/icons/history.svg b/app/Resources/icons/history.svg new file mode 100644 index 0000000000000000000000000000000000000000..684091df227fcb0b40123a5998967479270c17f0 --- /dev/null +++ b/app/Resources/icons/history.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <path fill="none" d="M0 0H24V24H0z"/> + <path d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12h2c0 4.418 3.582 8 8 8s8-3.582 8-8-3.582-8-8-8C9.536 4 7.332 5.114 5.865 6.865L8 9H2V3l2.447 2.446C6.28 3.336 8.984 2 12 2zm1 5v4.585l3.243 3.243-1.415 1.415L11 12.413V7h2z"/> +</svg> diff --git a/app/Views/Components/IconButton.php b/app/Views/Components/IconButton.php index f02c80788235de91411fe5e468691deebce86e38..636f755c91576ad3abc3e7a7ec26b854bd0b7f36 100644 --- a/app/Views/Components/IconButton.php +++ b/app/Views/Components/IconButton.php @@ -4,27 +4,28 @@ declare(strict_types=1); namespace App\Views\Components; -use ViewComponents\Component; - -class IconButton extends Component +class IconButton extends Button { public string $glyph = ''; - public function render(): string + public function __construct(array $attributes) { - $attributes = [ + $iconButtonAttributes = [ 'isSquared' => 'true', - 'title' => $this->slot, + 'title' => $attributes['slot'], 'data-tooltip' => 'bottom', ]; - $attributes = array_merge($attributes, $this->attributes); + $glyphSize = [ + 'small' => 'text-sm', + 'base' => 'text-lg', + 'large' => 'text-2xl', + ]; - $attributes['slot'] = icon($this->glyph); + $allAttributes = array_merge($attributes, $iconButtonAttributes); - unset($attributes['glyph']); + parent::__construct($allAttributes); - $iconButton = new Button($attributes); - return $iconButton->render(); + $this->slot = icon($this->glyph, $glyphSize[$this->size]); } } diff --git a/modules/Admin/Config/Routes.php b/modules/Admin/Config/Routes.php index 06aadf0e358a1c265b7b3af9897ae575c609f788..ad767a0d11e6bafe049d925998eec0241583fc0f 100644 --- a/modules/Admin/Config/Routes.php +++ b/modules/Admin/Config/Routes.php @@ -327,6 +327,23 @@ $routes->group( 'permission:podcast-manage_publications', ], ); + $routes->get( + 'publish-date-edit', + 'EpisodeController::publishDateEdit/$1/$2', + [ + 'as' => 'episode-publish_date_edit', + 'filter' => + 'permission:podcast-manage_publications', + ], + ); + $routes->post( + 'publish-date-edit', + 'EpisodeController::attemptPublishDateEdit/$1/$2', + [ + 'filter' => + 'permission:podcast-manage_publications', + ], + ); $routes->get( 'unpublish', 'EpisodeController::unpublish/$1/$2', diff --git a/modules/Admin/Controllers/EpisodeController.php b/modules/Admin/Controllers/EpisodeController.php index 9e5e906f7044693fdef87043bbf9fe0e3da8ec3a..6c14d7f38597a5db4fd932b90549035c63cd7a36 100644 --- a/modules/Admin/Controllers/EpisodeController.php +++ b/modules/Admin/Controllers/EpisodeController.php @@ -683,29 +683,104 @@ class EpisodeController extends BaseController return redirect()->route('episode-view', [$this->podcast->id, $this->episode->id]); } - public function unpublish(): string | RedirectResponse + public function publishDateEdit(): string|RedirectResponse { - if ($this->episode->publication_status === 'published') { - helper(['form']); + // only accessible if episode is already published + if ($this->episode->publication_status !== 'published') { + return redirect()->route('episode-view', [$this->podcast->id, $this->episode->id])->with( + 'error', + lang('Episode.publish_date_edit_error') + ); + } - $data = [ - 'podcast' => $this->podcast, - 'episode' => $this->episode, - ]; + helper('form'); - replace_breadcrumb_params([ - 0 => $this->podcast->title, - 1 => $this->episode->title, - ]); - return view('episode/unpublish', $data); + $data = [ + 'podcast' => $this->podcast, + 'episode' => $this->episode, + ]; + + replace_breadcrumb_params([ + 0 => $this->podcast->title, + 1 => $this->episode->title, + ]); + + return view('episode/publish_date_edit', $data); + } + + /** + * Allows to set an episode's publication date to a past date + * + * Prevents setting a future date as it does not make sense to set a future published date to an already published + * episode. This also prevents any side-effects from occurring. + */ + public function attemptPublishDateEdit(): RedirectResponse + { + $rules = [ + 'new_publication_date' => 'valid_date[Y-m-d H:i]', + ]; + + if (! $this->validate($rules)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $this->validator->getErrors()); + } + + $newPublicationDate = $this->request->getPost('new_publication_date'); + + $newPublicationDate = Time::createFromFormat( + 'Y-m-d H:i', + $newPublicationDate, + $this->request->getPost('client_timezone'), + )->setTimezone(app_timezone()); + + if ($newPublicationDate->isAfter(Time::now())) { + return redirect() + ->back() + ->withInput() + ->with('error', lang('Episode.publish_date_edit_future_error')); + } + + $this->episode->published_at = $newPublicationDate; + + $episodeModel = new EpisodeModel(); + if (! $episodeModel->update($this->episode->id, $this->episode)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $episodeModel->errors()); } return redirect()->route('episode-view', [$this->podcast->id, $this->episode->id])->with( - 'error', - lang('Episode.unpublish_error') + 'message', + lang('Episode.publish_date_edit_success') ); } + public function unpublish(): string | RedirectResponse + { + if ($this->episode->publication_status !== 'published') { + return redirect()->route('episode-view', [$this->podcast->id, $this->episode->id])->with( + 'error', + lang('Episode.unpublish_error') + ); + } + + helper(['form']); + + $data = [ + 'podcast' => $this->podcast, + 'episode' => $this->episode, + ]; + + replace_breadcrumb_params([ + 0 => $this->podcast->title, + 1 => $this->episode->title, + ]); + return view('episode/unpublish', $data); + } + public function attemptUnpublish(): RedirectResponse { $rules = [ diff --git a/modules/Admin/Language/en/Breadcrumb.php b/modules/Admin/Language/en/Breadcrumb.php index 46863af3bba195ac1a71c61b4eac718652fe52e8..f3269bfa4e45e583492211d3e7177cd933dcaaeb 100644 --- a/modules/Admin/Language/en/Breadcrumb.php +++ b/modules/Admin/Language/en/Breadcrumb.php @@ -25,6 +25,7 @@ return [ 'persons' => 'persons', 'publish' => 'publish', 'publish-edit' => 'edit publication', + 'publish-date-edit' => 'edit publication date', 'unpublish' => 'unpublish', 'delete' => 'delete', 'fediverse' => 'fediverse', diff --git a/modules/Admin/Language/en/Episode.php b/modules/Admin/Language/en/Episode.php index 92631ddf1c95dba582232b069567e7e91bb110e9..91313a7c5aa4d299bfd17c32825c0945a915c2bb 100644 --- a/modules/Admin/Language/en/Episode.php +++ b/modules/Admin/Language/en/Episode.php @@ -24,10 +24,14 @@ return [ 'edit' => 'Edit', 'publish' => 'Publish', 'publish_edit' => 'Edit publication', + 'publish_date_edit' => 'Edit publication date', 'unpublish' => 'Unpublish', 'publish_error' => 'Episode is already published.', 'publish_edit_error' => 'Episode is already published.', 'publish_cancel_error' => 'Episode is already published.', + 'publish_date_edit_error' => 'Episode has not been published yet, you cannot edit its publication date.', + 'publish_date_edit_future_error' => 'Episode\'s publication date can only be set to a past date! If you would like to reschedule it, unpublish it first.', + 'publish_date_edit_success' => 'Episode\'s publication date has been updated successfully!', 'unpublish_error' => 'Episode is not published.', 'delete' => 'Delete', 'go_to_page' => 'Go to page', @@ -178,6 +182,11 @@ return [ 'message_warning_hint' => 'Having a message increases social engagement, resulting in a better visibility for your episode.', 'message_warning_submit' => 'Publish anyways', ], + 'publish_date_edit_form' => [ + 'new_publication_date' => 'New publication date', + 'new_publication_date_hint' => 'Must be set to a past date.', + 'submit' => 'Edit publication date', + ], 'unpublish_form' => [ 'disclaimer' => "Unpublishing the episode will delete all the comments and posts associated with it and remove it from the podcast's RSS feed.", diff --git a/themes/cp_admin/_layout.php b/themes/cp_admin/_layout.php index 78e41ca00fe2a59d0902a427cbc811b8f2a5701b..d23a48a463dcb6daa7f9bfbcfad0ed2d04f00e00 100644 --- a/themes/cp_admin/_layout.php +++ b/themes/cp_admin/_layout.php @@ -1,3 +1,9 @@ +<?php declare(strict_types=1); + +$isPodcastArea = isset($podcast) && ! isset($episode); +$isEpisodeArea = isset($podcast) && isset($episode); +?> + <!DOCTYPE html> <html lang="<?= service('request') ->getLocale() ?>"> @@ -32,9 +38,9 @@ <?= render_breadcrumb('text-xs items-center flex') ?> <div class="flex justify-between py-1"> <div class="flex flex-wrap items-center"> - <?php if ((isset($episode) && $episode->is_premium) || (isset($podcast) && $podcast->is_premium)): ?> + <?php if (($isEpisodeArea && $episode->is_premium) || ($isPodcastArea && $podcast->is_premium)): ?> <div class="inline-flex items-center"> - <IconButton uri="<?= route_to('subscription-list', $podcast->id) ?>" glyph="exchange-dollar" variant="secondary" class="p-0 mr-2 text-4xl border-0"><?= isset($episode) ? lang('PremiumPodcasts.episode_is_premium') : lang('PremiumPodcasts.podcast_is_premium') ?></IconButton> + <IconButton uri="<?= route_to('subscription-list', $podcast->id) ?>" glyph="exchange-dollar" variant="secondary" size="large" class="p-0 mr-2 border-0"><?= ($isEpisodeArea && $episode->is_premium) ? lang('PremiumPodcasts.episode_is_premium') : lang('PremiumPodcasts.podcast_is_premium') ?></IconButton> <Heading tagName="h1" size="large" class="truncate"><?= $this->renderSection('pageTitle') ?></Heading> </div> <?php else: ?> @@ -42,11 +48,11 @@ <?php endif; ?> <?= $this->renderSection('headerLeft') ?> </div> - <div class="flex flex-shrink-0 gap-x-2"><?= $this->renderSection('headerRight') ?></div> + <div class="flex items-center flex-shrink-0 gap-x-2"><?= $this->renderSection('headerRight') ?></div> </div> </div> </header> - <?php if (isset($podcast) && $podcast->publication_status !== 'published'): ?> + <?php if ($isPodcastArea && $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"> diff --git a/themes/cp_admin/_partials/_nav_aside.php b/themes/cp_admin/_partials/_nav_aside.php index 79ff2a87f611c98aacbf9c50af1e60e8efd8c82f..eddfc4b0068284814597606981446002e1dd787e 100644 --- a/themes/cp_admin/_partials/_nav_aside.php +++ b/themes/cp_admin/_partials/_nav_aside.php @@ -1,8 +1,14 @@ +<?php declare(strict_types=1); + +$isPodcastArea = isset($podcast) && ! isset($episode); +$isEpisodeArea = isset($podcast) && isset($episode); +?> + <div data-sidebar-toggler="backdrop" role="button" tabIndex="0" aria-label="<?= lang('Common.close') ?>" class="fixed z-50 hidden w-full h-full bg-gray-800/75 md:hidden"></div> <aside data-sidebar-toggler="sidebar" data-toggle-class="-translate-x-full" data-hide-class="-translate-x-full" class="h-full max-h-[calc(100vh-40px)] sticky z-50 flex flex-col row-start-2 col-start-1 text-white transition duration-200 ease-in-out transform -translate-x-full border-r top-10 border-navigation bg-navigation md:translate-x-0"> - <?php if (isset($podcast) && isset($episode)): ?> + <?php if ($isEpisodeArea): ?> <?= $this->include('episode/_sidebar') ?> - <?php elseif (isset($podcast)): ?> + <?php elseif ($isPodcastArea): ?> <?= $this->include('podcast/_sidebar') ?> <?php else: ?> <?= $this->include('_sidebar') ?> diff --git a/themes/cp_admin/episode/publish_date_edit.php b/themes/cp_admin/episode/publish_date_edit.php new file mode 100644 index 0000000000000000000000000000000000000000..7346e659cc55ede83b60daeaa5d91880f73b4690 --- /dev/null +++ b/themes/cp_admin/episode/publish_date_edit.php @@ -0,0 +1,38 @@ +<?= $this->extend('_layout') ?> + +<?= $this->section('title') ?> +<?= lang('Episode.publish_date_edit') ?> +<?= $this->endSection() ?> + +<?= $this->section('pageTitle') ?> +<?= lang('Episode.publish_date_edit') ?> +<?= $this->endSection() ?> + +<?= $this->section('content') ?> + +<?= anchor( + route_to('episode-view', $podcast->id, $episode->id), + icon('arrow-left', 'mr-2 text-lg') . lang('Episode.publish_form.back_to_episode_dashboard'), + [ + 'class' => 'inline-flex items-center font-semibold mr-4 text-sm', + ], +) ?> + +<form action="<?= route_to('episode-publish_date_edit', $podcast->id, $episode->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" /> + +<Forms.Field + as="DatetimePicker" + name="new_publication_date" + label="<?= lang('Episode.publish_date_edit_form.new_publication_date') ?>" + hint="<?= lang('Episode.publish_date_edit_form.new_publication_date_hint') ?>" + value="<?= $episode->published_at ?>" + required="true" +/> + +<Button variant="primary" type="submit" class="mt-4"><?= lang('Episode.publish_date_edit_form.submit') ?></Button> + +</form> + +<?= $this->endSection() ?> diff --git a/themes/cp_admin/episode/view.php b/themes/cp_admin/episode/view.php index 0d42adf5c489a94269482f4722305f8ced4681e5..6357011e04e93b410976b6670b684d62a906ea28 100644 --- a/themes/cp_admin/episode/view.php +++ b/themes/cp_admin/episode/view.php @@ -17,6 +17,14 @@ <?= $this->endSection() ?> <?= $this->section('headerRight') ?> +<?php if ($episode->publication_status === 'published'): ?> +<IconButton + uri="<?= route_to('episode-publish_date_edit', $podcast->id, $episode->id) ?>" + glyph="history" + variant="secondary" + glyphClass="text-xl" +><?= lang('Episode.publish_date_edit') ?></IconButton> +<?php endif; ?> <?= publication_button( $podcast->id, $episode->id,