feat(public-ui): adapt public podcast and episode pages to wireframes

- adapt wireframes with responsive design
- refactor models methods to cache requests for faster queries
- update public controllers to cache pages while retaining analytics hits
- add platform links to podcast page
- add previous / next episodes in episode page
- update npm packages to latest versions

closes #30, #13
This commit is contained in:
Yassine Doghri 2020-09-04 09:09:26 +00:00
parent 2517808cd4
commit 40a0535fc1
32 changed files with 1977 additions and 1393 deletions

View File

@ -26,7 +26,7 @@ class Contributor extends BaseController
public function _remap($method, ...$params)
{
$this->podcast = (new PodcastModel())->find($params[0]);
$this->podcast = (new PodcastModel())->getPodcastById($params[0]);
if (count($params) > 1) {
if (

View File

@ -25,7 +25,7 @@ class Episode extends BaseController
public function _remap($method, ...$params)
{
$this->podcast = (new PodcastModel())->find($params[0]);
$this->podcast = (new PodcastModel())->getPodcastById($params[0]);
if (count($params) > 1) {
if (

View File

@ -25,7 +25,11 @@ class Podcast extends BaseController
public function _remap($method, ...$params)
{
if (count($params) > 0) {
if (!($this->podcast = (new PodcastModel())->find($params[0]))) {
if (
!($this->podcast = (new PodcastModel())->getPodcastById(
$params[0]
))
) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
}
@ -58,26 +62,8 @@ class Podcast extends BaseController
{
helper(['form', 'misc']);
$categories = (new CategoryModel())->findAll();
$languages = (new LanguageModel())->findAll();
$languageOptions = array_reduce(
$languages,
function ($result, $language) {
$result[$language->code] = $language->native_name;
return $result;
},
[]
);
$categoryOptions = array_reduce(
$categories,
function ($result, $category) {
$result[$category->id] = lang(
'Podcast.category_options.' . $category->code
);
return $result;
},
[]
);
$languageOptions = (new LanguageModel())->getLanguageOptions();
$categoryOptions = (new CategoryModel())->getCategoryOptions();
$data = [
'languageOptions' => $languageOptions,
@ -157,26 +143,8 @@ class Podcast extends BaseController
{
helper(['form', 'misc']);
$categories = (new CategoryModel())->findAll();
$languages = (new LanguageModel())->findAll();
$languageOptions = array_reduce(
$languages,
function ($result, $language) {
$result[$language->code] = $language->native_name;
return $result;
},
[]
);
$categoryOptions = array_reduce(
$categories,
function ($result, $category) {
$result[$category->id] = lang(
'Podcast.category_options.' . $category->code
);
return $result;
},
[]
);
$languageOptions = (new LanguageModel())->getLanguageOptions();
$categoryOptions = (new CategoryModel())->getCategoryOptions();
$data = [
'languageOptions' => $languageOptions,
@ -373,26 +341,8 @@ class Podcast extends BaseController
{
helper('form');
$categories = (new CategoryModel())->findAll();
$languages = (new LanguageModel())->findAll();
$languageOptions = array_reduce(
$languages,
function ($result, $language) {
$result[$language->code] = $language->native_name;
return $result;
},
[]
);
$categoryOptions = array_reduce(
$categories,
function ($result, $category) {
$result[$category->id] = lang(
'Podcast.category_options.' . $category->code
);
return $result;
},
[]
);
$languageOptions = (new LanguageModel())->getLanguageOptions();
$categoryOptions = (new CategoryModel())->getCategoryOptions();
$data = [
'podcast' => $this->podcast,

View File

@ -21,7 +21,9 @@ class PodcastSettings extends BaseController
public function _remap($method, ...$params)
{
if (!($this->podcast = (new PodcastModel())->find($params[0]))) {
if (
!($this->podcast = (new PodcastModel())->getPodcastById($params[0]))
) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
unset($params[0]);
@ -40,7 +42,9 @@ class PodcastSettings extends BaseController
$data = [
'podcast' => $this->podcast,
'platforms' => (new PlatformModel())->getPlatformsWithLinks(),
'platforms' => (new PlatformModel())->getPlatformsWithLinks(
$this->podcast->id
),
];
replace_breadcrumb_params([0 => $this->podcast->title]);

View File

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

View File

@ -25,18 +25,14 @@ class Episode extends BaseController
public function _remap($method, ...$params)
{
$this->podcast = (new PodcastModel())
->where('name', $params[0])
->first();
$this->podcast = (new PodcastModel())->getPodcastByName($params[0]);
if (
count($params) > 1 &&
!($this->episode = (new EpisodeModel())
->where([
'podcast_id' => $this->podcast->id,
'slug' => $params[1],
])
->first())
!($this->episode = (new EpisodeModel())->getEpisodeBySlug(
$this->podcast->id,
$params[1]
))
) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
@ -46,15 +42,31 @@ class Episode extends BaseController
public function index()
{
// The page cache is set to a decade so it is deleted manually upon podcast update
$this->cachePage(DECADE);
self::triggerWebpageHit($this->episode->podcast_id);
self::triggerWebpageHit($this->podcast->id);
if (
!($cachedView = cache(
"page_podcast{$this->episode->podcast_id}_episode{$this->episode->id}"
))
) {
$previousNextEpisodes = (new EpisodeModel())->getPreviousNextEpisodes(
$this->episode,
$this->podcast->type
);
$data = [
'podcast' => $this->podcast,
'episode' => $this->episode,
];
return view('episode', $data);
$data = [
'previousEpisode' => $previousNextEpisodes['previous'],
'nextEpisode' => $previousNextEpisodes['next'],
'episode' => $this->episode,
];
// The page cache is set to a decade so it is deleted manually upon podcast update
return view('episode', $data, [
'cache' => DECADE,
'cache_name' => "page_podcast{$this->episode->podcast_id}_episode{$this->episode->id}",
]);
}
return $cachedView;
}
}

View File

@ -8,6 +8,7 @@
namespace App\Controllers;
use App\Models\EpisodeModel;
use App\Models\PodcastModel;
class Podcast extends BaseController
@ -21,9 +22,9 @@ class Podcast extends BaseController
{
if (count($params) > 0) {
if (
!($this->podcast = (new PodcastModel())
->where('name', $params[0])
->first())
!($this->podcast = (new PodcastModel())->getPodcastByName(
$params[0]
))
) {
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
}
@ -34,15 +35,100 @@ class Podcast extends BaseController
public function index()
{
// The page cache is set to a decade so it is deleted manually upon podcast update
$this->cachePage(DECADE);
self::triggerWebpageHit($this->podcast->id);
$data = [
'podcast' => $this->podcast,
'episodes' => $this->podcast->episodes,
];
return view('podcast', $data);
$yearQuery = $this->request->getGet('year');
$seasonQuery = $this->request->getGet('season');
if (!$yearQuery and !$seasonQuery) {
$defaultQuery = (new EpisodeModel())->getDefaultQuery(
$this->podcast->id
);
if ($defaultQuery['type'] == 'season') {
$seasonQuery = $defaultQuery['data']['season_number'];
} elseif ($defaultQuery['type'] == 'year') {
$yearQuery = $defaultQuery['data']['year'];
}
}
$cacheName = implode(
'_',
array_filter([
'page',
"podcast{$this->podcast->id}",
$yearQuery,
$seasonQuery ? 'season' . $seasonQuery : null,
])
);
if (!($found = cache($cacheName))) {
// The page cache is set to a decade so it is deleted manually upon podcast update
// $this->cachePage(DECADE);
$episodeModel = new EpisodeModel();
// Build navigation array
$years = $episodeModel->getYears($this->podcast->id);
$seasons = $episodeModel->getSeasons($this->podcast->id);
$episodesNavigation = [];
$activeQuery = null;
foreach ($years as $year) {
$isActive = $yearQuery == $year['year'];
if ($isActive) {
$activeQuery = ['type' => 'year', 'value' => $year['year']];
}
array_push($episodesNavigation, [
'label' => $year['year'],
'number_of_episodes' => $year['number_of_episodes'],
'route' =>
route_to('podcast', $this->podcast->name) .
'?year=' .
$year['year'],
'is_active' => $isActive,
]);
}
foreach ($seasons as $season) {
$isActive = $seasonQuery == $season['season_number'];
if ($isActive) {
$activeQuery = [
'type' => 'season',
'value' => $season['season_number'],
];
}
array_push($episodesNavigation, [
'label' => lang('Podcast.season', [
'seasonNumber' => $season['season_number'],
]),
'number_of_episodes' => $season['number_of_episodes'],
'route' =>
route_to('podcast', $this->podcast->name) .
'?season=' .
$season['season_number'],
'is_active' => $isActive,
]);
}
$data = [
'podcast' => $this->podcast,
'episodesNav' => $episodesNavigation,
'activeQuery' => $activeQuery,
'episodes' => (new EpisodeModel())->getPodcastEpisodes(
$this->podcast->id,
$this->podcast->type,
$yearQuery,
$seasonQuery
),
];
return view('podcast', $data, [
'cache' => DECADE,
'cache_name' => $cacheName,
]);
}
return $found;
}
}

View File

@ -45,6 +45,22 @@ class AddEpisodes extends Migration
'type' => 'VARCHAR',
'constraint' => 1024,
],
'enclosure_duration' => [
'type' => 'INT',
'constraint' => 10,
'unsigned' => true,
'comment' => 'Playtime in seconds',
],
'enclosure_mimetype' => [
'type' => 'VARCHAR',
'constraint' => 255,
],
'enclosure_filesize' => [
'type' => 'INT',
'constraint' => 10,
'unsigned' => true,
'comment' => 'File size in bytes',
],
'description' => [
'type' => 'TEXT',
'null' => true,

View File

@ -36,6 +36,7 @@ class AddAnalyticsEpisodesByCountry extends Migration
'country_code' => [
'type' => 'VARCHAR',
'constraint' => 3,
'comment' => 'ISO 3166-1 code.',
],
'date' => [
'type' => 'date',

View File

@ -54,11 +54,6 @@ class Episode extends Entity
*/
protected $enclosure_url;
/**
* @var array
*/
protected $enclosure_metadata;
/**
* @var string
*/
@ -76,6 +71,9 @@ class Episode extends Entity
'slug' => 'string',
'title' => 'string',
'enclosure_uri' => 'string',
'enclosure_duration' => 'integer',
'enclosure_mimetype' => 'string',
'enclosure_filesize' => 'integer',
'description' => 'string',
'image_uri' => '?string',
'explicit' => 'boolean',
@ -106,19 +104,6 @@ class Episode extends Entity
$this->getPodcast()->name,
$this->attributes['slug']
);
} elseif (
$APICdata = $this->getEnclosureMetadata()['attached_picture']
) {
// if the user didn't input an image,
// check if the uploaded audio file has an attached cover and store it
$cover_image = new \CodeIgniter\Files\File('episode_cover');
file_put_contents($cover_image, $APICdata);
$this->attributes['image_uri'] = save_podcast_media(
$cover_image,
$this->getPodcast()->name,
$this->attributes['slug']
);
}
return $this;
@ -155,13 +140,22 @@ class Episode extends Entity
(!($enclosure instanceof \CodeIgniter\HTTP\Files\UploadedFile) ||
$enclosure->isValid())
) {
helper('media');
helper(['media', 'id3']);
$enclosure_metadata = get_file_tags($enclosure);
$this->attributes['enclosure_uri'] = save_podcast_media(
$enclosure,
$this->getPodcast()->name,
$this->attributes['slug']
);
$this->attributes['enclosure_duration'] = round(
$enclosure_metadata['playtime_seconds']
);
$this->attributes['enclosure_mimetype'] =
$enclosure_metadata['mime_type'];
$this->attributes['enclosure_filesize'] =
$enclosure_metadata['filesize'];
return $this;
}
@ -191,13 +185,6 @@ class Episode extends Entity
);
}
public function getEnclosureMetadata()
{
helper('id3');
return get_file_tags($this->getEnclosure());
}
public function getLink()
{
return base_url(
@ -218,7 +205,9 @@ class Episode extends Entity
public function getPodcast()
{
return (new PodcastModel())->find($this->attributes['podcast_id']);
return (new PodcastModel())->getPodcastById(
$this->attributes['podcast_id']
);
}
public function getDescriptionHtml()

View File

@ -8,7 +8,9 @@
namespace App\Entities;
use App\Models\CategoryModel;
use App\Models\EpisodeModel;
use App\Models\PlatformModel;
use CodeIgniter\Entity;
use App\Models\UserModel;
use League\CommonMark\CommonMarkConverter;
@ -40,6 +42,11 @@ class Podcast extends Entity
*/
protected $episodes;
/**
* @var \App\Entities\Category
*/
protected $category;
/**
* @var \App\Entities\User[]
*/
@ -50,6 +57,11 @@ class Podcast extends Entity
*/
protected $description_html;
/**
* @var \App\Entities\Platform
*/
protected $platforms;
protected $casts = [
'id' => 'integer',
'title' => 'string',
@ -134,13 +146,34 @@ class Podcast extends Entity
if (empty($this->episodes)) {
$this->episodes = (new EpisodeModel())->getPodcastEpisodes(
$this->id
$this->id,
$this->type
);
}
return $this->episodes;
}
/**
* Returns the podcast category entity
*
* @return \App\Entities\Category
*/
public function getCategory()
{
if (empty($this->id)) {
throw new \RuntimeException(
'Podcast must be created before getting category.'
);
}
if (empty($this->category)) {
$this->category = (new CategoryModel())->find($this->category_id);
}
return $this->category;
}
/**
* Returns all podcast contributors
*
@ -186,4 +219,26 @@ class Podcast extends Entity
return $this;
}
/**
* Returns the podcast's platform links
*
* @return \App\Entities\Platform[]
*/
public function getPlatforms()
{
if (empty($this->id)) {
throw new \RuntimeException(
'Podcast must be created before getting platform links.'
);
}
if (empty($this->platforms)) {
$this->platforms = (new PlatformModel())->getPodcastPlatformLinks(
$this->id
);
}
return $this->platforms;
}
}

View File

@ -63,7 +63,9 @@ class User extends \Myth\Auth\Entities\User
}
if (empty($this->podcast)) {
$this->podcast = (new PodcastModel())->find($this->podcast_id);
$this->podcast = (new PodcastModel())->getPodcastById(
$this->podcast_id
);
}
return $this->podcast;

View File

@ -25,9 +25,6 @@ function get_file_tags($file)
'filesize' => $FileInfo['filesize'],
'mime_type' => $FileInfo['mime_type'],
'playtime_seconds' => $FileInfo['playtime_seconds'],
'attached_picture' => array_key_exists('comments', $FileInfo)
? $FileInfo['comments']['picture'][0]['data']
: null,
];
}

View File

@ -11,17 +11,20 @@ use App\Models\PageModel;
/**
* Returns instance pages as links inside nav tag
*
* @param string $class
* @return string html pages navigation
*/
function render_page_links()
function render_page_links($class = null)
{
$pages = (new PageModel())->findAll();
$links = '';
$links = anchor(route_to('home'), lang('Common.home'), [
'class' => 'px-2 underline hover:no-underline',
]);
foreach ($pages as $page) {
$links .= anchor($page->link, $page->title, [
'class' => 'px-2 underline hover:no-underline',
]);
}
return '<nav class="inline-flex">' . $links . '</nav>';
return '<nav class="' . $class . '">' . $links . '</nav>';
}

View File

@ -115,10 +115,9 @@ function get_rss_feed($podcast)
$item->addChild('title', $episode->title);
$enclosure = $item->addChild('enclosure');
$enclosure_metadata = $episode->enclosure_metadata;
$enclosure->addAttribute('url', $episode->enclosure_url);
$enclosure->addAttribute('length', $enclosure_metadata['filesize']);
$enclosure->addAttribute('type', $enclosure_metadata['mime_type']);
$enclosure->addAttribute('length', $episode->enclosure_filesize);
$enclosure->addAttribute('type', $episode->enclosure_mimetype);
$item->addChild('guid', $episode->guid);
$item->addChild(
@ -128,7 +127,7 @@ function get_rss_feed($podcast)
$item->addChildWithCDATA('description', $episode->description_html);
$item->addChild(
'duration',
$enclosure_metadata['playtime_seconds'],
$episode->enclosure_duration,
$itunes_namespace
);
$item->addChild('link', $episode->link);

View File

@ -0,0 +1,15 @@
<?php
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
return [
'home' => 'Home',
'explicit' => 'Explicit',
'mediumDate' => '{0,date,medium}',
'duration' => '{0,duration}',
'powered_by' => 'Powered by {castopod}.',
];

View File

@ -7,6 +7,12 @@
*/
return [
'previous_episode' => 'Previous episode',
'previous_season' => 'Previous season',
'next_episode' => 'Next episode',
'next_season' => 'Next season',
'season' => 'Season {seasonNumber}',
'number' => 'Episode {episodeNumber}',
'all_podcast_episodes' => 'All podcast episodes',
'back_to_podcast' => 'Go back to podcast',
'edit' => 'Edit',

View File

@ -219,6 +219,11 @@ return [
'film_reviews' => 'Film Reviews',
'tv_reviews' => 'TV Reviews',
],
'list_of_episodes' => 'List of episodes',
'no_episode' => 'No episode found',
'by' => 'By {author}',
'season' => 'Season {seasonNumber}',
'list_of_episodes_year' => '{year} episodes',
'list_of_episodes_season' => 'Season {seasonNumber} episodes',
'no_episode' => 'No episode found!',
'no_episode_hint' =>
'Navigate the podcast episodes with the navigation bar above.',
];

View File

@ -31,4 +31,26 @@ class CategoryModel extends Model
{
return $this->find($parentId);
}
public function getCategoryOptions()
{
if (!($options = cache('category_options'))) {
$categories = $this->findAll();
$options = array_reduce(
$categories,
function ($result, $category) {
$result[$category->id] = lang(
'Podcast.category_options.' . $category->code
);
return $result;
},
[]
);
cache()->save('category_options', $options, DECADE);
}
return $options;
}
}

View File

@ -21,6 +21,9 @@ class EpisodeModel extends Model
'title',
'slug',
'enclosure_uri',
'enclosure_duration',
'enclosure_mimetype',
'enclosure_filesize',
'description',
'image_uri',
'explicit',
@ -75,7 +78,9 @@ class EpisodeModel extends Model
protected function clearCache(array $data)
{
$episode = (new EpisodeModel())->find(
$episodeModel = new EpisodeModel();
$episode = $episodeModel->find(
is_array($data['id']) ? $data['id'][0] : $data['id']
);
@ -85,26 +90,213 @@ class EpisodeModel extends Model
cache()->delete(md5($episode->link));
// delete model requests cache
cache()->delete("{$episode->podcast_id}_episodes");
cache()->delete("podcast{$episode->podcast_id}_episodes");
// delete episode lists cache per year / season
$years = $episodeModel->getYears($episode->podcast_id);
$seasons = $episodeModel->getSeasons($episode->podcast_id);
foreach ($years as $year) {
cache()->delete(
"podcast{$episode->podcast_id}_{$year['year']}_episodes"
);
cache()->delete(
"page_podcast{$episode->podcast_id}_{$year['year']}"
);
}
foreach ($seasons as $season) {
cache()->delete(
"podcast{$episode->podcast_id}_season{$season['season_number']}_episodes"
);
cache()->delete(
"page_podcast{$episode->podcast_id}_season{$season['season_number']}"
);
}
cache()->delete("podcast{$episode->podcast_id}_defaultQuery");
cache()->delete("podcast{$episode->podcast_id}_years");
cache()->delete("podcast{$episode->podcast_id}_seasons");
cache()->delete(
"podcast{$episode->podcast_id}_episode@{$episode->slug}"
);
return $data;
}
public function getEpisodeBySlug($podcastId, $episodeSlug)
{
if (!($found = cache("podcast{$podcastId}_episode@{$episodeSlug}"))) {
$found = $this->where([
'podcast_id' => $podcastId,
'slug' => $episodeSlug,
])->first();
cache()->save(
"podcast{$podcastId}_episode@{$episodeSlug}",
$found,
DECADE
);
}
return $found;
}
/**
* Gets all episodes for a podcast
* Gets all episodes for a podcast ordered according to podcast type
* Filtered depending on year or season
*
* @param int $podcastId
*
* @return \App\Entities\Episode[]
*/
public function getPodcastEpisodes(int $podcastId): array
{
if (!($found = cache("{$podcastId}_episodes"))) {
$found = $this->where('podcast_id', $podcastId)->findAll();
public function getPodcastEpisodes(
int $podcastId,
string $podcastType,
string $year = null,
string $season = null
): array {
$cacheName = implode(
'_',
array_filter([
"podcast{$podcastId}",
$year,
$season ? 'season' . $season : null,
'episodes',
])
);
cache()->save("{$podcastId}_episodes", $found, 300);
if (!($found = cache($cacheName))) {
$where = ['podcast_id' => $podcastId];
if ($year) {
$where['YEAR(published_at)'] = $year;
$where['season_number'] = null;
}
if ($season) {
$where['season_number'] = $season;
}
if ($podcastType == 'serial') {
// podcast is serial
$found = $this->where($where)
->orderBy('season_number DESC, number ASC')
->findAll();
} else {
$found = $this->where($where)
->orderBy('published_at', 'DESC')
->findAll();
}
cache()->save($cacheName, $found, DECADE);
}
return $found;
}
public function getYears(int $podcastId): array
{
if (!($found = cache("podcast{$podcastId}_years"))) {
$found = $this->select(
'YEAR(published_at) as year, count(*) as number_of_episodes'
)
->where(['podcast_id' => $podcastId, 'season_number' => null])
->groupBy('year')
->orderBy('year', 'DESC')
->get()
->getResultArray();
cache()->save("podcast{$podcastId}_years", $found, DECADE);
}
return $found;
}
public function getSeasons(int $podcastId): array
{
if (!($found = cache("podcast{$podcastId}_seasons"))) {
$found = $this->select(
'season_number, count(*) as number_of_episodes'
)
->where([
'podcast_id' => $podcastId,
'season_number is not' => null,
])
->groupBy('season_number')
->orderBy('season_number', 'ASC')
->get()
->getResultArray();
cache()->save("podcast{$podcastId}_seasons", $found, DECADE);
}
return $found;
}
/**
* Returns the default query for displaying the episode list on the podcast page
*/
public function getDefaultQuery(int $podcastId)
{
if (!($defaultQuery = cache("podcast{$podcastId}_defaultQuery"))) {
$seasons = $this->getSeasons($podcastId);
if (!empty($seasons)) {
// get latest season
$defaultQuery = ['type' => 'season', 'data' => end($seasons)];
} else {
$years = $this->getYears($podcastId);
if (!empty($years)) {
// get most recent year
$defaultQuery = ['type' => 'year', 'data' => $years[0]];
} else {
$defaultQuery = null;
}
}
cache()->save(
"podcast{$podcastId}_defaultQuery",
$defaultQuery,
DECADE
);
}
return $defaultQuery;
}
/**
* Returns the previous episode based on episode ordering
*/
public function getPreviousNextEpisodes($episode, $podcastType)
{
$sortNumberField =
$podcastType == 'serial'
? 'if(isnull(season_number),0,season_number)*1000+number'
: 'UNIX_TIMESTAMP(published_at)';
$sortNumberValue =
$podcastType == 'serial'
? (empty($episode->season_number)
? 0
: $episode->season_number) *
1000 +
$episode->number
: strtotime($episode->published_at);
$previousData = $this->orderBy('(' . $sortNumberField . ') DESC')
->where([
'podcast_id' => $episode->podcast_id,
$sortNumberField . ' <' => $sortNumberValue,
])
->first();
$nextData = $this->orderBy('(' . $sortNumberField . ') ASC')
->where([
'podcast_id' => $episode->podcast_id,
$sortNumberField . ' >' => $sortNumberValue,
])
->first();
return [
'previous' => $previousData,
'next' => $nextData,
];
}
}

View File

@ -21,4 +21,24 @@ class LanguageModel extends Model
protected $useSoftDeletes = false;
protected $useTimestamps = false;
public function getLanguageOptions()
{
if (!($options = cache('language_options'))) {
$languages = $this->findAll();
$options = array_reduce(
$languages,
function ($result, $language) {
$result[$language->code] = $language->native_name;
return $result;
},
[]
);
cache()->save('language_options', $options, DECADE);
}
return $options;
}
}

View File

@ -36,21 +36,49 @@ class PlatformModel extends Model
protected $useTimestamps = true;
public function getPlatformsWithLinks()
public function getPlatformsWithLinks($podcastId)
{
return $this->select(
'platforms.*, platform_links.link_url, platform_links.visible'
)
->join(
'platform_links',
'platform_links.platform_id = platforms.id',
'left'
if (!($found = cache("podcast{$podcastId}_platforms"))) {
$found = $this->select(
'platforms.*, platform_links.link_url, platform_links.visible'
)
->findAll();
->join(
'platform_links',
"platform_links.platform_id = platforms.id AND platform_links.podcast_id = $podcastId",
'left'
)
->findAll();
cache()->save("podcast{$podcastId}_platforms", $found, DECADE);
}
return $found;
}
public function getPodcastPlatformLinks($podcastId)
{
if (!($found = cache("podcast{$podcastId}_platformLinks"))) {
$found = $this->select(
'platforms.*, platform_links.link_url, platform_links.visible'
)
->join(
'platform_links',
'platform_links.platform_id = platforms.id'
)
->where('platform_links.podcast_id', $podcastId)
->findAll();
cache()->save("podcast{$podcastId}_platformLinks", $found, DECADE);
}
return $found;
}
public function savePlatformLinks($podcastId, $platformLinksData)
{
cache()->delete("podcast{$podcastId}_platforms");
cache()->delete("podcast{$podcastId}_platformLinks");
// Remove already previously set platforms to overwrite them
$this->db
->table('platform_links')
@ -81,6 +109,9 @@ class PlatformModel extends Model
public function removePlatformLink($podcastId, $platformId)
{
cache()->delete("podcast{$podcastId}_platforms");
cache()->delete("podcast{$podcastId}_platformLinks");
return $this->db->table('platform_links')->delete([
'podcast_id' => $podcastId,
'platform_id' => $platformId,

View File

@ -62,63 +62,99 @@ class PodcastModel extends Model
protected $beforeUpdate = ['clearCache'];
protected $beforeDelete = ['clearCache'];
public function getPodcastByName($podcastName)
{
if (!($found = cache("podcast@{$podcastName}"))) {
$found = $this->where('name', $podcastName)->first();
cache()->save("podcast@{$podcastName}", $found, DECADE);
}
return $found;
}
public function getPodcastById($podcastId)
{
if (!($found = cache("podcast{$podcastId}"))) {
$found = $this->find($podcastId);
cache()->save("podcast{$podcastId}", $found, DECADE);
}
return $found;
}
/**
* Gets all the podcasts a given user is contributing to
*
* @param int $user_id
* @param int $userId
*
* @return \App\Entities\Podcast[] podcasts
*/
public function getUserPodcasts($user_id)
public function getUserPodcasts($userId)
{
return $this->select('podcasts.*')
->join('users_podcasts', 'users_podcasts.podcast_id = podcasts.id')
->where('users_podcasts.user_id', $user_id)
->findAll();
if (!($found = cache("user{$userId}_podcasts"))) {
$found = $this->select('podcasts.*')
->join(
'users_podcasts',
'users_podcasts.podcast_id = podcasts.id'
)
->where('users_podcasts.user_id', $userId)
->findAll();
cache()->save("user{$userId}_podcasts", $found, DECADE);
}
return $found;
}
public function addPodcastContributor($user_id, $podcast_id, $group_id)
public function addPodcastContributor($userId, $podcastId, $groupId)
{
cache()->delete("podcast{$podcastId}_contributors");
$data = [
'user_id' => (int) $user_id,
'podcast_id' => (int) $podcast_id,
'group_id' => (int) $group_id,
'user_id' => (int) $userId,
'podcast_id' => (int) $podcastId,
'group_id' => (int) $groupId,
];
return $this->db->table('users_podcasts')->insert($data);
}
public function updatePodcastContributor($user_id, $podcast_id, $group_id)
public function updatePodcastContributor($userId, $podcastId, $groupId)
{
cache()->delete("podcast{$podcastId}_contributors");
return $this->db
->table('users_podcasts')
->where([
'user_id' => (int) $user_id,
'podcast_id' => (int) $podcast_id,
'user_id' => (int) $userId,
'podcast_id' => (int) $podcastId,
])
->update(['group_id' => $group_id]);
->update(['group_id' => $groupId]);
}
public function removePodcastContributor($user_id, $podcast_id)
public function removePodcastContributor($userId, $podcastId)
{
cache()->delete("podcast{$podcastId}_contributors");
return $this->db
->table('users_podcasts')
->where([
'user_id' => $user_id,
'podcast_id' => $podcast_id,
'user_id' => $userId,
'podcast_id' => $podcastId,
])
->delete();
}
public function getContributorGroupId($user_id, $podcast_id)
public function getContributorGroupId($userId, $podcastId)
{
// TODO: return only the group id
$user_podcast = $this->db
->table('users_podcasts')
->select('group_id')
->where([
'user_id' => $user_id,
'podcast_id' => $podcast_id,
'user_id' => $userId,
'podcast_id' => $podcastId,
])
->get()
->getResultObject();
@ -130,7 +166,7 @@ class PodcastModel extends Model
protected function clearCache(array $data)
{
$podcast = (new PodcastModel())->find(
$podcast = (new PodcastModel())->getPodcastById(
is_array($data['id']) ? $data['id'][0] : $data['id']
);
@ -143,6 +179,10 @@ class PodcastModel extends Model
cache()->delete(md5($episode->link));
}
// delete model requests cache
cache()->delete("podcast{$podcast->id}");
cache()->delete("podcast@{$podcast->name}");
return $data;
}
}

View File

@ -1,18 +1,33 @@
<?php
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Models;
class UserModel extends \Myth\Auth\Models\UserModel
{
protected $returnType = \App\Entities\User::class;
public function getPodcastContributors($podcast_id)
public function getPodcastContributors($podcastId)
{
return $this->select('users.*, auth_groups.name as podcast_role')
->join('users_podcasts', 'users_podcasts.user_id = users.id')
->join('auth_groups', 'auth_groups.id = users_podcasts.group_id')
->where('users_podcasts.podcast_id', $podcast_id)
->findAll();
if (!($found = cache("podcast{$podcastId}_contributors"))) {
$found = $this->select('users.*, auth_groups.name as podcast_role')
->join('users_podcasts', 'users_podcasts.user_id = users.id')
->join(
'auth_groups',
'auth_groups.id = users_podcasts.group_id'
)
->where('users_podcasts.podcast_id', $podcastId)
->findAll();
cache()->save("podcast{$podcastId}_contributors", $found, DECADE);
}
return $found;
}
public function getPodcastContributor($user_id, $podcast_id)

View File

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

After

Width:  |  Height:  |  Size: 214 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M13.172 12l-4.95-4.95 1.414-1.414L16 12l-6.364 6.364-1.414-1.414z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 217 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g>
<path fill="none" d="M0 0h24v24H0z"/>
<path d="M3 17a4 4 0 0 1 4 4H3v-4zm0-7c6.075 0 11 4.925 11 11h-2a9 9 0 0 0-9-9v-2zm0-7c9.941 0 18 8.059 18 18h-2c0-8.837-7.163-16-16-16V3z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 281 B

View File

@ -1,11 +1,103 @@
<?= $this->extend('_layout') ?>
<?= helper('page') ?>
<!DOCTYPE html>
<html lang="en">
<?= $this->section('title') ?>
<?= $episode->title ?>
<?= $this->endSection() ?>
<head>
<meta charset="UTF-8"/>
<title><?= $episode->title ?></title>
<meta name="description" content="Castopod is an open-source hosting platform made for podcasters who want engage and interact with their audience."/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="shortcut icon" type="image/png" href="/favicon.ico" />
<link rel="stylesheet" href="/assets/index.css"/>
</head>
<?= $this->section('content') ?>
<body class="flex flex-col min-h-screen mx-auto">
<header class="border-b bg-gradient-to-tr from-gray-900 to-gray-800">
<div class="container flex items-start px-2 py-2 mx-auto">
<img class="w-12 h-12 mr-2 rounded cover" src="<?= $episode->podcast
->image_url ?>" alt="<?= $episode->podcast->title ?>" />
<a href="<?= route_to(
'podcast',
$episode->podcast->name
) ?>" class="flex flex-col text-lg leading-tight text-white" title="<?= lang(
'Episode.back_to_podcast'
) ?>">
<?= $episode->podcast->title ?>
<span class="text-sm text-gray-300">
@<?= $episode->podcast->name ?>
</span>
</a>
</div>
</header>
<main class="container flex-1 mx-auto">
<nav class="flex items-center px-2 py-4">
<?php if ($previousEpisode): ?>
<a class="flex items-center text-xs leading-snug text-gray-600 hover:text-gray-900" href="<?= $previousEpisode->link ?>" title="<?= $previousEpisode->title ?>">
<?= icon('arrow-left', 'mr-2') ?>
<div class="flex flex-col">
<?= $previousEpisode->season_number ==
$episode->season_number
? lang('Episode.previous_episode')
: lang('Episode.previous_season') ?>
<span class="w-40 font-semibold truncate"><?= $previousEpisode->title ?></span>
</div>
</a>
<?php endif; ?>
<?php if ($nextEpisode): ?>
<a class="flex items-center ml-auto text-xs leading-snug text-right text-gray-600 hover:text-gray-900" href="<?= $nextEpisode->link ?>" title="<?= $nextEpisode->title ?>">
<div class="flex flex-col">
<?= $nextEpisode->season_number ==
$episode->season_number
? lang('Episode.next_episode')
: lang('Episode.next_season') ?>
<span class="w-40 font-semibold truncate"><?= $nextEpisode->title ?></span>
</div>
<?= icon('arrow-right', 'ml-2') ?>
</a>
<?php endif; ?>
</nav>
<header class="flex flex-col items-center px-4 md:items-stretch md:justify-center md:flex-row">
<img src="<?= $episode->image_url ?>" alt="<?= $episode->title ?>" class="object-cover w-full max-w-xs mb-2 rounded-lg md:mb-0 md:mr-4" />
<div class="flex flex-col w-full max-w-sm">
<h1 class="text-lg font-semibold md:text-2xl"><?= $episode->title ?></h1>
<?php if ($episode->number): ?>
<p class="text-gray-600">
<?php if ($episode->season_number): ?>
<a class="mr-1 underline hover:no-underline" href="<?= route_to(
'podcast',
$episode->podcast->name
) .
'?season=' .
$episode->season_number ?>">
<?= lang('Episode.season', [
'seasonNumber' => $episode->season_number,
]) ?></a>
<?php endif; ?>
<?= lang('Episode.number', [
'episodeNumber' => $episode->number,
]) ?>
</p>
<?php endif; ?>
<div class="text-sm">
<time
pubdate
datetime="<?= $episode->published_at->toDateTimeString() ?>"
title="<?= $episode->published_at ?>">
<?= lang('Common.mediumDate', [$episode->published_at]) ?>
</time>
<span class="mx-1"></span>
<time datetime="PT<?= $episode->enclosure_duration ?>S">
<?= lang('Common.duration', [$episode->enclosure_duration]) ?>
</time>
</div>
<audio controls preload="none" class="w-full mt-auto">
<source src="<?= $episode->enclosure_url ?>" type="<?= $episode->enclosure_type ?>">
Your browser does not support the audio tag.
</audio>
</div>
</header>
<<<<<<< HEAD
<a class="underline hover:no-underline" href="<?= route_to(
'podcast',
$podcast->name
@ -27,3 +119,22 @@
<?= $this->endSection() ?>
>>>>>>> 240f1d4... feat: enhance ui using javascript in admin area
=======
<section class="w-full max-w-3xl px-2 py-6 mx-auto prose md:px-6">
<?= $episode->description_html ?>
</section>
</main>
<footer class="px-2 py-4 border-t ">
<div class="container flex flex-col items-center justify-between mx-auto text-sm md:flex-row ">
<?= render_page_links('inline-flex mb-4 md:mb-0') ?>
<div class="flex flex-col items-end text-xs">
<p><?= $episode->podcast->copyright ?></p>
<p><?= lang('Common.powered_by', [
'castopod' =>
'<a class="underline hover:no-underline" href="https://castopod.org" target="_blank" rel="noreferrer noopener">Castopod</a>',
]) ?></p>
</div>
</div>
</footer>
</body>
>>>>>>> ecc68b2... feat(public-ui): adapt wireframes to public podcast and episode pages

View File

@ -1,46 +1,157 @@
<?= $this->extend('_layout') ?>
<?= helper('page') ?>
<?= $this->section('title') ?>
<?= $podcast->title ?>
<?= $this->endSection() ?>
<!DOCTYPE html>
<html lang="<?= $podcast->language ?>">
<?= $this->section('content') ?>
<header class="py-4 border-b">
<h1 class="text-2xl"><?= $podcast->title ?></h1>
<img src="<?= $podcast->image_url ?>" alt="Podcast cover" class="object-cover w-40 h-40 mb-6" />
<a class="inline-flex px-4 py-2 bg-orange-500 hover:bg-orange-600" href="<?= route_to(
'podcast_feed',
$podcast->name
) ?>"><?= lang('Podcast.feed') ?></a>
</header>
<head>
<meta charset="UTF-8"/>
<title><?= $podcast->title ?></title>
<meta name="description" content="Castopod is an open-source hosting platform made for podcasters who want engage and interact with their audience."/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="shortcut icon" type="image/png" href="/favicon.ico" />
<link rel="stylesheet" href="/assets/index.css"/>
<?= $podcast->custom_html_head ?>
</head>
<section class="flex flex-col py-4">
<h2 class="mb-4 text-xl"><?= lang('Podcast.list_of_episodes') ?> (<?= count(
$episodes
) ?>)</h2>
<?php if ($episodes): ?>
<?php foreach ($episodes as $episode): ?>
<article class="flex w-full max-w-lg p-4 mb-4 border shadow">
<img src="<?= $episode->image_url ?>" alt="<?= $episode->title ?>" class="object-cover w-32 h-32 mr-4" />
<div class="flex flex-col flex-1">
<a href="<?= $episode->link ?>">
<h3 class="text-xl font-semibold">
<span class="mr-1 underline hover:no-underline"><?= $episode->title ?></span>
<span class="text-base font-bold text-gray-600">#<?= $episode->number ?></span>
</h3>
</a>
<audio controls class="mt-auto" preload="none">
<source src="<?= $episode->enclosure_url ?>" type="<?= $episode->enclosure_type ?>">
Your browser does not support the audio tag.
</audio>
<body class="flex flex-col min-h-screen">
<main class="flex-1 bg-gray-200">
<header class="border-b bg-gradient-to-tr from-gray-900 to-gray-800">
<div class="flex flex-col items-center justify-center md:items-stretch md:mx-auto md:container md:py-12 md:flex-row ">
<img src="<?= $podcast->image_url ?>" alt="Podcast cover" class="object-cover w-full max-w-xs m-4 rounded-lg shadow-xl" />
<div class="w-full p-4 bg-white md:w-auto md:text-white md:bg-transparent">
<h1 class="text-2xl font-semibold leading-tight"><?= $podcast->title ?> <span class="text-lg font-normal opacity-75">@<?= $podcast->name ?></span></h1>
<div class="flex items-center mb-4">
<address>
<?= lang('Podcast.by', [
'author' => $podcast->author,
]) ?>
</address>
<?= $podcast->explicit
? '<span class="px-1 ml-2 text-xs font-semibold leading-tight tracking-wider uppercase border-2 border-gray-700 rounded md:border-white">' .
lang('Common.explicit') .
'</span>'
: '' ?>
</div>
<div class="inline-flex">
<?= anchor(
route_to('podcast_feed', $podcast->name),
icon('rss', 'mr-2') . lang('Podcast.feed'),
[
'class' =>
'text-white bg-gradient-to-r from-orange-400 to-red-500 hover:to-orange-500 hover:bg-orange-500 inline-flex items-center px-2 py-1 mb-2 font-semibold rounded-lg shadow-md hover:bg-orange-600',
]
) ?>
<?php foreach ($podcast->platforms as $platform): ?>
<a href="<?= $platform->link_url ?>" title="<?= $platform->label ?>" target="_blank" rel="noopener noreferrer" class="ml-2">
<?= platform_icon($platform->icon_filename, 'h-8') ?>
</a>
<?php endforeach; ?>
</div>
<div class="mb-2 opacity-75">
<?= $podcast->description_html ?>
</div>
<span class="px-2 py-1 text-sm text-gray-700 bg-gray-200 rounded">
<?= lang(
'Podcast.category_options.' .
$podcast->category->code
) ?>
</span>
</div>
</article>
<?php endforeach; ?>
<?php else: ?>
<p class="italic"><?= lang('Podcast.no_episode') ?></p>
<?php endif; ?>
</section>
</div>
</header>
<<<<<<< HEAD
<?= $this->endSection()
?>
=======
<section class="flex flex-col">
<nav class="inline-flex justify-center px-4 bg-gray-100 border-b">
<?php foreach ($episodesNav as $link): ?>
<?= anchor(
$link['route'],
$link['label'] .
' (' .
$link['number_of_episodes'] .
')',
[
'class' =>
'px-2 py-1 font-semibold ' .
($link['is_active']
? 'border-b-2 border-gray-600'
: 'text-gray-600 hover:text-gray-900'),
]
) ?>
<?php endforeach; ?>
</nav>
<div class="container py-6 mx-auto">
<?php if ($episodes): ?>
<h1 class="px-4 mb-2 text-xl text-center">
<?php if ($activeQuery['type'] == 'year'): ?>
<?= lang('Podcast.list_of_episodes_year', [
'year' => $activeQuery['value'],
]) ?> (<?= count($episodes) ?>)
<?php elseif ($activeQuery['type'] == 'season'): ?>
<?= lang('Podcast.list_of_episodes_season', [
'seasonNumber' => $activeQuery['value'],
]) ?> (<?= count($episodes) ?>)
<?php endif; ?>
</h1>
<?php foreach ($episodes as $episode): ?>
<article class="flex w-full max-w-lg p-4 mx-auto">
<img loading="lazy" src="<?= $episode->image_url ?>" alt="<?= $episode->title ?>" class="object-cover w-20 h-20 mr-2 rounded-lg" />
<div class="flex flex-col flex-1">
<a class="text-sm hover:underline" href="<?= $episode->link ?>">
<h2 class="inline-flex justify-between w-full font-bold leading-none group">
<span class="mr-1 group-hover:underline"><?= $episode->title ?></span>
<span class="font-bold text-gray-600">#<?= $episode->number ?></span>
</h2>
</a>
<div class="mb-2 text-xs">
<time
pubdate
datetime="<?= $episode->published_at->toDateTimeString() ?>"
title="<?= $episode->published_at ?>">
<?= lang('Common.mediumDate', [
$episode->published_at,
]) ?>
</time>
<span class="mx-1"></span>
<time datetime="PT<?= $episode->enclosure_duration ?>S">
<?= lang('Common.duration', [
$episode->enclosure_duration,
]) ?>
</time>
</div>
<audio controls preload="none" class="w-full mt-auto">
<source src="<?= $episode->enclosure_url ?>" type="<?= $episode->enclosure_type ?>">
Your browser does not support the audio tag.
</audio>
</div>
</article>
<?php endforeach; ?>
<?php else: ?>
<h1 class="px-4 mb-2 text-xl text-center"><?= lang(
'Podcast.no_episode'
) ?></h1>
<p class="italic text-center"><?= lang(
'Podcast.no_episode_hint'
) ?></p>
<?php endif; ?>
</div>
</section>
</main>
<footer class="px-2 py-4 border-t ">
<div class="container flex flex-col items-center justify-between mx-auto text-sm md:flex-row ">
<?= render_page_links('inline-flex mb-4 md:mb-0') ?>
<div class="flex flex-col items-center text-xs md:items-end">
<p><?= $podcast->copyright ?></p>
<p><?= lang('Common.powered_by', [
'castopod' =>
'<a class="underline hover:no-underline" href="https://castopod.org" target="_blank" rel="noreferrer noopener">Castopod</a>',
]) ?></p>
</div>
</div>
</footer>
</body>
>>>>>>> ecc68b2... feat(public-ui): adapt wireframes to public podcast and episode pages

2174
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -25,55 +25,55 @@
},
"dependencies": {
"@popperjs/core": "^2.4.4",
"codemirror": "^5.55.0",
"codemirror": "^5.57.0",
"prosemirror-example-setup": "^1.1.2",
"prosemirror-markdown": "^1.5.0",
"prosemirror-state": "^1.3.3",
"prosemirror-view": "^1.15.2"
"prosemirror-view": "^1.15.5"
},
"devDependencies": {
"@babel/core": "^7.10.5",
"@babel/core": "^7.11.4",
"@babel/plugin-proposal-class-properties": "^7.10.4",
"@babel/preset-env": "^7.10.4",
"@babel/preset-env": "^7.11.0",
"@babel/preset-typescript": "^7.10.4",
"@commitlint/cli": "^9.0.1",
"@commitlint/config-conventional": "^9.0.1",
"@prettier/plugin-php": "^0.14.2",
"@rollup/plugin-babel": "^5.1.0",
"@rollup/plugin-commonjs": "^14.0.0",
"@commitlint/cli": "^9.1.2",
"@commitlint/config-conventional": "^9.1.2",
"@prettier/plugin-php": "^0.14.3",
"@rollup/plugin-babel": "^5.2.0",
"@rollup/plugin-commonjs": "^15.0.0",
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-multi-entry": "^3.0.1",
"@rollup/plugin-node-resolve": "^8.4.0",
"@rollup/plugin-multi-entry": "^4.0.0",
"@rollup/plugin-node-resolve": "^9.0.0",
"@tailwindcss/custom-forms": "^0.2.1",
"@tailwindcss/typography": "^0.2.0",
"@types/codemirror": "0.0.97",
"@types/prosemirror-markdown": "^1.0.3",
"@types/prosemirror-view": "^1.15.0",
"@typescript-eslint/eslint-plugin": "^3.7.0",
"@typescript-eslint/parser": "^3.7.0",
"@types/prosemirror-view": "^1.15.1",
"@typescript-eslint/eslint-plugin": "^3.10.1",
"@typescript-eslint/parser": "^3.10.1",
"cross-env": "^7.0.2",
"cssnano": "^4.1.10",
"cz-conventional-changelog": "^3.2.0",
"eslint": "^7.5.0",
"cz-conventional-changelog": "^3.3.0",
"eslint": "^7.7.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-prettier": "^3.1.4",
"husky": "^4.2.5",
"lint-staged": "^10.2.11",
"lint-staged": "^10.2.13",
"postcss-cli": "^7.1.1",
"postcss-import": "^12.0.1",
"postcss-preset-env": "^6.7.0",
"prettier": "2.0.5",
"prettier": "2.1.1",
"prettier-plugin-organize-imports": "^1.1.1",
"rollup": "^2.23.0",
"rollup": "^2.26.6",
"rollup-plugin-multi-input": "^1.1.1",
"rollup-plugin-node-polyfills": "^0.2.1",
"rollup-plugin-postcss": "^3.1.3",
"rollup-plugin-terser": "^6.1.0",
"rollup-plugin-postcss": "^3.1.6",
"rollup-plugin-terser": "^7.0.0",
"stylelint": "^13.6.1",
"stylelint-config-standard": "^20.0.0",
"svgo": "^1.3.2",
"tailwindcss": "^1.4.6",
"typescript": "^3.9.7"
"tailwindcss": "^1.7.5",
"typescript": "^4.0.2"
},
"husky": {
"hooks": {

View File

@ -5,7 +5,9 @@ module.exports = {
theme: {
extend: {},
},
variants: {},
variants: {
textDecoration: ["responsive", "hover", "focus", "group-hover"],
},
plugins: [
require("@tailwindcss/custom-forms"),
require("@tailwindcss/typography"),