feat: enhance admin ui with responsive design and ux improvements

- add podcast sidebar navigation
- add podcast dashboard with latest episodes
- add pagination to podcast episodes
- add components helper to reuse ui components (button, data_table, etc.)
- enhance podcast and episode forms by splitting them into form sections
- add hint tooltips to podcast and episode forms
- transform radio inputs as buttons for better ux
- replace explicit field by parental_advisory
- replace author field by publisher
- add podcasts_categories table to set multiple categories
- use choices.js to enhance multiselect fields
- update Language files
- update js dependencies to latest versions

closes #31, #9
This commit is contained in:
Yassine Doghri 2020-10-02 15:38:16 +00:00
parent 31b7828e77
commit 2d44b457a0
111 changed files with 4048 additions and 1741 deletions

View File

@ -17,9 +17,9 @@ Javascript dependencies:
- [rollup](https://rollupjs.org/) ([MIT License](https://github.com/rollup/rollup/blob/master/LICENSE.md))
- [tailwindcss](https://tailwindcss.com/) ([MIT License](https://github.com/tailwindcss/tailwindcss/blob/master/LICENSE))
- [CodeMirror](https://github.com/codemirror/CodeMirror) ([MIT License](https://github.com/codemirror/CodeMirror/blob/master/LICENSE))
- [ProseMirror](https://prosemirror.net/) ([MIT License](https://github.com/ProseMirror/prosemirror/blob/master/LICENSE))
- [D3: Data-Driven Documents](https://d3js.org) ([BSD 3-Clause "New" or "Revised" License](https://github.com/d3/d3/blob/master/LICENSE))
- [Choices.js](https://joshuajohnson.co.uk/Choices/) ([MIT License](https://github.com/jshjohnson/Choices/blob/master/LICENSE))
Other:

View File

@ -20,7 +20,7 @@ class Pager extends BaseConfig
|
*/
public $templates = [
'default_full' => 'CodeIgniter\Pager\Views\default_full',
'default_full' => 'App\Views\pager\default_full',
'default_simple' => 'CodeIgniter\Pager\Views\default_simple',
'default_head' => 'CodeIgniter\Pager\Views\default_head',
];

View File

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

View File

@ -166,7 +166,7 @@ class Contributor extends BaseController
public function remove()
{
if ($this->podcast->owner_id == $this->user->id) {
if ($this->podcast->created_by == $this->user->id) {
return redirect()
->back()
->with('errors', [

View File

@ -45,8 +45,14 @@ class Episode extends BaseController
public function list()
{
$episodes = (new EpisodeModel())
->where('podcast_id', $this->podcast->id)
->orderBy('created_at', 'desc');
$data = [
'podcast' => $this->podcast,
'episodes' => $episodes->paginate(10),
'pager' => $episodes->pager,
];
replace_breadcrumb_params([
@ -57,7 +63,10 @@ class Episode extends BaseController
public function view()
{
$data = ['episode' => $this->episode];
$data = [
'podcast' => $this->podcast,
'episode' => $this->episode,
];
replace_breadcrumb_params([
0 => $this->podcast->title,
@ -105,7 +114,10 @@ class Episode extends BaseController
'enclosure' => $this->request->getFile('enclosure'),
'description' => $this->request->getPost('description'),
'image' => $this->request->getFile('image'),
'explicit' => $this->request->getPost('explicit') == 'yes',
'parental_advisory' =>
$this->request->getPost('parental_advisory') !== 'undefined'
? $this->request->getPost('parental_advisory')
: null,
'number' => $this->request->getPost('episode_number'),
'season_number' => $this->request->getPost('season_number'),
'type' => $this->request->getPost('type'),
@ -120,14 +132,33 @@ class Episode extends BaseController
$episodeModel = new EpisodeModel();
if (!$episodeModel->save($newEpisode)) {
if (!($newEpisodeId = $episodeModel->insert($newEpisode, true))) {
return redirect()
->back()
->withInput()
->with('errors', $episodeModel->errors());
}
return redirect()->route('episode-list', [$this->podcast->id]);
// update podcast's episode_description_footer if changed
$podcastModel = new PodcastModel();
if ($this->podcast->hasChanged('episode_description_footer')) {
$this->podcast->episode_description_footer = $this->request->getPost(
'description_footer'
);
if (!$podcastModel->update($this->podcast->id, $this->podcast)) {
return redirect()
->back()
->withInput()
->with('errors', $podcastModel->errors());
}
}
return redirect()->route('episode-view', [
$this->podcast->id,
$newEpisodeId,
]);
}
public function edit()
@ -135,6 +166,7 @@ class Episode extends BaseController
helper(['form']);
$data = [
'podcast' => $this->podcast,
'episode' => $this->episode,
];
@ -167,7 +199,10 @@ class Episode extends BaseController
$this->episode->title = $this->request->getPost('title');
$this->episode->slug = $this->request->getPost('slug');
$this->episode->description = $this->request->getPost('description');
$this->episode->explicit = $this->request->getPost('explicit') == 'yes';
$this->episode->parental_advisory =
$this->request->getPost('parental_advisory') !== 'undefined'
? $this->request->getPost('parental_advisory')
: null;
$this->episode->number = $this->request->getPost('episode_number');
$this->episode->season_number = $this->request->getPost('season_number')
? $this->request->getPost('season_number')
@ -191,14 +226,32 @@ class Episode extends BaseController
$episodeModel = new EpisodeModel();
if (!$episodeModel->save($this->episode)) {
if (!$episodeModel->update($this->episode->id, $this->episode)) {
return redirect()
->back()
->withInput()
->with('errors', $episodeModel->errors());
}
return redirect()->route('episode-list', [$this->podcast->id]);
// update podcast's episode_description_footer if changed
$this->podcast->episode_description_footer = $this->request->getPost(
'description_footer'
);
if ($this->podcast->hasChanged('episode_description_footer')) {
$podcastModel = new PodcastModel();
if (!$podcastModel->update($this->podcast->id, $this->podcast)) {
return redirect()
->back()
->withInput()
->with('errors', $podcastModel->errors());
}
}
return redirect()->route('episode-view', [
$this->podcast->id,
$this->episode->id,
]);
}
public function delete()

View File

@ -57,9 +57,8 @@ class MyAccount extends BaseController
}
user()->password = $this->request->getPost('new_password');
$userModel->save(user());
if (!$userModel->save(user())) {
if (!$userModel->update(user()->id, user())) {
return redirect()
->back()
->withInput()

View File

@ -59,7 +59,7 @@ class Page extends BaseController
$pageModel = new PageModel();
if (!$pageModel->save($page)) {
if (!$pageModel->insert($page)) {
return redirect()
->back()
->withInput()
@ -92,7 +92,7 @@ class Page extends BaseController
$pageModel = new PageModel();
if (!$pageModel->save($this->page)) {
if (!$pageModel->update($this->page->id, $this->page)) {
return redirect()
->back()
->withInput()

View File

@ -94,21 +94,20 @@ class Podcast extends BaseController
'title' => $this->request->getPost('title'),
'name' => $this->request->getPost('name'),
'description' => $this->request->getPost('description'),
'episode_description_footer' => $this->request->getPost(
'episode_description_footer'
),
'image' => $this->request->getFile('image'),
'language' => $this->request->getPost('language'),
'category_id' => $this->request->getPost('category'),
'explicit' => $this->request->getPost('explicit') == 'yes',
'author' => $this->request->getPost('author'),
'parental_advisory' =>
$this->request->getPost('parental_advisory') !== 'undefined'
? $this->request->getPost('parental_advisory')
: null,
'owner_name' => $this->request->getPost('owner_name'),
'owner_email' => $this->request->getPost('owner_email'),
'publisher' => $this->request->getPost('publisher'),
'type' => $this->request->getPost('type'),
'copyright' => $this->request->getPost('copyright'),
'block' => $this->request->getPost('block') == 'yes',
'complete' => $this->request->getPost('complete') == 'yes',
'custom_html_head' => $this->request->getPost('custom_html_head'),
'block' => $this->request->getPost('block') === 'yes',
'complete' => $this->request->getPost('complete') === 'yes',
'created_by' => user(),
'updated_by' => user(),
]);
@ -119,7 +118,7 @@ class Podcast extends BaseController
$db->transStart();
if (!($newPodcastId = $podcastModel->insert($podcast, true))) {
$db->transComplete();
$db->transRollback();
return redirect()
->back()
->withInput()
@ -135,6 +134,12 @@ class Podcast extends BaseController
$podcastAdminGroup->id
);
// set Podcast categories
(new CategoryModel())->setPodcastCategories(
$newPodcastId,
$this->request->getPost('other_categories')
);
$db->transComplete();
return redirect()->route('podcast-view', [$newPodcastId]);
@ -205,20 +210,22 @@ class Podcast extends BaseController
'image' => download_file($nsItunes->image->attributes()),
'language' => $this->request->getPost('language'),
'category_id' => $this->request->getPost('category'),
'explicit' => empty($nsItunes->explicit)
? false
: $nsItunes->explicit == 'yes',
'author' => $nsItunes->author,
'parental_advisory' => empty($nsItunes->explicit)
? null
: (in_array($nsItunes->explicit, ['yes', 'true'])
? 'explicit'
: null),
'owner_name' => $nsItunes->owner->name,
'owner_email' => $nsItunes->owner->email,
'publisher' => $nsItunes->author,
'type' => empty($nsItunes->type) ? 'episodic' : $nsItunes->type,
'copyright' => $feed->channel[0]->copyright,
'block' => empty($nsItunes->block)
? false
: $nsItunes->block == 'yes',
: $nsItunes->block === 'yes',
'complete' => empty($nsItunes->complete)
? false
: $nsItunes->complete == 'yes',
: $nsItunes->complete === 'yes',
'created_by' => user(),
'updated_by' => user(),
]);
@ -229,7 +236,7 @@ class Podcast extends BaseController
$db->transStart();
if (!($newPodcastId = $podcastModel->insert($podcast, true))) {
$db->transComplete();
$db->transRollback();
return redirect()
->back()
->withInput()
@ -265,7 +272,7 @@ class Podcast extends BaseController
);
$slug = slugify(
$this->request->getPost('slug_field') == 'title'
$this->request->getPost('slug_field') === 'title'
? $item->title
: basename($item->link)
);
@ -285,22 +292,23 @@ class Podcast extends BaseController
'slug' => $slug,
'enclosure' => download_file($item->enclosure->attributes()),
'description' => $converter->convert(
$this->request->getPost('description_field') == 'summary'
$this->request->getPost('description_field') === 'summary'
? $nsItunes->summary
: ($this->request->getPost('description_field') ==
: ($this->request->getPost('description_field') ===
'subtitle_summary'
? '<h3>' .
$nsItunes->subtitle .
"</h3>\n" .
$nsItunes->summary
? $nsItunes->subtitle . "\n" . $nsItunes->summary
: $item->description)
),
'image' => empty($nsItunes->image->attributes())
? null
: download_file($nsItunes->image->attributes()),
'explicit' => $nsItunes->explicit == 'yes',
'explicit' => $nsItunes->explicit
? (in_array($nsItunes->explicit, ['yes', 'true'])
? 'explicit'
: null)
: null,
'number' =>
$this->request->getPost('force_renumber') == 'yes'
$this->request->getPost('force_renumber') === 'yes'
? $itemNumber
: $nsItunes->episode,
'season_number' => empty(
@ -313,7 +321,7 @@ class Podcast extends BaseController
: $nsItunes->episodeType,
'block' => empty($nsItunes->block)
? false
: $nsItunes->block == 'yes',
: $nsItunes->block === 'yes',
'created_by' => user(),
'updated_by' => user(),
]);
@ -324,8 +332,8 @@ class Podcast extends BaseController
$episodeModel = new EpisodeModel();
if (!$episodeModel->save($newEpisode)) {
// FIX: What shall we do?
if (!$episodeModel->insert($newEpisode)) {
// FIXME: What shall we do?
return redirect()
->back()
->withInput()
@ -335,7 +343,7 @@ class Podcast extends BaseController
$db->transComplete();
return redirect()->route('podcast-list');
return redirect()->route('podcast-view', [$newPodcastId]);
}
public function edit()
@ -372,9 +380,6 @@ class Podcast extends BaseController
$this->podcast->title = $this->request->getPost('title');
$this->podcast->name = $this->request->getPost('name');
$this->podcast->description = $this->request->getPost('description');
$this->podcast->episode_description_footer = $this->request->getPost(
'episode_description_footer'
);
$image = $this->request->getFile('image');
if ($image->isValid()) {
@ -382,29 +387,50 @@ class Podcast extends BaseController
}
$this->podcast->language = $this->request->getPost('language');
$this->podcast->category_id = $this->request->getPost('category');
$this->podcast->explicit = $this->request->getPost('explicit') == 'yes';
$this->podcast->author = $this->request->getPost('author');
$this->podcast->parental_advisory =
$this->request->getPost('parental_advisory') !== 'undefined'
? $this->request->getPost('parental_advisory')
: null;
$this->podcast->publisher = $this->request->getPost('publisher');
$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 = $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->podcast->block = $this->request->getPost('block') === 'yes';
$this->podcast->complete =
$this->request->getPost('complete') === 'yes';
$this->updated_by = user();
$podcastModel = new PodcastModel();
$db = \Config\Database::connect();
$db->transStart();
if (!$podcastModel->save($this->podcast)) {
$podcastModel = new PodcastModel();
if (!$podcastModel->update($this->podcast->id, $this->podcast)) {
$db->transRollback();
return redirect()
->back()
->withInput()
->with('errors', $podcastModel->errors());
}
return redirect()->route('podcast-list');
// set Podcast categories
(new CategoryModel())->setPodcastCategories(
$this->podcast->id,
$this->request->getPost('other_categories')
);
$db->transComplete();
return redirect()->route('podcast-view', [$this->podcast->id]);
}
public function latestEpisodes(int $limit)
{
$episodes = (new EpisodeModel())
->orderBy('created_at', 'desc')
->findAll($limit);
return view('admin/podcast/latest_episodes', ['episodes' => $episodes]);
}
public function delete()

View File

@ -86,7 +86,7 @@ class User extends BaseController
// Force user to reset his password on first connection
$user->forcePasswordReset();
if (!$userModel->save($user)) {
if (!$userModel->insert($user)) {
return redirect()
->back()
->withInput()
@ -150,7 +150,7 @@ class User extends BaseController
$userModel = new UserModel();
$this->user->forcePasswordReset();
if (!$userModel->save($this->user)) {
if (!$userModel->update($this->user->id, $this->user)) {
return redirect()
->back()
->with('errors', $userModel->errors());
@ -184,7 +184,7 @@ class User extends BaseController
// TODO: add ban reason?
$this->user->ban('');
if (!$userModel->save($this->user)) {
if (!$userModel->update($this->user->id, $this->user)) {
return redirect()
->back()
->with('errors', $userModel->errors());
@ -205,7 +205,7 @@ class User extends BaseController
$userModel = new UserModel();
$this->user->unBan();
if (!$userModel->save($this->user)) {
if (!$userModel->update($this->user->id, $this->user)) {
return redirect()
->back()
->with('errors', $userModel->errors());

View File

@ -12,6 +12,14 @@ use App\Entities\User;
class Auth extends \Myth\Auth\Controllers\AuthController
{
/**
* An array of helpers to be automatically loaded
* upon class instantiation.
*
* @var array
*/
protected $helpers = ['components'];
/**
* Attempt to register a new user.
*/

View File

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

View File

@ -57,6 +57,7 @@ class Episode extends BaseController
$data = [
'previousEpisode' => $previousNextEpisodes['previous'],
'nextEpisode' => $previousNextEpisodes['next'],
'podcast' => $this->podcast,
'episode' => $this->episode,
];

View File

@ -50,10 +50,11 @@ class AddPodcasts extends Migration
'unsigned' => true,
'default' => 0,
],
'explicit' => [
'type' => 'TINYINT',
'constraint' => 1,
'default' => 0,
'parental_advisory' => [
'type' => 'ENUM',
'constraint' => ['clean', 'explicit'],
'null' => true,
'default' => null,
],
'owner_name' => [
'type' => 'VARCHAR',
@ -63,7 +64,7 @@ class AddPodcasts extends Migration
'type' => 'VARCHAR',
'constraint' => 1024,
],
'author' => [
'publisher' => [
'type' => 'VARCHAR',
'constraint' => 1024,
'null' => true,
@ -92,10 +93,6 @@ class AddPodcasts extends Migration
'type' => 'TEXT',
'null' => true,
],
'custom_html_head' => [
'type' => 'TEXT',
'null' => true,
],
'created_by' => [
'type' => 'INT',
'constraint' => 11,

View File

@ -70,10 +70,11 @@ class AddEpisodes extends Migration
'constraint' => 1024,
'null' => true,
],
'explicit' => [
'type' => 'TINYINT',
'constraint' => 1,
'default' => 0,
'parental_advisory' => [
'type' => 'ENUM',
'constraint' => ['clean', 'explicit'],
'null' => true,
'default' => null,
],
'number' => [
'type' => 'INT',

View File

@ -0,0 +1,42 @@
<?php
/**
* Class AddPodcastsCategories
* Creates podcasts_categories table in database
*
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddPodcastsCategories extends Migration
{
public function up()
{
$this->forge->addField([
'podcast_id' => [
'type' => 'BIGINT',
'constraint' => 20,
'unsigned' => true,
],
'category_id' => [
'type' => 'INT',
'constraint' => 10,
'unsigned' => true,
],
]);
$this->forge->addPrimaryKey(['podcast_id', 'category_id']);
$this->forge->addForeignKey('podcast_id', 'podcasts', 'id');
$this->forge->addForeignKey('category_id', 'categories', 'id');
$this->forge->createTable('podcasts_categories');
}
public function down()
{
$this->forge->dropTable('podcasts_categories');
}
}

View File

@ -66,7 +66,7 @@ class Episode extends Entity
'enclosure_filesize' => 'integer',
'description' => 'string',
'image_uri' => '?string',
'explicit' => 'boolean',
'parental_advisory' => '?string',
'number' => '?integer',
'season_number' => '?integer',
'type' => 'string',

View File

@ -37,6 +37,16 @@ class Podcast extends Entity
*/
protected $category;
/**
* @var \App\Entities\Category[]
*/
protected $other_categories;
/**
* @var integer[]
*/
protected $other_categories_ids;
/**
* @var \App\Entities\User[]
*/
@ -60,8 +70,8 @@ class Podcast extends Entity
'image_uri' => 'string',
'language' => 'string',
'category_id' => 'integer',
'explicit' => 'boolean',
'author' => '?string',
'parental_advisory' => '?string',
'publisher' => '?string',
'owner_name' => '?string',
'owner_email' => '?string',
'type' => 'string',
@ -69,7 +79,6 @@ class Podcast extends Entity
'block' => 'boolean',
'complete' => 'boolean',
'episode_description_footer' => '?string',
'custom_html_head' => '?string',
'created_by' => 'integer',
'updated_by' => 'integer',
'imported_feed_url' => '?string',
@ -225,4 +234,33 @@ class Podcast extends Entity
return $this->platforms;
}
public function getOtherCategories()
{
if (empty($this->id)) {
throw new \RuntimeException(
'Podcast must be created before getting other categories.'
);
}
if (empty($this->other_categories)) {
$this->other_categories = (new CategoryModel())->getPodcastCategories(
$this->id
);
}
return $this->other_categories;
}
public function getOtherCategoriesIds()
{
if (empty($this->other_categories_ids)) {
$this->other_categories_ids = array_column(
$this->getOtherCategories(),
'id'
);
}
return $this->other_categories_ids;
}
}

View File

@ -1,5 +1,11 @@
<?php
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Entities;
use App\Models\PodcastModel;

View File

@ -9,16 +9,15 @@
use Config\Services;
/**
* Returns the inline svg icon
* Renders the breadcrumb navigation through the Breadcrumb service
*
* @param string $name name of the icon file without the .svg extension
* @param string $class to be added to the svg string
* @param string $class to be added to the breadcrumb nav
* @return string html breadcrumb
*/
function render_breadcrumb()
function render_breadcrumb($class = null)
{
$breadcrumb = Services::breadcrumb();
return $breadcrumb->render();
return $breadcrumb->render($class);
}
function replace_breadcrumb_params($newParams)

View File

@ -0,0 +1,258 @@
<?php
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
if (!function_exists('button')) {
/**
* Button component
*
* Creates a stylized button or button like anchor tag if the URL is defined.
*
* @param string $label The button label
* @param mixed|null $uri URI string or array of URI segments
* @param array $customOptions button options: variant, size, iconLeft, iconRight
* @param array $customAttributes Additional attributes
*
* @return string
*/
function button(
string $label = '',
$uri = null,
$customOptions = [],
$customAttributes = []
): string {
$defaultOptions = [
'variant' => 'default',
'size' => 'base',
'iconLeft' => null,
'iconRight' => null,
'isRoundedFull' => false,
'isSquared' => false,
];
$options = array_merge($defaultOptions, $customOptions);
$baseClass =
'inline-flex items-center shadow-xs outline-none focus:shadow-outline';
$variantClass = [
'default' => 'bg-gray-300 hover:bg-gray-400',
'primary' => 'text-white bg-green-500 hover:bg-green-600',
'secondary' => 'text-white bg-gray-700 hover:bg-gray-800',
'success' => 'text-white bg-green-600 hover:bg-green-700',
'danger' => 'text-white bg-red-600 hover:bg-red-700',
'warning' => 'text-black bg-yellow-500 hover:bg-yellow-600',
'info' => 'text-white bg-teal-500 hover:bg-teal-600',
];
$sizeClass = [
'small' => 'text-xs md:text-sm ',
'base' => 'text-sm md:text-base',
'large' => 'text-lg md:text-xl',
];
$basePaddings = [
'small' => 'px-1 md:px-2 md:py-1',
'base' => 'px-2 py-1 md:px-3 md:py-2',
'large' => 'px-3 py-2 md:px-4 md:py-2',
];
$squaredPaddings = [
'small' => 'p-1',
'base' => 'p-2',
'large' => 'p-3',
];
$roundedClass = [
'full' => 'rounded-full',
'small' => 'rounded-sm md:rounded',
'base' => 'rounded md:rounded-md',
'large' => 'rounded-md md:rounded-lg',
];
$buttonClass =
$baseClass .
' ' .
($options['isRoundedFull']
? $roundedClass['full']
: $roundedClass[$options['size']]) .
' ' .
($options['isSquared']
? $squaredPaddings[$options['size']]
: $basePaddings[$options['size']]) .
' ' .
$sizeClass[$options['size']] .
' ' .
$variantClass[$options['variant']];
if (!empty($customAttributes['class'])) {
$buttonClass .= ' ' . $customAttributes['class'];
unset($customAttributes['class']);
}
if ($options['iconLeft']) {
$label = icon($options['iconLeft'], 'mr-2') . $label;
}
if ($options['iconRight']) {
$label .= icon($options['iconRight'], 'ml-2');
}
if ($uri) {
return anchor(
$uri,
$label,
array_merge(
[
'class' => $buttonClass,
],
$customAttributes
)
);
}
$defaultButtonAttributes = [
'type' => 'button',
];
$attributes = array_merge($defaultButtonAttributes, $customAttributes);
return '<button class="' .
$buttonClass .
'"' .
stringify_attributes($attributes) .
'>' .
$label .
'</button>';
}
}
// ------------------------------------------------------------------------
if (!function_exists('icon_button')) {
/**
* Icon Button component
*
* Abstracts the `button()` helper to create a stylized icon button
*
* @param string $label The button label
* @param mixed|null $uri URI string or array of URI segments
* @param array $customOptions button options: variant, size, iconLeft, iconRight
* @param array $customAttributes Additional attributes
*
* @return string
*/
function icon_button(
string $icon,
string $title,
$uri = null,
$customOptions = [],
$customAttributes = []
): string {
$defaultOptions = [
'isRoundedFull' => true,
'isSquared' => true,
];
$options = array_merge($defaultOptions, $customOptions);
$defaultAttributes = [
'title' => $title,
'data-toggle' => 'tooltip',
'data-placement' => 'bottom',
];
$attributes = array_merge($defaultAttributes, $customAttributes);
return button(icon($icon), $uri, $options, $attributes);
}
}
// ------------------------------------------------------------------------
if (!function_exists('hint_tooltip')) {
/**
* Hint component
*
* Used to produce tooltip with a question mark icon for hint texts
*
* @param string $hintText The hint text
*
* @return string
*/
function hint_tooltip(string $hintText = '', string $class = ''): string
{
$tooltip =
'<span data-toggle="tooltip" data-placement="bottom" tabindex="0" title="' .
$hintText .
'" class="inline-block align-middle outline-none focus:shadow-outline';
if ($class !== '') {
$tooltip .= ' ' . $class;
}
return $tooltip . '">' . icon('question') . '</span>';
}
}
// ------------------------------------------------------------------------
if (!function_exists('data_table')) {
/**
* Data table component
*
* Creates a stylized table.
*
* @param array $columns array of associate arrays with `header` and `cell` keys where `cell` is a function with a row of $data as parameter
* @param array $data data to loop through and display in rows
* @param array ...$rest Any other argument to pass to the `cell` function
*
* @return string
*/
function data_table($columns, $data = [], ...$rest): string
{
$table = new \CodeIgniter\View\Table();
$template = [
'table_open' => '<table class="w-full whitespace-no-wrap">',
'thead_open' =>
'<thead class="text-xs font-semibold text-left text-gray-500 uppercase border-b">',
'heading_cell_start' => '<th class="px-4 py-2">',
'cell_start' => '<td class="px-4 py-2">',
'cell_alt_start' => '<td class="px-4 py-2">',
'row_start' => '<tr class="bg-gray-100 hover:bg-green-100">',
'row_alt_start' => '<tr class="hover:bg-green-100">',
];
$table->setTemplate($template);
$tableHeaders = [];
foreach ($columns as $column) {
array_push($tableHeaders, $column['header']);
}
$table->setHeading($tableHeaders);
if ($dataCount = count($data)) {
for ($i = 0; $i < $dataCount; $i++) {
$row = $data[$i];
$rowData = [];
foreach ($columns as $column) {
array_push($rowData, $column['cell']($row, ...$rest));
}
$table->addRow($rowData);
}
} else {
return lang('Common.no_data');
}
return '<div class="overflow-x-auto bg-white rounded-lg shadow" >' .
$table->generate() .
'</div>';
}
}
// ------------------------------------------------------------------------

187
app/Helpers/form_helper.php Normal file
View File

@ -0,0 +1,187 @@
<?php
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
if (!function_exists('form_section')) {
/**
* Form section
*
* Used to produce a responsive form section with a title and subtitle. To close section,
* use form_section_close()
*
* @param string $title The section title
* @param string $subtitle The section subtitle
* @param array $attributes Additional attributes
*
* @return string
*/
function form_section(
string $title = '',
string $subtitle = '',
array $attributes = []
): string {
$section =
'<div class="flex flex-wrap w-full gap-6 mb-8"' .
stringify_attributes($attributes) .
">\n";
$info =
'<div class="w-full max-w-xs"><h2 class="text-lg font-semibold">' .
$title .
'</h2><p class="text-sm text-gray-600">' .
$subtitle .
'</p></div>';
return $section . $info . '<div class="flex flex-col w-full max-w-lg">';
}
}
//--------------------------------------------------------------------
if (!function_exists('form_section_close')) {
/**
* Form Section close Tag
*
* @param string $extra
*
* @return string
*/
function form_section_close(string $extra = ''): string
{
return '</div></div>' . $extra;
}
}
//--------------------------------------------------------------------
if (!function_exists('form_switch')) {
/**
* Form Checkbox Switch
*
* Abstracts form_label to stylize it as a switch toggle
*
* @param array $data
* @param string $value
* @param boolean $checked
* @param mixed $extra
*
* @return string
*/
function form_switch(
$label = '',
$data = '',
string $value = '',
bool $checked = false,
$class = '',
$extra = ''
): string {
$data['class'] = 'form-switch';
return '<label class="relative inline-flex items-center' .
' ' .
$class .
'">' .
form_checkbox($data, $value, $checked, $extra) .
'<span class="form-switch-slider"></span>' .
'<span class="ml-2">' .
$label .
'</span></label>';
}
}
//--------------------------------------------------------------------
if (!function_exists('form_label')) {
/**
* Form Label Tag
*
* @param string $label_text The text to appear onscreen
* @param string $id The id the label applies to
* @param array $attributes Additional attributes
* @param string $hintText Hint text to add next to the label
* @param boolean $isOptional adds an optional text if true
*
* @return string
*/
function form_label(
string $label_text = '',
string $id = '',
array $attributes = [],
string $hintText = '',
bool $isOptional = false
): string {
$label = '<label';
if ($id !== '') {
$label .= ' for="' . $id . '"';
}
if (is_array($attributes) && $attributes) {
foreach ($attributes as $key => $val) {
$label .= ' ' . $key . '="' . $val . '"';
}
}
$label_content = $label_text;
if ($isOptional) {
$label_content .=
'<small class="ml-1 lowercase">(' .
lang('Common.optional') .
')</small>';
}
if ($hintText !== '') {
$label_content .= hint_tooltip($hintText, 'ml-1');
}
return $label . '>' . $label_content . '</label>';
}
}
//--------------------------------------------------------------------
if (!function_exists('form_multiselect')) {
/**
* Multi-select menu
*
* @param string $name
* @param array $options
* @param array $selected
* @param mixed $extra
*
* @return string
*/
function form_multiselect(
string $name = '',
array $options = [],
array $selected = [],
$customExtra = ''
): string {
$defaultExtra = [
'data-class' => $customExtra['class'],
'data-select-text' => lang('Common.forms.multiSelect.selectText'),
'data-loading-text' => lang('Common.forms.multiSelect.loadingText'),
'data-no-results-text' => lang(
'Common.forms.multiSelect.noResultsText'
),
'data-no-choices-text' => lang(
'Common.forms.multiSelect.noChoicesText'
),
'data-max-item-text' => lang(
'Common.forms.multiSelect.maxItemText'
),
];
$extra = stringify_attributes(array_merge($defaultExtra, $customExtra));
if (stripos($extra, 'multiple') === false) {
$extra .= ' multiple="multiple"';
}
return form_dropdown($name, $options, $selected, $extra);
}
}
//--------------------------------------------------------------------

View File

@ -7,7 +7,6 @@
*/
use App\Libraries\SimpleRSSElement;
use App\Models\CategoryModel;
use CodeIgniter\I18n\Time;
/**
@ -18,14 +17,8 @@ use CodeIgniter\I18n\Time;
*/
function get_rss_feed($podcast)
{
$category_model = new CategoryModel();
$episodes = $podcast->episodes;
$podcast_category = $category_model
->where('id', $podcast->category_id)
->first();
$itunes_namespace = 'http://www.itunes.com/dtds/podcast-1.0.dtd';
$rss = new SimpleRSSElement(
@ -60,39 +53,20 @@ function get_rss_feed($podcast)
$itunes_image->addAttribute('href', $podcast->image->url);
$channel->addChild('language', $podcast->language);
$itunes_category = $channel->addChild('category', null, $itunes_namespace);
$itunes_category->addAttribute(
'text',
$podcast_category->parent
? $podcast_category->parent->apple_category
: $podcast_category->apple_category
);
if ($podcast_category->parent) {
$itunes_category_child = $itunes_category->addChild(
'category',
null,
$itunes_namespace
);
$itunes_category_child->addAttribute(
'text',
$podcast_category->apple_category
);
$channel->addChild(
'category',
$podcast_category->parent->apple_category
);
// set main category first, then other categories as apple
add_category_tag($channel, $podcast->category);
foreach ($podcast->other_categories as $other_category) {
add_category_tag($channel, $other_category);
}
$channel->addChild('category', $podcast_category->apple_category);
$channel->addChild(
'explicit',
$podcast->explicit ? 'true' : 'false',
$podcast->parental_advisory === 'explicit' ? 'true' : 'false',
$itunes_namespace
);
$podcast->author &&
$channel->addChild('author', $podcast->author, $itunes_namespace);
$podcast->publisher &&
$channel->addChild('author', $podcast->publisher, $itunes_namespace);
$channel->addChild('link', $podcast->link);
$owner = $channel->addChild('owner', null, $itunes_namespace);
@ -137,11 +111,13 @@ function get_rss_feed($podcast)
$itunes_namespace
);
$episode_itunes_image->addAttribute('href', $episode->image->feed_url);
$item->addChild(
'explicit',
$episode->explicit ? 'true' : 'false',
$itunes_namespace
);
$episode->parental_advisory &&
$item->addChild(
'explicit',
$episode->parental_advisory === 'explicit' ? 'true' : 'false',
$itunes_namespace
);
$item->addChild('episode', $episode->number, $itunes_namespace);
$episode->season_number &&
@ -157,3 +133,35 @@ function get_rss_feed($podcast)
return $rss->asXML();
}
/**
* Adds <itunes:category> and <category> tags to node for a given category
*
* @param \SimpleXMLElement $node
* @param \App\Entities\Category $category
*
* @return void
*/
function add_category_tag($node, $category)
{
$itunes_namespace = 'http://www.itunes.com/dtds/podcast-1.0.dtd';
$itunes_category = $node->addChild('category', null, $itunes_namespace);
$itunes_category->addAttribute(
'text',
$category->parent
? $category->parent->apple_category
: $category->apple_category
);
if ($category->parent) {
$itunes_category_child = $itunes_category->addChild(
'category',
null,
$itunes_namespace
);
$itunes_category_child->addAttribute('text', $category->apple_category);
$node->addChild('category', $category->parent->apple_category);
}
$node->addChild('category', $category->apple_category);
}

View File

@ -13,16 +13,17 @@
* @param string $class to be added to the svg string
* @return string svg contents
*/
function icon($name, $class = null)
function icon(string $name, string $class = '')
{
$svg_contents = file_get_contents('assets/icons/' . $name . '.svg');
if ($class) {
if ($class !== '') {
$svg_contents = str_replace(
'<svg',
'<svg class="' . $class . '"',
$svg_contents
);
}
return $svg_contents;
}

View File

@ -7,17 +7,17 @@
*/
return [
'go_to_website' => 'Go to website',
'dashboard' => 'Dashboard',
'podcasts' => 'Podcasts',
'users' => 'Users',
'pages' => 'Pages',
'admin' => 'Home',
'podcasts' => 'Podcasts',
'podcast-list' => 'All podcasts',
'podcast-create' => 'New podcast',
'podcast-import' => 'Import a podcast',
'users' => 'Users',
'user-list' => 'All users',
'user-create' => 'New user',
'pages' => 'Pages',
'page-list' => 'All pages',
'page-create' => 'New Page',
'go_to_website' => 'Go to website',
];

View File

@ -7,12 +7,25 @@
*/
return [
'yes' => 'Yes',
'no' => 'No',
'optional' => 'Optional',
'no_data' => 'No data found!',
'home' => 'Home',
'explicit' => 'Explicit',
'mediumDate' => '{0,date,medium}',
'duration' => '{0,duration}',
'powered_by' => 'Powered by {castopod}.',
'actions' => 'Actions',
'pageInfo' => 'Page {currentPage} out of {pageCount}',
'forms' => [
'multiSelect' => [
'selectText' => 'Press to select',
'loadingText' => 'Loading...',
'noResultsText' => 'No results found',
'noChoicesText' => 'No choices to choose from',
'maxItemText' => 'Cannot add more items',
],
'image_size_hint' =>
'Image must be squared with at least 1400px wide and tall.',
],

View File

@ -14,6 +14,10 @@ return [
'edit_role' => 'Update role for {0}',
'edit' => 'Edit',
'remove' => 'Remove',
'list' => [
'username' => 'Username',
'role' => 'Role',
],
'form' => [
'user' => 'User',
'role' => 'Role',

View File

@ -13,6 +13,9 @@ return [
'next_season' => 'Next season',
'season' => 'Season {seasonNumber}',
'number' => 'Episode {episodeNumber}',
'number_abbr' => 'Ep. {episodeNumber}',
'season_episode' => 'Season {seasonNumber} episode {episodeNumber}',
'season_episode_abbr' => 'S{seasonNumber}E{episodeNumber}',
'all_podcast_episodes' => 'All podcast episodes',
'back_to_podcast' => 'Go back to podcast',
'edit' => 'Edit',
@ -20,50 +23,51 @@ return [
'go_to_page' => 'Go to page',
'create' => 'Add an episode',
'form' => [
'enclosure' => 'Audio file',
'enclosure' => 'Choose an .mp3 or .m4a audio file…',
'info_section_title' => 'Episode info',
'info_section_subtitle' => '',
'image' => 'Cover image',
'image_hint' =>
'If you do not set an image, the podcast cover will be used instead.',
'title' => 'Title',
'title_help' =>
'This episode title. It should contain a clear, concise name for your episode. Dont specify the episode number or season number here.',
'title_hint' =>
'Should contain a clear and concise episode name. Do not specify the episode or season numbers here.',
'slug' => 'Slug',
'slug_help' =>
'This episode slug. It will be used for its URL address.',
'description' => 'Description',
'description_help' =>
'This is where you type the episode show notes. You may add rich text, links, images…',
'image' => 'Image',
'image_help' =>
'This episode image. If an image is already in the audio file, you dont need to add one here. If you add no image to this episode, the podcast image will be used instead.',
'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.',
'slug_hint' => 'Used for generating the episode URL.',
'season_number' => 'Season',
'episode_number' => 'Episode',
'type' => [
'label' => 'Type',
'hint' =>
'- <strong>full</strong>: complete content the episode.<br/>- <strong>trailer</strong>: short, promotional piece of content that represents a preview of the current show.<br/>- <strong>bonus</strong>: extra content for the show (for example, behind the scenes info or interviews with the cast) or cross-promotional content for another show.',
'full' => 'Full',
'full_help' =>
'Specify full when you are submitting the complete content of your episode.',
'trailer' => 'Trailer',
'trailer_help' =>
'Specify trailer when you are submitting a short, promotional piece of content that represents a preview of your current show.',
'bonus' => 'Bonus',
'bonus_help' =>
'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.',
],
'episode_number' => 'Episode number',
'episode_number_help' =>
'The episode number is mandatory for serial podcasts but optional for episodic podcasts.',
'season_number' => 'Season number',
'season_number_help' =>
'Season number is a non-zero integer (1, 2, 3, etc.) representing this episode season number.',
'block' => 'Block',
'block_help' =>
'This episode show or hide status. If you want this episode removed from the Apple directory, use this tag.',
'show_notes_section_title' => 'Show notes',
'show_notes_section_subtitle' =>
'Up to 4000 characters, be clear and concise. Show notes help potential listeners in finding the episode.',
'description' => 'Description',
'description_footer' => 'Description footer',
'description_footer_hint' =>
'This text is added at the end of each episode description, it is a good place to input your social links for example.',
'publication_section_title' => 'Publication info',
'publication_section_subtitle' => '',
'published_at' => [
'label' => 'Publication date',
'date' => 'Date',
'time' => 'Time',
],
'parental_advisory' => [
'label' => 'Parental advisory',
'hint' => 'Does the episode contain explicit content?',
'undefined' => 'undefined',
'clean' => 'Clean',
'explicit' => 'Explicit',
],
'block' => 'Episode should be hidden from all platforms',
'block_hint' =>
'The episode show or hide status. If you want this episode removed from the Apple directory, toggle this on.',
'submit_create' => 'Create episode',
'submit_edit' => 'Save episode',
],

View File

@ -7,6 +7,7 @@
*/
return [
'page' => 'Page',
'all_pages' => 'All pages',
'create' => 'New page',
'go_to_page' => 'Go to page',

19
app/Language/en/Pager.php Normal file
View File

@ -0,0 +1,19 @@
<?php
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'pageNavigation' => 'Page navigation',
'first' => 'First',
'previous' => 'Previous',
'next' => 'Next',
'last' => 'Last',
'older' => 'Older',
'newer' => 'Newer',
'invalidTemplate' => '{0} is not a valid Pager template.',
'invalidPaginationGroup' => '{0} is not a valid Pagination group.',
];

View File

@ -9,8 +9,8 @@
return [
'all_podcasts' => 'All podcasts',
'no_podcast' => 'No podcast found!',
'create' => 'Create a Podcast',
'import' => 'Create and Import a Podcast from an existing Feed',
'create' => 'Create a podcast',
'import' => 'Import a podcast',
'new_episode' => 'New Episode',
'feed' => 'RSS feed',
'view' => 'View podcast',
@ -19,93 +19,55 @@ return [
'see_episodes' => 'See episodes',
'see_contributors' => 'See contributors',
'go_to_page' => 'Go to page',
'latest_episodes' => 'Latest episodes',
'see_all_episodes' => 'See all episodes',
'form' => [
'identity_section_title' => 'Podcast identity',
'identity_section_subtitle' => 'These fields allow you to get noticed.',
'image' => 'Cover image',
'title' => 'Title',
'title_help' =>
'The podcast title will be shown on all podcasts platforms (such as Apple Podcasts) and players (such as Podcast Addict).',
'name' => 'Name',
'name_help' =>
'The podcast will be used in the URL address. It will be used as a Fediverse actor name, (for instance, it will be the podcast Mastodons name).',
'description' => 'Description',
'description_help' =>
'It will be shown on all podcasts platforms (such as Apple Podcasts) and players (such as Podcast Addict).',
'episode_description_footer' => 'Episode description footer',
'episode_description_footer_help' =>
'This text will be automatically added at the end of each episode description, so that you dont have to copy/paste it a gazillion times.',
'image' => 'Image',
'image_help' =>
'This podcast image. It must be square, JPEG or PNG, minimum 1400 x 1400 pixels and maximum 3000 x 3000 pixels.',
'language' => 'Language',
'language_help' => 'The language spoken on the podcast.',
'category' => 'Category',
'category_help' =>
'This podcast category. Because no one uses subcategories, Castopod does not allow you te use one.',
'explicit' => 'Explicit',
'explicit_help' =>
'The podcast parental advisory information. Does it contain explicit content?',
'owner_name' => 'Owner name',
'owner_name_help' =>
'For administrative use only. It will not be shown on podcasts platforms (such as Apple Podcasts) nor players (such as Podcast Addict) but it is visible in the public RSS feed.',
'owner_email' => 'Owner email',
'owner_email_help' =>
'It will be used by most platforms to verify this podcast ownership. It will not be shown on podcasts platforms (such as Apple Podcasts) nor players (such as Podcast Addict) but it is visible in the public RSS feed.',
'author' => 'Author',
'author_help' =>
'The group responsible for creating the show. Show author most often refers to the parent company or network of a podcast. This field is sometimes labeled as Author.',
'name_hint' => 'Used for generating the podcast URL.',
'type' => [
'label' => 'Type',
'hint' =>
'- <strong>episodic</strong>: if episodes are intended to be consumed without any specific order. Newest episodes will be presented first.<br/>- <strong>serial</strong>: if episodes are intended to be consumed in sequential order. The oldest episodes will be presented first.',
'episodic' => 'Episodic',
'episodic_help' =>
'Specify episodic when episodes are intended to be consumed without any specific order. The newest episodes will be presented first.',
'serial' => 'Serial',
'serial_help' =>
'Specify serial when episodes are intended to be consumed in sequential order. The oldest episodes will be presented first.',
],
'description' => 'Description',
'classification_section_title' => 'Classification',
'classification_section_subtitle' =>
'These fields will impact your audience and competition.',
'language' => 'Language',
'category' => 'Category',
'other_categories' => 'Other categories',
'parental_advisory' => [
'label' => 'Parental advisory',
'hint' => 'Does it contain explicit content?',
'undefined' => 'undefined',
'clean' => 'Clean',
'explicit' => 'Explicit',
],
'author_section_title' => 'Author',
'author_section_subtitle' => 'Who is managing the podcast?',
'owner_name' => 'Owner name',
'owner_name_hint' =>
'For administrative use only. Visible in the public RSS feed.',
'owner_email' => 'Owner email',
'owner_email_hint' =>
'Will be used by most platforms to verify the podcast ownership. Visible in the public RSS feed.',
'publisher' => 'Publisher',
'publisher_hint' =>
'The group responsible for creating the show. Often refers to the parent company or network of a podcast. This field is sometimes labeled as Author.',
'copyright' => 'Copyright',
'copyright_help' =>
'The podcast copyright details, such as "2020 (cc)(by-nc-sa)" or "©2020".',
'block' => 'Block',
'block_help' =>
'If you want your show removed from all platforms, use this tag.',
'complete' => 'Complete',
'complete_help' =>
'Check this if you will never publish another episode to your podcast.',
'custom_html_head' => 'Custom HTML code in <head/>',
'custom_html_head_help' =>
'Add here any HTML code that you would like to see on all this podcast pages within the <head/> tag.',
'status_section_title' => 'Status',
'status_section_subtitle' => 'Dead or alive?',
'block' => 'Podcast should be hidden from all platforms',
'complete' => 'Podcast will not be having new episodes',
'submit_create' => 'Create podcast',
'submit_edit' => 'Save podcast',
],
'form_import' => [
'name' => 'Name',
'name_help' =>
'This podcast name. It will be used in the URL address. It will be used as a Fediverse actor name, (for instance, it will be the podcast Mastodons name).',
'imported_feed_url' => 'Feed URL',
'imported_feed_url_help' =>
'Make sure you are legally allowed to copy that podcast.',
'force_renumber' => 'Force episodes renumbering',
'force_renumber_help' =>
'Use this if your old podcast does not have number but you want some on your new one.',
'season_number' => 'Season number',
'season_number_help' =>
'Use this if your old podcast does not have season number but you want one on your new one. Leave blank otherwise.',
'slug_field' => [
'label' => 'Which field should be used to calculate episode slug',
'link' => '&lt;link&gt;',
'title' => '&lt;title&gt;',
],
'description_field' => [
'label' => 'Source field used for episode description / show notes',
'description' => '&lt;description&gt;',
'summary' => '&lt;itunes:summary&gt;',
'subtitle_summary' =>
'&lt;itunes:subtitle&gt; &lt;itunes:summary&gt;',
],
'max_episodes' => 'Maximum number of episodes to import',
'max_episodes_helper' => 'Leave blank to import all episodes',
'submit_import' => 'Import podcast',
'submit_importing' => 'Importing podcast, this could take a while…',
],
'category_options' => [
'uncategorized' => 'uncategorized',
'arts' => 'Arts',
@ -219,7 +181,7 @@ return [
'film_reviews' => 'Film Reviews',
'tv_reviews' => 'TV Reviews',
],
'by' => 'By {author}',
'by' => 'By {publisher}',
'season' => 'Season {seasonNumber}',
'list_of_episodes_year' => '{year} episodes',
'list_of_episodes_season' => 'Season {seasonNumber} episodes',

View File

@ -0,0 +1,43 @@
<?php
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'old_podcast_section_title' => 'The podcast to import',
'old_podcast_section_subtitle' => '',
'imported_feed_url' => 'Feed URL',
'imported_feed_url_hint' =>
'The feed must be in `.xml` format. Make sure you are legally allowed to copy the podcast.',
'new_podcast_section_title' => 'The new podcast',
'new_podcast_section_subtitle' => '',
'name' => 'Name',
'name_hint' => 'Used for generating the podcast URL.',
'advanced_params_section_title' => 'Advanced parameters',
'advanced_params_section_subtitle' =>
'Keep the default values if you have no idea of what the fields are for.',
'slug_field' => [
'label' => 'Which field should be used to calculate episode slug',
'link' => '&lt;link&gt;',
'title' => '&lt;title&gt;',
],
'description_field' => [
'label' => 'Source field used for episode description / show notes',
'description' => '&lt;description&gt;',
'summary' => '&lt;itunes:summary&gt;',
'subtitle_summary' =>
'&lt;itunes:subtitle&gt; + &lt;itunes:summary&gt;',
],
'force_renumber' => 'Force episodes renumbering',
'force_renumber_hint' =>
'Use this if your podcast does not have episode numbers but wish to set them during import.',
'season_number' => 'Season number',
'season_number_hint' =>
'Use this if your podcast does not have a season number but wish to set one during import. Leave blank otherwise.',
'max_episodes' => 'Maximum number of episodes to import',
'max_episodes_hint' => 'Leave blank to import all episodes',
'submit' => 'Import podcast',
];

View File

@ -0,0 +1,23 @@
<?php
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'go_to_page' => 'Go to podcast page',
'dashboard' => 'Podcast dashboard',
'podcast-view' => 'Home',
'podcast-edit' => 'Edit podcast',
'episodes' => 'Episodes',
'episode-list' => 'All episodes',
'episode-create' => 'New episode',
'analytics' => 'Analytics',
'contributors' => 'Contributors',
'contributor-list' => 'All contributors',
'contributor-add' => 'Add contributor',
'settings' => 'Settings',
'platforms' => 'Podcast platforms',
];

View File

@ -12,15 +12,21 @@ return [
'ban' => 'Ban',
'unban' => 'Unban',
'delete' => 'Delete',
'create' => 'Create a user',
'create' => 'New user',
'view' => '{username}\'s info',
'all_users' => 'All users',
'list' => [
'user' => 'User',
'roles' => 'Roles',
'banned' => 'Banned?',
],
'form' => [
'email' => 'Email',
'username' => 'Username',
'password' => 'Password',
'new_password' => 'New Password',
'roles' => 'Roles',
'permissions' => 'Permissions',
'submit_create' => 'Create user',
'submit_edit' => 'Save',
'submit_password_change' => 'Change!',

View File

@ -75,7 +75,7 @@ class Breadcrumb
*
* @return string
*/
public function render()
public function render($class = null)
{
$listItems = '';
$keys = array_keys($this->links);
@ -97,7 +97,9 @@ class Breadcrumb
return '<nav aria-label="' .
lang('Breadcrumb.label') .
'"><ol class="breadcrumb">' .
'"><ol class="breadcrumb ' .
$class .
'">' .
$listItems .
'</ol></nav>';
}

View File

@ -53,4 +53,72 @@ class CategoryModel extends Model
return $options;
}
/**
* Sets categories for a given podcast
*
* @param int $podcastId
* @param array $categories
*
* @return integer|false Number of rows inserted or FALSE on failure
*/
public function setPodcastCategories($podcastId, $categories)
{
cache()->delete("podcasts{$podcastId}_categories");
// Remove already previously set categories to overwrite them
$this->db
->table('podcasts_categories')
->delete(['podcast_id' => $podcastId]);
if (!empty($categories)) {
// prepare data for `podcasts_categories` table
$data = array_reduce(
$categories,
function ($result, $categoryId) use ($podcastId) {
$result[] = [
'podcast_id' => $podcastId,
'category_id' => $categoryId,
];
return $result;
},
[]
);
// Set podcast categories
return $this->db->table('podcasts_categories')->insertBatch($data);
}
// no row has been inserted after deletion
return 0;
}
/**
* Gets all the podcast categories
*
* @param int $podcastId
*
* @return \App\Entities\Category[]
*/
public function getPodcastCategories($podcastId)
{
if (!($categories = cache("podcasts{$podcastId}_categories"))) {
$categories = $this->select('categories.*')
->join(
'podcasts_categories',
'podcasts_categories.category_id = categories.id'
)
->where('podcasts_categories.podcast_id', $podcastId)
->findAll();
cache()->save(
"podcasts{$podcastId}_categories",
$categories,
DECADE
);
}
return $categories;
}
}

View File

@ -26,7 +26,7 @@ class EpisodeModel extends Model
'enclosure_filesize',
'description',
'image_uri',
'explicit',
'parental_advisory',
'number',
'season_number',
'type',
@ -47,7 +47,6 @@ class EpisodeModel extends Model
'slug' => 'required|regex_match[/^[a-zA-Z0-9\-]{1,191}$/]',
'enclosure_uri' => 'required',
'description' => 'required',
'image_uri' => 'required',
'number' => 'is_natural_no_zero|permit_empty',
'season_number' => 'is_natural_no_zero|permit_empty',
'type' => 'required',

View File

@ -24,15 +24,14 @@ class PodcastModel extends Model
'image_uri',
'language',
'category_id',
'explicit',
'parental_advisory',
'owner_name',
'owner_email',
'author',
'publisher',
'type',
'copyright',
'block',
'complete',
'custom_html_head',
'created_by',
'updated_by',
'imported_feed_url',

View File

@ -1,11 +1,15 @@
import Dropdown from "./modules/Dropdown";
import HTMLEditor from "./modules/HTMLEditor";
import EnclosureInput from "./modules/EnclosureInput";
import MarkdownEditor from "./modules/MarkdownEditor";
import MultiSelect from "./modules/MultiSelect";
import SidebarToggler from "./modules/SidebarToggler";
import Slugify from "./modules/Slugify";
import Tooltip from "./modules/Tooltip";
Dropdown();
Tooltip();
MarkdownEditor();
HTMLEditor();
MultiSelect();
Slugify();
SidebarToggler();
EnclosureInput();

View File

@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M10.828 12l4.95 4.95-1.414 1.414L8 12l6.364-6.364 1.414 1.414z"/>
<path d="M7.828 11H20v2H7.828l5.364 5.364-1.414 1.414L4 12l7.778-7.778 1.414 1.414z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 214 B

After

Width:  |  Height:  |  Size: 226 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M14 12l-4 4V8z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 166 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M10.828 12l4.95 4.95-1.414 1.414L8 12l6.364-6.364 1.414 1.414z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 214 B

View File

Before

Width:  |  Height:  |  Size: 217 B

After

Width:  |  Height:  |  Size: 217 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M13 10h5l-6 6-6-6h5V3h2v7zm-9 9h16v-7h2v8a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1v-8h2v7z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 231 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0H24V24H0z"/>
<path d="M5 3v16h16v2H3V3h2zm15.293 3.293l1.414 1.414L16 13.414l-3-2.999-4.293 4.292-1.414-1.414L13 7.586l3 2.999 4.293-4.292z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 269 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M3 4h18v2H3V4zm0 7h12v2H3v-2zm0 7h18v2H3v-2z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 196 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm0-2a8 8 0 1 0 0-16 8 8 0 0 0 0 16zm-1-5h2v2h-2v-2zm2-1.645V14h-2v-1.5a1 1 0 0 1 1-1 1.5 1.5 0 1 0-1.471-1.794l-1.962-.393A3.501 3.501 0 1 1 13 13.355z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 376 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M2 12c0-.865.11-1.703.316-2.504A3 3 0 0 0 4.99 4.867a9.99 9.99 0 0 1 4.335-2.505 3 3 0 0 0 5.348 0 9.99 9.99 0 0 1 4.335 2.505 3 3 0 0 0 2.675 4.63c.206.8.316 1.638.316 2.503 0 .865-.11 1.703-.316 2.504a3 3 0 0 0-2.675 4.629 9.99 9.99 0 0 1-4.335 2.505 3 3 0 0 0-5.348 0 9.99 9.99 0 0 1-4.335-2.505 3 3 0 0 0-2.675-4.63C2.11 13.704 2 12.866 2 12zm4.804 3c.63 1.091.81 2.346.564 3.524.408.29.842.541 1.297.75A4.993 4.993 0 0 1 12 18c1.26 0 2.438.471 3.335 1.274.455-.209.889-.46 1.297-.75A4.993 4.993 0 0 1 17.196 15a4.993 4.993 0 0 1 2.77-2.25 8.126 8.126 0 0 0 0-1.5A4.993 4.993 0 0 1 17.195 9a4.993 4.993 0 0 1-.564-3.524 7.989 7.989 0 0 0-1.297-.75A4.993 4.993 0 0 1 12 6a4.993 4.993 0 0 1-3.335-1.274 7.99 7.99 0 0 0-1.297.75A4.993 4.993 0 0 1 6.804 9a4.993 4.993 0 0 1-2.77 2.25 8.126 8.126 0 0 0 0 1.5A4.993 4.993 0 0 1 6.805 15zM12 15a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-2a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M14 14.252v2.09A6 6 0 0 0 6 22l-2-.001a8 8 0 0 1 10-7.748zM12 13c-3.315 0-6-2.685-6-6s2.685-6 6-6 6 2.685 6 6-2.685 6-6 6zm0-2c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm6 6v-3h2v3h3v2h-3v3h-2v-3h-3v-2h3z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 366 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M4 22a8 8 0 1 1 16 0h-2a6 6 0 1 0-12 0H4zm8-9c-3.315 0-6-2.685-6-6s2.685-6 6-6 6 2.685 6 6-2.685 6-6 6zm0-2c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 312 B

View File

@ -0,0 +1,24 @@
const EnclosureInput = (): void => {
const enclosureInput = document.querySelector(
".form-enclosure-input"
) as HTMLInputElement;
if (enclosureInput) {
const label = enclosureInput?.nextElementSibling?.querySelector(
"span"
) as HTMLSpanElement;
const labelVal = label.innerHTML;
enclosureInput.addEventListener("change", (e: Event) => {
const fileName = (e.target as HTMLInputElement).value.split("\\").pop();
if (fileName) {
label.innerHTML = fileName;
} else {
label.innerHTML = labelVal;
}
});
}
};
export default EnclosureInput;

View File

@ -1,19 +0,0 @@
import CodeMirror from "codemirror";
import "codemirror/lib/codemirror.css";
const HTMLEditor = (): void => {
const allHTMLEditors: NodeListOf<HTMLTextAreaElement> = document.querySelectorAll(
"textarea[data-editor='html']"
);
for (let j = 0; j < allHTMLEditors.length; j++) {
const textarea = allHTMLEditors[j];
CodeMirror.fromTextArea(textarea, {
lineNumbers: true,
mode: { name: "xml", htmlMode: true },
});
}
};
export default HTMLEditor;

View File

@ -59,7 +59,7 @@ class ProseMirrorView {
}
},
attributes: {
class: "prose-sm px-3 py-2 overflow-y-auto",
class: "prose-sm px-3 py-2 overflow-y-auto focus:shadow-outline",
style: "min-height: 200px; max-height: 500px",
},
});
@ -95,12 +95,22 @@ const MarkdownEditor = (): void => {
"px-2",
"bg-white",
"border",
"text-xs"
"text-xs",
"outline-none",
"focus:shadow-outline"
);
wysiwygBtn.setAttribute("type", "button");
wysiwygBtn.innerHTML = "Wysiwyg";
const markdownBtn = document.createElement("button");
markdownBtn.classList.add("py-1", "px-2", "bg-white", "border", "text-xs");
markdownBtn.classList.add(
"py-1",
"px-2",
"bg-white",
"border",
"text-xs",
"outline-none",
"focus:shadow-outline"
);
markdownBtn.setAttribute("type", "button");
markdownBtn.innerHTML = "Markdown";

View File

@ -0,0 +1,40 @@
import Choices from "choices.js";
const MultiSelect = (): void => {
// Pass single element
const multiSelects: NodeListOf<HTMLSelectElement> = document.querySelectorAll(
"select[multiple]"
);
for (let i = 0; i < multiSelects.length; i++) {
const multiSelect = multiSelects[i];
new Choices(multiSelect, {
maxItemCount: parseInt(multiSelect.dataset.maxItemCount || "-1"),
itemSelectText: multiSelect.dataset.selectText,
maxItemText: multiSelect.dataset.maxItemText,
removeItemButton: true,
classNames: {
containerOuter:
"multiselect" +
(multiSelect.dataset.class ? ` ${multiSelect.dataset.class}` : ""),
containerInner: "multiselect__inner",
input: "multiselect__input",
inputCloned: "multiselect__input--cloned",
list: "multiselect__list",
listItems: "multiselect__list--multiple",
listDropdown: "multiselect__list--dropdown",
item: "multiselect__item",
itemSelectable: "multiselect__item--selectable",
itemDisabled: "multiselect__item--disabled",
itemChoice: "multiselect__item--choice",
placeholder: "multiselect__placeholder",
group: "multiselect__group",
groupHeading: "multiselect__heading",
button: "multiselect__button",
},
});
}
};
export default MultiSelect;

View File

@ -0,0 +1,62 @@
const SidebarToggler = (): void => {
const sidebar = document.querySelector(
"aside[id='admin-sidebar']"
) as HTMLElement;
const toggler = document.querySelector(
"button[id='sidebar-toggler']"
) as HTMLButtonElement;
const sidebarBackdrop = document.querySelector(
"div[id='sidebar-backdrop']"
) as HTMLElement;
const setAriaExpanded = (isExpanded: "true" | "false") => {
toggler.setAttribute("aria-expanded", isExpanded);
sidebarBackdrop.setAttribute("aria-expanded", isExpanded);
};
const hideSidebar = () => {
setAriaExpanded("false");
sidebar.classList.add("-translate-x-full");
sidebarBackdrop.classList.add("hidden");
toggler.style.transform = "translateX(0px)";
};
const showSidebar = () => {
setAriaExpanded("true");
sidebar.classList.remove("-translate-x-full");
sidebarBackdrop.classList.remove("hidden");
toggler.style.transform =
"translateX(" + sidebar.getBoundingClientRect().width + "px)";
};
toggler.addEventListener("click", () => {
if (sidebar.classList.contains("-translate-x-full")) {
showSidebar();
} else {
hideSidebar();
}
});
sidebarBackdrop.addEventListener("click", () => {
if (!sidebar.classList.contains("-translate-x-full")) {
hideSidebar();
}
});
const setAriaExpandedOnWindowEvent = () => {
const isExpanded =
!sidebar.classList.contains("-translate-x-full") ||
window.innerWidth >= 768;
const ariaExpanded = toggler.getAttribute("aria-expanded");
if (isExpanded && (!ariaExpanded || ariaExpanded === "false")) {
setAriaExpanded("true");
} else if (!isExpanded && (!ariaExpanded || ariaExpanded === "true")) {
setAriaExpanded("false");
}
};
window.addEventListener("load", setAriaExpandedOnWindowEvent);
window.addEventListener("resize", setAriaExpandedOnWindowEvent);
};
export default SidebarToggler;

View File

@ -10,10 +10,10 @@ const Tooltip = (): void => {
const tooltipContent = tooltipReference.title;
const tooltip = document.createElement("div");
tooltip.setAttribute("id", "tooltip");
tooltip.setAttribute("id", "tooltip" + i);
tooltip.setAttribute(
"class",
"px-2 py-1 text-sm bg-gray-900 text-white rounded"
"px-2 py-1 text-sm bg-gray-900 text-white rounded max-w-xs z-50"
);
tooltip.innerHTML = tooltipContent;
@ -31,13 +31,13 @@ const Tooltip = (): void => {
const show = () => {
tooltipReference.removeAttribute("title");
tooltipReference.setAttribute("aria-describedby", "tooltip");
tooltipReference.setAttribute("aria-describedby", "tooltip" + i);
document.body.appendChild(tooltip);
popper.update();
};
const hide = () => {
const element = document.getElementById("tooltip");
const element = document.getElementById("tooltip" + i);
tooltipReference.removeAttribute("aria-describedby");
tooltipReference.setAttribute("title", tooltipContent);
if (element) {

View File

@ -1,5 +1,5 @@
.breadcrumb {
@apply inline-flex flex-wrap px-1 py-2 text-sm text-gray-800;
@apply inline-flex flex-wrap px-1 py-2 text-sm;
}
.breadcrumb-item + .breadcrumb-item::before {

View File

@ -0,0 +1,16 @@
.form-enclosure-input {
@apply absolute w-0 h-0 opacity-0;
}
.form-enclosure-input + label {
@apply inline-flex items-center justify-center w-full py-2 text-lg font-semibold text-green-600 bg-white border-2 border-green-500 rounded-lg shadow cursor-pointer;
}
.form-enclosure-input:focus + label,
.form-enclosure-input + label:hover {
@apply text-green-700 border-green-700 shadow-md;
}
.form-enclosure-input:focus + label {
@apply shadow-outline;
}

View File

@ -1,3 +1,7 @@
@import "./tailwind.css";
@import "./layout.css";
@import "./breadcrumb.css";
@import "./multiSelect.css";
@import "./radioBtn.css";
@import "./switch.css";
@import "./enclosureInput.css";

View File

@ -1,21 +1,26 @@
.holy-grail-grid {
@apply grid;
grid-template: auto 1fr auto / auto 1fr auto;
@apply grid min-h-screen overflow-y-auto;
grid-template: 1fr auto / auto 1fr;
& .holy-grail-header {
grid-column: 1 / 4;
}
& .holy-grail-sidenav {
grid-column: 1 / 2;
grid-row: 2 / 4;
& .holy-grail-sidebar {
@apply w-64 col-start-1 col-end-2 row-start-1 row-end-3;
}
& .holy-grail-main {
grid-column: 2 / 4;
@apply w-full col-start-1 col-end-3 row-start-1 row-end-2;
}
& .holy-grail-footer {
grid-column: 2 / 4;
@apply w-full col-start-1 col-end-3 row-start-2 row-end-3;
}
@screen md {
& .holy-grail-main {
@apply col-start-2;
}
& .holy-grail-footer {
@apply col-start-2;
}
}
}

View File

@ -0,0 +1,180 @@
/*===============================
= MultiSelect =
===============================*/
.multiselect {
@apply relative;
&:focus {
@apply shadow-outline outline-none;
}
&:last-child {
@apply mb-0;
}
&.is-disabled {
&.multiselect__inner,
&.multiselect__input {
@apply bg-gray-300 cursor-not-allowed select-none;
}
&.multiselect__item {
@apply cursor-not-allowed;
}
}
& [hidden] {
@apply hidden;
}
}
.multiselect[data-type*="select-multiple"],
.multiselect[data-type*="text"] {
& .multiselect__inner {
@apply cursor-text;
}
& .multiselect__button {
@apply relative inline-block w-2 pl-4 mt-0 mb-0 ml-1 opacity-75;
background-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjEiIGhlaWdodD0iMjEiIHZpZXdCb3g9IjAgMCAyMSAyMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSIjRkZGIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxwYXRoIGQ9Ik0yLjU5Mi4wNDRsMTguMzY0IDE4LjM2NC0yLjU0OCAyLjU0OEwuMDQ0IDIuNTkyeiIvPjxwYXRoIGQ9Ik0wIDE4LjM2NEwxOC4zNjQgMGwyLjU0OCAyLjU0OEwyLjU0OCAyMC45MTJ6Ii8+PC9nPjwvc3ZnPg==);
background-size: 8px;
&:hover,
&:focus {
@apply opacity-100;
}
}
}
.multiselect__inner {
@apply inline-block w-full px-2 pt-2 pb-1 overflow-hidden align-top bg-white border rounded;
&.is-focused,
&.is-open {
@apply shadow-outline;
}
&.is-open {
@apply rounded-b-none;
}
&.is-flipped.is-open {
@apply rounded-t-none;
}
}
.multiselect__list {
@apply p-0 m-0 list-none;
}
.multiselect__list--multiple {
@apply inline;
& .multiselect__item {
@apply inline-flex px-2 py-1 mb-1 mr-2 text-sm text-white break-all bg-green-500 rounded;
&[data-deletable] {
@apply pr-1;
}
& [dir="rtl"] {
@apply ml-2 mr-0;
}
&.is-highlighted {
@apply bg-green-700;
}
&.is-disabled {
@apply bg-gray-500;
}
}
}
.multiselect__list--dropdown {
@apply absolute z-10 invisible w-full overflow-hidden break-all bg-white border border-t-0 rounded-b shadow-lg;
top: 100%;
will-change: visibility;
&.is-active {
@apply visible;
}
&.is-open {
@apply shadow-outline;
}
&.is-flipped {
@apply top-auto mt-0 rounded-t;
bottom: 100%;
}
& .multiselect__list {
@apply relative overflow-auto;
max-height: 300px;
-webkit-overflow-scrolling: touch;
will-change: scroll-position;
}
& .multiselect__item {
@apply relative p-3;
& [dir="rtl"] {
@apply text-right;
}
}
& .multiselect__item--selectable {
@screen sm {
padding-right: 100px;
&:after {
@apply absolute text-sm transform -translate-y-1/2 opacity-0;
content: attr(data-select-text);
right: 10px;
top: 50%;
}
& [dir="rtl"] {
@apply text-right;
padding-left: 100px;
padding-right: 10px;
&:after {
@apply right-auto;
left: 10px;
}
}
}
&.is-highlighted {
@apply bg-gray-100;
&:after {
@apply opacity-50;
}
}
}
}
.multiselect__item {
@apply cursor-default;
}
.multiselect__item--selectable {
@apply cursor-pointer;
}
.multiselect__item--disabled {
@apply opacity-50 cursor-not-allowed select-none;
}
.multiselect__heading {
@apply p-3 font-semibold text-gray-600 border-b;
}
.multiselect__button {
@apply bg-transparent bg-center bg-no-repeat border-0 appearance-none cursor-pointer;
text-indent: -9999px;
&:focus {
@apply outline-none;
}
}
.multiselect__input {
@apply inline-block max-w-full py-1 pl-1 mb-1 align-baseline bg-transparent border-0 rounded-none;
&:focus {
@apply outline-none;
}
& [dir="rtl"] {
@apply pl-0 pr-1;
}
}
.multiselect__placeholder {
@apply opacity-50;
}
/*===== End of Choices ======*/

View File

@ -0,0 +1,24 @@
.form-radio-btn {
@apply absolute opacity-0;
}
.form-radio-btn:focus + label {
@apply shadow-outline;
}
.form-radio-btn + label {
@apply px-2 py-1 text-sm text-black bg-white border rounded cursor-pointer;
&:hover {
@apply bg-green-100;
}
}
.form-radio-btn:checked + label {
@apply text-white bg-green-500;
&::before {
@apply mr-2 text-green-200;
content: "✓";
}
}

View File

@ -0,0 +1,26 @@
.form-switch {
@apply absolute w-0 h-0 opacity-0;
&:checked + .form-switch-slider {
@apply bg-green-500;
}
&:focus + .form-switch-slider {
@apply shadow-outline;
}
&:checked + .form-switch-slider::before {
@apply transform translate-x-5;
}
}
.form-switch-slider {
@apply relative inset-0 flex-shrink-0 w-10 h-5 transition duration-200 bg-gray-400 rounded-full cursor-pointer;
&::before {
@apply absolute w-4 h-4 transition duration-200 bg-white rounded-full shadow-xs;
content: "";
left: 2px;
bottom: 2px;
}
}

View File

@ -1,6 +1,6 @@
<?= helper('page') ?>
<!DOCTYPE html>
<html lang="en">
<html lang="<?= service('request')->getLocale() ?>">
<head>
<meta charset="UTF-8"/>
@ -9,9 +9,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="shortcut icon" type="image/png" href="/favicon.ico" />
<link rel="stylesheet" href="/assets/index.css"/>
<?php if (isset($podcast)): ?>
<?= $podcast->custom_html_head ?>
<?php endif; ?>
</head>
<body class="flex flex-col min-h-screen mx-auto">
@ -25,6 +22,9 @@
</main>
<footer class="container flex justify-between px-2 py-4 mx-auto text-sm text-right border-t">
<?= render_page_links() ?>
<p>Powered by <a class="underline hover:no-underline" href="https://castopod.org" target="_blank" rel="noreferrer noopener">Castopod</a>, a <a class="underline hover:no-underline" href="https://podlibre.org/" target="_blank" rel="noreferrer noopener">Podlibre</a> initiative.</p>
<small><?= lang('Common.powered_by', [
'castopod' =>
'<a class="underline hover:no-underline" href="https://castopod.org" target="_blank" rel="noreferrer noopener">Castopod</a>',
]) ?></small>
</footer>
</body>

View File

@ -1,28 +0,0 @@
<header class="<?= $class ?>">
<div class="w-64">
<a href="<?= route_to(
'admin'
) ?>" class="inline-flex items-center text-xl">
<?= svg('logo-castopod', 'h-10 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="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="my-accountDropdown" data-popper="menu" data-popper-placement="bottom-end">
<a class="px-4 py-1 hover:bg-gray-100" href="<?= route_to(
'my-account'
) ?>">My Account</a>
<a class="px-4 py-1 hover:bg-gray-100" href="<?= route_to(
'change-password'
) ?>">Change password</a>
<a class="px-4 py-1 hover:bg-gray-100" href="<?= route_to(
'logout'
) ?>">Logout</a>
</nav>
</div>
</header>

View File

@ -1,31 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<html lang="<?= service('request')->getLocale() ?>">
<head>
<meta charset="UTF-8"/>
<title>Castopod Admin</title>
<title><?= $this->renderSection('title') ?> | Castopod Admin</title>
<meta name="description" content="Castopod is an open-source hosting platform made for podcasters who want engage and interact with their audience."/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="shortcut icon" type="image/png" href="/favicon.ico" />
<link rel="stylesheet" href="/assets/admin.css"/>
<link rel="stylesheet" href="/assets/index.css"/>
<script src="/assets/admin.js" defer></script>
</head>
<body class="min-h-screen bg-gray-100 holy-grail-grid">
<?= view('admin/_header', [
'class' => 'flex items-center px-4 py-2 holy-grail-header',
]) ?>
<?= view('admin/_sidenav', [
'class' => 'flex flex-col w-64 py-6 holy-grail-sidenav',
]) ?>
<main class="container px-4 py-6 mx-auto holy-grail-main">
<h1 class="mb-4 text-2xl"><?= $this->renderSection('title') ?></h1>
<?= view('_message_block') ?>
<?= $this->renderSection('content') ?>
<body class="relative bg-gray-100 holy-grail-grid">
<div id="sidebar-backdrop" role="button" tabIndex="0" aria-label="Close" class="fixed z-50 hidden w-full h-full bg-gray-900 bg-opacity-50 md:hidden"></div>
<aside id="admin-sidebar" class="sticky top-0 z-50 flex flex-col w-64 max-h-screen transition duration-200 ease-in-out transform -translate-x-full bg-white border-r holy-grail-sidebar md:translate-x-0">
<?php if (isset($podcast)): ?>
<?= $this->include('admin/podcast/_sidebar') ?>
<?php else: ?>
<?= $this->include('admin/_sidebar') ?>
<?php endif; ?>
</aside>
<main class="overflow-hidden holy-grail-main">
<header class="text-white bg-gradient-to-tr from-gray-900 to-gray-800">
<div class="container flex flex-wrap items-end justify-between px-2 py-10 mx-auto md:px-12 gap-y-6 gap-x-6">
<div class="flex flex-col">
<?= render_breadcrumb('text-gray-300') ?>
<h1 class="text-3xl leading-none"><?= $this->renderSection(
'pageTitle'
) ?></h1>
</div>
<div class="flex flex-wrap gap-y-2"><?= $this->renderSection(
'headerRight'
) ?></div>
</div>
</header>
<div class="container px-2 py-8 mx-auto md:px-12">
<?= view('_message_block') ?>
<?= $this->renderSection('content') ?>
</div>
</main>
<footer class="w-full px-2 py-4 mx-auto text-xs text-right border-t holy-grail-footer">
Powered by <a class="underline hover:no-underline" href="https://castopod.org" target="_blank" rel="noreferrer noopener">Castopod</a>, a <a class="underline hover:no-underline" href="https://podlibre.org/" target="_blank" rel="noreferrer noopener">Podlibre</a> initiative.
<footer class="px-2 py-2 mx-auto text-xs text-right holy-grail-footer">
<small><?= lang('Common.powered_by', [
'castopod' =>
'<a class="underline hover:no-underline" href="https://castopod.org" target="_blank" rel="noreferrer noopener">Castopod</a>',
]) ?></small>
</footer>
<script src="/assets/admin.js"></script>
<button
type="button"
id="sidebar-toggler"
class="fixed bottom-0 left-0 z-50 p-3 mb-3 ml-3 text-xl transition duration-300 ease-in-out bg-white border-2 rounded-full shadow-lg focus:outline-none md:hidden hover:bg-gray-100 focus:shadow-outline"
style="transform: translateX(0px);"><?= icon('menu') ?></button>
</body>

View File

@ -1,44 +0,0 @@
<article class="flex w-full max-w-lg mb-4 bg-white border rounded shadow">
<img
loading="lazy"
src="<?= $episode->image->thumbnail_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->podcast->id,
$episode->id
) ?>">
<h3 class="text-xl font-semibold">
<span class="mr-1 underline hover:no-underline"><?= $episode->title ?></span>
<span class="text-base font-bold text-gray-600">#<?= $episode->number ?></span>
</h3>
</a>
<div class="relative ml-auto" data-toggle="dropdown">
<button type="button" class="inline-flex items-center p-1 outline-none focus:shadow-outline" id="moreDropdown" data-popper="button" aria-haspopup="true" aria-expanded="false">
<?= icon('more') ?>
</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->podcast->id,
$episode->id
) ?>"><?= lang('Episode.edit') ?></a>
<a class="px-4 py-1 hover:bg-gray-100" href="<?= route_to(
'episode',
$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->podcast->id,
$episode->id
) ?>"><?= lang('Episode.delete') ?></a>
</nav>
</div>
<audio controls class="mt-auto" preload="none">
<source src="/<?= $episode->enclosure_media_path ?>" type="<?= $episode->enclosure_type ?>">
Your browser does not support the audio tag.
</audio>
</div>
</article>

View File

@ -1,11 +0,0 @@
<div class="flex flex-col py-4">
<?php if ($episodes): ?>
<?php foreach ($episodes as $episode): ?>
<?= view('admin/_partials/_episode-card', [
'episode' => $episode,
]) ?>
<?php endforeach; ?>
<?php else: ?>
<p class="italic"><?= lang('Podcast.no_episode') ?></p>
<?php endif; ?>
</div>

View File

@ -1,29 +0,0 @@
<article class="w-48 h-full mb-4 mr-4 overflow-hidden bg-white border rounded shadow">
<img
alt="<?= $podcast->title ?>"
src="<?= $podcast->image
->thumbnail_url ?>" class="object-cover w-full h-40" />
<div class="p-2">
<a href="<?= route_to(
'podcast-view',
$podcast->id
) ?>" class="hover:underline">
<h2 class="font-semibold"><?= $podcast->title ?></h2>
</a>
<p class="text-gray-600">@<?= $podcast->name ?></p>
</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->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->id
) ?>" data-toggle="tooltip" data-placement="bottom" title="<?= lang(
'Podcast.view'
) ?>"><?= icon('eye') ?></a>
</footer>
</article>

View File

@ -1,6 +1,6 @@
<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
<?= lang('User.form.email') ?>
</dt>
<dd class="mt-1 text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2">
<?= $user->email ?>
@ -8,7 +8,7 @@
</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
<?= lang('User.form.username') ?>
</dt>
<dd class="mt-1 text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2">
<?= $user->username ?>
@ -16,7 +16,7 @@
</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
<?= lang('User.form.roles') ?>
</dt>
<dd class="mt-1 text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2">
[<?= implode(', ', $user->roles) ?>]
@ -24,7 +24,7 @@
</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
<?= lang('User.form.permissions') ?>
</dt>
<dd class="mt-1 text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2">
[<?= implode(', ', $user->permissions) ?>]

View File

@ -9,7 +9,19 @@ $navigation = [
'pages' => ['icon' => 'pages', 'items' => ['page-list', 'page-create']],
]; ?>
<nav class="<?= $class ?>">
<a href="<?= route_to(
'admin'
) ?>" class="inline-flex items-center px-4 py-2 mb-2 text-xl">
<?= svg('logo-castopod', 'h-8 mr-2') ?>
Castopod
</a>
<a href="<?= route_to(
'home'
) ?>" class="inline-flex items-center px-6 py-2 mb-2 text-sm underline outline-none hover:no-underline focus:shadow-outline">
<?= lang('AdminNavigation.go_to_website') ?>
<?= icon('external-link', 'ml-2 text-gray-500') ?>
</a>
<nav class="flex flex-col flex-1 overflow-y-auto">
<?php foreach ($navigation as $section => $data): ?>
<div class="mb-4">
<button class="inline-flex items-center w-full px-6 py-1 outline-none focus:shadow-outline" type="button">
@ -30,11 +42,23 @@ $navigation = [
</ul>
</div>
<?php endforeach; ?>
<a href="<?= route_to(
'home'
) ?>" class="inline-flex items-center px-4 py-1 mt-auto text-sm underline outline-none hover:no-underline focus:shadow-outline">
<?= lang('AdminNavigation.go_to_website') ?>
<?= icon('external-link', 'ml-2 text-gray-500') ?>
</a>
</nav>
<div class="w-full mt-auto border-t" data-toggle="dropdown">
<button type="button" class="inline-flex items-center w-full px-6 py-2 outline-none focus:shadow-outline" id="my-accountDropdown" data-popper="button" aria-haspopup="true" aria-expanded="false">
<?= icon('user', 'text-gray-500 mr-2') ?>
<?= user()->username ?>
<?= icon('caret-right', 'ml-auto') ?>
</button>
<nav class="absolute z-50 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="right-end">
<a class="px-4 py-1 hover:bg-gray-100" href="<?= route_to(
'my-account'
) ?>">My Account</a>
<a class="px-4 py-1 hover:bg-gray-100" href="<?= route_to(
'change-password'
) ?>">Change password</a>
<a class="px-4 py-1 hover:bg-gray-100" href="<?= route_to(
'logout'
) ?>">Logout</a>
</nav>
</div>

View File

@ -4,6 +4,10 @@
<?= lang('Contributor.add_contributor', [$podcast->title]) ?>
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
<?= lang('Contributor.add_contributor', [$podcast->title]) ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
@ -26,11 +30,12 @@
'required' => 'required',
]) ?>
<?= form_button([
'content' => lang('Contributor.form.submit_add'),
'type' => 'submit',
'class' => 'self-end px-4 py-2 bg-gray-200',
]) ?>
<?= button(
lang('Contributor.form.submit_add'),
null,
['variant' => 'primary'],
['type' => 'submit', 'class' => 'self-end']
) ?>
<?= form_close() ?>

View File

@ -4,6 +4,10 @@
<?= lang('Contributor.edit_role', [$user->username]) ?>
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
<?= lang('Contributor.edit_role', [$user->username]) ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
@ -19,11 +23,12 @@
'required' => 'required',
]) ?>
<?= form_button([
'content' => lang('Contributor.form.submit_edit'),
'type' => 'submit',
'class' => 'self-end px-4 py-2 bg-gray-200',
]) ?>
<?= button(
lang('Contributor.form.submit_edit'),
null,
['variant' => 'primary'],
['type' => 'submit', 'class' => 'self-end']
) ?>
<?= form_close() ?>

View File

@ -2,47 +2,67 @@
<?= $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',
$podcast->id
) ?>">
<?= icon('add', 'mr-2') ?>
<?= lang('Contributor.add') ?></a>
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
<?= lang('Contributor.podcast_contributors') ?>
<?= $this->endSection() ?>
<?= $this->section('headerRight') ?>
<?= button(lang('Contributor.add'), route_to('contributor-add', $podcast->id), [
'variant' => 'primary',
'iconLeft' => 'add',
]) ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<table class="table-auto">
<thead>
<tr>
<th class="px-4 py-2">Username</th>
<th class="px-4 py-2">Role</th>
<th class="px-4 py-2">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($podcast->contributors as $contributor): ?>
<tr>
<td class="px-4 py-2 border"><?= $contributor->username ?></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',
$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',
$podcast->id,
$contributor->id
) ?>"><?= lang('Contributor.remove') ?></a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?= data_table(
[
[
'header' => lang('Contributor.list.username'),
'cell' => function ($contributor) {
return $contributor->username;
},
],
[
'header' => lang('Contributor.list.role'),
'cell' => function ($contributor) {
return lang('Contributor.roles.' . $contributor->podcast_role);
},
],
[
'header' => lang('Common.actions'),
'cell' => function ($contributor, $podcast) {
return button(
lang('Contributor.edit'),
route_to(
'contributor-edit',
$podcast->id,
$contributor->id
),
[
'variant' => 'info',
'size' => 'small',
],
['class' => 'mr-2']
) .
button(
lang('Contributor.remove'),
route_to(
'contributor-remove',
$podcast->id,
$contributor->id
),
['variant' => 'danger', 'size' => 'small'],
['class' => 'mr-2']
);
},
],
],
$podcast->contributors,
$podcast
) ?>
<?= $this->endSection() ?>

View File

@ -1,6 +1,14 @@
<?= helper('components') ?>
<?= $this->extend('admin/_layout') ?>
<?= $this->section('title') ?>
Welcome to the admin dashboard!
Dashboard
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
Admin dashboard
<?= $this->endSection() ?>
<?= $this->section('content') ?>
Welcome to the admin area!
<?= $this->endsection() ?>

View File

@ -4,26 +4,44 @@
<?= lang('Episode.create') ?>
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
<?= lang('Episode.create') ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<?= form_open_multipart(route_to('episode-create', $podcast->id), [
'method' => 'post',
'class' => 'flex flex-col max-w-md',
'class' => 'flex flex-col',
]) ?>
<?= csrf_field() ?>
<?= 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 w-full mb-6">
<?= form_input([
'id' => 'enclosure',
'name' => 'enclosure',
'class' => 'form-enclosure-input',
'required' => 'required',
'type' => 'file',
'accept' => '.mp3,.m4a',
]) ?>
<label for="enclosure"><?= icon('upload', 'mr-2 text-') ?>
<span><?= lang('Episode.form.enclosure') ?></span></label>
</div>
<?= form_label(lang('Episode.form.image'), 'image') ?>
<?= form_section(
lang('Episode.form.info_section_title'),
lang('Episode.form.info_section_subtitle')
) ?>
<?= form_label(
lang('Episode.form.image'),
'image',
[],
lang('Episode.form.image_hint'),
true
) ?>
<?= form_input([
'id' => 'image',
'name' => 'image',
@ -35,7 +53,12 @@
'Common.forms.image_size_hint'
) ?></small>
<?= form_label(lang('Episode.form.title'), 'title') ?>
<?= form_label(
lang('Episode.form.title'),
'title',
[],
lang('Episode.form.title_hint')
) ?>
<?= form_input([
'id' => 'title',
'name' => 'title',
@ -45,7 +68,12 @@
'data-slugify' => 'title',
]) ?>
<?= form_label(lang('Episode.form.slug'), 'slug') ?>
<?= form_label(
lang('Episode.form.slug'),
'slug',
[],
lang('Episode.form.slug_hint')
) ?>
<?= form_input([
'id' => 'slug',
'name' => 'slug',
@ -55,6 +83,74 @@
'data-slugify' => 'slug',
]) ?>
<div class="flex flex-col mb-4 gap-x-2 gap-y-4 md:flex-row">
<div class="flex flex-col flex-1">
<?= form_label(lang('Episode.form.season_number'), 'season_number') ?>
<?= form_input([
'id' => 'season_number',
'name' => 'season_number',
'class' => 'form-input w-full',
'value' => old('season_number'),
'type' => 'number',
]) ?>
</div>
<div class="flex flex-col flex-1">
<?= form_label(lang('Episode.form.episode_number'), 'episode_number') ?>
<?= form_input([
'id' => 'episode_number',
'name' => 'episode_number',
'class' => 'form-input w-full',
'value' => old('episode_number'),
'required' => 'required',
'type' => 'number',
]) ?>
</div>
</div>
<?= form_fieldset('', ['class' => 'flex mb-4 gap-1']) ?>
<legend>
<?= lang('Episode.form.type.label') .
hint_tooltip(lang('Episode.form.type.hint'), 'ml-1') ?>
</legend>
<?= form_radio(
['id' => 'full', 'name' => 'type', 'class' => 'form-radio-btn'],
'full',
old('type') ? old('type') == 'full' : true
) ?>
<label for="full" class="inline-flex items-center">
<?= lang('Episode.form.type.full') ?>
</label>
<?= form_radio(
['id' => 'trailer', 'name' => 'type', 'class' => 'form-radio-btn'],
'trailer',
old('type') ? old('type') == 'trailer' : false
) ?>
<label for="trailer" class="inline-flex items-center">
<?= lang('Episode.form.type.trailer') ?>
</label>
<?= form_radio(
[
'id' => 'bonus',
'name' => 'type',
'class' => 'form-radio-btn',
],
'bonus',
old('type') ? old('type') == 'bonus' : false
) ?>
<label for="bonus" class="inline-flex items-center">
<?= lang('Episode.form.type.bonus') ?>
</label>
<?= form_fieldset_close() ?>
<?= form_section_close() ?>
<?= form_section(
lang('Episode.form.show_notes_section_title'),
lang('Episode.form.show_notes_section_subtitle')
) ?>
<div class="mb-4">
<?= form_label(lang('Episode.form.description'), 'description') ?>
<?= form_textarea(
@ -69,6 +165,36 @@
) ?>
</div>
<div class="mb-4">
<?= form_label(
lang('Episode.form.description_footer'),
'description_footer',
[],
lang('Episode.form.description_footer_hint')
) ?>
<?= form_textarea(
[
'id' => 'description_footer',
'name' => 'description_footer',
'class' => 'form-textarea',
],
old(
'description_footer',
$podcast->episode_description_footer ?? '',
false
),
'data-editor="markdown"'
) ?>
</div>
<?= form_section_close() ?>
<?= form_section(
lang('Episode.form.publication_section_title'),
lang('Episode.form.publication_section_subtitle')
) ?>
<?= form_fieldset('', ['class' => 'flex mb-4']) ?>
<legend><?= lang('Episode.form.published_at.label') ?></legend>
<div class="flex flex-col flex-1">
@ -99,76 +225,69 @@
</div>
<?= form_fieldset_close() ?>
<?= 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',
]) ?>
<?= 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',
]) ?>
<label class="inline-flex items-center mb-4">
<?= form_checkbox(
['id' => 'explicit', 'name' => 'explicit', 'class' => 'form-checkbox'],
'yes',
old('explicit', false)
<?= form_fieldset('', ['class' => 'flex mb-6 gap-1']) ?>
<legend>
<?= lang('Episode.form.parental_advisory.label') .
hint_tooltip(lang('Episode.form.type.hint'), 'ml-1') ?>
</legend>
<?= form_radio(
[
'id' => 'undefined',
'name' => 'parental_advisory',
'class' => 'form-radio-btn',
],
'undefined',
old('parental_advisory')
? old('parental_advisory') === 'undefined'
: true
) ?>
<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">
<?= 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">
<?= 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">
<?= 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>
<label for="undefined"><?= lang(
'Episode.form.parental_advisory.undefined'
) ?></label>
<?= form_radio(
[
'id' => 'clean',
'name' => 'parental_advisory',
'class' => 'form-radio-btn',
],
'clean',
old('parental_advisory') ? old('parental_advisory') === 'clean' : false
) ?>
<label for="clean"><?= lang(
'Episode.form.parental_advisory.clean'
) ?></label>
<?= form_radio(
[
'id' => 'explicit',
'name' => 'parental_advisory',
'class' => 'form-radio-btn',
],
'explicit',
old('parental_advisory')
? old('parental_advisory') === 'explicit'
: false
) ?>
<label for="explicit"><?= lang(
'Episode.form.parental_advisory.explicit'
) ?></label>
<?= 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_switch(
lang('Episode.form.block') .
hint_tooltip(lang('Episode.form.block_hint'), 'ml-1'),
['id' => 'block', 'name' => 'block'],
'yes',
old('block', false)
) ?>
<?= form_button([
'content' => lang('Episode.form.submit_create'),
'type' => 'submit',
'class' => 'self-end px-4 py-2 bg-gray-200',
]) ?>
<?= form_section_close() ?>
<?= button(
lang('Episode.form.submit_create'),
null,
['variant' => 'primary'],
['type' => 'submit', 'class' => 'self-end']
) ?>
<?= form_close() ?>

View File

@ -4,25 +4,43 @@
<?= lang('Episode.edit') ?>
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
<?= lang('Episode.edit') ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<?= form_open_multipart(
route_to('episode-edit', $episode->podcast->id, $episode->id),
['method' => 'post', 'class' => 'flex flex-col max-w-md']
) ?>
<?= form_open_multipart(route_to('episode-edit', $podcast->id, $episode->id), [
'method' => 'post',
'class' => 'flex flex-col',
]) ?>
<?= csrf_field() ?>
<?= form_label(lang('Episode.form.enclosure'), 'enclosure') ?>
<?= form_input([
'id' => 'enclosure',
'name' => 'enclosure',
'class' => 'form-input mb-4',
'type' => 'file',
'accept' => '.mp3,.m4a',
]) ?>
<div class="flex w-full mb-6">
<?= form_input([
'id' => 'enclosure',
'name' => 'enclosure',
'class' => 'form-enclosure-input',
'type' => 'file',
'accept' => '.mp3,.m4a',
]) ?>
<label for="enclosure"><?= icon('upload', 'mr-2 text-') ?>
<span><?= lang('Episode.form.enclosure') ?></span></label>
</div>
<?= form_label(lang('Episode.form.image'), 'image') ?>
<?= form_section(
lang('Episode.form.info_section_title'),
lang('Episode.form.info_section_subtitle')
) ?>
<?= form_label(
lang('Episode.form.image'),
'image',
[],
lang('Episode.form.image_hint'),
true
) ?>
<img
src="<?= $episode->image->thumbnail_url ?>"
alt="<?= $episode->title ?>"
@ -39,7 +57,12 @@
'Common.forms.image_size_hint'
) ?></small>
<?= form_label(lang('Episode.form.title'), 'title') ?>
<?= form_label(
lang('Episode.form.title'),
'title',
[],
lang('Episode.form.title_hint')
) ?>
<?= form_input([
'id' => 'title',
'name' => 'title',
@ -49,7 +72,12 @@
'data-slugify' => 'title',
]) ?>
<?= form_label(lang('Episode.form.slug'), 'slug') ?>
<?= form_label(
lang('Episode.form.slug'),
'slug',
[],
lang('Episode.form.slug_hint')
) ?>
<?= form_input([
'id' => 'slug',
'name' => 'slug',
@ -59,6 +87,69 @@
'data-slugify' => 'slug',
]) ?>
<div class="flex flex-col mb-4 gap-x-2 gap-y-4 md:flex-row">
<div class="flex flex-col flex-1">
<?= form_label(lang('Episode.form.season_number'), 'season_number') ?>
<?= form_input([
'id' => 'season_number',
'name' => 'season_number',
'class' => 'form-input w-full',
'value' => old('season_number', $episode->season_number),
'type' => 'number',
]) ?>
</div>
<div class="flex flex-col flex-1">
<?= form_label(lang('Episode.form.episode_number'), 'episode_number') ?>
<?= form_input([
'id' => 'episode_number',
'name' => 'episode_number',
'class' => 'form-input w-full',
'value' => old('episode_number', $episode->number),
'required' => 'required',
'type' => 'number',
]) ?>
</div>
</div>
<?= form_fieldset('', ['class' => 'flex mb-4 gap-1']) ?>
<legend>
<?= lang('Episode.form.type.label') .
hint_tooltip(lang('Episode.form.type.hint'), 'ml-1') ?>
</legend>
<?= form_radio(
['id' => 'full', 'name' => 'type', 'class' => 'form-radio-btn'],
'full',
old('type') ? old('type') === 'full' : $episode->type === 'full'
) ?>
<label for="full" class="inline-flex items-center">
<?= lang('Episode.form.type.full') ?>
</label>
<?= form_radio(
['id' => 'trailer', 'name' => 'type', 'class' => 'form-radio-btn'],
'trailer',
old('type') ? old('type') === 'trailer' : $episode->type === 'trailer'
) ?>
<label for="trailer" class="inline-flex items-center">
<?= lang('Episode.form.type.trailer') ?>
</label>
<?= form_radio(
['id' => 'bonus', 'name' => 'type', 'class' => 'form-radio-btn'],
'bonus',
old('type') ? old('type') === 'bonus' : $episode->type === 'bonus'
) ?>
<label for="bonus" class="inline-flex items-center">
<?= lang('Episode.form.type.bonus') ?>
</label>
<?= form_fieldset_close() ?>
<?= form_section_close() ?>
<?= form_section(
lang('Episode.form.show_notes_section_title'),
lang('Episode.form.show_notes_section_subtitle')
) ?>
<div class="mb-4">
<?= form_label(lang('Episode.form.description'), 'description') ?>
<?= form_textarea(
@ -73,6 +164,36 @@
) ?>
</div>
<div class="mb-4">
<?= form_label(
lang('Episode.form.description_footer'),
'description_footer',
[],
lang('Episode.form.description_footer_hint')
) ?>
<?= form_textarea(
[
'id' => 'description_footer',
'name' => 'description_footer',
'class' => 'form-textarea',
],
old(
'description_footer',
$podcast->episode_description_footer ?? '',
false
),
'data-editor="markdown"'
) ?>
</div>
<?= form_section_close() ?>
<?= form_section(
lang('Episode.form.publication_section_title'),
lang('Episode.form.publication_section_subtitle')
) ?>
<?= form_fieldset('', ['class' => 'flex mb-4']) ?>
<legend><?= lang('Episode.form.published_at.label') ?></legend>
<div class="flex flex-col flex-1">
@ -111,76 +232,76 @@
</div>
<?= form_fieldset_close() ?>
<?= 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)
<?= form_fieldset('', ['class' => 'flex mb-6 gap-1']) ?>
<legend>
<?= lang('Episode.form.parental_advisory.label') .
hint_tooltip(lang('Episode.form.type.hint'), 'ml-1') ?>
</legend>
<?= form_radio(
[
'id' => 'undefined',
'name' => 'parental_advisory',
'class' => 'form-radio-btn',
],
'undefined',
old('parental_advisory')
? old('parental_advisory') === 'undefined'
: $episode->parental_advisory === null
) ?>
<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">
<?= 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">
<?= 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">
<?= 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>
<label for="undefined"><?= lang(
'Episode.form.parental_advisory.undefined'
) ?></label>
<?= form_radio(
[
'id' => 'clean',
'name' => 'parental_advisory',
'class' => 'form-radio-btn',
],
'clean',
old('parental_advisory')
? old('parental_advisory') === 'clean'
: $episode->parental_advisory === 'clean'
) ?>
<label for="clean"><?= lang(
'Episode.form.parental_advisory.clean'
) ?></label>
<?= form_radio(
[
'id' => 'explicit',
'name' => 'parental_advisory',
'class' => 'form-radio-btn',
],
'explicit',
old('parental_advisory')
? old('parental_advisory') === 'explicit'
: $episode->parental_advisory === 'explicit'
) ?>
<label for="explicit"><?= lang(
'Episode.form.parental_advisory.explicit'
) ?></label>
<?= 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_switch(
lang('Episode.form.block') .
hint_tooltip(lang('Episode.form.block_hint'), 'ml-1'),
['id' => 'block', 'name' => 'block'],
'yes',
old(
'block',
<?= form_button([
'content' => lang('Episode.form.submit_edit'),
'type' => 'submit',
'class' => 'self-end px-4 py-2 bg-gray-200',
]) ?>
$episode->block
)
) ?>
<?= form_section_close() ?>
<?= button(
lang('Episode.form.submit_edit'),
null,
['variant' => 'primary'],
['type' => 'submit', 'class' => 'self-end']
) ?>
<?= form_close() ?>

View File

@ -1,23 +1,130 @@
<?= $this->extend('admin/_layout') ?>
<?= $this->section('title') ?>
<?= lang('Episode.all_podcast_episodes') ?>
<?= $this->endSection() ?>
<?= 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->section('pageTitle') ?>
<?= lang('Episode.all_podcast_episodes') ?> (<?= $pager->getDetails()[
'total'
] ?>)
<?= $this->endSection() ?>
<?= $this->section('headerRight') ?>
<?= button(
lang('Episode.create'),
route_to('episode-create', $podcast->id),
['variant' => 'primary', 'iconLeft' => 'add']
) ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<?= view('admin/_partials/_episode-list.php', [
'episodes' => $podcast->episodes,
]) ?>
<p class="mb-4 text-sm italic text-gray-700"><?= lang('Common.pageInfo', [
'currentPage' => $pager->getDetails()['currentPage'],
'pageCount' => $pager->getDetails()['pageCount'],
]) ?></p>
<div class="flex flex-wrap mb-6">
<?php if ($episodes): ?>
<?php foreach ($episodes as $episode): ?>
<article class="flex w-full max-w-lg p-4 mx-auto">
<img
loading="lazy"
src="<?= $episode->image->thumbnail_url ?>"
alt="<?= $episode->title ?>" class="object-cover w-20 h-20 mr-2 rounded-lg" />
<div class="flex flex-col flex-1">
<div class="flex">
<a class="flex-1 text-sm hover:underline" href="<?= route_to(
'episode-view',
$podcast->id,
$episode->id
) ?>">
<h2 class="inline-flex justify-between w-full font-bold leading-none group">
<span class="mr-1 group-hover:underline"><?= $episode->title ?></span>
<?php if (
$episode->season_number &&
$episode->number
): ?>
<abbr class="text-xs font-bold text-gray-600" title="<?= lang(
'Episode.season_episode',
[
'seasonNumber' =>
$episode->season_number,
'episodeNumber' => $episode->number,
]
) ?>"><?= lang('Episode.season_episode_abbr', [
'seasonNumber' => $episode->season_number,
'episodeNumber' => $episode->number,
]) ?></abbr>
<?php elseif (
!$episode->season_number &&
$episode->number
): ?>
<abbr class="text-xs font-bold text-gray-600" title="<?= lang(
'Episode.number',
[
'episodeNumber' => $episode->number,
]
) ?>"><?= lang('Episode.number_abbr', [
'episodeNumber' => $episode->number,
]) ?></abbr>
<?php endif; ?>
</h2>
</a>
<div class="relative" data-toggle="dropdown">
<button type="button" class="inline-flex items-center p-1 outline-none focus:shadow-outline" id="moreDropdown" data-popper="button" aria-haspopup="true" aria-expanded="false">
<?= icon('more') ?>
</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-end" data-popper-offset-x="0" data-popper-offset-y="-24" >
<a class="px-4 py-1 hover:bg-gray-100" href="<?= route_to(
'episode-edit',
$podcast->id,
$episode->id
) ?>"><?= lang('Episode.edit') ?></a>
<a class="px-4 py-1 hover:bg-gray-100" href="<?= route_to(
'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',
$podcast->id,
$episode->id
) ?>"><?= lang('Episode.delete') ?></a>
</nav>
</div>
</div>
<div class="mb-2 text-xs">
<time
pubdate
datetime="<?= $episode->published_at->toDateTimeString() ?>"
title="<?= $episode->published_at ?>">
<?= lang('Common.mediumDate', [
$episode->published_at,
]) ?>
</time>
<span class="mx-1"></span>
<time datetime="PT<?= $episode->enclosure_duration ?>S">
<?= lang('Common.duration', [
$episode->enclosure_duration,
]) ?>
</time>
</div>
<audio controls preload="none" class="w-full mt-auto">
<source src="/<?= $episode->enclosure_media_path ?>" type="<?= $episode->enclosure_type ?>">
Your browser does not support the audio tag.
</audio>
</div>
</article>
<?php endforeach; ?>
<?php else: ?>
<p class="italic"><?= lang('Podcast.no_episode') ?></p>
<?php endif; ?>
</div>
<?= $pager->links() ?>
<?= $this->endSection()
?>

View File

@ -4,40 +4,46 @@
<?= $episode->title ?>
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
<?= $episode->title ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<img
src="<?= $episode->image->medium_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 ?>">
Your browser does not support the audio tag.
</audio>
<div class="flex flex-wrap">
<div class="w-full max-w-sm mb-6 md:mr-4">
<img
src="<?= $episode->image->medium_url ?>"
alt="Episode cover"
class="object-cover w-full"
/>
<audio controls preload="none" class="w-full mb-6">
<source src="/<?= $episode->enclosure_media_path ?>" type="<?= $episode->enclosure_type ?>">
Your browser does not support the audio tag.
</audio>
<a class="inline-flex px-4 py-2 text-white bg-teal-700 hover:bg-teal-800" href="<?= route_to(
'episode-edit',
$episode->podcast->id,
$episode->id
) ?>"><?= lang('Episode.edit') ?></a>
<a href="<?= route_to(
'episode',
$episode->podcast->name,
$episode->slug
) ?>" class="inline-flex px-4 py-2 text-white bg-gray-700 hover:bg-gray-800"><?= lang(
'Episode.go_to_page'
) ?></a>
<a href="<?= route_to(
'episode-delete',
$episode->podcast->id,
$episode->id
) ?>" class="inline-flex px-4 py-2 text-white bg-red-700 hover:bg-red-800"><?= lang(
'Episode.delete'
) ?></a>
<div class="flex justify-around">
<?= button(
lang('Episode.edit'),
route_to('episode-edit', $podcast->id, $episode->id),
['variant' => 'info', 'iconLeft' => 'edit']
) ?>
<?= button(
lang('Episode.go_to_page'),
route_to('episode', $podcast->name, $episode->slug),
['variant' => 'secondary', 'iconLeft' => 'external-link']
) ?>
<?= button(
lang('Episode.delete'),
route_to('episode-delete', $podcast->id, $episode->id),
['variant' => 'danger', 'iconLeft' => 'delete-bin']
) ?>
</div>
</div>
<section class="prose">
<?= $episode->description_html ?>
</section>
<section class="w-full max-w-sm prose">
<?= $episode->description_html ?>
</section>
</div>
<?= $this->endSection() ?>

View File

@ -31,11 +31,12 @@
'autocomplete' => 'new-password',
]) ?>
<?= form_button([
'content' => lang('User.form.submit_password_change'),
'type' => 'submit',
'class' => 'self-end px-4 py-2 bg-gray-200',
]) ?>
<?= button(
lang('User.form.submit_password_change'),
null,
['variant' => 'primary'],
['type' => 'submit', 'class' => 'self-end']
) ?>
<?= form_close() ?>

View File

@ -4,6 +4,10 @@
<?= lang('Page.create') ?>
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
<?= lang('Page.create') ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
@ -46,11 +50,13 @@
) ?>
</div>
<?= form_button([
'content' => lang('Page.form.submit_create'),
'type' => 'submit',
'class' => 'self-end px-4 py-2 bg-gray-200',
]) ?>
<?= button(
lang('Page.form.submit_create'),
null,
['variant' => 'primary'],
['type' => 'submit', 'class' => 'self-end']
) ?>
<?= form_close() ?>

View File

@ -4,6 +4,10 @@
<?= lang('Page.edit') ?>
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
<?= lang('Page.edit') ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
@ -46,11 +50,12 @@
) ?>
</div>
<?= form_button([
'content' => lang('Page.form.submit_edit'),
'type' => 'submit',
'class' => 'self-end px-4 py-2 bg-gray-200',
]) ?>
<?= button(
lang('Page.form.submit_edit'),
null,
['variant' => 'primary'],
['type' => 'submit', 'class' => 'self-end']
) ?>
<?= form_close() ?>

View File

@ -1,47 +1,62 @@
<?= $this->extend('admin/_layout') ?>
<?= $this->section('title') ?>
<?= lang('Page.all_pages') ?>
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
<?= lang('Page.all_pages') ?> (<?= count($pages) ?>)
<a class="inline-flex items-center px-2 py-1 mb-2 ml-4 text-sm text-white bg-green-500 rounded shadow-xs outline-none hover:bg-green-600 focus:shadow-outline" href="<?= route_to(
'page-create'
) ?>">
<?= icon('add', 'mr-2') ?>
<?= lang('Page.create') ?></a>
<?= $this->endSection() ?>
<?= $this->section('headerRight') ?>
<?= button(lang('Page.create'), route_to('page-create'), [
'variant' => 'primary',
'iconLeft' => 'add',
]) ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<table class="table-auto">
<thead>
<tr>
<th class="px-4 py-2">Title</th>
<th class="px-4 py-2">Slug</th>
<th class="px-4 py-2">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($pages as $page): ?>
<tr>
<td class="px-4 py-2 border"><?= $page->title ?></td>
<td class="px-4 py-2 border"><?= $page->slug ?></td>
<td class="px-4 py-2 border">
<a class="inline-flex px-2 py-1 mb-2 text-sm text-white bg-gray-700 hover:bg-gray-800" href="<?= route_to(
'page',
$page->slug
) ?>"><?= lang('Page.go_to_page') ?></a>
<a class="inline-flex px-2 py-1 mb-2 text-sm text-white bg-teal-700 hover:bg-teal-800" href="<?= route_to(
'page-edit',
$page->id
) ?>"><?= lang('Page.edit') ?></a>
<a class="inline-flex px-2 py-1 text-sm text-white bg-red-700 hover:bg-red-800" href="<?= route_to(
'page-delete',
$page->id
) ?>"><?= lang('Page.delete') ?></a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?= data_table(
[
[
'header' => lang('Page.page'),
'cell' => function ($page) {
return '<div class="flex flex-col">' .
$page->title .
'<span class="text-sm text-gray-600">/' .
$page->slug .
'</span></div>';
},
],
[
'header' => lang('Common.actions'),
'cell' => function ($page) {
return button(
lang('Page.go_to_page'),
route_to('page', $page->slug),
[
'variant' => 'secondary',
'size' => 'small',
],
['class' => 'mr-2']
) .
button(
lang('Page.edit'),
route_to('page-edit', $page->id),
['variant' => 'info', 'size' => 'small'],
['class' => 'mr-2']
) .
button(
lang('Page.delete'),
route_to('page-delete', $page->id),
['variant' => 'danger', 'size' => 'small']
);
},
],
],
$pages
) ?>
<?= $this->endSection() ?>

View File

@ -2,12 +2,17 @@
<?= $this->section('title') ?>
<?= $page->title ?>
<a class="inline-flex items-center px-2 py-1 mb-2 ml-4 text-sm text-white bg-teal-500 rounded shadow-xs outline-none hover:bg-teal-600 focus:shadow-outline" href="<?= route_to(
'page-edit',
$page->id
) ?>">
<?= icon('edit', 'mr-2') ?>
<?= lang('Page.edit') ?></a>
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
<?= $page->title ?>
<?= $this->endSection() ?>
<?= $this->section('headerRight') ?>
<?= button(lang('Page.edit'), route_to('page-edit', $page->id), [
'variant' => 'primary',
'iconLeft' => 'add',
]) ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>

View File

@ -0,0 +1,94 @@
<?php
$podcastNavigation = [
'dashboard' => [
'icon' => 'dashboard',
'items' => ['podcast-view', 'podcast-edit'],
],
'episodes' => [
'icon' => 'mic',
'items' => ['episode-list', 'episode-create'],
],
'analytics' => [
'icon' => 'line-chart',
'items' => [],
],
'contributors' => [
'icon' => 'group',
'items' => ['contributor-list', 'contributor-add'],
],
'settings' => [
'icon' => 'settings',
'items' => ['platforms'],
],
]; ?>
<a href="<?= route_to(
'admin'
) ?>" class="inline-flex items-center px-4 py-2 border-b">
<?= icon('arrow-left', 'mr-4') ?>
<?= svg('logo-castopod', 'h-8 mr-2') ?>
Castopod
</a>
<div class="flex items-center border-b">
<img
src="<?= $podcast->image->thumbnail_url ?>"
alt="<?= $podcast->title ?>"
class="object-cover w-16 h-16 mr-2"
/>
<div class="flex flex-col items-start flex-1">
<span class="font-semibold truncate"><?= $podcast->title ?></span>
<a href="<?= route_to(
'podcast',
$podcast->name
) ?>" class="inline-flex items-center text-sm underline outline-none hover:no-underline focus:shadow-outline"
data-toggle="tooltip" data-placement="bottom" title="<?= lang(
'PodcastNavigation.go_to_page'
) ?>">@<?= $podcast->name ?>
<?= icon('external-link', 'ml-1 text-gray-500') ?>
</a>
</div>
</div>
<nav class="flex flex-col flex-1 py-6 overflow-y-auto">
<?php foreach ($podcastNavigation as $section => $data): ?>
<div class="mb-4">
<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(
'PodcastNavigation.' . $section
) ?></span>
</button>
<ul>
<?php foreach ($data['items'] as $item): ?>
<?php $isActive =
base_url(route_to($item, $podcast->id)) == current_url(); ?>
<li>
<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,
$podcast->id
) ?>"><?= lang('PodcastNavigation.' . $item) ?></a>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endforeach; ?>
</nav>
<div class="w-full mt-auto border-t" data-toggle="dropdown">
<button type="button" class="inline-flex items-center w-full px-6 py-2 outline-none focus:shadow-outline" id="my-accountDropdown" data-popper="button" aria-haspopup="true" aria-expanded="false">
<?= icon('user', 'text-gray-500 mr-2') ?>
<?= user()->username ?>
<?= icon('caret-right', 'ml-auto') ?>
</button>
<nav class="absolute z-50 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="right-end">
<a class="px-4 py-1 hover:bg-gray-100" href="<?= route_to(
'my-account'
) ?>">My Account</a>
<a class="px-4 py-1 hover:bg-gray-100" href="<?= route_to(
'change-password'
) ?>">Change password</a>
<a class="px-4 py-1 hover:bg-gray-100" href="<?= route_to(
'logout'
) ?>">Logout</a>
</nav>
</div>

View File

@ -4,20 +4,30 @@
<?= lang('Podcast.create') ?>
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
<?= lang('Podcast.create') ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<?= form_open_multipart(route_to('podcast-create'), [
'method' => 'post',
'class' => 'flex flex-col max-w-md',
'class' => 'flex flex-col',
]) ?>
<?= csrf_field() ?>
<?= form_section(
lang('Podcast.form.identity_section_title'),
lang('Podcast.form.identity_section_subtitle')
) ?>
<?= form_label(lang('Podcast.form.image'), 'image') ?>
<?= form_input([
'id' => 'image',
'name' => 'image',
'class' => 'form-input',
'required' => 'required',
'type' => 'file',
'accept' => '.jpg,.jpeg,.png',
@ -35,7 +45,12 @@
'required' => 'required',
]) ?>
<?= form_label(lang('Podcast.form.name'), 'name') ?>
<?= form_label(
lang('Podcast.form.name'),
'name',
[],
lang('Podcast.form.name_hint')
) ?>
<?= form_input([
'id' => 'name',
'name' => 'name',
@ -44,6 +59,33 @@
'required' => 'required',
]) ?>
<?= form_fieldset('', ['class' => 'flex mb-4 gap-1']) ?>
<legend>
<?= lang('Podcast.form.type.label') .
hint_tooltip(lang('Podcast.form.type.hint'), 'ml-1') ?>
</legend>
<?= form_radio(
[
'id' => 'episodic',
'name' => 'type',
'class' => 'form-radio-btn',
],
'episodic',
old('type') ? old('type') == 'episodic' : true
) ?>
<label for="episodic"><?= lang('Podcast.form.type.episodic') ?></label>
<?= form_radio(
[
'id' => 'serial',
'name' => 'type',
'class' => 'form-radio-btn',
],
'serial',
old('type') ? old('type') == 'serial' : false
) ?>
<label for="serial"><?= lang('Podcast.form.type.serial') ?></label>
<?= form_fieldset_close() ?>
<div class="mb-4">
<?= form_label(lang('Podcast.form.description'), 'description') ?>
<?= form_textarea(
@ -58,21 +100,13 @@
) ?>
</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', '', false),
'data-editor="markdown"'
) ?>
</div>
<?= form_section_close() ?>
<?= form_section(
lang('Podcast.form.classification_section_title'),
lang('Podcast.form.classification_section_subtitle')
) ?>
<?= form_label(lang('Podcast.form.language'), 'language') ?>
<?= form_dropdown('language', $languageOptions, old('language', $browserLang), [
@ -88,16 +122,87 @@
'required' => 'required',
]) ?>
<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.other_categories'),
<?= form_label(lang('Podcast.form.owner_name'), 'owner_name') ?>
'other_categories',
[],
'',
true
) ?>
<?= form_multiselect(
'other_categories[]',
$categoryOptions,
old('other_categories', []),
[
'id' => 'other_categories',
'class' => 'mb-4',
'required' => 'required',
'data-max-item-count' => '2',
]
) ?>
<?= form_fieldset('', ['class' => 'flex mb-4 gap-1']) ?>
<legend>
<?= lang('Podcast.form.parental_advisory.label') .
hint_tooltip(lang('Podcast.form.parental_advisory.hint'), 'ml-1') ?>
</legend>
<?= form_radio(
[
'id' => 'undefined',
'name' => 'parental_advisory',
'class' => 'form-radio-btn',
],
'undefined',
old('parental_advisory')
? old('parental_advisory') === 'undefined'
: true
) ?>
<label for="undefined"><?= lang(
'Podcast.form.parental_advisory.undefined'
) ?></label>
<?= form_radio(
[
'id' => 'clean',
'name' => 'parental_advisory',
'class' => 'form-radio-btn',
],
'clean',
old('parental_advisory') ? old('parental_advisory') === 'clean' : false
) ?>
<label for="clean"><?= lang(
'Podcast.form.parental_advisory.clean'
) ?></label>
<?= form_radio(
[
'id' => 'explicit',
'name' => 'parental_advisory',
'class' => 'form-radio-btn',
],
'explicit',
old('parental_advisory')
? old('parental_advisory') === 'explicit'
: false
) ?>
<label for="explicit"><?= lang(
'Podcast.form.parental_advisory.explicit'
) ?></label>
<?= form_fieldset_close() ?>
<?= form_section_close() ?>
<?= form_section(
lang('Podcast.form.author_section_title'),
lang('Podcast.form.author_section_subtitle')
) ?>
<?= form_label(
lang('Podcast.form.owner_name'),
'owner_name',
[],
lang('Podcast.form.owner_name_hint')
) ?>
<?= form_input([
'id' => 'owner_name',
'name' => 'owner_name',
@ -106,7 +211,12 @@
'required' => 'required',
]) ?>
<?= form_label(lang('Podcast.form.owner_email'), 'owner_email') ?>
<?= form_label(
lang('Podcast.form.owner_email'),
'owner_email',
[],
lang('Podcast.form.owner_email_hint')
) ?>
<?= form_input([
'id' => 'owner_email',
'name' => 'owner_email',
@ -116,37 +226,21 @@
'required' => 'required',
]) ?>
<?= form_label(lang('Podcast.form.author'), 'author') ?>
<?= form_label(
lang('Podcast.form.publisher'),
'publisher',
[],
lang('Podcast.form.publisher_hint'),
true
) ?>
<?= form_input([
'id' => 'author',
'name' => 'author',
'id' => 'publisher',
'name' => 'publisher',
'class' => 'form-input mb-4',
'value' => old('author'),
'value' => old('publisher'),
]) ?>
<?= form_fieldset('', [
'class' => 'flex flex-col mb-4',
]) ?>
<legend><?= lang('Podcast.form.type.label') ?></legend>
<label for="episodic" class="inline-flex items-center">
<?= form_radio(
['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">
<?= 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>
<?= form_fieldset_close() ?>
<?= form_label(lang('Podcast.form.copyright'), 'copyright') ?>
<?= form_label(lang('Podcast.form.copyright'), 'copyright', [], '', true) ?>
<?= form_input([
'id' => 'copyright',
'name' => 'copyright',
@ -154,42 +248,39 @@
'value' => old('copyright'),
]) ?>
<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>
<?= form_section_close() ?>
<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_section(
lang('Podcast.form.status_section_title'),
lang('Podcast.form.status_section_subtitle')
) ?>
<?= form_switch(
lang('Podcast.form.block'),
['id' => 'block', 'name' => 'block'],
'yes',
old('block', false),
'mb-2'
) ?>
<?= form_switch(
lang('Podcast.form.complete'),
['id' => 'complete', 'name' => 'complete'],
'yes',
old('complete', false)
) ?>
<?= form_section_close() ?>
<?= button(
lang('Podcast.form.submit_create'),
null,
['variant' => 'primary'],
['type' => 'submit', 'class' => 'self-end']
) ?>
<?= form_button([
'content' => lang('Podcast.form.submit_create'),
'type' => 'submit',
'class' => 'self-end px-4 py-2 bg-gray-200',
]) ?>
<?= form_close() ?>

View File

@ -4,15 +4,23 @@
<?= lang('Podcast.edit') ?>
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
<?= lang('Podcast.edit') ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<?= form_open_multipart(route_to('podcast-edit', $podcast->id), [
'method' => 'post',
'class' => 'flex flex-col max-w-md',
'class' => 'flex flex-col',
]) ?>
<?= csrf_field() ?>
<?= form_section(
lang('Podcast.form.identity_section_title'),
lang('Podcast.form.identity_section_subtitle')
) ?>
<?= form_label(lang('Podcast.form.image'), 'image') ?>
<img
src="<?= $podcast->image->thumbnail_url ?>"
@ -39,7 +47,12 @@
'required' => 'required',
]) ?>
<?= form_label(lang('Podcast.form.name'), 'name') ?>
<?= form_label(
lang('Podcast.form.name'),
'name',
[],
lang('Podcast.form.name_hint')
) ?>
<?= form_input([
'id' => 'name',
'name' => 'name',
@ -48,6 +61,24 @@
'required' => 'required',
]) ?>
<?= form_fieldset('', ['class' => 'flex mb-4 gap-1']) ?>
<legend><?= lang('Podcast.form.type.label') .
hint_tooltip(lang('Podcast.form.type.hint'), 'ml-1') ?>
</legend>
<?= form_radio(
['id' => 'episodic', 'name' => 'type', 'class' => 'form-radio-btn'],
'episodic',
old('type') ? old('type') == 'episodic' : $podcast->type == 'episodic'
) ?>
<label for="episodic"><?= lang('Podcast.form.type.episodic') ?></label>
<?= form_radio(
['id' => 'serial', 'name' => 'type', 'class' => 'form-radio-btn'],
'serial',
old('type') ? old('type') == 'serial' : $podcast->type == 'serial'
) ?>
<label for="serial"><?= lang('Podcast.form.type.serial') ?></label>
<?= form_fieldset_close() ?>
<div class="mb-4">
<?= form_label(lang('Podcast.form.description'), 'description') ?>
<?= form_textarea(
@ -62,25 +93,13 @@
) ?>
</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_section_close() ?>
<?= form_section(
lang('Podcast.form.classification_section_title'),
lang('Podcast.form.classification_section_subtitle')
) ?>
<?= form_label(lang('Podcast.form.language'), 'language') ?>
<?= form_dropdown(
@ -98,7 +117,7 @@
<?= form_dropdown(
'category',
$categoryOptions,
old('category', $podcast->category_id),
old('category', (string) $podcast->category_id),
[
'id' => 'category',
'class' => 'form-select mb-4',
@ -106,16 +125,85 @@
]
) ?>
<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.other_categories'),
'other_categories',
[],
'',
true
) ?>
<?= form_multiselect(
'other_categories[]',
$categoryOptions,
old('other_categories', $podcast->other_categories_ids),
[
'id' => 'other_categories',
'class' => 'mb-4',
'data-max-item-count' => '2',
]
) ?>
<?= form_label(lang('Podcast.form.owner_name'), 'owner_name') ?>
<?= form_fieldset('', ['class' => 'flex mb-4 gap-1']) ?>
<legend><?= lang('Podcast.form.parental_advisory.label') .
hint_tooltip(lang('Podcast.form.parental_advisory.hint'), 'ml-1') ?>
</legend>
<?= form_radio(
[
'id' => 'undefined',
'name' => 'parental_advisory',
'class' => 'form-radio-btn',
],
'undefined',
old('parental_advisory')
? old('parental_advisory') === 'undefined'
: $podcast->parental_advisory === null
) ?>
<label for="undefined"><?= lang(
'Podcast.form.parental_advisory.undefined'
) ?></label>
<?= form_radio(
[
'id' => 'clean',
'name' => 'parental_advisory',
'class' => 'form-radio-btn',
],
'clean',
old('parental_advisory')
? old('parental_advisory') === 'clean'
: $podcast->parental_advisory === 'clean'
) ?>
<label for="clean"><?= lang(
'Podcast.form.parental_advisory.clean'
) ?></label>
<?= form_radio(
[
'id' => 'explicit',
'name' => 'parental_advisory',
'class' => 'form-radio-btn',
],
'explicit',
old('parental_advisory')
? old('parental_advisory') === 'explicit'
: $podcast->parental_advisory === 'explicit'
) ?>
<label for="explicit"><?= lang(
'Podcast.form.parental_advisory.explicit'
) ?></label>
<?= form_fieldset_close() ?>
<?= form_section_close() ?>
<?= form_section(
lang('Podcast.form.author_section_title'),
lang('Podcast.form.author_section_subtitle')
) ?>
<?= form_label(
lang('Podcast.form.owner_name'),
'owner_name',
[],
lang('Podcast.form.owner_name_hint')
) ?>
<?= form_input([
'id' => 'owner_name',
'name' => 'owner_name',
@ -124,7 +212,12 @@
'required' => 'required',
]) ?>
<?= form_label(lang('Podcast.form.owner_email'), 'owner_email') ?>
<?= form_label(
lang('Podcast.form.owner_email'),
'owner_email',
[],
lang('Podcast.form.owner_email_hint')
) ?>
<?= form_input([
'id' => 'owner_email',
'name' => 'owner_email',
@ -134,37 +227,21 @@
'required' => 'required',
]) ?>
<?= form_label(lang('Podcast.form.author'), 'author') ?>
<?= form_label(
lang('Podcast.form.publisher'),
'publisher',
[],
lang('Podcast.form.publisher_hint'),
true
) ?>
<?= form_input([
'id' => 'author',
'name' => 'author',
'id' => 'publisher',
'name' => 'publisher',
'class' => 'form-input mb-4',
'value' => old('author', $podcast->author),
'value' => old('publisher', $podcast->publisher),
]) ?>
<?= form_fieldset('', ['class' => 'flex flex-col mb-4']) ?>
<legend><?= lang('Podcast.form.type.label') ?></legend>
<label for="episodic" class="inline-flex items-center">
<?= form_radio(
['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">
<?= 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>
<?= form_fieldset_close() ?>
<?= form_label(lang('Podcast.form.copyright'), 'copyright') ?>
<?= form_label(lang('Podcast.form.copyright'), 'copyright', [], '', true) ?>
<?= form_input([
'id' => 'copyright',
'name' => 'copyright',
@ -172,42 +249,37 @@
'value' => old('copyright', $podcast->copyright),
]) ?>
<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>
<?= form_section_close() ?>
<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_section(
lang('Podcast.form.status_section_title'),
lang('Podcast.form.status_section_subtitle')
) ?>
<?= form_button([
'content' => lang('Podcast.form.submit_edit'),
'type' => 'submit',
'class' => 'self-end px-4 py-2 bg-gray-200',
]) ?>
<?= form_switch(
lang('Podcast.form.block'),
['id' => 'block', 'name' => 'block'],
'yes',
old('block', $podcast->block),
'mb-2'
) ?>
<?= form_switch(
lang('Podcast.form.complete'),
['id' => 'complete', 'name' => 'complete'],
'yes',
old('complete', $podcast->complete)
) ?>
<?= form_section_close() ?>
<?= button(
lang('Podcast.form.submit_edit'),
null,
['variant' => 'primary'],
['type' => 'submit', 'class' => 'self-end']
) ?>
<?= form_close() ?>

View File

@ -4,16 +4,53 @@
<?= lang('Podcast.import') ?>
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
<?= lang('Podcast.import') ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<?= form_open_multipart(route_to('rzqr'), [
<?= form_open_multipart(route_to('podcast-import'), [
'method' => 'post',
'class' => 'flex flex-col max-w-md',
'class' => 'flex flex-col items-start',
]) ?>
<?= csrf_field() ?>
<?= form_label(lang('Podcast.form_import.name'), 'name') ?>
<?= form_section(
lang('PodcastImport.old_podcast_section_title'),
lang('PodcastImport.old_podcast_section_subtitle')
) ?>
<?= form_label(
lang('PodcastImport.imported_feed_url'),
'imported_feed_url',
[],
lang('PodcastImport.imported_feed_url_hint')
) ?>
<?= form_input([
'id' => 'imported_feed_url',
'name' => 'imported_feed_url',
'class' => 'form-input',
'value' => old('imported_feed_url'),
'placeholder' => 'https://...',
'type' => 'url',
'required' => 'required',
]) ?>
<?= form_section_close() ?>
<?= form_section(
lang('PodcastImport.new_podcast_section_title'),
lang('PodcastImport.new_podcast_section_subtitle')
) ?>
<?= form_label(
lang('PodcastImport.name'),
'name',
[],
lang('PodcastImport.name_hint')
) ?>
<?= form_input([
'id' => 'name',
'name' => 'name',
@ -22,19 +59,6 @@
'required' => 'required',
]) ?>
<?= form_label(
lang('Podcast.form_import.imported_feed_url'),
'imported_feed_url'
) ?>
<?= form_input([
'id' => 'imported_feed_url',
'name' => 'imported_feed_url',
'class' => 'form-input mb-4',
'value' => old('imported_feed_url'),
'type' => 'url',
'required' => 'required',
]) ?>
<?= form_label(lang('Podcast.form.language'), 'language') ?>
<?= form_dropdown('language', $languageOptions, old('language', $browserLang), [
'id' => 'language',
@ -49,38 +73,50 @@
'required' => 'required',
]) ?>
<?= form_section_close() ?>
<?= form_section(
lang('PodcastImport.advanced_params_section_title'),
lang('PodcastImport.advanced_params_section_subtitle')
) ?>
<?= form_fieldset('', ['class' => 'flex flex-col mb-4']) ?>
<legend><?= lang('Podcast.form_import.slug_field.label') ?></legend>
<legend><?= lang('PodcastImport.slug_field.label') ?></legend>
<label for="link" class="inline-flex items-center">
<?= form_radio(
['id' => 'link', 'name' => 'slug_field', 'class' => 'form-radio'],
[
'id' => 'link',
'name' => 'slug_field',
'class' => 'form-radio text-green-500',
],
'link',
old('slug_field') ? old('slug_field') == 'link' : true
) ?>
<span class="ml-2"><?= lang(
'Podcast.form_import.slug_field.link'
) ?></span>
<span class="ml-2"><?= lang('PodcastImport.slug_field.link') ?></span>
</label>
<label for="title" class="inline-flex items-center">
<?= form_radio(
['id' => 'title', 'name' => 'slug_field', 'class' => 'form-radio'],
[
'id' => 'title',
'name' => 'slug_field',
'class' => 'form-radio text-green-500',
],
'title',
old('slug_field') ? old('slug_field') == 'title' : false
) ?>
<span class="ml-2"><?= lang(
'Podcast.form_import.slug_field.title'
) ?></span>
<span class="ml-2"><?= lang('PodcastImport.slug_field.title') ?></span>
</label>
<?= form_fieldset_close() ?>
<?= form_fieldset('', ['class' => 'flex flex-col mb-4']) ?>
<legend><?= lang('Podcast.form_import.description_field.label') ?></legend>
<legend><?= lang('PodcastImport.description_field.label') ?></legend>
<label for="description" class="inline-flex items-center">
<?= form_radio(
[
'id' => 'description',
'name' => 'description_field',
'class' => 'form-radio',
'class' => 'form-radio text-green-500',
],
'description',
old('description_field')
@ -88,7 +124,7 @@
: true
) ?>
<span class="ml-2"><?= lang(
'Podcast.form_import.description_field.description'
'PodcastImport.description_field.description'
) ?></span>
</label>
<label for="summary" class="inline-flex items-center">
@ -96,7 +132,7 @@
[
'id' => 'summary',
'name' => 'description_field',
'class' => 'form-radio',
'class' => 'form-radio text-green-500',
],
'summary',
old('description_field')
@ -104,7 +140,7 @@
: false
) ?>
<span class="ml-2"><?= lang(
'Podcast.form_import.description_field.summary'
'PodcastImport.description_field.summary'
) ?></span>
</label>
<label for="subtitle_summary" class="inline-flex items-center">
@ -112,7 +148,7 @@
[
'id' => 'subtitle_summary',
'name' => 'description_field',
'class' => 'form-radio',
'class' => 'form-radio text-green-500',
],
'subtitle_summary',
old('description_field')
@ -120,7 +156,7 @@
: false
) ?>
<span class="ml-2"><?= lang(
'Podcast.form_import.description_field.subtitle_summary'
'PodcastImport.description_field.subtitle_summary'
) ?></span>
</label>
<?= form_fieldset_close() ?>
@ -131,15 +167,21 @@
[
'id' => 'force_renumber',
'name' => 'force_renumber',
'class' => 'form-checkbox',
'class' => 'form-checkbox text-green-500',
],
'yes',
old('force_renumber', false)
) ?>
<span class="ml-2"><?= lang('Podcast.form_import.force_renumber') ?></span>
<span class="ml-2"><?= lang('PodcastImport.force_renumber') ?></span>
<?= hint_tooltip(lang('PodcastImport.force_renumber_hint'), 'ml-1') ?>
</label>
<?= form_label(lang('Podcast.form_import.season_number'), 'season_number') ?>
<?= form_label(
lang('PodcastImport.season_number'),
'season_number',
[],
lang('PodcastImport.season_number_hint')
) ?>
<?= form_input([
'id' => 'season_number',
'name' => 'season_number',
@ -148,7 +190,12 @@
'type' => 'number',
]) ?>
<?= form_label(lang('Podcast.form_import.max_episodes'), 'max_episodes') ?>
<?= form_label(
lang('PodcastImport.max_episodes'),
'max_episodes',
[],
lang('PodcastImport.max_episodes_hint')
) ?>
<?= form_input([
'id' => 'max_episodes',
'name' => 'max_episodes',
@ -157,11 +204,14 @@
'type' => 'number',
]) ?>
<?= form_button([
'content' => lang('Podcast.form_import.submit_import'),
'type' => 'submit',
'class' => 'self-end px-4 py-2 bg-gray-200',
]) ?>
<?= form_section_close() ?>
<?= button(
lang('PodcastImport.submit'),
null,
['variant' => 'primary'],
['type' => 'submit', 'class' => 'self-end']
) ?>
<?= form_close() ?>

View File

@ -0,0 +1,101 @@
<section class="flex flex-col">
<header class="flex justify-between py-2">
<h1 class="text-xl"><?= lang('Podcast.latest_episodes') ?></h1>
<a href="<?= route_to(
'episode-list',
$podcast->id
) ?>" class="inline-flex items-center text-sm underline hover:no-underline">
<?= lang('Podcast.see_all_episodes') ?>
<?= icon('chevron-right', 'ml-2') ?>
</a>
</header>
<?php if ($episodes): ?>
<div class="flex justify-between gap-4 overflow-x-auto">
<?php foreach ($episodes as $episode): ?>
<article class="flex flex-col w-56 mb-4 bg-white border rounded shadow" style="min-width: 12rem;">
<img
src="<?= $episode->image->thumbnail_url ?>"
alt="<?= $episode->title ?>" class="object-cover" />
<div class="flex justify-between p-2">
<div class="flex flex-col">
<a href="<?= route_to(
'episode-view',
$podcast->id,
$episode->id
) ?>"
class="text-sm font-semibold hover:underline"
><?= $episode->title ?>
</a>
<div class="text-xs">
<?php if (
$episode->season_number &&
$episode->number
): ?>
<abbr class="font-bold text-gray-600" title="<?= lang(
'Episode.season_episode',
[
'seasonNumber' =>
$episode->season_number,
'episodeNumber' => $episode->number,
]
) ?>"><?= lang(
'Episode.season_episode_abbr',
[
'seasonNumber' => $episode->season_number,
'episodeNumber' => $episode->number,
]
) ?></abbr>
<?php elseif (
!$episode->season_number &&
$episode->number
): ?>
<abbr class="font-bold text-gray-600" title="<?= lang(
'Episode.number',
[
'episodeNumber' => $episode->number,
]
) ?>"><?= lang('Episode.number_abbr', [
'episodeNumber' => $episode->number,
]) ?></abbr>
<?php endif; ?>
<span class="mx-1"></span>
<time
pubdate
datetime="<?= $episode->published_at->toDateTimeString() ?>"
title="<?= $episode->published_at ?>">
<?= lang('Common.mediumDate', [
$episode->published_at,
]) ?>
</time>
</div>
</div>
<div class="relative" data-toggle="dropdown">
<button type="button" class="inline-flex items-center p-1 outline-none focus:shadow-outline" id="moreDropdown" data-popper="button" aria-haspopup="true" aria-expanded="false">
<?= icon('more') ?>
</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="top-end" data-popper-offset-x="0" data-popper-offset-y="-24" >
<a class="px-4 py-1 hover:bg-gray-100" href="<?= route_to(
'episode-edit',
$podcast->id,
$episode->id
) ?>"><?= lang('Episode.edit') ?></a>
<a class="px-4 py-1 hover:bg-gray-100" href="<?= route_to(
'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',
$podcast->id,
$episode->id
) ?>"><?= lang('Episode.delete') ?></a>
</nav>
</div>
</div>
</article>
<?php endforeach; ?>
</div>
<?php else: ?>
<p class="italic"><?= lang('Podcast.no_episode') ?></p>
<?php endif; ?>
</section>

View File

@ -1,17 +1,24 @@
<?= $this->extend('admin/_layout') ?>
<?= $this->section('title') ?>
<?= lang('Podcast.all_podcasts') ?>
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
<?= 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'
) ?>">
<?= icon('add', 'mr-2') ?>
<?= lang('Podcast.create') ?></a>
<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-import'
) ?>">
<?= icon('add', 'mr-2') ?>
<?= lang('Podcast.import') ?></a>
<?= $this->endSection() ?>
<?= $this->section('headerRight') ?>
<?= button(
lang('Podcast.create'),
route_to('podcast-create'),
['variant' => 'primary', 'iconLeft' => 'add'],
['class' => 'mr-2']
) ?>
<?= button(lang('Podcast.import'), route_to('podcast-import'), [
'variant' => 'primary',
'iconLeft' => 'download',
]) ?>
<?= $this->endSection() ?>
@ -20,9 +27,35 @@
<div class="flex flex-wrap">
<?php if (!empty($podcasts)): ?>
<?php foreach ($podcasts as $podcast): ?>
<?= view('admin/_partials/_podcast-card', [
'podcast' => $podcast,
]) ?>
<article class="w-48 h-full mb-4 mr-4 overflow-hidden bg-white border rounded shadow">
<img
alt="<?= $podcast->title ?>"
src="<?= $podcast->image
->thumbnail_url ?>" class="object-cover w-full h-40" />
<div class="p-2">
<a href="<?= route_to(
'podcast-view',
$podcast->id
) ?>" class="hover:underline">
<h2 class="font-semibold"><?= $podcast->title ?></h2>
</a>
<p class="text-gray-600">@<?= $podcast->name ?></p>
</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->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->id
) ?>" data-toggle="tooltip" data-placement="bottom" title="<?= lang(
'Podcast.view'
) ?>"><?= icon('eye') ?></a>
</footer>
</article>
<?php endforeach; ?>
<?php else: ?>
<p class="italic"><?= lang('Podcast.no_podcast') ?></p>

View File

@ -4,6 +4,10 @@
<?= lang('Podcast.platforms.title') ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<?= $this->section('pageTitle') ?>
<?= lang('Podcast.platforms.title') ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
Podcast settings...
<?= $this->endSection() ?>

View File

@ -4,6 +4,10 @@
<?= lang('Platforms.title') ?>
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
<?= lang('Platforms.title') ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<?= form_open(route_to('platforms', $podcast->id), [
@ -88,11 +92,12 @@
<?php endforeach; ?>
<?= form_button([
'content' => lang('Platforms.submit'),
'type' => 'submit',
'class' => 'self-end px-4 py-2 bg-gray-200',
]) ?>
<?= button(
lang('Platforms.submit'),
null,
['variant' => 'primary'],
['type' => 'submit', 'class' => 'self-end']
) ?>
<?= form_close() ?>

View File

@ -2,45 +2,29 @@
<?= $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->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',
$podcast->id
) ?>">
<?= icon('add', 'mr-2') ?>
<?= lang('Episode.create') ?></a>
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
<?= $podcast->title ?>
<?= $this->endSection() ?>
<?= $this->section('headerRight') ?>
<?= button(
lang('Podcast.edit'),
route_to('podcast-edit', $podcast->id),
['variant' => 'secondary', 'iconLeft' => 'edit'],
['class' => 'mr-2']
) ?>
<?= button(lang('Episode.create'), route_to('episode-create', $podcast->id), [
'variant' => 'primary',
'iconLeft' => 'add',
]) ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<img
class="w-64 mb-4"
src="<?= $podcast->image->medium_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',
$podcast->id
) ?>"><?= lang('Podcast.see_contributors') ?></a>
<a class="inline-flex px-2 py-1 mb-2 text-white bg-indigo-700 hover:bg-indigo-800" href="<?= route_to(
'platforms',
$podcast->id
) ?>"><?= lang('Platforms.title') ?></a>
<a class="inline-flex px-2 py-1 text-white bg-gray-700 hover:bg-gray-800" href="<?= route_to(
'podcast',
$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->id
) ?>"><?= lang('Podcast.delete') ?></a>
<?= view('admin/_partials/_episode-list.php', [
'episodes' => $podcast->episodes,
]) ?>
<?= view_cell('\App\Controllers\Admin\Podcast::latestEpisodes', [
'limit' => 5,
]) ?>
<?= $this->endSection() ?>

View File

@ -4,12 +4,14 @@
<?= lang('User.create') ?>
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
<?= lang('User.create') ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<?= form_open(route_to('user-create'), [
'class' => 'flex flex-col max-w-sm',
]) ?>
<?= form_open(route_to('user-create'), ['class' => 'flex flex-col max-w-sm']) ?>
<?= csrf_field() ?>
<?= form_label(lang('User.form.email'), 'email') ?>
@ -33,16 +35,18 @@
<?= 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',
]) ?>
<?= button(
lang('User.form.submit_create'),
null,
['variant' => 'primary'],
['type' => 'submit', 'class' => 'self-end']
) ?>
<?= form_close() ?>

View File

@ -4,6 +4,10 @@
<?= lang('User.edit_roles', ['username' => $user->username]) ?>
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
<?= lang('User.edit_roles', ['username' => $user->username]) ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
@ -15,14 +19,15 @@
<?= form_label(lang('User.form.roles'), 'roles') ?>
<?= form_multiselect('roles[]', $roleOptions, $user->roles, [
'id' => 'roles',
'class' => 'form-multiselect mb-4',
'class' => 'mb-4',
]) ?>
<?= form_button([
'content' => lang('User.form.submit_edit'),
'type' => 'submit',
'class' => 'self-end px-4 py-2 bg-gray-200',
]) ?>
<?= button(
lang('User.form.submit_edit'),
null,
['variant' => 'primary'],
['type' => 'submit', 'class' => 'self-end']
) ?>
<?= form_close() ?>

View File

@ -1,63 +1,89 @@
<?= $this->extend('admin/_layout') ?>
<?= $this->section('title') ?>
<?= lang('User.all_users') ?>
<?= $this->endSection() ?>
<?= $this->section('pageTitle') ?>
<?= lang('User.all_users') ?> (<?= count($users) ?>)
<?= $this->endSection() ?>
<?= $this->section('headerRight') ?>
<?= button(lang('User.create'), route_to('user-create'), [
'variant' => 'primary',
'iconLeft' => 'user-add',
]) ?>
<?= $this->endSection() ?>
<?= $this->section('content') ?>
<table class="table-auto">
<thead>
<tr>
<th class="px-4 py-2">Username</th>
<th class="px-4 py-2">Email</th>
<th class="px-4 py-2">Roles</th>
<th class="px-4 py-2">Banned?</th>
<th class="px-4 py-2">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $user): ?>
<tr>
<td class="px-4 py-2 border"><?= $user->username ?></td>
<td class="px-4 py-2 border"><?= $user->email ?></td>
<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->id
) ?>" data-toggle="tooltip" data-placement="bottom"
title="<?= lang('User.edit_roles', [
'username' => $user->username,
]) ?>">
<?= icon('edit') ?>
</a>
</td>
<td class="px-4 py-2 border"><?= $user->isBanned()
? 'Yes'
: '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->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->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->id
) ?>"><?= lang('User.delete') ?></a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?= data_table(
[
[
'header' => lang('User.list.user'),
'cell' => function ($user) {
return '<div class="flex flex-col">' .
$user->username .
'<span class="text-sm text-gray-600">' .
$user->email .
'</span></div>';
},
],
[
'header' => lang('User.list.roles'),
'cell' => function ($user) {
return implode(',', $user->roles) .
icon_button(
'edit',
lang('User.edit_roles', [
'username' => $user->username,
]),
route_to('user-edit', $user->id),
['variant' => 'info'],
['class' => 'ml-2']
);
},
],
[
'header' => lang('User.list.banned'),
'cell' => function ($user) {
return $user->isBanned()
? lang('Common.yes')
: lang('Common.no');
},
],
[
'header' => lang('Common.actions'),
'cell' => function ($user) {
return button(
lang('User.forcePassReset'),
route_to('user-force_pass_reset', $user->id),
[
'variant' => 'secondary',
'size' => 'small',
],
['class' => 'mr-2']
) .
button(
lang('User.' . ($user->isBanned() ? 'unban' : 'ban')),
route_to(
$user->isBanned() ? 'user-unban' : 'user-ban',
$user->id
),
['variant' => 'warning', 'size' => 'small'],
['class' => 'mr-2']
) .
button(
lang('User.delete'),
route_to('user-delete', $user->id),
['variant' => 'danger', 'size' => 'small']
);
},
],
],
$users
) ?>
<?= $this->endSection()
?>

View File

@ -24,8 +24,9 @@
</main>
<footer class="flex flex-col text-sm">
<?= $this->renderSection('footer') ?>
<p class="py-4 border-t">
Powered by <a class="underline hover:no-underline" href="https://castopod.org" target="_blank" rel="noreferrer noopener">Castopod</a>, a <a class="underline hover:no-underline" href="https://podlibre.org/" target="_blank" rel="noreferrer noopener">Podlibre</a> initiative.
</p>
<small class="py-4 text-center border-t"><?= lang('Common.powered_by', [
'castopod' =>
'<a class="underline hover:no-underline" href="https://castopod.org" target="_blank" rel="noreferrer noopener">Castopod</a>',
]) ?></small>
</footer>
</body>

View File

@ -22,11 +22,12 @@
'required' => 'required',
]) ?>
<?= form_button([
'content' => lang('Auth.sendInstructions'),
'type' => 'submit',
'class' => 'px-4 py-2 ml-auto border',
]) ?>
<?= button(
lang('Auth.sendInstructions'),
null,
['variant' => 'primary'],
['type' => 'submit', 'class' => 'self-end']
) ?>
<?= form_close() ?>

View File

@ -28,11 +28,13 @@
'required' => 'required',
]) ?>
<?= form_button([
'content' => lang('Auth.loginAction'),
'class' => 'px-4 py-2 ml-auto border',
'type' => 'submit',
]) ?>
<?= button(
lang('Auth.loginAction'),
null,
['variant' => 'primary'],
['type' => 'submit', 'class' => 'self-end']
) ?>
<?= form_close() ?>

View File

@ -44,11 +44,12 @@
'autocomplete' => 'new-password',
]) ?>
<?= form_button([
'content' => lang('Auth.register'),
'class' => 'px-4 py-2 ml-auto border',
'type' => 'submit',
]) ?>
<?= button(
lang('Auth.register'),
null,
['variant' => 'primary'],
['type' => 'submit', 'class' => 'self-end']
) ?>
<?= form_close() ?>

View File

@ -42,11 +42,12 @@
'autocomplete' => 'new-password',
]) ?>
<?= form_button([
'content' => lang('Auth.resetPassword'),
'class' => 'px-4 py-2 ml-auto border',
'type' => 'submit',
]) ?>
<?= button(
lang('Auth.resetPassword'),
null,
['variant' => 'primary'],
['type' => 'submit', 'class' => 'self-end']
) ?>
<?= form_close() ?>

Some files were not shown because too many files have changed in this diff Show More