diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index 00756676..d4c1c29c 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -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: diff --git a/app/Controllers/Admin/BaseController.php b/app/Controllers/Admin/BaseController.php index 5d7de7d2..6bc9ff85 100644 --- a/app/Controllers/Admin/BaseController.php +++ b/app/Controllers/Admin/BaseController.php @@ -26,7 +26,7 @@ class BaseController extends Controller * * @var array */ - protected $helpers = ['auth', 'breadcrumb', 'svg', 'components']; + protected $helpers = ['auth', 'breadcrumb', 'svg', 'components', 'misc']; /** * Constructor. diff --git a/app/Controllers/Admin/Episode.php b/app/Controllers/Admin/Episode.php index e10c4060..82a73a14 100644 --- a/app/Controllers/Admin/Episode.php +++ b/app/Controllers/Admin/Episode.php @@ -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'); diff --git a/app/Controllers/Admin/Podcast.php b/app/Controllers/Admin/Podcast.php index 05503ca2..afac930d 100644 --- a/app/Controllers/Admin/Podcast.php +++ b/app/Controllers/Admin/Podcast.php @@ -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(); diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php index 3bfbd4b0..ab249c80 100644 --- a/app/Controllers/BaseController.php +++ b/app/Controllers/BaseController.php @@ -26,7 +26,7 @@ class BaseController extends Controller * * @var array */ - protected $helpers = ['analytics', 'svg', 'components']; + protected $helpers = ['analytics', 'svg', 'components', 'misc']; /** * Constructor. diff --git a/app/Controllers/Episode.php b/app/Controllers/Episode.php index 38c83da9..7b5dc9f7 100644 --- a/app/Controllers/Episode.php +++ b/app/Controllers/Episode.php @@ -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, ]); } diff --git a/app/Controllers/Feed.php b/app/Controllers/Feed.php index 3da87f0d..0170e05e 100644 --- a/app/Controllers/Feed.php +++ b/app/Controllers/Feed.php @@ -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); } diff --git a/app/Controllers/Install.php b/app/Controllers/Install.php index cc9e26d4..053e3fae 100644 --- a/app/Controllers/Install.php +++ b/app/Controllers/Install.php @@ -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', diff --git a/app/Controllers/Podcast.php b/app/Controllers/Podcast.php index 5d555f29..1e1dbda3 100644 --- a/app/Controllers/Podcast.php +++ b/app/Controllers/Podcast.php @@ -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, ]); } diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php index 70ab1ed3..23f7737c 100644 --- a/app/Entities/Episode.php +++ b/app/Entities/Episode.php @@ -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; + } } diff --git a/app/Helpers/components_helper.php b/app/Helpers/components_helper.php index 5b26da6d..0e31cb65 100644 --- a/app/Helpers/components_helper.php +++ b/app/Helpers/components_helper.php @@ -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', + [ + '', + ] + ); + + return '' . + $label . + ''; + } +} +// ------------------------------------------------------------------------ diff --git a/app/Helpers/misc_helper.php b/app/Helpers/misc_helper.php index b1d1b168..b87051c4 100644 --- a/app/Helpers/misc_helper.php +++ b/app/Helpers/misc_helper.php @@ -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 + ); + } +} diff --git a/app/Language/en/Common.php b/app/Language/en/Common.php index 7e90eb89..f1d101e1 100644 --- a/app/Language/en/Common.php +++ b/app/Language/en/Common.php @@ -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}', diff --git a/app/Language/en/Episode.php b/app/Language/en/Episode.php index d2a294c2..61f0132d 100644 --- a/app/Language/en/Episode.php +++ b/app/Language/en/Episode.php @@ -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?', diff --git a/app/Language/fr/Common.php b/app/Language/fr/Common.php index 0bef7680..59a9514e 100644 --- a/app/Language/fr/Common.php +++ b/app/Language/fr/Common.php @@ -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}', diff --git a/app/Language/fr/Episode.php b/app/Language/fr/Episode.php index 1471e307..2b5716cd 100644 --- a/app/Language/fr/Episode.php +++ b/app/Language/fr/Episode.php @@ -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, c’est 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 ?', diff --git a/app/Language/fr/MyAccount.php b/app/Language/fr/MyAccount.php index 5a2181f2..3d75c589 100644 --- a/app/Language/fr/MyAccount.php +++ b/app/Language/fr/MyAccount.php @@ -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 !', ], ]; diff --git a/app/Models/EpisodeModel.php b/app/Models/EpisodeModel.php index 2242266c..e01d5e35 100644 --- a/app/Models/EpisodeModel.php +++ b/app/Models/EpisodeModel.php @@ -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(); diff --git a/app/Models/PodcastModel.php b/app/Models/PodcastModel.php index 5c75640e..0cc21617 100644 --- a/app/Models/PodcastModel.php +++ b/app/Models/PodcastModel.php @@ -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']; diff --git a/app/Views/_assets/admin.ts b/app/Views/_assets/admin.ts index 4adc1b5b..d05abf00 100644 --- a/app/Views/_assets/admin.ts +++ b/app/Views/_assets/admin.ts @@ -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(); diff --git a/app/Views/_assets/modules/Charts.ts b/app/Views/_assets/modules/Charts.ts index 2f6053af..41149edd 100644 --- a/app/Views/_assets/modules/Charts.ts +++ b/app/Views/_assets/modules/Charts.ts @@ -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")); diff --git a/app/Views/_assets/modules/ClientTimezone.ts b/app/Views/_assets/modules/ClientTimezone.ts new file mode 100644 index 00000000..94ad2368 --- /dev/null +++ b/app/Views/_assets/modules/ClientTimezone.ts @@ -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; diff --git a/app/Views/_assets/modules/DateTimePicker.ts b/app/Views/_assets/modules/DateTimePicker.ts new file mode 100644 index 00000000..235cf69f --- /dev/null +++ b/app/Views/_assets/modules/DateTimePicker.ts @@ -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 = 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; diff --git a/app/Views/_assets/modules/Time.ts b/app/Views/_assets/modules/Time.ts new file mode 100644 index 00000000..58ea0f26 --- /dev/null +++ b/app/Views/_assets/modules/Time.ts @@ -0,0 +1,24 @@ +const Time = (): void => { + const timeElements: NodeListOf = 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