diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 43df8b3b..adec234c 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -30,7 +30,7 @@ $routes->setAutoRoute(false); */ $routes->addPlaceholder('podcastName', '[a-zA-Z0-9\_]{1,191}'); -$routes->addPlaceholder('episodeSlug', '[a-zA-Z0-9\-]{1,191}'); +$routes->addPlaceholder('slug', '[a-zA-Z0-9\-]{1,191}'); /** * -------------------------------------------------------------------- @@ -53,15 +53,6 @@ $routes->group(config('App')->installGateway, function ($routes) { ]); }); -// Public routes -$routes->group('@(:podcastName)', function ($routes) { - $routes->get('/', 'Podcast/$1', ['as' => 'podcast']); - $routes->get('(:episodeSlug)', 'Episode/$1/$2', [ - 'as' => 'episode', - ]); - $routes->get('feed.xml', 'Feed/$1', ['as' => 'podcast_feed']); -}); - // Route for podcast audio file analytics (/stats/podcast_id/episode_id/podcast_folder/filename.mp3) $routes->add('stats/(:num)/(:num)/(:any)', 'Analytics::hit/$1/$2/$3', [ 'as' => 'analytics_hit', @@ -80,10 +71,6 @@ $routes->group( 'as' => 'admin', ]); - $routes->get('my-podcasts', 'Podcast::myPodcasts', [ - 'as' => 'my-podcasts', - ]); - // Podcasts $routes->group('podcasts', function ($routes) { $routes->get('/', 'Podcast::list', [ @@ -201,6 +188,27 @@ $routes->group( }); }); + // Pages + $routes->group('pages', function ($routes) { + $routes->get('/', 'Page::list', ['as' => 'page-list']); + $routes->get('new', 'Page::create', [ + 'as' => 'page-create', + ]); + $routes->post('new', 'Page::attemptCreate'); + + $routes->group('(:num)', function ($routes) { + $routes->get('/', 'Page::view/$1', ['as' => 'page-view']); + $routes->get('edit', 'Page::edit/$1', [ + 'as' => 'page-edit', + ]); + $routes->post('edit', 'Page::attemptEdit/$1'); + + $routes->add('delete', 'Page::delete/$1', [ + 'as' => 'page-delete', + ]); + }); + }); + // Users $routes->group('users', function ($routes) { $routes->get('/', 'User::list', [ @@ -294,6 +302,16 @@ $routes->group(config('App')->authGateway, function ($routes) { $routes->post('reset-password', 'Auth::attemptReset'); }); +// Public routes +$routes->group('@(:podcastName)', function ($routes) { + $routes->get('/', 'Podcast/$1', ['as' => 'podcast']); + $routes->get('(:slug)', 'Episode/$1/$2', [ + 'as' => 'episode', + ]); + $routes->get('feed.xml', 'Feed/$1', ['as' => 'podcast_feed']); +}); +$routes->get('/(:slug)', 'Page/$1', ['as' => 'page']); + /** * -------------------------------------------------------------------- * Additional Routing diff --git a/app/Config/Validation.php b/app/Config/Validation.php index 25ec0ce8..f27fefb9 100644 --- a/app/Config/Validation.php +++ b/app/Config/Validation.php @@ -19,6 +19,7 @@ class Validation \CodeIgniter\Validation\FormatRules::class, \CodeIgniter\Validation\FileRules::class, \CodeIgniter\Validation\CreditCardRules::class, + \App\Validation\Rules::class, \Myth\Auth\Authentication\Passwords\ValidationRules::class, ]; diff --git a/app/Controllers/Admin/Page.php b/app/Controllers/Admin/Page.php new file mode 100644 index 00000000..384b72bf --- /dev/null +++ b/app/Controllers/Admin/Page.php @@ -0,0 +1,111 @@ + 0) { + if (!($this->page = (new PageModel())->find($params[0]))) { + throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound(); + } + } + + return $this->$method(); + } + + function list() + { + $data = [ + 'pages' => (new PageModel())->findAll(), + ]; + + return view('admin/page/list', $data); + } + + function view() + { + return view('admin/page/view', ['page' => $this->page]); + } + + function create() + { + helper('form'); + + return view('admin/page/create'); + } + + function attemptCreate() + { + $page = new \App\Entities\Page([ + 'title' => $this->request->getPost('title'), + 'slug' => $this->request->getPost('slug'), + 'content' => $this->request->getPost('content'), + ]); + + $pageModel = new PageModel(); + + if (!$pageModel->save($page)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $pageModel->errors()); + } + + return redirect() + ->route('page-list') + ->with( + 'message', + lang('Page.messages.createSuccess', [ + 'pageTitle' => $page->title, + ]) + ); + } + + function edit() + { + helper('form'); + + replace_breadcrumb_params([0 => $this->page->title]); + return view('admin/page/edit', ['page' => $this->page]); + } + + function attemptEdit() + { + $this->page->title = $this->request->getPost('title'); + $this->page->slug = $this->request->getPost('slug'); + $this->page->content = $this->request->getPost('content'); + + $pageModel = new PageModel(); + + if (!$pageModel->save($this->page)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $pageModel->errors()); + } + + return redirect()->route('page-list'); + } + + public function delete() + { + (new PageModel())->delete($this->page->id); + + return redirect()->route('page-list'); + } +} diff --git a/app/Controllers/Admin/Podcast.php b/app/Controllers/Admin/Podcast.php index 2f9dcef1..6de23cf9 100644 --- a/app/Controllers/Admin/Podcast.php +++ b/app/Controllers/Admin/Podcast.php @@ -31,23 +31,16 @@ class Podcast extends BaseController return $this->$method(); } - public function myPodcasts() - { - $data = [ - 'podcasts' => (new PodcastModel())->getUserPodcasts(user()->id), - ]; - - return view('admin/podcast/list', $data); - } - public function list() { if (!has_permission('podcasts-list')) { - return redirect()->route('my-podcasts'); + $data = [ + 'podcasts' => (new PodcastModel())->getUserPodcasts(user()->id), + ]; + } else { + $data = ['podcasts' => (new PodcastModel())->findAll()]; } - $data = ['podcasts' => (new PodcastModel())->findAll()]; - return view('admin/podcast/list', $data); } @@ -155,7 +148,7 @@ class Podcast extends BaseController $db->transComplete(); - return redirect()->route('podcast-list'); + return redirect()->route('podcast-view', [$newPodcastId]); } public function edit() diff --git a/app/Controllers/Page.php b/app/Controllers/Page.php new file mode 100644 index 00000000..b30b5fd6 --- /dev/null +++ b/app/Controllers/Page.php @@ -0,0 +1,45 @@ + 0) { + if ( + !($this->page = (new PageModel()) + ->where('slug', $params[0]) + ->first()) + ) { + throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound(); + } + } + + return $this->$method(); + } + + public function index() + { + // The page cache is set to a decade so it is deleted manually upon page update + $this->cachePage(DECADE); + + $data = [ + 'page' => $this->page, + ]; + return view('page', $data); + } +} diff --git a/app/Database/Migrations/2020-05-30-101500_add_podcasts.php b/app/Database/Migrations/2020-05-30-101500_add_podcasts.php index 368699c1..5aa7aa20 100644 --- a/app/Database/Migrations/2020-05-30-101500_add_podcasts.php +++ b/app/Database/Migrations/2020-05-30-101500_add_podcasts.php @@ -54,19 +54,17 @@ class AddPodcasts extends Migration 'constraint' => 1, 'default' => 0, ], - 'author' => [ - 'type' => 'VARCHAR', - 'constraint' => 1024, - 'null' => true, - ], 'owner_name' => [ 'type' => 'VARCHAR', 'constraint' => 1024, - 'null' => true, ], 'owner_email' => [ 'type' => 'VARCHAR', 'constraint' => 1024, + ], + 'author' => [ + 'type' => 'VARCHAR', + 'constraint' => 1024, 'null' => true, ], 'type' => [ diff --git a/app/Database/Migrations/2020-06-05-170000_add_episodes.php b/app/Database/Migrations/2020-06-05-170000_add_episodes.php index 23bd03ff..c0fa74af 100644 --- a/app/Database/Migrations/2020-06-05-170000_add_episodes.php +++ b/app/Database/Migrations/2020-06-05-170000_add_episodes.php @@ -41,7 +41,6 @@ class AddEpisodes extends Migration 'type' => 'VARCHAR', 'constraint' => 1024, ], - 'description' => [ 'type' => 'TEXT', 'null' => true, diff --git a/app/Database/Migrations/2020-08-17-150000_add_pages.php b/app/Database/Migrations/2020-08-17-150000_add_pages.php new file mode 100644 index 00000000..32f22ca0 --- /dev/null +++ b/app/Database/Migrations/2020-08-17-150000_add_pages.php @@ -0,0 +1,58 @@ +forge->addField([ + 'id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'title' => [ + 'type' => 'VARCHAR', + 'constraint' => 1024, + ], + 'slug' => [ + 'type' => 'VARCHAR', + 'constraint' => 191, + 'unique' => true, + ], + 'content' => [ + 'type' => 'TEXT', + ], + 'created_at' => [ + 'type' => 'TIMESTAMP', + ], + 'updated_at' => [ + 'type' => 'TIMESTAMP', + ], + 'deleted_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->createTable('pages'); + } + + public function down() + { + $this->forge->dropTable('pages'); + } +} diff --git a/app/Entities/Page.php b/app/Entities/Page.php new file mode 100644 index 00000000..bb40d68a --- /dev/null +++ b/app/Entities/Page.php @@ -0,0 +1,47 @@ + 'integer', + 'title' => 'string', + 'slug' => 'string', + 'content' => 'string', + ]; + + public function getLink() + { + return base_url($this->attributes['slug']); + } + + public function getContentHtml() + { + $converter = new CommonMarkConverter([ + 'html_input' => 'strip', + 'allow_unsafe_links' => false, + ]); + + return $converter->convertToHtml($this->attributes['content']); + } +} diff --git a/app/Helpers/page_helper.php b/app/Helpers/page_helper.php new file mode 100644 index 00000000..488a6740 --- /dev/null +++ b/app/Helpers/page_helper.php @@ -0,0 +1,27 @@ +findAll(); + $links = ''; + foreach ($pages as $page) { + $links .= anchor($page->link, $page->title, [ + 'class' => 'px-2 underline hover:no-underline', + ]); + } + + return ''; +} diff --git a/app/Helpers/rss_helper.php b/app/Helpers/rss_helper.php index 690049f2..58f50726 100644 --- a/app/Helpers/rss_helper.php +++ b/app/Helpers/rss_helper.php @@ -95,13 +95,9 @@ function get_rss_feed($podcast) $channel->addChild('author', $podcast->author, $itunes_namespace); $channel->addChild('link', $podcast->link); - if ($podcast->owner_name || $podcast->owner_email) { - $owner = $channel->addChild('owner', null, $itunes_namespace); - $podcast->owner_name && - $owner->addChild('name', $podcast->owner_name, $itunes_namespace); - $podcast->owner_email && - $owner->addChild('email', $podcast->owner_email, $itunes_namespace); - } + $owner = $channel->addChild('owner', null, $itunes_namespace); + $owner->addChild('name', $podcast->owner_name, $itunes_namespace); + $owner->addChild('email', $podcast->owner_email, $itunes_namespace); $channel->addChild('type', $podcast->type, $itunes_namespace); $podcast->copyright && $channel->addChild('copyright', $podcast->copyright); diff --git a/app/Language/en/AdminNavigation.php b/app/Language/en/AdminNavigation.php index 4f221580..4f03423a 100644 --- a/app/Language/en/AdminNavigation.php +++ b/app/Language/en/AdminNavigation.php @@ -10,11 +10,13 @@ return [ 'dashboard' => 'Dashboard', 'podcasts' => 'Podcasts', 'users' => 'Users', + 'pages' => 'Pages', 'admin' => 'Home', - 'my-podcasts' => 'My podcasts', 'podcast-list' => 'All podcasts', 'podcast-create' => 'New podcast', 'user-list' => 'All users', 'user-create' => 'New user', + 'page-list' => 'All pages', + 'page-create' => 'New Page', 'go_to_website' => 'Go to website', ]; diff --git a/app/Language/en/Breadcrumb.php b/app/Language/en/Breadcrumb.php index 6ef22d33..bd5d4b61 100644 --- a/app/Language/en/Breadcrumb.php +++ b/app/Language/en/Breadcrumb.php @@ -9,10 +9,10 @@ return [ 'label' => 'breadcrumb', config('App')->adminGateway => 'Home', - 'my-podcasts' => 'my podcasts', 'podcasts' => 'podcasts', 'episodes' => 'episodes', 'contributors' => 'contributors', + 'pages' => 'pages', 'add' => 'add', 'new' => 'new', 'edit' => 'edit', diff --git a/app/Language/en/Page.php b/app/Language/en/Page.php new file mode 100644 index 00000000..e0f5f032 --- /dev/null +++ b/app/Language/en/Page.php @@ -0,0 +1,25 @@ + 'All pages', + 'create' => 'New page', + 'go_to_page' => 'Go to page', + 'edit' => 'Edit page', + 'delete' => 'Delete page', + 'form' => [ + 'title' => 'Title', + 'slug' => 'Slug', + 'content' => 'Content', + 'submit_create' => 'Create page', + 'submit_edit' => 'Save', + ], + 'messages' => [ + 'createSuccess' => 'The {pageTitle} page was created successfully!', + ], +]; diff --git a/app/Language/en/Podcast.php b/app/Language/en/Podcast.php index 03bae019..b64c6725 100644 --- a/app/Language/en/Podcast.php +++ b/app/Language/en/Podcast.php @@ -42,15 +42,15 @@ return [ 'explicit' => 'Explicit', 'explicit_help' => 'The podcast parental advisory information. Does it contain explicit content?', - 'author' => 'Author', - 'author_help' => - 'The group responsible for creating the show. Show author most often refers to the parent company or network of a podcast. This field is sometimes labeled as ’Author’.', 'owner_name' => 'Owner name', 'owner_name_help' => 'The podcast owner contact name. For administrative use only. It will not be shown on podcasts platforms (such as Apple Podcasts) nor players (such as Podcast Addict) but it is visible in the public RSS feed.', 'owner_email' => 'Owner email', 'owner_email_help' => 'The podcast owner contact e-mail. For administrative use only. It will mostly be used by some platforms to verify this podcast ownerhip. It will not be shown on podcasts platforms (such as Apple Podcasts) nor players (such as Podcast Addict) but it is visible in the public RSS feed.', + 'author' => 'Author', + 'author_help' => + 'The group responsible for creating the show. Show author most often refers to the parent company or network of a podcast. This field is sometimes labeled as ’Author’.', 'type' => [ 'label' => 'Type', 'episodic' => 'Episodic', diff --git a/app/Language/en/Validation.php b/app/Language/en/Validation.php new file mode 100644 index 00000000..6672dd85 --- /dev/null +++ b/app/Language/en/Validation.php @@ -0,0 +1,12 @@ + + 'The {field} field conflicts with one of the gateway routes (admin, auth or install).', +]; diff --git a/app/Models/EpisodeModel.php b/app/Models/EpisodeModel.php index cc68834c..9f911d77 100644 --- a/app/Models/EpisodeModel.php +++ b/app/Models/EpisodeModel.php @@ -53,8 +53,10 @@ class EpisodeModel extends Model ]; protected $validationMessages = []; - protected $afterInsert = ['writeEnclosureMetadata', 'clearCache']; - protected $afterUpdate = ['writeEnclosureMetadata', 'clearCache']; + protected $afterInsert = ['writeEnclosureMetadata']; + // 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) diff --git a/app/Models/PageModel.php b/app/Models/PageModel.php new file mode 100644 index 00000000..38224fba --- /dev/null +++ b/app/Models/PageModel.php @@ -0,0 +1,52 @@ + 'required', + 'slug' => + 'required|regex_match[/^[a-zA-Z0-9\-]{1,191}$/]|is_unique[pages.slug,id,{id}]|not_in_protected_slugs', + 'content' => 'required', + ]; + protected $validationMessages = []; + + // Before update because slug might change + protected $beforeUpdate = ['clearCache']; + protected $beforeDelete = ['clearCache']; + + protected function clearCache(array $data) + { + $page = (new PageModel())->find( + is_array($data['id']) ? $data['id'][0] : $data['id'] + ); + + // delete page cache + cache()->delete(md5($page->link)); + + // Clear the cache of all podcast and episode pages + // TODO: change the logic of page caching to prevent clearing all cache every time + cache()->clean(); + + return $data; + } +} diff --git a/app/Models/PodcastModel.php b/app/Models/PodcastModel.php index 01bbdbb4..9a3bc6ac 100644 --- a/app/Models/PodcastModel.php +++ b/app/Models/PodcastModel.php @@ -25,9 +25,9 @@ class PodcastModel extends Model 'language', 'category', 'explicit', - 'author', 'owner_name', 'owner_email', + 'author', 'type', 'copyright', 'block', @@ -50,6 +50,7 @@ class PodcastModel extends Model 'image_uri' => 'required', 'language' => 'required', 'category' => 'required', + 'owner_name' => 'required', 'owner_email' => 'required|valid_email', 'type' => 'required', 'created_by' => 'required', @@ -57,8 +58,8 @@ class PodcastModel extends Model ]; protected $validationMessages = []; - protected $afterInsert = ['clearCache']; - protected $afterUpdate = ['clearCache']; + // clear cache before update if by any chance, the podcast name changes, and so will the podcast link + protected $beforeUpdate = ['clearCache']; protected $beforeDelete = ['clearCache']; /** diff --git a/app/Validation/Rules.php b/app/Validation/Rules.php new file mode 100644 index 00000000..6f264cae --- /dev/null +++ b/app/Validation/Rules.php @@ -0,0 +1,30 @@ +adminGateway, + $appConfig->authGateway, + $appConfig->installGateway, + ]; + return !in_array($value, $protectedSlugs, true); + } +} diff --git a/app/Views/_assets/icons/pages.svg b/app/Views/_assets/icons/pages.svg new file mode 100644 index 00000000..e33ed93f --- /dev/null +++ b/app/Views/_assets/icons/pages.svg @@ -0,0 +1,6 @@ + diff --git a/app/Views/_assets/modules/MarkdownEditor.ts b/app/Views/_assets/modules/MarkdownEditor.ts index 4074ef8b..cb38bca9 100644 --- a/app/Views/_assets/modules/MarkdownEditor.ts +++ b/app/Views/_assets/modules/MarkdownEditor.ts @@ -38,13 +38,7 @@ class ProseMirrorView { constructor(target: HTMLTextAreaElement, content: string) { this.editorContainer = document.createElement("div"); - this.editorContainer.classList.add( - "bg-white", - "border", - "px-2", - "min-h-full", - "prose-sm" - ); + this.editorContainer.classList.add("bg-white", "border"); this.editorContainer.style.minHeight = "200px"; const editor = target.parentNode?.insertBefore( this.editorContainer, @@ -64,6 +58,10 @@ class ProseMirrorView { target.innerHTML = this.content; } }, + attributes: { + class: "prose-sm px-3 py-2 overflow-y-auto", + style: "min-height: 200px; max-height: 500px", + }, }); } diff --git a/app/Views/_layout.php b/app/Views/_layout.php index 9bc3e371..5bd01391 100644 --- a/app/Views/_layout.php +++ b/app/Views/_layout.php @@ -1,9 +1,10 @@ += helper('page') ?>
-