From 9c224a8ac6dd95f3c6c087a300fc8bac48e8090f Mon Sep 17 00:00:00 2001 From: Yassine Doghri <yassine@doghri.fr> Date: Tue, 18 Aug 2020 16:31:28 +0000 Subject: [PATCH] feat: add pages table to store custom instance pages (eg. legal-notice, cookie policy, etc.) - add pages migration, model and entity - add page controllers - update routes config to input page forms and page view in public - fix markdow editor focus area - show pages links in public side footer closes #24 --- app/Config/Routes.php | 46 +++++--- app/Config/Validation.php | 1 + app/Controllers/Admin/Page.php | 111 ++++++++++++++++++ app/Controllers/Admin/Podcast.php | 19 +-- app/Controllers/Page.php | 45 +++++++ .../2020-05-30-101500_add_podcasts.php | 8 +- .../2020-06-05-170000_add_episodes.php | 1 - .../2020-08-17-150000_add_pages.php | 58 +++++++++ app/Entities/Page.php | 47 ++++++++ app/Helpers/page_helper.php | 27 +++++ app/Helpers/rss_helper.php | 10 +- app/Language/en/AdminNavigation.php | 4 +- app/Language/en/Breadcrumb.php | 2 +- app/Language/en/Page.php | 25 ++++ app/Language/en/Podcast.php | 6 +- app/Language/en/Validation.php | 12 ++ app/Models/EpisodeModel.php | 6 +- app/Models/PageModel.php | 52 ++++++++ app/Models/PodcastModel.php | 7 +- app/Validation/Rules.php | 30 +++++ app/Views/_assets/icons/pages.svg | 6 + app/Views/_assets/modules/MarkdownEditor.ts | 12 +- app/Views/_layout.php | 8 +- app/Views/admin/_sidenav.php | 3 +- app/Views/admin/page/create.php | 57 +++++++++ app/Views/admin/page/edit.php | 57 +++++++++ app/Views/admin/page/list.php | 47 ++++++++ app/Views/admin/page/view.php | 17 +++ app/Views/admin/podcast/create.php | 20 ++-- app/Views/admin/podcast/edit.php | 17 +-- app/Views/episode.php | 4 + app/Views/home.php | 2 + app/Views/page.php | 11 ++ app/Views/podcast.php | 4 + 34 files changed, 703 insertions(+), 79 deletions(-) create mode 100644 app/Controllers/Admin/Page.php create mode 100644 app/Controllers/Page.php create mode 100644 app/Database/Migrations/2020-08-17-150000_add_pages.php create mode 100644 app/Entities/Page.php create mode 100644 app/Helpers/page_helper.php create mode 100644 app/Language/en/Page.php create mode 100644 app/Language/en/Validation.php create mode 100644 app/Models/PageModel.php create mode 100644 app/Validation/Rules.php create mode 100644 app/Views/_assets/icons/pages.svg create mode 100644 app/Views/admin/page/create.php create mode 100644 app/Views/admin/page/edit.php create mode 100644 app/Views/admin/page/list.php create mode 100644 app/Views/admin/page/view.php create mode 100644 app/Views/page.php diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 43df8b3b09..adec234ceb 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 25ec0ce8e4..f27fefb992 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 0000000000..384b72bf21 --- /dev/null +++ b/app/Controllers/Admin/Page.php @@ -0,0 +1,111 @@ +<?php + +/** + * @copyright 2020 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Controllers\Admin; + +use App\Models\PageModel; + +class Page extends BaseController +{ + /** + * @var \App\Entities\Page|null + */ + protected $page; + + public function _remap($method, ...$params) + { + if (count($params) > 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 2f9dcef13d..6de23cf9c8 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 0000000000..b30b5fd6e4 --- /dev/null +++ b/app/Controllers/Page.php @@ -0,0 +1,45 @@ +<?php + +/** + * @copyright 2020 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Controllers; + +use App\Models\PageModel; + +class Page extends BaseController +{ + /** + * @var \App\Entities\Page|null + */ + protected $page; + + public function _remap($method, ...$params) + { + if (count($params) > 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 368699c11a..5aa7aa20c4 100644 --- a/app/Database/Migrations/2020-05-30-101500_add_podcasts.php +++ b/app/Database/Migrations/2020-05-30-101500_add_podcasts.php @@ -54,17 +54,15 @@ class AddPodcasts extends Migration 'constraint' => 1, 'default' => 0, ], - 'author' => [ + 'owner_name' => [ 'type' => 'VARCHAR', 'constraint' => 1024, - 'null' => true, ], - 'owner_name' => [ + 'owner_email' => [ 'type' => 'VARCHAR', 'constraint' => 1024, - 'null' => true, ], - 'owner_email' => [ + 'author' => [ 'type' => 'VARCHAR', 'constraint' => 1024, 'null' => true, 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 23bd03ff37..c0fa74af15 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 0000000000..32f22ca0c5 --- /dev/null +++ b/app/Database/Migrations/2020-08-17-150000_add_pages.php @@ -0,0 +1,58 @@ +<?php + +/** + * Class AddLanguages + * Creates languages table in database + * + * @copyright 2020 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Database\Migrations; + +use CodeIgniter\Database\Migration; + +class AddPages extends Migration +{ + public function up() + { + $this->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 0000000000..bb40d68a02 --- /dev/null +++ b/app/Entities/Page.php @@ -0,0 +1,47 @@ +<?php + +/** + * @copyright 2020 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Entities; + +use CodeIgniter\Entity; +use League\CommonMark\CommonMarkConverter; + +class Page extends Entity +{ + /** + * @var string + */ + protected $link; + + /** + * @var string + */ + protected $content_html; + + protected $casts = [ + 'id' => '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 0000000000..488a6740e9 --- /dev/null +++ b/app/Helpers/page_helper.php @@ -0,0 +1,27 @@ +<?php + +/** + * @copyright 2020 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +use App\Models\PageModel; + +/** + * Returns instance pages as links inside nav tag + * + * @return string html pages navigation + */ +function render_page_links() +{ + $pages = (new PageModel())->findAll(); + $links = ''; + foreach ($pages as $page) { + $links .= anchor($page->link, $page->title, [ + 'class' => 'px-2 underline hover:no-underline', + ]); + } + + return '<nav class="inline-flex">' . $links . '</nav>'; +} diff --git a/app/Helpers/rss_helper.php b/app/Helpers/rss_helper.php index 690049f2e4..58f5072628 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 4f22158013..4f03423a36 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 6ef22d33d2..bd5d4b61d1 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 0000000000..e0f5f032ee --- /dev/null +++ b/app/Language/en/Page.php @@ -0,0 +1,25 @@ +<?php + +/** + * @copyright 2020 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +return [ + 'all_pages' => '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 03bae0199c..b64c672546 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 0000000000..6672dd852c --- /dev/null +++ b/app/Language/en/Validation.php @@ -0,0 +1,12 @@ +<?php + +/** + * @copyright 2020 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +return [ + 'not_in_protected_slugs' => + '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 cc68834c7b..9f911d770b 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 0000000000..38224fba86 --- /dev/null +++ b/app/Models/PageModel.php @@ -0,0 +1,52 @@ +<?php + +/** + * @copyright 2020 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Models; + +use CodeIgniter\Model; + +class PageModel extends Model +{ + protected $table = 'pages'; + protected $primaryKey = 'id'; + + protected $allowedFields = ['id', 'title', 'slug', 'content']; + + protected $returnType = \App\Entities\Page::class; + protected $useSoftDeletes = true; + + protected $useTimestamps = true; + + protected $validationRules = [ + 'title' => '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 01bbdbb4dc..9a3bc6ac49 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 0000000000..6f264caecb --- /dev/null +++ b/app/Validation/Rules.php @@ -0,0 +1,30 @@ +<?php + +/** + * @copyright 2020 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Validation; + +class Rules +{ + /** + * Value should not be within the array of protected slugs (adminGateway, authGateway or installGateway) + * + * @param string $value + * + * @return boolean + */ + public function not_in_protected_slugs(string $value = null): bool + { + $appConfig = config('App'); + $protectedSlugs = [ + $appConfig->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 0000000000..e33ed93fda --- /dev/null +++ b/app/Views/_assets/icons/pages.svg @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> + <g> + <path fill="none" d="M0 0h24v24H0z"/> + <path d="M5 8v12h14V8H5zm0-2h14V4H5v2zm15 16H4a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1zM7 10h4v4H7v-4zm0 6h10v2H7v-2zm6-5h4v2h-4v-2z"/> + </g> +</svg> diff --git a/app/Views/_assets/modules/MarkdownEditor.ts b/app/Views/_assets/modules/MarkdownEditor.ts index 4074ef8b69..cb38bca969 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 9bc3e3714b..5bd01391ee 100644 --- a/app/Views/_layout.php +++ b/app/Views/_layout.php @@ -1,9 +1,10 @@ +<?= helper('page') ?> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"/> - <title>Castopod</title> + <title><?= $this->renderSection('title') ?></title> <meta name="description" content="Castopod is an open-source hosting platform made for podcasters who want engage and interact with their audience."/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/> <link rel="shortcut icon" type="image/png" href="/favicon.ico" /> @@ -22,7 +23,8 @@ <main class="container flex-1 px-4 py-10 mx-auto"> <?= $this->renderSection('content') ?> </main> - <footer class="container px-2 py-4 mx-auto text-sm text-right border-t"> - Powered by <a class="underline hover:no-underline" href="https://castopod.org" target="_blank" rel="noreferrer noopener">Castopod</a>, a <a class="underline hover:no-underline" href="https://podlibre.org/" target="_blank" rel="noreferrer noopener">Podlibre</a> initiative. + <footer class="container flex justify-between px-2 py-4 mx-auto text-sm text-right border-t"> + <?= render_page_links() ?> + <p>Powered by <a class="underline hover:no-underline" href="https://castopod.org" target="_blank" rel="noreferrer noopener">Castopod</a>, a <a class="underline hover:no-underline" href="https://podlibre.org/" target="_blank" rel="noreferrer noopener">Podlibre</a> initiative.</p> </footer> </body> diff --git a/app/Views/admin/_sidenav.php b/app/Views/admin/_sidenav.php index 5cec5d8b74..6fea0a1cab 100644 --- a/app/Views/admin/_sidenav.php +++ b/app/Views/admin/_sidenav.php @@ -3,9 +3,10 @@ $navigation = [ 'dashboard' => ['icon' => 'dashboard', 'items' => ['admin']], 'podcasts' => [ 'icon' => 'mic', - 'items' => ['my-podcasts', 'podcast-list', 'podcast-create'], + 'items' => ['podcast-list', 'podcast-create'], ], 'users' => ['icon' => 'group', 'items' => ['user-list', 'user-create']], + 'pages' => ['icon' => 'pages', 'items' => ['page-list', 'page-create']], ]; ?> <nav class="<?= $class ?>"> diff --git a/app/Views/admin/page/create.php b/app/Views/admin/page/create.php new file mode 100644 index 0000000000..817a6c833f --- /dev/null +++ b/app/Views/admin/page/create.php @@ -0,0 +1,57 @@ +<?= $this->extend('admin/_layout') ?> + +<?= $this->section('title') ?> +<?= lang('Page.create') ?> +<?= $this->endSection() ?> + + +<?= $this->section('content') ?> + +<?= form_open(route_to('page-create'), [ + 'class' => 'flex flex-col max-w-3xl', +]) ?> +<?= csrf_field() ?> + +<?= form_label(lang('Page.form.title'), 'title', ['class' => 'max-w-sm']) ?> +<?= form_input([ + 'id' => 'title', + 'name' => 'title', + 'class' => 'form-input mb-4 max-w-sm', + 'value' => old('title'), + 'required' => 'required', + 'data-slugify' => 'title', +]) ?> + +<?= form_label(lang('Page.form.slug'), 'slug', ['class' => 'max-w-sm']) ?> +<?= form_input([ + 'id' => 'slug', + 'name' => 'slug', + 'class' => 'form-input mb-4 max-w-sm', + 'value' => old('slug'), + 'required' => 'required', + 'data-slugify' => 'slug', +]) ?> + +<div class="mb-4"> + <?= form_label(lang('Page.form.content'), 'content') ?> + <?= form_textarea( + [ + 'id' => 'content', + 'name' => 'content', + 'class' => 'form-textarea', + 'required' => 'required', + ], + old('content', '', false), + 'data-editor="markdown"' + ) ?> +</div> + +<?= form_button([ + 'content' => lang('Page.form.submit_create'), + 'type' => 'submit', + 'class' => 'self-end px-4 py-2 bg-gray-200', +]) ?> + +<?= form_close() ?> + +<?= $this->endSection() ?> diff --git a/app/Views/admin/page/edit.php b/app/Views/admin/page/edit.php new file mode 100644 index 0000000000..58b99b5e12 --- /dev/null +++ b/app/Views/admin/page/edit.php @@ -0,0 +1,57 @@ +<?= $this->extend('admin/_layout') ?> + +<?= $this->section('title') ?> +<?= lang('Page.edit') ?> +<?= $this->endSection() ?> + + +<?= $this->section('content') ?> + +<?= form_open(route_to('page-edit', $page->id), [ + 'class' => 'flex flex-col max-w-3xl', +]) ?> +<?= csrf_field() ?> + +<?= form_label(lang('Page.form.title'), 'title', ['class' => 'max-w-sm']) ?> +<?= form_input([ + 'id' => 'title', + 'name' => 'title', + 'class' => 'form-input mb-4 max-w-sm', + 'value' => old('title', $page->title), + 'required' => 'required', + 'data-slugify' => 'title', +]) ?> + +<?= form_label(lang('Page.form.slug'), 'slug', ['class' => 'max-w-sm']) ?> +<?= form_input([ + 'id' => 'slug', + 'name' => 'slug', + 'class' => 'form-input mb-4 max-w-sm', + 'value' => old('slug', $page->slug), + 'required' => 'required', + 'data-slugify' => 'slug', +]) ?> + +<div class="mb-4"> + <?= form_label(lang('Page.form.content'), 'content') ?> + <?= form_textarea( + [ + 'id' => 'content', + 'name' => 'content', + 'class' => 'form-textarea', + 'required' => 'required', + ], + old('content', $page->content, false), + 'data-editor="markdown"' + ) ?> +</div> + +<?= form_button([ + 'content' => lang('Page.form.submit_edit'), + 'type' => 'submit', + 'class' => 'self-end px-4 py-2 bg-gray-200', +]) ?> + +<?= form_close() ?> + +<?= $this->endSection() ?> diff --git a/app/Views/admin/page/list.php b/app/Views/admin/page/list.php new file mode 100644 index 0000000000..fd2a962c7b --- /dev/null +++ b/app/Views/admin/page/list.php @@ -0,0 +1,47 @@ +<?= $this->extend('admin/_layout') ?> + +<?= $this->section('title') ?> +<?= lang('Page.all_pages') ?> (<?= count($pages) ?>) +<a class="inline-flex items-center px-2 py-1 mb-2 ml-4 text-sm text-white bg-green-500 rounded shadow-xs outline-none hover:bg-green-600 focus:shadow-outline" href="<?= route_to( + 'page-create' +) ?>"> +<?= icon('add', 'mr-2') ?> +<?= lang('Page.create') ?></a> +<?= $this->endSection() ?> + + +<?= $this->section('content') ?> + +<table class="table-auto"> + <thead> + <tr> + <th class="px-4 py-2">Title</th> + <th class="px-4 py-2">Slug</th> + <th class="px-4 py-2">Actions</th> + </tr> + </thead> + <tbody> + <?php foreach ($pages as $page): ?> + <tr> + <td class="px-4 py-2 border"><?= $page->title ?></td> + <td class="px-4 py-2 border"><?= $page->slug ?></td> + <td class="px-4 py-2 border"> + <a class="inline-flex px-2 py-1 mb-2 text-sm text-white bg-gray-700 hover:bg-gray-800" href="<?= route_to( + 'page', + $page->slug + ) ?>"><?= lang('Page.go_to_page') ?></a> + <a class="inline-flex px-2 py-1 mb-2 text-sm text-white bg-teal-700 hover:bg-teal-800" href="<?= route_to( + 'page-edit', + $page->id + ) ?>"><?= lang('Page.edit') ?></a> + <a class="inline-flex px-2 py-1 text-sm text-white bg-red-700 hover:bg-red-800" href="<?= route_to( + 'page-delete', + $page->id + ) ?>"><?= lang('Page.delete') ?></a> + </td> + </tr> + <?php endforeach; ?> + </tbody> +</table> + +<?= $this->endSection() ?> diff --git a/app/Views/admin/page/view.php b/app/Views/admin/page/view.php new file mode 100644 index 0000000000..992550e4a0 --- /dev/null +++ b/app/Views/admin/page/view.php @@ -0,0 +1,17 @@ +<?= $this->extend('admin/_layout') ?> + +<?= $this->section('title') ?> +<?= $page->title ?> +<a class="inline-flex items-center px-2 py-1 mb-2 ml-4 text-sm text-white bg-teal-500 rounded shadow-xs outline-none hover:bg-teal-600 focus:shadow-outline" href="<?= route_to( + 'page-edit', + $page->id +) ?>"> +<?= icon('edit', 'mr-2') ?> +<?= lang('Page.edit') ?></a> +<?= $this->endSection() ?> + +<?= $this->section('content') ?> +<div class="prose"> + <?= $page->content_html ?> +</div> +<?= $this->endSection() ?> diff --git a/app/Views/admin/podcast/create.php b/app/Views/admin/podcast/create.php index 498199051f..e27277c9a2 100644 --- a/app/Views/admin/podcast/create.php +++ b/app/Views/admin/podcast/create.php @@ -54,6 +54,7 @@ [ 'id' => 'episode_description_footer', 'name' => 'episode_description_footer', + 'class' => 'form-textarea', ], old('episode_description_footer', '', false), @@ -94,20 +95,13 @@ <span class="ml-2"><?= lang('Podcast.form.explicit') ?></span> </label> -<?= form_label(lang('Podcast.form.author'), 'author') ?> -<?= form_input([ - 'id' => 'author', - 'name' => 'author', - 'class' => 'form-input mb-4', - 'value' => old('author'), -]) ?> - <?= form_label(lang('Podcast.form.owner_name'), 'owner_name') ?> <?= form_input([ 'id' => 'owner_name', 'name' => 'owner_name', 'class' => 'form-input mb-4', 'value' => old('owner_name'), + 'required' => 'required', ]) ?> <?= form_label(lang('Podcast.form.owner_email'), 'owner_email') ?> @@ -120,9 +114,15 @@ 'required' => 'required', ]) ?> -<?= form_fieldset('', [ - 'class' => 'flex flex-col mb-4', +<?= form_label(lang('Podcast.form.author'), 'author') ?> +<?= form_input([ + 'id' => 'author', + 'name' => 'author', + 'class' => 'form-input mb-4', + 'value' => old('author'), ]) ?> + +<?= form_fieldset('', ['class' => 'flex flex-col mb-4']) ?> <legend><?= lang('Podcast.form.type.label') ?></legend> <label for="episodic" class="inline-flex items-center"> <?= form_radio( diff --git a/app/Views/admin/podcast/edit.php b/app/Views/admin/podcast/edit.php index b40f82fd37..7581a64831 100644 --- a/app/Views/admin/podcast/edit.php +++ b/app/Views/admin/podcast/edit.php @@ -109,20 +109,13 @@ <span class="ml-2"><?= lang('Podcast.form.explicit') ?></span> </label> -<?= form_label(lang('Podcast.form.author'), 'author') ?> -<?= form_input([ - 'id' => 'author', - 'name' => 'author', - 'class' => 'form-input mb-4', - 'value' => old('author', $podcast->author), -]) ?> - <?= form_label(lang('Podcast.form.owner_name'), 'owner_name') ?> <?= form_input([ 'id' => 'owner_name', 'name' => 'owner_name', 'class' => 'form-input mb-4', 'value' => old('owner_name', $podcast->owner_name), + 'required' => 'required', ]) ?> <?= form_label(lang('Podcast.form.owner_email'), 'owner_email') ?> @@ -135,6 +128,14 @@ 'required' => 'required', ]) ?> +<?= form_label(lang('Podcast.form.author'), 'author') ?> +<?= form_input([ + 'id' => 'author', + 'name' => 'author', + 'class' => 'form-input mb-4', + 'value' => old('author', $podcast->author), +]) ?> + <?= form_fieldset('', ['class' => 'flex flex-col mb-4']) ?> <legend><?= lang('Podcast.form.type.label') ?></legend> <label for="episodic" class="inline-flex items-center"> diff --git a/app/Views/episode.php b/app/Views/episode.php index 9ed19e3736..8593876476 100644 --- a/app/Views/episode.php +++ b/app/Views/episode.php @@ -1,5 +1,9 @@ <?= $this->extend('_layout') ?> +<?= $this->section('title') ?> +<?= $episode->title ?> +<?= $this->endSection() ?> + <?= $this->section('content') ?> <a class="underline hover:no-underline" href="<?= route_to( diff --git a/app/Views/home.php b/app/Views/home.php index 1cd0048d96..8a7e7f962b 100644 --- a/app/Views/home.php +++ b/app/Views/home.php @@ -1,5 +1,7 @@ <?= $this->extend('_layout') ?> +<?= $this->section('title') ?>Castopod<?= $this->endSection() ?> + <?= $this->section('content') ?> <h1 class="mb-2 text-xl"><?= lang('Home.all_podcasts') ?> (<?= count( diff --git a/app/Views/page.php b/app/Views/page.php new file mode 100644 index 0000000000..aa6b502e9d --- /dev/null +++ b/app/Views/page.php @@ -0,0 +1,11 @@ +<?= $this->extend('_layout') ?> + +<?= $this->section('title') ?> +<?= $page->title ?> +<?= $this->endSection() ?> + +<?= $this->section('content') ?> +<div class="prose"> + <?= $page->content_html ?> +</div> +<?= $this->endSection() ?> diff --git a/app/Views/podcast.php b/app/Views/podcast.php index b64782b0c9..9e0b4ce95d 100644 --- a/app/Views/podcast.php +++ b/app/Views/podcast.php @@ -1,5 +1,9 @@ <?= $this->extend('_layout') ?> +<?= $this->section('title') ?> +<?= $podcast->title ?> +<?= $this->endSection() ?> + <?= $this->section('content') ?> <header class="py-4 border-b"> <h1 class="text-2xl"><?= $podcast->title ?></h1> -- GitLab