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
This commit is contained in:
Yassine Doghri 2020-08-05 16:10:39 +00:00
parent ed6e953010
commit 7fb1de2cf3
35 changed files with 397 additions and 82 deletions

View File

@ -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

View File

@ -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', [

View File

@ -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();
}
}

View File

@ -26,7 +26,7 @@ class BaseController extends Controller
*
* @var array
*/
protected $helpers = ['auth'];
protected $helpers = ['auth', 'breadcrumb', 'svg'];
/**
* Constructor.

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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',

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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',
];

View File

@ -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}',

View File

@ -8,8 +8,8 @@
return [
'info' => 'My account info',
'changePassword' => 'Change my password',
'messages' => [
'passwordChangeSuccess' => 'Password has been successfully changed!',
'changePassword' => 'Change my password',
],
];

View File

@ -13,6 +13,7 @@ return [
'unban' => 'Unban',
'delete' => 'Delete',
'create' => 'Create a user',
'view' => '{username}\'s info',
'all_users' => 'All users',
'form' => [
'email' => 'Email',

View File

@ -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>';
}
}

View File

@ -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,

View File

@ -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;
}

View File

@ -1,2 +1,3 @@
@import "./tailwind.css";
@import "./layout.css";
@import "./breadcrumb.css";

View File

@ -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 ?>

View File

@ -1,7 +1,3 @@
<?php
helper('html'); ?>
<!DOCTYPE html>
<html lang="en">

View File

@ -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">

View File

@ -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>

View File

@ -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>

View File

@ -1,5 +1,3 @@
<?php helper('html'); ?>
<?= $this->extend('admin/_layout') ?>
<?= $this->section('title') ?>

View File

@ -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() ?>

View File

@ -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,
]) ?>

View File

@ -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 ?>">

View File

@ -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()
?>

View File

@ -1,5 +1,3 @@
<?php helper('html'); ?>
<?= $this->extend('admin/_layout') ?>
<?= $this->section('title') ?>

View File

@ -1,7 +1,3 @@
<?php
helper('html'); ?>
<?= $this->extend('admin/_layout') ?>
<?= $this->section('title') ?>

View File

@ -1,5 +1,3 @@
<?php helper('html'); ?>
<?= $this->extend('admin/_layout') ?>
<?= $this->section('title') ?>

View File

@ -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() ?>

View File

@ -1,7 +1,7 @@
/* eslint-disable */
module.exports = {
purge: ["./app/Views/**/*.php", "./app/Views/**/*.js"],
purge: ["./app/Views/**/*.php", "./app/Views/**/*.ts"],
theme: {
extend: {},
},