Unverified Commit 33d01b8d authored by Yassine Doghri's avatar Yassine Doghri
Browse files

fix(ux): allow for empty message upon episode publication and warn user on submit

- clarify distiction between the announcement post and the show notes
- change "note" occurences in UI by "post"
- show warning message explaining why the podcaster should fill the message area
- the podcaster
can choose to publish the episode with an empty message anyways
- redirect user to episode
dashboard with error message when episode publication pages are inaccessible instead of showing a
404 error
- add a cancel publication button in publish-edit form when episode is scheduled

closes #129
parent 8f3e9d90
Pipeline #956 failed with stages
in 6 minutes and 7 seconds
......@@ -310,6 +310,15 @@ $routes->group(
'permission:podcast-manage_publications',
],
);
$routes->get(
'publish-cancel',
'EpisodeController::publishCancel/$1/$2',
[
'as' => 'episode-publish-cancel',
'filter' =>
'permission:podcast-manage_publications',
],
);
$routes->get(
'unpublish',
'EpisodeController::unpublish/$1/$2',
......
......@@ -388,7 +388,7 @@ class EpisodeController extends BaseController
return redirect()->back();
}
public function publish(): string
public function publish(): string | RedirectResponse
{
if ($this->episode->publication_status === 'not_published') {
helper(['form']);
......@@ -405,7 +405,10 @@ class EpisodeController extends BaseController
return view('admin/episode/publish', $data);
}
throw PageNotFoundException::forPageNotFound();
return redirect()->route('episode-view', [$this->podcast->id, $this->episode->id])->with(
'error',
lang('Episode.publish_error')
);
}
public function attemptPublish(): RedirectResponse
......@@ -478,7 +481,7 @@ class EpisodeController extends BaseController
return redirect()->route('episode-view', [$this->podcast->id, $this->episode->id]);
}
public function publishEdit(): string
public function publishEdit(): string | RedirectResponse
{
if ($this->episode->publication_status === 'scheduled') {
helper(['form']);
......@@ -500,7 +503,11 @@ class EpisodeController extends BaseController
]);
return view('admin/episode/publish_edit', $data);
}
throw PageNotFoundException::forPageNotFound();
return redirect()->route('episode-view', [$this->podcast->id, $this->episode->id])->with(
'error',
lang('Episode.publish_edit_error')
);
}
public function attemptPublishEdit(): RedirectResponse
......@@ -572,7 +579,44 @@ class EpisodeController extends BaseController
return redirect()->route('episode-view', [$this->podcast->id, $this->episode->id]);
}
public function unpublish(): string
public function publishCancel(): RedirectResponse
{
if ($this->episode->publication_status === 'scheduled') {
$db = db_connect();
$db->transStart();
$statusModel = new StatusModel();
$status = $statusModel
->where([
'actor_id' => $this->podcast->actor_id,
'episode_id' => $this->episode->id,
])
->first();
$statusModel->removeStatus($status);
$this->episode->published_at = null;
$episodeModel = new EpisodeModel();
if (! $episodeModel->update($this->episode->id, $this->episode)) {
$db->transRollback();
return redirect()
->back()
->withInput()
->with('errors', $episodeModel->errors());
}
$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(
'error',
lang('Episode.publish_cancel_error')
);
}
public function unpublish(): string | RedirectResponse
{
if ($this->episode->publication_status === 'published') {
helper(['form']);
......@@ -589,7 +633,10 @@ class EpisodeController extends BaseController
return view('admin/episode/unpublish', $data);
}
throw PageNotFoundException::forPageNotFound();
return redirect()->route('episode-view', [$this->podcast->id, $this->episode->id])->with(
'error',
lang('Episode.unpublish_error')
);
}
public function attemptUnpublish(): RedirectResponse
......
......@@ -86,11 +86,11 @@ if (! function_exists('button')) {
}
if ($options['iconLeft']) {
$label = icon($options['iconLeft'], 'mr-2') . $label;
$label = icon((string) $options['iconLeft'], 'mr-2') . $label;
}
if ($options['iconRight']) {
$label .= icon($options['iconRight'], 'ml-2');
$label .= icon((string) $options['iconRight'], 'ml-2');
}
if ($uri !== '') {
......
......@@ -27,8 +27,8 @@ return [
other {# total shares}
}',
'total_statuses' => '{numberOfTotalStatuses, plural,
one {# note}
other {# total notes}
one {# total post}
other {# total posts}
}',
'all_podcast_episodes' => 'All podcast episodes',
'back_to_podcast' => 'Go back to podcast',
......@@ -36,6 +36,10 @@ return [
'publish' => 'Publish',
'publish_edit' => 'Edit publication',
'unpublish' => 'Unpublish',
'publish_error' => 'Episode is already published.',
'publish_edit_error' => 'Episode is already published.',
'publish_cancel_error' => 'Episode is already published.',
'unpublish_error' => 'Episode is not published.',
'delete' => 'Delete',
'go_to_page' => 'Go to page',
'create' => 'Add an episode',
......@@ -112,9 +116,10 @@ return [
'submit_edit' => 'Save episode',
],
'publish_form' => [
'status' => 'Your note',
'back_to_episode_dashboard' => 'Back to episode dashboard',
'status' => 'Your announcement post',
'status_hint' =>
'The message you write will be broadcasted to all your followers in the fediverse.',
"Write a message to announce the publication of your episode. The message will be broadcasted to all your followers in the fediverse and be featured in your podcast's homepage.",
'publication_date' => 'Publication date',
'publication_method' => [
'now' => 'Now',
......@@ -126,6 +131,10 @@ return [
'You can schedule the episode release by setting a future publication date. This field must be formatted as YYYY-MM-DD HH:mm',
'submit' => 'Publish',
'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 episode.',
'message_warning_submit' => 'Publish anyways',
],
'unpublish_form' => [
'disclaimer' =>
......
......@@ -224,8 +224,8 @@ return [
other {<span class="font-semibold">#</span> followers}
}',
'statuses' => '{numberOfStatuses, plural,
one {<span class="font-semibold">#</span> note}
other {<span class="font-semibold">#</span> notes}
one {<span class="font-semibold">#</span> post}
other {<span class="font-semibold">#</span> posts}
}',
'activity' => 'Activity',
'episodes' => 'Episodes',
......
......@@ -27,8 +27,8 @@ return [
other {# partages en tout}
}',
'total_statuses' => '{numberOfTotalStatuses, plural,
one {# note}
other {# notes}
one {# message}
other {# messages}
}',
'all_podcast_episodes' => 'Tous les épisodes du podcast',
'back_to_podcast' => 'Revenir au podcast',
......@@ -36,6 +36,10 @@ return [
'publish' => 'Publier',
'publish_edit' => 'Modifier la publication',
'unpublish' => 'Dépublier',
'publish_error' => 'L’épisode est déjà publié.',
'publish_edit_error' => 'L’épisode est déjà publié.',
'publish_cancel_error' => 'L’épisode est déjà publié.',
'unpublish_error' => 'L’épisode n’est pas publié.',
'delete' => 'Supprimer',
'go_to_page' => 'Voir',
'create' => 'Ajouter un épisode',
......@@ -115,9 +119,10 @@ return [
'submit_edit' => 'Enregistrer l’épisode',
],
'publish_form' => [
'status' => 'Votre note',
'back_to_episode_dashboard' => 'Retour au tableau de bord de l’épisode',
'status' => 'Votre message de publication',
'status_hint' =>
'Le message que vous écrirez sera diffusé à toutes les personnes qui vous suivent dans le fédiverse.',
'Écrivez un message pour annoncer la publication de votre épisode. Le message sera diffusé à toutes les personnes qui vous suivent dans le fédiverse et mis en évidence sur la page d’accueil de votre podcast.',
'publication_date' => 'Date de publication',
'publication_date_clear' => 'Effacer la date de publication',
'publication_date_hint' =>
......@@ -132,6 +137,10 @@ return [
'Vous pouvez planifier la sortie de l’épisode en saisissant une date de publication future. Ce champ doit être au format YYYY-MM-DD HH:mm',
'submit' => 'Publier',
'submit_edit' => 'Modifier la publication',
'cancel_publication' => 'Annuler la publication',
'message_warning' => 'Vous n’avez pas saisi de message pour l’annonce de votre épisode !',
'message_warning_hint' => 'Ajouter un message augmente l’engagement social, menant à une meilleure visibilité pour votre épisode.',
'message_warning_submit' => 'Publish quand même',
],
'soundbites' => 'Extraits sonores',
'soundbites_form' => [
......
......@@ -226,8 +226,8 @@ return [
other {<span class="font-semibold">#</span> abonné·e·s}
}',
'notes' => '{numberOfStatuses, plural,
one {<span class="font-semibold">#</span> note}
other {<span class="font-semibold">#</span> notes}
one {<span class="font-semibold">#</span> message}
other {<span class="font-semibold">#</span> messages}
}',
'activity' => 'Activité',
'episodes' => 'Épisodes',
......
......@@ -81,7 +81,7 @@ class StatusModel extends UuidModel
*/
protected $validationRules = [
'actor_id' => 'required',
'message_html' => 'required_without[reblog_of_id]|max_length[500]',
'message_html' => 'max_length[500]',
];
/**
......
......@@ -4,6 +4,7 @@ import DateTimePicker from "./modules/DateTimePicker";
import Dropdown from "./modules/Dropdown";
import MarkdownEditor from "./modules/MarkdownEditor";
import MultiSelect from "./modules/MultiSelect";
import PublishMessageWarning from "./modules/PublishMessageWarning";
import SidebarToggler from "./modules/SidebarToggler";
import Slugify from "./modules/Slugify";
import Soundbites from "./modules/Soundbites";
......@@ -23,3 +24,4 @@ Time();
Soundbites();
Clipboard();
ThemePicker();
PublishMessageWarning();
const PublishMessageWarning = (): void => {
const publishForm: HTMLFormElement | null = document.querySelector(
"form[data-submit='validate-message']"
);
if (publishForm) {
const messageTextArea: HTMLTextAreaElement | null = publishForm.querySelector(
"[name='message']"
);
const submitButton: HTMLButtonElement | null = publishForm.querySelector(
"button[type='submit']"
);
const publishMessageWarning: HTMLDivElement | null = publishForm.querySelector(
"[id='publish-warning']"
);
if (
messageTextArea &&
submitButton &&
submitButton.dataset.btnTextWarning &&
submitButton.dataset.btnText &&
publishMessageWarning
) {
publishForm.addEventListener("submit", (event) => {
if (
messageTextArea.value === "" &&
publishMessageWarning.classList.contains("hidden")
) {
event.preventDefault();
publishMessageWarning.classList.remove("hidden");
messageTextArea.focus();
submitButton.innerText = submitButton.dataset
.btnTextWarning as string;
}
});
messageTextArea.addEventListener("input", () => {
if (
submitButton.innerText !== submitButton.dataset.btnText &&
messageTextArea.value !== ""
) {
publishMessageWarning.classList.add("hidden");
submitButton.innerText = submitButton.dataset.btnText as string;
}
});
}
}
};
export default PublishMessageWarning;
......@@ -11,26 +11,34 @@
<?= $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_open(route_to('episode-publish', $podcast->id, $episode->id), [
'method' => 'post',
'class' => 'flex flex-col max-w-xl items-start',
'class' => 'mx-auto flex flex-col max-w-xl items-start',
'data-submit' => 'validate-message'
]) ?>
<?= csrf_field() ?>
<?= form_hidden('client_timezone', 'UTC') ?>
<label for="message" class="text-lg font-semibold"><?= lang(
'Episode.publish_form.status',
) . hint_tooltip(lang('Episode.publish_form.status_hint'), 'ml-1') ?></label>
'Episode.publish_form.status',
) ?></label>
<small class="max-w-md mb-2 text-gray-600"><?= lang('Episode.publish_form.status_hint') ?></small>
<div class="mb-8 overflow-hidden bg-white shadow-md rounded-xl">
<div class="flex px-4 py-3">
<img src="<?= $podcast->actor->avatar_image_url ?>" alt="<?= $podcast
->actor->display_name ?>" class="w-12 h-12 mr-4 rounded-full"/>
->actor->display_name ?>" class="w-12 h-12 mr-4 rounded-full" />
<p class="flex items-baseline min-w-0">
<span class="mr-2 font-semibold truncate"><?= $podcast->actor
->display_name ?></span>
->display_name ?></span>
<span class="text-sm text-gray-500 truncate">@<?= $podcast->actor
->username ?></span>
->username ?></span>
</p>
</div>
<div class="px-4 mb-2">
......@@ -39,17 +47,15 @@
'id' => 'message',
'name' => 'message',
'class' => 'form-textarea min-w-0 w-full',
'required' => 'required',
'placeholder' => 'Write your message...',
'autofocus' => ''
],
old('message', '', false),
['rows' => 2],
) ?>
</div>
<div class="flex">
<img
src="<?= $episode->image->thumbnail_url ?>"
alt="<?= $episode->title ?>" class="w-24 h-24"/>
<img src="<?= $episode->image->thumbnail_url ?>" alt="<?= $episode->title ?>" class="w-24 h-24" />
<div class="flex flex-col flex-1">
<a href="<?= $episode->link ?>" class="flex-1 px-4 py-2 bg-gray-100">
<div class="flex items-baseline">
......@@ -68,34 +74,32 @@
</div>
</a>
<audio controls preload="none" class="w-full mt-auto">
<source
src="<?= $episode->audio_file_url ?>"
type="<?= $episode->audio_file_mimetype ?>">
<source src="<?= $episode->audio_file_url ?>" type="<?= $episode->audio_file_mimetype ?>">
Your browser does not support the audio tag.
</audio>
</div>
</div>
<footer class="flex justify-around px-6 py-3">
<span class="inline-flex items-center"><?= icon(
'chat',
'text-xl mr-1 text-gray-400',
) . '0' ?></span>
'chat',
'text-xl mr-1 text-gray-400',
) . '0' ?></span>
<span class="inline-flex items-center"><?= icon(
'repeat',
'text-xl mr-1 text-gray-400',
) . '0' ?></span>
'repeat',
'text-xl mr-1 text-gray-400',
) . '0' ?></span>
<span class="inline-flex items-center"><?= icon(
'heart',
'text-xl mr-1 text-gray-400',
) . '0' ?></span>
'heart',
'text-xl mr-1 text-gray-400',
) . '0' ?></span>
</footer>
</div>
<?= form_fieldset('', ['class' => 'flex flex-col mb-4']) ?>
<legend class="text-lg font-semibold"><?= lang(
'Episode.publish_form.publication_date',
) ?></legend>
<label for="now" class="inline-flex items-center">
<legend class="text-lg font-semibold"><?= lang(
'Episode.publish_form.publication_date',
) ?></legend>
<label for="now" class="inline-flex items-center">
<?= form_radio(
[
'id' => 'now',
......@@ -105,65 +109,70 @@
'now',
old('publication_method') ? old('publish') === 'now' : true,
) ?>
<span class="ml-2"><?= lang(
'Episode.publish_form.publication_method.now',
) ?></span>
</label>
<div class="inline-flex flex-wrap items-center mb-4 radio-toggler">
<?= form_radio(
[
'id' => 'schedule',
'name' => 'publication_method',
'class' => 'text-pine-700',
],
'schedule',
old('publication_method') &&
old('publication_method') === 'schedule',
<span class="ml-2"><?= lang(
'Episode.publish_form.publication_method.now',
) ?></span>
</label>
<div class="inline-flex flex-wrap items-center radio-toggler">
<?= form_radio(
[
'id' => 'schedule',
'name' => 'publication_method',
'class' => 'text-pine-700',
],
'schedule',
old('publication_method') &&
old('publication_method') === 'schedule',
) ?>
<label for="schedule" class="ml-2"><?= lang(
'Episode.publish_form.publication_method.schedule',
) ?></label>
<div class="w-full mt-2 radio-toggler-element">
<?= form_label(
lang('Episode.publish_form.scheduled_publication_date'),
'scheduled_publication_date',
[],
lang('Episode.publish_form.scheduled_publication_date_hint'),
) ?>
<label for="schedule" class="ml-2"><?= lang(
'Episode.publish_form.publication_method.schedule',
) ?></label>
<div class="w-full mt-2 radio-toggler-element">
<?= form_label(
lang('Episode.publish_form.scheduled_publication_date'),
'scheduled_publication_date',
[],
lang('Episode.publish_form.scheduled_publication_date_hint'),
) ?>
<div class="flex mb-4" data-picker="datetime">
<?= form_input([
'id' => 'scheduled_publication_date',
'name' => 'scheduled_publication_date',
'class' => 'form-input rounded-r-none flex-1',
'value' => old('scheduled_publication_date', ''),
'data-input' => '',
]) ?>
<button
class="p-3 border border-l-0 border-gray-500 bg-pine-100 focus:outline-none rounded-r-md hover:bg-pine-200 focus:ring"
type="button"
title="<?= lang(
'Episode.publish_form.scheduled_publication_date_clear',
) ?>"
data-clear=""><?= icon('close') ?></button>
</div>
<div class="flex" data-picker="datetime">
<?= form_input([
'id' => 'scheduled_publication_date',
'name' => 'scheduled_publication_date',
'class' => 'form-input rounded-r-none flex-1',
'value' => old('scheduled_publication_date', ''),
'data-input' => '',
]) ?>
<button class="p-3 border border-l-0 border-gray-500 bg-pine-100 focus:outline-none rounded-r-md hover:bg-pine-200 focus:ring" type="button" title="<?= lang(
'Episode.publish_form.scheduled_publication_date_clear',
) ?>" data-clear=""><?= icon('close') ?></button>
</div>
</div>
</div>
<?= form_fieldset_close() ?>
<div class="self-end">
<?= anchor(
route_to('episode-view', $podcast->id, $episode->id),
lang('Common.cancel'),
['class' => 'font-semibold mr-4'],
) ?>
<div id="publish-warning" class="inline-flex flex-col hidden p-4 text-black bg-yellow-300 border-2 border-yellow-900 rounded-md" role="alert">
<p class="flex items-baseline font-semibold">
<?= icon('alert', 'mr-2 text-lg flex-shrink-0') . lang(
'Episode.publish_form.message_warning',
) ?></p>
<p>
<?= lang(
'Episode.publish_form.message_warning_hint',
) ?>
</p>
</div>
<?= button(
lang('Episode.publish_form.submit'),
'',
['variant' => 'primary'],
['type' => 'submit'],
[
'class' => 'self-end mt-4',
'type' => 'submit',
'data-btn-text-warning' => lang('Episode.publish_form.message_warning_submit'),
'data-btn-text' => lang('Episode.publish_form.submit_edit')
],
) ?>
</div>
<?= form_close() ?>
......
<?= $this->extend('admin/_layout') ?>
<?= $this->section('title') ?>
<?= lang('Episode.publish') ?>
<?= lang('Episode.publish_edit') ?>
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
<?= lang('Episode.publish') ?>
<?= lang('Episode.publish_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_open(route_to('episode-publish_edit', $podcast->id, $episode->id), [
'method' => 'post',
'class' => 'flex flex-col max-w-xl items-start',
'class' => 'mx-auto flex flex-col max-w-xl items-start',
'data-submit' => 'validate-message'
]) ?>
<?= csrf_field() ?>
<?= form_hidden('client_timezone', 'UTC') ?>
......@@ -21,25 +28,26 @@
<label for="message" class="text-lg font-semibold"><?= lang(
'Episode.publish_form.status',
) . hint_tooltip(lang('Episode.publish_form.status_hint'), 'ml-1') ?></label>
'Episode.publish_form.status',
) ?></label>
<small class="max-w-md mb-2 text-gray-600"><?= lang('Episode.publish_form.status_hint') ?></small>
<div class="mb-8 overflow-hidden bg-white shadow-md rounded-xl">
<div class="flex px-4 py-3">
<img src="<?= $podcast->actor->avatar_image_url ?>" alt="<?= $podcast->actor
->display_name ?>" class="w-12 h-12 mr-4 rounded-full"/>
<img src="<?= $podcast->actor->avatar_image_url ?>" alt="<?= $podcast->actor
->display_name ?>" class="w-12 h-12 mr-4 rounded-full" />
<div class="flex flex-col min-w-0">
<p class="flex items-baseline min-w-0">
<span class="mr-2 font-semibold truncate"><?= $podcast->actor
->display_name ?></span>
->display_name ?></span>
<span class="text-sm text-gray-500 truncate">@<?= $podcast
->actor->username ?></span>
->actor->username ?></span>