diff --git a/.gitignore b/.gitignore index 28030f56d76742767dd7770054970fe5918ee080..1baa8fe3b49280b199e1fe04075ecf16cc598672 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,9 @@ $RECYCLE.BIN/ # Linux *~ +# vim +*.swp + # KDE directory preferences .directory @@ -135,6 +138,7 @@ node_modules # public folder public/* !public/media +!public/media/~person !public/.htaccess !public/favicon.ico !public/index.php @@ -144,6 +148,14 @@ public/* public/media/* !public/media/index.html +# public person folder +public/media/~person/* +!public/media/~person/index.html + +# Generated files +app/Language/en/PersonsTaxonomy.php +app/Language/fr/PersonsTaxonomy.php + #------------------------- # Docker volumes #------------------------- diff --git a/app/Config/Routes.php b/app/Config/Routes.php index cfbc02c9b88be296bc3cabd726a0300e7ced386a..91de34a65356f0bcf316a310bca257028622a55d 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -85,6 +85,37 @@ $routes->group( 'as' => 'my-podcasts', ]); + $routes->group('persons', function ($routes) { + $routes->get('/', 'Person', [ + 'as' => 'person-list', + 'filter' => 'permission:person-list', + ]); + $routes->get('new', 'Person::create', [ + 'as' => 'person-create', + 'filter' => 'permission:person-create', + ]); + $routes->post('new', 'Person::attemptCreate', [ + 'filter' => 'permission:person-create', + ]); + $routes->group('(:num)', function ($routes) { + $routes->get('/', 'Person::view/$1', [ + 'as' => 'person-view', + 'filter' => 'permission:person-view', + ]); + $routes->get('edit', 'Person::edit/$1', [ + 'as' => 'person-edit', + 'filter' => 'permission:person-edit', + ]); + $routes->post('edit', 'Person::attemptEdit/$1', [ + 'filter' => 'permission:person-edit', + ]); + $routes->add('delete', 'Person::delete/$1', [ + 'as' => 'person-delete', + 'filter' => 'permission:person-delete', + ]); + }); + }); + // Podcasts $routes->group('podcasts', function ($routes) { $routes->get('/', 'Podcast::list', [ @@ -124,6 +155,25 @@ $routes->group( 'filter' => 'permission:podcasts-delete', ]); + $routes->group('persons', function ($routes) { + $routes->get('/', 'PodcastPerson/$1', [ + 'as' => 'podcast-person-manage', + 'filter' => 'permission:podcast-edit', + ]); + $routes->post('/', 'PodcastPerson::attemptAdd/$1', [ + 'filter' => 'permission:podcast-edit', + ]); + + $routes->get( + '(:num)/remove', + 'PodcastPerson::remove/$1/$2', + [ + 'as' => 'podcast-person-remove', + 'filter' => 'permission:podcast-edit', + ] + ); + }); + $routes->group('analytics', function ($routes) { $routes->get('/', 'Podcast::viewAnalytics/$1', [ 'as' => 'podcast-analytics', @@ -276,6 +326,30 @@ $routes->group( 'filter' => 'permission:podcast_episodes-edit', ] ); + + $routes->group('persons', function ($routes) { + $routes->get('/', 'EpisodePerson/$1/$2', [ + 'as' => 'episode-person-manage', + 'filter' => 'permission:podcast_episodes-edit', + ]); + $routes->post( + '/', + 'EpisodePerson::attemptAdd/$1/$2', + [ + 'filter' => + 'permission:podcast_episodes-edit', + ] + ); + $routes->get( + '(:num)/remove', + 'EpisodePerson::remove/$1/$2/$3', + [ + 'as' => 'episode-person-remove', + 'filter' => + 'permission:podcast_episodes-edit', + ] + ); + }); }); }); @@ -497,6 +571,7 @@ $routes->group('@(:podcastName)', function ($routes) { $routes->head('feed.xml', 'Feed/$1', ['as' => 'podcast_feed']); $routes->get('feed.xml', 'Feed/$1', ['as' => 'podcast_feed']); }); +$routes->get('/credits', 'Page::credits', ['as' => 'credits']); $routes->get('/(:slug)', 'Page/$1', ['as' => 'page']); /** diff --git a/app/Controllers/Admin/EpisodePerson.php b/app/Controllers/Admin/EpisodePerson.php new file mode 100644 index 0000000000000000000000000000000000000000..9d35dd9c2b9d99fa6c66d89fa08a0c662be15588 --- /dev/null +++ b/app/Controllers/Admin/EpisodePerson.php @@ -0,0 +1,111 @@ +<?php + +/** + * @copyright 2020 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Controllers\Admin; + +use App\Models\EpisodePersonModel; +use App\Models\PodcastModel; +use App\Models\EpisodeModel; +use App\Models\PersonModel; + +class EpisodePerson extends BaseController +{ + /** + * @var \App\Entities\Podcast + */ + protected $podcast; + + /** + * @var \App\Entities\Episode + */ + protected $episode; + + public function _remap($method, ...$params) + { + if (count($params) > 1) { + if ( + !($this->podcast = (new PodcastModel())->getPodcastById( + $params[0] + )) + ) { + throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound(); + } + if ( + !($this->episode = (new EpisodeModel()) + ->where([ + 'id' => $params[1], + 'podcast_id' => $params[0], + ]) + ->first()) + ) { + throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound(); + } + } else { + throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound(); + } + unset($params[1]); + unset($params[0]); + + return $this->$method(...$params); + } + + public function index() + { + helper('form'); + + $data = [ + 'episode' => $this->episode, + 'podcast' => $this->podcast, + 'episodePersons' => (new EpisodePersonModel())->getPersonsByEpisodeId( + $this->podcast->id, + $this->episode->id + ), + 'personOptions' => (new PersonModel())->getPersonOptions(), + 'taxonomyOptions' => (new PersonModel())->getTaxonomyOptions(), + ]; + replace_breadcrumb_params([ + 0 => $this->podcast->title, + 1 => $this->episode->title, + ]); + return view('admin/episode/person', $data); + } + + public function attemptAdd() + { + $rules = [ + 'person' => 'required', + ]; + + if (!$this->validate($rules)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $this->validator->getErrors()); + } + + (new EpisodePersonModel())->addEpisodePersons( + $this->podcast->id, + $this->episode->id, + $this->request->getPost('person'), + $this->request->getPost('person_group_role') + ); + + return redirect()->back(); + } + + public function remove($episodePersonId) + { + (new EpisodePersonModel())->removeEpisodePersons( + $this->podcast->id, + $this->episode->id, + $episodePersonId + ); + + return redirect()->back(); + } +} diff --git a/app/Controllers/Admin/Person.php b/app/Controllers/Admin/Person.php new file mode 100644 index 0000000000000000000000000000000000000000..f78631ff5e11bd84601aca0d8ee8747ff744e9e3 --- /dev/null +++ b/app/Controllers/Admin/Person.php @@ -0,0 +1,147 @@ +<?php + +/** + * @copyright 2021 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Controllers\Admin; + +use App\Models\PersonModel; + +class Person extends BaseController +{ + /** + * @var \App\Entities\Person|null + */ + protected $person; + + public function _remap($method, ...$params) + { + if (count($params) > 0) { + if ( + !($this->person = (new PersonModel())->getPersonById( + $params[0] + )) + ) { + throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound(); + } + } + + return $this->$method(); + } + + public function index() + { + $data = ['persons' => (new PersonModel())->findAll()]; + + return view('admin/person/list', $data); + } + + public function view() + { + $data = ['person' => $this->person]; + + replace_breadcrumb_params([0 => $this->person->full_name]); + return view('admin/person/view', $data); + } + + public function create() + { + helper(['form']); + + return view('admin/person/create'); + } + + public function attemptCreate() + { + $rules = [ + 'image' => + 'is_image[image]|ext_in[image,jpg,jpeg,png]|min_dims[image,400,400]|is_image_squared[image]', + ]; + + if (!$this->validate($rules)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $this->validator->getErrors()); + } + + $person = new \App\Entities\Person([ + 'full_name' => $this->request->getPost('full_name'), + 'unique_name' => $this->request->getPost('unique_name'), + 'information_url' => $this->request->getPost('information_url'), + 'image' => $this->request->getFile('image'), + 'created_by' => user()->id, + 'updated_by' => user()->id, + ]); + + $personModel = new PersonModel(); + + if (!$personModel->insert($person)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $personModel->errors()); + } + + return redirect()->route('person-list'); + } + + public function edit() + { + helper('form'); + + $data = [ + 'person' => $this->person, + ]; + + replace_breadcrumb_params([0 => $this->person->full_name]); + return view('admin/person/edit', $data); + } + + public function attemptEdit() + { + $rules = [ + 'image' => + 'is_image[image]|ext_in[image,jpg,jpeg,png]|min_dims[image,400,400]|is_image_squared[image]', + ]; + + if (!$this->validate($rules)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $this->validator->getErrors()); + } + + $this->person->full_name = $this->request->getPost('full_name'); + $this->person->unique_name = $this->request->getPost('unique_name'); + $this->person->information_url = $this->request->getPost( + 'information_url' + ); + $image = $this->request->getFile('image'); + if ($image->isValid()) { + $this->person->image = $image; + } + + $this->updated_by = user(); + + $personModel = new PersonModel(); + if (!$personModel->update($this->person->id, $this->person)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $personModel->errors()); + } + + return redirect()->route('person-view', [$this->person->id]); + } + + public function delete() + { + (new PersonModel())->delete($this->person->id); + + return redirect()->route('person-list'); + } +} diff --git a/app/Controllers/Admin/PodcastImport.php b/app/Controllers/Admin/PodcastImport.php index 0ae92f15caebbcb02bc0751b04da5a6809b5e74d..05625077a8471a5725f1a391ae9a4fe459df8d40 100644 --- a/app/Controllers/Admin/PodcastImport.php +++ b/app/Controllers/Admin/PodcastImport.php @@ -13,6 +13,9 @@ use App\Models\LanguageModel; use App\Models\PodcastModel; use App\Models\EpisodeModel; use App\Models\PlatformModel; +use App\Models\PersonModel; +use App\Models\PodcastPersonModel; +use App\Models\EpisodePersonModel; use Config\Services; use League\HTMLToMarkdown\HtmlConverter; @@ -150,7 +153,7 @@ class PodcastImport extends BaseController : $nsItunes->complete === 'yes', 'location_name' => !$nsPodcast->location ? null - : $nsPodcast->location->attributes()['name'], + : $nsPodcast->location, 'location_geo' => !$nsPodcast->location || empty($nsPodcast->location->attributes()['geo']) @@ -158,9 +161,9 @@ class PodcastImport extends BaseController : $nsPodcast->location->attributes()['geo'], 'location_osmid' => !$nsPodcast->location || - empty($nsPodcast->location->attributes()['osmid']) + empty($nsPodcast->location->attributes()['osm']) ? null - : $nsPodcast->location->attributes()['osmid'], + : $nsPodcast->location->attributes()['osm'], 'created_by' => user(), 'updated_by' => user(), ]); @@ -200,40 +203,40 @@ class PodcastImport extends BaseController $podcastAdminGroup->id ); - $platformModel = new PlatformModel(); $podcastsPlatformsData = []; - foreach ($nsPodcast->id as $podcastingPlatform) { - $slug = $podcastingPlatform->attributes()['platform']; - $platformModel->getOrCreatePlatform($slug, 'podcasting'); - array_push($podcastsPlatformsData, [ - 'platform_slug' => $slug, - 'podcast_id' => $newPodcastId, - 'link_url' => $podcastingPlatform->attributes()['url'], - 'link_content' => $podcastingPlatform->attributes()['id'], - 'is_visible' => false, - ]); - } - foreach ($nsPodcast->social as $socialPlatform) { - $slug = $socialPlatform->attributes()['platform']; - $platformModel->getOrCreatePlatform($slug, 'social'); - array_push($podcastsPlatformsData, [ - 'platform_slug' => $socialPlatform->attributes()['platform'], - 'podcast_id' => $newPodcastId, - 'link_url' => $socialPlatform->attributes()['url'], - 'link_content' => $socialPlatform, - 'is_visible' => false, - ]); - } - foreach ($nsPodcast->funding as $fundingPlatform) { - $slug = $fundingPlatform->attributes()['platform']; - $platformModel->getOrCreatePlatform($slug, 'funding'); - array_push($podcastsPlatformsData, [ - 'platform_slug' => $fundingPlatform->attributes()['platform'], - 'podcast_id' => $newPodcastId, - 'link_url' => $fundingPlatform->attributes()['url'], - 'link_content' => $fundingPlatform->attributes()['id'], - 'is_visible' => false, - ]); + $platformTypes = [ + ['name' => 'podcasting', 'elements' => $nsPodcast->id], + ['name' => 'social', 'elements' => $nsPodcast->social], + ['name' => 'funding', 'elements' => $nsPodcast->funding], + ]; + $platformModel = new PlatformModel(); + foreach ($platformTypes as $platformType) { + foreach ($platformType['elements'] as $platform) { + $platformLabel = $platform->attributes()['platform']; + $platformSlug = slugify($platformLabel); + if (!$platformModel->getPlatform($platformSlug)) { + if ( + !$platformModel->createPlatform( + $platformSlug, + $platformType['name'], + $platformLabel, + '' + ) + ) { + return redirect() + ->back() + ->withInput() + ->with('errors', $platformModel->errors()); + } + } + array_push($podcastsPlatformsData, [ + 'platform_slug' => $platformSlug, + 'podcast_id' => $newPodcastId, + 'link_url' => $platform->attributes()['url'], + 'link_content' => $platform->attributes()['id'], + 'is_visible' => false, + ]); + } } if (count($podcastsPlatformsData) > 1) { $platformModel->createPodcastPlatforms( @@ -242,6 +245,54 @@ class PodcastImport extends BaseController ); } + foreach ($nsPodcast->person as $podcastPerson) { + $personModel = new PersonModel(); + $newPersonId = null; + if ($newPerson = $personModel->getPerson($podcastPerson)) { + $newPersonId = $newPerson->id; + } else { + if ( + !($newPersonId = $personModel->createPerson( + $podcastPerson, + $podcastPerson->attributes()['href'], + $podcastPerson->attributes()['img'] + )) + ) { + return redirect() + ->back() + ->withInput() + ->with('errors', $personModel->errors()); + } + } + + $personGroup = empty($podcastPerson->attributes()['group']) + ? ['slug' => ''] + : \Podlibre\PodcastNamespace\ReversedTaxonomy::$taxonomy[ + (string) $podcastPerson->attributes()['group'] + ]; + $personRole = + empty($podcastPerson->attributes()['role']) || + empty($personGroup) + ? ['slug' => ''] + : $personGroup['roles'][ + strval($podcastPerson->attributes()['role']) + ]; + $newPodcastPerson = new \App\Entities\PodcastPerson([ + 'podcast_id' => $newPodcastId, + 'person_id' => $newPersonId, + 'person_group' => $personGroup['slug'], + 'person_role' => $personRole['slug'], + ]); + $podcastPersonModel = new PodcastPersonModel(); + + if (!$podcastPersonModel->insert($newPodcastPerson)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $podcastPersonModel->errors()); + } + } + $numberItems = $feed->channel[0]->item->count(); $lastItem = !empty($this->request->getPost('max_episodes')) && @@ -251,6 +302,7 @@ class PodcastImport extends BaseController $slugs = []; + ////////////////////////////////////////////////////////////////// // For each Episode: for ($itemNumber = 1; $itemNumber <= $lastItem; $itemNumber++) { $item = $feed->channel[0]->item[$numberItems - $itemNumber]; @@ -326,7 +378,7 @@ class PodcastImport extends BaseController : $nsItunes->block === 'yes', 'location_name' => !$nsPodcast->location ? null - : $nsPodcast->location->attributes()['name'], + : $nsPodcast->location, 'location_geo' => !$nsPodcast->location || empty($nsPodcast->location->attributes()['geo']) @@ -334,9 +386,9 @@ class PodcastImport extends BaseController : $nsPodcast->location->attributes()['geo'], 'location_osmid' => !$nsPodcast->location || - empty($nsPodcast->location->attributes()['osmid']) + empty($nsPodcast->location->attributes()['osm']) ? null - : $nsPodcast->location->attributes()['osmid'], + : $nsPodcast->location->attributes()['osm'], 'created_by' => user(), 'updated_by' => user(), 'published_at' => strtotime($item->pubDate), @@ -344,13 +396,62 @@ class PodcastImport extends BaseController $episodeModel = new EpisodeModel(); - if (!$episodeModel->insert($newEpisode)) { + if (!($newEpisodeId = $episodeModel->insert($newEpisode, true))) { // FIXME: What shall we do? return redirect() ->back() ->withInput() ->with('errors', $episodeModel->errors()); } + + foreach ($nsPodcast->person as $episodePerson) { + $personModel = new PersonModel(); + $newPersonId = null; + if ($newPerson = $personModel->getPerson($episodePerson)) { + $newPersonId = $newPerson->id; + } else { + if ( + !($newPersonId = $personModel->createPerson( + $episodePerson, + $episodePerson->attributes()['href'], + $episodePerson->attributes()['img'] + )) + ) { + return redirect() + ->back() + ->withInput() + ->with('errors', $personModel->errors()); + } + } + + $personGroup = empty($episodePerson->attributes()['group']) + ? ['slug' => ''] + : \Podlibre\PodcastNamespace\ReversedTaxonomy::$taxonomy[ + strval($episodePerson->attributes()['group']) + ]; + $personRole = + empty($episodePerson->attributes()['role']) || + empty($personGroup) + ? ['slug' => ''] + : $personGroup['roles'][ + strval($episodePerson->attributes()['role']) + ]; + $newEpisodePerson = new \App\Entities\PodcastPerson([ + 'podcast_id' => $newPodcastId, + 'episode_id' => $newEpisodeId, + 'person_id' => $newPersonId, + 'person_group' => $personGroup['slug'], + 'person_role' => $personRole['slug'], + ]); + $episodePersonModel = new EpisodePersonModel(); + + if (!$episodePersonModel->insert($newEpisodePerson)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $episodePersonModel->errors()); + } + } } $db->transComplete(); diff --git a/app/Controllers/Admin/PodcastPerson.php b/app/Controllers/Admin/PodcastPerson.php new file mode 100644 index 0000000000000000000000000000000000000000..676037007db6f5c4f4c3883818c73f4b030fb822 --- /dev/null +++ b/app/Controllers/Admin/PodcastPerson.php @@ -0,0 +1,89 @@ +<?php + +/** + * @copyright 2020 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Controllers\Admin; + +use App\Models\PodcastPersonModel; +use App\Models\PodcastModel; +use App\Models\PersonModel; + +class PodcastPerson extends BaseController +{ + /** + * @var \App\Entities\Podcast + */ + protected $podcast; + + public function _remap($method, ...$params) + { + if (count($params) > 0) { + if ( + !($this->podcast = (new PodcastModel())->getPodcastById( + $params[0] + )) + ) { + throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound(); + } + } else { + throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound(); + } + unset($params[0]); + + return $this->$method(...$params); + } + + public function index() + { + helper('form'); + + $data = [ + 'podcast' => $this->podcast, + 'podcastPersons' => (new PodcastPersonModel())->getPersonsByPodcastId( + $this->podcast->id + ), + 'personOptions' => (new PersonModel())->getPersonOptions(), + 'taxonomyOptions' => (new PersonModel())->getTaxonomyOptions(), + ]; + replace_breadcrumb_params([ + 0 => $this->podcast->title, + ]); + return view('admin/podcast/person', $data); + } + + public function attemptAdd() + { + $rules = [ + 'person' => 'required', + ]; + + if (!$this->validate($rules)) { + return redirect() + ->back() + ->withInput() + ->with('errors', $this->validator->getErrors()); + } + + (new PodcastPersonModel())->addPodcastPersons( + $this->podcast->id, + $this->request->getPost('person'), + $this->request->getPost('person_group_role') + ); + + return redirect()->back(); + } + + public function remove($podcastPersonId) + { + (new PodcastPersonModel())->removePodcastPersons( + $this->podcast->id, + $podcastPersonId + ); + + return redirect()->back(); + } +} diff --git a/app/Controllers/Episode.php b/app/Controllers/Episode.php index 7b5dc9f7ab10cca53c4ad91728984fdc3b10cbaf..3df89dda870acb533787256340b2b827cf255778 100644 --- a/app/Controllers/Episode.php +++ b/app/Controllers/Episode.php @@ -54,11 +54,55 @@ class Episode extends BaseController $this->podcast->type ); + $persons = []; + foreach ($this->episode->episode_persons as $episodePerson) { + if (array_key_exists($episodePerson->person->id, $persons)) { + $persons[$episodePerson->person->id]['roles'] .= + empty($episodePerson->person_group) || + empty($episodePerson->person_role) + ? '' + : (empty( + $persons[$episodePerson->person->id][ + 'roles' + ] + ) + ? '' + : ', ') . + lang( + 'PersonsTaxonomy.persons.' . + $episodePerson->person_group . + '.roles.' . + $episodePerson->person_role . + '.label' + ); + } else { + $persons[$episodePerson->person->id] = [ + 'full_name' => $episodePerson->person->full_name, + 'information_url' => + $episodePerson->person->information_url, + 'thumbnail_url' => + $episodePerson->person->image->thumbnail_url, + 'roles' => + empty($episodePerson->person_group) || + empty($episodePerson->person_role) + ? '' + : lang( + 'PersonsTaxonomy.persons.' . + $episodePerson->person_group . + '.roles.' . + $episodePerson->person_role . + '.label' + ), + ]; + } + } + $data = [ 'previousEpisode' => $previousNextEpisodes['previous'], 'nextEpisode' => $previousNextEpisodes['next'], 'podcast' => $this->podcast, 'episode' => $this->episode, + 'persons' => $persons, ]; $secondsToNextUnpublishedEpisode = $episodeModel->getSecondsToNextUnpublishedEpisode( diff --git a/app/Controllers/Page.php b/app/Controllers/Page.php index b30b5fd6e479a7210fe326dd6b4c82c5edf8a171..74735dee6ad3272bdbd809dcabb58e3c8ec423eb 100644 --- a/app/Controllers/Page.php +++ b/app/Controllers/Page.php @@ -9,6 +9,8 @@ namespace App\Controllers; use App\Models\PageModel; +use App\Models\CreditModel; +use App\Models\PodcastModel; class Page extends BaseController { @@ -42,4 +44,137 @@ class Page extends BaseController ]; return view('page', $data); } + + public function credits() + { + $locale = service('request')->getLocale(); + $model = new PodcastModel(); + $allPodcasts = $model->findAll(); + + if (!($found = cache("credits_{$locale}"))) { + $page = new \App\Entities\Page([ + 'title' => lang('Person.credits', [], $locale), + 'slug' => 'credits', + 'content' => '', + ]); + + $creditModel = (new CreditModel())->findAll(); + + // Unlike the carpenter, we make a tree from a table: + + $person_group = null; + $person_id = null; + $person_role = null; + $credits = []; + foreach ($creditModel as $credit) { + if ($person_group !== $credit->person_group) { + $person_group = $credit->person_group; + $person_id = $credit->person_id; + $person_role = $credit->person_role; + $credits[$person_group] = [ + 'group_label' => $credit->group_label, + 'persons' => [ + $person_id => [ + 'full_name' => $credit->person->full_name, + 'thumbnail_url' => + $credit->person->image->thumbnail_url, + 'information_url' => + $credit->person->information_url, + 'roles' => [ + $person_role => [ + 'role_label' => $credit->role_label, + 'is_in' => [ + [ + 'link' => $credit->episode + ? $credit->episode->link + : $credit->podcast->link, + 'title' => $credit->episode + ? (count($allPodcasts) > 1 + ? "{$credit->podcast->title} ▸ " + : '') . + "(S{$credit->episode->season_number}E{$credit->episode->number}) {$credit->episode->title}" + : $credit->podcast->title, + ], + ], + ], + ], + ], + ], + ]; + } elseif ($person_id !== $credit->person_id) { + $person_id = $credit->person_id; + $person_role = $credit->person_role; + $credits[$person_group]['persons'][$person_id] = [ + 'full_name' => $credit->person->full_name, + 'thumbnail_url' => + $credit->person->image->thumbnail_url, + 'information_url' => $credit->person->information_url, + 'roles' => [ + $person_role => [ + 'role_label' => $credit->role_label, + 'is_in' => [ + [ + 'link' => $credit->episode + ? $credit->episode->link + : $credit->podcast->link, + 'title' => $credit->episode + ? (count($allPodcasts) > 1 + ? "{$credit->podcast->title} ▸ " + : '') . + "(S{$credit->episode->season_number}E{$credit->episode->number}) {$credit->episode->title}" + : $credit->podcast->title, + ], + ], + ], + ], + ]; + } elseif ($person_role !== $credit->person_role) { + $person_role = $credit->person_role; + $credits[$person_group]['persons'][$person_id]['roles'][ + $person_role + ] = [ + 'role_label' => $credit->role_label, + 'is_in' => [ + [ + 'link' => $credit->episode + ? $credit->episode->link + : $credit->podcast->link, + 'title' => $credit->episode + ? (count($allPodcasts) > 1 + ? "{$credit->podcast->title} ▸ " + : '') . + "(S{$credit->episode->season_number}E{$credit->episode->number}) {$credit->episode->title}" + : $credit->podcast->title, + ], + ], + ]; + } else { + $credits[$person_group]['persons'][$person_id]['roles'][ + $person_role + ]['is_in'][] = [ + 'link' => $credit->episode + ? $credit->episode->link + : $credit->podcast->link, + 'title' => $credit->episode + ? (count($allPodcasts) > 1 + ? "{$credit->podcast->title} ▸ " + : '') . + "(S{$credit->episode->season_number}E{$credit->episode->number}) {$credit->episode->title}" + : $credit->podcast->title, + ]; + } + } + + $data = [ + 'page' => $page, + 'credits' => $credits, + ]; + + $found = view('credits', $data); + + cache()->save("credits_{$locale}", $found, DECADE); + } + + return $found; + } } diff --git a/app/Controllers/Podcast.php b/app/Controllers/Podcast.php index 1e1dbda3b31bdeb5da027eff837cef30a993f0bb..9c6fc6756e19feca366c76f0233790a877ed03e3 100644 --- a/app/Controllers/Podcast.php +++ b/app/Controllers/Podcast.php @@ -109,6 +109,49 @@ class Podcast extends BaseController ]); } + $persons = []; + foreach ($this->podcast->podcast_persons as $podcastPerson) { + if (array_key_exists($podcastPerson->person->id, $persons)) { + $persons[$podcastPerson->person->id]['roles'] .= + empty($podcastPerson->person_group) || + empty($podcastPerson->person_role) + ? '' + : (empty( + $persons[$podcastPerson->person->id][ + 'roles' + ] + ) + ? '' + : ', ') . + lang( + 'PersonsTaxonomy.persons.' . + $podcastPerson->person_group . + '.roles.' . + $podcastPerson->person_role . + '.label' + ); + } else { + $persons[$podcastPerson->person->id] = [ + 'full_name' => $podcastPerson->person->full_name, + 'information_url' => + $podcastPerson->person->information_url, + 'thumbnail_url' => + $podcastPerson->person->image->thumbnail_url, + 'roles' => + empty($podcastPerson->person_group) || + empty($podcastPerson->person_role) + ? '' + : lang( + 'PersonsTaxonomy.persons.' . + $podcastPerson->person_group . + '.roles.' . + $podcastPerson->person_role . + '.label' + ), + ]; + } + } + $data = [ 'podcast' => $this->podcast, 'episodesNav' => $episodesNavigation, @@ -119,6 +162,7 @@ class Podcast extends BaseController $yearQuery, $seasonQuery ), + 'personArray' => $persons, ]; $secondsToNextUnpublishedEpisode = $episodeModel->getSecondsToNextUnpublishedEpisode( diff --git a/app/Database/Migrations/2020-06-05-190000_add_platforms.php b/app/Database/Migrations/2020-06-05-190000_add_platforms.php index bbb231e9c0b5b2d89bf654bd0883330a75d66c9c..b79e7939da65ec5b81f367dd6c6aadb26937b4c5 100644 --- a/app/Database/Migrations/2020-06-05-190000_add_platforms.php +++ b/app/Database/Migrations/2020-06-05-190000_add_platforms.php @@ -41,11 +41,9 @@ class AddPlatforms extends Migration 'default' => null, ], ]); + $this->forge->addField('`created_at` timestamp NOT NULL DEFAULT NOW()'); $this->forge->addField( - '`created_at` timestamp NOT NULL DEFAULT current_timestamp()' - ); - $this->forge->addField( - '`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp()' + '`updated_at` timestamp NOT NULL DEFAULT NOW() ON UPDATE NOW()' ); $this->forge->addKey('slug', true); $this->forge->createTable('platforms'); diff --git a/app/Database/Migrations/2020-06-08-160000_add_podcasts_platforms.php b/app/Database/Migrations/2020-06-08-160000_add_podcasts_platforms.php index c353a5d7a166c01433de7d790aed8ffdf89216f7..045add8548aeb5844618560c801d5289f76a0b71 100644 --- a/app/Database/Migrations/2020-06-08-160000_add_podcasts_platforms.php +++ b/app/Database/Migrations/2020-06-08-160000_add_podcasts_platforms.php @@ -40,12 +40,6 @@ class AddPodcastsPlatforms extends Migration 'constraint' => 1, 'default' => 0, ], - 'created_at' => [ - 'type' => 'DATETIME', - ], - 'updated_at' => [ - 'type' => 'DATETIME', - ], ]); $this->forge->addPrimaryKey(['podcast_id', 'platform_slug']); diff --git a/app/Database/Migrations/2020-12-25-120000_add_persons.php b/app/Database/Migrations/2020-12-25-120000_add_persons.php new file mode 100644 index 0000000000000000000000000000000000000000..bacdafcc07373399262525f0c4541c72675c8a12 --- /dev/null +++ b/app/Database/Migrations/2020-12-25-120000_add_persons.php @@ -0,0 +1,74 @@ +<?php + +/** + * Class Persons + * Creates persons 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 AddPersons extends Migration +{ + public function up() + { + $this->forge->addField([ + 'id' => [ + 'type' => 'INT', + 'unsigned' => true, + 'auto_increment' => true, + ], + 'full_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 192, + 'comment' => 'This is the full name or alias of the person.', + ], + 'unique_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 192, + 'comment' => 'This is the slug name or alias of the person.', + 'unique' => true, + ], + 'information_url' => [ + 'type' => 'VARCHAR', + 'constraint' => 512, + 'comment' => + 'The url to a relevant resource of information about the person, such as a homepage or third-party profile platform.', + 'null' => true, + ], + 'image_uri' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + 'created_by' => [ + 'type' => 'INT', + 'unsigned' => true, + ], + 'updated_by' => [ + 'type' => 'INT', + 'unsigned' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + ], + 'updated_at' => [ + 'type' => 'DATETIME', + ], + ]); + + $this->forge->addKey('id', true); + $this->forge->addForeignKey('created_by', 'users', 'id'); + $this->forge->addForeignKey('updated_by', 'users', 'id'); + $this->forge->createTable('persons'); + } + + public function down() + { + $this->forge->dropTable('persons'); + } +} diff --git a/app/Database/Migrations/2020-12-25-130000_add_podcasts_persons.php b/app/Database/Migrations/2020-12-25-130000_add_podcasts_persons.php new file mode 100644 index 0000000000000000000000000000000000000000..1e7bc16b2d44ecd3d05792924d61390fa82e7672 --- /dev/null +++ b/app/Database/Migrations/2020-12-25-130000_add_podcasts_persons.php @@ -0,0 +1,59 @@ +<?php + +/** + * Class AddPodcastsPersons + * Creates podcasts_persons 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 AddPodcastsPersons extends Migration +{ + public function up() + { + $this->forge->addField([ + 'id' => [ + 'type' => 'INT', + 'unsigned' => true, + 'auto_increment' => true, + ], + 'podcast_id' => [ + 'type' => 'INT', + 'unsigned' => true, + ], + 'person_id' => [ + 'type' => 'INT', + 'unsigned' => true, + ], + 'person_group' => [ + 'type' => 'VARCHAR', + 'constraint' => 32, + ], + 'person_role' => [ + 'type' => 'VARCHAR', + 'constraint' => 32, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addUniqueKey([ + 'podcast_id', + 'person_id', + 'person_group', + 'person_role', + ]); + $this->forge->addForeignKey('podcast_id', 'podcasts', 'id'); + $this->forge->addForeignKey('person_id', 'persons', 'id'); + $this->forge->createTable('podcasts_persons'); + } + + public function down() + { + $this->forge->dropTable('podcasts_persons'); + } +} diff --git a/app/Database/Migrations/2020-12-25-140000_add_episodes_persons.php b/app/Database/Migrations/2020-12-25-140000_add_episodes_persons.php new file mode 100644 index 0000000000000000000000000000000000000000..4c1c6383f7a8cac61aee1bcfe890a1e3754baecc --- /dev/null +++ b/app/Database/Migrations/2020-12-25-140000_add_episodes_persons.php @@ -0,0 +1,65 @@ +<?php + +/** + * Class AddEpisodesPersons + * Creates episodes_persons 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 AddEpisodesPersons extends Migration +{ + public function up() + { + $this->forge->addField([ + 'id' => [ + 'type' => 'INT', + 'unsigned' => true, + 'auto_increment' => true, + ], + 'podcast_id' => [ + 'type' => 'INT', + 'unsigned' => true, + ], + 'episode_id' => [ + 'type' => 'INT', + 'unsigned' => true, + ], + 'person_id' => [ + 'type' => 'INT', + 'unsigned' => true, + ], + 'person_group' => [ + 'type' => 'VARCHAR', + 'constraint' => 32, + ], + 'person_role' => [ + 'type' => 'VARCHAR', + 'constraint' => 32, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addUniqueKey([ + 'podcast_id', + 'episode_id', + 'person_id', + 'person_group', + 'person_role', + ]); + $this->forge->addForeignKey('podcast_id', 'podcasts', 'id'); + $this->forge->addForeignKey('episode_id', 'episodes', 'id'); + $this->forge->addForeignKey('person_id', 'persons', 'id'); + $this->forge->createTable('episodes_persons'); + } + + public function down() + { + $this->forge->dropTable('episodes_persons'); + } +} diff --git a/app/Database/Migrations/2020-12-25-150000_add_credit_view.php b/app/Database/Migrations/2020-12-25-150000_add_credit_view.php new file mode 100644 index 0000000000000000000000000000000000000000..42731dfcba6e317025c9afe3a8e1cabe3f933e6b --- /dev/null +++ b/app/Database/Migrations/2020-12-25-150000_add_credit_view.php @@ -0,0 +1,43 @@ +<?php + +/** + * Class AddCreditView + * Creates Credit View 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 AddCreditView extends Migration +{ + public function up() + { + // Creates View for credit UNION query + $viewName = $this->db->prefixTable('credits'); + $personTable = $this->db->prefixTable('persons'); + $podcastPersonTable = $this->db->prefixTable('podcasts_persons'); + $episodePersonTable = $this->db->prefixTable('episodes_persons'); + $createQuery = <<<EOD +CREATE VIEW `$viewName` AS + SELECT `person_group`, `person_id`, `full_name`, `person_role`, `podcast_id`, NULL AS `episode_id` FROM `$podcastPersonTable` + INNER JOIN `$personTable` + ON (`person_id`=`$personTable`.`id`) + UNION + SELECT `person_group`, `person_id`, `full_name`, `person_role`, `podcast_id`, `episode_id` FROM `$episodePersonTable` + INNER JOIN `$personTable` + ON (`person_id`=`$personTable`.`id`) + ORDER BY `person_group`, `full_name`, `person_role`, `podcast_id`, `episode_id`; +EOD; + $this->db->query($createQuery); + } + + public function down() + { + $viewName = $this->db->prefixTable('credits'); + $this->db->query("DROP VIEW IF EXISTS `$viewName`"); + } +} diff --git a/app/Database/Seeds/AuthSeeder.php b/app/Database/Seeds/AuthSeeder.php index 8c509669f5968f1b1ee7e57c2c11cf544af714c3..eb567ad0911f6bf0c8b2cb03f60108b876c068f2 100644 --- a/app/Database/Seeds/AuthSeeder.php +++ b/app/Database/Seeds/AuthSeeder.php @@ -198,6 +198,33 @@ class AuthSeeder extends Seeder 'has_permission' => ['podcast_admin'], ], ], + 'person' => [ + [ + 'name' => 'create', + 'description' => 'Add a new person', + 'has_permission' => ['superadmin'], + ], + [ + 'name' => 'list', + 'description' => 'List all persons', + 'has_permission' => ['superadmin'], + ], + [ + 'name' => 'view', + 'description' => 'View any person', + 'has_permission' => ['superadmin'], + ], + [ + 'name' => 'edit', + 'description' => 'Edit a person', + 'has_permission' => ['superadmin'], + ], + [ + 'name' => 'delete_permanently', + 'description' => 'Delete any person from the database', + 'has_permission' => ['superadmin'], + ], + ], ]; static function getGroupIdByName($name, $dataGroups) diff --git a/app/Database/Seeds/PlatformSeeder.php b/app/Database/Seeds/PlatformSeeder.php index c6df88a0165acc93c6e1c33d1ae62faab2259486..4e0bdb631566edf22608ca474f78d184e03524d5 100644 --- a/app/Database/Seeds/PlatformSeeder.php +++ b/app/Database/Seeds/PlatformSeeder.php @@ -47,6 +47,13 @@ class PlatformSeeder extends Seeder 'home_url' => 'https://www.blubrry.com/', 'submit_url' => 'https://www.blubrry.com/addpodcast.php', ], + [ + 'slug' => 'breaker', + 'type' => 'podcasting', + 'label' => 'Breaker', + 'home_url' => 'https://www.breaker.audio/', + 'submit_url' => 'https://podcasters.breaker.audio/', + ], [ 'slug' => 'castbox', 'type' => 'podcasting', diff --git a/app/Entities/Credit.php b/app/Entities/Credit.php new file mode 100644 index 0000000000000000000000000000000000000000..0988e7ca15fc8d621d2a94aecf7632a24a7cb64b --- /dev/null +++ b/app/Entities/Credit.php @@ -0,0 +1,94 @@ +<?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\PersonModel; +use App\Models\PodcastModel; +use App\Models\EpisodeModel; + +use CodeIgniter\Entity; + +class Credit extends Entity +{ + /** + * @var \App\Entities\Person + */ + protected $person; + + /** + * @var \App\Entities\Podcast + */ + protected $podcast; + + /** + * @var \App\Entities\Episode + */ + protected $episode; + + /** + * @var string + */ + protected $group_label; + + /** + * @var string + */ + protected $role_label; + + public function getPodcast() + { + return (new PodcastModel())->getPodcastById( + $this->attributes['podcast_id'] + ); + } + + public function getEpisode() + { + if (empty($this->attributes['episode_id'])) { + return null; + } else { + return (new EpisodeModel())->getEpisodeById( + $this->attributes['podcast_id'], + $this->attributes['episode_id'] + ); + } + } + + public function getPerson() + { + return (new PersonModel())->getPersonById( + $this->attributes['person_id'] + ); + } + + public function getGroupLabel() + { + if (empty($this->attributes['person_group'])) { + return null; + } else { + return lang( + "PersonsTaxonomy.persons.{$this->attributes['person_group']}.label" + ); + } + } + + public function getRoleLabel() + { + if ( + empty($this->attributes['person_group']) || + empty($this->attributes['person_role']) + ) { + return null; + } else { + return lang( + "PersonsTaxonomy.persons.{$this->attributes['person_group']}.roles.{$this->attributes['person_role']}.label" + ); + } + } +} diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php index 4249defe8775a476dc863a5dc05a5de295daf709..964d51b2c005bb26303b87bc8f3b1fee72d5b6a6 100644 --- a/app/Entities/Episode.php +++ b/app/Entities/Episode.php @@ -10,6 +10,7 @@ namespace App\Entities; use App\Models\PodcastModel; use App\Models\SoundbiteModel; +use App\Models\EpisodePersonModel; use CodeIgniter\Entity; use CodeIgniter\I18n\Time; use League\CommonMark\CommonMarkConverter; @@ -76,6 +77,11 @@ class Episode extends Entity */ protected $chapters_url; + /** + * @var \App\Entities\EpisodePerson[] + */ + protected $episode_persons; + /** * @var \App\Entities\Soundbite[] */ @@ -358,6 +364,29 @@ class Episode extends Entity : null; } + /** + * Returns the episode's persons + * + * @return \App\Entities\EpisodePerson[] + */ + public function getEpisodePersons() + { + if (empty($this->id)) { + throw new \RuntimeException( + 'Episode must be created before getting persons.' + ); + } + + if (empty($this->episode_persons)) { + $this->episode_persons = (new EpisodePersonModel())->getPersonsByEpisodeId( + $this->podcast_id, + $this->id + ); + } + + return $this->episode_persons; + } + /** * Returns the episode’s soundbites * diff --git a/app/Entities/EpisodePerson.php b/app/Entities/EpisodePerson.php new file mode 100644 index 0000000000000000000000000000000000000000..6c0a6388bdc30e58e222366baf95b12fc9295571 --- /dev/null +++ b/app/Entities/EpisodePerson.php @@ -0,0 +1,36 @@ +<?php + +/** + * @copyright 2020 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Entities; + +use CodeIgniter\Entity; +use App\Models\PersonModel; + +class EpisodePerson extends Entity +{ + /** + * @var \App\Entities\Person + */ + protected $person; + + protected $casts = [ + 'id' => 'integer', + 'podcast_id' => 'integer', + 'episode_id' => 'integer', + 'person_id' => 'integer', + 'person_group' => '?string', + 'person_role' => '?string', + ]; + + public function getPerson() + { + return (new PersonModel())->getPersonById( + $this->attributes['person_id'] + ); + } +} diff --git a/app/Entities/Person.php b/app/Entities/Person.php new file mode 100644 index 0000000000000000000000000000000000000000..8f20885c37138c33bd82258ce533623f13dd9106 --- /dev/null +++ b/app/Entities/Person.php @@ -0,0 +1,59 @@ +<?php + +/** + * @copyright 2021 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Entities; + +use CodeIgniter\Entity; + +class Person extends Entity +{ + /** + * @var \App\Entities\Image + */ + protected $image; + + protected $casts = [ + 'id' => 'integer', + 'full_name' => 'string', + 'unique_name' => 'string', + 'information_url' => '?string', + 'image_uri' => 'string', + 'created_by' => 'integer', + 'updated_by' => 'integer', + ]; + + /** + * Saves a picture in `public/media/~person/` + * + * @param \CodeIgniter\HTTP\Files\UploadedFile|\CodeIgniter\Files\File $image + * + */ + public function setImage($image = null) + { + if ($image) { + helper('media'); + + $this->attributes['image_uri'] = save_podcast_media( + $image, + '~person', + $this->attributes['unique_name'] + ); + $this->image = new \App\Entities\Image( + $this->attributes['image_uri'] + ); + $this->image->saveSizes(); + } + + return $this; + } + + public function getImage() + { + return new \App\Entities\Image($this->attributes['image_uri']); + } +} diff --git a/app/Entities/Podcast.php b/app/Entities/Podcast.php index 8782a0a7d69db86590b87db0e3655898c21d6243..f35cd75954a0f626e8a37e7ab52f1fbd1b60e994 100644 --- a/app/Entities/Podcast.php +++ b/app/Entities/Podcast.php @@ -11,6 +11,7 @@ namespace App\Entities; use App\Models\CategoryModel; use App\Models\EpisodeModel; use App\Models\PlatformModel; +use App\Models\PodcastPersonModel; use CodeIgniter\Entity; use App\Models\UserModel; use League\CommonMark\CommonMarkConverter; @@ -32,6 +33,11 @@ class Podcast extends Entity */ protected $episodes; + /** + * @var \App\Entities\PodcastPerson[] + */ + protected $podcast_persons; + /** * @var \App\Entities\Category */ @@ -167,6 +173,28 @@ class Podcast extends Entity return $this->episodes; } + /** + * Returns the podcast's persons + * + * @return \App\Entities\PodcastPerson[] + */ + public function getPodcastPersons() + { + if (empty($this->id)) { + throw new \RuntimeException( + 'Podcast must be created before getting persons.' + ); + } + + if (empty($this->podcast_persons)) { + $this->podcast_persons = (new PodcastPersonModel())->getPersonsByPodcastId( + $this->id + ); + } + + return $this->podcast_persons; + } + /** * Returns the podcast category entity * diff --git a/app/Entities/PodcastPerson.php b/app/Entities/PodcastPerson.php new file mode 100644 index 0000000000000000000000000000000000000000..95dec77c5162b797eaa7f2cacfcec30d66c13ce5 --- /dev/null +++ b/app/Entities/PodcastPerson.php @@ -0,0 +1,35 @@ +<?php + +/** + * @copyright 2020 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Entities; + +use CodeIgniter\Entity; +use App\Models\PersonModel; + +class PodcastPerson extends Entity +{ + /** + * @var \App\Entities\Person + */ + protected $person; + + protected $casts = [ + 'id' => 'integer', + 'podcast_id' => 'integer', + 'person_id' => 'integer', + 'person_group' => '?string', + 'person_role' => '?string', + ]; + + public function getPerson() + { + return (new PersonModel())->getPersonById( + $this->attributes['person_id'] + ); + } +} diff --git a/app/Helpers/page_helper.php b/app/Helpers/page_helper.php index 74b64220d61fe9dd63050f1464ce302ba08b310d..5dda5b387a35893b1c660b5e7298e96ebea15799 100644 --- a/app/Helpers/page_helper.php +++ b/app/Helpers/page_helper.php @@ -20,6 +20,9 @@ function render_page_links($class = null) $links = anchor(route_to('home'), lang('Common.home'), [ 'class' => 'px-2 underline hover:no-underline', ]); + $links .= anchor(route_to('credits'), lang('Person.credits'), [ + 'class' => 'px-2 underline hover:no-underline', + ]); foreach ($pages as $page) { $links .= anchor($page->link, $page->title, [ 'class' => 'px-2 underline hover:no-underline', diff --git a/app/Helpers/rss_helper.php b/app/Helpers/rss_helper.php index bfc1798493ef5d1af700532f444247e9a4227848..69a08539087f31593bdba637bd498c9212062912 100644 --- a/app/Helpers/rss_helper.php +++ b/app/Helpers/rss_helper.php @@ -68,18 +68,14 @@ function get_rss_feed($podcast, $serviceSlug = '') if (!empty($podcast->location_name)) { $locationElement = $channel->addChild( 'location', - null, + htmlspecialchars($podcast->location_name), $podcast_namespace ); - $locationElement->addAttribute( - 'name', - htmlspecialchars($podcast->location_name) - ); if (!empty($podcast->location_geo)) { $locationElement->addAttribute('geo', $podcast->location_geo); } if (!empty($podcast->location_osmid)) { - $locationElement->addAttribute('osmid', $podcast->location_osmid); + $locationElement->addAttribute('osm', $podcast->location_osmid); } } if (!empty($podcast->payment_pointer)) { @@ -105,7 +101,7 @@ function get_rss_feed($podcast, $serviceSlug = '') ) ->addAttribute('owner', $podcast->owner_email); if (!empty($podcast->imported_feed_url)) { - $channel->addChildWithCDATA( + $channel->addChild( 'previousUrl', $podcast->imported_feed_url, $podcast_namespace @@ -169,6 +165,51 @@ function get_rss_feed($podcast, $serviceSlug = '') } } + foreach ($podcast->podcast_persons as $podcastPerson) { + $podcastPersonElement = $channel->addChild( + 'person', + htmlspecialchars($podcastPerson->person->full_name), + $podcast_namespace + ); + if ( + !empty($podcastPerson->person_role) && + !empty($podcastPerson->person_group) + ) { + $podcastPersonElement->addAttribute( + 'role', + htmlspecialchars( + lang( + "PersonsTaxonomy.persons.{$podcastPerson->person_group}.roles.{$podcastPerson->person_role}.label", + [], + 'en' + ) + ) + ); + } + if (!empty($podcastPerson->person_group)) { + $podcastPersonElement->addAttribute( + 'group', + htmlspecialchars( + lang( + "PersonsTaxonomy.persons.{$podcastPerson->person_group}.label", + [], + 'en' + ) + ) + ); + } + $podcastPersonElement->addAttribute( + 'img', + $podcastPerson->person->image->large_url + ); + if (!empty($podcastPerson->person->information_url)) { + $podcastPersonElement->addAttribute( + 'href', + $podcastPerson->person->information_url + ); + } + } + // set main category first, then other categories as apple add_category_tag($channel, $podcast->category); foreach ($podcast->other_categories as $other_category) { @@ -222,21 +263,14 @@ function get_rss_feed($podcast, $serviceSlug = '') if (!empty($episode->location_name)) { $locationElement = $item->addChild( 'location', - null, + htmlspecialchars($episode->location_name), $podcast_namespace ); - $locationElement->addAttribute( - 'name', - htmlspecialchars($episode->location_name) - ); if (!empty($episode->location_geo)) { $locationElement->addAttribute('geo', $episode->location_geo); } if (!empty($episode->location_osmid)) { - $locationElement->addAttribute( - 'osmid', - $episode->location_osmid - ); + $locationElement->addAttribute('osm', $episode->location_osmid); } } $item->addChildWithCDATA('description', $episode->description_html); @@ -312,6 +346,51 @@ function get_rss_feed($podcast, $serviceSlug = '') $soundbiteElement->addAttribute('duration', $soundbite->duration); } + foreach ($episode->episode_persons as $episodePerson) { + $episodePersonElement = $item->addChild( + 'person', + htmlspecialchars($episodePerson->person->full_name), + $podcast_namespace + ); + if ( + !empty($episodePerson->person_role) && + !empty($episodePerson->person_group) + ) { + $episodePersonElement->addAttribute( + 'role', + htmlspecialchars( + lang( + "PersonsTaxonomy.persons.{$episodePerson->person_group}.roles.{$episodePerson->person_role}.label", + [], + 'en' + ) + ) + ); + } + if (!empty($episodePerson->person_group)) { + $episodePersonElement->addAttribute( + 'group', + htmlspecialchars( + lang( + "PersonsTaxonomy.persons.{$episodePerson->person_group}.label", + [], + 'en' + ) + ) + ); + } + $episodePersonElement->addAttribute( + 'img', + $episodePerson->person->image->large_url + ); + if (!empty($episodePerson->person->information_url)) { + $episodePersonElement->addAttribute( + 'href', + $episodePerson->person->information_url + ); + } + } + $episode->is_blocked && $item->addChild('block', 'Yes', $itunes_namespace); } diff --git a/app/Language/en/AdminNavigation.php b/app/Language/en/AdminNavigation.php index d9c2ada029b5d50c3d98ea26814e3b339a873fba..aa36aabfd2514fa52f6f6ed2dad67dbc3e9a792f 100644 --- a/app/Language/en/AdminNavigation.php +++ b/app/Language/en/AdminNavigation.php @@ -14,6 +14,9 @@ return [ 'podcast-list' => 'All podcasts', 'podcast-create' => 'New podcast', 'podcast-import' => 'Import a podcast', + 'persons' => 'Persons', + 'person-list' => 'All persons', + 'person-create' => 'New person', 'users' => 'Users', 'user-list' => 'All users', 'user-create' => 'New user', diff --git a/app/Language/en/Breadcrumb.php b/app/Language/en/Breadcrumb.php index 27301ab73a22642f900756c65809c9290700520b..03db0bce7f4e22e1c9257f41788613046373f423 100644 --- a/app/Language/en/Breadcrumb.php +++ b/app/Language/en/Breadcrumb.php @@ -16,6 +16,7 @@ return [ 'add' => 'add', 'new' => 'new', 'edit' => 'edit', + 'persons' => 'persons', 'users' => 'users', 'my-account' => 'my account', 'change-password' => 'change password', diff --git a/app/Language/en/Person.php b/app/Language/en/Person.php new file mode 100644 index 0000000000000000000000000000000000000000..3d504b2ad47271a83d3080d9128122ea7bfdb8fe --- /dev/null +++ b/app/Language/en/Person.php @@ -0,0 +1,64 @@ +<?php + +/** + * @copyright 2020 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +return [ + 'persons' => 'Persons', + 'all_persons' => 'All persons', + 'no_person' => 'Nobody found!', + 'create' => 'Create a person', + 'view' => 'View person', + 'edit' => 'Edit person', + 'delete' => 'Delete person', + 'form' => [ + 'identity_section_title' => 'Identity', + 'identity_section_subtitle' => 'Who is working on the podcast', + 'full_name' => 'Full name', + 'full_name_hint' => 'This is the full name or alias of the person.', + 'unique_name' => 'Unique name', + 'unique_name_hint' => 'Used for URLs', + 'information_url' => 'Information URL', + 'information_url_hint' => + 'Url to a relevant resource of information about the person, such as a homepage or third-party profile platform.', + 'image' => 'Picture, avatar, image', + 'image_size_hint' => + 'Image must be squared with at least 400px wide and tall.', + 'submit_create' => 'Create person', + 'submit_edit' => 'Save person', + ], + 'podcast_form' => [ + 'title' => 'Manage persons', + 'manage_section_title' => 'Management', + 'manage_section_subtitle' => 'Remove persons from this podcast', + 'add_section_title' => 'Add persons to this podcast', + 'add_section_subtitle' => 'You may pick several persons and roles.', + 'person' => 'Persons', + 'person_hint' => + 'You may select one or several persons with the same roles. You need to create the persons first.', + 'group_role' => 'Groups and roles', + 'group_role_hint' => + 'You may select none, one or several groups and roles for a person.', + 'submit_add' => 'Add person(s)', + 'remove' => 'Remove', + ], + 'episode_form' => [ + 'title' => 'Manage persons', + 'manage_section_title' => 'Management', + 'manage_section_subtitle' => 'Remove persons from this episode', + 'add_section_title' => 'Add persons to this episode', + 'add_section_subtitle' => 'You may pick several persons and roles', + 'person' => 'Persons', + 'person_hint' => + 'You may select one or several persons with the same roles. You need to create the persons first.', + 'group_role' => 'Groups and roles', + 'group_role_hint' => + 'You may select none, one or several groups and roles for a person.', + 'submit_add' => 'Add person(s)', + 'remove' => 'Remove', + ], + 'credits' => 'Credits', +]; diff --git a/app/Language/en/Podcast.php b/app/Language/en/Podcast.php index 86f7b4c9bd635710d8469fac454ee6dcc398a48e..f7c87572990259d40466396c91cf0711fdc3ac97 100644 --- a/app/Language/en/Podcast.php +++ b/app/Language/en/Podcast.php @@ -27,7 +27,8 @@ return [ 'image' => 'Cover image', 'title' => 'Title', 'name' => 'Name', - 'name_hint' => 'Used for generating the podcast URL.', + 'name_hint' => + 'Used for generating the podcast URL. Uppercase, lowercase, numbers and underscores are accepted.', 'type' => [ 'label' => 'Type', 'hint' => diff --git a/app/Language/en/PodcastNavigation.php b/app/Language/en/PodcastNavigation.php index 048ca8be92b2d16539e4bbf63d07ec419e8f5d0b..49062fe30e9cb0e012133b799281231e55b2c206 100644 --- a/app/Language/en/PodcastNavigation.php +++ b/app/Language/en/PodcastNavigation.php @@ -15,6 +15,8 @@ return [ 'episode-list' => 'All episodes', 'episode-create' => 'New episode', 'analytics' => 'Analytics', + 'persons' => 'Persons', + 'podcast-person-manage' => 'Manage persons', 'contributors' => 'Contributors', 'contributor-list' => 'All contributors', 'contributor-add' => 'Add contributor', diff --git a/app/Language/fr/AdminNavigation.php b/app/Language/fr/AdminNavigation.php index ea79018dd7a600a01332e187ecfea88ffb30d8ab..b22523f359dd12238678186b074773a82b90fbe9 100644 --- a/app/Language/fr/AdminNavigation.php +++ b/app/Language/fr/AdminNavigation.php @@ -14,6 +14,9 @@ return [ 'podcast-list' => 'Tous les podcasts', 'podcast-create' => 'Créer un podcast', 'podcast-import' => 'Importer un podcast', + 'persons' => 'Intervenants', + 'person-list' => 'Tous les intervenants', + 'person-create' => 'Nouvel intervenant', 'users' => 'Utilisateurs', 'user-list' => 'Tous les utilisateurs', 'user-create' => 'Créer un utilisateur', diff --git a/app/Language/fr/Breadcrumb.php b/app/Language/fr/Breadcrumb.php index 71d8c331d9303395334e10d84c1d0a94f89ba487..961d403c081fd1fbf64e571dbaca19a51c692132 100644 --- a/app/Language/fr/Breadcrumb.php +++ b/app/Language/fr/Breadcrumb.php @@ -16,6 +16,7 @@ return [ 'add' => 'ajouter', 'new' => 'créer', 'edit' => 'modifier', + 'persons' => 'intervenants', 'users' => 'utilisateurs', 'my-account' => 'mon compte', 'change-password' => 'changer le mot de passe', diff --git a/app/Language/fr/Person.php b/app/Language/fr/Person.php new file mode 100644 index 0000000000000000000000000000000000000000..aef667f0ac8e143e3cf73d1c2466cfe0440729d6 --- /dev/null +++ b/app/Language/fr/Person.php @@ -0,0 +1,66 @@ +<?php + +/** + * @copyright 2020 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +return [ + 'persons' => 'Intervenants', + 'all_persons' => 'Tous les intervenants', + 'no_person' => 'Aucun intervenant trouvé !', + 'create' => 'Créer un intervenant', + 'view' => 'Voir l’intervenant', + 'edit' => 'Modifier l’intervenant', + 'delete' => 'Supprimer l’intervenant', + 'form' => [ + 'identity_section_title' => 'Identité', + 'identity_section_subtitle' => 'Qui intervient sur le podcast', + 'full_name' => 'Nom complet', + 'full_name_hint' => 'Le nom complet ou le pseudonyme de l’intervenant', + 'unique_name' => 'Nom unique', + 'unique_name_hint' => 'Utilisé pour les URLs', + 'information_url' => 'Adresse d’information', + 'information_url_hint' => + 'URL pointant vers des informations relatives à l’intervenant, telle qu’une page personnelle ou une page de profil sur une plateforme tierce.', + 'image' => 'Photo, avatar, image', + 'image_size_hint' => + 'L’image doit être carrée et avoir au moins 400px de largeur et de hauteur.', + 'submit_create' => 'Créer l’intervenant', + 'submit_edit' => 'Enregistrer l’intervenant', + ], + 'podcast_form' => [ + 'title' => 'Gérer les intervenants', + 'manage_section_title' => 'Gestion', + 'manage_section_subtitle' => 'Retirer des intervenants de ce podcast', + 'add_section_title' => 'Ajouter des intervenants à ce podcast', + 'add_section_subtitle' => + 'Vous pouvez sélectionner plusieurs intervenants et rôles.', + 'person' => 'Intervenants', + 'person_hint' => + 'Vous pouvez selectionner un ou plusieurs intervenants ayant les mêmes rôles. Les intervenants doivent avoir été préalablement créés.', + 'group_role' => 'Groupes et rôles', + 'group_role_hint' => + 'Vous pouvez sélectionner aucun, un ou plusieurs groupes et rôles par intervenant.', + 'submit_add' => 'Ajouter un/des intervenant(s)', + 'remove' => 'Retirer', + ], + 'episode_form' => [ + 'title' => 'Gérer les intervenants', + 'manage_section_title' => 'Gestion', + 'manage_section_subtitle' => 'Retirer des intervenants de cet épisode', + 'add_section_title' => 'Ajouter des intervenants à cet épisode', + 'add_section_subtitle' => + 'Vous pouvez sélectionner plusieurs intervenants et rôles.', + 'person' => 'Intervenants', + 'person_hint' => + 'Vous pouvez selectionner un ou plusieurs intervenants ayant les mêmes rôles. Les intervenants doivent avoir été préalablement créés.', + 'group_role' => 'Groupes et rôles', + 'group_role_hint' => + 'Vous pouvez sélectionner aucun, un ou plusieurs groupes et rôles par intervenant.', + 'submit_add' => 'Ajouter un/des intervenant(s)', + 'remove' => 'Retirer', + ], + 'credits' => 'Crédits', +]; diff --git a/app/Language/fr/Podcast.php b/app/Language/fr/Podcast.php index 49131cbf5449ed344965dcf510a1f1e04d47d1f7..20672a4cddd1ee5810aadd130076abe96e493a77 100644 --- a/app/Language/fr/Podcast.php +++ b/app/Language/fr/Podcast.php @@ -28,7 +28,8 @@ return [ 'image' => 'Image de couverture', 'title' => 'Titre', 'name' => 'Nom', - 'name_hint' => 'Utilisé pour l’adresse du podcast.', + 'name_hint' => + 'Utilisé pour l’adresse du podcast. Les majuscules, les minuscules, les chiffres et le caractère souligné « _ » sont acceptés.', 'type' => [ 'label' => 'Type', 'hint' => diff --git a/app/Language/fr/PodcastNavigation.php b/app/Language/fr/PodcastNavigation.php index 5ac59cd290b45537f1bdfa0434b7b191c002bc7f..1b5414bc6959f6e7fb1d14cd06b708efae1dac25 100644 --- a/app/Language/fr/PodcastNavigation.php +++ b/app/Language/fr/PodcastNavigation.php @@ -15,6 +15,8 @@ return [ 'episode-list' => 'Tous les épisodes', 'episode-create' => 'Créer un épisode', 'analytics' => 'Mesures d’audience', + 'persons' => 'Intervenants', + 'podcast-person-manage' => 'Gestion des intervenants', 'contributors' => 'Contributeurs', 'contributor-list' => 'Tous les contributeurs', 'contributor-add' => 'Ajouter un contributeur', diff --git a/app/Models/CreditModel.php b/app/Models/CreditModel.php new file mode 100644 index 0000000000000000000000000000000000000000..00121757bfe1ce19f11c97b0b56d89ac2e44eaf1 --- /dev/null +++ b/app/Models/CreditModel.php @@ -0,0 +1,20 @@ +<?php + +/** + * @copyright 2020 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Models; + +use CodeIgniter\Model; + +class CreditModel extends Model +{ + protected $table = 'credits'; + + protected $allowedFields = []; + + protected $returnType = \App\Entities\Credit::class; +} diff --git a/app/Models/EpisodeModel.php b/app/Models/EpisodeModel.php index a28d29d3499387851cd46923d71eea813b76092c..eacbac9b3c2e91b4c4614fbde6126beda2d49961 100644 --- a/app/Models/EpisodeModel.php +++ b/app/Models/EpisodeModel.php @@ -89,6 +89,26 @@ class EpisodeModel extends Model return $found; } + public function getEpisodeById($podcastId, $episodeId) + { + if (!($found = cache("podcast{$podcastId}_episode{$episodeId}"))) { + $found = $this->where([ + 'podcast_id' => $podcastId, + 'id' => $episodeId, + ]) + ->where('published_at <=', 'NOW()') + ->first(); + + cache()->save( + "podcast{$podcastId}_episode{$episodeId}", + $found, + DECADE + ); + } + + return $found; + } + /** * Returns the previous episode based on episode ordering */ @@ -334,7 +354,7 @@ class EpisodeModel extends Model return $data; } - protected function clearCache(array $data) + public function clearCache(array $data) { $episodeModel = new EpisodeModel(); $episode = (new EpisodeModel())->find( @@ -366,6 +386,7 @@ class EpisodeModel extends Model cache()->delete( "page_podcast{$episode->podcast->id}_episode{$episode->id}_{$locale}" ); + cache()->delete("credits_{$locale}"); } foreach ($years as $year) { diff --git a/app/Models/EpisodePersonModel.php b/app/Models/EpisodePersonModel.php new file mode 100644 index 0000000000000000000000000000000000000000..1ed80d1e06f58cdababde8e781c36a0b476e7261 --- /dev/null +++ b/app/Models/EpisodePersonModel.php @@ -0,0 +1,150 @@ +<?php + +/** + * @copyright 2021 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Models; + +use CodeIgniter\Model; + +class EpisodePersonModel extends Model +{ + protected $table = 'episodes_persons'; + protected $primaryKey = 'id'; + + protected $allowedFields = [ + 'id', + 'podcast_id', + 'episode_id', + 'person_id', + 'person_group', + 'person_role', + ]; + + protected $returnType = \App\Entities\EpisodePerson::class; + protected $useSoftDeletes = false; + + protected $useTimestamps = false; + + protected $validationRules = [ + 'episode_id' => 'required', + 'person_id' => 'required', + ]; + protected $validationMessages = []; + + protected $afterInsert = ['clearCache']; + protected $beforeDelete = ['clearCache']; + + public function getPersonsByEpisodeId($podcastId, $episodeId) + { + if ( + !($found = cache( + "podcast{$podcastId}_episodes{$episodeId}_persons" + )) + ) { + $found = $this->select('episodes_persons.*') + ->where('episode_id', $episodeId) + ->join( + 'persons', + 'person_id=persons.id' + ) + ->orderby('full_name') + ->findAll(); + + cache()->save( + "podcast{$podcastId}_episodes{$episodeId}_persons", + $found, + DECADE + ); + } + return $found; + } + + /** + * Add persons to episode + * + * @param int podcastId + * @param int $episodeId + * @param array $persons + * @param array $groups_roles + * + * @return integer|false Number of rows inserted or FALSE on failure + */ + public function addEpisodePersons( + $podcastId, + $episodeId, + $persons, + $groups_roles + ) { + if (!empty($persons)) { + $this->clearCache([ + 'id' => [ + 'podcast_id' => $podcastId, + 'episode_id' => $episodeId, + ], + ]); + $data = []; + foreach ($persons as $person) { + if ($groups_roles) { + foreach ($groups_roles as $group_role) { + $group_role = explode(',', $group_role); + $data[] = [ + 'podcast_id' => $podcastId, + 'episode_id' => $episodeId, + 'person_id' => $person, + 'person_group' => $group_role[0], + 'person_role' => $group_role[1], + ]; + } + } else { + $data[] = [ + 'podcast_id' => $podcastId, + 'episode_id' => $episodeId, + 'person_id' => $person, + ]; + } + } + return $this->insertBatch($data); + } + return 0; + } + + public function removeEpisodePersons( + $podcastId, + $episodeId, + $episodePersonId + ) { + return $this->delete([ + 'podcast_id' => $podcastId, + 'episode_id' => $episodeId, + 'id' => $episodePersonId, + ]); + } + + protected function clearCache(array $data) + { + $podcastId = null; + $episodeId = null; + if ( + isset($data['id']['podcast_id']) && + isset($data['id']['episode_id']) + ) { + $podcastId = $data['id']['podcast_id']; + $episodeId = $data['id']['episode_id']; + } else { + $episodePerson = (new EpisodePersonModel())->find( + is_array($data['id']) ? $data['id']['id'] : $data['id'] + ); + $podcastId = $episodePerson->podcast_id; + $episodeId = $episodePerson->episode_id; + } + + cache()->delete("podcast{$podcastId}_episodes{$episodeId}_persons"); + (new EpisodeModel())->clearCache(['id' => $episodeId]); + + return $data; + } +} diff --git a/app/Models/PersonModel.php b/app/Models/PersonModel.php new file mode 100644 index 0000000000000000000000000000000000000000..ac8661c8d11a2c4b7a2a6e4f762870f0d1e48dcd --- /dev/null +++ b/app/Models/PersonModel.php @@ -0,0 +1,134 @@ +<?php + +/** + * @copyright 2021 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Models; + +use CodeIgniter\Model; + +class PersonModel extends Model +{ + protected $table = 'persons'; + protected $primaryKey = 'id'; + + protected $allowedFields = [ + 'id', + 'full_name', + 'unique_name', + 'information_url', + 'image_uri', + 'created_by', + 'updated_by', + ]; + + protected $returnType = \App\Entities\Person::class; + protected $useSoftDeletes = false; + + protected $useTimestamps = true; + + protected $validationRules = [ + 'full_name' => 'required', + 'unique_name' => + 'required|regex_match[/^[a-z0-9\-]{1,191}$/]|is_unique[persons.unique_name,id,{id}]', + 'image_uri' => 'required', + 'created_by' => 'required', + 'updated_by' => 'required', + ]; + protected $validationMessages = []; + + // clear cache before update if by any chance, the person name changes, so will the person link + protected $afterInsert = ['clearCache']; + protected $beforeUpdate = ['clearCache']; + protected $beforeDelete = ['clearCache']; + + public function getPersonById($personId) + { + if (!($found = cache("person{$personId}"))) { + $found = $this->find($personId); + cache()->save("person{$personId}", $found, DECADE); + } + + return $found; + } + + public function getPerson($fullName) + { + return $this->where('full_name', $fullName)->first(); + } + + public function createPerson($fullName, $informationUrl, $image) + { + $person = new \App\Entities\Person([ + 'full_name' => $fullName, + 'unique_name' => slugify($fullName), + 'information_url' => $informationUrl, + 'image' => download_file($image), + 'created_by' => user()->id, + 'updated_by' => user()->id, + ]); + return $this->insert($person); + } + + public function getPersonOptions() + { + $options = []; + + if (!($options = cache('person_options'))) { + $options = array_reduce( + $this->select('`id`, `full_name`') + ->orderBy('`full_name`', 'ASC') + ->findAll(), + function ($result, $person) { + $result[$person->id] = $person->full_name; + return $result; + }, + [] + ); + cache()->save('person_options', $options, DECADE); + } + + return $options; + } + + public function getTaxonomyOptions() + { + $options = []; + $locale = service('request')->getLocale(); + if (!($options = cache("taxonomy_options_{$locale}"))) { + foreach (lang('PersonsTaxonomy.persons') as $group_key => $group) { + foreach ($group['roles'] as $role_key => $role) { + $options[ + "$group_key,$role_key" + ] = "{$group['label']} ▸ {$role['label']}"; + } + } + + cache()->save("taxonomy_options_{$locale}", $options, DECADE); + } + + return $options; + } + + protected function clearCache(array $data) + { + $person = (new PersonModel())->getPersonById( + is_array($data['id']) ? $data['id'][0] : $data['id'] + ); + + cache()->delete('person_options'); + cache()->delete("person{$person->id}"); + cache()->delete("user{$person->created_by}_persons"); + + $supportedLocales = config('App')->supportedLocales; + // clear cache for every credit page + foreach ($supportedLocales as $locale) { + cache()->delete("credit_{$locale}"); + } + + return $data; + } +} diff --git a/app/Models/PlatformModel.php b/app/Models/PlatformModel.php index f13586dcbe99415ed800da069f8792a22e47886c..827c4de1fff0957b1254d7add4127d7357c2c5db 100644 --- a/app/Models/PlatformModel.php +++ b/app/Models/PlatformModel.php @@ -16,14 +16,20 @@ use CodeIgniter\Model; class PlatformModel extends Model { protected $table = 'platforms'; - protected $primaryKey = 'id'; + protected $primaryKey = 'slug'; - protected $allowedFields = ['slug', 'label', 'home_url', 'submit_url']; + protected $allowedFields = [ + 'slug', + 'type', + 'label', + 'home_url', + 'submit_url', + ]; protected $returnType = \App\Entities\Platform::class; protected $useSoftDeletes = false; - protected $useTimestamps = true; + protected $useTimestamps = false; public function getPlatforms() { @@ -37,26 +43,32 @@ class PlatformModel extends Model return $found; } - public function getOrCreatePlatform($slug, $platformType) + public function getPlatform($slug) { - if (!($found = cache("platforms_$slug"))) { + if (!($found = cache("platform_$slug"))) { $found = $this->where('slug', $slug)->first(); - if (!$found) { - $data = [ - 'slug' => $slug, - 'type' => $platformType, - 'label' => $slug, - 'home_url' => '', - 'submit_url' => null, - ]; - $this->insert($data); - $found = $this->where('slug', $slug)->first(); - } - cache()->save("platforms_$slug", $found, DECADE); + cache()->save("platform_$slug", $found, DECADE); } return $found; } + public function createPlatform( + $slug, + $type, + $label, + $homeUrl, + $submitUrl = null + ) { + $data = [ + 'slug' => $slug, + 'type' => $type, + 'label' => $label, + 'home_url' => $homeUrl, + 'submit_url' => $submitUrl, + ]; + return $this->insert($data, false); + } + public function getPlatformsWithLinks($podcastId, $platformType) { if ( diff --git a/app/Models/PodcastModel.php b/app/Models/PodcastModel.php index 7e401fd16f9e6df701d8062c4034335e95e9a134..4bfed81d2cf810f03a177f82ea69a0d885d3ac45 100644 --- a/app/Models/PodcastModel.php +++ b/app/Models/PodcastModel.php @@ -1,7 +1,7 @@ <?php /** - * @copyright 2020 Podlibre + * @copyright 2021 Podlibre * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 * @link https://castopod.org/ */ @@ -170,7 +170,7 @@ class PodcastModel extends Model : false; } - protected function clearCache(array $data) + public function clearCache(array $data) { $podcast = (new PodcastModel())->getPodcastById( is_array($data['id']) ? $data['id'][0] : $data['id'] @@ -195,6 +195,10 @@ class PodcastModel extends Model ); } } + // clear cache for every credit page + foreach ($supportedLocales as $locale) { + cache()->delete("credits_{$locale}"); + } // delete episode lists cache per year / season // and localized pages diff --git a/app/Models/PodcastPersonModel.php b/app/Models/PodcastPersonModel.php new file mode 100644 index 0000000000000000000000000000000000000000..8268cf0ebd2f544b2e1975f25d30c96db0c02d42 --- /dev/null +++ b/app/Models/PodcastPersonModel.php @@ -0,0 +1,119 @@ +<?php + +/** + * @copyright 2021 Podlibre + * @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3 + * @link https://castopod.org/ + */ + +namespace App\Models; + +use CodeIgniter\Model; + +class PodcastPersonModel extends Model +{ + protected $table = 'podcasts_persons'; + protected $primaryKey = 'id'; + + protected $allowedFields = [ + 'id', + 'podcast_id', + 'person_id', + 'person_group', + 'person_role', + ]; + + protected $returnType = \App\Entities\PodcastPerson::class; + protected $useSoftDeletes = false; + + protected $useTimestamps = false; + + protected $validationRules = [ + 'podcast_id' => 'required', + 'person_id' => 'required', + ]; + protected $validationMessages = []; + + protected $afterInsert = ['clearCache']; + protected $beforeDelete = ['clearCache']; + + public function getPersonsByPodcastId($podcastId) + { + if (!($found = cache("podcast{$podcastId}_persons"))) { + $found = $this->select('podcasts_persons.*') + ->where('podcast_id', $podcastId) + ->join( + 'persons', + 'person_id=persons.id' + ) + ->orderby('full_name') + ->findAll(); + + cache()->save("podcast{$podcastId}_persons", $found, DECADE); + } + return $found; + } + + /** + * Add persons to podcast + * + * @param int $podcastId + * @param array $persons + * @param array $groups_roles + * + * @return integer Number of rows inserted or FALSE on failure + */ + public function addPodcastPersons($podcastId, $persons, $groups_roles) + { + if (!empty($persons)) { + $this->clearCache(['id' => ['podcast_id' => $podcastId]]); + $data = []; + foreach ($persons as $person) { + if ($groups_roles) { + foreach ($groups_roles as $group_role) { + $group_role = explode(',', $group_role); + $data[] = [ + 'podcast_id' => $podcastId, + 'person_id' => $person, + 'person_group' => $group_role[0], + 'person_role' => $group_role[1], + ]; + } + } else { + $data[] = [ + 'podcast_id' => $podcastId, + 'person_id' => $person, + ]; + } + } + return $this->insertBatch($data); + } + return 0; + } + + public function removePodcastPersons($podcastId, $podcastPersonId) + { + return $this->delete([ + 'podcast_id' => $podcastId, + 'id' => $podcastPersonId, + ]); + } + + protected function clearCache(array $data) + { + $podcastId = null; + if (isset($data['id']['podcast_id'])) { + $podcastId = $data['id']['podcast_id']; + } else { + $person = (new PodcastPersonModel())->find( + is_array($data['id']) ? $data['id']['id'] : $data['id'] + ); + $podcastId = $person->podcast_id; + } + + cache()->delete("podcast{$podcastId}_persons"); + (new PodcastModel())->clearCache(['id' => $podcastId]); + + return $data; + } +} diff --git a/app/Views/_assets/icons/folder-user.svg b/app/Views/_assets/icons/folder-user.svg new file mode 100644 index 0000000000000000000000000000000000000000..590e6aa19abc431a1edf746bb8630f1edb4efab2 --- /dev/null +++ b/app/Views/_assets/icons/folder-user.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M12.414 5H21a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h7.414l2 2zM4 5v14h16V7h-8.414l-2-2H4zm4 13a4 4 0 1 1 8 0H8zm4-5a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5z"/></svg> \ No newline at end of file diff --git a/app/Views/_assets/images/platforms/podcasting/breaker.svg b/app/Views/_assets/images/platforms/podcasting/breaker.svg new file mode 100644 index 0000000000000000000000000000000000000000..27eeadd7408cdbdf523848b3d8f6eb020ab90625 --- /dev/null +++ b/app/Views/_assets/images/platforms/podcasting/breaker.svg @@ -0,0 +1,11 @@ +<svg version="1.1" viewBox="0 0 300 300" xmlns="http://www.w3.org/2000/svg"> + <rect width="300" height="300" rx="67" fill="#f2f8ff"/> + <g transform="matrix(1.36 0 0 1.36 -45.282 22.882)"> + <path d="m133.88 120.47c4.08 12.49 10.64 23.85 19.12 33.5 15.88-2.06 29.85-10.22 39.46-22.06-7.84 2.07-16.07 3.17-24.56 3.17-6.79 0-13.42-.72-19.81-2.06-6.67-1.36-12.07-6.21-14.21-12.55z" fill="#1269ff"/> + <path d="m145.09 154.47c2.68 0 5.32-.17 7.91-.51-8.48-9.65-15.04-21.01-19.12-33.5-.64-1.91-.99-3.95-.99-6.07 0-4.77 1.76-9.12 4.66-12.46-11.1 12.41-19.02 27.71-22.48 44.64 8.85 5.03 19.1 7.9 30.02 7.9z" fill="#5c9dff"/> + <path d="m85.78 107.8c4 16.61 14.79 30.57 29.28 38.78 3.47-16.95 11.4-32.28 22.52-44.69 3.48-3.98 8.6-6.49 14.3-6.49 1.27 0 2.5.12 3.7.36-6.36-1.33-12.95-2.04-19.71-2.04-18.35-.01-35.5 5.14-50.09 14.08z" fill="#9ec6ff"/> + <path d="m155.59 95.754c-6.36-1.33-12.95-2.04-19.71-2.04-18.36 0-35.51 5.15-50.1 14.09-1.1-4.59-1.69-9.39-1.69-14.33 0-19.02 8.71-36.01 22.35-47.2 29.91 9.03 53.64 32.31 63.39 61.91.09.26.18.52.26.79-.08-.26-.17-.53-.26-.79-2.19-6.29-7.6-11.12-14.24-12.43z" fill="#d1e3ff"/> + <path d="m203.08 82.474c0-27-22.49-50-58-50-14.66 0-28.12 5.18-38.64 13.8 30.18 9.12 54.07 32.7 63.64 62.69.56 1.75 1.07 3.62 1.53 5.42 17.34-.38 31.47-14.48 31.47-31.91z" fill="#fff"/> + <path d="m151.89 98.394c1.12 0 2.06.09 3.12.3 5.56 1.1 10.12 5.14 11.98 10.43.54 1.7 1.25 4.25 1.7 6l.59 2.31 2.38-.05c18.94-.4 34.43-15.81 34.43-34.91 0-28.93-24.13-53-61-53-15.21.05-29.63 5.58-40.55 14.48-14.31 11.73-23.45 29.56-23.45 49.52.01 5.12.63 10.27 1.78 15.03 4.2 17.43 15.52 32.08 30.71 40.68 9.19 5.18 20.18 8.25 31.51 8.28 2.78 0 5.61-.19 8.29-.53 16.67-2.16 31.32-10.73 41.41-23.14l.66-.81-1.28-4.63-2.47.65c-7.66 2.03-15.51 3.08-23.8 3.07-6.63 0-12.95-.68-19.19-2-5.62-1.13-10.19-5.22-11.99-10.56-.58-1.68-.84-3.24-.83-5.11-.06-4.17 1.37-7.6 3.95-10.53 2.94-3.36 7.25-5.48 12.05-5.48zm-6.8-62.92c34.12 0 55 21.92 55 47 0 14.99-11.56 27.3-26.23 28.78-.39-1.39-.81-2.86-1.17-3.98-9.29-29.1-31.41-51.63-59.87-62.03 9.25-6.28 20.16-9.81 32.27-9.77zm-58 58c0-17.51 7.76-33.21 20.03-43.85 23.34 7.48 42.47 23.45 53.64 44.64-1.45-.64-2.98-1.13-4.58-1.45-6.5-1.35-13.39-2.1-20.3-2.1-17.28.03-33.84 4.58-48 12.4-.53-3.13-.79-6.31-.79-9.64zm60.39 42.48c6.53 1.37 13.47 2.12 20.42 2.12 5.31 0 10.59-.44 15.72-1.25-8.1 7.21-18.23 12.17-29.43 13.94-4.26-5-7.96-10.4-11.05-16.17 1.38.59 2.83 1.05 4.34 1.36zm-.45 15.49c-.64.02-1.29.03-1.94.03-9.73.03-18.59-2.26-26.63-6.45 2.31-10.17 6.25-19.6 11.58-28.14.18 1.57.53 3.13.99 4.52 3.57 10.89 9.08 21.14 16 30.04zm-11.69-51.54c-10.42 11.68-18.29 26.2-22.27 41.94-11.43-7.58-20.01-19.1-23.82-32.62 13.79-8.04 29.5-12.54 46.63-12.51.96 0 1.91.01 2.86.04-1.24.93-2.38 1.98-3.4 3.15z" fill="#003dad"/> + </g> +</svg> diff --git a/app/Views/_layout.php b/app/Views/_layout.php index 831f2cb0b682eb1bbea719fcefaef7831c03603e..9f0e6a9d947e504c4451fab1a1f6546576c630ba 100644 --- a/app/Views/_layout.php +++ b/app/Views/_layout.php @@ -11,8 +11,8 @@ <link rel="stylesheet" href="/assets/index.css"/> </head> -<body class="flex flex-col min-h-screen mx-auto"> - <header class="border-b"> +<body class="flex flex-col min-h-screen mx-auto bg-gray-100"> + <header class="bg-white border-b"> <div class="container flex items-center justify-between px-2 py-4 mx-auto"> <a href="<?= route_to('home') ?>" class="text-2xl"><?= isset($page) ? $page->title @@ -22,11 +22,15 @@ <main class="container flex-1 px-4 py-10 mx-auto"> <?= $this->renderSection('content') ?> </main> - <footer class="container flex justify-between px-2 py-4 mx-auto text-sm text-right border-t"> - <?= render_page_links() ?> - <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> + <footer class="px-2 py-4 bg-white border-t"> + <div class="container flex flex-col items-center justify-between mx-auto text-xs md:flex-row "> + <?= render_page_links('inline-flex mb-4 md:mb-0') ?> + <p class="flex flex-col items-center md:items-end"> + <?= lang('Common.powered_by', [ + 'castopod' => + '<a class="underline hover:no-underline" href="https://castopod.org" target="_blank" rel="noreferrer noopener">Castopod</a>', + ]) ?> + </p> + </div> + </footer> </body> diff --git a/app/Views/admin/_sidebar.php b/app/Views/admin/_sidebar.php index b57cccfe2b7dfd38d293292d4212ee028b7c9ef3..069dc74243a1623bbfebf0fbb2c38f8524cde36a 100644 --- a/app/Views/admin/_sidebar.php +++ b/app/Views/admin/_sidebar.php @@ -5,6 +5,10 @@ $navigation = [ 'icon' => 'mic', 'items' => ['podcast-list', 'podcast-create', 'podcast-import'], ], + 'persons' => [ + 'icon' => 'folder-user', + 'items' => ['person-list', 'person-create'], + ], 'users' => ['icon' => 'group', 'items' => ['user-list', 'user-create']], 'pages' => ['icon' => 'pages', 'items' => ['page-list', 'page-create']], ]; ?> diff --git a/app/Views/admin/episode/list.php b/app/Views/admin/episode/list.php index 78f8f4270b32f4ad2dee6ddd1dd60b6d5007951c..6bed3c358aed1680d5bc611ea732bf848eb3e4b2 100644 --- a/app/Views/admin/episode/list.php +++ b/app/Views/admin/episode/list.php @@ -61,6 +61,11 @@ $podcast->id, $episode->id ) ?>"><?= lang('Episode.edit') ?></a> + <a class="px-4 py-1 hover:bg-gray-100" href="<?= route_to( + 'episode-person-manage', + $podcast->id, + $episode->id + ) ?>"><?= lang('Person.persons') ?></a> <a class="px-4 py-1 hover:bg-gray-100" href="<?= route_to( 'soundbites-edit', $podcast->id, diff --git a/app/Views/admin/episode/person.php b/app/Views/admin/episode/person.php new file mode 100644 index 0000000000000000000000000000000000000000..be803b50f4106dfe9af20d5ed448693cf4218231 --- /dev/null +++ b/app/Views/admin/episode/person.php @@ -0,0 +1,131 @@ +<?= $this->extend('admin/_layout') ?> + +<?= $this->section('title') ?> +<?= lang('Person.episode_form.title') ?> +<?= $this->endSection() ?> + +<?= $this->section('pageTitle') ?> +<?= lang('Person.episode_form.title') ?> (<?= count($episodePersons) ?>) +<?= $this->endSection() ?> + +<?= $this->section('headerRight') ?> +<?= button( + lang('Person.create'), + route_to('person-create'), + ['variant' => 'primary', 'iconLeft' => 'add'], + ['class' => 'mr-2'] +) ?> +<?= $this->endSection() ?> + +<?= $this->section('content') ?> + +<?= form_open(route_to('episode-person-edit', $episode->id), [ + 'method' => 'post', + 'class' => 'flex flex-col', +]) ?> +<?= csrf_field() ?> + +<?php if ($episodePersons): ?> + +<?= form_section( + lang('Person.episode_form.manage_section_title'), + lang('Person.episode_form.manage_section_subtitle') +) ?> + + +<?= data_table( + [ + [ + 'header' => lang('Person.episode_form.person'), + 'cell' => function ($episodePerson) { + return '<div class="flex">' . + '<a href="' . + route_to('person-view', $episodePerson->person->id) . + "\"><img src=\"{$episodePerson->person->image->thumbnail_url}\" alt=\"{$episodePerson->person->full_name}\" class=\"object-cover w-16 h-16 rounded-full\" /></a>" . + '<div class="flex flex-col ml-3">' . + $episodePerson->person->full_name . + ($episodePerson->person_group && $episodePerson->person_role + ? '<span class="text-sm text-gray-600">' . + lang( + "PersonsTaxonomy.persons.{$episodePerson->person_group}.label" + ) . + ' ▸ ' . + lang( + "PersonsTaxonomy.persons.{$episodePerson->person_group}.roles.{$episodePerson->person_role}.label" + ) . + '</span>' + : '') . + (empty($episodePerson->person->information_url) + ? '' + : "<a href=\"{$episodePerson->person->information_url}\" target=\"_blank\" rel=\"noreferrer noopener\" class=\"text-sm text-blue-800 hover:underline\">" . + $episodePerson->person->information_url . + '</a>') . + '</div></div>'; + }, + ], + [ + 'header' => lang('Common.actions'), + 'cell' => function ($episodePerson) { + return button( + lang('Person.episode_form.remove'), + route_to( + 'episode-person-remove', + $episodePerson->podcast_id, + $episodePerson->episode_id, + $episodePerson->id + ), + ['variant' => 'danger', 'size' => 'small'] + ); + }, + ], + ], + $episodePersons +) ?> + +<?= form_section_close() ?> +<?php endif; ?> + + +<?= form_section( + lang('Person.episode_form.add_section_title'), + lang('Person.episode_form.add_section_subtitle') +) ?> + +<?= form_label( + lang('Person.episode_form.person'), + 'person', + [], + lang('Person.episode_form.person_hint') +) ?> +<?= form_multiselect('person[]', $personOptions, old('person', []), [ + 'id' => 'person', + 'class' => 'form-select mb-4', + 'required' => 'required', +]) ?> + +<?= form_label( + lang('Person.episode_form.group_role'), + 'group_role', + [], + + lang('Person.episode_form.group_role_hint'), + true +) ?> +<?= form_multiselect( + 'person_group_role[]', + $taxonomyOptions, + old('person_group_role', []), + ['id' => 'person_group_role', 'class' => 'form-select mb-4'] +) ?> + + +<?= form_section_close() ?> +<?= button( + lang('Person.episode_form.submit_add'), + null, + ['variant' => 'primary'], + ['type' => 'submit', 'class' => 'self-end'] +) ?> +<?= form_close() ?> + +<?= $this->endSection() ?> diff --git a/app/Views/admin/episode/view.php b/app/Views/admin/episode/view.php index a66c8d710cf0a1c6fb3857557b128bccc8250143..08956ec37c83e7c8be6fb287445d167fa5cdd629 100644 --- a/app/Views/admin/episode/view.php +++ b/app/Views/admin/episode/view.php @@ -64,6 +64,12 @@ ['variant' => 'info', 'iconLeft' => 'edit'], ['class' => 'mb-4'] ) ?> + <?= button( + lang('Person.episode_form.title'), + route_to('episode-person-manage', $podcast->id, $episode->id), + ['variant' => 'info', 'iconLeft' => 'folder-user'], + ['class' => 'mb-4'] + ) ?> <?php if (count($episode->soundbites) > 0): ?> <?= data_table( [ diff --git a/app/Views/admin/person/create.php b/app/Views/admin/person/create.php new file mode 100644 index 0000000000000000000000000000000000000000..5ec2c7ee5c6154045cecd2100a2ccf1a6df4a341 --- /dev/null +++ b/app/Views/admin/person/create.php @@ -0,0 +1,95 @@ +<?= $this->extend('admin/_layout') ?> + +<?= $this->section('title') ?> +<?= lang('Person.create') ?> +<?= $this->endSection() ?> + +<?= $this->section('pageTitle') ?> +<?= lang('Person.create') ?> +<?= $this->endSection() ?> + + +<?= $this->section('content') ?> + +<?= form_open_multipart(route_to('person-create'), [ + 'method' => 'post', + 'class' => 'flex flex-col', +]) ?> +<?= csrf_field() ?> + +<?= form_section( + lang('Person.form.identity_section_title'), + lang('Person.form.identity_section_subtitle') +) ?> + +<?= form_label( + lang('Person.form.full_name'), + 'full_name', + [], + lang('Person.form.full_name_hint') +) ?> +<?= form_input([ + 'id' => 'full_name', + 'name' => 'full_name', + 'class' => 'form-input mb-4', + 'value' => old('full_name'), + 'required' => 'required', + 'data-slugify' => 'title', +]) ?> + +<?= form_label( + lang('Person.form.unique_name'), + 'unique_name', + [], + lang('Person.form.unique_name_hint') +) ?> +<?= form_input([ + 'id' => 'unique_name', + 'name' => 'unique_name', + 'class' => 'form-input mb-4', + 'value' => old('unique_name'), + 'required' => 'required', + 'data-slugify' => 'slug', +]) ?> + +<?= form_label( + lang('Person.form.information_url'), + 'information_url', + [], + lang('Person.form.information_url_hint'), + true +) ?> +<?= form_input([ + 'id' => 'information_url', + 'name' => 'information_url', + 'class' => 'form-input mb-4', + 'value' => old('information_url'), +]) ?> + +<?= form_label(lang('Person.form.image'), 'image') ?> +<?= form_input([ + 'id' => 'image', + 'name' => 'image', + 'class' => 'form-input', + 'required' => 'required', + 'type' => 'file', + 'accept' => '.jpg,.jpeg,.png', +]) ?> +<small class="mb-4 text-gray-600"><?= lang( + 'Person.form.image_size_hint' +) ?></small> + +<?= form_section_close() ?> + +<?= button( + lang('Person.form.submit_create'), + null, + ['variant' => 'primary'], + ['type' => 'submit', 'class' => 'self-end'] +) ?> + + +<?= form_close() ?> + + +<?= $this->endSection() ?> diff --git a/app/Views/admin/person/edit.php b/app/Views/admin/person/edit.php new file mode 100644 index 0000000000000000000000000000000000000000..98a1d629b947f4f1e0228c87ba4d64033c79d225 --- /dev/null +++ b/app/Views/admin/person/edit.php @@ -0,0 +1,95 @@ +<?= $this->extend('admin/_layout') ?> + +<?= $this->section('title') ?> +<?= lang('Person.edit') ?> +<?= $this->endSection() ?> + +<?= $this->section('pageTitle') ?> +<?= lang('Person.edit') ?> +<?= $this->endSection() ?> + + +<?= $this->section('content') ?> + +<?= form_open_multipart(route_to('person-edit', $person->id), [ + 'method' => 'post', + 'class' => 'flex flex-col', +]) ?> +<?= csrf_field() ?> + +<?= form_section( + lang('Person.form.identity_section_title'), + lang('Person.form.identity_section_subtitle') . + "<img src=\"{$person->image->thumbnail_url}\" alt=\"{$person->full_name}\" class=\"object-cover w-32 h-32 mt-3 rounded\" />" +) ?> + +<?= form_label( + lang('Person.form.full_name'), + 'full_name', + [], + lang('Person.form.full_name_hint') +) ?> +<?= form_input([ + 'id' => 'full_name', + 'name' => 'full_name', + 'class' => 'form-input mb-4', + 'value' => old('full_name', $person->full_name), + 'required' => 'required', + 'data-slugify' => 'title', +]) ?> + +<?= form_label( + lang('Person.form.unique_name'), + 'unique_name', + [], + lang('Person.form.unique_name_hint') +) ?> +<?= form_input([ + 'id' => 'unique_name', + 'name' => 'unique_name', + 'class' => 'form-input mb-4', + 'value' => old('unique_name', $person->unique_name), + 'required' => 'required', + 'data-slugify' => 'slug', +]) ?> + +<?= form_label( + lang('Person.form.information_url'), + 'information_url', + [], + lang('Person.form.information_url_hint'), + true +) ?> +<?= form_input([ + 'id' => 'information_url', + 'name' => 'information_url', + 'class' => 'form-input mb-4', + 'value' => old('information_url', $person->information_url), +]) ?> + +<?= form_label(lang('Person.form.image'), 'image') ?> +<?= form_input([ + 'id' => 'image', + 'name' => 'image', + 'class' => 'form-input', + 'type' => 'file', + 'accept' => '.jpg,.jpeg,.png', +]) ?> +<small class="mb-4 text-gray-600"><?= lang( + 'Person.form.image_size_hint' +) ?></small> + +<?= form_section_close() ?> + +<?= button( + lang('Person.form.submit_edit'), + null, + ['variant' => 'primary'], + ['type' => 'submit', 'class' => 'self-end'] +) ?> + + +<?= form_close() ?> + + +<?= $this->endSection() ?> diff --git a/app/Views/admin/person/list.php b/app/Views/admin/person/list.php new file mode 100644 index 0000000000000000000000000000000000000000..de4040fd7af17e371462ec4d3705fd331de7e0c3 --- /dev/null +++ b/app/Views/admin/person/list.php @@ -0,0 +1,65 @@ +<?= $this->extend('admin/_layout') ?> + +<?= $this->section('title') ?> +<?= lang('Person.all_persons') ?> +<?= $this->endSection() ?> + +<?= $this->section('pageTitle') ?> +<?= lang('Person.all_persons') ?> (<?= count($persons) ?>) +<?= $this->endSection() ?> + +<?= $this->section('headerRight') ?> +<?= button( + lang('Person.create'), + route_to('person-create'), + ['variant' => 'primary', 'iconLeft' => 'add'], + ['class' => 'mr-2'] +) ?> +<?= $this->endSection() ?> + +<?= $this->section('content') ?> + +<div class="flex flex-wrap"> + <?php if (!empty($persons)): ?> + <?php foreach ($persons as $person): ?> + <article class="w-48 h-full mb-4 mr-4 overflow-hidden bg-white border rounded shadow"> + <img + alt="<?= $person->full_name ?>" + src="<?= $person->image + ->thumbnail_url ?>" class="object-cover w-40 w-full" /> + <div class="p-2"> + <a href="<?= route_to( + 'person-view', + $person->id + ) ?>" class="hover:underline"> + <h2 class="font-semibold"><?= $person->full_name ?></h2> + </a> + </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( + 'person-edit', + $person->id + ) ?>" data-toggle="tooltip" data-placement="bottom" title="<?= lang( + 'Person.edit' +) ?>"><?= icon('edit') ?></a> + <a class="inline-flex p-2 mr-2 text-gray-700 bg-red-100 rounded-full shadow-xs hover:bg-gray-200" href="<?= route_to( + 'person-delete', + $person->id + ) ?>" data-toggle="tooltip" data-placement="bottom" title="<?= lang( + 'Person.delete' +) ?>"><?= icon('delete-bin') ?></a> + <a class="inline-flex p-2 text-gray-700 bg-gray-100 rounded-full shadow-xs hover:bg-gray-200" href="<?= route_to( + 'person-view', + $person->id + ) ?>" data-toggle="tooltip" data-placement="bottom" title="<?= lang( + 'Person.view' +) ?>"><?= icon('eye') ?></a> + </footer> + </article> + <?php endforeach; ?> + <?php else: ?> + <p class="italic"><?= lang('Person.no_person') ?></p> + <?php endif; ?> +</div> + +<?= $this->endSection() ?> diff --git a/app/Views/admin/person/view.php b/app/Views/admin/person/view.php new file mode 100644 index 0000000000000000000000000000000000000000..99446eb41ea5b89a01e37bcf4b6016ebe08d39cc --- /dev/null +++ b/app/Views/admin/person/view.php @@ -0,0 +1,38 @@ +<?= $this->extend('admin/_layout') ?> + +<?= $this->section('title') ?> +<?= $person->full_name ?> +<?= $this->endSection() ?> + +<?= $this->section('pageTitle') ?> +<?= $person->full_name ?> + +<?= $this->endSection() ?> + +<?= $this->section('headerRight') ?> +<?= button( + lang('Person.edit'), + route_to('person-edit', $person->id), + ['variant' => 'secondary', 'iconLeft' => 'edit'], + ['class' => 'mr-2'] +) ?> +<?= $this->endSection() ?> + +<?= $this->section('content') ?> + +<div class="flex flex-wrap"> + <div class="w-full max-w-sm mb-6 md:mr-4"> + <img + src="<?= $person->image->medium_url ?>" + alt="$person->full_name" + class="object-cover w-full rounded" + /> + </div> + + <section class="w-full prose"> + <?= $person->full_name ?><br /> + <a href="<?= $person->information_url ?>"><?= $person->information_url ?></a> + </section> +</div> + +<?= $this->endSection() ?> diff --git a/app/Views/admin/podcast/_sidebar.php b/app/Views/admin/podcast/_sidebar.php index 7d525a25c4606d18b35ec6e0c987e74d4dd311d7..ffa8fe3c44370cd066448988ece8f2016eacc295 100644 --- a/app/Views/admin/podcast/_sidebar.php +++ b/app/Views/admin/podcast/_sidebar.php @@ -8,6 +8,10 @@ $podcastNavigation = [ 'icon' => 'mic', 'items' => ['episode-list', 'episode-create'], ], + 'persons' => [ + 'icon' => 'folder-user', + 'items' => ['podcast-person-manage'], + ], 'analytics' => [ 'icon' => 'line-chart', 'items' => [ diff --git a/app/Views/admin/podcast/latest_episodes.php b/app/Views/admin/podcast/latest_episodes.php index db3049588538bf97abf18f9b1311cde879e6583a..7c6f17dba3a7bb0bc6c463b1c355d8f7a13afa62 100644 --- a/app/Views/admin/podcast/latest_episodes.php +++ b/app/Views/admin/podcast/latest_episodes.php @@ -58,6 +58,16 @@ $podcast->id, $episode->id ) ?>"><?= lang('Episode.edit') ?></a> + <a class="px-4 py-1 hover:bg-gray-100" href="<?= route_to( + 'episode-person-manage', + $podcast->id, + $episode->id + ) ?>"><?= lang('Person.persons') ?></a> + <a class="px-4 py-1 hover:bg-gray-100" href="<?= route_to( + 'soundbites-edit', + $podcast->id, + $episode->id + ) ?>"><?= lang('Episode.soundbites') ?></a> <a class="px-4 py-1 hover:bg-gray-100" href="<?= route_to( 'episode', $podcast->name, diff --git a/app/Views/admin/podcast/person.php b/app/Views/admin/podcast/person.php new file mode 100644 index 0000000000000000000000000000000000000000..1338f2ad1d24ef962a6713ebe1d08376eaafd85a --- /dev/null +++ b/app/Views/admin/podcast/person.php @@ -0,0 +1,131 @@ +<?= $this->extend('admin/_layout') ?> + +<?= $this->section('title') ?> +<?= lang('Person.podcast_form.title') ?> +<?= $this->endSection() ?> + +<?= $this->section('pageTitle') ?> +<?= lang('Person.podcast_form.title') ?> (<?= count($podcastPersons) ?>) +<?= $this->endSection() ?> + +<?= $this->section('headerRight') ?> +<?= button( + lang('Person.create'), + route_to('person-create'), + ['variant' => 'primary', 'iconLeft' => 'add'], + ['class' => 'mr-2'] +) ?> +<?= $this->endSection() ?> + +<?= $this->section('content') ?> + +<?= form_open(route_to('podcast-person-edit', $podcast->id), [ + 'method' => 'post', + 'class' => 'flex flex-col', +]) ?> +<?= csrf_field() ?> + +<?php if ($podcastPersons): ?> + +<?= form_section( + lang('Person.podcast_form.manage_section_title'), + lang('Person.podcast_form.manage_section_subtitle') +) ?> + + +<?= data_table( + [ + [ + 'header' => lang('Person.podcast_form.person'), + 'cell' => function ($podcastPerson) { + return '<div class="flex">' . + '<a href="' . + route_to('person-view', $podcastPerson->person->id) . + "\"><img src=\"{$podcastPerson->person->image->thumbnail_url}\" alt=\"{$podcastPerson->person->full_name}\" class=\"object-cover w-16 h-16 rounded-full\" /></a>" . + '<div class="flex flex-col ml-3">' . + $podcastPerson->person->full_name . + ($podcastPerson->person_group && $podcastPerson->person_role + ? '<span class="text-sm text-gray-600">' . + lang( + "PersonsTaxonomy.persons.{$podcastPerson->person_group}.label" + ) . + ' ▸ ' . + lang( + "PersonsTaxonomy.persons.{$podcastPerson->person_group}.roles.{$podcastPerson->person_role}.label" + ) . + '</span>' + : '') . + (empty($podcastPerson->person->information_url) + ? '' + : "<a href=\"{$podcastPerson->person->information_url}\" target=\"_blank\" rel=\"noreferrer noopener\" class=\"text-sm text-blue-800 hover:underline\">" . + $podcastPerson->person->information_url . + '</a>') . + '</div></div>'; + }, + ], + [ + 'header' => lang('Common.actions'), + 'cell' => function ($podcastPerson) { + return button( + lang('Person.podcast_form.remove'), + route_to( + 'podcast-person-remove', + $podcastPerson->podcast_id, + $podcastPerson->id + ), + + ['variant' => 'danger', 'size' => 'small'] + ); + }, + ], + ], + $podcastPersons +) ?> + +<?= form_section_close() ?> +<?php endif; ?> + + +<?= form_section( + lang('Person.podcast_form.add_section_title'), + lang('Person.podcast_form.add_section_subtitle') +) ?> + +<?= form_label( + lang('Person.podcast_form.person'), + 'person', + [], + lang('Person.podcast_form.person_hint') +) ?> +<?= form_multiselect('person[]', $personOptions, old('person', []), [ + 'id' => 'person', + 'class' => 'form-select mb-4', + 'required' => 'required', +]) ?> + +<?= form_label( + lang('Person.podcast_form.group_role'), + 'group_role', + [], + + lang('Person.podcast_form.group_role_hint'), + true +) ?> +<?= form_multiselect( + 'person_group_role[]', + $taxonomyOptions, + old('person_group_role', []), + ['id' => 'person_group_role', 'class' => 'form-select mb-4'] +) ?> + + +<?= form_section_close() ?> +<?= button( + lang('Person.podcast_form.submit_add'), + null, + ['variant' => 'primary'], + ['type' => 'submit', 'class' => 'self-end'] +) ?> +<?= form_close() ?> + +<?= $this->endSection() ?> diff --git a/app/Views/credits.php b/app/Views/credits.php new file mode 100644 index 0000000000000000000000000000000000000000..97152c0be232e6249c51b111b9aa2a59c0176302 --- /dev/null +++ b/app/Views/credits.php @@ -0,0 +1,49 @@ +<?= $this->extend('_layout') ?> + +<?= $this->section('title') ?> +<?= lang('Person.credits') ?> +<?= $this->endSection() ?> + +<?= $this->section('content') ?> + +<div class="grid w-full grid-cols-1 gap-4 md:grid-cols-2"> +<?php foreach ($credits as $groupSlug => $groups): ?> + <?php if ( + $groupSlug + ): ?><div class="col-span-1 mt-12 mb-2 text-xl font-bold text-gray-500 md:text-2xl md:col-span-2 "><?= $groups[ + 'group_label' +] ?></div><?php endif; ?> + <?php foreach ($groups['persons'] as $personId => $persons): ?> + <div class="flex mt-2 mb-2"> + <img src="<?= $persons['thumbnail_url'] ?>" alt="<?= $persons[ + 'full_name' +] ?>" class="object-cover w-16 h-16 border-4 rounded-full md:h-24 md:w-24 border-gray" /> + <div class="flex flex-col ml-3 mr-4"><span class="text-lg font-bold text-gray-700 md:text-xl"><?= $persons[ + 'full_name' + ] ?></span> + <?php if ( + !empty($persons['information_url']) + ): ?><a href="<?= $persons[ + 'information_url' +] ?>" class="text-sm text-blue-800 hover:underline" target="_blank" rel="noreferrer noopener"><?= $persons[ + 'information_url' +] ?></a><?php endif; ?></div> + </div> + <div class="flex flex-col"> + <?php foreach ($persons['roles'] as $role_slug => $role_array): ?> + <?= $role_array['role_label'] ?> + + <?php foreach ($role_array['is_in'] as $isIn): ?> + <a href="<?= $isIn[ + 'link' + ] ?>" class="text-sm text-gray-500 hover:underline"><?= $isIn[ + 'title' +] ?></a> + <?php endforeach; ?> + + <?php endforeach; ?> + </div> + <?php endforeach; ?> +<?php endforeach; ?> +</div> +<?php $this->endSection(); ?> diff --git a/app/Views/episode.php b/app/Views/episode.php index 6bdf4840b82185c712798e3f48e01c23cf744c12..0b116d7bd4bf97c8bd4dbd89dd8e96af283668da 100644 --- a/app/Views/episode.php +++ b/app/Views/episode.php @@ -100,11 +100,28 @@ <?= format_duration($episode->enclosure_duration) ?> </time> </div> + <div class="flex mt-2 mb-1 space-x-2"> + <?php foreach ($persons as $person): ?> + <?php if (!empty($person['information_url'])): ?> + <a href="<?= $person[ + 'information_url' + ] ?>" target="_blank" rel="noreferrer noopener"> + <?php endif; ?> + <img src="<?= $person['thumbnail_url'] ?>" alt="<?= $person[ + 'full_name' +] ?>" title="[<?= $person['full_name'] ?>] <?= $person[ + 'roles' +] ?>" class="object-cover w-12 h-12 rounded-full" /> + <?php if (!empty($person['information_url'])): ?> + </a> + <?php endif; ?> + <?php endforeach; ?> + </div> <?= location_link( $episode->location_name, $episode->location_geo, $episode->location_osmid, - 'self-start mt-2' + 'self-start mt-2 mb-2' ) ?> <audio controls preload="none" class="w-full mt-auto"> <source src="<?= $episode->enclosure_web_url ?>" type="<?= $episode->enclosure_type ?>"> diff --git a/app/Views/podcast.php b/app/Views/podcast.php index eaafbd9fb52244b13e09a67de9b9dbcf696f24ee..29fed4b48ed4d97e1dd8547bab98ebd8c313c83c 100644 --- a/app/Views/podcast.php +++ b/app/Views/podcast.php @@ -114,6 +114,26 @@ <?php endif; ?> <?php endforeach; ?> </div> + + <div class="flex mb-2 space-x-2"> + <?php foreach ($personArray as $person): ?> + <?php if (!empty($person['information_url'])): ?> + <a href="<?= $person[ + 'information_url' + ] ?>" target="_blank" rel="noreferrer noopener"> + <?php endif; ?> + <img src="<?= $person[ + 'thumbnail_url' + ] ?>" alt="<?= $person[ + 'full_name' +] ?>" title="[<?= $person['full_name'] ?>] <?= $person[ + 'roles' +] ?>" class="object-cover w-12 h-12 rounded-full" /> + <?php if (!empty($person['information_url'])): ?> + </a> + <?php endif; ?> + <?php endforeach; ?> + </div> <div class="mb-2 opacity-75"> <?= $podcast->description_html ?> diff --git a/composer.json b/composer.json index 757d0d948ce0f8879bb9cc2f04d63ac8f8efbecf..3f6e47a037b10dab6059c2310dc19851b0833ac0 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,8 @@ "vlucas/phpdotenv": "^5.2", "league/html-to-markdown": "^4.10", "opawg/user-agents-php": "^1.0", - "podlibre/ipcat": "^1.0" + "podlibre/ipcat": "^1.0", + "podlibre/podcast-namespace": "^1.0.6" }, "require-dev": { "mikey179/vfsstream": "1.6.*", @@ -33,13 +34,19 @@ "post-install-cmd": [ "@php vendor/opawg/user-agents-php/src/UserAgentsGenerate.php > vendor/opawg/user-agents-php/src/UserAgents.php", "@php vendor/opawg/user-agents-php/src/UserAgentsRSSGenerate.php > vendor/opawg/user-agents-php/src/UserAgentsRSS.php", - "@php vendor/podlibre/ipcat/IpDbGenerate.php > vendor/podlibre/ipcat/IpDb.php" + "@php vendor/podlibre/ipcat/IpDbGenerate.php > vendor/podlibre/ipcat/IpDb.php", + "@php vendor/podlibre/podcast-namespace/src/TaxonomyGenerate.php https://raw.githubusercontent.com/Podcastindex-org/podcast-namespace/main/taxonomy-en.json > app/Language/en/PersonsTaxonomy.php", + "@php vendor/podlibre/podcast-namespace/src/TaxonomyGenerate.php https://raw.githubusercontent.com/Podcastindex-org/podcast-namespace/main/taxonomy-fr.json > app/Language/fr/PersonsTaxonomy.php", + "@php vendor/podlibre/podcast-namespace/src/ReversedTaxonomyGenerate.php https://raw.githubusercontent.com/Podcastindex-org/podcast-namespace/main/taxonomy-en.json > vendor/podlibre/podcast-namespace/src/ReversedTaxonomy.php" ], "post-update-cmd": [ "@composer dump-autoload", "@php vendor/opawg/user-agents-php/src/UserAgentsGenerate.php > vendor/opawg/user-agents-php/src/UserAgents.php", "@php vendor/opawg/user-agents-php/src/UserAgentsRSSGenerate.php > vendor/opawg/user-agents-php/src/UserAgentsRSS.php", - "@php vendor/podlibre/ipcat/IpDbGenerate.php > vendor/podlibre/ipcat/IpDb.php" + "@php vendor/podlibre/ipcat/IpDbGenerate.php > vendor/podlibre/ipcat/IpDb.php", + "@php vendor/podlibre/podcast-namespace/src/TaxonomyGenerate.php https://raw.githubusercontent.com/Podcastindex-org/podcast-namespace/main/taxonomy-en.json > app/Language/en/PersonsTaxonomy.php", + "@php vendor/podlibre/podcast-namespace/src/TaxonomyGenerate.php https://raw.githubusercontent.com/Podcastindex-org/podcast-namespace/main/taxonomy-fr.json > app/Language/fr/PersonsTaxonomy.php", + "@php vendor/podlibre/podcast-namespace/src/ReversedTaxonomyGenerate.php https://raw.githubusercontent.com/Podcastindex-org/podcast-namespace/main/taxonomy-en.json > vendor/podlibre/podcast-namespace/src/ReversedTaxonomy.php" ] }, "support": { diff --git a/composer.lock b/composer.lock index d8c81dfc4f69a97b9284be4cc0e01ce947462386..1b9ff8f66f222bd7afed3da7aa130bca510a828f 100644 --- a/composer.lock +++ b/composer.lock @@ -1071,6 +1071,35 @@ }, "time": "2020-10-05T17:15:07+00:00" }, + { + "name": "podlibre/podcast-namespace", + "version": "v1.0.6", + "source": { + "type": "git", + "url": "https://code.podlibre.org/podlibre/podcastnamespace", + "reference": "4525c06ee9dd95bb745ee875d55b64a053c74cd6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Podlibre\\PodcastNamespace\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Benjamin Bellamy", + "email": "ben@podlibre.org", + "homepage": "https://podlibre.org/" + } + ], + "description": "PHP implementation for the Podcast Namespace.", + "homepage": "https://code.podlibre.org/podlibre/podcastnamespace", + "time": "2021-01-14T15:47:06+00:00" + }, { "name": "psr/cache", "version": "1.0.1",