From a1a28de702c8488a4f92ec05e42e3cdead0d1edd Mon Sep 17 00:00:00 2001
From: Yassine Doghri <yassine@doghri.fr>
Date: Fri, 14 Aug 2020 18:27:57 +0000
Subject: [PATCH] refactor: rewrite form pages using form helper

- add installGateway to app config
- update route names and groups
- remove `author_name` and `author_email` from `episodes` table
- remove `author_name` and `author_email` from `podcasts` table
- remove `owner_id` + add `created_by` and `updated_by` fields in `podcasts` and `episodes` tables
- remove unnecessary comments in database fields
- remove confirm password inputs from auth forms for better ux
- rename `pub_date` field to `published_at` and add publication time field in episode form

closes #14, #28
---
 app/Config/App.php                            |   8 +
 app/Config/Routes.php                         | 399 +++++++++---------
 app/Controllers/Admin/Contributor.php         |  44 +-
 app/Controllers/Admin/Episode.php             |  37 +-
 app/Controllers/Admin/Myaccount.php           |  10 +-
 app/Controllers/Admin/Podcast.php             |  82 +++-
 app/Controllers/Admin/User.php                |  27 +-
 app/Controllers/Auth.php                      | 167 ++++++++
 .../2020-05-30-101000_add_languages.php       |   1 -
 .../2020-05-30-101500_add_podcasts.php        |  60 +--
 .../2020-06-05-170000_add_episodes.php        |  57 +--
 .../2020-06-05-190000_add_platforms.php       |  13 -
 .../2020-06-08-160000_add_platform_links.php  |   5 -
 ...0000_add_analytics_episodes_by_country.php |   6 -
 ...10000_add_analytics_episodes_by_player.php |   6 -
 ...0000_add_analytics_podcasts_by_country.php |   4 -
 ...10000_add_analytics_podcasts_by_player.php |   5 -
 ...10000_add_analytics_unknown_useragents.php |   3 -
 ...10000_add_analytics_website_by_browser.php |   5 -
 ...10000_add_analytics_website_by_country.php |   4 -
 ...10000_add_analytics_website_by_referer.php |   4 -
 app/Entities/Episode.php                      |  37 +-
 app/Entities/Podcast.php                      |  52 +--
 app/Helpers/id3_helper.php                    |   4 +-
 app/Helpers/rss_helper.php                    |  21 +-
 app/Language/en/AdminNavigation.php           |  10 +-
 app/Language/en/Contributor.php               |   3 +
 app/Language/en/Episode.php                   |  14 +-
 app/Language/en/MyAccount.php                 |   2 +
 app/Language/en/Podcast.php                   |   5 +-
 app/Language/en/User.php                      |   6 +-
 app/Models/EpisodeModel.php                   |  15 +-
 app/Models/PodcastModel.php                   |  15 +-
 app/Views/admin/_header.php                   |   8 +-
 app/Views/admin/_partials/_episode-card.php   |   8 +-
 app/Views/admin/_partials/_podcast-card.php   |   6 +-
 app/Views/admin/_sidenav.php                  |   6 +-
 app/Views/admin/contributor/add.php           |  63 ++-
 app/Views/admin/contributor/edit.php          |  47 +--
 app/Views/admin/contributor/list.php          |  10 +-
 app/Views/admin/episode/create.php            | 217 ++++++----
 app/Views/admin/episode/edit.php              | 235 +++++++----
 app/Views/admin/episode/list.php              |   2 +-
 app/Views/admin/episode/view.php              |   4 +-
 .../admin/my_account/change_password.php      |  53 ++-
 app/Views/admin/podcast/create.php            | 297 +++++++------
 app/Views/admin/podcast/edit.php              | 284 ++++++++-----
 app/Views/admin/podcast/list.php              |   2 +-
 app/Views/admin/podcast/view.php              |   8 +-
 app/Views/admin/user/create.php               |  63 +--
 app/Views/admin/user/edit.php                 |  40 +-
 app/Views/admin/user/list.php                 |   8 +-
 app/Views/auth/_layout.php                    |  11 +-
 app/Views/auth/forgot.php                     |  30 +-
 app/Views/auth/login.php                      |  45 +-
 app/Views/auth/register.php                   |  74 ++--
 app/Views/auth/reset.php                      |  65 +--
 app/Views/install/env.php                     |  10 +-
 app/Views/install/superadmin.php              |  11 +-
 59 files changed, 1580 insertions(+), 1158 deletions(-)
 create mode 100644 app/Controllers/Auth.php

diff --git a/app/Config/App.php b/app/Config/App.php
index f05de3d87e..5dd608b357 100644
--- a/app/Config/App.php
+++ b/app/Config/App.php
@@ -290,4 +290,12 @@ class App extends BaseConfig
     | Defines a base route for all authentication related pages
     */
     public $authGateway = 'cp-auth';
+
+    /*
+    |--------------------------------------------------------------------------
+    | Install gateway
+    |--------------------------------------------------------------------------
+    | Defines a base route for instance installation
+    */
+    public $installGateway = 'cp-install';
 }
diff --git a/app/Config/Routes.php b/app/Config/Routes.php
index dc8d3ce95b..43df8b3b09 100644
--- a/app/Config/Routes.php
+++ b/app/Config/Routes.php
@@ -43,24 +43,23 @@ $routes->addPlaceholder('episodeSlug', '[a-zA-Z0-9\-]{1,191}');
 $routes->get('/', 'Home::index', ['as' => 'home']);
 
 // Install Wizard route
-$routes->group('cp-install', function ($routes) {
+$routes->group(config('App')->installGateway, function ($routes) {
     $routes->get('/', 'Install', ['as' => 'install']);
     $routes->post('generate-env', 'Install::attemptCreateEnv', [
-        'as' => 'install_generate_env',
+        'as' => 'generate-env',
     ]);
     $routes->post('create-superadmin', 'Install::attemptCreateSuperAdmin', [
-        'as' => 'install_create_superadmin',
+        'as' => 'create-superadmin',
     ]);
 });
 
 // Public routes
 $routes->group('@(:podcastName)', function ($routes) {
     $routes->get('/', 'Podcast/$1', ['as' => 'podcast']);
-
-    $routes->get('feed.xml', 'Feed/$1', ['as' => 'podcast_feed']);
-    $routes->get('episodes/(:episodeSlug)', 'Episode/$1/$2', [
+    $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)
@@ -82,228 +81,218 @@ $routes->group(
         ]);
 
         $routes->get('my-podcasts', 'Podcast::myPodcasts', [
-            'as' => 'my_podcasts',
-        ]);
-        $routes->get('podcasts', 'Podcast::list', [
-            'as' => 'podcast_list',
-        ]);
-        $routes->get('podcasts/new', 'Podcast::create', [
-            'as' => 'podcast_create',
-            'filter' => 'permission:podcasts-create',
-        ]);
-        $routes->post('podcasts/new', 'Podcast::attemptCreate', [
-            'filter' => 'permission:podcasts-create',
+            'as' => 'my-podcasts',
         ]);
 
-        // Use ids in admin area to help permission and group lookups
-        $routes->group('podcasts/(:num)', function ($routes) {
-            $routes->get('/', 'Podcast::view/$1', [
-                'as' => 'podcast_view',
-                'filter' => 'permission:podcasts-view,podcast-view',
-            ]);
-            $routes->get('edit', 'Podcast::edit/$1', [
-                'as' => 'podcast_edit',
-                'filter' => 'permission:podcasts-edit,podcast-edit',
+        // Podcasts
+        $routes->group('podcasts', function ($routes) {
+            $routes->get('/', 'Podcast::list', [
+                'as' => 'podcast-list',
             ]);
-            $routes->post('edit', 'Podcast::attemptEdit/$1', [
-                'filter' => 'permission:podcasts-edit,podcast-edit',
+            $routes->get('new', 'Podcast::create', [
+                'as' => 'podcast-create',
+                'filter' => 'permission:podcasts-create',
             ]);
-            $routes->add('delete', 'Podcast::delete/$1', [
-                'as' => 'podcast_delete',
-                'filter' => 'permission:podcasts-edit,podcast-delete',
+            $routes->post('new', 'Podcast::attemptCreate', [
+                'filter' => 'permission:podcasts-create',
             ]);
 
-            // Podcast episodes
-            $routes->get('episodes', 'Episode::list/$1', [
-                'as' => 'episode_list',
-                'filter' => 'permission:podcasts-view,podcast-view',
-            ]);
-            $routes->get('episodes/new', 'Episode::create/$1', [
-                'as' => 'episode_create',
-                'filter' =>
-                    'permission:episodes-create,podcast_episodes-create',
-            ]);
-            $routes->post('episodes/new', 'Episode::attemptCreate/$1', [
-                'filter' =>
-                    'permission:episodes-create,podcast_episodes-create',
-            ]);
+            // Podcast
+            // Use ids in admin area to help permission and group lookups
+            $routes->group('(:num)', function ($routes) {
+                $routes->get('/', 'Podcast::view/$1', [
+                    'as' => 'podcast-view',
+                    'filter' => 'permission:podcasts-view,podcast-view',
+                ]);
+                $routes->get('edit', 'Podcast::edit/$1', [
+                    'as' => 'podcast-edit',
+                    'filter' => 'permission:podcasts-edit,podcast-edit',
+                ]);
+                $routes->post('edit', 'Podcast::attemptEdit/$1', [
+                    'filter' => 'permission:podcasts-edit,podcast-edit',
+                ]);
+                $routes->add('delete', 'Podcast::delete/$1', [
+                    'as' => 'podcast-delete',
+                    'filter' => 'permission:podcasts-edit,podcast-delete',
+                ]);
 
-            $routes->get('episodes/(:num)', 'Episode::view/$1/$2', [
-                'as' => 'episode_view',
-                'filter' => 'permission:episodes-view,podcast_episodes-view',
-            ]);
-            $routes->get('episodes/(:num)/edit', 'Episode::edit/$1/$2', [
-                'as' => 'episode_edit',
-                'filter' => 'permission:episodes-edit,podcast_episodes-edit',
-            ]);
-            $routes->post(
-                'episodes/(:num)/edit',
-                'Episode::attemptEdit/$1/$2',
-                [
-                    'filter' =>
-                        'permission:episodes-edit,podcast_episodes-edit',
-                ]
-            );
-            $routes->add('episodes/(:num)/delete', 'Episode::delete/$1/$2', [
-                'as' => 'episode_delete',
-                'filter' =>
-                    'permission:episodes-delete,podcast_episodes-delete',
-            ]);
+                // Podcast episodes
+                $routes->group('episodes', function ($routes) {
+                    $routes->get('/', 'Episode::list/$1', [
+                        'as' => 'episode-list',
+                        'filter' => 'permission:podcasts-view,podcast-view',
+                    ]);
+                    $routes->get('new', 'Episode::create/$1', [
+                        'as' => 'episode-create',
+                        'filter' =>
+                            'permission:episodes-create,podcast_episodes-create',
+                    ]);
+                    $routes->post('new', 'Episode::attemptCreate/$1', [
+                        'filter' =>
+                            'permission:episodes-create,podcast_episodes-create',
+                    ]);
 
-            // Podcast contributors
-            $routes->get('contributors', 'Contributor::list/$1', [
-                'as' => 'contributor_list',
-                'filter' =>
-                    'permission:podcasts-manage_contributors,podcast-manage_contributors',
-            ]);
-            $routes->get('contributors/add', 'Contributor::add/$1', [
-                'as' => 'contributor_add',
-                'filter' =>
-                    'permission:podcasts-manage_contributors,podcast-manage_contributors',
-            ]);
-            $routes->post('contributors/add', 'Contributor::attemptAdd/$1', [
-                'filter' =>
-                    'permission:podcasts-manage_contributors,podcast-manage_contributors',
-            ]);
-            $routes->get('contributors/(:num)', 'Contributor::view/$1/$2', [
-                'as' => 'contributor_view',
-            ]);
-            $routes->get(
-                'contributors/(:num)/edit',
-                'Contributor::edit/$1/$2',
-                [
-                    'as' => 'contributor_edit',
-                    'filter' =>
-                        'permission:podcasts-manage_contributors,podcast-manage_contributors',
-                ]
-            );
-            $routes->post(
-                'contributors/(:num)/edit',
-                'Contributor::attemptEdit/$1/$2',
-                [
-                    'filter' =>
-                        'permission:podcasts-manage_contributors,podcast-manage_contributors',
-                ]
-            );
-            $routes->add(
-                'contributors/(:num)/remove',
-                'Contributor::remove/$1/$2',
-                [
-                    'as' => 'contributor_remove',
-                    'filter' =>
-                        'permission:podcasts-manage_contributors,podcast-manage_contributors',
-                ]
-            );
+                    // Episode
+                    $routes->group('(:num)', function ($routes) {
+                        $routes->get('/', 'Episode::view/$1/$2', [
+                            'as' => 'episode-view',
+                            'filter' =>
+                                'permission:episodes-view,podcast_episodes-view',
+                        ]);
+                        $routes->get('edit', 'Episode::edit/$1/$2', [
+                            'as' => 'episode-edit',
+                            'filter' =>
+                                'permission:episodes-edit,podcast_episodes-edit',
+                        ]);
+                        $routes->post('edit', 'Episode::attemptEdit/$1/$2', [
+                            'filter' =>
+                                'permission:episodes-edit,podcast_episodes-edit',
+                        ]);
+                        $routes->add('delete', 'Episode::delete/$1/$2', [
+                            'as' => 'episode-delete',
+                            'filter' =>
+                                'permission:episodes-delete,podcast_episodes-delete',
+                        ]);
+                    });
+                });
+
+                // Podcast contributors
+                $routes->group('contributors', function ($routes) {
+                    $routes->get('/', 'Contributor::list/$1', [
+                        'as' => 'contributor-list',
+                        'filter' =>
+                            'permission:podcasts-manage_contributors,podcast-manage_contributors',
+                    ]);
+                    $routes->get('add', 'Contributor::add/$1', [
+                        'as' => 'contributor-add',
+                        'filter' =>
+                            'permission:podcasts-manage_contributors,podcast-manage_contributors',
+                    ]);
+                    $routes->post('add', 'Contributor::attemptAdd/$1', [
+                        'filter' =>
+                            'permission:podcasts-manage_contributors,podcast-manage_contributors',
+                    ]);
+
+                    // Contributor
+                    $routes->group('(:num)', function ($routes) {
+                        $routes->get('/', 'Contributor::view/$1/$2', [
+                            'as' => 'contributor-view',
+                        ]);
+                        $routes->get('edit', 'Contributor::edit/$1/$2', [
+                            'as' => 'contributor-edit',
+                            'filter' =>
+                                'permission:podcasts-manage_contributors,podcast-manage_contributors',
+                        ]);
+                        $routes->post(
+                            'edit',
+                            'Contributor::attemptEdit/$1/$2',
+                            [
+                                'filter' =>
+                                    'permission:podcasts-manage_contributors,podcast-manage_contributors',
+                            ]
+                        );
+                        $routes->add('remove', 'Contributor::remove/$1/$2', [
+                            'as' => 'contributor-remove',
+                            'filter' =>
+                                'permission:podcasts-manage_contributors,podcast-manage_contributors',
+                        ]);
+                    });
+                });
+            });
         });
 
         // Users
-        $routes->get('users', 'User::list', [
-            'as' => 'user_list',
-            'filter' => 'permission:users-list',
-        ]);
-        $routes->get('users/new', 'User::create', [
-            'as' => 'user_create',
-            'filter' => 'permission:users-create',
-        ]);
-        $routes->get('users/(:num)', 'User::view/$1', [
-            'as' => 'user_view',
-            'filter' => 'permission:users-view',
-        ]);
-        $routes->post('users/new', 'User::attemptCreate', [
-            'filter' => 'permission:users-create',
-        ]);
-        $routes->get('users/(:num)/edit', 'User::edit/$1', [
-            'as' => 'user_edit',
-            'filter' => 'permission:users-manage_authorizations',
-        ]);
-        $routes->post('users/(:num)/edit', 'User::attemptEdit/$1', [
-            'filter' => 'permission:users-manage_authorizations',
-        ]);
-
-        $routes->add('users/(:num)/ban', 'User::ban/$1', [
-            'as' => 'user_ban',
-            'filter' => 'permission:users-manage_bans',
-        ]);
-        $routes->add('users/(:num)/unban', 'User::unBan/$1', [
-            'as' => 'user_unban',
-            'filter' => 'permission:users-manage_bans',
-        ]);
-        $routes->add(
-            'users/(:num)/force-pass-reset',
-            'User::forcePassReset/$1',
-            [
-                'as' => 'user_force_pass_reset',
-                'filter' => 'permission:users-force_pass_reset',
-            ]
-        );
+        $routes->group('users', function ($routes) {
+            $routes->get('/', 'User::list', [
+                'as' => 'user-list',
+                'filter' => 'permission:users-list',
+            ]);
+            $routes->get('new', 'User::create', [
+                'as' => 'user-create',
+                'filter' => 'permission:users-create',
+            ]);
+            $routes->post('new', 'User::attemptCreate', [
+                'filter' => 'permission:users-create',
+            ]);
 
-        $routes->add('users/(:num)/delete', 'User::delete/$1', [
-            'as' => 'user_delete',
-            'filter' => 'permission:users-delete',
-        ]);
+            // User
+            $routes->group('(:num)', function ($routes) {
+                $routes->get('/', 'User::view/$1', [
+                    'as' => 'user-view',
+                    'filter' => 'permission:users-view',
+                ]);
+                $routes->get('edit', 'User::edit/$1', [
+                    'as' => 'user-edit',
+                    'filter' => 'permission:users-manage_authorizations',
+                ]);
+                $routes->post('edit', 'User::attemptEdit/$1', [
+                    'filter' => 'permission:users-manage_authorizations',
+                ]);
+                $routes->add('ban', 'User::ban/$1', [
+                    'as' => 'user-ban',
+                    'filter' => 'permission:users-manage_bans',
+                ]);
+                $routes->add('unban', 'User::unBan/$1', [
+                    'as' => 'user-unban',
+                    'filter' => 'permission:users-manage_bans',
+                ]);
+                $routes->add('force-pass-reset', 'User::forcePassReset/$1', [
+                    'as' => 'user-force_pass_reset',
+                    'filter' => 'permission:users-force_pass_reset',
+                ]);
+                $routes->add('delete', 'User::delete/$1', [
+                    'as' => 'user-delete',
+                    'filter' => 'permission:users-delete',
+                ]);
+            });
+        });
 
         // My account
-        $routes->get('my-account', 'Myaccount', [
-            'as' => 'myAccount',
-        ]);
-        $routes->get(
-            'my-account/change-password',
-            'Myaccount::changePassword/$1',
-            [
-                'as' => 'myAccount_change-password',
-            ]
-        );
-        $routes->post(
-            'my-account/change-password',
-            'Myaccount::attemptChange/$1',
-            [
-                'as' => 'myAccount_change-password',
-            ]
-        );
+        $routes->group('my-account', function ($routes) {
+            $routes->get('/', 'Myaccount', [
+                'as' => 'my-account',
+            ]);
+            $routes->get('change-password', 'Myaccount::changePassword/$1', [
+                'as' => 'change-password',
+            ]);
+            $routes->post('change-password', 'Myaccount::attemptChange/$1');
+        });
     }
 );
 
 /**
  * Overwriting Myth:auth routes file
  */
-$routes->group(
-    config('App')->authGateway,
-    ['namespace' => 'Myth\Auth\Controllers'],
-    function ($routes) {
-        // Login/out
-        $routes->get('login', 'AuthController::login', ['as' => 'login']);
-        $routes->post('login', 'AuthController::attemptLogin');
-        $routes->get('logout', 'AuthController::logout', ['as' => 'logout']);
+$routes->group(config('App')->authGateway, function ($routes) {
+    // Login/out
+    $routes->get('login', 'Auth::login', ['as' => 'login']);
+    $routes->post('login', 'Auth::attemptLogin');
+    $routes->get('logout', 'Auth::logout', ['as' => 'logout']);
 
-        // Registration
-        $routes->get('register', 'AuthController::register', [
-            'as' => 'register',
-        ]);
-        $routes->post('register', 'AuthController::attemptRegister');
+    // Registration
+    $routes->get('register', 'Auth::register', [
+        'as' => 'register',
+    ]);
+    $routes->post('register', 'Auth::attemptRegister');
 
-        // Activation
-        $routes->get('activate-account', 'AuthController::activateAccount', [
-            'as' => 'activate-account',
-        ]);
-        $routes->get(
-            'resend-activate-account',
-            'AuthController::resendActivateAccount',
-            [
-                'as' => 'resend-activate-account',
-            ]
-        );
+    // Activation
+    $routes->get('activate-account', 'Auth::activateAccount', [
+        'as' => 'activate-account',
+    ]);
+    $routes->get('resend-activate-account', 'Auth::resendActivateAccount', [
+        'as' => 'resend-activate-account',
+    ]);
 
-        // Forgot/Resets
-        $routes->get('forgot', 'AuthController::forgotPassword', [
-            'as' => 'forgot',
-        ]);
-        $routes->post('forgot', 'AuthController::attemptForgot');
-        $routes->get('reset-password', 'AuthController::resetPassword', [
-            'as' => 'reset-password',
-        ]);
-        $routes->post('reset-password', 'AuthController::attemptReset');
-    }
-);
+    // Forgot/Resets
+    $routes->get('forgot', 'Auth::forgotPassword', [
+        'as' => 'forgot',
+    ]);
+    $routes->post('forgot', 'Auth::attemptForgot');
+    $routes->get('reset-password', 'Auth::resetPassword', [
+        'as' => 'reset-password',
+    ]);
+    $routes->post('reset-password', 'Auth::attemptReset');
+});
 
 /**
  * --------------------------------------------------------------------
diff --git a/app/Controllers/Admin/Contributor.php b/app/Controllers/Admin/Contributor.php
index def6ab688a..c892dc5c14 100644
--- a/app/Controllers/Admin/Contributor.php
+++ b/app/Controllers/Admin/Contributor.php
@@ -70,10 +70,32 @@ class Contributor extends BaseController
 
     public function add()
     {
+        helper('form');
+
+        $users = (new UserModel())->findAll();
+        $userOptions = array_reduce(
+            $users,
+            function ($result, $user) {
+                $result[$user->id] = $user->username;
+                return $result;
+            },
+            []
+        );
+
+        $roles = (new GroupModel())->getContributorRoles();
+        $roleOptions = array_reduce(
+            $roles,
+            function ($result, $role) {
+                $result[$role->id] = lang('Contributor.roles.' . $role->name);
+                return $result;
+            },
+            []
+        );
+
         $data = [
             'podcast' => $this->podcast,
-            'users' => (new UserModel())->findAll(),
-            'roles' => (new GroupModel())->getContributorRoles(),
+            'userOptions' => $userOptions,
+            'roleOptions' => $roleOptions,
         ];
 
         replace_breadcrumb_params([0 => $this->podcast->title]);
@@ -97,11 +119,23 @@ class Contributor extends BaseController
                 ]);
         }
 
-        return redirect()->route('contributor_list', [$this->podcast->id]);
+        return redirect()->route('contributor-list', [$this->podcast->id]);
     }
 
     public function edit()
     {
+        helper('form');
+
+        $roles = (new GroupModel())->getContributorRoles();
+        $roleOptions = array_reduce(
+            $roles,
+            function ($result, $role) {
+                $result[$role->id] = lang('Contributor.roles.' . $role->name);
+                return $result;
+            },
+            []
+        );
+
         $data = [
             'podcast' => $this->podcast,
             'user' => $this->user,
@@ -109,7 +143,7 @@ class Contributor extends BaseController
                 $this->user->id,
                 $this->podcast->id
             ),
-            'roles' => (new GroupModel())->getContributorRoles(),
+            'roleOptions' => $roleOptions,
         ];
 
         replace_breadcrumb_params([
@@ -127,7 +161,7 @@ class Contributor extends BaseController
             $this->request->getPost('role')
         );
 
-        return redirect()->route('contributor_list', [$this->podcast->id]);
+        return redirect()->route('contributor-list', [$this->podcast->id]);
     }
 
     public function remove()
diff --git a/app/Controllers/Admin/Episode.php b/app/Controllers/Admin/Episode.php
index d1bc612cbb..32ca7f3a6c 100644
--- a/app/Controllers/Admin/Episode.php
+++ b/app/Controllers/Admin/Episode.php
@@ -86,6 +86,9 @@ class Episode extends BaseController
             'enclosure' => 'uploaded[enclosure]|ext_in[enclosure,mp3,m4a]',
             'image' =>
                 'uploaded[image]|is_image[image]|ext_in[image,jpg,png]|permit_empty',
+            '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',
         ];
 
         if (!$this->validate($rules)) {
@@ -100,17 +103,20 @@ class Episode extends BaseController
             'title' => $this->request->getPost('title'),
             'slug' => $this->request->getPost('slug'),
             'enclosure' => $this->request->getFile('enclosure'),
-            'pub_date' => $this->request->getPost('pub_date'),
             'description' => $this->request->getPost('description'),
             'image' => $this->request->getFile('image'),
-            'explicit' => (bool) $this->request->getPost('explicit'),
+            'explicit' => $this->request->getPost('explicit') == 'yes',
             'number' => $this->request->getPost('episode_number'),
             'season_number' => $this->request->getPost('season_number'),
             'type' => $this->request->getPost('type'),
-            'author_name' => $this->request->getPost('author_name'),
-            'author_email' => $this->request->getPost('author_email'),
-            'block' => (bool) $this->request->getPost('block'),
+            'block' => $this->request->getPost('block') == 'yes',
+            'created_by' => user(),
+            'updated_by' => user(),
         ]);
+        $newEpisode->setPublishedAt(
+            $this->request->getPost('publication_date'),
+            $this->request->getPost('publication_time')
+        );
 
         $episodeModel = new EpisodeModel();
 
@@ -121,7 +127,7 @@ class Episode extends BaseController
                 ->with('errors', $episodeModel->errors());
         }
 
-        return redirect()->route('episode_list', [$this->podcast->id]);
+        return redirect()->route('episode-list', [$this->podcast->id]);
     }
 
     public function edit()
@@ -146,6 +152,9 @@ class Episode extends BaseController
                 'uploaded[enclosure]|ext_in[enclosure,mp3,m4a]|permit_empty',
             'image' =>
                 'uploaded[image]|is_image[image]|ext_in[image,jpg,png]|permit_empty',
+            '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',
         ];
 
         if (!$this->validate($rules)) {
@@ -157,17 +166,19 @@ class Episode extends BaseController
 
         $this->episode->title = $this->request->getPost('title');
         $this->episode->slug = $this->request->getPost('slug');
-        $this->episode->pub_date = $this->request->getPost('pub_date');
         $this->episode->description = $this->request->getPost('description');
-        $this->episode->explicit = (bool) $this->request->getPost('explicit');
+        $this->episode->explicit = $this->request->getPost('explicit') == 'yes';
         $this->episode->number = $this->request->getPost('episode_number');
         $this->episode->season_number = $this->request->getPost('season_number')
             ? $this->request->getPost('season_number')
             : null;
         $this->episode->type = $this->request->getPost('type');
-        $this->episode->author_name = $this->request->getPost('author_name');
-        $this->episode->author_email = $this->request->getPost('author_email');
-        $this->episode->block = (bool) $this->request->getPost('block');
+        $this->episode->block = $this->request->getPost('block') == 'yes';
+        $this->episode->setPublishedAt(
+            $this->request->getPost('publication_date'),
+            $this->request->getPost('publication_time')
+        );
+        $this->episode->updated_by = user();
 
         $enclosure = $this->request->getFile('enclosure');
         if ($enclosure->isValid()) {
@@ -187,13 +198,13 @@ class Episode extends BaseController
                 ->with('errors', $episodeModel->errors());
         }
 
-        return redirect()->route('episode_list', [$this->podcast->id]);
+        return redirect()->route('episode-list', [$this->podcast->id]);
     }
 
     public function delete()
     {
         (new EpisodeModel())->delete($this->episode->id);
 
-        return redirect()->route('episode_list', [$this->podcast->id]);
+        return redirect()->route('episode-list', [$this->podcast->id]);
     }
 }
diff --git a/app/Controllers/Admin/Myaccount.php b/app/Controllers/Admin/Myaccount.php
index fa7e7b4d39..b6d19117aa 100644
--- a/app/Controllers/Admin/Myaccount.php
+++ b/app/Controllers/Admin/Myaccount.php
@@ -20,6 +20,8 @@ class Myaccount extends BaseController
 
     public function changePassword()
     {
+        helper('form');
+
         return view('admin/my_account/change_password');
     }
 
@@ -31,10 +33,8 @@ class Myaccount extends BaseController
         // Validate here first, since some things,
         // like the password, can only be validated properly here.
         $rules = [
-            'email' => 'required|valid_email',
             'password' => 'required',
-            'new_password' => 'required|strong_password',
-            'new_pass_confirm' => 'required|matches[new_password]',
+            'new_password' => 'required|strong_password|differs[password]',
         ];
 
         if (!$this->validate($rules)) {
@@ -53,7 +53,7 @@ class Myaccount extends BaseController
             return redirect()
                 ->back()
                 ->withInput()
-                ->with('errors', $userModel->errors());
+                ->with('error', lang('MyAccount.messages.wrongPasswordError'));
         }
 
         user()->password = $this->request->getPost('new_password');
@@ -68,7 +68,7 @@ class Myaccount extends BaseController
 
         // Success!
         return redirect()
-            ->route('myAccount')
+            ->back()
             ->with('message', lang('MyAccount.messages.passwordChangeSuccess'));
     }
 }
diff --git a/app/Controllers/Admin/Podcast.php b/app/Controllers/Admin/Podcast.php
index 3b18f44bf0..2f9dcef13d 100644
--- a/app/Controllers/Admin/Podcast.php
+++ b/app/Controllers/Admin/Podcast.php
@@ -43,7 +43,7 @@ class Podcast extends BaseController
     public function list()
     {
         if (!has_permission('podcasts-list')) {
-            return redirect()->route('my_podcasts');
+            return redirect()->route('my-podcasts');
         }
 
         $data = ['podcasts' => (new PodcastModel())->findAll()];
@@ -63,11 +63,30 @@ class Podcast extends BaseController
     {
         helper(['form', 'misc']);
 
-        $languageModel = new LanguageModel();
-        $categoryModel = new CategoryModel();
+        $categories = (new CategoryModel())->findAll();
+        $languages = (new LanguageModel())->findAll();
+        $languageOptions = array_reduce(
+            $languages,
+            function ($result, $language) {
+                $result[$language->code] = $language->native_name;
+                return $result;
+            },
+            []
+        );
+        $categoryOptions = array_reduce(
+            $categories,
+            function ($result, $category) {
+                $result[$category->code] = lang(
+                    'Podcast.category_options.' . $category->code
+                );
+                return $result;
+            },
+            []
+        );
+
         $data = [
-            'languages' => $languageModel->findAll(),
-            'categories' => $categoryModel->findAll(),
+            'languageOptions' => $languageOptions,
+            'categoryOptions' => $categoryOptions,
             'browserLang' => get_browser_language(
                 $this->request->getServer('HTTP_ACCEPT_LANGUAGE')
             ),
@@ -99,17 +118,17 @@ class Podcast extends BaseController
             'image' => $this->request->getFile('image'),
             'language' => $this->request->getPost('language'),
             'category' => $this->request->getPost('category'),
-            'explicit' => (bool) $this->request->getPost('explicit'),
-            'author_name' => $this->request->getPost('author_name'),
-            'author_email' => $this->request->getPost('author_email'),
-            'owner' => user(),
+            'explicit' => $this->request->getPost('explicit') == 'yes',
+            'author' => $this->request->getPost('author'),
             'owner_name' => $this->request->getPost('owner_name'),
             'owner_email' => $this->request->getPost('owner_email'),
             'type' => $this->request->getPost('type'),
             'copyright' => $this->request->getPost('copyright'),
-            'block' => (bool) $this->request->getPost('block'),
-            'complete' => (bool) $this->request->getPost('complete'),
+            'block' => $this->request->getPost('block') == 'yes',
+            'complete' => $this->request->getPost('complete') == 'yes',
             'custom_html_head' => $this->request->getPost('custom_html_head'),
+            'created_by' => user(),
+            'updated_by' => user(),
         ]);
 
         $podcastModel = new PodcastModel();
@@ -136,17 +155,38 @@ class Podcast extends BaseController
 
         $db->transComplete();
 
-        return redirect()->route('podcast_list');
+        return redirect()->route('podcast-list');
     }
 
     public function edit()
     {
         helper('form');
 
+        $categories = (new CategoryModel())->findAll();
+        $languages = (new LanguageModel())->findAll();
+        $languageOptions = array_reduce(
+            $languages,
+            function ($result, $language) {
+                $result[$language->code] = $language->native_name;
+                return $result;
+            },
+            []
+        );
+        $categoryOptions = array_reduce(
+            $categories,
+            function ($result, $category) {
+                $result[$category->code] = lang(
+                    'Podcast.category_options.' . $category->code
+                );
+                return $result;
+            },
+            []
+        );
+
         $data = [
             'podcast' => $this->podcast,
-            'languages' => (new LanguageModel())->findAll(),
-            'categories' => (new CategoryModel())->findAll(),
+            'languageOptions' => $languageOptions,
+            'categoryOptions' => $categoryOptions,
         ];
 
         replace_breadcrumb_params([0 => $this->podcast->title]);
@@ -180,18 +220,18 @@ class Podcast extends BaseController
         }
         $this->podcast->language = $this->request->getPost('language');
         $this->podcast->category = $this->request->getPost('category');
-        $this->podcast->explicit = (bool) $this->request->getPost('explicit');
-        $this->podcast->author_name = $this->request->getPost('author_name');
-        $this->podcast->author_email = $this->request->getPost('author_email');
+        $this->podcast->explicit = $this->request->getPost('explicit') == 'yes';
+        $this->podcast->author = $this->request->getPost('author');
         $this->podcast->owner_name = $this->request->getPost('owner_name');
         $this->podcast->owner_email = $this->request->getPost('owner_email');
         $this->podcast->type = $this->request->getPost('type');
         $this->podcast->copyright = $this->request->getPost('copyright');
-        $this->podcast->block = (bool) $this->request->getPost('block');
-        $this->podcast->complete = (bool) $this->request->getPost('complete');
+        $this->podcast->block = $this->request->getPost('block') == 'yes';
+        $this->podcast->complete = $this->request->getPost('complete') == 'yes';
         $this->podcast->custom_html_head = $this->request->getPost(
             'custom_html_head'
         );
+        $this->updated_by = user();
 
         $podcastModel = new PodcastModel();
 
@@ -202,13 +242,13 @@ class Podcast extends BaseController
                 ->with('errors', $podcastModel->errors());
         }
 
-        return redirect()->route('podcast_list');
+        return redirect()->route('podcast-list');
     }
 
     public function delete()
     {
         (new PodcastModel())->delete($this->podcast->id);
 
-        return redirect()->route('podcast_list');
+        return redirect()->route('podcast-list');
     }
 }
diff --git a/app/Controllers/Admin/User.php b/app/Controllers/Admin/User.php
index 52528c6734..63874156ca 100644
--- a/app/Controllers/Admin/User.php
+++ b/app/Controllers/Admin/User.php
@@ -47,6 +47,8 @@ class User extends BaseController
 
     public function create()
     {
+        helper('form');
+
         $data = [
             'roles' => (new GroupModel())->getUserRoles(),
         ];
@@ -65,7 +67,6 @@ class User extends BaseController
             [
                 'email' => 'required|valid_email|is_unique[users.email]',
                 'password' => 'required|strong_password',
-                'pass_confirm' => 'required|matches[password]',
             ]
         );
 
@@ -94,7 +95,7 @@ class User extends BaseController
 
         // Success!
         return redirect()
-            ->route('user_list')
+            ->route('user-list')
             ->with(
                 'message',
                 lang('User.messages.createSuccess', [
@@ -105,9 +106,21 @@ class User extends BaseController
 
     public function edit()
     {
+        helper('form');
+
+        $roles = (new GroupModel())->getUserRoles();
+        $roleOptions = array_reduce(
+            $roles,
+            function ($result, $role) {
+                $result[$role->name] = lang('User.roles.' . $role->name);
+                return $result;
+            },
+            []
+        );
+
         $data = [
             'user' => $this->user,
-            'roles' => (new GroupModel())->getUserRoles(),
+            'roleOptions' => $roleOptions,
         ];
 
         replace_breadcrumb_params([0 => $this->user->username]);
@@ -123,7 +136,7 @@ class User extends BaseController
 
         // Success!
         return redirect()
-            ->route('user_list')
+            ->route('user-list')
             ->with(
                 'message',
                 lang('User.messages.rolesEditSuccess', [
@@ -145,7 +158,7 @@ class User extends BaseController
 
         // Success!
         return redirect()
-            ->route('user_list')
+            ->route('user-list')
             ->with(
                 'message',
                 lang('User.messages.forcePassResetSuccess', [
@@ -178,7 +191,7 @@ class User extends BaseController
         }
 
         return redirect()
-            ->route('user_list')
+            ->route('user-list')
             ->with(
                 'message',
                 lang('User.messages.banSuccess', [
@@ -199,7 +212,7 @@ class User extends BaseController
         }
 
         return redirect()
-            ->route('user_list')
+            ->route('user-list')
             ->with(
                 'message',
                 lang('User.messages.unbanSuccess', [
diff --git a/app/Controllers/Auth.php b/app/Controllers/Auth.php
new file mode 100644
index 0000000000..aaac73ccc0
--- /dev/null
+++ b/app/Controllers/Auth.php
@@ -0,0 +1,167 @@
+<?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\Entities\User;
+
+class Auth extends \Myth\Auth\Controllers\AuthController
+{
+    /**
+     * Attempt to register a new user.
+     */
+    public function attemptRegister()
+    {
+        // Check if registration is allowed
+        if (!$this->config->allowRegistration) {
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('error', lang('Auth.registerDisabled'));
+        }
+
+        $users = model('UserModel');
+
+        // Validate here first, since some things,
+        // like the password, can only be validated properly here.
+        $rules = [
+            'username' =>
+                'required|alpha_numeric_space|min_length[3]|is_unique[users.username]',
+            'email' => 'required|valid_email|is_unique[users.email]',
+            'password' => 'required|strong_password',
+        ];
+
+        if (!$this->validate($rules)) {
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('errors', service('validation')->getErrors());
+        }
+
+        // Save the user
+        $allowedPostFields = array_merge(
+            ['password'],
+            $this->config->validFields,
+            $this->config->personalFields
+        );
+        $user = new User($this->request->getPost($allowedPostFields));
+
+        $this->config->requireActivation !== false
+            ? $user->generateActivateHash()
+            : $user->activate();
+
+        // Ensure default group gets assigned if set
+        if (!empty($this->config->defaultUserGroup)) {
+            $users = $users->withGroup($this->config->defaultUserGroup);
+        }
+
+        if (!$users->save($user)) {
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('errors', $users->errors());
+        }
+
+        if ($this->config->requireActivation !== false) {
+            $activator = service('activator');
+            $sent = $activator->send($user);
+
+            if (!$sent) {
+                return redirect()
+                    ->back()
+                    ->withInput()
+                    ->with(
+                        'error',
+                        $activator->error() ?? lang('Auth.unknownError')
+                    );
+            }
+
+            // Success!
+            return redirect()
+                ->route('login')
+                ->with('message', lang('Auth.activationSuccess'));
+        }
+
+        // Success!
+        return redirect()
+            ->route('login')
+            ->with('message', lang('Auth.registerSuccess'));
+    }
+
+    /**
+     * Verifies the code with the email and saves the new password,
+     * if they all pass validation.
+     *
+     * @return mixed
+     */
+    public function attemptReset()
+    {
+        if ($this->config->activeResetter === false) {
+            return redirect()
+                ->route('login')
+                ->with('error', lang('Auth.forgotDisabled'));
+        }
+
+        $users = model('UserModel');
+
+        // First things first - log the reset attempt.
+        $users->logResetAttempt(
+            $this->request->getPost('email'),
+            $this->request->getPost('token'),
+            $this->request->getIPAddress(),
+            (string) $this->request->getUserAgent()
+        );
+
+        $rules = [
+            'token' => 'required',
+            'email' => 'required|valid_email',
+            'password' => 'required|strong_password',
+        ];
+
+        if (!$this->validate($rules)) {
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('errors', $users->errors());
+        }
+
+        $user = $users
+            ->where('email', $this->request->getPost('email'))
+            ->where('reset_hash', $this->request->getPost('token'))
+            ->first();
+
+        if (is_null($user)) {
+            return redirect()
+                ->back()
+                ->with('error', lang('Auth.forgotNoUser'));
+        }
+
+        // Reset token still valid?
+        if (
+            !empty($user->reset_expires) &&
+            time() > $user->reset_expires->getTimestamp()
+        ) {
+            return redirect()
+                ->back()
+                ->withInput()
+                ->with('error', lang('Auth.resetTokenExpired'));
+        }
+
+        // Success! Save the new password, and cleanup the reset hash.
+        $user->password = $this->request->getPost('password');
+        $user->reset_hash = null;
+        $user->reset_at = date('Y-m-d H:i:s');
+        $user->reset_expires = null;
+        $user->force_pass_reset = false;
+        $users->save($user);
+
+        return redirect()
+            ->route('login')
+            ->with('message', lang('Auth.resetSuccess'));
+    }
+}
diff --git a/app/Database/Migrations/2020-05-30-101000_add_languages.php b/app/Database/Migrations/2020-05-30-101000_add_languages.php
index 95c817ff3c..ae11bdf705 100644
--- a/app/Database/Migrations/2020-05-30-101000_add_languages.php
+++ b/app/Database/Migrations/2020-05-30-101000_add_languages.php
@@ -30,7 +30,6 @@ class AddLanguages extends Migration
             ],
             'native_name' => [
                 'type' => 'VARCHAR',
-                'comment' => 'Native language name.',
                 'constraint' => 191,
             ],
         ]);
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 373345dc36..368699c11a 100644
--- a/app/Database/Migrations/2020-05-30-101500_add_podcasts.php
+++ b/app/Database/Migrations/2020-05-30-101500_add_podcasts.php
@@ -23,125 +23,90 @@ class AddPodcasts extends Migration
                 'constraint' => 20,
                 'unsigned' => true,
                 'auto_increment' => true,
-                'comment' => 'The podcast ID',
             ],
             'title' => [
                 'type' => 'VARCHAR',
                 'constraint' => 1024,
-                'comment' =>
-                    'The show title.  It’s important to have a clear, concise name for your podcast. Make your title specific. A show titled Our Community Bulletin is too vague to attract many subscribers, no matter how compelling the content.  Pay close attention to the title as Apple Podcasts uses this field for search.  If you include a long list of keywords in an attempt to game podcast search, your show may be removed from the Apple directory.',
             ],
             'name' => [
                 'type' => 'VARCHAR',
                 'constraint' => 191,
                 'unique' => true,
-                'comment' => 'Unique podcast string identifier.',
             ],
             'description' => [
                 'type' => 'TEXT',
-                'comment' =>
-                    'The show description. Where description is text containing one or more sentences describing your podcast to potential listeners. The maximum amount of text allowed for this tag is 4000 characters. To include links in your description or rich HTML, adhere to the following technical guidelines: enclose all portions of your XML that contain embedded HTML in a CDATA section to prevent formatting issues, and to ensure proper link functionality.',
             ],
             'image_uri' => [
                 'type' => 'VARCHAR',
                 'constraint' => 1024,
-                'comment' =>
-                    'The artwork for the show.  Specify your show artwork by providing a URL linking to it.  Depending on their device, subscribers see your podcast artwork in varying sizes. Therefore, make sure your design is effective at both its original size and at thumbnail size. You should include a show title, brand, or source name as part of your podcast artwork. Artwork must be a minimum size of 1400 x 1400 pixels and a maximum size of 3000 x 3000 pixels, in JPEG or PNG format, 72 dpi, with appropriate file extensions (.jpg, .png), and in the RGB colorspace.',
             ],
             'language' => [
                 'type' => 'VARCHAR',
                 'constraint' => 2,
-                'comment' =>
-                    'The language spoken on the show.  Because Apple Podcasts is available in territories around the world, it is critical to specify the language of a podcast. Apple Podcasts only supports values from the ISO 639 list (two-letter language codes, with some possible modifiers, such as "en-us").  Invalid language codes will cause your feed to fail Apple validation.',
             ],
             'category' => [
                 'type' => 'VARCHAR',
                 'constraint' => 1024,
-                'comment' =>
-                    'The show category information. For a complete list of categories and subcategories, see Apple Podcasts categories.  Select the category that best reflects the content of your show. If available, you can also define a subcategory.  Although you can specify more than one category and subcategory in your RSS feed, Apple Podcasts only recognizes the first category and subcategory.  When specifying categories and subcategories, be sure to properly escape ampersands.',
                 'null' => true,
             ],
             'explicit' => [
                 'type' => 'TINYINT',
                 'constraint' => 1,
                 'default' => 0,
-                'comment' =>
-                    'The podcast parental advisory information.  The explicit value can be one of the following:      True: If you specify true, indicating the presence of explicit content, Apple Podcasts displays an Explicit parental advisory graphic for your podcast.      Podcasts containing explicit material aren’t available in some Apple Podcasts territories.      False: If you specify false, indicating that your podcast doesn’t contain explicit language or adult content, Apple Podcasts displays a Clean parental advisory graphic for your podcast.',
             ],
-            'author_name' => [
+            'author' => [
                 'type' => 'VARCHAR',
                 'constraint' => 1024,
-                'comment' =>
-                    'Name of the group responsible for creating the show.  Show author most often refers to the parent company or network of a podcast, but it can also be used to identify the host(s) if none exists.  Author information is especially useful if a company or organization publishes multiple podcasts. Providing this information will allow listeners to see all shows created by the same entity.',
                 'null' => true,
             ],
-            'author_email' => [
-                'type' => 'VARCHAR',
-                'constraint' => 1024,
-                'owner_email' =>
-                    'Email of the group responsible for creating the show.  Show author most often refers to the parent company or network of a podcast, but it can also be used to identify the host(s) if none exists.  Author information is especially useful if a company or organization publishes multiple podcasts. Providing this information will allow listeners to see all shows created by the same entity.',
-                'null' => true,
-            ],
-            'owner_id' => [
-                'type' => 'INT',
-                'constraint' => 11,
-                'unsigned' => true,
-                'comment' => 'The podcast owner.',
-            ],
             'owner_name' => [
                 'type' => 'VARCHAR',
                 'constraint' => 1024,
-                'comment' =>
-                    'The podcast owner name.  Note: The owner information is for administrative communication about the podcast and isn’t displayed in Apple Podcasts.',
                 'null' => true,
             ],
             'owner_email' => [
                 'type' => 'VARCHAR',
                 'constraint' => 1024,
-                'comment' =>
-                    'The podcast owner email address.  Note: The owner information is for administrative communication about the podcast and isn’t displayed in Apple Podcasts. Please make sure the email address is active and monitored.',
                 'null' => true,
             ],
             'type' => [
                 'type' => 'ENUM',
                 'constraint' => ['episodic', 'serial'],
                 'default' => 'episodic',
-                'comment' =>
-                    'The type of show.  If your show is Serial you must use this tag.  Its values can be one of the following:      episodic (default). Specify episodic when episodes are intended to be consumed without any specific order. Apple Podcasts will present newest episodes first and display the publish date (required) of each episode. If organized into seasons, the newest season will be presented first - otherwise, episodes will be grouped by year published, newest first.      For new subscribers, Apple Podcasts adds the newest, most recent episode in their Library.      serial. Specify serial when episodes are intended to be consumed in sequential order. Apple Podcasts will present the oldest episodes first and display the episode numbers (required) of each episode. If organized into seasons, the newest season will be presented first and  <itunes:episode> numbers must be given for each episode.      For new subscribers, Apple Podcasts adds the first episode to their Library, or the entire current season if using seasons',
             ],
             'copyright' => [
                 'type' => 'VARCHAR',
                 'constraint' => 1024,
-                'comment' =>
-                    'The show copyright details.  If your show is copyrighted you should use this tag.',
                 'null' => true,
             ],
             'block' => [
                 'type' => 'TINYINT',
                 'constraint' => 1,
                 'default' => 0,
-                'comment' =>
-                    'The podcast show or hide status.  If you want your show removed from the Apple directory, use this tag.  Specifying the <itunes:block> tag with a Yes value, prevents the entire podcast from appearing in Apple Podcasts.  Specifying any value other than Yes has no effect.',
             ],
             'complete' => [
                 'type' => 'TINYINT',
                 'constraint' => 1,
                 'default' => 0,
-                'comment' =>
-                    'The podcast update status.  If you will never publish another episode to your show, use this tag.  Specifying the <itunes:complete> tag with a Yes value indicates that a podcast is complete and you will not post any more episodes in the future.  Specifying any value other than Yes has no effect.',
             ],
             'episode_description_footer' => [
                 'type' => 'TEXT',
-                'comment' =>
-                    'The text that will be added in every episode description (show notes).',
                 'null' => true,
             ],
             'custom_html_head' => [
                 'type' => 'TEXT',
-                'comment' =>
-                    'The HTML code that will be added to every page for this podcast. (You could add Google Analytics tracking code here for instance.)',
                 'null' => true,
             ],
+            'created_by' => [
+                'type' => 'INT',
+                'constraint' => 11,
+                'unsigned' => true,
+            ],
+            'updated_by' => [
+                'type' => 'INT',
+                'constraint' => 11,
+                'unsigned' => true,
+            ],
             'created_at' => [
                 'type' => 'TIMESTAMP',
             ],
@@ -154,7 +119,8 @@ class AddPodcasts extends Migration
             ],
         ]);
         $this->forge->addKey('id', true);
-        $this->forge->addForeignKey('owner_id', 'users', 'id');
+        $this->forge->addForeignKey('created_by', 'users', 'id');
+        $this->forge->addForeignKey('updated_by', 'users', 'id');
         $this->forge->createTable('podcasts');
     }
 
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 b29f0583ac..23bd03ff37 100644
--- a/app/Database/Migrations/2020-06-05-170000_add_episodes.php
+++ b/app/Database/Migrations/2020-06-05-170000_add_episodes.php
@@ -23,98 +23,73 @@ class AddEpisodes extends Migration
                 'constraint' => 20,
                 'unsigned' => true,
                 'auto_increment' => true,
-                'comment' => 'The episode ID',
             ],
             'podcast_id' => [
                 'type' => 'BIGINT',
                 'constraint' => 20,
                 'unsigned' => true,
-                'comment' => 'The podcast ID',
             ],
             'title' => [
                 'type' => 'VARCHAR',
                 'constraint' => 1024,
-                'comment' =>
-                    'An episode title. title is a string containing a clear, concise name for your episode. Don’t specify the episode number or season number in this tag.',
             ],
             'slug' => [
                 'type' => 'VARCHAR',
                 'constraint' => 191,
-                'comment' => 'Episode slug for URLs',
             ],
             'enclosure_uri' => [
                 'type' => 'VARCHAR',
                 'constraint' => 1024,
-                'comment' =>
-                    'The URI attribute points to your podcast media file. The file extension specified within the URI attribute determines whether or not content appears in the podcast directory. Supported file formats include M4A, MP3, MOV, MP4, M4V, and PDF.',
-            ],
-            'pub_date' => [
-                'type' => 'DATETIME',
-                'comment' =>
-                    'The date and time when an episode was released. Format the date using the RFC 2822 specifications. For example: Wed, 15 Jun 2019 19:00:00 UTC.',
             ],
+
             'description' => [
                 'type' => 'TEXT',
                 'null' => true,
-                'comment' =>
-                    'An episode description. Description is text containing one or more sentences describing your episode to potential listeners. You can specify up to 4000 characters. You can use rich text formatting and some HTML (<p>, <ol>, <ul>, <li>, <a>) if wrapped in the <CDATA> tag. To include links in your description or rich HTML, adhere to the following technical guidelines: enclose all portions of your XML that contain embedded HTML in a CDATA section to prevent formatting issues, and to ensure proper link functionality.',
             ],
             'image_uri' => [
                 'type' => 'VARCHAR',
                 'constraint' => 1024,
                 'null' => true,
-                'comment' =>
-                    'The artwork for the show. Specify your show artwork by providing a URL linking to it. Depending on their device, subscribers see your podcast artwork in varying sizes. Therefore, make sure your design is effective at both its original size and at thumbnail size. You should include a show title, brand, or source name as part of your podcast artwork. Artwork must be a minimum size of 1400 x 1400 pixels and a maximum size of 3000 x 3000 pixels, in JPEG or PNG format, 72 dpi, with appropriate file extensions (.jpg, .png), and in the RGB colorspace.',
             ],
             'explicit' => [
                 'type' => 'TINYINT',
                 'constraint' => 1,
                 'default' => 0,
-                'comment' =>
-                    'The episode parental advisory information. Where the explicit value can be one of the following: true. If you specify true, indicating the presence of explicit content, Apple Podcasts displays an Explicit parental advisory graphic for your episode.     Episodes containing explicit material aren’t available in some Apple Podcasts territories.     false. If you specify false, indicating that the episode does not contain explicit language or adult content, Apple Podcasts displays a Clean parental advisory graphic for your episode.',
             ],
             'number' => [
                 'type' => 'INT',
                 'constraint' => 10,
                 'unsigned' => true,
-                'comment' =>
-                    'An episode number. If all your episodes have numbers and you would like them to be ordered based on them use this tag for each one. Episode numbers are optional for <itunes:type> episodic shows, but are mandatory for serial shows. Where episode is a non-zero integer (1, 2, 3, etc.) representing your episode number.',
             ],
             'season_number' => [
                 'type' => 'INT',
                 'constraint' => 10,
                 'unsigned' => true,
                 'default' => 1,
-                'comment' =>
-                    'The episode season number. If an episode is within a season use this tag. Where season is a non-zero integer (1, 2, 3, etc.) representing your season number. To allow the season feature for shows containing a single season, if only one season exists in the RSS feed, Apple Podcasts doesn’t display a season number. When you add a second season to the RSS feed, Apple Podcasts displays the season numbers.',
-            ],
-            'author_name' => [
-                'type' => 'VARCHAR',
-                'constraint' => 1024,
-                'comment' =>
-                    'Name of the group responsible for creating the episode.  Episode author most often refers to the parent company or network of a podcast, but it can also be used to identify the host(s) if none exists.  Author information is especially useful if a company or organization publishes multiple podcasts. Providing this information will allow listeners to see all episodes created by the same entity.',
-                'null' => true,
-            ],
-            'author_email' => [
-                'type' => 'VARCHAR',
-                'constraint' => 1024,
-                'owner_email' =>
-                    'Email of the group responsible for creating the episode.  Episode author most often refers to the parent company or network of a podcast, but it can also be used to identify the host(s) if none exists.  Author information is especially useful if a company or organization publishes multiple podcasts. Providing this information will allow listeners to see all episodes created by the same entity.',
-                'null' => true,
             ],
             'type' => [
                 'type' => 'ENUM',
                 'constraint' => ['full', 'trailer', 'bonus'],
                 'default' => 'full',
-                'comment' =>
-                    'The episode type. If an episode is a trailer or bonus content, use this tag. Where the episodeType value can be one of the following: full (default). Specify full when you are submitting the complete content of your show.     trailer. Specify trailer when you are submitting a short, promotional piece of content that represents a preview of your current show.     bonus. Specify bonus when you are submitting extra content for your show (for example, behind the scenes information or interviews with the cast) or cross-promotional content for another show.',
             ],
             'block' => [
                 'type' => 'TINYINT',
                 'constraint' => 1,
                 'default' => 0,
-                'comment' =>
-                    'The episode show or hide status. If you want an episode removed from the Apple directory, use this tag. Specifying the <itunes:block> tag with a Yes value prevents that episode from appearing in Apple Podcasts. For example, you might want to block a specific episode if you know that its content would otherwise cause the entire podcast to be removed from Apple Podcasts. Specifying any value other than Yes has no effect.',
+            ],
+            'created_by' => [
+                'type' => 'INT',
+                'constraint' => 11,
+                'unsigned' => true,
+            ],
+            'updated_by' => [
+                'type' => 'INT',
+                'constraint' => 11,
+                'unsigned' => true,
+            ],
+            'published_at' => [
+                'type' => 'DATETIME',
+                'null' => true,
             ],
             'created_at' => [
                 'type' => 'TIMESTAMP',
@@ -132,6 +107,8 @@ class AddEpisodes extends Migration
 
         $this->forge->addUniqueKey(['podcast_id', 'season_number', 'number']);
         $this->forge->addForeignKey('podcast_id', 'podcasts', 'id');
+        $this->forge->addForeignKey('created_by', 'users', 'id');
+        $this->forge->addForeignKey('updated_by', 'users', 'id');
         $this->forge->createTable('episodes');
     }
 
diff --git a/app/Database/Migrations/2020-06-05-190000_add_platforms.php b/app/Database/Migrations/2020-06-05-190000_add_platforms.php
index 5c3b53af93..c65929d098 100644
--- a/app/Database/Migrations/2020-06-05-190000_add_platforms.php
+++ b/app/Database/Migrations/2020-06-05-190000_add_platforms.php
@@ -23,66 +23,53 @@ class AddPlatforms extends Migration
                 'constraint' => 20,
                 'unsigned' => true,
                 'auto_increment' => true,
-                'comment' => 'The platform ID',
             ],
             'name' => [
                 'type' => 'VARCHAR',
                 'constraint' => 191,
                 'unique' => true,
-                'comment' => 'Platform name.',
             ],
             'home_url' => [
                 'type' => 'VARCHAR',
                 'constraint' => 191,
-                'comment' => 'Platform home URL.',
             ],
             'submit_url' => [
                 'type' => 'VARCHAR',
                 'constraint' => 191,
-                'comment' => 'Platform URL to submit podcasts.',
                 'null' => true,
             ],
             'iosapp_url' => [
                 'type' => 'VARCHAR',
                 'constraint' => 191,
-                'comment' => 'Platform iOS app URL (if any).',
                 'null' => true,
             ],
             'androidapp_url' => [
                 'type' => 'VARCHAR',
                 'constraint' => 191,
-                'comment' => 'Platform Android app URL (if any).',
                 'null' => true,
             ],
             'comment' => [
                 'type' => 'TEXT',
-                'comment' => 'Comment.',
                 'null' => true,
             ],
             'display_by_default' => [
                 'type' => 'TINYINT',
                 'constraint' => 1,
                 'default' => 0,
-                'comment' =>
-                    'True if the platform link should be displayed by default.',
             ],
             'ios_deeplink' => [
                 'type' => 'TINYINT',
                 'constraint' => 1,
                 'default' => 0,
-                'comment' => 'iOS deeplinking for this platform.',
             ],
             'android_deeplink' => [
                 'type' => 'TINYINT',
                 'constraint' => 1,
                 'default' => 0,
-                'comment' =>
-                    'Android deeplinking for this platform: 0=No, 1=Manual, 2=Automatic.',
             ],
             'logo_file_name' => [
                 'type' => 'VARCHAR',
                 'constraint' => 1024,
-                'comment' => 'The logo for this platform.',
             ],
             'created_at' => [
                 'type' => 'TIMESTAMP',
diff --git a/app/Database/Migrations/2020-06-08-160000_add_platform_links.php b/app/Database/Migrations/2020-06-08-160000_add_platform_links.php
index 214a27c677..bc44741f62 100644
--- a/app/Database/Migrations/2020-06-08-160000_add_platform_links.php
+++ b/app/Database/Migrations/2020-06-08-160000_add_platform_links.php
@@ -23,24 +23,20 @@ class AddPlatformLinks extends Migration
                 'constraint' => 20,
                 'unsigned' => true,
                 'auto_increment' => true,
-                'comment' => 'The link ID',
             ],
             'podcast_id' => [
                 'type' => 'BIGINT',
                 'constraint' => 20,
                 'unsigned' => true,
-                'comment' => 'The podcast ID',
             ],
             'platform_id' => [
                 'type' => 'BIGINT',
                 'constraint' => 20,
                 'unsigned' => true,
-                'comment' => 'The platform ID',
             ],
             'link_url' => [
                 'type' => 'VARCHAR',
                 'constraint' => 191,
-                'comment' => 'Podcast link URL on this platform.',
             ],
             'comment' => [
                 'type' => 'TEXT',
@@ -51,7 +47,6 @@ class AddPlatformLinks extends Migration
                 'type' => 'TINYINT',
                 'constraint' => 1,
                 'default' => 0,
-                'comment' => 'Show this link.',
             ],
             'created_at' => [
                 'type' => 'TIMESTAMP',
diff --git a/app/Database/Migrations/2020-06-08-210000_add_analytics_episodes_by_country.php b/app/Database/Migrations/2020-06-08-210000_add_analytics_episodes_by_country.php
index 18141d0d00..c4f6218a54 100644
--- a/app/Database/Migrations/2020-06-08-210000_add_analytics_episodes_by_country.php
+++ b/app/Database/Migrations/2020-06-08-210000_add_analytics_episodes_by_country.php
@@ -22,34 +22,28 @@ class AddAnalyticsEpisodesByCountry extends Migration
                 'constraint' => 20,
                 'unsigned' => true,
                 'auto_increment' => true,
-                'comment' => 'The line ID',
             ],
             'podcast_id' => [
                 'type' => 'BIGINT',
                 'constraint' => 20,
                 'unsigned' => true,
-                'comment' => 'The podcast ID',
             ],
             'episode_id' => [
                 'type' => 'BIGINT',
                 'constraint' => 20,
                 'unsigned' => true,
-                'comment' => 'The episode ID',
             ],
             'country_code' => [
                 'type' => 'VARCHAR',
                 'constraint' => 3,
-                'comment' => 'ISO 3166-1 code.',
             ],
             'date' => [
                 'type' => 'date',
-                'comment' => 'Line date.',
             ],
             'hits' => [
                 'type' => 'INT',
                 'constraint' => 10,
                 'default' => 1,
-                'comment' => 'Number of hits.',
             ],
         ]);
         $this->forge->addKey('id', true);
diff --git a/app/Database/Migrations/2020-06-08-210000_add_analytics_episodes_by_player.php b/app/Database/Migrations/2020-06-08-210000_add_analytics_episodes_by_player.php
index 7e6fd2bbaf..3a1e257af6 100644
--- a/app/Database/Migrations/2020-06-08-210000_add_analytics_episodes_by_player.php
+++ b/app/Database/Migrations/2020-06-08-210000_add_analytics_episodes_by_player.php
@@ -22,34 +22,28 @@ class AddAnalyticsEpisodesByPlayer extends Migration
                 'constraint' => 20,
                 'unsigned' => true,
                 'auto_increment' => true,
-                'comment' => 'The line ID',
             ],
             'podcast_id' => [
                 'type' => 'BIGINT',
                 'constraint' => 20,
                 'unsigned' => true,
-                'comment' => 'The podcast ID',
             ],
             'episode_id' => [
                 'type' => 'BIGINT',
                 'constraint' => 20,
                 'unsigned' => true,
-                'comment' => 'The episode ID',
             ],
             'player' => [
                 'type' => 'VARCHAR',
                 'constraint' => 191,
-                'comment' => 'Podcast player name.',
             ],
             'date' => [
                 'type' => 'date',
-                'comment' => 'Line date.',
             ],
             'hits' => [
                 'type' => 'INT',
                 'constraint' => 10,
                 'default' => 1,
-                'comment' => 'Number of hits.',
             ],
         ]);
         $this->forge->addKey('id', true);
diff --git a/app/Database/Migrations/2020-06-08-210000_add_analytics_podcasts_by_country.php b/app/Database/Migrations/2020-06-08-210000_add_analytics_podcasts_by_country.php
index c4d1ec3738..6545a7a11b 100644
--- a/app/Database/Migrations/2020-06-08-210000_add_analytics_podcasts_by_country.php
+++ b/app/Database/Migrations/2020-06-08-210000_add_analytics_podcasts_by_country.php
@@ -22,13 +22,11 @@ class AddAnalyticsPodcastsByCountry extends Migration
                 'constraint' => 20,
                 'unsigned' => true,
                 'auto_increment' => true,
-                'comment' => 'The line ID',
             ],
             'podcast_id' => [
                 'type' => 'BIGINT',
                 'constraint' => 20,
                 'unsigned' => true,
-                'comment' => 'The podcast ID',
             ],
             'country_code' => [
                 'type' => 'VARCHAR',
@@ -37,13 +35,11 @@ class AddAnalyticsPodcastsByCountry extends Migration
             ],
             'date' => [
                 'type' => 'date',
-                'comment' => 'Line date.',
             ],
             'hits' => [
                 'type' => 'INT',
                 'constraint' => 10,
                 'default' => 1,
-                'comment' => 'Number of hits.',
             ],
         ]);
         $this->forge->addKey('id', true);
diff --git a/app/Database/Migrations/2020-06-08-210000_add_analytics_podcasts_by_player.php b/app/Database/Migrations/2020-06-08-210000_add_analytics_podcasts_by_player.php
index 7aa84b624f..3a13f65fd5 100644
--- a/app/Database/Migrations/2020-06-08-210000_add_analytics_podcasts_by_player.php
+++ b/app/Database/Migrations/2020-06-08-210000_add_analytics_podcasts_by_player.php
@@ -22,28 +22,23 @@ class AddAnalyticsPodcastsByPlayer extends Migration
                 'constraint' => 20,
                 'unsigned' => true,
                 'auto_increment' => true,
-                'comment' => 'The line ID',
             ],
             'podcast_id' => [
                 'type' => 'BIGINT',
                 'constraint' => 20,
                 'unsigned' => true,
-                'comment' => 'The podcast ID',
             ],
             'player' => [
                 'type' => 'VARCHAR',
                 'constraint' => 191,
-                'comment' => 'Podcast player name.',
             ],
             'date' => [
                 'type' => 'date',
-                'comment' => 'Line date.',
             ],
             'hits' => [
                 'type' => 'INT',
                 'constraint' => 10,
                 'default' => 1,
-                'comment' => 'Number of hits.',
             ],
         ]);
         $this->forge->addKey('id', true);
diff --git a/app/Database/Migrations/2020-06-08-210000_add_analytics_unknown_useragents.php b/app/Database/Migrations/2020-06-08-210000_add_analytics_unknown_useragents.php
index 9a65045f76..9327e2abdc 100644
--- a/app/Database/Migrations/2020-06-08-210000_add_analytics_unknown_useragents.php
+++ b/app/Database/Migrations/2020-06-08-210000_add_analytics_unknown_useragents.php
@@ -22,19 +22,16 @@ class AddAnalyticsUnknownUseragents extends Migration
                 'constraint' => 20,
                 'unsigned' => true,
                 'auto_increment' => true,
-                'comment' => 'The line ID',
             ],
             'useragent' => [
                 'type' => 'VARCHAR',
                 'constraint' => 191,
                 'unique' => true,
-                'comment' => 'The unknown user-agent.',
             ],
             'hits' => [
                 'type' => 'INT',
                 'constraint' => 10,
                 'default' => 1,
-                'comment' => 'Number of hits.',
             ],
         ]);
         $this->forge->addKey('id', true);
diff --git a/app/Database/Migrations/2020-06-08-210000_add_analytics_website_by_browser.php b/app/Database/Migrations/2020-06-08-210000_add_analytics_website_by_browser.php
index d86c3395e1..6e4942d433 100644
--- a/app/Database/Migrations/2020-06-08-210000_add_analytics_website_by_browser.php
+++ b/app/Database/Migrations/2020-06-08-210000_add_analytics_website_by_browser.php
@@ -22,28 +22,23 @@ class AddAnalyticsWebsiteByBrowser extends Migration
                 'constraint' => 20,
                 'unsigned' => true,
                 'auto_increment' => true,
-                'comment' => 'The line ID',
             ],
             'podcast_id' => [
                 'type' => 'BIGINT',
                 'constraint' => 20,
                 'unsigned' => true,
-                'comment' => 'The podcast ID',
             ],
             'browser' => [
                 'type' => 'VARCHAR',
                 'constraint' => 191,
-                'comment' => 'The Web Browser.',
             ],
             'date' => [
                 'type' => 'date',
-                'comment' => 'Line date.',
             ],
             'hits' => [
                 'type' => 'INT',
                 'constraint' => 10,
                 'default' => 1,
-                'comment' => 'Number of hits.',
             ],
         ]);
         $this->forge->addKey('id', true);
diff --git a/app/Database/Migrations/2020-06-08-210000_add_analytics_website_by_country.php b/app/Database/Migrations/2020-06-08-210000_add_analytics_website_by_country.php
index c0e5b73183..7f8b1415c0 100644
--- a/app/Database/Migrations/2020-06-08-210000_add_analytics_website_by_country.php
+++ b/app/Database/Migrations/2020-06-08-210000_add_analytics_website_by_country.php
@@ -22,13 +22,11 @@ class AddAnalyticsWebsiteByCountry extends Migration
                 'constraint' => 20,
                 'unsigned' => true,
                 'auto_increment' => true,
-                'comment' => 'The line ID',
             ],
             'podcast_id' => [
                 'type' => 'BIGINT',
                 'constraint' => 20,
                 'unsigned' => true,
-                'comment' => 'The podcast ID',
             ],
             'country_code' => [
                 'type' => 'VARCHAR',
@@ -37,13 +35,11 @@ class AddAnalyticsWebsiteByCountry extends Migration
             ],
             'date' => [
                 'type' => 'date',
-                'comment' => 'Line date.',
             ],
             'hits' => [
                 'type' => 'INT',
                 'constraint' => 10,
                 'default' => 1,
-                'comment' => 'Number of hits.',
             ],
         ]);
         $this->forge->addKey('id', true);
diff --git a/app/Database/Migrations/2020-06-08-210000_add_analytics_website_by_referer.php b/app/Database/Migrations/2020-06-08-210000_add_analytics_website_by_referer.php
index fa0ec5ea02..28808f273e 100644
--- a/app/Database/Migrations/2020-06-08-210000_add_analytics_website_by_referer.php
+++ b/app/Database/Migrations/2020-06-08-210000_add_analytics_website_by_referer.php
@@ -22,13 +22,11 @@ class AddAnalyticsWebsiteByReferer extends Migration
                 'constraint' => 20,
                 'unsigned' => true,
                 'auto_increment' => true,
-                'comment' => 'The line ID',
             ],
             'podcast_id' => [
                 'type' => 'BIGINT',
                 'constraint' => 20,
                 'unsigned' => true,
-                'comment' => 'The podcast ID',
             ],
             'referer' => [
                 'type' => 'VARCHAR',
@@ -37,13 +35,11 @@ class AddAnalyticsWebsiteByReferer extends Migration
             ],
             'date' => [
                 'type' => 'date',
-                'comment' => 'Line date.',
             ],
             'hits' => [
                 'type' => 'INT',
                 'constraint' => 10,
                 'default' => 1,
-                'comment' => 'Number of hits.',
             ],
         ]);
         $this->forge->addKey('id', true);
diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php
index f60a524480..45f6bae675 100644
--- a/app/Entities/Episode.php
+++ b/app/Entities/Episode.php
@@ -69,20 +69,26 @@ class Episode extends Entity
      */
     protected $description_html;
 
+    protected $dates = [
+        'published_at',
+        'created_at',
+        'updated_at',
+        'deleted_at',
+    ];
+
     protected $casts = [
         'slug' => 'string',
         'title' => 'string',
         'enclosure_uri' => 'string',
-        'pub_date' => 'datetime',
         'description' => 'string',
         'image_uri' => '?string',
-        'author_name' => '?string',
-        'author_email' => '?string',
         'explicit' => 'boolean',
         'number' => 'integer',
         'season_number' => '?integer',
         'type' => 'string',
         'block' => 'boolean',
+        'created_by' => 'integer',
+        'updated_by' => 'integer',
     ];
 
     public function setImage(?\CodeIgniter\HTTP\Files\UploadedFile $image)
@@ -216,4 +222,29 @@ 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;
+
+        return $this;
+    }
+
+    public function setUpdatedBy(\App\Entities\User $user)
+    {
+        $this->attributes['updated_by'] = $user->id;
+
+        return $this;
+    }
 }
diff --git a/app/Entities/Podcast.php b/app/Entities/Podcast.php
index cb4040fe33..c139ef028e 100644
--- a/app/Entities/Podcast.php
+++ b/app/Entities/Podcast.php
@@ -40,11 +40,6 @@ class Podcast extends Entity
      */
     protected $episodes;
 
-    /**
-     * @var
-     */
-    protected $owner;
-
     /**
      * @var \App\Entities\User[]
      */
@@ -64,9 +59,7 @@ class Podcast extends Entity
         'language' => 'string',
         'category' => 'string',
         'explicit' => 'boolean',
-        'author_name' => '?string',
-        'author_email' => '?string',
-        'owner_id' => 'integer',
+        'author' => '?string',
         'owner_name' => '?string',
         'owner_email' => '?string',
         'type' => 'string',
@@ -75,6 +68,8 @@ class Podcast extends Entity
         'complete' => 'boolean',
         'episode_description_footer' => '?string',
         'custom_html_head' => '?string',
+        'created_by' => 'integer',
+        'updated_by' => 'integer',
     ];
 
     public function setImage(\CodeIgniter\HTTP\Files\UploadedFile $image = null)
@@ -139,33 +134,6 @@ class Podcast extends Entity
         return $this->episodes;
     }
 
-    /**
-     * Returns the podcast owner
-     *
-     * @return \App\Entities\User
-     */
-    public function getOwner()
-    {
-        if (empty($this->id)) {
-            throw new \RuntimeException(
-                'Podcast must be created before getting owner.'
-            );
-        }
-
-        if (empty($this->owner)) {
-            $this->owner = (new UserModel())->find($this->owner_id);
-        }
-
-        return $this->owner;
-    }
-
-    public function setOwner(\App\Entities\User $user)
-    {
-        $this->attributes['owner_id'] = $user->id;
-
-        return $this;
-    }
-
     /**
      * Returns all podcast contributors
      *
@@ -197,4 +165,18 @@ class Podcast extends Entity
 
         return $converter->convertToHtml($this->attributes['description']);
     }
+
+    public function setCreatedBy(\App\Entities\User $user)
+    {
+        $this->attributes['created_by'] = $user->id;
+
+        return $this;
+    }
+
+    public function setUpdatedBy(\App\Entities\User $user)
+    {
+        $this->attributes['updated_by'] = $user->id;
+
+        return $this;
+    }
 }
diff --git a/app/Helpers/id3_helper.php b/app/Helpers/id3_helper.php
index a80eaaf55a..7933505029 100644
--- a/app/Helpers/id3_helper.php
+++ b/app/Helpers/id3_helper.php
@@ -64,7 +64,9 @@ function write_enclosure_tags($episode)
         'title' => [$episode->title],
         'artist' => [$episode->podcast->author],
         'album' => [$episode->podcast->title],
-        'year' => [$episode->pub_date->format('Y')],
+        'year' => [
+            $episode->published_at ? $episode->published_at->format('Y') : '',
+        ],
         'genre' => ['Podcast'],
         'comment' => [$episode->description],
         'track_number' => [strval($episode->number)],
diff --git a/app/Helpers/rss_helper.php b/app/Helpers/rss_helper.php
index ba665aab39..690049f2e4 100644
--- a/app/Helpers/rss_helper.php
+++ b/app/Helpers/rss_helper.php
@@ -91,8 +91,8 @@ function get_rss_feed($podcast)
         $itunes_namespace
     );
 
-    $podcast->author_name &&
-        $channel->addChild('author', $podcast->author_name, $itunes_namespace);
+    $podcast->author &&
+        $channel->addChild('author', $podcast->author, $itunes_namespace);
     $channel->addChild('link', $podcast->link);
 
     if ($podcast->owner_name || $podcast->owner_email) {
@@ -125,7 +125,10 @@ function get_rss_feed($podcast)
         $enclosure->addAttribute('type', $enclosure_metadata['mime_type']);
 
         $item->addChild('guid', $episode->guid);
-        $item->addChild('pubDate', $episode->pub_date->format(DATE_RFC1123));
+        $item->addChild(
+            'pubDate',
+            $episode->published_at->format(DATE_RFC1123)
+        );
         $item->addChildWithCDATA('description', $episode->description_html);
         $item->addChild(
             'duration',
@@ -145,18 +148,6 @@ function get_rss_feed($podcast)
             $itunes_namespace
         );
 
-        if ($episode->author_email || $episode->author_name) {
-            $item->addChild(
-                'author',
-                $episode->author_name
-                    ? $episode->author_email .
-                        ' (' .
-                        $episode->author_name .
-                        ')'
-                    : $episode->author_email
-            );
-        }
-
         $item->addChild('episode', $episode->number, $itunes_namespace);
         $episode->season_number &&
             $item->addChild(
diff --git a/app/Language/en/AdminNavigation.php b/app/Language/en/AdminNavigation.php
index 255e1b5ed0..4f22158013 100644
--- a/app/Language/en/AdminNavigation.php
+++ b/app/Language/en/AdminNavigation.php
@@ -11,10 +11,10 @@ return [
     'podcasts' => 'Podcasts',
     'users' => 'Users',
     'admin' => 'Home',
-    'my_podcasts' => 'My podcasts',
-    'podcast_list' => 'All podcasts',
-    'podcast_create' => 'New podcast',
-    'user_list' => 'All users',
-    'user_create' => 'New user',
+    'my-podcasts' => 'My podcasts',
+    'podcast-list' => 'All podcasts',
+    'podcast-create' => 'New podcast',
+    'user-list' => 'All users',
+    'user-create' => 'New user',
     'go_to_website' => 'Go to website',
 ];
diff --git a/app/Language/en/Contributor.php b/app/Language/en/Contributor.php
index 25fed24366..46b95f0416 100644
--- a/app/Language/en/Contributor.php
+++ b/app/Language/en/Contributor.php
@@ -20,6 +20,9 @@ return [
         'submit_add' => 'Add contributor',
         'submit_edit' => 'Update role',
     ],
+    'roles' => [
+        'podcast_admin' => 'Podcast admin',
+    ],
     'messages' => [
         'removeOwnerContributorError' => 'You can\'t remove the podcast owner!',
         'removeContributorSuccess' =>
diff --git a/app/Language/en/Episode.php b/app/Language/en/Episode.php
index 2c4b3adf9b..ae5e3db1a8 100644
--- a/app/Language/en/Episode.php
+++ b/app/Language/en/Episode.php
@@ -14,7 +14,7 @@ return [
     'go_to_page' => 'Go to page',
     'create' => 'Add an episode',
     'form' => [
-        'file' => 'Audio file',
+        'enclosure' => 'Audio file',
         'title' => 'Title',
         'title_help' =>
             'This episode title. It should contain a clear, concise name for your episode. Don’t specify the episode number or season number here.',
@@ -24,17 +24,19 @@ return [
         'description' => 'Description',
         'description_help' =>
             'This is where you type the episode show notes. You may add rich text, links, images…',
-        'pub_date' => 'Publication date',
-        'pub_date_help' =>
-            'The date and time when this episode was released. It can be in the past or in the future.',
         'image' => 'Image',
         'image_help' =>
             'This episode image. If an image is already in the audio file, you don’t need to add one here. If you add no image to this episode, the podcast image will be used instead.',
-        'author_name' => 'Author name',
-        'author_email' => 'Author email',
         'explicit' => 'Explicit',
         'explicit_help' =>
             'The episode parental advisory information for this episode.',
+        'published_at' => [
+            'label' => 'Publication date',
+            'date' => 'Publication date',
+            'time' => 'Publication time',
+        ],
+        'published_at_help' =>
+            'The date and time when this episode was released. It can be in the past or in the future.',
         'type' => [
             'label' => 'Type',
             'full' => 'Full',
diff --git a/app/Language/en/MyAccount.php b/app/Language/en/MyAccount.php
index b675cc1770..b9b001d8d0 100644
--- a/app/Language/en/MyAccount.php
+++ b/app/Language/en/MyAccount.php
@@ -10,6 +10,8 @@ return [
     'info' => 'My account info',
     'changePassword' => 'Change my password',
     'messages' => [
+        'wrongPasswordError' =>
+            'You\'ve entered the wrong password, try again.',
         'passwordChangeSuccess' => 'Password has been successfully changed!',
     ],
 ];
diff --git a/app/Language/en/Podcast.php b/app/Language/en/Podcast.php
index edf686cd2d..03bae0199c 100644
--- a/app/Language/en/Podcast.php
+++ b/app/Language/en/Podcast.php
@@ -42,10 +42,9 @@ return [
         'explicit' => 'Explicit',
         'explicit_help' =>
             'The podcast parental advisory information. Does it contain explicit content?',
-        'author_name' => 'Publisher',
-        'author_name_help' =>
+        '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’.',
-        'author_email' => 'Author email',
         '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.',
diff --git a/app/Language/en/User.php b/app/Language/en/User.php
index 0e742c2a32..2e221aaf30 100644
--- a/app/Language/en/User.php
+++ b/app/Language/en/User.php
@@ -20,11 +20,13 @@ return [
         'username' => 'Username',
         'password' => 'Password',
         'new_password' => 'New Password',
-        'repeat_password' => 'Repeat password',
-        'repeat_new_password' => 'Repeat new password',
         'roles' => 'Roles',
         'submit_create' => 'Create user',
         'submit_edit' => 'Save',
+        'submit_password_change' => 'Change!',
+    ],
+    'roles' => [
+        'superadmin' => 'Super admin',
     ],
     'messages' => [
         'createSuccess' =>
diff --git a/app/Models/EpisodeModel.php b/app/Models/EpisodeModel.php
index 9514abec0f..cc68834c7b 100644
--- a/app/Models/EpisodeModel.php
+++ b/app/Models/EpisodeModel.php
@@ -20,16 +20,16 @@ class EpisodeModel extends Model
         'title',
         'slug',
         'enclosure_uri',
-        'pub_date',
         'description',
         'image_uri',
         'explicit',
         'number',
         'season_number',
-        'author_name',
-        'author_email',
         'type',
         'block',
+        'published_at',
+        'created_by',
+        'updated_by',
     ];
 
     protected $returnType = \App\Entities\Episode::class;
@@ -42,13 +42,14 @@ class EpisodeModel extends Model
         'title' => 'required',
         'slug' => 'required|regex_match[/^[a-zA-Z0-9\-]{1,191}$/]',
         'enclosure_uri' => 'required',
-        'pub_date' => 'required|valid_date',
         'description' => 'required',
         'image_uri' => 'required',
-        'number' => 'required',
-        'season_number' => 'required',
-        'author_email' => 'valid_email|permit_empty',
+        'number' => 'required|is_natural_no_zero',
+        'season_number' => 'required|is_natural_no_zero',
         'type' => 'required',
+        'published_at' => 'valid_date|permit_empty',
+        'created_by' => 'required',
+        'updated_by' => 'required',
     ];
     protected $validationMessages = [];
 
diff --git a/app/Models/PodcastModel.php b/app/Models/PodcastModel.php
index acf34709d0..01bbdbb4dc 100644
--- a/app/Models/PodcastModel.php
+++ b/app/Models/PodcastModel.php
@@ -25,9 +25,7 @@ class PodcastModel extends Model
         'language',
         'category',
         'explicit',
-        'author_name',
-        'author_email',
-        'owner_id',
+        'author',
         'owner_name',
         'owner_email',
         'type',
@@ -35,6 +33,8 @@ class PodcastModel extends Model
         'block',
         'complete',
         'custom_html_head',
+        'created_by',
+        'updated_by',
     ];
 
     protected $returnType = \App\Entities\Podcast::class;
@@ -50,10 +50,10 @@ class PodcastModel extends Model
         'image_uri' => 'required',
         'language' => 'required',
         'category' => 'required',
-        'author_email' => 'valid_email|permit_empty',
-        'owner_id' => 'required',
         'owner_email' => 'required|valid_email',
         'type' => 'required',
+        'created_by' => 'required',
+        'updated_by' => 'required',
     ];
     protected $validationMessages = [];
 
@@ -61,11 +61,6 @@ class PodcastModel extends Model
     protected $afterUpdate = ['clearCache'];
     protected $beforeDelete = ['clearCache'];
 
-    public function hello(array $data)
-    {
-        return $data;
-    }
-
     /**
      *  Gets all the podcasts a given user is contributing to
      *
diff --git a/app/Views/admin/_header.php b/app/Views/admin/_header.php
index 6756660b62..6f3605a114 100644
--- a/app/Views/admin/_header.php
+++ b/app/Views/admin/_header.php
@@ -9,16 +9,16 @@
     </div>
     <?= render_breadcrumb() ?>
     <div class="relative ml-auto" data-toggle="dropdown">
-        <button type="button" class="inline-flex items-center px-2 py-1 outline-none focus:shadow-outline" id="myAccountDropdown" data-popper="button" aria-haspopup="true" aria-expanded="false">
+        <button type="button" class="inline-flex items-center px-2 py-1 outline-none focus:shadow-outline" id="my-accountDropdown" data-popper="button" aria-haspopup="true" aria-expanded="false">
             Hey <?= user()->username ?>
             <?= icon('caret-down', 'ml-2') ?>
         </button>
-        <nav class="absolute z-10 flex-col hidden py-2 text-black whitespace-no-wrap bg-white border rounded shadow" aria-labelledby="myAccountDropdown" data-popper="menu" data-popper-placement="bottom-end">
+        <nav class="absolute z-10 flex-col hidden py-2 text-black whitespace-no-wrap bg-white border rounded shadow" aria-labelledby="my-accountDropdown" data-popper="menu" data-popper-placement="bottom-end">
                 <a class="px-4 py-1 hover:bg-gray-100" href="<?= route_to(
-                    'myAccount'
+                    'my-account'
                 ) ?>">My Account</a>
                 <a class="px-4 py-1 hover:bg-gray-100" href="<?= route_to(
-                    'myAccount_change-password'
+                    'change-password'
                 ) ?>">Change password</a>
                 <a class="px-4 py-1 hover:bg-gray-100" href="<?= route_to(
                     'logout'
diff --git a/app/Views/admin/_partials/_episode-card.php b/app/Views/admin/_partials/_episode-card.php
index af1b46de63..353674de73 100644
--- a/app/Views/admin/_partials/_episode-card.php
+++ b/app/Views/admin/_partials/_episode-card.php
@@ -2,7 +2,7 @@
     <img src="<?= $episode->image_url ?>" alt="<?= $episode->title ?>" class="object-cover w-32 h-32 rounded-l" />
     <div class="flex flex-col flex-1 px-4 py-2">
         <a href="<?= route_to(
-            'episode_view',
+            'episode-view',
             $episode->podcast->id,
             $episode->id
         ) ?>">
@@ -17,17 +17,17 @@
             </button>
             <nav class="absolute z-10 flex-col hidden py-2 text-black whitespace-no-wrap bg-white border rounded shadow" aria-labelledby="moreDropdown" data-popper="menu" data-popper-placement="bottom-start" data-popper-offset-x="0" data-popper-offset-y="0" >
                     <a class="px-4 py-1 hover:bg-gray-100" href="<?= route_to(
-                        'episode_edit',
+                        'episode-edit',
                         $episode->podcast->id,
                         $episode->id
                     ) ?>"><?= lang('Episode.edit') ?></a>
                     <a class="px-4 py-1 hover:bg-gray-100" href="<?= route_to(
                         'episode',
-                        $episode->podcast->id,
+                        $episode->podcast->name,
                         $episode->slug
                     ) ?>"><?= lang('Episode.go_to_page') ?></a>
                     <a class="px-4 py-1 hover:bg-gray-100" href="<?= route_to(
-                        'episode_delete',
+                        'episode-delete',
                         $episode->podcast->id,
                         $episode->id
                     ) ?>"><?= lang('Episode.delete') ?></a>
diff --git a/app/Views/admin/_partials/_podcast-card.php b/app/Views/admin/_partials/_podcast-card.php
index a56c7fe6a6..0db3051be5 100644
--- a/app/Views/admin/_partials/_podcast-card.php
+++ b/app/Views/admin/_partials/_podcast-card.php
@@ -2,7 +2,7 @@
     <img alt="<?= $podcast->title ?>" src="<?= $podcast->image_url ?>" class="object-cover w-full h-40" />
     <div class="p-2">
         <a href="<?= route_to(
-            'podcast_view',
+            'podcast-view',
             $podcast->id
         ) ?>" class="hover:underline">
             <h2 class="font-semibold"><?= $podcast->title ?></h2>
@@ -11,13 +11,13 @@
     </div>
     <footer class="flex items-center justify-end p-2">
         <a class="inline-flex p-2 mr-2 text-teal-700 bg-teal-100 rounded-full shadow-xs hover:bg-teal-200" href="<?= route_to(
-            'podcast_edit',
+            'podcast-edit',
             $podcast->id
         ) ?>" data-toggle="tooltip" data-placement="bottom" title="<?= lang(
     'Podcast.edit'
 ) ?>"><?= icon('edit') ?></a>
         <a class="inline-flex p-2 text-gray-700 bg-gray-100 rounded-full shadow-xs hover:bg-gray-200" href="<?= route_to(
-            'podcast_view',
+            'podcast-view',
             $podcast->id
         ) ?>" data-toggle="tooltip" data-placement="bottom" title="<?= lang(
     'Podcast.view'
diff --git a/app/Views/admin/_sidenav.php b/app/Views/admin/_sidenav.php
index dad0054913..5cec5d8b74 100644
--- a/app/Views/admin/_sidenav.php
+++ b/app/Views/admin/_sidenav.php
@@ -3,9 +3,9 @@ $navigation = [
     'dashboard' => ['icon' => 'dashboard', 'items' => ['admin']],
     'podcasts' => [
         'icon' => 'mic',
-        'items' => ['my_podcasts', 'podcast_list', 'podcast_create'],
+        'items' => ['my-podcasts', 'podcast-list', 'podcast-create'],
     ],
-    'users' => ['icon' => 'group', 'items' => ['user_list', 'user_create']],
+    'users' => ['icon' => 'group', 'items' => ['user-list', 'user-create']],
 ]; ?>
 
 <nav class="<?= $class ?>">
@@ -19,7 +19,7 @@ $navigation = [
             <?php foreach ($data['items'] as $item): ?>
                 <?php $isActive = base_url(route_to($item)) == current_url(); ?>
             <li>
-                <a class="block py-1 pl-10 pr-2 text-sm text-gray-600 outline-none hover:text-gray-900 focus:shadow-outline <?= $isActive
+                <a class="block py-1 pl-12 pr-2 text-sm text-gray-600 outline-none hover:text-gray-900 focus:shadow-outline <?= $isActive
                     ? 'font-semibold text-gray-900'
                     : '' ?>" href="<?= route_to($item) ?>"><?= lang(
     'AdminNavigation.' . $item
diff --git a/app/Views/admin/contributor/add.php b/app/Views/admin/contributor/add.php
index 69bbe71a39..2f69a13aca 100644
--- a/app/Views/admin/contributor/add.php
+++ b/app/Views/admin/contributor/add.php
@@ -6,43 +6,32 @@
 
 
 <?= $this->section('content') ?>
-<form action="<?= route_to(
-    'contributor_add',
-    $podcast->id
-) ?>" method="post" class="flex flex-col max-w-lg">
-    <?= csrf_field() ?>
+
+<?= form_open(route_to('contributor-add', $podcast->id), [
+    'class' => 'flex flex-col max-w-sm',
+]) ?>
+<?= csrf_field() ?>
     
-    <div class="flex flex-col mb-4">
-        <label for="user"><?= lang('Contributor.form.user') ?></label>
-        <select id="user" name="user" autocomplete="off" class="form-select" required>
-            <?php foreach ($users as $user): ?>
-                <option value="<?= $user->id ?>"
-                <?php if (
-                    old('user') == $user->id
-                ): ?> selected <?php endif; ?>>
-                    <?= $user->username ?>
-                </option>
-            <?php endforeach; ?>
-        </select>
-    </div>
-
-    <div class="flex flex-col mb-4">
-        <label for="role"><?= lang('Contributor.form.role') ?></label>
-        <select id="role" name="role" autocomplete="off" class="form-select" required>
-            <?php foreach ($roles as $role): ?>
-                <option value="<?= $role->id ?>"
-                <?php if (
-                    old('role') == $role->id
-                ): ?> selected <?php endif; ?>>
-                    <?= $role->name ?>
-                </option>
-            <?php endforeach; ?>
-        </select>
-    </div>
-
-    <button type="submit" name="submit" class="self-end px-4 py-2 bg-gray-200"><?= lang(
-        'Contributor.form.submit_add'
-    ) ?></button>
-</form>
+<?= form_label(lang('Contributor.form.user'), 'user') ?>
+<?= form_dropdown('user', $userOptions, old('user'), [
+    'id' => 'user',
+    'class' => 'form-select mb-4',
+    'required' => 'required',
+]) ?>
+
+<?= form_label(lang('Contributor.form.role'), 'role') ?>
+<?= form_dropdown('role', $roleOptions, old('role'), [
+    'id' => 'role',
+    'class' => 'form-select mb-4',
+    'required' => 'required',
+]) ?>
+
+<?= form_button([
+    'content' => lang('Contributor.form.submit_add'),
+    'type' => 'submit',
+    'class' => 'self-end px-4 py-2 bg-gray-200',
+]) ?>
+
+<?= form_close() ?>
 
 <?= $this->endSection() ?>
diff --git a/app/Views/admin/contributor/edit.php b/app/Views/admin/contributor/edit.php
index 3329cf85b7..3a2c73adda 100644
--- a/app/Views/admin/contributor/edit.php
+++ b/app/Views/admin/contributor/edit.php
@@ -6,30 +6,25 @@
 
 
 <?= $this->section('content') ?>
-<form action="<?= route_to(
-    'contributor_edit',
-    $podcast->id,
-    $user->id
-) ?>" method="post" class="flex flex-col max-w-lg">
-    <?= csrf_field() ?>
-
-    <div class="flex flex-col mb-4">
-        <label for="category"><?= lang('Contributor.form.role') ?></label>
-        <select id="role" name="role" autocomplete="off" class="form-select" required>
-            <?php foreach ($roles as $role): ?>
-                <option value="<?= $role->id ?>"
-                <?php if (
-                    $contributorGroupId == $role->id
-                ): ?> selected <?php endif; ?>>
-                    <?= $role->name ?>
-                </option>
-            <?php endforeach; ?>
-        </select>
-    </div>
-
-    <button type="submit" name="submit" class="self-end px-4 py-2 bg-gray-200"><?= lang(
-        'Contributor.form.submit_edit'
-    ) ?></button>
-
-</form>
+
+<?= form_open(route_to('contributor-edit', $podcast->id, $user->id), [
+    'class' => 'flex flex-col max-w-sm',
+]) ?>
+<?= csrf_field() ?>
+
+<?= form_label(lang('Contributor.form.role'), 'role') ?>
+<?= form_dropdown('role', $roleOptions, old('role', $contributorGroupId), [
+    'id' => 'role',
+    'class' => 'form-select mb-4',
+    'required' => 'required',
+]) ?>
+
+<?= form_button([
+    'content' => lang('Contributor.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/contributor/list.php b/app/Views/admin/contributor/list.php
index b0e7eb41a0..e3c5e9874f 100644
--- a/app/Views/admin/contributor/list.php
+++ b/app/Views/admin/contributor/list.php
@@ -3,7 +3,7 @@
 <?= $this->section('title') ?>
 <?= lang('Contributor.podcast_contributors') ?>
 <a class="inline-flex items-center px-2 py-1 mb-2 ml-2 text-sm text-white bg-green-500 rounded shadow-xs outline-none hover:bg-green-600 focus:shadow-outline" href="<?= route_to(
-    'contributor_add',
+    'contributor-add',
     $podcast->id
 ) ?>">
 <?= icon('add', 'mr-2') ?>
@@ -25,15 +25,17 @@
         <?php foreach ($podcast->contributors as $contributor): ?>
         <tr>
             <td class="px-4 py-2 border"><?= $contributor->username ?></td>
-            <td class="px-4 py-2 border"><?= $contributor->podcast_role ?></td>
+            <td class="px-4 py-2 border"><?= lang(
+                'Contributor.roles.' . $contributor->podcast_role
+            ) ?></td>
             <td class="px-4 py-2 border">
                 <a class="inline-flex px-2 py-1 mb-2 text-sm text-white bg-teal-700 hover:bg-teal-800" href="<?= route_to(
-                    'contributor_edit',
+                    'contributor-edit',
                     $podcast->id,
                     $contributor->id
                 ) ?>"><?= lang('Contributor.edit') ?></a>
                 <a class="inline-flex px-2 py-1 text-sm text-white bg-red-700 hover:bg-red-800" href="<?= route_to(
-                    'contributor_remove',
+                    'contributor-remove',
                     $podcast->id,
                     $contributor->id
                 ) ?>"><?= lang('Contributor.remove') ?></a>
diff --git a/app/Views/admin/episode/create.php b/app/Views/admin/episode/create.php
index 1651f833b2..c19a097349 100644
--- a/app/Views/admin/episode/create.php
+++ b/app/Views/admin/episode/create.php
@@ -7,122 +7,167 @@
 
 <?= $this->section('content') ?>
 
-<?= form_open_multipart(route_to('episode_create', $podcast->id), [
+<?= form_open_multipart(route_to('episode-create', $podcast->id), [
     'method' => 'post',
     'class' => 'flex flex-col max-w-md',
 ]) ?>
 <?= csrf_field() ?>
 
-<div class="flex flex-col mb-4">
-    <label for="enclosure"><?= lang('Episode.form.file') ?></label>
-    <input type="file" class="form-input" id="enclosure" name="enclosure" required accept=".mp3,.m4a" />
-</div>
+<?= form_label(lang('Episode.form.enclosure'), 'enclosure') ?>
+<?= form_input([
+    'id' => 'enclosure',
+    'name' => 'enclosure',
+    'class' => 'form-input mb-4',
+    'required' => 'required',
+    'type' => 'file',
+    'accept' => '.mp3,.m4a',
+]) ?>
 
-<div class="flex flex-col mb-4">
-    <label for="title"><?= lang('Episode.form.title') ?></label>
-    <input type="text" class="form-input" id="title" name="title" data-slugify="title" required value="<?= old(
-        'title'
-    ) ?>" />
-</div>
+<?= form_label(lang('Episode.form.title'), 'title') ?>
+<?= form_input([
+    'id' => 'title',
+    'name' => 'title',
+    'class' => 'form-input mb-4',
+    'value' => old('title'),
+    'required' => 'required',
+    'data-slugify' => 'title',
+]) ?>
 
-<div class="flex flex-col mb-4">
-    <label for="slug"><?= lang('Episode.form.slug') ?></label>
-    <input type="text" class="form-input" id="slug" name="slug" data-slugify="slug" required value="<?= old(
-        'slug'
-    ) ?>" />
-</div>
+<?= form_label(lang('Episode.form.slug'), 'slug') ?>
+<?= form_input([
+    'id' => 'slug',
+    'name' => 'slug',
+    'class' => 'form-input mb-4',
+    'value' => old('slug'),
+    'required' => 'required',
+    'data-slugify' => 'slug',
+]) ?>
 
-<div class="flex flex-col mb-4">
-    <label for="description"><?= lang('Episode.form.description') ?></label>
-    <textarea class="hidden form-textarea" id="description" name="description" required data-editor="markdown"><?= old(
-        'description'
-    ) ?></textarea>
+<div class="mb-4">
+    <?= form_label(lang('Episode.form.description'), 'description') ?>
+    <?= form_textarea(
+        [
+            'id' => 'description',
+            'name' => 'description',
+            'class' => 'form-textarea',
+            'required' => 'required',
+        ],
+        old('description', '', false),
+        'data-editor="markdown"'
+    ) ?>
 </div>
 
-<div class="flex flex-col mb-4">
-    <label for="pub_date"><?= lang('Episode.form.pub_date') ?></label>
-    <input type="date" class="form-input" id="pub_date" name="pub_date" value="<?= old(
-        'pub_date'
-    ) || date('Y-m-d') ?>" />
+<?= 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 mb-4">
-    <label for="image"><?= lang('Episode.form.image') ?></label>
-    <input type="file" class="form-input" id="image" name="image" accept=".jpg,.jpeg,.png" />
+<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() ?>
 
-<div class="flex flex-col mb-4">
-    <label for="episode_number"><?= lang(
-        'Episode.form.episode_number'
-    ) ?></label>
-    <input type="number" class="form-input" id="episode_number" name="episode_number" required value="<?= old(
-        'episode_number'
-    ) ?>" />
-</div>
 
-<div class="flex flex-col mb-4">
-    <label for="season_number"><?= lang('Episode.form.season_number') ?></label>
-    <input type="number" class="form-input" id="season_number" name="season_number" value="<?= old(
-        'season_number'
-    ) ?>" />
-</div>
+<?= form_label(lang('Episode.form.image'), 'image') ?>
+<?= form_input([
+    'id' => 'image',
+    'name' => 'image',
+    'class' => 'form-input mb-4',
+    'type' => 'file',
+    'accept' => '.jpg,.jpeg,.png',
+]) ?>
 
-<div class="inline-flex items-center mb-4">
-    <input type="checkbox" id="explicit" name="explicit" class="form-checkbox" <?php if (
-        old('explicit')
-    ): ?> checked <?php endif; ?> />
-    <label for="explicit" class="pl-2"><?= lang(
-        'Episode.form.explicit'
-    ) ?></label>
-</div>
+<?= form_label(lang('Episode.form.season_number'), 'season_number') ?>
+<?= form_input([
+    'id' => 'season_number',
+    'name' => 'season_number',
+    'class' => 'form-input mb-4',
+    'value' => old('season_number'),
+    'type' => 'number',
+]) ?>
 
-<div class="flex flex-col mb-4">
-    <label for="author_name"><?= lang('Podcast.form.author_name') ?></label>
-    <input type="text" class="form-input" id="author_name" name="author_name" value="<?= old(
-        'author_name'
-    ) ?>" />
-</div>
+<?= form_label(lang('Episode.form.episode_number'), 'episode_number') ?>
+<?= form_input([
+    'id' => 'episode_number',
+    'name' => 'episode_number',
+    'class' => 'form-input mb-4',
+    'value' => old('episode_number'),
+    'required' => 'required',
+    'type' => 'number',
+]) ?>
 
-<div class="flex flex-col mb-4">
-    <label for="author_email"><?= lang('Podcast.form.author_email') ?></label>
-    <input type="email" class="form-input" id="author_email" name="author_email" value="<?= old(
-        'author_email'
-    ) ?>" />
-</div>
+<label class="inline-flex items-center mb-4">
+    <?= form_checkbox(
+        ['id' => 'explicit', 'name' => 'explicit', 'class' => 'form-checkbox'],
+        'yes',
+        old('explicit', false)
+    ) ?>
+    <span class="ml-2"><?= lang('Episode.form.explicit') ?></span>
+</label>
 
-<fieldset class="flex flex-col mb-4">
+<?= form_fieldset('', ['class' => 'flex flex-col mb-4']) ?>
     <legend><?= lang('Episode.form.type.label') ?></legend>
     <label for="full" class="inline-flex items-center">
-        <input type="radio" class="form-radio" value="full" id="full" name="type" required <?php if (
-            !old('type') ||
-            old('type') == 'full'
-        ): ?> checked <?php endif; ?> />
+        <?= form_radio(
+            ['id' => 'full', 'name' => 'type', 'class' => 'form-radio'],
+            'full',
+            old('type') ? old('type') == 'full' : true
+        ) ?>
         <span class="ml-2"><?= lang('Episode.form.type.full') ?></span>
     </label>
     <label for="trailer" class="inline-flex items-center">
-        <input type="radio" class="form-radio" value="trailer" id="trailer" name="type" required <?php if (
-            old('type') == 'trailer'
-        ): ?> checked <?php endif; ?> />
+        <?= form_radio(
+            ['id' => 'trailer', 'name' => 'type', 'class' => 'form-radio'],
+            'trailer',
+            old('type') ? old('type') == 'trailer' : false
+        ) ?>
         <span class="ml-2"><?= lang('Episode.form.type.trailer') ?></span>
     </label>
     <label for="bonus" class="inline-flex items-center">
-        <input type="radio" class="form-radio" value="bonus" id="bonus" name="type" required <?php if (
-            old('type') == 'bonus'
-        ): ?> checked <?php endif; ?> />
+        <?= form_radio(
+            ['id' => 'bonus', 'name' => 'type', 'class' => 'form-radio'],
+            'bonus',
+            old('type') ? old('type') == 'bonus' : false
+        ) ?>
         <span class="ml-2"><?= lang('Episode.form.type.bonus') ?></span>
     </label>
-</fieldset>
-
-<div class="inline-flex items-center mb-4">
-    <input type="checkbox" id="block" name="block" class="form-checkbox" <?php if (
-        old('block')
-    ): ?> checked <?php endif; ?> />
-    <label for="block" class="pl-2"><?= lang('Episode.form.block') ?></label>
-</div>
+<?= form_fieldset_close() ?>
+
+<label class="inline-flex items-center mb-4">
+    <?= form_checkbox(
+        ['id' => 'block', 'name' => 'block', 'class' => 'form-checkbox'],
+        'yes',
+        old('block', false)
+    ) ?>
+    <span class="ml-2"><?= lang('Episode.form.block') ?></span>
+</label>
+
+<?= form_button([
+    'content' => lang('Episode.form.submit_create'),
+    'type' => 'submit',
+    'class' => 'self-end px-4 py-2 bg-gray-200',
+]) ?>
 
-<button type="submit" name="submit" class="self-end px-4 py-2 bg-gray-200"><?= lang(
-    'Episode.form.submit_create'
-) ?></button>
 <?= form_close() ?>
 
 
diff --git a/app/Views/admin/episode/edit.php b/app/Views/admin/episode/edit.php
index 4128b90672..7076114fe3 100644
--- a/app/Views/admin/episode/edit.php
+++ b/app/Views/admin/episode/edit.php
@@ -8,106 +8,173 @@
 <?= $this->section('content') ?>
 
 <?= form_open_multipart(
-    route_to('episode_edit', $episode->podcast->id, $episode->id),
-    [
-        'method' => 'post',
-        'class' => 'flex flex-col max-w-md',
-    ]
+    route_to('episode-edit', $episode->podcast->id, $episode->id),
+    ['method' => 'post', 'class' => 'flex flex-col max-w-md']
 ) ?>
 <?= csrf_field() ?>
 
-<div class="flex flex-col mb-4">
-    <label for="enclosure"><?= lang('Episode.form.file') ?></label>
-    <input type="file" class="form-input" id="enclosure" name="enclosure" accept=".mp3,.m4a" />
+<?= form_label(lang('Episode.form.enclosure'), 'enclosure') ?>
+<?= form_input([
+    'id' => 'enclosure',
+    'name' => 'enclosure',
+    'class' => 'form-input mb-4',
+    'type' => 'file',
+    'accept' => '.mp3,.m4a',
+]) ?>
+
+<?= form_label(lang('Episode.form.title'), 'title') ?>
+<?= form_input([
+    'id' => 'title',
+    'name' => 'title',
+    'class' => 'form-input mb-4',
+    'value' => old('title', $episode->title),
+    'required' => 'required',
+    'data-slugify' => 'title',
+]) ?>
+
+<?= form_label(lang('Episode.form.slug'), 'slug') ?>
+<?= form_input([
+    'id' => 'slug',
+    'name' => 'slug',
+    'class' => 'form-input mb-4',
+    'value' => old('slug', $episode->slug),
+    'required' => 'required',
+    'data-slugify' => 'slug',
+]) ?>
+
+<div class="mb-4">
+    <?= form_label(lang('Episode.form.description'), 'description') ?>
+    <?= form_textarea(
+        [
+            'id' => 'description',
+            'name' => 'description',
+            'class' => 'form-textarea',
+            'required' => 'required',
+        ],
+        old('description', $episode->description, false),
+        'data-editor="markdown"'
+    ) ?>
 </div>
 
-<div class="flex flex-col mb-4">
-    <label for="title"><?= lang('Episode.form.title') ?></label>
-    <input type="text" class="form-input" id="title" name="title" data-slugify="title" value="<?= $episode->title ?>" required />
+<?= 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 mb-4">
-    <label for="slug"><?= lang('Episode.form.slug') ?></label>
-    <input type="text" class="form-input" id="slug" name="slug" data-slugify="slug" value="<?= $episode->slug ?>" required />
+<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>
-
-<div class="flex flex-col mb-4">
-    <label for="description"><?= lang('Episode.form.description') ?></label>
-    <textarea class="form-textarea" id="description" name="description" required data-editor="markdown"><?= $episode->description ?></textarea>
-</div>
-
-<div class="flex flex-col mb-4">
-    <label for="pub_date"><?= lang('Episode.form.pub_date') ?></label>
-    <input type="date" class="form-input" id="pub_date" name="pub_date" value="<?= $episode->pub_date->format(
-        'Y-m-d'
-    ) ?>" />
-</div>
-
-<div class="flex flex-col mb-4">
-    <label for="image"><?= lang('Episode.form.image') ?></label>
-    <input type="file" class="form-input" id="image" name="image" accept=".jpg,.jpeg,.png" />
-</div>
-
-<div class="flex flex-col mb-4">
-    <label for="episode_number"><?= lang(
-        'Episode.form.episode_number'
-    ) ?></label>
-    <input type="number" class="form-input" id="episode_number" name="episode_number" value="<?= $episode->number ?>" required />
-</div>
-
-<div class="flex flex-col mb-4">
-    <label for="season_number"><?= lang('Episode.form.season_number') ?></label>
-    <input type="number" class="form-input" id="season_number" name="season_number" value="<?= $episode->season_number ?>" />
-</div>
-
-<div class="inline-flex items-center mb-4">
-    <input type="checkbox" id="explicit" name="explicit" class="form-checkbox" <?= $episode->explicit
-        ? 'checked'
-        : '' ?> />
-    <label for="explicit" class="pl-2"><?= lang(
-        'Episode.form.explicit'
-    ) ?></label>
-</div>
-
-<div class="flex flex-col mb-4">
-    <label for="author_name"><?= lang('Podcast.form.author_name') ?></label>
-    <input type="text" class="form-input" id="author_name" name="author_name" value="<?= $episode->author_name ?>" />
-</div>
-
-<div class="flex flex-col mb-4">
-    <label for="author_email"><?= lang('Podcast.form.author_email') ?></label>
-    <input type="email" class="form-input" id="author_email" name="author_email" value="<?= $episode->author_email ?>" />
-</div>
-
-<fieldset class="flex flex-col mb-4">
+<?= form_fieldset_close() ?>
+
+<?= form_label(lang('Episode.form.image'), 'image') ?>
+<img src="<?= $episode->image_url ?>" alt="<?= $episode->title ?>" class="object-cover w-32 h-32" />
+<?= form_input([
+    'id' => 'image',
+    'name' => 'image',
+    'class' => 'form-input mb-4',
+    'type' => 'file',
+    'accept' => '.jpg,.jpeg,.png',
+]) ?>
+
+<?= form_label(lang('Episode.form.season_number'), 'season_number') ?>
+<?= form_input([
+    'id' => 'season_number',
+    'name' => 'season_number',
+    'class' => 'form-input mb-4',
+    'value' => old('season_number', $episode->season_number),
+    'type' => 'number',
+]) ?>
+
+<?= form_label(lang('Episode.form.episode_number'), 'episode_number') ?>
+<?= form_input([
+    'id' => 'episode_number',
+    'name' => 'episode_number',
+    'class' => 'form-input mb-4',
+    'value' => old('episode_number', $episode->number),
+    'required' => 'required',
+    'type' => 'number',
+]) ?>
+
+<label class="inline-flex items-center mb-4">
+    <?= form_checkbox(
+        ['id' => 'explicit', 'name' => 'explicit', 'class' => 'form-checkbox'],
+        'yes',
+        old('explicit', $episode->explicit)
+    ) ?>
+    <span class="ml-2"><?= lang('Episode.form.explicit') ?></span>
+</label>
+
+<?= form_fieldset('', ['class' => 'flex flex-col mb-4']) ?>
     <legend><?= lang('Episode.form.type.label') ?></legend>
     <label for="full" class="inline-flex items-center">
-        <input type="radio" class="form-radio" value="full" id="full" name="type" required 
-        <?= $episode->type == 'full' ? 'checked' : '' ?>/>
-        <span class="ml-2"><?= lang('Episode.form.type.full') ?></span>  
+        <?= form_radio(
+            ['id' => 'full', 'name' => 'type', 'class' => 'form-radio'],
+            'full',
+            old('type') ? old('type') == 'full' : $episode->type == 'full'
+        ) ?>
+        <span class="ml-2"><?= lang('Episode.form.type.full') ?></span>
     </label>
     <label for="trailer" class="inline-flex items-center">
-        <input type="radio" class="form-radio" value="trailer" id="trailer" name="type" required
-        <?= $episode->type == 'trailer' ? 'checked' : '' ?>/>
-        <span class="ml-2"><?= lang('Episode.form.type.trailer') ?></span>  
+        <?= form_radio(
+            ['id' => 'trailer', 'name' => 'type', 'class' => 'form-radio'],
+            'trailer',
+            old('type') ? old('type') == 'trailer' : $episode->type == 'trailer'
+        ) ?>
+        <span class="ml-2"><?= lang('Episode.form.type.trailer') ?></span>
     </label>
     <label for="bonus" class="inline-flex items-center">
-        <input type="radio" class="form-radio" value="bonus" id="bonus" name="type" required
-        <?= $episode->type == 'bonus' ? 'checked' : '' ?> />
-        <span class="ml-2"><?= lang('Episode.form.type.bonus') ?></span> 
+        <?= form_radio(
+            ['id' => 'bonus', 'name' => 'type', 'class' => 'form-radio'],
+            'bonus',
+            old('type') ? old('type') == 'bonus' : $episode->type == 'bonus'
+        ) ?>
+        <span class="ml-2"><?= lang('Episode.form.type.bonus') ?></span>
     </label>
-</fieldset>
-
-<div class="inline-flex items-center mb-4">
-    <input type="checkbox" id="block" name="block" class="form-checkbox" <?= $episode->block
-        ? 'checked'
-        : '' ?> />
-    <label for="block" class="pl-2"><?= lang('Episode.form.block') ?></label>
-</div>
+<?= form_fieldset_close() ?>
+
+<label class="inline-flex items-center mb-4">
+    <?= form_checkbox(
+        ['id' => 'block', 'name' => 'block', 'class' => 'form-checkbox'],
+        'yes',
+        old('block', $episode->block)
+    ) ?>
+    <span class="ml-2"><?= lang('Episode.form.block') ?></span>
+</label>
+
+<?= form_button([
+    'content' => lang('Episode.form.submit_edit'),
+    'type' => 'submit',
+    'class' => 'self-end px-4 py-2 bg-gray-200',
+]) ?>
 
-<button type="submit" name="submit" class="self-end px-4 py-2 bg-gray-200"><?= lang(
-    'Episode.form.submit_edit'
-) ?></button>
 <?= form_close() ?>
 
 
diff --git a/app/Views/admin/episode/list.php b/app/Views/admin/episode/list.php
index 59f457c40d..c6cfe92da3 100644
--- a/app/Views/admin/episode/list.php
+++ b/app/Views/admin/episode/list.php
@@ -4,7 +4,7 @@
 
 <?= lang('Episode.all_podcast_episodes') ?> (<?= count($podcast->episodes) ?>)
 <a class="inline-flex items-center px-2 py-1 mb-2 ml-2 text-sm text-white bg-green-500 rounded shadow-xs outline-none hover:bg-green-600 focus:shadow-outline" href="<?= route_to(
-    'episode_create',
+    'episode-create',
     $podcast->id
 ) ?>">
 <?= icon('add', 'mr-2') ?>
diff --git a/app/Views/admin/episode/view.php b/app/Views/admin/episode/view.php
index 90b9a343ae..2b154a6dd5 100644
--- a/app/Views/admin/episode/view.php
+++ b/app/Views/admin/episode/view.php
@@ -13,7 +13,7 @@
 </audio>
 
 <a class="inline-flex px-4 py-2 text-white bg-teal-700 hover:bg-teal-800" href="<?= route_to(
-    'episode_edit',
+    'episode-edit',
     $episode->podcast->id,
     $episode->id
 ) ?>"><?= lang('Episode.edit') ?></a>
@@ -25,7 +25,7 @@
     'Episode.go_to_page'
 ) ?></a>
     <a href="<?= route_to(
-        'episode_delete',
+        'episode-delete',
         $episode->podcast->id,
         $episode->id
     ) ?>" class="inline-flex px-4 py-2 text-white bg-red-700 hover:bg-red-800"><?= lang(
diff --git a/app/Views/admin/my_account/change_password.php b/app/Views/admin/my_account/change_password.php
index 619085d826..285f058e6e 100644
--- a/app/Views/admin/my_account/change_password.php
+++ b/app/Views/admin/my_account/change_password.php
@@ -7,28 +7,37 @@
 
 <?= $this->section('content') ?>
 
-<form action="<?= route_to(
-    'myAccount_changePassword'
-) ?>" method="post" class="flex flex-col max-w-lg">
-    <?= csrf_field() ?>
-
-    <input type="hidden" name="email" value="<?= user()->email ?>">
-
-    <label for="password"><?= lang('User.form.password') ?></label>
-    <input type="password" name="password" class="mb-4 form-input" id="password" autocomplete="off">
-
-    <label for="new_password"><?= lang('User.form.new_password') ?></label>
-    <input type="password" name="new_password" class="mb-4 form-input" id="new_password" autocomplete="off">
-
-    <label for="pass_confirm"><?= lang(
-        'User.form.repeat_new_password'
-    ) ?></label>
-    <input type="password" name="new_pass_confirm" class="mb-6 form-input" id="new_pass_confirm" autocomplete="off">
-
-    <button type="submit" class="px-4 py-2 ml-auto border">
-        <?= lang('User.form.submit_edit') ?>
-    </button>
-</form>
+<?= form_open(route_to('change-password'), [
+    'class' => 'flex flex-col max-w-sm',
+]) ?>
+<?= csrf_field() ?>
+
+<?= form_label(lang('User.form.password'), 'password') ?>
+<?= form_input([
+    'id' => 'password',
+    'name' => 'password',
+    'class' => 'form-input mb-4',
+    'required' => 'required',
+    'type' => 'password',
+]) ?>
+
+<?= form_label(lang('User.form.new_password'), 'new_password') ?>
+<?= form_input([
+    'id' => 'new_password',
+    'name' => 'new_password',
+    'class' => 'form-input mb-4',
+    'required' => 'required',
+    'type' => 'password',
+    'autocomplete' => 'new-password',
+]) ?>
+
+<?= form_button([
+    'content' => lang('User.form.submit_password_change'),
+    'type' => 'submit',
+    'class' => 'self-end px-4 py-2 bg-gray-200',
+]) ?>
+
+<?= form_close() ?>
 
 <?= $this->endSection()
 ?>
diff --git a/app/Views/admin/podcast/create.php b/app/Views/admin/podcast/create.php
index 299ab325c6..498199051f 100644
--- a/app/Views/admin/podcast/create.php
+++ b/app/Views/admin/podcast/create.php
@@ -7,169 +7,186 @@
 
 <?= $this->section('content') ?>
 
-<?= form_open_multipart(route_to('podcast_create'), [
+<?= form_open_multipart(route_to('podcast-create'), [
     'method' => 'post',
     'class' => 'flex flex-col max-w-md',
 ]) ?>
 <?= csrf_field() ?>
 
-<div class="flex flex-col mb-4">
-    <label for="title"><?= lang('Podcast.form.title') ?></label>
-    <input type="text" class="form-input" id="title" name="title" value="<?= old(
-        'title'
-    ) ?>" required />
-</div>
-
-<div class="flex flex-col mb-4">
-    <label for="name"><?= lang('Podcast.form.name') ?></label>
-    <input type="text" class="form-input" id="name" name="name" value="<?= old(
-        'name'
-    ) ?>" required />
-</div>
+<?= form_label(lang('Podcast.form.title'), 'title') ?>
+<?= form_input([
+    'id' => 'title',
+    'name' => 'title',
+    'class' => 'form-input mb-4',
+    'value' => old('title'),
+    'required' => 'required',
+]) ?>
 
-<div class="flex flex-col mb-4">
-    <label for="description"><?= lang('Podcast.form.description') ?></label>
-    <textarea class="form-textarea" id="description" name="description" required data-editor="markdown"><?= old(
-        'description'
-    ) ?></textarea>
-</div>
+<?= form_label(lang('Podcast.form.name'), 'name') ?>
+<?= form_input([
+    'id' => 'name',
+    'name' => 'name',
+    'class' => 'form-input mb-4',
+    'value' => old('name'),
+    'required' => 'required',
+]) ?>
 
-<div class="flex flex-col mb-4">
-    <label for="episode_description_footer"><?= lang(
-        'Podcast.form.episode_description_footer'
-    ) ?></label>
-    <textarea class="form-textarea" id="episode_description_footer" name="episode_description_footer" data-editor="markdown"><?= old(
+<div class="mb-4">
+    <?= form_label(lang('Podcast.form.description'), 'description') ?>
+    <?= form_textarea(
+        [
+            'id' => 'description',
+            'name' => 'description',
+            'class' => 'form-textarea',
+            'required' => 'required',
+        ],
+        old('description', '', false),
+        'data-editor="markdown"'
+    ) ?>
+</div>
+
+<div class="mb-4">
+    <?= form_label(
+        lang('Podcast.form.episode_description_footer'),
         'episode_description_footer'
-    ) ?></textarea>
-</div>
-
-<div class="flex flex-col mb-4">
-    <label for="image"><?= lang('Podcast.form.image') ?></label>
-    <input type="file" class="form-input" id="image" name="image" required />
-</div>
-
-<div class="flex flex-col mb-4">
-    <label for="language"><?= lang('Podcast.form.language') ?></label>
-    <select id="language" name="language" autocomplete="off" class="form-select" required>
-        <?php foreach ($languages as $language): ?>
-            <option value="<?= $language->code ?>"
-            <?php if (
-                old('language') == $language->code
-            ): ?> selected <?php endif; ?>
-            <?php if (
-                !old('language') &&
-                $language->code == $browserLang
-            ): ?> selected <?php endif; ?>
-                >
-                <?= $language->native_name ?>
-            </option>
-        <?php endforeach; ?>
-    </select>
-</div>
-
-<div class="flex flex-col mb-4">
-    <label for="category"><?= lang('Podcast.form.category') ?></label>
-    <select id="category" name="category" class="form-select" required>
-        <?php foreach ($categories as $category): ?>
-            <option value="<?= $category->code ?>"
-            <?php if (
-                old('category') == $category->code
-            ): ?> selected <?php endif; ?>
-            ><?= lang('Podcast.category_options.' . $category->code) ?>
-            </option>
-        <?php endforeach; ?>
-    </select>
-</div>
+    ) ?>
+    <?= form_textarea(
+        [
+            'id' => 'episode_description_footer',
+            'name' => 'episode_description_footer',
+            'class' => 'form-textarea',
+        ],
+        old('episode_description_footer', '', false),
+        'data-editor="markdown"'
+    ) ?>
+</div>
+
+<?= form_label(lang('Podcast.form.image'), 'image') ?>
+<?= form_input([
+    'id' => 'image',
+    'name' => 'image',
+    'class' => 'form-input mb-4',
+    'required' => 'required',
+    'type' => 'file',
+    'accept' => '.jpg,.jpeg,.png',
+]) ?>
 
-<div class="inline-flex items-center mb-4">
-    <input type="checkbox" id="explicit" name="explicit" class="form-checkbox" <?php if (
-        old('explicit')
-    ): ?> checked <?php endif; ?> />
-    <label for="explicit" class="pl-2"><?= lang(
-        'Podcast.form.explicit'
-    ) ?></label>
-</div>
+<?= form_label(lang('Podcast.form.language'), 'language') ?>
+<?= form_dropdown('language', $languageOptions, old('language', $browserLang), [
+    'id' => 'language',
+    'class' => 'form-select mb-4',
+    'required' => 'required',
+]) ?>
 
-<div class="flex flex-col mb-4">
-    <label for="author_name"><?= lang('Podcast.form.author_name') ?></label>
-    <input type="text" class="form-input" id="author_name" name="author_name" value="<?= old(
-        'author_name'
-    ) ?>" />
-</div>
+<?= form_label(lang('Podcast.form.category'), 'category') ?>
+<?= form_dropdown('category', $categoryOptions, old('category'), [
+    'id' => 'category',
+    'class' => 'form-select mb-4',
+    'required' => 'required',
+]) ?>
 
-<div class="flex flex-col mb-4">
-    <label for="author_email"><?= lang('Podcast.form.author_email') ?></label>
-    <input type="email" class="form-input" id="author_email" name="author_email" value="<?= old(
-        'author_email'
-    ) ?>" />
-</div>
+<label class="inline-flex items-center mb-4">
+    <?= form_checkbox(
+        ['id' => 'explicit', 'name' => 'explicit', 'class' => 'form-checkbox'],
+        'yes',
+        old('explicit', false)
+    ) ?>
+    <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'),
+]) ?>
 
-<div class="flex flex-col mb-4">
-    <label for="owner_name"><?= lang('Podcast.form.owner_name') ?></label>
-    <input type="text" class="form-input" id="owner_name" name="owner_name" value="<?= old(
-        'owner_name'
-    ) ?>" />
-</div>
+<?= 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'),
+]) ?>
 
-<div class="flex flex-col mb-4">
-    <label for="owner_email"><?= lang('Podcast.form.owner_email') ?></label>
-    <input type="email" class="form-input" id="owner_email" name="owner_email" value="<?= old(
-        'owner_email'
-    ) ?>" required />
-</div>
+<?= form_label(lang('Podcast.form.owner_email'), 'owner_email') ?>
+<?= form_input([
+    'id' => 'owner_email',
+    'name' => 'owner_email',
+    'class' => 'form-input mb-4',
+    'value' => old('owner_email'),
+    'type' => 'email',
+    'required' => 'required',
+]) ?>
 
-<fieldset class="flex flex-col mb-4">
+<?= form_fieldset('', [
+    'class' => 'flex flex-col mb-4',
+]) ?>
     <legend><?= lang('Podcast.form.type.label') ?></legend>
     <label for="episodic" class="inline-flex items-center">
-        <input type="radio" class="form-radio" value="episodic" id="episodic" name="type" required <?php if (
-            !old('type') ||
-            old('type') == 'episodic'
-        ): ?> checked <?php endif; ?> />
+        <?= form_radio(
+            ['id' => 'episodic', 'name' => 'type', 'class' => 'form-radio'],
+            'episodic',
+            old('type') ? old('type') == 'episodic' : true
+        ) ?>
         <span class="ml-2"><?= lang('Podcast.form.type.episodic') ?></span>
     </label>
     <label for="serial" class="inline-flex items-center">
-        <input type="radio" class="form-radio" value="serial" id="serial" name="type" required <?php if (
-            old('type') == 'serial'
-        ): ?> checked <?php endif; ?>  />
+        <?= form_radio(
+            ['id' => 'serial', 'name' => 'type', 'class' => 'form-radio'],
+            'serial',
+            old('type') ? old('type') == 'serial' : false
+        ) ?>
         <span class="ml-2"><?= lang('Podcast.form.type.serial') ?></span>
     </label>
-</fieldset>
-
-<div class="flex flex-col mb-4">
-    <label for="copyright"><?= lang('Podcast.form.copyright') ?></label>
-    <input type="text" class="form-input" id="copyright" name="copyright" value="<?= old(
-        'copyright'
-    ) ?>" />
-</div>
-
-<div class="inline-flex items-center mb-4">
-    <input type="checkbox" id="block" name="block" class="form-checkbox" <?php if (
-        old('block')
-    ): ?> checked <?php endif; ?> />
-    <label for="block" class="pl-2"><?= lang('Podcast.form.block') ?></label>
-</div>
-
-<div class="inline-flex items-center mb-4">
-    <input type="checkbox" id="complete" name="complete" class="form-checkbox" <?php if (
-        old('complete')
-    ): ?> checked <?php endif; ?> />
-    <label for="complete" class="pl-2"><?= lang(
-        'Podcast.form.complete'
-    ) ?></label>
-</div>
+<?= form_fieldset_close() ?>
+
+<?= form_label(lang('Podcast.form.copyright'), 'copyright') ?>
+<?= form_input([
+    'id' => 'copyright',
+    'name' => 'copyright',
+    'class' => 'form-input mb-4',
+    'value' => old('copyright'),
+]) ?>
 
-<div class="flex flex-col mb-4">
-    <label for="custom_html_head"><?= esc(
-        lang('Podcast.form.custom_html_head')
-    ) ?></label>
-    <textarea class="form-textarea" id="custom_html_head" name="custom_html_head" data-editor="html"><?= old(
-        'custom_html_head'
-    ) ?></textarea>
-</div>
+<label class="inline-flex items-center mb-4">
+    <?= form_checkbox(
+        ['id' => 'block', 'name' => 'block', 'class' => 'form-checkbox'],
+        'yes',
+        old('block', false)
+    ) ?>
+    <span class="ml-2"><?= lang('Podcast.form.block') ?></span>
+</label>
+
+<label class="inline-flex items-center mb-4">
+    <?= form_checkbox(
+        ['id' => 'complete', 'name' => 'complete', 'class' => 'form-checkbox'],
+        'yes',
+        old('complete', false)
+    ) ?>
+    <span class="ml-2"><?= lang('Podcast.form.complete') ?></span>
+</label>
+
+<div class="mb-4">
+    <?= form_label(lang('Podcast.form.custom_html_head'), 'custom_html_head') ?>
+    <?= form_textarea(
+        [
+            'id' => 'custom_html_head',
+            'name' => 'custom_html_head',
+            'class' => 'form-textarea',
+        ],
+        old('custom_html_head', '', false),
+        'data-editor="html"'
+    ) ?>
+</div>
+
+<?= form_button([
+    'content' => lang('Podcast.form.submit_create'),
+    'type' => 'submit',
+    'class' => 'self-end px-4 py-2 bg-gray-200',
+]) ?>
 
-<button type="submit" name="submit" class="self-end px-4 py-2 bg-gray-200"><?= lang(
-    'Podcast.form.submit_create'
-) ?></button>
 <?= form_close() ?>
 
 
diff --git a/app/Views/admin/podcast/edit.php b/app/Views/admin/podcast/edit.php
index 434937feed..b40f82fd37 100644
--- a/app/Views/admin/podcast/edit.php
+++ b/app/Views/admin/podcast/edit.php
@@ -7,139 +7,201 @@
 
 <?= $this->section('content') ?>
 
-<?= form_open_multipart(route_to('podcast_edit', $podcast->id), [
+<?= form_open_multipart(route_to('podcast-edit', $podcast->id), [
     'method' => 'post',
     'class' => 'flex flex-col max-w-md',
 ]) ?>
 <?= csrf_field() ?>
 
-<div class="flex flex-col mb-4">
-    <label for="title"><?= lang('Podcast.form.title') ?></label>
-    <input type="text" class="form-input" id="title" name="title" value="<?= $podcast->title ?>" required />
-</div>
-
-<div class="flex flex-col mb-4">
-    <label for="name"><?= lang('Podcast.form.name') ?></label>
-    <input type="text" class="form-input" id="name" name="name" value="<?= $podcast->name ?>" required />
-</div>
-
-<div class="flex flex-col mb-4">
-    <label for="description"><?= lang('Podcast.form.description') ?></label>
-    <textarea class="form-textarea" id="description" name="description" required data-editor="markdown"><?= $podcast->description ?></textarea>
-</div>
 
-<div class="flex flex-col mb-4">
-    <label for="episode_description_footer"><?= lang(
-        'Podcast.form.episode_description_footer'
-    ) ?></label>
-    <textarea class="form-textarea" id="episode_description_footer" name="episode_description_footer" data-editor="markdown"><?= $podcast->episode_description_footer ?></textarea>
-</div>
+<?= form_label(lang('Podcast.form.title'), 'title') ?>
+<?= form_input([
+    'id' => 'title',
+    'name' => 'title',
+    'class' => 'form-input mb-4',
+    'value' => old('title', $podcast->title),
+    'required' => 'required',
+]) ?>
 
-<div class="flex flex-col mb-4">
-    <label for="image"><?= lang('Podcast.form.image') ?></label>
-    <input type="file" class="form-input" id="image" name="image" />
-    <img src="<?= $podcast->image_url ?>" alt="<?= $podcast->title ?>" class="object-cover w-32 h-32" />
-</div>
+<?= form_label(lang('Podcast.form.name'), 'name') ?>
+<?= form_input([
+    'id' => 'name',
+    'name' => 'name',
+    'class' => 'form-input mb-4',
+    'value' => old('name', $podcast->name),
+    'required' => 'required',
+]) ?>
 
-<div class="flex flex-col mb-4">
-    <label for="language"><?= lang('Podcast.form.language') ?></label>
-    <select id="language" name="language" autocomplete="off" class="form-select" required>
-        <?php foreach ($languages as $language): ?>
-            <option <?= $podcast->language == $language->code
-                ? "selected='selected'"
-                : '' ?> value="<?= $language->code ?>">
-                <?= $language->native_name ?>
-            </option>
-        <?php endforeach; ?>
-    </select>
-</div>
+<div class="mb-4">
+    <?= form_label(lang('Podcast.form.description'), 'description') ?>
+    <?= form_textarea(
+        [
+            'id' => 'description',
+            'name' => 'description',
+            'class' => 'form-textarea',
+            'required' => 'required',
+        ],
+        old('description', $podcast->description, false),
+        'data-editor="markdown"'
+    ) ?>
+</div>
+
+<div class="mb-4">
+    <?= form_label(
+        lang('Podcast.form.episode_description_footer'),
+        'episode_description_footer'
+    ) ?>
+    <?= form_textarea(
+        [
+            'id' => 'episode_description_footer',
+            'name' => 'episode_description_footer',
+            'class' => 'form-textarea',
+        ],
+        old(
+            'episode_description_footer',
+            $podcast->episode_description_footer,
+            false
+        ),
+        'data-editor="markdown"'
+    ) ?>
+</div>
+
+<?= form_label(lang('Podcast.form.image'), 'image') ?>
+<img src="<?= $podcast->image_url ?>" alt="<?= $podcast->title ?>" class="object-cover w-32 h-32" />
+<?= form_input([
+    'id' => 'image',
+    'name' => 'image',
+    'class' => 'form-input mb-4',
+    'type' => 'file',
+    'accept' => '.jpg,.jpeg,.png',
+]) ?>
 
-<div class="flex flex-col mb-4">
-    <label for="category"><?= lang('Podcast.form.category') ?></label>
-    <select id="category" name="category" class="form-select" required>
-        <?php foreach ($categories as $category): ?>
-            <option <?= $podcast->category == $category->code
-                ? "selected='selected'"
-                : '' ?> value="<?= $category->code ?>"><?= lang(
-    'Podcast.category_options.' . $category->code
+<?= form_label(lang('Podcast.form.language'), 'language') ?>
+<?= form_dropdown(
+    'language',
+    $languageOptions,
+    old('language', $podcast->language),
+    [
+        'id' => 'language',
+        'class' => 'form-select mb-4',
+        'required' => 'required',
+    ]
 ) ?>
-            </option>
-        <?php endforeach; ?>
-    </select>
-</div>
 
-<div class="inline-flex items-center mb-4">
-    <input type="checkbox" id="explicit" name="explicit" class="form-checkbox" <?= $podcast->explicit
-        ? 'checked'
-        : '' ?> />
-    <label for="explicit" class="pl-2"><?= lang(
-        'Podcast.form.explicit'
-    ) ?></label>
-</div>
-
-<div class="flex flex-col mb-4">
-    <label for="author_name"><?= lang('Podcast.form.author_name') ?></label>
-    <input type="text" class="form-input" id="author_name" name="author_name" value="<?= $podcast->author_name ?>" />
-</div>
+<?= form_label(lang('Podcast.form.category'), 'category') ?>
+<?= form_dropdown(
+    'category',
+    $categoryOptions,
+    old('category', $podcast->category),
+    [
+        'id' => 'category',
+        'class' => 'form-select mb-4',
+        'required' => 'required',
+    ]
+) ?>
 
-<div class="flex flex-col mb-4">
-    <label for="author_email"><?= lang('Podcast.form.author_email') ?></label>
-    <input type="email" class="form-input" id="author_email" name="author_email" value="<?= $podcast->author_email ?>" />
-</div>
+<label class="inline-flex items-center mb-4">
+    <?= form_checkbox(
+        ['id' => 'explicit', 'name' => 'explicit', 'class' => 'form-checkbox'],
+        'yes',
+        old('explicit', $podcast->explicit)
+    ) ?>
+    <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),
+]) ?>
 
-<div class="flex flex-col mb-4">
-    <label for="owner_name"><?= lang('Podcast.form.owner_name') ?></label>
-    <input type="text" class="form-input" id="owner_name" name="owner_name" value="<?= $podcast->owner_name ?>" />
-</div>
+<?= 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),
+]) ?>
 
-<div class="flex flex-col mb-4">
-    <label for="owner_email"><?= lang('Podcast.form.owner_email') ?></label>
-    <input type="email" class="form-input" id="owner_email" name="owner_email" value="<?= $podcast->owner_email ?>" required />
-</div>
+<?= form_label(lang('Podcast.form.owner_email'), 'owner_email') ?>
+<?= form_input([
+    'id' => 'owner_email',
+    'name' => 'owner_email',
+    'class' => 'form-input mb-4',
+    'value' => old('owner_email', $podcast->owner_email),
+    'type' => 'email',
+    'required' => 'required',
+]) ?>
 
-<fieldset class="flex flex-col mb-4">
+<?= form_fieldset('', ['class' => 'flex flex-col mb-4']) ?>
     <legend><?= lang('Podcast.form.type.label') ?></legend>
     <label for="episodic" class="inline-flex items-center">
-        <input type="radio" class="form-radio" value="episodic" id="episodic" name="type" required
-        <?= $podcast->type == 'episodic' ? 'checked' : '' ?> />
-        <span class="ml-2"><?= lang('Podcast.form.type.episodic') ?></span>  
+        <?= form_radio(
+            ['id' => 'episodic', 'name' => 'type', 'class' => 'form-radio'],
+            'episodic',
+            old('type')
+                ? old('type') == 'episodic'
+                : $podcast->type == 'episodic'
+        ) ?>
+        <span class="ml-2"><?= lang('Podcast.form.type.episodic') ?></span>
     </label>
     <label for="serial" class="inline-flex items-center">
-        <input type="radio" class="form-radio" value="serial" id="serial" name="type" required
-        <?= $podcast->type == 'serial' ? 'checked' : '' ?>/>
-        <span class="ml-2"><?= lang('Podcast.form.type.serial') ?></span>  
+        <?= form_radio(
+            ['id' => 'serial', 'name' => 'type', 'class' => 'form-radio'],
+            'serial',
+            old('type') ? old('type') == 'serial' : $podcast->type == 'serial'
+        ) ?>
+        <span class="ml-2"><?= lang('Podcast.form.type.serial') ?></span>
     </label>
-</fieldset>
-
-<div class="flex flex-col mb-4">
-    <label for="copyright"><?= lang('Podcast.form.copyright') ?></label>
-    <input type="text" class="form-input" id="copyright" name="copyright" value="<?= $podcast->copyright ?>" />
-</div>
-
-<div class="inline-flex items-center mb-4">
-    <input type="checkbox" id="block" name="block" class="form-checkbox"
-    <?= $podcast->block ? 'checked' : '' ?> />
-    <label for="block" class="pl-2"><?= lang('Podcast.form.block') ?></label>
-</div>
-
-<div class="inline-flex items-center mb-4">
-    <input type="checkbox" id="complete" name="complete" class="form-checkbox"
-    <?= $podcast->complete ? 'checked' : '' ?> />
-    <label for="complete" class="pl-2"><?= lang(
-        'Podcast.form.complete'
-    ) ?></label>
-</div>
+<?= form_fieldset_close() ?>
+
+<?= form_label(lang('Podcast.form.copyright'), 'copyright') ?>
+<?= form_input([
+    'id' => 'copyright',
+    'name' => 'copyright',
+    'class' => 'form-input mb-4',
+    'value' => old('copyright', $podcast->copyright),
+]) ?>
 
-<div class="flex flex-col mb-4">
-    <label for="custom_html_head"><?= esc(
-        lang('Podcast.form.custom_html_head')
-    ) ?></label>
-    <textarea class="form-textarea" id="custom_html_head" name="custom_html_head" data-editor="html"><?= $podcast->custom_html_head ?></textarea>
-</div>
+<label class="inline-flex items-center mb-4">
+    <?= form_checkbox(
+        ['id' => 'block', 'name' => 'block', 'class' => 'form-checkbox'],
+        'yes',
+        old('block', $podcast->block)
+    ) ?>
+    <span class="ml-2"><?= lang('Podcast.form.block') ?></span>
+</label>
+
+<label class="inline-flex items-center mb-4">
+    <?= form_checkbox(
+        ['id' => 'complete', 'name' => 'complete', 'class' => 'form-checkbox'],
+        'yes',
+        old('complete', $podcast->complete)
+    ) ?>
+    <span class="ml-2"><?= lang('Podcast.form.complete') ?></span>
+</label>
+
+<div class="mb-4">
+    <?= form_label(lang('Podcast.form.custom_html_head'), 'custom_html_head') ?>
+    <?= form_textarea(
+        [
+            'id' => 'custom_html_head',
+            'name' => 'custom_html_head',
+            'class' => 'form-textarea',
+        ],
+        old('custom_html_head', $podcast->custom_html_head, false),
+        'data-editor="html"'
+    ) ?>
+</div>
+
+<?= form_button([
+    'content' => lang('Podcast.form.submit_edit'),
+    'type' => 'submit',
+    'class' => 'self-end px-4 py-2 bg-gray-200',
+]) ?>
 
-<button type="submit" name="submit" class="self-end px-4 py-2 bg-gray-200"><?= lang(
-    'Podcast.form.submit_edit'
-) ?></button>
 <?= form_close() ?>
 
 
diff --git a/app/Views/admin/podcast/list.php b/app/Views/admin/podcast/list.php
index 2efbe4d931..83768a7cbe 100644
--- a/app/Views/admin/podcast/list.php
+++ b/app/Views/admin/podcast/list.php
@@ -3,7 +3,7 @@
 <?= $this->section('title') ?>
 <?= lang('Podcast.all_podcasts') ?> (<?= count($podcasts) ?>)
 <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(
-    'podcast_create'
+    'podcast-create'
 ) ?>">
 <?= icon('add', 'mr-2') ?>
 <?= lang('Podcast.create') ?></a>
diff --git a/app/Views/admin/podcast/view.php b/app/Views/admin/podcast/view.php
index 3f21185e41..013e22aa82 100644
--- a/app/Views/admin/podcast/view.php
+++ b/app/Views/admin/podcast/view.php
@@ -3,14 +3,14 @@
 <?= $this->section('title') ?>
 <?= $podcast->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(
-    'podcast_edit',
+    'podcast-edit',
     $podcast->id
 ) ?>">
 <?= icon('edit', 'mr-2') ?>
 <?= lang('Podcast.edit') ?>
 </a>
 <a class="inline-flex items-center px-2 py-1 mb-2 ml-2 text-sm text-white bg-green-500 rounded shadow-xs outline-none hover:bg-green-600 focus:shadow-outline" href="<?= route_to(
-    'episode_create',
+    'episode-create',
     $podcast->id
 ) ?>">
 <?= icon('add', 'mr-2') ?>
@@ -20,7 +20,7 @@
 <?= $this->section('content') ?>
     <img class="w-64 mb-4" src="<?= $podcast->image_url ?>" alt="<?= $podcast->title ?>" />
     <a class="inline-flex px-2 py-1 mb-2 text-white bg-yellow-700 hover:bg-yellow-800" href="<?= route_to(
-        'contributor_list',
+        'contributor-list',
         $podcast->id
     ) ?>"><?= lang('Podcast.see_contributors') ?></a>
     <a class="inline-flex px-2 py-1 text-white bg-gray-700 hover:bg-gray-800" href="<?= route_to(
@@ -28,7 +28,7 @@
         $podcast->name
     ) ?>"><?= lang('Podcast.go_to_page') ?></a>
     <a class="inline-flex px-2 py-1 text-white bg-red-700 hover:bg-red-800" href="<?= route_to(
-        'podcast_delete',
+        'podcast-delete',
         $podcast->id
     ) ?>"><?= lang('Podcast.delete') ?></a>
 
diff --git a/app/Views/admin/user/create.php b/app/Views/admin/user/create.php
index 865521ce52..9b860c1bf5 100644
--- a/app/Views/admin/user/create.php
+++ b/app/Views/admin/user/create.php
@@ -7,31 +7,44 @@
 
 <?= $this->section('content') ?>
 
-<form action="<?= route_to(
-    'user_create'
-) ?>" method="post" class="flex flex-col max-w-lg">
-    <?= csrf_field() ?>
-
-    <label for="email"><?= lang('User.form.email') ?></label>
-    <input type="email" class="mb-4 form-input" name="email" id="email" value="<?= old(
-        'email'
-    ) ?>">
-
-    <label for="username"><?= lang('User.form.username') ?></label>
-    <input type="text" class="mb-4 form-input" name="username" id="username" value="<?= old(
-        'username'
-    ) ?>">
-
-    <label for="password"><?= lang('User.form.password') ?></label>
-    <input type="password" name="password" class="mb-4 form-input" id="password" autocomplete="off">
-
-    <label for="pass_confirm"><?= lang('User.form.repeat_password') ?></label>
-    <input type="password" name="pass_confirm" class="mb-6 form-input" id="pass_confirm" autocomplete="off">
-
-    <button type="submit" class="px-4 py-2 ml-auto border">
-        <?= lang('User.form.submit_create') ?>
-    </button>
-</form>
+<?= form_open(route_to('user-create'), [
+    'class' => 'flex flex-col max-w-sm',
+]) ?>
+<?= csrf_field() ?>
+
+<?= form_label(lang('User.form.email'), 'email') ?>
+<?= form_input([
+    'id' => 'email',
+    'name' => 'email',
+    'class' => 'form-input mb-4',
+    'value' => old('email'),
+    'type' => 'email',
+]) ?>
+
+<?= form_label(lang('User.form.username'), 'username') ?>
+<?= form_input([
+    'id' => 'username',
+    'name' => 'username',
+    'class' => 'form-input mb-4',
+    'value' => old('username'),
+]) ?>
+
+<?= form_label(lang('User.form.password'), 'password') ?>
+<?= form_input([
+    'id' => 'password',
+    'name' => 'password',
+    'class' => 'form-input mb-4',
+    'type' => 'password',
+    'autocomplete' => 'new-password',
+]) ?>
+
+<?= form_button([
+    'content' => lang('User.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/user/edit.php b/app/Views/admin/user/edit.php
index dc35946902..ef9fc1f287 100644
--- a/app/Views/admin/user/edit.php
+++ b/app/Views/admin/user/edit.php
@@ -7,27 +7,23 @@
 
 <?= $this->section('content') ?>
 
-<form action="<?= route_to(
-    'user_edit',
-    $user->id
-) ?>" method="post" class="flex flex-col max-w-lg">
-    <?= csrf_field() ?>
-
-    <label for="roles"><?= lang('User.form.roles') ?></label>
-    <select id="roles" name="roles[]" autocomplete="off" class="mb-6 form-multiselect" multiple>
-        <?php foreach ($roles as $role): ?>
-            <option value="<?= $role->id ?>"
-            <?php if (
-                in_array($role->name, $user->roles)
-            ): ?> selected <?php endif; ?>>
-                <?= $role->name ?>
-            </option>
-        <?php endforeach; ?>
-    </select>
-
-    <button type="submit" class="px-4 py-2 ml-auto border">
-        <?= lang('User.form.submit_edit') ?>
-    </button>
-</form>
+<?= form_open(route_to('user-edit', $user->id), [
+    'class' => 'flex flex-col max-w-sm',
+]) ?>
+<?= csrf_field() ?>
+
+<?= form_label(lang('User.form.roles'), 'roles') ?>
+<?= form_multiselect('roles[]', $roleOptions, $user->roles, [
+    'id' => 'roles',
+    'class' => 'form-multiselect mb-4',
+]) ?>
+
+<?= form_button([
+    'content' => lang('User.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/user/list.php b/app/Views/admin/user/list.php
index a8d096358b..f47b3b2220 100644
--- a/app/Views/admin/user/list.php
+++ b/app/Views/admin/user/list.php
@@ -25,7 +25,7 @@
             <td class="px-4 py-2 border">
                 [<?= implode(', ', $user->roles) ?>]
                 <a class="inline-flex p-2 mr-2 text-teal-700 bg-teal-100 rounded-full shadow-xs hover:bg-teal-200" href="<?= route_to(
-                    'user_edit',
+                    'user-edit',
                     $user->id
                 ) ?>" data-toggle="tooltip" data-placement="bottom"
                 title="<?= lang('User.edit_roles', [
@@ -39,18 +39,18 @@
                 : 'No' ?></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(
-                    'user_force_pass_reset',
+                    'user-force_pass_reset',
                     $user->id
                 ) ?>"><?= lang('User.forcePassReset') ?></a>
                 <a class="inline-flex px-2 py-1 mb-2 text-sm text-white bg-orange-700 hover:bg-orange-800" href="<?= route_to(
-                    $user->isBanned() ? 'user_unban' : 'user_ban',
+                    $user->isBanned() ? 'user-unban' : 'user-ban',
                     $user->id
                 ) ?>">
                 <?= $user->isBanned()
                     ? lang('User.unban')
                     : lang('User.ban') ?></a>
                 <a class="inline-flex px-2 py-1 text-sm text-white bg-red-700 hover:bg-red-800" href="<?= route_to(
-                    'user_delete',
+                    'user-delete',
                     $user->id
                 ) ?>"><?= lang('User.delete') ?></a>
             </td>
diff --git a/app/Views/auth/_layout.php b/app/Views/auth/_layout.php
index c0cae49f71..5f9c8de38f 100644
--- a/app/Views/auth/_layout.php
+++ b/app/Views/auth/_layout.php
@@ -1,3 +1,4 @@
+<?= helper('svg') ?>
 <!DOCTYPE html>
 <html lang="en">
 
@@ -12,11 +13,15 @@
 
 <body class="flex flex-col items-center justify-center min-h-screen mx-auto bg-gray-100">
 	<header class="mb-4">
-		<a href="<?= route_to('home') ?>" class="text-2xl"><?= $this->renderSection(
-    'title'
-) ?></a>
+		<a href="<?= route_to('home') ?>" class="inline-flex items-center">
+			<?= svg(
+       'logo-castopod',
+       'text-3xl mr-2'
+   ) ?><span class="text-xl">Castopod</span>
+		</a>
 	</header>
 	<main class="w-full max-w-md px-6 py-4 mx-auto bg-white rounded-lg shadow">
+		<h1 class="mb-2 text-2xl text-center"><?= $this->renderSection('title') ?></h1>
 		<?= view('_message_block') ?>
 		<?= $this->renderSection('content') ?>
 	</main>
diff --git a/app/Views/auth/forgot.php b/app/Views/auth/forgot.php
index cef9a08805..7a742021a7 100644
--- a/app/Views/auth/forgot.php
+++ b/app/Views/auth/forgot.php
@@ -1,3 +1,4 @@
+<?= helper('form') ?>
 <?= $this->extend($config->viewLayout) ?>
 
 <?= $this->section('title') ?>
@@ -7,19 +8,26 @@
 
 <?= $this->section('content') ?>
 
-<p class="mb-4"><?= lang('Auth.enterEmailForInstructions') ?></p>
+<p class="mb-4 text-gray-600"><?= lang('Auth.enterEmailForInstructions') ?></p>
 
-<form action="<?= route_to('forgot') ?>" method="post" class="flex flex-col">
-    <?= csrf_field() ?>
+<?= form_open(route_to('forgot'), ['class' => 'flex flex-col']) ?>
+<?= csrf_field() ?>
 
-    <label for="email"><?= lang('Auth.emailAddress') ?></label>
-    <input type="email" class="mb-6 form-input" name="email" placeholder="<?= lang(
-        'Auth.email'
-    ) ?>">
+<?= form_label(lang('Auth.emailAddress'), 'email') ?>
+<?= form_input([
+    'id' => 'email',
+    'name' => 'email',
+    'class' => 'form-input mb-4',
+    'type' => 'email',
+    'required' => 'required',
+]) ?>
 
-    <button type="submit" class="px-4 py-2 ml-auto border">
-        <?= lang('Auth.sendInstructions') ?>
-    </button>
-</form>
+<?= form_button([
+    'content' => lang('Auth.sendInstructions'),
+    'type' => 'submit',
+    'class' => 'px-4 py-2 ml-auto border',
+]) ?>
+
+<?= form_close() ?>
 
 <?= $this->endSection() ?>
diff --git a/app/Views/auth/login.php b/app/Views/auth/login.php
index 106851d06d..c231de5423 100644
--- a/app/Views/auth/login.php
+++ b/app/Views/auth/login.php
@@ -1,3 +1,4 @@
+<?= helper('form') ?>
 <?= $this->extend($config->viewLayout) ?>
 
 <?= $this->section('title') ?>
@@ -7,23 +8,33 @@
 
 <?= $this->section('content') ?>
 
-<form action="<?= route_to('login') ?>" method="post" class="flex flex-col">
-    <?= csrf_field() ?>
-
-    <label for="login"><?= lang('Auth.emailOrUsername') ?></label>
-    <input type="text" name="login" class="mb-4 form-input" placeholder="<?= lang(
-        'Auth.emailOrUsername'
-    ) ?>">
-
-    <label for="password"><?= lang('Auth.password') ?></label>
-    <input type="password" name="password" class="mb-6 form-input" placeholder="<?= lang(
-        'Auth.password'
-    ) ?>">
-
-    <button type="submit" class="px-4 py-2 ml-auto border">
-        <?= lang('Auth.loginAction') ?>
-    </button>
-</form>
+<?= form_open(route_to('login'), ['class' => 'flex flex-col']) ?>
+<?= csrf_field() ?>
+
+<?= form_label(lang('Auth.emailOrUsername'), 'login') ?>
+<?= form_input([
+    'id' => 'login',
+    'name' => 'login',
+    'class' => 'form-input mb-4',
+    'required' => 'required',
+]) ?>
+
+<?= form_label(lang('Auth.password'), 'password') ?>
+<?= form_input([
+    'id' => 'password',
+    'name' => 'password',
+    'class' => 'form-input mb-4',
+    'type' => 'password',
+    'required' => 'required',
+]) ?>
+
+<?= form_button([
+    'content' => lang('Auth.loginAction'),
+    'class' => 'px-4 py-2 ml-auto border',
+    'type' => 'submit',
+]) ?>
+
+<?= form_close() ?>
 
 <?= $this->endSection() ?>
 
diff --git a/app/Views/auth/register.php b/app/Views/auth/register.php
index 571aff38fc..aa2a7db6d4 100644
--- a/app/Views/auth/register.php
+++ b/app/Views/auth/register.php
@@ -1,3 +1,4 @@
+<?= helper('form') ?>
 <?= $this->extend($config->viewLayout) ?>
 
 <?= $this->section('title') ?>
@@ -7,36 +8,49 @@
 
 <?= $this->section('content') ?>
 
-<form action="<?= route_to('register') ?>" method="post" class="flex flex-col">
-    <?= csrf_field() ?>
-
-    <label for="email"><?= lang('Auth.email') ?></label>
-    <input type="email" class="mb-4 form-input" name="email" aria-describedby="emailHelp" placeholder="<?= lang(
-        'Auth.email'
-    ) ?>" value="<?= old('email') ?>">
-    <small id="emailHelp" class="mb-4">
-        <?= lang('Auth.weNeverShare') ?>
-    </small>
-
-    <label for="username"><?= lang('Auth.username') ?></label>
-    <input type="text" class="mb-4 form-input" name="username" placeholder="<?= lang(
-        'Auth.username'
-    ) ?>" value="<?= old('username') ?>">
-
-    <label for="password"><?= lang('Auth.password') ?></label>
-    <input type="password" name="password" class="mb-4 form-input" placeholder="<?= lang(
-        'Auth.password'
-    ) ?>" autocomplete="off">
-
-    <label for="pass_confirm"><?= lang('Auth.repeatPassword') ?></label>
-    <input type="password" name="pass_confirm" class="mb-6 form-input" placeholder="<?= lang(
-        'Auth.repeatPassword'
-    ) ?>" autocomplete="off">
-
-    <button type="submit" class="px-4 py-2 ml-auto border">
-        <?= lang('Auth.register') ?>
-    </button>
-</form>
+<?= form_open(route_to('register'), ['class' => 'flex flex-col']) ?>
+<?= csrf_field() ?>
+
+<?= form_label(lang('Auth.email'), 'email') ?>
+<?= form_input([
+    'id' => 'email',
+    'name' => 'email',
+    'class' => 'form-input',
+    'value' => old('email'),
+    'type' => 'email',
+    'required' => 'required',
+    'aria-describedby' => 'emailHelp',
+]) ?>
+<small id="emailHelp" class="mb-4 text-gray-700">
+    <?= lang('Auth.weNeverShare') ?>
+</small>
+
+<?= form_label(lang('Auth.username'), 'username') ?>
+<?= form_input([
+    'id' => 'username',
+    'name' => 'username',
+    'class' => 'form-input mb-4',
+    'value' => old('username'),
+    'required' => 'required',
+]) ?>
+
+<?= form_label(lang('Auth.password'), 'password') ?>
+<?= form_input([
+    'id' => 'password',
+    'name' => 'password',
+    'class' => 'form-input mb-4',
+    'type' => 'password',
+    'required' => 'required',
+    'autocomplete' => 'new-password',
+]) ?>
+
+<?= form_button([
+    'content' => lang('Auth.register'),
+    'class' => 'px-4 py-2 ml-auto border',
+    'type' => 'submit',
+]) ?>
+
+<?= form_close() ?>
 
 <?= $this->endSection() ?>
 
diff --git a/app/Views/auth/reset.php b/app/Views/auth/reset.php
index d2bfbce9b4..8e26e72f2a 100644
--- a/app/Views/auth/reset.php
+++ b/app/Views/auth/reset.php
@@ -1,3 +1,4 @@
+<?= helper('form') ?>
 <?= $this->extend($config->viewLayout) ?>
 
 <?= $this->section('title') ?>
@@ -9,30 +10,44 @@
 
 <p class="mb-4"><?= lang('Auth.enterCodeEmailPassword') ?></p>
 
-<form action="<?= route_to(
-    'reset-password'
-) ?>" method="post" class="flex flex-col">
-    <?= csrf_field() ?>
-
-    <label for="token"><?= lang('Auth.token') ?></label>
-    <input type="text" class="mb-4 form-input" name="token" placeholder="<?= lang(
-        'Auth.token'
-    ) ?>" value="<?= old('token', $token ?? '') ?>">
-
-    <label for="email"><?= lang('Auth.email') ?></label>
-    <input type="email" class="mb-4 form-input" name="email" placeholder="<?= lang(
-        'Auth.email'
-    ) ?>" value="<?= old('email') ?>">
-
-    <label for="password"><?= lang('Auth.newPassword') ?></label>
-    <input type="password" class="mb-4 form-input" name="password">
-
-    <label for="pass_confirm"><?= lang('Auth.newPasswordRepeat') ?></label>
-    <input type="password" class="mb-6 form-input" name="pass_confirm">
-
-    <button type="submit" class="px-4 py-2 ml-auto border">
-        <?= lang('Auth.resetPassword') ?>
-    </button>
-</form>
+<?= form_open(route_to('reset-password'), ['class' => 'flex flex-col']) ?>
+<?= csrf_field() ?>
+
+<?= form_label(lang('Auth.token'), 'token') ?>
+<?= form_input([
+    'id' => 'token',
+    'name' => 'token',
+    'class' => 'form-input mb-4',
+    'value' => old('token', $token ?? ''),
+    'required' => 'required',
+]) ?>
+
+<?= form_label(lang('Auth.email'), 'email') ?>
+<?= form_input([
+    'id' => 'email',
+    'name' => 'email',
+    'class' => 'form-input mb-4',
+    'value' => old('email'),
+    'required' => 'required',
+    'type' => 'email',
+]) ?>
+
+<?= form_label(lang('Auth.newPassword'), 'password') ?>
+<?= form_input([
+    'id' => 'password',
+    'name' => 'password',
+    'class' => 'form-input mb-4',
+    'type' => 'password',
+    'required' => 'required',
+    'autocomplete' => 'new-password',
+]) ?>
+
+<?= form_button([
+    'content' => lang('Auth.resetPassword'),
+    'class' => 'px-4 py-2 ml-auto border',
+    'type' => 'submit',
+]) ?>
+
+<?= form_close() ?>
 
 <?= $this->endSection() ?>
diff --git a/app/Views/install/env.php b/app/Views/install/env.php
index d41eff3fb5..d11b2a7f20 100644
--- a/app/Views/install/env.php
+++ b/app/Views/install/env.php
@@ -2,9 +2,10 @@
 
 <?= $this->section('content') ?>
 
-<?= form_open(route_to('install_generate_env'), [
+<?= form_open(route_to('generate-env'), [
     'class' => 'flex flex-col max-w-sm mx-auto',
 ]) ?>
+<?= csrf_field() ?>
 
 <?= form_fieldset('', ['class' => 'flex flex-col mb-6']) ?>
     <legend class="mb-4 text-xl"><?= lang(
@@ -16,6 +17,7 @@
         'name' => 'hostname',
         'class' => 'form-input mb-4',
         'value' => config('App')->baseURL,
+        'required' => 'required',
     ]) ?>
 
     <?= form_label(lang('Install.form.admin_gateway'), 'admin_gateway') ?>
@@ -24,6 +26,7 @@
         'name' => 'admin_gateway',
         'class' => 'form-input mb-4',
         'value' => config('App')->adminGateway,
+        'required' => 'required',
     ]) ?>
 
     <?= form_label(lang('Install.form.auth_gateway'), 'auth_gateway') ?>
@@ -32,6 +35,7 @@
         'name' => 'auth_gateway',
         'class' => 'form-input',
         'value' => config('App')->authGateway,
+        'required' => 'required',
     ]) ?>
 <?= form_fieldset_close() ?>
 
@@ -43,6 +47,7 @@
         'name' => 'db_hostname',
         'class' => 'form-input mb-4',
         'value' => config('Database')->default['hostname'],
+        'required' => 'required',
     ]) ?>
 
     <?= form_label(lang('Install.form.db_name'), 'db_name') ?>
@@ -51,6 +56,7 @@
         'name' => 'db_name',
         'class' => 'form-input mb-4',
         'value' => config('Database')->default['database'],
+        'required' => 'required',
     ]) ?>
 
     <?= form_label(lang('Install.form.db_username'), 'db_username') ?>
@@ -59,6 +65,7 @@
         'name' => 'db_username',
         'class' => 'form-input mb-4',
         'value' => config('Database')->default['username'],
+        'required' => 'required',
     ]) ?>
 
     <?= form_label(lang('Install.form.db_password'), 'db_password') ?>
@@ -67,6 +74,7 @@
         'name' => 'db_password',
         'class' => 'form-input mb-4',
         'value' => config('Database')->default['password'],
+        'required' => 'required',
     ]) ?>
 
     <?= form_label(lang('Install.form.db_prefix'), 'db_prefix') ?>
diff --git a/app/Views/install/superadmin.php b/app/Views/install/superadmin.php
index 37858097ae..d4ddc61f97 100644
--- a/app/Views/install/superadmin.php
+++ b/app/Views/install/superadmin.php
@@ -2,9 +2,10 @@
 
 <?= $this->section('content') ?>
 
-<?= form_open(route_to('install_create_superadmin'), [
+<?= form_open(route_to('create-superadmin'), [
     'class' => 'flex flex-col max-w-sm mx-auto',
 ]) ?>
+<?= csrf_field() ?>
 
 <?= form_fieldset('', ['class' => 'flex flex-col mb-6']) ?>
     <legend class="mb-4 text-xl"><?= lang(
@@ -16,6 +17,8 @@
         'name' => 'email',
         'class' => 'form-input mb-4',
         'type' => 'email',
+        'required' => 'required',
+        'value' => old('email'),
     ]) ?>
     
     <?= form_label(lang('Install.form.username'), 'username') ?>
@@ -23,14 +26,18 @@
         'id' => 'username',
         'name' => 'username',
         'class' => 'form-input mb-4',
+        'required' => 'required',
+        'value' => old('username'),
     ]) ?>
 
     <?= form_label(lang('Install.form.password'), 'password') ?>
     <?= form_input([
         'id' => 'password',
         'name' => 'password',
-        'class' => 'form-input',
+        'class' => 'form-input mb-4',
         'type' => 'password',
+        'required' => 'required',
+        'autocomplete' => 'new-password',
     ]) ?>
 <?= form_fieldset_close() ?>
 
-- 
GitLab