diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md
index fed484c9..df0c8a6e 100644
--- a/DEPENDENCIES.md
+++ b/DEPENDENCIES.md
@@ -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:
diff --git a/app/Config/Pager.php b/app/Config/Pager.php
index 50a4a5f9..699ab907 100644
--- a/app/Config/Pager.php
+++ b/app/Config/Pager.php
@@ -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',
];
diff --git a/app/Controllers/Admin/BaseController.php b/app/Controllers/Admin/BaseController.php
index 92f4849a..5d7de7d2 100644
--- a/app/Controllers/Admin/BaseController.php
+++ b/app/Controllers/Admin/BaseController.php
@@ -26,7 +26,7 @@ class BaseController extends Controller
*
* @var array
*/
- protected $helpers = ['auth', 'breadcrumb', 'svg'];
+ protected $helpers = ['auth', 'breadcrumb', 'svg', 'components'];
/**
* Constructor.
diff --git a/app/Controllers/Admin/Contributor.php b/app/Controllers/Admin/Contributor.php
index 693746d0..01e66ee6 100644
--- a/app/Controllers/Admin/Contributor.php
+++ b/app/Controllers/Admin/Contributor.php
@@ -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', [
diff --git a/app/Controllers/Admin/Episode.php b/app/Controllers/Admin/Episode.php
index 39ce2e0c..5e25bd00 100644
--- a/app/Controllers/Admin/Episode.php
+++ b/app/Controllers/Admin/Episode.php
@@ -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()
diff --git a/app/Controllers/Admin/Myaccount.php b/app/Controllers/Admin/Myaccount.php
index 7a058af0..50f2fb45 100644
--- a/app/Controllers/Admin/Myaccount.php
+++ b/app/Controllers/Admin/Myaccount.php
@@ -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()
diff --git a/app/Controllers/Admin/Page.php b/app/Controllers/Admin/Page.php
index 384b72bf..f2ce56db 100644
--- a/app/Controllers/Admin/Page.php
+++ b/app/Controllers/Admin/Page.php
@@ -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()
diff --git a/app/Controllers/Admin/Podcast.php b/app/Controllers/Admin/Podcast.php
index ab0dde47..794bcef3 100644
--- a/app/Controllers/Admin/Podcast.php
+++ b/app/Controllers/Admin/Podcast.php
@@ -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'
- ? '
' .
- $nsItunes->subtitle .
- "
\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()
diff --git a/app/Controllers/Admin/User.php b/app/Controllers/Admin/User.php
index 63874156..be174529 100644
--- a/app/Controllers/Admin/User.php
+++ b/app/Controllers/Admin/User.php
@@ -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());
diff --git a/app/Controllers/Auth.php b/app/Controllers/Auth.php
index aaac73cc..1a36daf1 100644
--- a/app/Controllers/Auth.php
+++ b/app/Controllers/Auth.php
@@ -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.
*/
diff --git a/app/Controllers/BaseController.php b/app/Controllers/BaseController.php
index ab5eef7d..2f5bdcff 100644
--- a/app/Controllers/BaseController.php
+++ b/app/Controllers/BaseController.php
@@ -26,7 +26,7 @@ class BaseController extends Controller
*
* @var array
*/
- protected $helpers = ['analytics', 'svg'];
+ protected $helpers = ['analytics', 'svg', 'components'];
/**
* Constructor.
diff --git a/app/Controllers/Episode.php b/app/Controllers/Episode.php
index a0e0dfa2..8b340901 100644
--- a/app/Controllers/Episode.php
+++ b/app/Controllers/Episode.php
@@ -57,6 +57,7 @@ class Episode extends BaseController
$data = [
'previousEpisode' => $previousNextEpisodes['previous'],
'nextEpisode' => $previousNextEpisodes['next'],
+ 'podcast' => $this->podcast,
'episode' => $this->episode,
];
diff --git a/app/Database/Migrations/2020-05-30-101500_add_podcasts.php b/app/Database/Migrations/2020-05-30-101500_add_podcasts.php
index 4c024439..a95e4db1 100644
--- a/app/Database/Migrations/2020-05-30-101500_add_podcasts.php
+++ b/app/Database/Migrations/2020-05-30-101500_add_podcasts.php
@@ -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,
diff --git a/app/Database/Migrations/2020-06-05-170000_add_episodes.php b/app/Database/Migrations/2020-06-05-170000_add_episodes.php
index e965fd8e..24b2f02f 100644
--- a/app/Database/Migrations/2020-06-05-170000_add_episodes.php
+++ b/app/Database/Migrations/2020-06-05-170000_add_episodes.php
@@ -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',
diff --git a/app/Database/Migrations/2020-09-29-150000_add_podcasts_categories.php b/app/Database/Migrations/2020-09-29-150000_add_podcasts_categories.php
new file mode 100644
index 00000000..4139b150
--- /dev/null
+++ b/app/Database/Migrations/2020-09-29-150000_add_podcasts_categories.php
@@ -0,0 +1,42 @@
+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');
+ }
+}
diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php
index 9c731123..7bbda5e5 100644
--- a/app/Entities/Episode.php
+++ b/app/Entities/Episode.php
@@ -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',
diff --git a/app/Entities/Podcast.php b/app/Entities/Podcast.php
index 0a8a273b..c0dd2b7d 100644
--- a/app/Entities/Podcast.php
+++ b/app/Entities/Podcast.php
@@ -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;
+ }
}
diff --git a/app/Entities/User.php b/app/Entities/User.php
index ed6bc920..6a3e7a1f 100644
--- a/app/Entities/User.php
+++ b/app/Entities/User.php
@@ -1,5 +1,11 @@
render();
+ return $breadcrumb->render($class);
}
function replace_breadcrumb_params($newParams)
diff --git a/app/Helpers/components_helper.php b/app/Helpers/components_helper.php
new file mode 100644
index 00000000..5b26da6d
--- /dev/null
+++ b/app/Helpers/components_helper.php
@@ -0,0 +1,258 @@
+ '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 '';
+ }
+}
+
+// ------------------------------------------------------------------------
+
+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 =
+ '' . icon('question') . '';
+ }
+}
+
+// ------------------------------------------------------------------------
+
+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' => '',
+
+ 'thead_open' =>
+ '',
+
+ 'heading_cell_start' => '',
+ 'cell_start' => ' | ',
+ 'cell_alt_start' => ' | ',
+
+ 'row_start' => ' | ',
+ 'row_alt_start' => '
',
+ ];
+
+ $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 '' .
+ $table->generate() .
+ '
';
+ }
+}
+
+// ------------------------------------------------------------------------
diff --git a/app/Helpers/form_helper.php b/app/Helpers/form_helper.php
new file mode 100644
index 00000000..5be15eec
--- /dev/null
+++ b/app/Helpers/form_helper.php
@@ -0,0 +1,187 @@
+\n";
+
+ $info =
+ '' .
+ $title .
+ '
' .
+ $subtitle .
+ '
';
+
+ return $section . $info . '';
+ }
+}
+
+//--------------------------------------------------------------------
+
+if (!function_exists('form_section_close')) {
+ /**
+ * Form Section close Tag
+ *
+ * @param string $extra
+ *
+ * @return string
+ */
+ function form_section_close(string $extra = ''): string
+ {
+ return '
' . $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 '';
+ }
+}
+
+//--------------------------------------------------------------------
+
+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 = '';
+ }
+}
+
+//--------------------------------------------------------------------
+
+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);
+ }
+}
+
+//--------------------------------------------------------------------
diff --git a/app/Helpers/rss_helper.php b/app/Helpers/rss_helper.php
index 68da0f2a..532b9bcb 100644
--- a/app/Helpers/rss_helper.php
+++ b/app/Helpers/rss_helper.php
@@ -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 and 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);
+}
diff --git a/app/Helpers/svg_helper.php b/app/Helpers/svg_helper.php
index a3b353d7..921284c9 100644
--- a/app/Helpers/svg_helper.php
+++ b/app/Helpers/svg_helper.php
@@ -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(
'',
- 'custom_html_head_help' =>
- 'Add here any HTML code that you would like to see on all this podcast pages within the 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 Mastodon’s 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' => '<link>',
- 'title' => '<title>',
- ],
- 'description_field' => [
- 'label' => 'Source field used for episode description / show notes',
- 'description' => '<description>',
- 'summary' => '<itunes:summary>',
- 'subtitle_summary' =>
- '<itunes:subtitle> <itunes:summary>',
- ],
- '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',
diff --git a/app/Language/en/PodcastImport.php b/app/Language/en/PodcastImport.php
new file mode 100644
index 00000000..6b86eb16
--- /dev/null
+++ b/app/Language/en/PodcastImport.php
@@ -0,0 +1,43 @@
+ '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' => '<link>',
+ 'title' => '<title>',
+ ],
+ 'description_field' => [
+ 'label' => 'Source field used for episode description / show notes',
+ 'description' => '<description>',
+ 'summary' => '<itunes:summary>',
+ 'subtitle_summary' =>
+ '<itunes:subtitle> + <itunes:summary>',
+ ],
+ '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',
+];
diff --git a/app/Language/en/PodcastNavigation.php b/app/Language/en/PodcastNavigation.php
new file mode 100644
index 00000000..05fe31f1
--- /dev/null
+++ b/app/Language/en/PodcastNavigation.php
@@ -0,0 +1,23 @@
+ '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',
+];
diff --git a/app/Language/en/User.php b/app/Language/en/User.php
index 2e221aaf..3c84d274 100644
--- a/app/Language/en/User.php
+++ b/app/Language/en/User.php
@@ -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!',
diff --git a/app/Libraries/Breadcrumb.php b/app/Libraries/Breadcrumb.php
index 43cb2f95..816f61eb 100644
--- a/app/Libraries/Breadcrumb.php
+++ b/app/Libraries/Breadcrumb.php
@@ -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 '';
}
diff --git a/app/Models/CategoryModel.php b/app/Models/CategoryModel.php
index af52ff04..6764f6ed 100644
--- a/app/Models/CategoryModel.php
+++ b/app/Models/CategoryModel.php
@@ -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;
+ }
}
diff --git a/app/Models/EpisodeModel.php b/app/Models/EpisodeModel.php
index 9eb734d8..d72f3f08 100644
--- a/app/Models/EpisodeModel.php
+++ b/app/Models/EpisodeModel.php
@@ -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',
diff --git a/app/Models/PodcastModel.php b/app/Models/PodcastModel.php
index 16f50a26..2df72349 100644
--- a/app/Models/PodcastModel.php
+++ b/app/Models/PodcastModel.php
@@ -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',
diff --git a/app/Views/_assets/admin.ts b/app/Views/_assets/admin.ts
index 2f390556..7967215c 100644
--- a/app/Views/_assets/admin.ts
+++ b/app/Views/_assets/admin.ts
@@ -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();
diff --git a/app/Views/_assets/icons/arrow-left.svg b/app/Views/_assets/icons/arrow-left.svg
index 6d82f7ba..d10d02b5 100644
--- a/app/Views/_assets/icons/arrow-left.svg
+++ b/app/Views/_assets/icons/arrow-left.svg
@@ -1,6 +1,6 @@
diff --git a/app/Views/_assets/icons/caret-right.svg b/app/Views/_assets/icons/caret-right.svg
new file mode 100644
index 00000000..346cb156
--- /dev/null
+++ b/app/Views/_assets/icons/caret-right.svg
@@ -0,0 +1,6 @@
+
diff --git a/app/Views/_assets/icons/chevron-left.svg b/app/Views/_assets/icons/chevron-left.svg
new file mode 100644
index 00000000..6d82f7ba
--- /dev/null
+++ b/app/Views/_assets/icons/chevron-left.svg
@@ -0,0 +1,6 @@
+
diff --git a/app/Views/_assets/icons/arrow-right.svg b/app/Views/_assets/icons/chevron-right.svg
similarity index 100%
rename from app/Views/_assets/icons/arrow-right.svg
rename to app/Views/_assets/icons/chevron-right.svg
diff --git a/app/Views/_assets/icons/download.svg b/app/Views/_assets/icons/download.svg
new file mode 100644
index 00000000..42702f57
--- /dev/null
+++ b/app/Views/_assets/icons/download.svg
@@ -0,0 +1,6 @@
+
diff --git a/app/Views/_assets/icons/line-chart.svg b/app/Views/_assets/icons/line-chart.svg
new file mode 100644
index 00000000..c3080e57
--- /dev/null
+++ b/app/Views/_assets/icons/line-chart.svg
@@ -0,0 +1,6 @@
+
diff --git a/app/Views/_assets/icons/menu.svg b/app/Views/_assets/icons/menu.svg
new file mode 100644
index 00000000..666764dc
--- /dev/null
+++ b/app/Views/_assets/icons/menu.svg
@@ -0,0 +1,6 @@
+
diff --git a/app/Views/_assets/icons/question.svg b/app/Views/_assets/icons/question.svg
new file mode 100644
index 00000000..984376ae
--- /dev/null
+++ b/app/Views/_assets/icons/question.svg
@@ -0,0 +1,6 @@
+
diff --git a/app/Views/_assets/icons/settings.svg b/app/Views/_assets/icons/settings.svg
new file mode 100644
index 00000000..8ab66f65
--- /dev/null
+++ b/app/Views/_assets/icons/settings.svg
@@ -0,0 +1,6 @@
+
diff --git a/app/Views/_assets/icons/user-add.svg b/app/Views/_assets/icons/user-add.svg
new file mode 100644
index 00000000..ab808608
--- /dev/null
+++ b/app/Views/_assets/icons/user-add.svg
@@ -0,0 +1,6 @@
+
diff --git a/app/Views/_assets/icons/user.svg b/app/Views/_assets/icons/user.svg
new file mode 100644
index 00000000..9e64bb56
--- /dev/null
+++ b/app/Views/_assets/icons/user.svg
@@ -0,0 +1,6 @@
+
diff --git a/app/Views/_assets/modules/EnclosureInput.ts b/app/Views/_assets/modules/EnclosureInput.ts
new file mode 100644
index 00000000..ef6b95e1
--- /dev/null
+++ b/app/Views/_assets/modules/EnclosureInput.ts
@@ -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;
diff --git a/app/Views/_assets/modules/HTMLEditor.ts b/app/Views/_assets/modules/HTMLEditor.ts
deleted file mode 100644
index 7e67b65f..00000000
--- a/app/Views/_assets/modules/HTMLEditor.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import CodeMirror from "codemirror";
-import "codemirror/lib/codemirror.css";
-
-const HTMLEditor = (): void => {
- const allHTMLEditors: NodeListOf = 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;
diff --git a/app/Views/_assets/modules/MarkdownEditor.ts b/app/Views/_assets/modules/MarkdownEditor.ts
index cb38bca9..604e948e 100644
--- a/app/Views/_assets/modules/MarkdownEditor.ts
+++ b/app/Views/_assets/modules/MarkdownEditor.ts
@@ -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";
diff --git a/app/Views/_assets/modules/MultiSelect.ts b/app/Views/_assets/modules/MultiSelect.ts
new file mode 100644
index 00000000..87ef62be
--- /dev/null
+++ b/app/Views/_assets/modules/MultiSelect.ts
@@ -0,0 +1,40 @@
+import Choices from "choices.js";
+
+const MultiSelect = (): void => {
+ // Pass single element
+ const multiSelects: NodeListOf = 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;
diff --git a/app/Views/_assets/modules/SidebarToggler.ts b/app/Views/_assets/modules/SidebarToggler.ts
new file mode 100644
index 00000000..f9176f4f
--- /dev/null
+++ b/app/Views/_assets/modules/SidebarToggler.ts
@@ -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;
diff --git a/app/Views/_assets/modules/Tooltip.ts b/app/Views/_assets/modules/Tooltip.ts
index 7f91ec0b..b8d2ab2d 100644
--- a/app/Views/_assets/modules/Tooltip.ts
+++ b/app/Views/_assets/modules/Tooltip.ts
@@ -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) {
diff --git a/app/Views/_assets/styles/breadcrumb.css b/app/Views/_assets/styles/breadcrumb.css
index f2cb9162..0a89fe55 100644
--- a/app/Views/_assets/styles/breadcrumb.css
+++ b/app/Views/_assets/styles/breadcrumb.css
@@ -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 {
diff --git a/app/Views/_assets/styles/enclosureInput.css b/app/Views/_assets/styles/enclosureInput.css
new file mode 100644
index 00000000..44ea5329
--- /dev/null
+++ b/app/Views/_assets/styles/enclosureInput.css
@@ -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;
+}
diff --git a/app/Views/_assets/styles/index.css b/app/Views/_assets/styles/index.css
index f12f46bb..d68082f6 100644
--- a/app/Views/_assets/styles/index.css
+++ b/app/Views/_assets/styles/index.css
@@ -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";
diff --git a/app/Views/_assets/styles/layout.css b/app/Views/_assets/styles/layout.css
index bed5c1eb..b613b747 100644
--- a/app/Views/_assets/styles/layout.css
+++ b/app/Views/_assets/styles/layout.css
@@ -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;
+ }
}
}
diff --git a/app/Views/_assets/styles/multiSelect.css b/app/Views/_assets/styles/multiSelect.css
new file mode 100644
index 00000000..83fe9c76
--- /dev/null
+++ b/app/Views/_assets/styles/multiSelect.css
@@ -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 ======*/
diff --git a/app/Views/_assets/styles/radioBtn.css b/app/Views/_assets/styles/radioBtn.css
new file mode 100644
index 00000000..7e6045d6
--- /dev/null
+++ b/app/Views/_assets/styles/radioBtn.css
@@ -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: "✓";
+ }
+}
diff --git a/app/Views/_assets/styles/switch.css b/app/Views/_assets/styles/switch.css
new file mode 100644
index 00000000..be0cbe0f
--- /dev/null
+++ b/app/Views/_assets/styles/switch.css
@@ -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;
+ }
+}
diff --git a/app/Views/_layout.php b/app/Views/_layout.php
index 5bd01391..c2ce4918 100644
--- a/app/Views/_layout.php
+++ b/app/Views/_layout.php
@@ -1,6 +1,6 @@
= helper('page') ?>
-
+
@@ -9,9 +9,6 @@
-
- = $podcast->custom_html_head ?>
-
@@ -25,6 +22,9 @@
diff --git a/app/Views/admin/_header.php b/app/Views/admin/_header.php
deleted file mode 100644
index 274f52dc..00000000
--- a/app/Views/admin/_header.php
+++ /dev/null
@@ -1,28 +0,0 @@
-
-
- = render_breadcrumb() ?>
-
-
\ No newline at end of file
diff --git a/app/Views/admin/_layout.php b/app/Views/admin/_layout.php
index f87c44f1..8cd3c3f1 100644
--- a/app/Views/admin/_layout.php
+++ b/app/Views/admin/_layout.php
@@ -1,31 +1,54 @@
-
+
- Castopod Admin
+ = $this->renderSection('title') ?> | Castopod Admin
+
-
- = 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',
- ]) ?>
-
- = $this->renderSection('title') ?>
- = view('_message_block') ?>
- = $this->renderSection('content') ?>
+
+
+
+
+
+
+
+ = render_breadcrumb('text-gray-300') ?>
+
= $this->renderSection(
+ 'pageTitle'
+ ) ?>
+
+
= $this->renderSection(
+ 'headerRight'
+ ) ?>
+
+
+
+ = view('_message_block') ?>
+ = $this->renderSection('content') ?>
+
-