feat(episodes): schedule episode with future publication_date by using cache expiration time

- merge publication date fields into one field instanciated with flatpickr datetime picker
- get user timezone to convert user publication_date input to UTC
- remove setPublishedAt() method from episode entity
- add publication pill component to display the episode publication date info
- clear cache after episode insert
- use CI is_really_writable() helper in install instead of is_writable()
- fix latest episodes layout
- update tsconfig to only include ts folders
- update DEPENDENCIES.md to include flatpickr
- add format_duration helper to format episode enclosure duration instead of translating it (causes
translation bug)
- add Time.ts module to convert UTC time to user localized time for episode publication dates
- fix some layout issues
- update php and js dependencies to latest versions

closes #47
This commit is contained in:
Yassine Doghri 2020-10-22 17:41:59 +00:00
parent 0ab17d1075
commit 4f1e773c0f
45 changed files with 556 additions and 308 deletions

View File

@ -37,6 +37,8 @@ Javascript dependencies:
([Free amCharts license](https://github.com/amcharts/amcharts4/blob/master/dist/script/LICENSE))
- [Choices.js](https://joshuajohnson.co.uk/Choices/)
([MIT License](https://github.com/jshjohnson/Choices/blob/master/LICENSE))
- [flatpickr](https://flatpickr.js.org/)
([MIT License](https://github.com/flatpickr/flatpickr/blob/master/LICENSE.md))
Other:

View File

@ -26,7 +26,7 @@ class BaseController extends Controller
*
* @var array
*/
protected $helpers = ['auth', 'breadcrumb', 'svg', 'components'];
protected $helpers = ['auth', 'breadcrumb', 'svg', 'components', 'misc'];
/**
* Constructor.

View File

@ -10,6 +10,7 @@ namespace App\Controllers\Admin;
use App\Models\EpisodeModel;
use App\Models\PodcastModel;
use CodeIgniter\I18n\Time;
class Episode extends BaseController
{
@ -95,9 +96,7 @@ class Episode extends BaseController
'enclosure' => 'uploaded[enclosure]|ext_in[enclosure,mp3,m4a]',
'image' =>
'is_image[image]|ext_in[image,jpg,png]|min_dims[image,1400,1400]|is_image_squared[image]',
'publication_date' => 'valid_date[Y-m-d]|permit_empty',
'publication_time' =>
'regex_match[/^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$/]|permit_empty',
'publication_date' => 'valid_date[Y-m-d H:i]|permit_empty',
];
if (!$this->validate($rules)) {
@ -125,11 +124,12 @@ class Episode extends BaseController
'block' => $this->request->getPost('block') == 'yes',
'created_by' => user(),
'updated_by' => user(),
'published_at' => Time::createFromFormat(
'Y-m-d H:i',
$this->request->getPost('publication_date'),
$this->request->getPost('client_timezone')
)->setTimezone('UTC'),
]);
$newEpisode->setPublishedAt(
$this->request->getPost('publication_date'),
$this->request->getPost('publication_time')
);
$episodeModel = new EpisodeModel();
@ -185,9 +185,7 @@ class Episode extends BaseController
'uploaded[enclosure]|ext_in[enclosure,mp3,m4a]|permit_empty',
'image' =>
'is_image[image]|ext_in[image,jpg,png]|min_dims[image,1400,1400]|is_image_squared[image]',
'publication_date' => 'valid_date[Y-m-d]|permit_empty',
'publication_time' =>
'regex_match[/^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$/]|permit_empty',
'publication_date' => 'valid_date[Y-m-d H:i]|permit_empty',
];
if (!$this->validate($rules)) {
@ -210,10 +208,11 @@ class Episode extends BaseController
: null;
$this->episode->type = $this->request->getPost('type');
$this->episode->block = $this->request->getPost('block') == 'yes';
$this->episode->setPublishedAt(
$this->episode->published_at = Time::createFromFormat(
'Y-m-d H:i',
$this->request->getPost('publication_date'),
$this->request->getPost('publication_time')
);
$this->request->getPost('client_timezone')
)->setTimezone('UTC');
$this->episode->updated_by = user();
$enclosure = $this->request->getFile('enclosure');

View File

@ -388,11 +388,8 @@ class Podcast extends BaseController
: $nsItunes->block === 'yes',
'created_by' => user(),
'updated_by' => user(),
'published_at' => strtotime($item->pubDate),
]);
$newEpisode->setPublishedAt(
date('Y-m-d', strtotime($item->pubDate)),
date('H:i:s', strtotime($item->pubDate))
);
$episodeModel = new EpisodeModel();

View File

@ -26,7 +26,7 @@ class BaseController extends Controller
*
* @var array
*/
protected $helpers = ['analytics', 'svg', 'components'];
protected $helpers = ['analytics', 'svg', 'components', 'misc'];
/**
* Constructor.

View File

@ -48,7 +48,8 @@ class Episode extends BaseController
$cacheName = "page_podcast{$this->episode->podcast_id}_episode{$this->episode->id}_{$locale}";
if (!($cachedView = cache($cacheName))) {
$previousNextEpisodes = (new EpisodeModel())->getPreviousNextEpisodes(
$episodeModel = new EpisodeModel();
$previousNextEpisodes = $episodeModel->getPreviousNextEpisodes(
$this->episode,
$this->podcast->type
);
@ -60,9 +61,15 @@ class Episode extends BaseController
'episode' => $this->episode,
];
$secondsToNextUnpublishedEpisode = $episodeModel->getSecondsToNextUnpublishedEpisode(
$this->podcast->id
);
// The page cache is set to a decade so it is deleted manually upon podcast update
return view('episode', $data, [
'cache' => DECADE,
'cache' => $secondsToNextUnpublishedEpisode
? $secondsToNextUnpublishedEpisode
: DECADE,
'cache_name' => $cacheName,
]);
}

View File

@ -8,6 +8,7 @@
namespace App\Controllers;
use App\Models\EpisodeModel;
use App\Models\PodcastModel;
use CodeIgniter\Controller;
@ -31,15 +32,29 @@ class Feed extends Controller
// If things go wrong the show must go on and the user must be able to download the file
log_message('critical', $e);
}
$cacheName =
"podcast{$podcast->id}_feed" .
($service ? "_{$service['slug']}" : '');
if (!($found = cache($cacheName))) {
$found = get_rss_feed(
$podcast,
$service ? '?s=' . urlencode($service['name']) : ''
);
cache()->save($cacheName, $found, DECADE);
// The page cache is set to expire after next episode publication or a decade by default so it is deleted manually upon podcast update
$secondsToNextUnpublishedEpisode = (new EpisodeModel())->getSecondsToNextUnpublishedEpisode(
$podcast->id
);
cache()->save(
$cacheName,
$found,
$secondsToNextUnpublishedEpisode
? $secondsToNextUnpublishedEpisode
: DECADE
);
}
return $this->response->setXML($found);
}

View File

@ -48,7 +48,7 @@ class Install extends Controller
}
// Check if the created .env file is writable to continue install process
if (is_writable(ROOTPATH . '.env')) {
if (is_really_writable(ROOTPATH . '.env')) {
try {
$dotenv->required([
'app.baseURL',

View File

@ -113,7 +113,7 @@ class Podcast extends BaseController
'podcast' => $this->podcast,
'episodesNav' => $episodesNavigation,
'activeQuery' => $activeQuery,
'episodes' => (new EpisodeModel())->getPodcastEpisodes(
'episodes' => $episodeModel->getPodcastEpisodes(
$this->podcast->id,
$this->podcast->type,
$yearQuery,
@ -121,8 +121,14 @@ class Podcast extends BaseController
),
];
$secondsToNextUnpublishedEpisode = $episodeModel->getSecondsToNextUnpublishedEpisode(
$this->podcast->id
);
return view('podcast', $data, [
'cache' => DECADE,
'cache' => $secondsToNextUnpublishedEpisode
? $secondsToNextUnpublishedEpisode
: DECADE,
'cache_name' => $cacheName,
]);
}

View File

@ -10,6 +10,7 @@ namespace App\Entities;
use App\Models\PodcastModel;
use CodeIgniter\Entity;
use CodeIgniter\I18n\Time;
use League\CommonMark\CommonMarkConverter;
class Episode extends Entity
@ -49,6 +50,11 @@ class Episode extends Entity
*/
protected $description_html;
/**
* @var boolean
*/
protected $is_published;
protected $dates = [
'published_at',
'created_at',
@ -232,17 +238,6 @@ class Episode extends Entity
return $converter->convertToHtml($this->attributes['description']);
}
public function setPublishedAt($date, $time)
{
if (empty($date)) {
$this->attributes['published_at'] = null;
} else {
$this->attributes['published_at'] = $date . ' ' . $time;
}
return $this;
}
public function setCreatedBy(\App\Entities\User $user)
{
$this->attributes['created_by'] = $user->id;
@ -256,4 +251,17 @@ class Episode extends Entity
return $this;
}
public function getIsPublished()
{
if ($this->is_published) {
return $this->is_published;
}
helper('date');
$this->is_published = $this->published_at->isBefore(Time::now());
return $this->is_published;
}
}

View File

@ -256,3 +256,51 @@ if (!function_exists('data_table')) {
}
// ------------------------------------------------------------------------
if (!function_exists('publication_pill')) {
/**
* Data table component
*
* Creates a stylized table.
*
* @param \CodeIgniter\I18n\Time $publicationDate publication datetime of the episode
* @param boolean $isPublished whether or not the episode has been published
* @param string $customClass css class to add to the component
*
* @return string
*/
function publication_pill(
$publicationDate,
$isPublished,
$customClass = ''
): string {
$class = $isPublished
? 'text-green-500 border-green-500'
: 'text-orange-600 border-orange-600';
$label = lang(
$isPublished ? 'Episode.published' : 'Episode.scheduled',
[
'<time
pubdate
datetime="' .
$publicationDate->format(DateTime::ATOM) .
'"
title="' .
$publicationDate .
'">' .
lang('Common.mediumDate', [$publicationDate]) .
'</time>',
]
);
return '<span class="px-1 border ' .
$class .
' ' .
$customClass .
'">' .
$label .
'</span>';
}
}
// ------------------------------------------------------------------------

View File

@ -143,3 +143,27 @@ function slugify($text)
return $text;
}
//--------------------------------------------------------------------
if (!function_exists('format_duration')) {
/**
* Formats duration in seconds to an hh:mm:ss string
*
* @param int $seconds seconds to format
* @param string $separator
*
* @return string
*/
function format_duration($seconds, $separator = ':')
{
return sprintf(
'%02d%s%02d%s%02d',
floor($seconds / 3600),
$separator,
($seconds / 60) % 60,
$separator,
$seconds % 60
);
}
}

View File

@ -14,7 +14,6 @@ return [
'home' => 'Home',
'explicit' => 'Explicit',
'mediumDate' => '{0,date,medium}',
'duration' => '{0,duration}',
'powered_by' => 'Powered by {castopod}.',
'actions' => 'Actions',
'pageInfo' => 'Page {currentPage} out of {pageCount}',

View File

@ -22,6 +22,8 @@ return [
'delete' => 'Delete',
'go_to_page' => 'Go to page',
'create' => 'Add an episode',
'published' => 'Published on {0}',
'scheduled' => 'Scheduled for {0}',
'form' => [
'enclosure' => 'Audio file',
'enclosure_hint' => 'Choose an .mp3 or .m4a audio file.',
@ -54,11 +56,9 @@ return [
'This text is added at the end of each episode description, it is a good place to input your social links for example.',
'publication_section_title' => 'Publication info',
'publication_section_subtitle' => '',
'published_at' => [
'label' => 'Publication date',
'date' => 'Date',
'time' => 'Time',
],
'publication_date' => 'Publication date',
'publication_date_hint' =>
'You can schedule the episode release by setting a future publication date. This field must be formatted as YYYY-MM-DD HH:mm',
'parental_advisory' => [
'label' => 'Parental advisory',
'hint' => 'Does the episode contain explicit content?',

View File

@ -14,7 +14,6 @@ return [
'home' => 'Accueil',
'explicit' => 'Explicite',
'mediumDate' => '{0,date,medium}',
'duration' => '{0,duration}',
'powered_by' => 'Propulsé par {castopod}.',
'actions' => 'Actions',
'pageInfo' => 'Page {currentPage} sur {pageCount}',

View File

@ -22,6 +22,8 @@ return [
'delete' => 'Supprimer',
'go_to_page' => 'Voir',
'create' => 'Ajouter un épisode',
'published' => 'Publié le {0}',
'scheduled' => 'Planifié pour le {0}',
'form' => [
'enclosure' => 'Fichier audio',
'enclosure_hint' => 'Sélectionnez un fichier audio .mp3 ou .m4a.',
@ -54,11 +56,9 @@ return [
'Ce texte est ajouté à la fin de chaque description dépisode, cest un bon endroit pour placer vos liens sociaux par exemple.',
'publication_section_title' => 'Information de publication',
'publication_section_subtitle' => '',
'published_at' => [
'label' => 'Date de publication',
'date' => 'Date',
'time' => 'Heure',
],
'publication_date' => 'Date de publication',
'publication_date_hint' =>
'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',
'parental_advisory' => [
'label' => 'Avertissement parental',
'hint' => 'Lépisode contient-il un contenu explicite?',

View File

@ -12,6 +12,7 @@ return [
'messages' => [
'wrongPasswordError' =>
'Le mot de passe que vous avez saisi est invalide.',
'passwordChangeSuccess' => 'Le mot de passe a été modifié avec succès!',
'passwordChangeSuccess' =>
'Le mot de passe a été modifié avec succès!',
],
];

View File

@ -57,32 +57,21 @@ class EpisodeModel extends Model
];
protected $validationMessages = [];
protected $afterInsert = ['writeEnclosureMetadata'];
protected $afterInsert = ['writeEnclosureMetadata', 'clearCache'];
// clear cache beforeUpdate because if slug changes, so will the episode link
protected $beforeUpdate = ['clearCache'];
protected $afterUpdate = ['writeEnclosureMetadata'];
protected $beforeDelete = ['clearCache'];
protected function writeEnclosureMetadata(array $data)
{
helper('id3');
$episode = (new EpisodeModel())->find(
is_array($data['id']) ? $data['id'][0] : $data['id']
);
write_enclosure_tags($episode);
return $data;
}
public function getEpisodeBySlug($podcastId, $episodeSlug)
{
if (!($found = cache("podcast{$podcastId}_episode@{$episodeSlug}"))) {
$found = $this->where([
'podcast_id' => $podcastId,
'slug' => $episodeSlug,
])->first();
])
->where('`published_at` <= NOW()', null, false)
->first();
cache()->save(
"podcast{$podcastId}_episode@{$episodeSlug}",
@ -120,6 +109,7 @@ class EpisodeModel extends Model
'podcast_id' => $episode->podcast_id,
$sortNumberField . ' <' => $sortNumberValue,
])
->where('`published_at` <= NOW()', null, false)
->first();
$nextData = $this->orderBy('(' . $sortNumberField . ') ASC')
@ -127,6 +117,7 @@ class EpisodeModel extends Model
'podcast_id' => $episode->podcast_id,
$sortNumberField . ' >' => $sortNumberValue,
])
->where('`published_at` <= NOW()', null, false)
->first();
return [
@ -160,7 +151,9 @@ class EpisodeModel extends Model
);
if (!($found = cache($cacheName))) {
$where = ['podcast_id' => $podcastId];
$where = [
'podcast_id' => $podcastId,
];
if ($year) {
$where['YEAR(published_at)'] = $year;
$where['season_number'] = null;
@ -172,15 +165,27 @@ class EpisodeModel extends Model
if ($podcastType == 'serial') {
// podcast is serial
$found = $this->where($where)
->where('`published_at` <= NOW()', null, false)
->orderBy('season_number DESC, number ASC')
->findAll();
} else {
$found = $this->where($where)
->where('`published_at` <= NOW()', null, false)
->orderBy('published_at', 'DESC')
->findAll();
}
cache()->save($cacheName, $found, DECADE);
$secondsToNextUnpublishedEpisode = $this->getSecondsToNextUnpublishedEpisode(
$podcastId
);
cache()->save(
$cacheName,
$found,
$secondsToNextUnpublishedEpisode
? $secondsToNextUnpublishedEpisode
: DECADE
);
}
return $found;
@ -197,12 +202,23 @@ class EpisodeModel extends Model
'season_number' => null,
$this->deletedField => null,
])
->where('`published_at` <= NOW()', null, false)
->groupBy('year')
->orderBy('year', 'DESC')
->get()
->getResultArray();
cache()->save("podcast{$podcastId}_years", $found, DECADE);
$secondsToNextUnpublishedEpisode = $this->getSecondsToNextUnpublishedEpisode(
$podcastId
);
cache()->save(
"podcast{$podcastId}_years",
$found,
$secondsToNextUnpublishedEpisode
? $secondsToNextUnpublishedEpisode
: DECADE
);
}
return $found;
@ -219,12 +235,23 @@ class EpisodeModel extends Model
'season_number is not' => null,
$this->deletedField => null,
])
->where('`published_at` <= NOW()', null, false)
->groupBy('season_number')
->orderBy('season_number', 'ASC')
->get()
->getResultArray();
cache()->save("podcast{$podcastId}_seasons", $found, DECADE);
$secondsToNextUnpublishedEpisode = $this->getSecondsToNextUnpublishedEpisode(
$podcastId
);
cache()->save(
"podcast{$podcastId}_seasons",
$found,
$secondsToNextUnpublishedEpisode
? $secondsToNextUnpublishedEpisode
: DECADE
);
}
return $found;
@ -264,6 +291,43 @@ class EpisodeModel extends Model
return $defaultQuery;
}
/**
* Returns the timestamp difference in seconds between the next episode to publish and the current timestamp
* Returns false if there's no episode to publish
*
* @param int $podcastId
*
* @return int|false seconds
*/
public function getSecondsToNextUnpublishedEpisode(int $podcastId)
{
$result = $this->select(
'TIMESTAMPDIFF(SECOND, NOW(), `published_at`) as timestamp_diff'
)
->where([
'podcast_id' => $podcastId,
])
->where('`published_at` > NOW()', null, false)
->orderBy('published_at', 'asc')
->get()
->getResultArray();
return (int) $result ? $result[0]['timestamp_diff'] : false;
}
protected function writeEnclosureMetadata(array $data)
{
helper('id3');
$episode = (new EpisodeModel())->find(
is_array($data['id']) ? $data['id'][0] : $data['id']
);
write_enclosure_tags($episode);
return $data;
}
protected function clearCache(array $data)
{
$episodeModel = new EpisodeModel();

View File

@ -59,7 +59,7 @@ class PodcastModel extends Model
];
protected $validationMessages = [];
// clear cache before update if by any chance, the podcast name changes, and so will the podcast link
// clear cache before update if by any chance, the podcast name changes, so will the podcast link
protected $beforeUpdate = ['clearCache'];
protected $beforeDelete = ['clearCache'];

View File

@ -1,8 +1,11 @@
import ClientTimezone from "./modules/ClientTimezone";
import DateTimePicker from "./modules/DateTimePicker";
import Dropdown from "./modules/Dropdown";
import MarkdownEditor from "./modules/MarkdownEditor";
import MultiSelect from "./modules/MultiSelect";
import SidebarToggler from "./modules/SidebarToggler";
import Slugify from "./modules/Slugify";
import Time from "./modules/Time";
import Tooltip from "./modules/Tooltip";
Dropdown();
@ -11,3 +14,6 @@ MarkdownEditor();
MultiSelect();
Slugify();
SidebarToggler();
ClientTimezone();
DateTimePicker();
Time();

View File

@ -68,7 +68,10 @@ const drawXYChart = (chartDivId: string, dataUrl: string | null): void => {
chart.scrollbarX = new am4core.Scrollbar();
};
const drawXYDurationChart = (chartDivId: string, dataUrl: string | null): void => {
const drawXYDurationChart = (
chartDivId: string,
dataUrl: string | null
): void => {
// Create chart instance
const chart = am4core.create(chartDivId, am4charts.XYChart);
am4core.percent(100);
@ -203,7 +206,10 @@ const DrawCharts = (): void => {
drawXYChart(chartDiv.id, chartDiv.getAttribute("data-chart-url"));
break;
case "xy-duration-chart":
drawXYDurationChart(chartDiv.id, chartDiv.getAttribute("data-chart-url"));
drawXYDurationChart(
chartDiv.id,
chartDiv.getAttribute("data-chart-url")
);
break;
case "xy-series-chart":
drawXYSeriesChart(chartDiv.id, chartDiv.getAttribute("data-chart-url"));

View File

@ -0,0 +1,11 @@
const ClientTimezone = (): void => {
const input: HTMLInputElement | null = document.querySelector(
"input[name='client_timezone']"
);
if (input) {
input.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
}
};
export default ClientTimezone;

View File

@ -0,0 +1,41 @@
import flatpickr from "flatpickr";
import "flatpickr/dist/flatpickr.min.css";
/*
* Detects navigator locale 24h time preference
* It works by checking whether hour output contains AM ('1 AM' or '01 h')
*/
const isBrowserLocale24h = () =>
!new Intl.DateTimeFormat(navigator.language, { hour: "numeric" })
.format(0)
.match(/AM/);
const DateTimePicker = (): void => {
const dateTimeContainers: NodeListOf<HTMLInputElement> = document.querySelectorAll(
"input[data-picker='datetime']"
);
for (let i = 0; i < dateTimeContainers.length; i++) {
const dateTimeContainer = dateTimeContainers[i];
const flatpickrInstance = flatpickr(dateTimeContainer, {
enableTime: true,
time_24hr: isBrowserLocale24h(),
});
// convert container UTC date value to user timezone
const dateTime = new Date(dateTimeContainer.value);
const dateUTC = Date.UTC(
dateTime.getFullYear(),
dateTime.getMonth(),
dateTime.getDate(),
dateTime.getHours(),
dateTime.getMinutes()
);
// set converted date as field value
flatpickrInstance.setDate(new Date(dateUTC));
}
};
export default DateTimePicker;

View File

@ -0,0 +1,24 @@
const Time = (): void => {
const timeElements: NodeListOf<HTMLTimeElement> = document.querySelectorAll(
"time"
);
console.log(timeElements);
for (let i = 0; i < timeElements.length; i++) {
const timeElement = timeElements[i];
// convert UTC date value to user timezone
const timeElementDateTime = timeElement.getAttribute("datetime");
// check if timeElementDateTime is not null and not a duration
if (timeElementDateTime && !timeElementDateTime.startsWith("PT")) {
const dateTime = new Date(timeElementDateTime);
// replace <time/> title with localized datetime
timeElement.setAttribute("title", dateTime.toLocaleString());
}
}
};
export default Time;

View File

@ -0,0 +1,3 @@
import Time from "./modules/Time";
Time();

View File

@ -26,7 +26,7 @@
<div class="container flex flex-wrap items-end justify-between px-2 py-10 mx-auto md:px-12 gap-y-6 gap-x-6">
<div class="flex flex-col">
<?= render_breadcrumb('text-gray-300') ?>
<h1 class="text-3xl leading-none"><?= $this->renderSection(
<h1 class="text-3xl"><?= $this->renderSection(
'pageTitle'
) ?></h1>
</div>

View File

@ -16,6 +16,7 @@
'class' => 'flex flex-col',
]) ?>
<?= csrf_field() ?>
<?= form_hidden('client_timezone', 'UTC') ?>
<?= form_section(
lang('Episode.form.info_section_title'),
@ -193,35 +194,19 @@
lang('Episode.form.publication_section_subtitle')
) ?>
<?= form_fieldset('', ['class' => 'flex mb-4']) ?>
<legend><?= lang('Episode.form.published_at.label') ?></legend>
<div class="flex flex-col flex-1">
<?= form_label(lang('Episode.form.publication_date'), 'publication_date', [
'class' => 'sr-only',
]) ?>
<?= form_input([
'id' => 'publication_date',
'name' => 'publication_date',
'class' => 'form-input',
'value' => old('publication_date', date('Y-m-d')),
'type' => 'date',
]) ?>
</div>
<div class="flex flex-col flex-1">
<?= form_label(lang('Episode.form.publication_time'), 'publication_time', [
'class' => 'sr-only',
]) ?>
<?= form_input([
'id' => 'publication_time',
'name' => 'publication_time',
'class' => 'form-input',
'value' => old('publication_time', date('H:i')),
'placeholder' => '--:--',
'type' => 'time',
]) ?>
</div>
<?= form_fieldset_close() ?>
<?= form_label(
lang('Episode.form.publication_date'),
'publication_date',
[],
lang('Episode.form.publication_date_hint')
) ?>
<?= form_input([
'id' => 'publication_date',
'name' => 'publication_date',
'class' => 'form-input mb-4',
'value' => old('publication_date', date('Y-m-d H:i')),
'data-picker' => 'datetime',
]) ?>
<?= form_fieldset('', ['class' => 'flex mb-6 gap-1']) ?>
<legend>

View File

@ -16,6 +16,7 @@
'class' => 'flex flex-col',
]) ?>
<?= csrf_field() ?>
<?= form_hidden('client_timezone', 'UTC') ?>
<?= form_section(
lang('Episode.form.info_section_title'),
@ -197,44 +198,24 @@
lang('Episode.form.publication_section_subtitle')
) ?>
<?= form_fieldset('', ['class' => 'flex mb-4']) ?>
<legend><?= lang('Episode.form.published_at.label') ?></legend>
<div class="flex flex-col flex-1">
<?= form_label(lang('Episode.form.publication_date'), 'publication_date', [
'class' => 'sr-only',
]) ?>
<?= form_input([
'id' => 'publication_date',
'name' => 'publication_date',
'class' => 'form-input',
'value' => old(
'publication_date',
$episode->published_at
? $episode->published_at->format('Y-m-d')
: ''
),
'type' => 'date',
]) ?>
</div>
<div class="flex flex-col flex-1">
<?= form_label(lang('Episode.form.publication_time'), 'publication_time', [
'class' => 'sr-only',
]) ?>
<?= form_input([
'id' => 'publication_time',
'name' => 'publication_time',
'class' => 'form-input',
'value' => old(
'publication_time',
$episode->published_at ? $episode->published_at->format('H:i') : ''
),
'placeholder' => '--:--',
'type' => 'time',
]) ?>
</div>
<?= form_fieldset_close() ?>
<?= form_label(
lang('Episode.form.publication_date'),
'publication_date',
[],
lang('Episode.form.publication_date_hint')
) ?>
<?= form_input([
'id' => 'publication_date',
'name' => 'publication_date',
'class' => 'form-input mb-4',
'value' => old(
'publication_date',
$episode->published_at
? $episode->published_at->format('Y-m-d H:i')
: ''
),
'data-picker' => 'datetime',
]) ?>
<?= form_fieldset('', ['class' => 'mb-6']) ?>
<legend>
@ -288,6 +269,7 @@
<?= form_switch(
lang('Episode.form.block') .
hint_tooltip(lang('Episode.form.block_hint'), 'ml-1'),
['id' => 'block', 'name' => 'block'],
'yes',
old('block', $episode->block)

View File

@ -97,19 +97,13 @@
</div>
</div>
<div class="mb-2 text-xs">
<time
pubdate
datetime="<?= $episode->published_at->toDateTimeString() ?>"
title="<?= $episode->published_at ?>">
<?= lang('Common.mediumDate', [
<?= publication_pill(
$episode->published_at,
]) ?>
</time>
$episode->is_published
) ?>
<span class="mx-1"></span>
<time datetime="PT<?= $episode->enclosure_duration ?>S">
<?= lang('Common.duration', [
$episode->enclosure_duration,
]) ?>
<?= format_duration($episode->enclosure_duration) ?>
</time>
</div>
<audio controls preload="none" class="w-full mt-auto">
@ -126,5 +120,4 @@
<?= $pager->links() ?>
<?= $this->endSection()
?>
<?= $this->endSection() ?>

View File

@ -5,7 +5,12 @@
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
<?= $episode->title ?>
<?= $episode->title .
publication_pill(
$episode->published_at,
$episode->is_published,
'text-sm ml-2 align-middle'
) ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>

View File

@ -44,5 +44,4 @@
<?= form_close() ?>
<?= $this->endSection()
?>
<?= $this->endSection() ?>

View File

@ -13,5 +13,4 @@
<?= view('admin/_partials/_user_info.php', ['user' => user()]) ?>
<?= $this->endSection()
?>
<?= $this->endSection() ?>

View File

@ -238,7 +238,14 @@
'value' => old('publisher'),
]) ?>
<?= form_label(lang('Podcast.form.copyright'), 'copyright', [], '', true) ?>
<?= form_label(
lang('Podcast.form.copyright'),
'copyright',
[],
'',
true
) ?>
<?= form_input([
'id' => 'copyright',
'name' => 'copyright',

View File

@ -10,9 +10,9 @@
</a>
</header>
<?php if ($episodes): ?>
<div class="flex justify-between gap-4 overflow-x-auto">
<div class="flex p-2 space-x-4 overflow-x-auto">
<?php foreach ($episodes as $episode): ?>
<article class="flex flex-col w-56 mb-4 bg-white border rounded shadow" style="min-width: 12rem;">
<article class="flex flex-col w-56 bg-white border rounded shadow" style="min-width: 12rem;">
<img
src="<?= $episode->image->thumbnail_url ?>"
alt="<?= $episode->title ?>" class="object-cover" />
@ -61,7 +61,9 @@
<span class="mx-1"></span>
<time
pubdate
datetime="<?= $episode->published_at->toDateTimeString() ?>"
datetime="<?= $episode->published_at->format(
DateTime::ATOM
) ?>"
title="<?= $episode->published_at ?>">
<?= lang('Common.mediumDate', [
$episode->published_at,

View File

@ -62,5 +62,4 @@
<?php endif; ?>
</div>
<?= $this->endSection()
?>
<?= $this->endSection() ?>

View File

@ -50,5 +50,4 @@
<?= form_close() ?>
<?= $this->endSection()
?>
<?= $this->endSection() ?>

View File

@ -85,5 +85,4 @@
$users
) ?>
<?= $this->endSection()
?>
<?= $this->endSection() ?>

View File

@ -11,6 +11,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="shortcut icon" type="image/png" href="/favicon.ico" />
<link rel="stylesheet" href="/assets/index.css"/>
<script src="/assets/podcast.js" type="module" defer></script>
</head>
<body class="flex flex-col min-h-screen mx-auto">
@ -85,17 +86,13 @@
<div class="text-sm">
<time
pubdate
datetime="<?= $episode->published_at->toDateTimeString() ?>"
datetime="<?= $episode->published_at->format(DateTime::ATOM) ?>"
title="<?= $episode->published_at ?>">
<?= lang('Common.mediumDate', [$episode->published_at]) ?>
</time>
<span class="mx-1"></span>
<time datetime="PT<?= $episode->enclosure_duration ?>S">
<?= lang(
'Common.duration',
[$episode->enclosure_duration],
'en'
) ?>
<?= format_duration($episode->enclosure_duration) ?>
</time>
</div>
<audio controls preload="none" class="w-full mt-auto">
@ -110,9 +107,9 @@
</section>
</main>
<footer class="px-2 py-4 border-t ">
<div class="container flex flex-col items-center justify-between mx-auto text-sm md:flex-row ">
<div class="container flex flex-col items-center justify-between mx-auto text-xs md:flex-row ">
<?= render_page_links('inline-flex mb-4 md:mb-0') ?>
<div class="flex flex-col items-end text-xs">
<div class="flex flex-col items-end">
<p><?= $podcast->copyright ?></p>
<p><?= lang('Common.powered_by', [
'castopod' =>

View File

@ -20,4 +20,5 @@ Line Number: <?= $exception->getLine() ?>
<?php endif; ?>
<?php endforeach; ?>
<?php endif; ?>
<?php endif;
?>

View File

@ -13,6 +13,7 @@
<link rel="shortcut icon" type="image/png" href="/favicon.ico" />
<link rel="stylesheet" href="/assets/index.css"/>
<link type="application/rss+xml" rel="alternate" title="<?= $podcast->title ?>" href="<?= $podcast->feed_url ?>"/>
<script src="/assets/podcast.js" type="module" defer></script>
</head>
<body class="flex flex-col min-h-screen">
@ -127,7 +128,9 @@
<div class="mb-2 text-xs">
<time
pubdate
datetime="<?= $episode->published_at->toDateTimeString() ?>"
datetime="<?= $episode->published_at->format(
DateTime::ATOM
) ?>"
title="<?= $episode->published_at ?>">
<?= lang('Common.mediumDate', [
$episode->published_at,
@ -135,9 +138,9 @@
</time>
<span class="mx-1"></span>
<time datetime="PT<?= $episode->enclosure_duration ?>S">
<?= lang('Common.duration', [
$episode->enclosure_duration,
]) ?>
<?= format_duration(
$episode->enclosure_duration
) ?>
</time>
</div>
<audio controls preload="none" class="w-full mt-auto">
@ -159,9 +162,9 @@
</section>
</main>
<footer class="px-2 py-4 border-t ">
<div class="container flex flex-col items-center justify-between mx-auto text-sm md:flex-row ">
<div class="container flex flex-col items-center justify-between mx-auto text-xs md:flex-row ">
<?= render_page_links('inline-flex mb-4 md:mb-0') ?>
<div class="flex flex-col items-center text-xs md:items-end">
<div class="flex flex-col items-center md:items-end">
<p><?= $podcast->copyright ?></p>
<p><?= lang('Common.powered_by', [
'castopod' =>

19
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "37551523e4097a9341bc00dd317f573d",
"content-hash": "58e59ff661eaa3553d3f9f9f88b9d274",
"packages": [
{
"name": "codeigniter4/codeigniter4",
@ -12,12 +12,12 @@
"source": {
"type": "git",
"url": "https://github.com/codeigniter4/CodeIgniter4.git",
"reference": "13ff147fa4cd9db15888b041ef35bc22ed94252a"
"reference": "58993fbbab54a2523be25e8230337b855f465a7a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/codeigniter4/CodeIgniter4/zipball/13ff147fa4cd9db15888b041ef35bc22ed94252a",
"reference": "13ff147fa4cd9db15888b041ef35bc22ed94252a",
"url": "https://api.github.com/repos/codeigniter4/CodeIgniter4/zipball/58993fbbab54a2523be25e8230337b855f465a7a",
"reference": "58993fbbab54a2523be25e8230337b855f465a7a",
"shasum": ""
},
"require": {
@ -53,7 +53,6 @@
},
"scripts": {
"post-update-cmd": [
"@composer dump-autoload",
"CodeIgniter\\ComposerScripts::postUpdate",
"bash admin/setup.sh"
],
@ -75,7 +74,7 @@
"slack": "https://codeigniterchat.slack.com",
"issues": "https://github.com/codeigniter4/CodeIgniter4/issues"
},
"time": "2020-10-20T18:13:11+00:00"
"time": "2020-10-21T16:26:19+00:00"
},
{
"name": "composer/ca-bundle",
@ -805,12 +804,12 @@
"source": {
"type": "git",
"url": "https://github.com/lonnieezell/myth-auth.git",
"reference": "e9d6a2f557bd275158e0b84624534b2abeeb539c"
"reference": "fe9739e1a410d9a30292faee9e8b6369667241e8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/lonnieezell/myth-auth/zipball/e9d6a2f557bd275158e0b84624534b2abeeb539c",
"reference": "e9d6a2f557bd275158e0b84624534b2abeeb539c",
"url": "https://api.github.com/repos/lonnieezell/myth-auth/zipball/fe9739e1a410d9a30292faee9e8b6369667241e8",
"reference": "fe9739e1a410d9a30292faee9e8b6369667241e8",
"shasum": ""
},
"require": {
@ -860,7 +859,7 @@
"type": "patreon"
}
],
"time": "2020-10-16T18:51:37+00:00"
"time": "2020-10-22T03:25:47+00:00"
},
{
"name": "opawg/user-agents-php",

133
package-lock.json generated
View File

@ -5,9 +5,9 @@
"requires": true,
"dependencies": {
"@amcharts/amcharts4": {
"version": "4.10.7",
"resolved": "https://registry.npmjs.org/@amcharts/amcharts4/-/amcharts4-4.10.7.tgz",
"integrity": "sha512-XWITAuewadEnkX9XgZTqT6CUn91gCJpvLJYrnSdnwu4GOGV4Siu6esoEb4JEYQYEDCzDIK3zlmOT5+a0fulcTw==",
"version": "4.10.8",
"resolved": "https://registry.npmjs.org/@amcharts/amcharts4/-/amcharts4-4.10.8.tgz",
"integrity": "sha512-2xIPHkvuxhsN49btE+K0ThO0CxvEgHC+n2aFa05GLwIH2JKgSjFBmjSvELrEqlEYf2mEPjmKjuYe6d4TgHfGUA==",
"requires": {
"@babel/runtime": "^7.6.3",
"core-js": "^3.0.0",
@ -53,16 +53,16 @@
"dev": true
},
"@babel/core": {
"version": "7.12.1",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.12.1.tgz",
"integrity": "sha512-6bGmltqzIJrinwRRdczQsMhruSi9Sqty9Te+/5hudn4Izx/JYRhW1QELpR+CIL0gC/c9A7WroH6FmkDGxmWx3w==",
"version": "7.12.3",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.12.3.tgz",
"integrity": "sha512-0qXcZYKZp3/6N2jKYVxZv0aNCsxTSVCiK72DTiTYZAu7sjg73W0/aynWjMbiGd87EQL4WyA8reiJVh92AVla9g==",
"dev": true,
"requires": {
"@babel/code-frame": "^7.10.4",
"@babel/generator": "^7.12.1",
"@babel/helper-module-transforms": "^7.12.1",
"@babel/helpers": "^7.12.1",
"@babel/parser": "^7.12.1",
"@babel/parser": "^7.12.3",
"@babel/template": "^7.10.4",
"@babel/traverse": "^7.12.1",
"@babel/types": "^7.12.1",
@ -102,6 +102,12 @@
"js-tokens": "^4.0.0"
}
},
"@babel/parser": {
"version": "7.12.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.12.3.tgz",
"integrity": "sha512-kFsOS0IbsuhO5ojF8Hc8z/8vEIOkylVBrjiZUbLTE3XFe0Qi+uu6HjzQixkFaqr0ZPAMZcBVxEwmsnsLPZ2Xsw==",
"dev": true
},
"@babel/types": {
"version": "7.12.1",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.12.1.tgz",
@ -2379,13 +2385,13 @@
"integrity": "sha512-RFwCobxsvZ6j7twS7dHIZQZituMIDJJNHS/qY6iuthVebxS3zhRY+jaC2roEKiAYaVuTcGmX6Luc6YBcf6zJVg=="
},
"@prettier/plugin-php": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@prettier/plugin-php/-/plugin-php-0.15.0.tgz",
"integrity": "sha512-OnzCmDTDdWLkm2nsvtiWKip1ePoy+KucY1h9zHDVXIFWBrd+OZATeZZgC7JU7gjly96g86hW1ZHpbF9ip9KHfg==",
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/@prettier/plugin-php/-/plugin-php-0.15.1.tgz",
"integrity": "sha512-uQiaGGXCs0uqpck1LyDU+V4Z50Qqml7ltajPQL+DB43r5aHVawDCSkgLGYZJSb1g+hK5eBmdVBqMa7ED8EBjbA==",
"dev": true,
"requires": {
"linguist-languages": "^7.5.1",
"mem": "^6.0.1",
"mem": "^8.0.0",
"php-parser": "3.0.2"
}
},
@ -3133,13 +3139,13 @@
"dev": true
},
"@typescript-eslint/eslint-plugin": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.4.1.tgz",
"integrity": "sha512-O+8Utz8pb4OmcA+Nfi5THQnQpHSD2sDUNw9AxNHpuYOo326HZTtG8gsfT+EAYuVrFNaLyNb2QnUNkmTRDskuRA==",
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.5.0.tgz",
"integrity": "sha512-mjb/gwNcmDKNt+6mb7Aj/TjKzIJjOPcoCJpjBQC9ZnTRnBt1p4q5dJSSmIqAtsZ/Pff5N+hJlbiPc5bl6QN4OQ==",
"dev": true,
"requires": {
"@typescript-eslint/experimental-utils": "4.4.1",
"@typescript-eslint/scope-manager": "4.4.1",
"@typescript-eslint/experimental-utils": "4.5.0",
"@typescript-eslint/scope-manager": "4.5.0",
"debug": "^4.1.1",
"functional-red-black-tree": "^1.0.1",
"regexpp": "^3.0.0",
@ -3156,55 +3162,55 @@
}
},
"@typescript-eslint/experimental-utils": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.4.1.tgz",
"integrity": "sha512-Nt4EVlb1mqExW9cWhpV6pd1a3DkUbX9DeyYsdoeziKOpIJ04S2KMVDO+SEidsXRH/XHDpbzXykKcMTLdTXH6cQ==",
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.5.0.tgz",
"integrity": "sha512-bW9IpSAKYvkqDGRZzayBXIgPsj2xmmVHLJ+flGSoN0fF98pGoKFhbunIol0VF2Crka7z984EEhFi623Rl7e6gg==",
"dev": true,
"requires": {
"@types/json-schema": "^7.0.3",
"@typescript-eslint/scope-manager": "4.4.1",
"@typescript-eslint/types": "4.4.1",
"@typescript-eslint/typescript-estree": "4.4.1",
"@typescript-eslint/scope-manager": "4.5.0",
"@typescript-eslint/types": "4.5.0",
"@typescript-eslint/typescript-estree": "4.5.0",
"eslint-scope": "^5.0.0",
"eslint-utils": "^2.0.0"
}
},
"@typescript-eslint/parser": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.4.1.tgz",
"integrity": "sha512-S0fuX5lDku28Au9REYUsV+hdJpW/rNW0gWlc4SXzF/kdrRaAVX9YCxKpziH7djeWT/HFAjLZcnY7NJD8xTeUEg==",
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.5.0.tgz",
"integrity": "sha512-xb+gmyhQcnDWe+5+xxaQk5iCw6KqXd8VQxGiTeELTMoYeRjpocZYYRP1gFVM2C8Yl0SpUvLa1lhprwqZ00w3Iw==",
"dev": true,
"requires": {
"@typescript-eslint/scope-manager": "4.4.1",
"@typescript-eslint/types": "4.4.1",
"@typescript-eslint/typescript-estree": "4.4.1",
"@typescript-eslint/scope-manager": "4.5.0",
"@typescript-eslint/types": "4.5.0",
"@typescript-eslint/typescript-estree": "4.5.0",
"debug": "^4.1.1"
}
},
"@typescript-eslint/scope-manager": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.4.1.tgz",
"integrity": "sha512-2oD/ZqD4Gj41UdFeWZxegH3cVEEH/Z6Bhr/XvwTtGv66737XkR4C9IqEkebCuqArqBJQSj4AgNHHiN1okzD/wQ==",
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.5.0.tgz",
"integrity": "sha512-C0cEO0cTMPJ/w4RA/KVe4LFFkkSh9VHoFzKmyaaDWAnPYIEzVCtJ+Un8GZoJhcvq+mPFXEsXa01lcZDHDG6Www==",
"dev": true,
"requires": {
"@typescript-eslint/types": "4.4.1",
"@typescript-eslint/visitor-keys": "4.4.1"
"@typescript-eslint/types": "4.5.0",
"@typescript-eslint/visitor-keys": "4.5.0"
}
},
"@typescript-eslint/types": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.4.1.tgz",
"integrity": "sha512-KNDfH2bCyax5db+KKIZT4rfA8rEk5N0EJ8P0T5AJjo5xrV26UAzaiqoJCxeaibqc0c/IvZxp7v2g3difn2Pn3w==",
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.5.0.tgz",
"integrity": "sha512-n2uQoXnyWNk0Les9MtF0gCK3JiWd987JQi97dMSxBOzVoLZXCNtxFckVqt1h8xuI1ix01t+iMY4h4rFMj/303g==",
"dev": true
},
"@typescript-eslint/typescript-estree": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.4.1.tgz",
"integrity": "sha512-wP/V7ScKzgSdtcY1a0pZYBoCxrCstLrgRQ2O9MmCUZDtmgxCO/TCqOTGRVwpP4/2hVfqMz/Vw1ZYrG8cVxvN3g==",
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.5.0.tgz",
"integrity": "sha512-gN1mffq3zwRAjlYWzb5DanarOPdajQwx5MEWkWCk0XvqC8JpafDTeioDoow2L4CA/RkYZu7xEsGZRhqrTsAG8w==",
"dev": true,
"requires": {
"@typescript-eslint/types": "4.4.1",
"@typescript-eslint/visitor-keys": "4.4.1",
"@typescript-eslint/types": "4.5.0",
"@typescript-eslint/visitor-keys": "4.5.0",
"debug": "^4.1.1",
"globby": "^11.0.1",
"is-glob": "^4.0.1",
@ -3222,12 +3228,12 @@
}
},
"@typescript-eslint/visitor-keys": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.4.1.tgz",
"integrity": "sha512-H2JMWhLaJNeaylSnMSQFEhT/S/FsJbebQALmoJxMPMxLtlVAMy2uJP/Z543n9IizhjRayLSqoInehCeNW9rWcw==",
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.5.0.tgz",
"integrity": "sha512-UHq4FSa55NDZqscRU//O5ROFhHa9Hqn9KWTEvJGTArtTQp5GKv9Zqf6d/Q3YXXcFv4woyBml7fJQlQ+OuqRcHA==",
"dev": true,
"requires": {
"@typescript-eslint/types": "4.4.1",
"@typescript-eslint/types": "4.5.0",
"eslint-visitor-keys": "^2.0.0"
}
},
@ -5909,9 +5915,9 @@
}
},
"eslint-config-prettier": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-6.13.0.tgz",
"integrity": "sha512-LcT0i0LSmnzqK2t764pyIt7kKH2AuuqKRTtJTdddWxOiUja9HdG5GXBVF2gmCTvVYWVsTu8J2MhJLVGRh+pj8w==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-6.14.0.tgz",
"integrity": "sha512-DbVwh0qZhAC7CNDWcq8cBdK6FcVHiMTKmCypOPWeZkp9hJ8xYwTaWSa6bb6cjfi8KOeJy0e9a8Izxyx+O4+gCQ==",
"dev": true,
"requires": {
"get-stdin": "^6.0.0"
@ -6488,6 +6494,11 @@
"write": "1.0.3"
}
},
"flatpickr": {
"version": "4.6.6",
"resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.6.tgz",
"integrity": "sha512-EZ48CJMttMg3maMhJoX+GvTuuEhX/RbA1YeuI19attP3pwBdbYy6+yqAEVm0o0hSBFYBiLbVxscLW6gJXq6H3A=="
},
"flatted": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-2.0.2.tgz",
@ -7888,9 +7899,9 @@
}
},
"lint-staged": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-10.4.1.tgz",
"integrity": "sha512-E2Y6Mu1haUD3ZefzwBG8tqy3QDQ9udWRS946YcuDCU8Mi22RjwxrEhLrqTLszxl80DG/sCtKdGCArzEkTsBzJQ==",
"version": "10.4.2",
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-10.4.2.tgz",
"integrity": "sha512-OLCA9K1hS+Sl179SO6kX0JtnsaKj/MZalEhUj5yAgXsb63qPI/Gfn6Ua1KuZdbfkZNEu3/n5C/obYCu70IMt9g==",
"dev": true,
"requires": {
"chalk": "^4.1.0",
@ -8612,13 +8623,13 @@
"integrity": "sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4="
},
"mem": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/mem/-/mem-6.1.1.tgz",
"integrity": "sha512-Ci6bIfq/UgcxPTYa8dQQ5FY3BzKkT894bwXWXxC/zqs0XgMO2cT20CGkOqda7gZNkmK5VP4x89IGZ6K7hfbn3Q==",
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/mem/-/mem-8.0.0.tgz",
"integrity": "sha512-qrcJOe6uD+EW8Wrci1Vdiua/15Xw3n/QnaNXE7varnB6InxSk7nu3/i5jfy3S6kWxr8WYJ6R1o0afMUtvorTsA==",
"dev": true,
"requires": {
"map-age-cleaner": "^0.1.3",
"mimic-fn": "^3.0.0"
"mimic-fn": "^3.1.0"
}
},
"meow": {
@ -15403,9 +15414,9 @@
}
},
"rollup": {
"version": "2.31.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.31.0.tgz",
"integrity": "sha512-0d8S3XwEZ7aCP910/9SjnelgLvC+ZXziouVolzxPOM1zvKkHioGkWGJIWmlOULlmvB8BZ6S0wrgsT4yMz0eyMg==",
"version": "2.32.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.32.1.tgz",
"integrity": "sha512-Op2vWTpvK7t6/Qnm1TTh7VjEZZkN8RWgf0DHbkKzQBwNf748YhXbozHVefqpPp/Fuyk/PQPAnYsBxAEtlMvpUw==",
"dev": true,
"requires": {
"fsevents": "~2.1.2"
@ -17027,9 +17038,9 @@
}
},
"tailwindcss": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-1.9.2.tgz",
"integrity": "sha512-D3uKSZZkh4GaKiZWmPEfNrqEmEuYdwaqXOQ7trYSQQFI5laSD9+b2FUUj5g39nk5R1omKp5tBW9wZsfJq+KIVA==",
"version": "1.9.5",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-1.9.5.tgz",
"integrity": "sha512-Je5t1fAfyW333YTpSxF+8uJwbnrkpyBskDtZYgSMMKQbNp6QUhEKJ4g/JIevZjD2Zidz9VxLraEUq/yWOx6nQg==",
"dev": true,
"requires": {
"@fullhuman/postcss-purgecss": "^2.1.2",

View File

@ -25,23 +25,24 @@
"release": "semantic-release"
},
"dependencies": {
"@amcharts/amcharts4": "^4.10.7",
"@amcharts/amcharts4": "^4.10.8",
"@amcharts/amcharts4-geodata": "^4.1.17",
"@popperjs/core": "^2.5.3",
"choices.js": "^9.0.1",
"flatpickr": "^4.6.6",
"prosemirror-example-setup": "^1.1.2",
"prosemirror-markdown": "^1.5.0",
"prosemirror-state": "^1.3.3",
"prosemirror-view": "^1.16.0"
},
"devDependencies": {
"@babel/core": "^7.12.1",
"@babel/core": "^7.12.3",
"@babel/plugin-proposal-class-properties": "^7.12.1",
"@babel/preset-env": "^7.12.1",
"@babel/preset-typescript": "^7.12.1",
"@commitlint/cli": "^11.0.0",
"@commitlint/config-conventional": "^11.0.0",
"@prettier/plugin-php": "^0.15.0",
"@prettier/plugin-php": "^0.15.1",
"@rollup/plugin-babel": "^5.2.1",
"@rollup/plugin-commonjs": "^15.1.0",
"@rollup/plugin-json": "^4.1.0",
@ -55,22 +56,22 @@
"@tailwindcss/typography": "^0.2.0",
"@types/prosemirror-markdown": "^1.0.3",
"@types/prosemirror-view": "^1.16.1",
"@typescript-eslint/eslint-plugin": "^4.4.1",
"@typescript-eslint/parser": "^4.4.1",
"@typescript-eslint/eslint-plugin": "^4.5.0",
"@typescript-eslint/parser": "^4.5.0",
"cross-env": "^7.0.2",
"cssnano": "^4.1.10",
"cz-conventional-changelog": "^3.3.0",
"eslint": "^7.11.0",
"eslint-config-prettier": "^6.13.0",
"eslint-config-prettier": "^6.14.0",
"eslint-plugin-prettier": "^3.1.4",
"husky": "^4.3.0",
"lint-staged": "^10.4.1",
"lint-staged": "^10.4.2",
"postcss-cli": "^8.1.0",
"postcss-import": "^12.0.1",
"postcss-preset-env": "^6.7.0",
"prettier": "2.1.2",
"prettier-plugin-organize-imports": "^1.1.1",
"rollup": "^2.31.0",
"rollup": "^2.32.1",
"rollup-plugin-multi-input": "^1.1.1",
"rollup-plugin-node-polyfills": "^0.2.1",
"rollup-plugin-postcss": "^3.1.8",
@ -79,7 +80,7 @@
"stylelint": "^13.7.2",
"stylelint-config-standard": "^20.0.0",
"svgo": "^1.3.2",
"tailwindcss": "^1.9.2",
"tailwindcss": "^1.9.5",
"typescript": "^4.0.3"
},
"husky": {

View File

@ -1,9 +1,9 @@
# Running Application Tests
This is the quick-start to CodeIgniter testing. Its intent is to describe what
it takes to set up your application and get it ready to run unit tests.
It is not intended to be a full description of the test features that you can
use to test your application. Those details can be found in the documentation.
it takes to set up your application and get it ready to run unit tests. It is
not intended to be a full description of the test features that you can use to
test your application. Those details can be found in the documentation.
## Resources
@ -15,33 +15,36 @@ use to test your application. Those details can be found in the documentation.
It is recommended to use the latest version of PHPUnit. At the time of this
writing we are running version 8.5.2. Support for this has been built into the
**composer.json** file that ships with CodeIgniter and can easily be installed
via [Composer](https://getcomposer.org/) if you don't already have it installed globally.
via [Composer](https://getcomposer.org/) if you don't already have it installed
globally.
> composer install
If running under OS X or Linux, you can create a symbolic link to make running tests a touch nicer.
If running under OS X or Linux, you can create a symbolic link to make running
tests a touch nicer.
> ln -s ./vendor/bin/phpunit ./phpunit
You also need to install [XDebug](https://xdebug.org/index.php) in order
for code coverage to be calculated successfully.
You also need to install [XDebug](https://xdebug.org/index.php) in order for
code coverage to be calculated successfully.
## Setting Up
A number of the tests use a running database.
In order to set up the database edit the details for the `tests` group in
**app/Config/Database.php** or **phpunit.xml**. Make sure that you provide a database engine
that is currently running on your machine. More details on a test database setup are in the
A number of the tests use a running database. In order to set up the database
edit the details for the `tests` group in **app/Config/Database.php** or
**phpunit.xml**. Make sure that you provide a database engine that is currently
running on your machine. More details on a test database setup are in the
_Docs>>Testing>>Testing Your Database_ section of the documentation.
If you want to run the tests without using live database you can
exclude @DatabaseLive group. Or make a copy of **phpunit.dist.xml** -
call it **phpunit.xml** - and comment out the <testsuite> named "database". This will make
the tests run quite a bit faster.
If you want to run the tests without using live database you can exclude
@DatabaseLive group. Or make a copy of **phpunit.dist.xml** - call it
**phpunit.xml** - and comment out the <testsuite> named "database". This will
make the tests run quite a bit faster.
## Running the tests
The entire test suite can be run by simply typing one command-line command from the main directory.
The entire test suite can be run by simply typing one command-line command from
the main directory.
> ./phpunit
@ -52,59 +55,62 @@ directory name after phpunit.
## Generating Code Coverage
To generate coverage information, including HTML reports you can view in your browser,
you can use the following command:
To generate coverage information, including HTML reports you can view in your
browser, you can use the following command:
> ./phpunit --colors --coverage-text=tests/coverage.txt --coverage-html=tests/coverage/ -d memory_limit=1024m
This runs all of the tests again collecting information about how many lines,
functions, and files are tested. It also reports the percentage of the code that is covered by tests.
It is collected in two formats: a simple text file that provides an overview as well
as a comprehensive collection of HTML files that show the status of every line of code in the project.
functions, and files are tested. It also reports the percentage of the code that
is covered by tests. It is collected in two formats: a simple text file that
provides an overview as well as a comprehensive collection of HTML files that
show the status of every line of code in the project.
The text file can be found at **tests/coverage.txt**.
The HTML files can be viewed by opening **tests/coverage/index.html** in your favorite browser.
The text file can be found at **tests/coverage.txt**. The HTML files can be
viewed by opening **tests/coverage/index.html** in your favorite browser.
## PHPUnit XML Configuration
The repository has a `phpunit.xml.dist` file in the project root that's used for
PHPUnit configuration. This is used to provide a default configuration if you
do not have your own configuration file in the project root.
PHPUnit configuration. This is used to provide a default configuration if you do
not have your own configuration file in the project root.
The normal practice would be to copy `phpunit.xml.dist` to `phpunit.xml`
(which is git ignored), and to tailor it as you see fit.
For instance, you might wish to exclude database tests, or automatically generate
HTML code coverage reports.
The normal practice would be to copy `phpunit.xml.dist` to `phpunit.xml` (which
is git ignored), and to tailor it as you see fit. For instance, you might wish
to exclude database tests, or automatically generate HTML code coverage reports.
## Test Cases
Every test needs a _test case_, or class that your tests extend. CodeIgniter 4
provides a few that you may use directly:
- `CodeIgniter\Test\CIUnitTestCase` - for basic tests with no other service needs
- `CodeIgniter\Test\CIUnitTestCase` - for basic tests with no other service
needs
- `CodeIgniter\Test\CIDatabaseTestCase` - for tests that need database access
Most of the time you will want to write your own test cases to hold functions and services
common to your test suites.
Most of the time you will want to write your own test cases to hold functions
and services common to your test suites.
## Creating Tests
All tests go in the **tests/** directory. Each test file is a class that extends a
**Test Case** (see above) and contains methods for the individual tests. These method
names must start with the word "test" and should have descriptive names for precisely what
they are testing:
`testUserCanModifyFile()` `testOutputColorMatchesInput()` `testIsLoggedInFailsWithInvalidUser()`
All tests go in the **tests/** directory. Each test file is a class that extends
a **Test Case** (see above) and contains methods for the individual tests. These
method names must start with the word "test" and should have descriptive names
for precisely what they are testing: `testUserCanModifyFile()`
`testOutputColorMatchesInput()` `testIsLoggedInFailsWithInvalidUser()`
Writing tests is an art, and there are many resources available to help learn how.
Review the links above and always pay attention to your code coverage.
Writing tests is an art, and there are many resources available to help learn
how. Review the links above and always pay attention to your code coverage.
### Database Tests
Tests can include migrating, seeding, and testing against a mock or live<sup>1</sup> database.
Be sure to modify the test case (or create your own) to point to your seed and migrations
and include any additional steps to be run before tests in the `setUp()` method.
Tests can include migrating, seeding, and testing against a mock or
live<sup>1</sup> database. Be sure to modify the test case (or create your own)
to point to your seed and migrations and include any additional steps to be run
before tests in the `setUp()` method.
<sup>1</sup> Note: If you are using database tests that require a live database connection
you will need to rename **phpunit.xml.dist** to **phpunit.xml**, uncomment the database
configuration lines and add your connection details. Prevent **phpunit.xml** from being
tracked in your repo by adding it to **.gitignore**.
<sup>1</sup> Note: If you are using database tests that require a live database
connection you will need to rename **phpunit.xml.dist** to **phpunit.xml**,
uncomment the database configuration lines and add your connection details.
Prevent **phpunit.xml** from being tracked in your repo by adding it to
**.gitignore**.

View File

@ -21,5 +21,6 @@
/* Advanced Options */
"skipLibCheck": true /* Skip type checking of declaration files. */,
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
}
},
"include": ["app/Views/_assets/**/*"]
}