From 7fb1de2cf3c97c4cd7afe3bd71bbe66041786ecd Mon Sep 17 00:00:00 2001
From: Yassine Doghri <yassine@doghri.fr>
Date: Wed, 5 Aug 2020 16:10:39 +0000
Subject: [PATCH] feat: add breadcrumb in admin area

- add Breadcrumb library and service
- update authorizations
- add missing routes to avoid 404 links in breadcrumb
- add svg_helper globally in base controller
- update purgecss config to check .ts files

closes #17
---
 app/Authorization/FlatAuthorization.php       |   2 +-
 app/Config/Routes.php                         |  25 +++--
 app/Config/Services.php                       |  10 ++
 app/Controllers/Admin/BaseController.php      |   2 +-
 app/Controllers/Admin/Contributor.php         |  28 ++++-
 app/Controllers/Admin/Episode.php             |  18 ++-
 app/Controllers/Admin/Podcast.php             |   6 +-
 app/Controllers/Admin/User.php                |  13 ++-
 app/Database/Seeds/AuthSeeder.php             |  15 +++
 app/Entities/User.php                         |  22 ++++
 app/Helpers/breadcrumb_helper.php             |  28 +++++
 .../{html_helper.php => svg_helper.php}       |   0
 app/Language/en/Breadcrumb.php                |  22 ++++
 app/Language/en/Contributor.php               |   1 +
 app/Language/en/MyAccount.php                 |   2 +-
 app/Language/en/User.php                      |   1 +
 app/Libraries/Breadcrumb.php                  | 104 ++++++++++++++++++
 app/Models/UserModel.php                      |   5 +-
 app/Views/_assets/styles/breadcrumb.css       |  20 ++++
 app/Views/_assets/styles/index.css            |   1 +
 app/Views/admin/_header.php                   |  15 ++-
 app/Views/admin/_layout.php                   |   4 -
 app/Views/admin/_partials/_episode-card.php   |   4 -
 app/Views/admin/_partials/_user_info.php      |  32 ++++++
 app/Views/admin/_sidenav.php                  |   2 +-
 app/Views/admin/contributor/list.php          |   2 -
 app/Views/admin/contributor/view.php          |  28 +++++
 app/Views/admin/episode/list.php              |  11 +-
 app/Views/admin/episode/view.php              |   9 +-
 app/Views/admin/my_account/view.php           |  25 +----
 app/Views/admin/podcast/list.php              |   2 -
 app/Views/admin/podcast/view.php              |   4 -
 app/Views/admin/user/list.php                 |   2 -
 app/Views/admin/user/view.php                 |  12 ++
 tailwind.config.js                            |   2 +-
 35 files changed, 397 insertions(+), 82 deletions(-)
 create mode 100644 app/Helpers/breadcrumb_helper.php
 rename app/Helpers/{html_helper.php => svg_helper.php} (100%)
 create mode 100644 app/Language/en/Breadcrumb.php
 create mode 100644 app/Libraries/Breadcrumb.php
 create mode 100644 app/Views/_assets/styles/breadcrumb.css
 create mode 100644 app/Views/admin/_partials/_user_info.php
 create mode 100644 app/Views/admin/contributor/view.php
 create mode 100644 app/Views/admin/user/view.php

diff --git a/app/Authorization/FlatAuthorization.php b/app/Authorization/FlatAuthorization.php
index a732a8cbfd..f96fb2aa7b 100644
--- a/app/Authorization/FlatAuthorization.php
+++ b/app/Authorization/FlatAuthorization.php
@@ -49,7 +49,7 @@ class FlatAuthorization extends \Myth\Auth\Authorization\FlatAuthorization
     }
 
     /**
-     * Makes a member a part of multiple groups.
+     * Makes user part of given groups.
      *
      * @param $userId
      * @param array|null $groups // Either collection of ID or names
diff --git a/app/Config/Routes.php b/app/Config/Routes.php
index 84a96218ac..e288b15e2e 100644
--- a/app/Config/Routes.php
+++ b/app/Config/Routes.php
@@ -77,11 +77,11 @@ $routes->group(
         $routes->get('podcasts', 'Podcast::list', [
             'as' => 'podcast_list',
         ]);
-        $routes->get('new-podcast', 'Podcast::create', [
+        $routes->get('podcasts/new', 'Podcast::create', [
             'as' => 'podcast_create',
             'filter' => 'permission:podcasts-create',
         ]);
-        $routes->post('new-podcast', 'Podcast::attemptCreate', [
+        $routes->post('podcasts/new', 'Podcast::attemptCreate', [
             'filter' => 'permission:podcasts-create',
         ]);
 
@@ -108,19 +108,19 @@ $routes->group(
                 'as' => 'episode_list',
                 'filter' => 'permission:podcasts-view,podcast-view',
             ]);
-            $routes->get('new-episode', 'Episode::create/$1', [
+            $routes->get('episodes/new', 'Episode::create/$1', [
                 'as' => 'episode_create',
                 'filter' =>
                     'permission:episodes-create,podcast_episodes-create',
             ]);
-            $routes->post('new-episode', 'Episode::attemptCreate/$1', [
+            $routes->post('episodes/new', 'Episode::attemptCreate/$1', [
                 'filter' =>
                     'permission:episodes-create,podcast_episodes-create',
             ]);
 
             $routes->get('episodes/(:num)', 'Episode::view/$1/$2', [
                 'as' => 'episode_view',
-                'filter' => 'permission:episodes-list,podcast_episodes-list',
+                'filter' => 'permission:episodes-view,podcast_episodes-view',
             ]);
             $routes->get('episodes/(:num)/edit', 'Episode::edit/$1/$2', [
                 'as' => 'episode_edit',
@@ -146,15 +146,18 @@ $routes->group(
                 'filter' =>
                     'permission:podcasts-manage_contributors,podcast-manage_contributors',
             ]);
-            $routes->get('add-contributor', 'Contributor::add/$1', [
+            $routes->get('contributors/add', 'Contributor::add/$1', [
                 'as' => 'contributor_add',
                 'filter' =>
                     'permission:podcasts-manage_contributors,podcast-manage_contributors',
             ]);
-            $routes->post('add-contributor', 'Contributor::attemptAdd/$1', [
+            $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',
@@ -188,11 +191,15 @@ $routes->group(
             'as' => 'user_list',
             'filter' => 'permission:users-list',
         ]);
-        $routes->get('new-user', 'User::create', [
+        $routes->get('users/new', 'User::create', [
             'as' => 'user_create',
             'filter' => 'permission:users-create',
         ]);
-        $routes->post('new-user', 'User::attemptCreate', [
+        $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', [
diff --git a/app/Config/Services.php b/app/Config/Services.php
index 275caab237..27d601fae5 100644
--- a/app/Config/Services.php
+++ b/app/Config/Services.php
@@ -7,6 +7,7 @@ use CodeIgniter\Model;
 use App\Authorization\FlatAuthorization;
 use App\Authorization\PermissionModel;
 use App\Authorization\GroupModel;
+use App\Libraries\Breadcrumb;
 use App\Models\UserModel;
 use Myth\Auth\Models\LoginModel;
 
@@ -91,4 +92,13 @@ class Services extends CoreServices
 
         return $instance->setUserModel($userModel);
     }
+
+    public static function breadcrumb(bool $getShared = true)
+    {
+        if ($getShared) {
+            return self::getSharedInstance('breadcrumb');
+        }
+
+        return new Breadcrumb();
+    }
 }
diff --git a/app/Controllers/Admin/BaseController.php b/app/Controllers/Admin/BaseController.php
index a10692c9e7..92f4849ab4 100644
--- a/app/Controllers/Admin/BaseController.php
+++ b/app/Controllers/Admin/BaseController.php
@@ -26,7 +26,7 @@ class BaseController extends Controller
      *
      * @var array
      */
-    protected $helpers = ['auth'];
+    protected $helpers = ['auth', 'breadcrumb', 'svg'];
 
     /**
      * Constructor.
diff --git a/app/Controllers/Admin/Contributor.php b/app/Controllers/Admin/Contributor.php
index 4f5a8ae825..2f8ed6d5f4 100644
--- a/app/Controllers/Admin/Contributor.php
+++ b/app/Controllers/Admin/Contributor.php
@@ -41,7 +41,24 @@ class Contributor extends BaseController
             'podcast' => $this->podcast,
         ];
 
-        echo view('admin/contributor/list', $data);
+        replace_breadcrumb_params([0 => $this->podcast->title]);
+        return view('admin/contributor/list', $data);
+    }
+
+    public function view()
+    {
+        $data = [
+            'contributor' => (new UserModel())->getPodcastContributor(
+                $this->user->id,
+                $this->podcast->id
+            ),
+        ];
+
+        replace_breadcrumb_params([
+            0 => $this->podcast->title,
+            1 => $this->user->username,
+        ]);
+        return view('admin/contributor/view', $data);
     }
 
     public function add()
@@ -52,7 +69,8 @@ class Contributor extends BaseController
             'roles' => (new GroupModel())->getContributorRoles(),
         ];
 
-        echo view('admin/contributor/add', $data);
+        replace_breadcrumb_params([0 => $this->podcast->title]);
+        return view('admin/contributor/add', $data);
     }
 
     public function attemptAdd()
@@ -87,7 +105,11 @@ class Contributor extends BaseController
             'roles' => (new GroupModel())->getContributorRoles(),
         ];
 
-        echo view('admin/contributor/edit', $data);
+        replace_breadcrumb_params([
+            0 => $this->podcast->title,
+            1 => $this->user->username,
+        ]);
+        return view('admin/contributor/edit', $data);
     }
 
     public function attemptEdit()
diff --git a/app/Controllers/Admin/Episode.php b/app/Controllers/Admin/Episode.php
index 0c5ce4c273..b639512ef1 100644
--- a/app/Controllers/Admin/Episode.php
+++ b/app/Controllers/Admin/Episode.php
@@ -42,6 +42,9 @@ class Episode extends BaseController
             'podcast' => $this->podcast,
         ];
 
+        replace_breadcrumb_params([
+            0 => $this->podcast->title,
+        ]);
         return view('admin/episode/list', $data);
     }
 
@@ -49,6 +52,10 @@ class Episode extends BaseController
     {
         $data = ['episode' => $this->episode];
 
+        replace_breadcrumb_params([
+            0 => $this->podcast->title,
+            1 => $this->episode->title,
+        ]);
         return view('admin/episode/view', $data);
     }
 
@@ -60,7 +67,10 @@ class Episode extends BaseController
             'podcast' => $this->podcast,
         ];
 
-        echo view('admin/episode/create', $data);
+        replace_breadcrumb_params([
+            0 => $this->podcast->title,
+        ]);
+        return view('admin/episode/create', $data);
     }
 
     public function attemptCreate()
@@ -115,7 +125,11 @@ class Episode extends BaseController
             'episode' => $this->episode,
         ];
 
-        echo view('admin/episode/edit', $data);
+        replace_breadcrumb_params([
+            0 => $this->podcast->title,
+            1 => $this->episode->title,
+        ]);
+        return view('admin/episode/edit', $data);
     }
 
     public function attemptEdit()
diff --git a/app/Controllers/Admin/Podcast.php b/app/Controllers/Admin/Podcast.php
index 99209e0761..c0e5b2cb33 100644
--- a/app/Controllers/Admin/Podcast.php
+++ b/app/Controllers/Admin/Podcast.php
@@ -52,6 +52,7 @@ class Podcast extends BaseController
     {
         $data = ['podcast' => $this->podcast];
 
+        replace_breadcrumb_params([0 => $this->podcast->title]);
         return view('admin/podcast/view', $data);
     }
 
@@ -69,7 +70,7 @@ class Podcast extends BaseController
             ),
         ];
 
-        echo view('admin/podcast/create', $data);
+        return view('admin/podcast/create', $data);
     }
 
     public function attemptCreate()
@@ -145,7 +146,8 @@ class Podcast extends BaseController
             'categories' => (new CategoryModel())->findAll(),
         ];
 
-        echo view('admin/podcast/edit', $data);
+        replace_breadcrumb_params([0 => $this->podcast->title]);
+        return view('admin/podcast/edit', $data);
     }
 
     public function attemptEdit()
diff --git a/app/Controllers/Admin/User.php b/app/Controllers/Admin/User.php
index 98470cf13f..872d973ff8 100644
--- a/app/Controllers/Admin/User.php
+++ b/app/Controllers/Admin/User.php
@@ -34,13 +34,21 @@ class User extends BaseController
         return view('admin/user/list', $data);
     }
 
+    public function view()
+    {
+        $data = ['user' => $this->user];
+
+        replace_breadcrumb_params([0 => $this->user->username]);
+        return view('admin/user/view', $data);
+    }
+
     public function create()
     {
         $data = [
             'roles' => (new GroupModel())->getUserRoles(),
         ];
 
-        echo view('admin/user/create', $data);
+        return view('admin/user/create', $data);
     }
 
     public function attemptCreate()
@@ -99,7 +107,8 @@ class User extends BaseController
             'roles' => (new GroupModel())->getUserRoles(),
         ];
 
-        echo view('admin/user/edit', $data);
+        replace_breadcrumb_params([0 => $this->user->username]);
+        return view('admin/user/edit', $data);
     }
 
     public function attemptEdit()
diff --git a/app/Database/Seeds/AuthSeeder.php b/app/Database/Seeds/AuthSeeder.php
index 5ce7edcce6..6ddfd95012 100644
--- a/app/Database/Seeds/AuthSeeder.php
+++ b/app/Database/Seeds/AuthSeeder.php
@@ -50,6 +50,11 @@ class AuthSeeder extends Seeder
                 'description' => 'List all users',
                 'has_permission' => ['superadmin'],
             ],
+            [
+                'name' => 'view',
+                'description' => 'View any user info',
+                'has_permission' => ['superadmin'],
+            ],
             [
                 'name' => 'manage_authorizations',
                 'description' => 'Add or remove roles/permissions to a user',
@@ -128,6 +133,11 @@ class AuthSeeder extends Seeder
                 'description' => 'List all episodes of any podcast',
                 'has_permission' => ['superadmin'],
             ],
+            [
+                'name' => 'view',
+                'description' => 'View any episode of any podcast',
+                'has_permission' => ['superadmin'],
+            ],
             [
                 'name' => 'create',
                 'description' => 'Add a new episode to any podcast',
@@ -195,6 +205,11 @@ class AuthSeeder extends Seeder
                 'description' => 'List all episodes of a podcast',
                 'has_permission' => ['podcast_admin'],
             ],
+            [
+                'name' => 'view',
+                'description' => 'View any episode of a podcast',
+                'has_permission' => ['podcast_admin'],
+            ],
             [
                 'name' => 'create',
                 'description' => 'Add new episodes for a podcast',
diff --git a/app/Entities/User.php b/app/Entities/User.php
index f710fd0692..88fe6ed962 100644
--- a/app/Entities/User.php
+++ b/app/Entities/User.php
@@ -12,6 +12,12 @@ class User extends \Myth\Auth\Entities\User
      */
     protected $podcasts = [];
 
+    /**
+     * The podcast user is contributing to
+     * @var \App\Entities\Podcast
+     */
+    protected $podcast;
+
     /**
      * Array of field names and the type of value to cast them as
      * when they are accessed.
@@ -20,6 +26,7 @@ class User extends \Myth\Auth\Entities\User
         'active' => 'boolean',
         'force_pass_reset' => 'boolean',
         'podcast_role' => '?string',
+        'podcast_id' => '?integer',
     ];
 
     /**
@@ -41,4 +48,19 @@ class User extends \Myth\Auth\Entities\User
 
         return $this->podcasts;
     }
+
+    public function getPodcast()
+    {
+        if (empty($this->podcast_id)) {
+            throw new \RuntimeException(
+                'Podcast_id must be set before getting podcast.'
+            );
+        }
+
+        if (empty($this->podcast)) {
+            $this->podcast = (new PodcastModel())->find($this->podcast_id);
+        }
+
+        return $this->podcast;
+    }
 }
diff --git a/app/Helpers/breadcrumb_helper.php b/app/Helpers/breadcrumb_helper.php
new file mode 100644
index 0000000000..52022860be
--- /dev/null
+++ b/app/Helpers/breadcrumb_helper.php
@@ -0,0 +1,28 @@
+<?php
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+use Config\Services;
+
+/**
+ * Returns the inline svg icon
+ *
+ * @param  string $name name of the icon file without the .svg extension
+ * @param  string $class to be added to the svg string
+ * @return string html breadcrumb
+ */
+function render_breadcrumb()
+{
+    $breadcrumb = Services::breadcrumb();
+    return $breadcrumb->render();
+}
+
+function replace_breadcrumb_params($newParams)
+{
+    $breadcrumb = Services::breadcrumb();
+    $breadcrumb->replaceParams($newParams);
+}
diff --git a/app/Helpers/html_helper.php b/app/Helpers/svg_helper.php
similarity index 100%
rename from app/Helpers/html_helper.php
rename to app/Helpers/svg_helper.php
diff --git a/app/Language/en/Breadcrumb.php b/app/Language/en/Breadcrumb.php
new file mode 100644
index 0000000000..6ef22d33d2
--- /dev/null
+++ b/app/Language/en/Breadcrumb.php
@@ -0,0 +1,22 @@
+<?php
+
+/**
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+return [
+    'label' => 'breadcrumb',
+    config('App')->adminGateway => 'Home',
+    'my-podcasts' => 'my podcasts',
+    'podcasts' => 'podcasts',
+    'episodes' => 'episodes',
+    'contributors' => 'contributors',
+    'add' => 'add',
+    'new' => 'new',
+    'edit' => 'edit',
+    'users' => 'users',
+    'my-account' => 'my account',
+    'change-password' => 'change password',
+];
diff --git a/app/Language/en/Contributor.php b/app/Language/en/Contributor.php
index a9bf108bc5..25fed24366 100644
--- a/app/Language/en/Contributor.php
+++ b/app/Language/en/Contributor.php
@@ -8,6 +8,7 @@
 
 return [
     'podcast_contributors' => 'Podcast contributors',
+    'view' => '{username}\'s contribution to {podcastName}',
     'add' => 'Add contributor',
     'add_contributor' => 'Add a contributor for {0}',
     'edit_role' => 'Update role for {0}',
diff --git a/app/Language/en/MyAccount.php b/app/Language/en/MyAccount.php
index c65d934460..b675cc1770 100644
--- a/app/Language/en/MyAccount.php
+++ b/app/Language/en/MyAccount.php
@@ -8,8 +8,8 @@
 
 return [
     'info' => 'My account info',
+    'changePassword' => 'Change my password',
     'messages' => [
         'passwordChangeSuccess' => 'Password has been successfully changed!',
-        'changePassword' => 'Change my password',
     ],
 ];
diff --git a/app/Language/en/User.php b/app/Language/en/User.php
index 2cdc1e332a..0e742c2a32 100644
--- a/app/Language/en/User.php
+++ b/app/Language/en/User.php
@@ -13,6 +13,7 @@ return [
     'unban' => 'Unban',
     'delete' => 'Delete',
     'create' => 'Create a user',
+    'view' => '{username}\'s info',
     'all_users' => 'All users',
     'form' => [
         'email' => 'Email',
diff --git a/app/Libraries/Breadcrumb.php b/app/Libraries/Breadcrumb.php
new file mode 100644
index 0000000000..43cb2f951a
--- /dev/null
+++ b/app/Libraries/Breadcrumb.php
@@ -0,0 +1,104 @@
+<?php
+
+/**
+ * Generates and renders a breadcrumb based on the current url segments
+ *
+ * @copyright  2020 Podlibre
+ * @license    https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
+ * @link       https://castopod.org/
+ */
+
+namespace App\Libraries;
+
+class Breadcrumb
+{
+    /**
+     * List of breadcrumb links.
+     *
+     * @var array
+     * $links = [
+     *  'text' => (string) the anchor text,
+     *  'href' => (string) the anchor href,
+     * ]
+     */
+    protected $links = [];
+
+    /**
+     * Initializes the Breadcrumb object using the segments from
+     * current_url by populating the $links property with text and href data
+     */
+    public function __construct()
+    {
+        $uri = '';
+        foreach (current_url(true)->getSegments() as $segment) {
+            $uri .= '/' . $segment;
+            array_push($this->links, [
+                'text' => is_numeric($segment)
+                    ? $segment
+                    : lang('Breadcrumb.' . $segment),
+                'href' => base_url($uri),
+            ]);
+        }
+    }
+
+    /**
+     * Replaces all numeric text in breadcrumb's $link property
+     * with new params at same position
+     *
+     * Given a breadcrumb with numeric params, this function
+     * replaces them with the values provided in $newParams
+     *
+     * Example with `Home / podcasts / 1 / episodes / 1`
+     *
+     * $newParams = [
+     *  0 => 'foo',
+     *  1 => 'bar'
+     * ]
+     * replaceParams($newParams);
+     *
+     * The breadcrumb is now `Home / podcasts / foo / episodes / bar`
+     *
+     * @param array $newParams
+     */
+    public function replaceParams($newParams)
+    {
+        foreach ($this->links as $key => $link) {
+            if (is_numeric($link['text'])) {
+                $this->links[$key]['text'] = $newParams[0];
+                array_shift($newParams);
+            }
+        }
+    }
+
+    /**
+     * Renders the breadcrumb object as an accessible html breadcrumb nav
+     *
+     * @return string
+     */
+    public function render()
+    {
+        $listItems = '';
+        $keys = array_keys($this->links);
+        foreach ($this->links as $key => $link) {
+            if (end($keys) == $key) {
+                $listItem =
+                    '<li class="breadcrumb-item active" aria-current="page">' .
+                    $link['text'] .
+                    '</li>';
+            } else {
+                $listItem =
+                    '<li class="breadcrumb-item">' .
+                    anchor($link['href'], $link['text']) .
+                    '</li>';
+            }
+
+            $listItems .= $listItem;
+        }
+
+        return '<nav aria-label="' .
+            lang('Breadcrumb.label') .
+            '"><ol class="breadcrumb">' .
+            $listItems .
+            '</ol></nav>';
+    }
+}
diff --git a/app/Models/UserModel.php b/app/Models/UserModel.php
index 420c002dae..4f9f7d0333 100644
--- a/app/Models/UserModel.php
+++ b/app/Models/UserModel.php
@@ -17,8 +17,11 @@ class UserModel extends \Myth\Auth\Models\UserModel
 
     public function getPodcastContributor($user_id, $podcast_id)
     {
-        return $this->select('users.*')
+        return $this->select(
+            'users.*, users_podcasts.podcast_id as podcast_id, auth_groups.name as podcast_role'
+        )
             ->join('users_podcasts', 'users_podcasts.user_id = users.id')
+            ->join('auth_groups', 'auth_groups.id = users_podcasts.group_id')
             ->where([
                 'users.id' => $user_id,
                 'podcast_id' => $podcast_id,
diff --git a/app/Views/_assets/styles/breadcrumb.css b/app/Views/_assets/styles/breadcrumb.css
new file mode 100644
index 0000000000..f2cb91620f
--- /dev/null
+++ b/app/Views/_assets/styles/breadcrumb.css
@@ -0,0 +1,20 @@
+.breadcrumb {
+  @apply inline-flex flex-wrap px-1 py-2 text-sm text-gray-800;
+}
+
+.breadcrumb-item + .breadcrumb-item::before {
+  @apply inline-block px-1 text-gray-500;
+  content: "/";
+}
+
+.breadcrumb-item a {
+  @apply no-underline;
+
+  &:hover {
+    @apply underline;
+  }
+}
+
+.breadcrumb-item.active {
+  @apply font-semibold;
+}
diff --git a/app/Views/_assets/styles/index.css b/app/Views/_assets/styles/index.css
index 5f4c3b7fda..f12f46bb99 100644
--- a/app/Views/_assets/styles/index.css
+++ b/app/Views/_assets/styles/index.css
@@ -1,2 +1,3 @@
 @import "./tailwind.css";
 @import "./layout.css";
+@import "./breadcrumb.css";
diff --git a/app/Views/admin/_header.php b/app/Views/admin/_header.php
index 3fc1d87718..b97236e3cf 100644
--- a/app/Views/admin/_header.php
+++ b/app/Views/admin/_header.php
@@ -1,10 +1,13 @@
 <header class="<?= $class ?>">
-    <a href="<?= route_to(
-        'admin_home'
-    ) ?>" class="inline-flex items-center text-xl">
-        <?= svg('logo-castopod', 'text-3xl mr-2 -ml-2') ?>
-        Admin
-    </a>
+    <div class="w-64">
+        <a href="<?= route_to(
+            'admin_home'
+        ) ?>" class="inline-flex items-center text-xl">
+            <?= svg('logo-castopod', 'text-3xl mr-2') ?>
+            Admin
+        </a>
+    </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">
             Hey <?= user()->username ?>
diff --git a/app/Views/admin/_layout.php b/app/Views/admin/_layout.php
index 75b11ac5ed..f87c44f151 100644
--- a/app/Views/admin/_layout.php
+++ b/app/Views/admin/_layout.php
@@ -1,7 +1,3 @@
-<?php
-
-helper('html'); ?>
-
 <!DOCTYPE html>
 <html lang="en">
 
diff --git a/app/Views/admin/_partials/_episode-card.php b/app/Views/admin/_partials/_episode-card.php
index baf0b5677f..af1b46de63 100644
--- a/app/Views/admin/_partials/_episode-card.php
+++ b/app/Views/admin/_partials/_episode-card.php
@@ -1,7 +1,3 @@
-<?php
-
-helper('html'); ?>
-
 <article class="flex w-full max-w-lg mb-4 bg-white border rounded shadow">
     <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">
diff --git a/app/Views/admin/_partials/_user_info.php b/app/Views/admin/_partials/_user_info.php
new file mode 100644
index 0000000000..576311f197
--- /dev/null
+++ b/app/Views/admin/_partials/_user_info.php
@@ -0,0 +1,32 @@
+<div class="px-4 py-5 bg-gray-50 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
+    <dt class="text-sm font-medium leading-5 text-gray-500">
+    Email
+    </dt>
+    <dd class="mt-1 text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2">
+    <?= $user->email ?>
+    </dd>
+</div>
+<div class="px-4 py-5 bg-gray-50 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
+    <dt class="text-sm font-medium leading-5 text-gray-500">
+    Username
+    </dt>
+    <dd class="mt-1 text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2">
+    <?= $user->username ?>
+    </dd>
+</div>
+<div class="px-4 py-5 bg-gray-50 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
+    <dt class="text-sm font-medium leading-5 text-gray-500">
+    Roles
+    </dt>
+    <dd class="mt-1 text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2">
+    [<?= implode(', ', $user->roles) ?>]
+    </dd>
+</div>
+<div class="px-4 py-5 bg-gray-50 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
+    <dt class="text-sm font-medium leading-5 text-gray-500">
+    Permissions
+    </dt>
+    <dd class="mt-1 text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2">
+    [<?= implode(', ', $user->permissions) ?>]
+    </dd>
+</div>
\ No newline at end of file
diff --git a/app/Views/admin/_sidenav.php b/app/Views/admin/_sidenav.php
index 8b51f31397..fa349177e1 100644
--- a/app/Views/admin/_sidenav.php
+++ b/app/Views/admin/_sidenav.php
@@ -11,7 +11,7 @@ $navigation = [
 <nav class="<?= $class ?>">
     <?php foreach ($navigation as $section => $data): ?>
     <div class="mb-4">
-        <button class="inline-flex items-center w-full px-4 py-1 outline-none focus:shadow-outline" type="button">
+        <button class="inline-flex items-center w-full px-6 py-1 outline-none focus:shadow-outline" type="button">
             <?= icon($data['icon'], 'text-gray-500') ?>
             <span class="ml-2"><?= lang('AdminNavigation.' . $section) ?></span>
         </button>
diff --git a/app/Views/admin/contributor/list.php b/app/Views/admin/contributor/list.php
index f80732a2cf..b0e7eb41a0 100644
--- a/app/Views/admin/contributor/list.php
+++ b/app/Views/admin/contributor/list.php
@@ -1,5 +1,3 @@
-<?php helper('html'); ?>
-
 <?= $this->extend('admin/_layout') ?>
 
 <?= $this->section('title') ?>
diff --git a/app/Views/admin/contributor/view.php b/app/Views/admin/contributor/view.php
new file mode 100644
index 0000000000..2aca782570
--- /dev/null
+++ b/app/Views/admin/contributor/view.php
@@ -0,0 +1,28 @@
+<?= $this->extend('admin/_layout') ?>
+
+<?= $this->section('title') ?>
+<?= lang('Contributor.view', [
+    'username' => $contributor->username,
+    'podcastName' => $contributor->podcast->name,
+]) ?>
+<?= $this->endSection() ?>
+
+
+<?= $this->section('content') ?>
+<div class="px-4 py-5 bg-gray-50 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
+    <dt class="text-sm font-medium leading-5 text-gray-500">
+    Username
+    </dt>
+    <dd class="mt-1 text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2">
+    <?= $contributor->username ?>
+    </dd>
+</div>
+<div class="px-4 py-5 bg-gray-50 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
+    <dt class="text-sm font-medium leading-5 text-gray-500">
+    Role
+    </dt>
+    <dd class="mt-1 text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2">
+    <?= $contributor->podcast_role ?>
+    </dd>
+</div>
+<?= $this->endSection() ?>
diff --git a/app/Views/admin/episode/list.php b/app/Views/admin/episode/list.php
index 1b86523d64..59f457c40d 100644
--- a/app/Views/admin/episode/list.php
+++ b/app/Views/admin/episode/list.php
@@ -3,17 +3,18 @@
 <?= $this->section('title') ?>
 
 <?= 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',
+    $podcast->id
+) ?>">
+<?= icon('add', 'mr-2') ?>
+<?= lang('Episode.create') ?></a>
 
 <?= $this->endSection() ?>
 
 
 <?= $this->section('content') ?>
 
-<a class="inline-block px-4 py-2 mb-2 border hover:bg-gray-100" href="<?= route_to(
-    'episode_create',
-    $podcast->id
-) ?>"><?= lang('Episode.create') ?></a>
-
 <?= view('admin/_partials/_episode-list.php', [
     'episodes' => $podcast->episodes,
 ]) ?>
diff --git a/app/Views/admin/episode/view.php b/app/Views/admin/episode/view.php
index 5bea39c45a..90b9a343ae 100644
--- a/app/Views/admin/episode/view.php
+++ b/app/Views/admin/episode/view.php
@@ -1,12 +1,11 @@
 <?= $this->extend('admin/_layout') ?>
 
+<?= $this->section('title') ?>
+<?= $episode->title ?>
+<?= $this->endSection() ?>
+
 <?= $this->section('content') ?>
 
-<a class="underline hover:no-underline" href="<?= route_to(
-    'podcast_view',
-    $episode->podcast->id
-) ?>">< <?= lang('Episode.back_to_podcast') ?></a>
-<h1 class="text-2xl font-semibold"><?= $episode->title ?></h1>
 <img src="<?= $episode->image_url ?>" alt="Episode cover"  class="object-cover w-40 h-40 mb-6" />
 <audio controls preload="none" class="mb-12">
   <source src="<?= $episode->enclosure_url ?>" type="<?= $episode->enclosure_type ?>">
diff --git a/app/Views/admin/my_account/view.php b/app/Views/admin/my_account/view.php
index 92ce2ed5b3..556876d561 100644
--- a/app/Views/admin/my_account/view.php
+++ b/app/Views/admin/my_account/view.php
@@ -7,30 +7,7 @@
 
 <?= $this->section('content') ?>
 
-<div class="px-4 py-5 bg-gray-50 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
-    <dt class="text-sm font-medium leading-5 text-gray-500">
-    Email
-    </dt>
-    <dd class="mt-1 text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2">
-    <?= user()->email ?>
-    </dd>
-</div>
-<div class="px-4 py-5 bg-gray-50 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
-    <dt class="text-sm font-medium leading-5 text-gray-500">
-    Username
-    </dt>
-    <dd class="mt-1 text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2">
-    <?= user()->username ?>
-    </dd>
-</div>
-<div class="px-4 py-5 bg-gray-50 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
-    <dt class="text-sm font-medium leading-5 text-gray-500">
-    Permissions
-    </dt>
-    <dd class="mt-1 text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2">
-    [<?= implode(', ', user()->permissions) ?>]
-    </dd>
-</div>
+<?= view('admin/_partials/_user_info.php', ['user' => user()]) ?>
 
 <?= $this->endSection()
 ?>
diff --git a/app/Views/admin/podcast/list.php b/app/Views/admin/podcast/list.php
index 9f18b1cfec..2efbe4d931 100644
--- a/app/Views/admin/podcast/list.php
+++ b/app/Views/admin/podcast/list.php
@@ -1,5 +1,3 @@
-<?php helper('html'); ?>
-
 <?= $this->extend('admin/_layout') ?>
 
 <?= $this->section('title') ?>
diff --git a/app/Views/admin/podcast/view.php b/app/Views/admin/podcast/view.php
index 9a781031c0..3f21185e41 100644
--- a/app/Views/admin/podcast/view.php
+++ b/app/Views/admin/podcast/view.php
@@ -1,7 +1,3 @@
-<?php
-
-helper('html'); ?>
-
 <?= $this->extend('admin/_layout') ?>
 
 <?= $this->section('title') ?>
diff --git a/app/Views/admin/user/list.php b/app/Views/admin/user/list.php
index 078d14ea70..a8d096358b 100644
--- a/app/Views/admin/user/list.php
+++ b/app/Views/admin/user/list.php
@@ -1,5 +1,3 @@
-<?php helper('html'); ?>
-
 <?= $this->extend('admin/_layout') ?>
 
 <?= $this->section('title') ?>
diff --git a/app/Views/admin/user/view.php b/app/Views/admin/user/view.php
new file mode 100644
index 0000000000..42aa6495f8
--- /dev/null
+++ b/app/Views/admin/user/view.php
@@ -0,0 +1,12 @@
+<?= $this->extend('admin/_layout') ?>
+
+<?= $this->section('title') ?>
+<?= lang('User.view', ['username' => $user->username]) ?>
+<?= $this->endSection() ?>
+
+
+<?= $this->section('content') ?>
+
+<?= view('admin/_partials/_user_info.php', ['user' => $user]) ?>
+
+<?= $this->endSection() ?>
diff --git a/tailwind.config.js b/tailwind.config.js
index 52ddd9b497..2f7c33d367 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -1,7 +1,7 @@
 /* eslint-disable */
 
 module.exports = {
-  purge: ["./app/Views/**/*.php", "./app/Views/**/*.js"],
+  purge: ["./app/Views/**/*.php", "./app/Views/**/*.ts"],
   theme: {
     extend: {},
   },
-- 
GitLab