Podcast Pages Overhaul
- Move the Podcasts pages from individual PHP templates into a Vue mini-SPA - Podcasts and episodes are paginated, sortable and searchable - The full podcast page is embeddable in external pages - The podcast player is our standard seekable inline player and persists as you're navigating around the podcasts page
This commit is contained in:
parent
8c4a4b8a76
commit
6deffe0ca2
|
@ -49,6 +49,9 @@ return static function (RouteCollectorProxy $group) {
|
|||
->add(new Middleware\RateLimit('ondemand', 1, 2));
|
||||
|
||||
// Podcast Public Pages
|
||||
$group->get('/podcasts', Controller\Api\Stations\PodcastsController::class . ':listAction')
|
||||
->setName('api:stations:podcasts');
|
||||
|
||||
$group->group(
|
||||
'/podcast/{podcast_id}',
|
||||
function (RouteCollectorProxy $group) {
|
||||
|
@ -145,9 +148,6 @@ return static function (RouteCollectorProxy $group) {
|
|||
$group->group(
|
||||
'',
|
||||
function (RouteCollectorProxy $group) {
|
||||
$group->get('/podcasts', Controller\Api\Stations\PodcastsController::class . ':listAction')
|
||||
->setName('api:stations:podcasts');
|
||||
|
||||
$group->post(
|
||||
'/podcasts',
|
||||
Controller\Api\Stations\PodcastsController::class . ':createAction'
|
||||
|
|
|
@ -57,20 +57,16 @@ return static function (RouteCollectorProxy $app) {
|
|||
$group->get('/schedule[/{embed:embed}]', Controller\Frontend\PublicPages\ScheduleAction::class)
|
||||
->setName('public:schedule');
|
||||
|
||||
$group->get('/podcasts', Controller\Frontend\PublicPages\PodcastsAction::class)
|
||||
->setName('public:podcasts');
|
||||
$routes = [
|
||||
'public:podcasts' => '/podcasts',
|
||||
'public:podcast' => '/podcast/{podcast_id}',
|
||||
'public:podcast:episode' => '/podcast/{podcast_id}/episode/{episode_id}',
|
||||
];
|
||||
|
||||
$group->get(
|
||||
'/podcast/{podcast_id}/episodes',
|
||||
Controller\Frontend\PublicPages\PodcastEpisodesAction::class
|
||||
)
|
||||
->setName('public:podcast:episodes');
|
||||
|
||||
$group->get(
|
||||
'/podcast/{podcast_id}/episode/{episode_id}',
|
||||
Controller\Frontend\PublicPages\PodcastEpisodeAction::class
|
||||
)
|
||||
->setName('public:podcast:episode');
|
||||
foreach ($routes as $routeName => $routePath) {
|
||||
$group->get($routePath, Controller\Frontend\PublicPages\PodcastsAction::class)
|
||||
->setName($routeName);
|
||||
}
|
||||
|
||||
$group->get('/podcast/{podcast_id}/feed', Controller\Frontend\PublicPages\PodcastFeedAction::class)
|
||||
->setName('public:podcast:feed');
|
||||
|
|
|
@ -0,0 +1,180 @@
|
|||
<template>
|
||||
<loading
|
||||
:loading="isLoading"
|
||||
lazy
|
||||
>
|
||||
<div class="card-body">
|
||||
<div class="d-flex">
|
||||
<div class="flex-fill">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item">
|
||||
<router-link :to="{name: 'public:podcasts'}">
|
||||
{{ $gettext('Podcasts') }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li class="breadcrumb-item">
|
||||
{{ podcast.title }}
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h4 class="card-title mb-1">
|
||||
{{ podcast.title }}
|
||||
<br>
|
||||
<small>
|
||||
{{ $gettext('by') }} <a
|
||||
:href="'mailto:'+podcast.email"
|
||||
target="_blank"
|
||||
>{{ podcast.author }}</a>
|
||||
</small>
|
||||
</h4>
|
||||
|
||||
<div class="badges my-2">
|
||||
<span class="badge text-bg-info">
|
||||
{{ podcast.language_name }}
|
||||
</span>
|
||||
<span
|
||||
v-for="category in podcast.categories"
|
||||
:key="category.category"
|
||||
class="badge text-bg-secondary"
|
||||
>
|
||||
{{ category.text }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="card-text">
|
||||
{{ podcast.description }}
|
||||
</p>
|
||||
<div class="buttons">
|
||||
<a
|
||||
class="btn btn-warning btn-sm"
|
||||
:href="podcast.links.public_feed"
|
||||
target="_blank"
|
||||
>
|
||||
<icon :icon="IconRss" />
|
||||
{{ $gettext('RSS') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-shrink ps-3">
|
||||
<album-art
|
||||
:src="podcast.art"
|
||||
:width="128"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<data-table
|
||||
id="podcast-episodes"
|
||||
ref="$datatable"
|
||||
paginated
|
||||
:fields="fields"
|
||||
:api-url="episodesUrl"
|
||||
>
|
||||
<template #cell(play_button)="{item}">
|
||||
<play-button
|
||||
icon-class="lg"
|
||||
:url="item.links.download"
|
||||
/>
|
||||
</template>
|
||||
<template #cell(art)="{item}">
|
||||
<album-art
|
||||
:src="item.art"
|
||||
:width="64"
|
||||
/>
|
||||
</template>
|
||||
<template #cell(title)="{item}">
|
||||
<h5 class="m-0">
|
||||
<router-link
|
||||
:to="{name: 'public:podcast:episode', params: {podcast_id: podcast.id, episode_id: item.id}}"
|
||||
>
|
||||
{{ item.title }}
|
||||
</router-link>
|
||||
</h5>
|
||||
<div class="badges my-2">
|
||||
<span
|
||||
v-if="item.publish_at"
|
||||
class="badge text-bg-secondary"
|
||||
>
|
||||
{{ formatTime(item.publish_at) }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="badge text-bg-secondary"
|
||||
>
|
||||
{{ formatTime(item.created_at) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="item.explicit"
|
||||
class="badge text-bg-danger"
|
||||
>
|
||||
{{ $gettext('Explicit') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="card-text">
|
||||
{{ item.description_short }}
|
||||
</p>
|
||||
</template>
|
||||
<template #cell(actions)="{item}">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<router-link
|
||||
:to="{name: 'public:podcast:episode', params: {podcast_id: podcast.id, episode_id: item.id}}"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
{{ $gettext('Details') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
</data-table>
|
||||
</loading>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {getStationApiUrl} from "~/router.ts";
|
||||
import {useRoute} from "vue-router";
|
||||
import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
|
||||
import useRefreshableAsyncState from "~/functions/useRefreshableAsyncState.ts";
|
||||
import {useAxios} from "~/vendor/axios.ts";
|
||||
import Loading from "~/components/Common/Loading.vue";
|
||||
import AlbumArt from "~/components/Common/AlbumArt.vue";
|
||||
import {useTranslate} from "~/vendor/gettext.ts";
|
||||
import {IconRss} from "~/components/Common/icons.ts";
|
||||
import Icon from "~/components/Common/Icon.vue";
|
||||
import PlayButton from "~/components/Common/PlayButton.vue";
|
||||
import {useLuxon} from "~/vendor/luxon.ts";
|
||||
import {useAzuraCast, useAzuraCastStation} from "~/vendor/azuracast.ts";
|
||||
|
||||
const {params} = useRoute();
|
||||
|
||||
const podcastUrl = getStationApiUrl(`/podcast/${params.podcast_id}`);
|
||||
|
||||
const {axios} = useAxios();
|
||||
const {state: podcast, isLoading} = useRefreshableAsyncState(
|
||||
() => axios.get(podcastUrl.value).then((r) => r.data),
|
||||
{},
|
||||
);
|
||||
|
||||
const episodesUrl = getStationApiUrl(`/podcast/${params.podcast_id}/episodes`);
|
||||
|
||||
const {$gettext} = useTranslate();
|
||||
const fields: DataTableField[] = [
|
||||
{key: 'play_button', label: '', sortable: false, class: 'shrink pe-0'},
|
||||
{key: 'art', label: '', sortable: false, class: 'shrink pe-0'},
|
||||
{key: 'title', label: $gettext('Episode'), sortable: true},
|
||||
{key: 'actions', label: $gettext('Actions'), sortable: false, class: 'shrink'}
|
||||
];
|
||||
|
||||
const {DateTime} = useLuxon();
|
||||
const {timezone} = useAzuraCastStation();
|
||||
const {timeConfig} = useAzuraCast();
|
||||
|
||||
const formatTime = (value) => {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return DateTime.fromSeconds(value).setZone(timezone).toLocaleString(
|
||||
{...DateTime.DATETIME_MED, ...timeConfig}
|
||||
);
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,159 @@
|
|||
<template>
|
||||
<div class="card-body">
|
||||
<loading
|
||||
:loading="podcastLoading || episodeLoading"
|
||||
lazy
|
||||
>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb m-0">
|
||||
<li class="breadcrumb-item">
|
||||
<router-link :to="{name: 'public:podcasts'}">
|
||||
{{ $gettext('Podcasts') }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li class="breadcrumb-item">
|
||||
<router-link :to="{name: 'public:podcast', params: {podcast_id: podcast.id}}">
|
||||
{{ podcast.title }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li class="breadcrumb-item">
|
||||
{{ episode.title }}
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</loading>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="card-body alert alert-secondary"
|
||||
aria-live="polite"
|
||||
>
|
||||
<loading
|
||||
:loading="podcastLoading"
|
||||
lazy
|
||||
>
|
||||
<h4 class="card-title mb-1">
|
||||
{{ podcast.title }}
|
||||
<br>
|
||||
<small>
|
||||
{{ $gettext('by') }} <a
|
||||
:href="'mailto:'+podcast.email"
|
||||
class="alert-link"
|
||||
target="_blank"
|
||||
>{{ podcast.author }}</a>
|
||||
</small>
|
||||
</h4>
|
||||
|
||||
<div class="badges my-2">
|
||||
<span class="badge text-bg-info">
|
||||
{{ podcast.language_name }}
|
||||
</span>
|
||||
<span
|
||||
v-for="category in podcast.categories"
|
||||
:key="category.category"
|
||||
class="badge text-bg-secondary"
|
||||
>
|
||||
{{ category.text }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="card-text">
|
||||
{{ podcast.description }}
|
||||
</p>
|
||||
</loading>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<loading
|
||||
:loading="episodeLoading"
|
||||
lazy
|
||||
>
|
||||
<div class="d-flex">
|
||||
<div class="flex-shrink-0 pe-3">
|
||||
<play-button
|
||||
icon-class="lg"
|
||||
:url="episode.links.download"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-fill">
|
||||
<h4 class="card-title mb-1">
|
||||
{{ episode.title }}
|
||||
</h4>
|
||||
|
||||
<div class="badges my-2">
|
||||
<span
|
||||
v-if="episode.publish_at"
|
||||
class="badge text-bg-secondary"
|
||||
>
|
||||
{{ formatTime(episode.publish_at) }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="badge text-bg-secondary"
|
||||
>
|
||||
{{ formatTime(episode.created_at) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="episode.explicit"
|
||||
class="badge text-bg-danger"
|
||||
>
|
||||
{{ $gettext('Explicit') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="card-text">
|
||||
{{ episode.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-shrink-0 ps-3">
|
||||
<album-art
|
||||
:src="episode.art"
|
||||
:width="96"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</loading>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Loading from "~/components/Common/Loading.vue";
|
||||
import {useRoute} from "vue-router";
|
||||
import {getStationApiUrl} from "~/router.ts";
|
||||
import {useAxios} from "~/vendor/axios.ts";
|
||||
import useRefreshableAsyncState from "~/functions/useRefreshableAsyncState.ts";
|
||||
import AlbumArt from "~/components/Common/AlbumArt.vue";
|
||||
import PlayButton from "~/components/Common/PlayButton.vue";
|
||||
import {useLuxon} from "~/vendor/luxon.ts";
|
||||
import {useAzuraCast, useAzuraCastStation} from "~/vendor/azuracast.ts";
|
||||
|
||||
const {params} = useRoute();
|
||||
|
||||
const podcastUrl = getStationApiUrl(`/podcast/${params.podcast_id}`);
|
||||
const episodeUrl = getStationApiUrl(`/podcast/${params.podcast_id}/episode/${params.episode_id}`);
|
||||
|
||||
const {axios} = useAxios();
|
||||
|
||||
const {state: podcast, isLoading: podcastLoading} = useRefreshableAsyncState(
|
||||
() => axios.get(podcastUrl.value).then((r) => r.data),
|
||||
{},
|
||||
);
|
||||
|
||||
const {state: episode, isLoading: episodeLoading} = useRefreshableAsyncState(
|
||||
() => axios.get(episodeUrl.value).then((r) => r.data),
|
||||
{},
|
||||
);
|
||||
|
||||
const {DateTime} = useLuxon();
|
||||
const {timezone} = useAzuraCastStation();
|
||||
const {timeConfig} = useAzuraCast();
|
||||
|
||||
const formatTime = (value) => {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return DateTime.fromSeconds(value).setZone(timezone).toLocaleString(
|
||||
{...DateTime.DATETIME_MED, ...timeConfig}
|
||||
);
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,85 @@
|
|||
<template>
|
||||
<data-table
|
||||
id="podcasts"
|
||||
ref="$datatable"
|
||||
paginated
|
||||
:fields="fields"
|
||||
:api-url="apiUrl"
|
||||
>
|
||||
<template #cell(art)="{item}">
|
||||
<album-art
|
||||
:src="item.art"
|
||||
:width="96"
|
||||
/>
|
||||
</template>
|
||||
<template #cell(title)="{item}">
|
||||
<h5 class="m-0">
|
||||
<router-link
|
||||
:to="{name: 'public:podcast', params: {podcast_id: item.id}}"
|
||||
>
|
||||
{{ item.title }}
|
||||
</router-link>
|
||||
<br>
|
||||
<small>
|
||||
{{ $gettext('by') }} <a
|
||||
:href="'mailto:'+item.email"
|
||||
target="_blank"
|
||||
>{{ item.author }}</a>
|
||||
</small>
|
||||
</h5>
|
||||
<div class="badges my-2">
|
||||
<span class="badge text-bg-info">
|
||||
{{ item.language_name }}
|
||||
</span>
|
||||
<span
|
||||
v-for="category in item.categories"
|
||||
:key="category.category"
|
||||
class="badge text-bg-secondary"
|
||||
>
|
||||
{{ category.text }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="card-text">
|
||||
{{ item.description_short }}
|
||||
</p>
|
||||
</template>
|
||||
<template #cell(actions)="{item}">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<router-link
|
||||
:to="{name: 'public:podcast', params: {podcast_id: item.id}}"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
{{ $gettext('Episodes') }}
|
||||
</router-link>
|
||||
|
||||
<a
|
||||
class="btn btn-warning"
|
||||
:href="item.links.public_feed"
|
||||
target="_blank"
|
||||
>
|
||||
<icon :icon="IconRss" />
|
||||
{{ $gettext('RSS') }}
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</data-table>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AlbumArt from "~/components/Common/AlbumArt.vue";
|
||||
import DataTable, {DataTableField} from "~/components/Common/DataTable.vue";
|
||||
import {getStationApiUrl} from "~/router.ts";
|
||||
import {useTranslate} from "~/vendor/gettext.ts";
|
||||
import {IconRss} from "~/components/Common/icons.ts";
|
||||
import Icon from "~/components/Common/Icon.vue";
|
||||
|
||||
const apiUrl = getStationApiUrl('/podcasts');
|
||||
|
||||
const {$gettext} = useTranslate();
|
||||
|
||||
const fields: DataTableField[] = [
|
||||
{key: 'art', label: '', sortable: false, class: 'shrink pe-0'},
|
||||
{key: 'title', label: $gettext('Podcast'), sortable: true},
|
||||
{key: 'actions', label: $gettext('Actions'), sortable: false, class: 'shrink'}
|
||||
];
|
||||
</script>
|
|
@ -0,0 +1,32 @@
|
|||
<template>
|
||||
<minimal-layout>
|
||||
<full-height-card>
|
||||
<template #header>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-shrink">
|
||||
<h2 class="card-title py-2">
|
||||
<slot name="title">
|
||||
{{ name }}
|
||||
</slot>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="flex-fill text-end">
|
||||
<inline-player ref="player" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<router-view />
|
||||
</template>
|
||||
</full-height-card>
|
||||
</minimal-layout>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import FullHeightCard from "~/components/Public/FullHeightCard.vue";
|
||||
import InlinePlayer from "~/components/InlinePlayer.vue";
|
||||
import {useAzuraCastStation} from "~/vendor/azuracast.ts";
|
||||
import MinimalLayout from "~/components/MinimalLayout.vue";
|
||||
|
||||
const {name} = useAzuraCastStation();
|
||||
</script>
|
|
@ -0,0 +1,21 @@
|
|||
import {RouteRecordRaw} from "vue-router";
|
||||
|
||||
export default function usePodcastRoutes(): RouteRecordRaw[] {
|
||||
return [
|
||||
{
|
||||
path: '/podcasts',
|
||||
component: () => import('~/components/Public/Podcasts/PodcastList.vue'),
|
||||
name: 'public:podcasts'
|
||||
},
|
||||
{
|
||||
path: '/podcast/:podcast_id',
|
||||
component: () => import('~/components/Public/Podcasts/Podcast.vue'),
|
||||
name: 'public:podcast'
|
||||
},
|
||||
{
|
||||
path: '/podcast/:podcast_id/episode/:episode_id',
|
||||
component: () => import('~/components/Public/Podcasts/PodcastEpisode.vue'),
|
||||
name: 'public:podcast:episode'
|
||||
}
|
||||
];
|
||||
}
|
|
@ -49,8 +49,8 @@
|
|||
<h5 class="m-0">
|
||||
{{ row.item.name }}
|
||||
</h5>
|
||||
<div>
|
||||
<span class="badge text-bg-secondary me-1">
|
||||
<div class="badges">
|
||||
<span class="badge text-bg-secondary">
|
||||
<template v-if="row.item.source === 'songs'">
|
||||
{{ $gettext('Song-based') }}
|
||||
</template>
|
||||
|
@ -60,31 +60,31 @@
|
|||
</span>
|
||||
<span
|
||||
v-if="row.item.is_jingle"
|
||||
class="badge text-bg-primary me-1"
|
||||
class="badge text-bg-primary"
|
||||
>
|
||||
{{ $gettext('Jingle Mode') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.item.source === 'songs' && row.item.order === 'sequential'"
|
||||
class="badge text-bg-info me-1"
|
||||
class="badge text-bg-info"
|
||||
>
|
||||
{{ $gettext('Sequential') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.item.include_in_on_demand"
|
||||
class="badge text-bg-info me-1"
|
||||
class="badge text-bg-info"
|
||||
>
|
||||
{{ $gettext('On-Demand') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.item.schedule_items.length > 0"
|
||||
class="badge text-bg-info me-1"
|
||||
class="badge text-bg-info"
|
||||
>
|
||||
{{ $gettext('Scheduled') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="!row.item.is_enabled"
|
||||
class="badge text-bg-danger me-1"
|
||||
class="badge text-bg-danger"
|
||||
>
|
||||
{{ $gettext('Disabled') }}
|
||||
</span>
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
import initApp from "~/layout";
|
||||
import {h} from "vue";
|
||||
import {createRouter, createWebHistory} from "vue-router";
|
||||
import {useAzuraCast} from "~/vendor/azuracast";
|
||||
import {installRouter} from "~/vendor/router";
|
||||
import PodcastsLayout from "~/components/Public/Podcasts/PodcastsLayout.vue";
|
||||
import usePodcastRoutes from "~/components/Public/Podcasts/routes";
|
||||
|
||||
initApp({
|
||||
render() {
|
||||
return h(PodcastsLayout);
|
||||
}
|
||||
}, async (vueApp) => {
|
||||
const routes = usePodcastRoutes();
|
||||
const {componentProps} = useAzuraCast();
|
||||
|
||||
installRouter(
|
||||
createRouter({
|
||||
history: createWebHistory(componentProps.baseUrl),
|
||||
routes
|
||||
}),
|
||||
vueApp
|
||||
);
|
||||
});
|
|
@ -17,6 +17,7 @@ use App\Http\Response;
|
|||
use App\Http\ServerRequest;
|
||||
use App\OpenApi;
|
||||
use App\Service\Flow\UploadedFile;
|
||||
use App\Utilities\Strings;
|
||||
use App\Utilities\Types;
|
||||
use InvalidArgumentException;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
@ -222,6 +223,15 @@ final class PodcastEpisodesController extends AbstractApiCrudController
|
|||
->orderBy('e.created_at', 'DESC')
|
||||
->setParameter('podcast', $podcast);
|
||||
|
||||
$acl = $request->getAcl();
|
||||
if (!$acl->isAllowed(StationPermissions::Podcasts, $station)) {
|
||||
$queryBuilder = $queryBuilder
|
||||
->andWhere('e.publish_at IS NULL OR e.publish_at <= :publishTime')
|
||||
->setParameter('publishTime', time())
|
||||
->andWhere('pm.id IS NOT NULL')
|
||||
->orderBy('e.publish_at', 'DESC');
|
||||
}
|
||||
|
||||
$queryBuilder = $this->searchQueryBuilder(
|
||||
$request,
|
||||
$queryBuilder,
|
||||
|
@ -309,10 +319,14 @@ final class PodcastEpisodesController extends AbstractApiCrudController
|
|||
$router = $request->getRouter();
|
||||
|
||||
$return = new ApiPodcastEpisode();
|
||||
$return->id = $record->getId();
|
||||
$return->id = $record->getIdRequired();
|
||||
$return->title = $record->getTitle();
|
||||
|
||||
$return->description = $record->getDescription();
|
||||
$return->description_short = Strings::truncateText($return->description, 100);
|
||||
|
||||
$return->explicit = $record->getExplicit();
|
||||
$return->created_at = $record->getCreatedAt();
|
||||
$return->publish_at = $record->getPublishAt();
|
||||
|
||||
$mediaRow = $record->getMedia();
|
||||
|
|
|
@ -7,6 +7,7 @@ namespace App\Controller\Api\Stations;
|
|||
use App\Controller\Api\AbstractApiCrudController;
|
||||
use App\Controller\Api\Traits\CanSearchResults;
|
||||
use App\Entity\Api\Podcast as ApiPodcast;
|
||||
use App\Entity\Api\PodcastCategory as ApiPodcastCategory;
|
||||
use App\Entity\Podcast;
|
||||
use App\Entity\PodcastCategory;
|
||||
use App\Entity\Repository\PodcastRepository;
|
||||
|
@ -15,10 +16,13 @@ use App\Http\Response;
|
|||
use App\Http\ServerRequest;
|
||||
use App\OpenApi;
|
||||
use App\Service\Flow\UploadedFile;
|
||||
use App\Utilities\Strings;
|
||||
use App\Utilities\Types;
|
||||
use InvalidArgumentException;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Symfony\Component\Intl\Exception\MissingResourceException;
|
||||
use Symfony\Component\Intl\Languages;
|
||||
use Symfony\Component\Serializer\Serializer;
|
||||
use Symfony\Component\Validator\Validator\ValidatorInterface;
|
||||
|
||||
|
@ -239,18 +243,40 @@ final class PodcastsController extends AbstractApiCrudController
|
|||
$station = $request->getStation();
|
||||
|
||||
$return = new ApiPodcast();
|
||||
$return->id = $record->getId();
|
||||
$return->storage_location_id = $record->getStorageLocation()->getId();
|
||||
$return->id = $record->getIdRequired();
|
||||
$return->storage_location_id = $record->getStorageLocation()->getIdRequired();
|
||||
|
||||
$return->title = $record->getTitle();
|
||||
$return->link = $record->getLink();
|
||||
|
||||
$return->description = $record->getDescription();
|
||||
$return->description_short = Strings::truncateText($return->description, 200);
|
||||
|
||||
$return->language = $record->getLanguage();
|
||||
try {
|
||||
$locale = $request->getCustomization()->getLocale();
|
||||
$return->language_name = Languages::getName(
|
||||
$return->language,
|
||||
$locale->value
|
||||
);
|
||||
} catch (MissingResourceException) {
|
||||
}
|
||||
|
||||
$return->author = $record->getAuthor();
|
||||
$return->email = $record->getEmail();
|
||||
|
||||
$categories = [];
|
||||
foreach ($record->getCategories() as $category) {
|
||||
$categories[] = $category->getCategory();
|
||||
$categoryRow = new ApiPodcastCategory();
|
||||
$categoryRow->category = $category->getCategory();
|
||||
$categoryRow->title = $category->getTitle();
|
||||
$categoryRow->subtitle = $category->getSubTitle();
|
||||
|
||||
$categoryRow->text = (!empty($categoryRow->subtitle))
|
||||
? $categoryRow->title . ' - ' . $categoryRow->subtitle
|
||||
: $categoryRow->title;
|
||||
|
||||
$categories[] = $categoryRow;
|
||||
}
|
||||
$return->categories = $categories;
|
||||
|
||||
|
@ -287,7 +313,7 @@ final class PodcastsController extends AbstractApiCrudController
|
|||
absolute: !$isInternal
|
||||
),
|
||||
'public_episodes' => $router->fromHere(
|
||||
routeName: 'public:podcast:episodes',
|
||||
routeName: 'public:podcast',
|
||||
routeParams: ['podcast_id' => $record->getId()],
|
||||
absolute: !$isInternal
|
||||
),
|
||||
|
|
|
@ -1,85 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Frontend\PublicPages;
|
||||
|
||||
use App\Controller\SingleActionInterface;
|
||||
use App\Entity\PodcastEpisode;
|
||||
use App\Entity\Repository\PodcastEpisodeRepository;
|
||||
use App\Entity\Repository\PodcastRepository;
|
||||
use App\Exception\NotFoundException;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
final class PodcastEpisodeAction implements SingleActionInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PodcastRepository $podcastRepository,
|
||||
private readonly PodcastEpisodeRepository $episodeRepository
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(
|
||||
ServerRequest $request,
|
||||
Response $response,
|
||||
array $params
|
||||
): ResponseInterface {
|
||||
/** @var string $podcastId */
|
||||
$podcastId = $params['podcast_id'];
|
||||
|
||||
/** @var string $episodeId */
|
||||
$episodeId = $params['episode_id'];
|
||||
|
||||
$router = $request->getRouter();
|
||||
$station = $request->getStation();
|
||||
|
||||
if (!$station->getEnablePublicPage()) {
|
||||
throw NotFoundException::station();
|
||||
}
|
||||
|
||||
$podcast = $this->podcastRepository->fetchPodcastForStation($station, $podcastId);
|
||||
|
||||
if ($podcast === null) {
|
||||
throw NotFoundException::podcast();
|
||||
}
|
||||
|
||||
$episode = $this->episodeRepository->fetchEpisodeForStation($station, $episodeId);
|
||||
|
||||
$podcastEpisodesLink = $router->named(
|
||||
'public:podcast:episodes',
|
||||
[
|
||||
'station_id' => $station->getId(),
|
||||
'podcast_id' => $podcastId,
|
||||
]
|
||||
);
|
||||
|
||||
if (!($episode instanceof PodcastEpisode) || !$episode->isPublished()) {
|
||||
$request->getFlash()->error(__('Episode not found.'));
|
||||
return $response->withRedirect($podcastEpisodesLink);
|
||||
}
|
||||
|
||||
$feedLink = $router->named(
|
||||
'public:podcast:feed',
|
||||
[
|
||||
'station_id' => $station->getId(),
|
||||
'podcast_id' => $podcast->getId(),
|
||||
]
|
||||
);
|
||||
|
||||
return $request->getView()->renderToResponse(
|
||||
$response
|
||||
->withHeader('X-Frame-Options', '*')
|
||||
->withHeader('X-Robots-Tag', 'index, nofollow'),
|
||||
'frontend/public/podcast-episode',
|
||||
[
|
||||
'episode' => $episode,
|
||||
'feedLink' => $feedLink,
|
||||
'podcast' => $podcast,
|
||||
'podcastEpisodesLink' => $podcastEpisodesLink,
|
||||
'station' => $station,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,95 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Frontend\PublicPages;
|
||||
|
||||
use App\Controller\SingleActionInterface;
|
||||
use App\Entity\PodcastEpisode;
|
||||
use App\Entity\Repository\PodcastEpisodeRepository;
|
||||
use App\Entity\Repository\PodcastRepository;
|
||||
use App\Exception\NotFoundException;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
final class PodcastEpisodesAction implements SingleActionInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PodcastRepository $podcastRepository,
|
||||
private readonly PodcastEpisodeRepository $episodeRepository
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(
|
||||
ServerRequest $request,
|
||||
Response $response,
|
||||
array $params
|
||||
): ResponseInterface {
|
||||
/** @var string $podcastId */
|
||||
$podcastId = $params['podcast_id'];
|
||||
|
||||
$router = $request->getRouter();
|
||||
$station = $request->getStation();
|
||||
|
||||
if (!$station->getEnablePublicPage()) {
|
||||
throw NotFoundException::station();
|
||||
}
|
||||
|
||||
$podcast = $this->podcastRepository->fetchPodcastForStation($station, $podcastId);
|
||||
|
||||
if ($podcast === null) {
|
||||
throw NotFoundException::podcast();
|
||||
}
|
||||
|
||||
$publishedEpisodes = $this->episodeRepository->fetchPublishedEpisodesForPodcast($podcast);
|
||||
|
||||
// Reverse sort order according to the calculated publishing timestamp
|
||||
usort(
|
||||
$publishedEpisodes,
|
||||
static function ($prevEpisode, $nextEpisode) {
|
||||
/** @var PodcastEpisode $prevEpisode */
|
||||
/** @var PodcastEpisode $nextEpisode */
|
||||
|
||||
$prevPublishedAt = $prevEpisode->getPublishAt() ?? $prevEpisode->getCreatedAt();
|
||||
$nextPublishedAt = $nextEpisode->getPublishAt() ?? $nextEpisode->getCreatedAt();
|
||||
|
||||
return ($nextPublishedAt <=> $prevPublishedAt);
|
||||
}
|
||||
);
|
||||
|
||||
$podcastsLink = $router->fromHere(
|
||||
'public:podcasts',
|
||||
[
|
||||
'station_id' => $station->getId(),
|
||||
]
|
||||
);
|
||||
|
||||
if (count($publishedEpisodes) === 0) {
|
||||
$request->getFlash()->error(__('No episodes found.'));
|
||||
return $response->withRedirect($podcastsLink);
|
||||
}
|
||||
|
||||
$feedLink = $router->named(
|
||||
'public:podcast:feed',
|
||||
[
|
||||
'station_id' => $station->getId(),
|
||||
'podcast_id' => $podcast->getId(),
|
||||
]
|
||||
);
|
||||
|
||||
return $request->getView()->renderToResponse(
|
||||
$response
|
||||
->withHeader('X-Frame-Options', '*')
|
||||
->withHeader('X-Robots-Tag', 'index, nofollow'),
|
||||
'frontend/public/podcast-episodes',
|
||||
[
|
||||
'episodes' => $publishedEpisodes,
|
||||
'feedLink' => $feedLink,
|
||||
'podcast' => $podcast,
|
||||
'podcastsLink' => $podcastsLink,
|
||||
'station' => $station,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
|
@ -4,8 +4,8 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Controller\Frontend\PublicPages;
|
||||
|
||||
use App\Controller\Frontend\PublicPages\Traits\IsEmbeddable;
|
||||
use App\Controller\SingleActionInterface;
|
||||
use App\Entity\Repository\PodcastRepository;
|
||||
use App\Exception\NotFoundException;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
|
@ -13,10 +13,7 @@ use Psr\Http\Message\ResponseInterface;
|
|||
|
||||
final class PodcastsAction implements SingleActionInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PodcastRepository $podcastRepository
|
||||
) {
|
||||
}
|
||||
use IsEmbeddable;
|
||||
|
||||
public function __invoke(
|
||||
ServerRequest $request,
|
||||
|
@ -29,17 +26,37 @@ final class PodcastsAction implements SingleActionInterface
|
|||
throw NotFoundException::station();
|
||||
}
|
||||
|
||||
$publishedPodcasts = $this->podcastRepository->fetchPublishedPodcastsForStation($station);
|
||||
$pageClass = 'podcasts station-' . $station->getShortName();
|
||||
if ($this->isEmbedded($request, $params)) {
|
||||
$pageClass .= ' embed';
|
||||
}
|
||||
|
||||
return $request->getView()->renderToResponse(
|
||||
$response
|
||||
$router = $request->getRouter();
|
||||
$view = $request->getView();
|
||||
|
||||
// Add station public code.
|
||||
$view->fetch(
|
||||
'frontend/public/partials/station-custom',
|
||||
['station' => $station]
|
||||
);
|
||||
|
||||
return $view->renderVuePage(
|
||||
response: $response
|
||||
->withHeader('X-Frame-Options', '*')
|
||||
->withHeader('X-Robots-Tag', 'index, nofollow'),
|
||||
'frontend/public/podcasts',
|
||||
[
|
||||
'podcasts' => $publishedPodcasts,
|
||||
'station' => $station,
|
||||
]
|
||||
component: 'Public/Podcasts',
|
||||
id: 'podcast',
|
||||
layout: 'minimal',
|
||||
title: 'Podcasts - ' . $station->getName(),
|
||||
layoutParams: [
|
||||
'page_class' => $pageClass,
|
||||
'hide_footer' => true,
|
||||
],
|
||||
props: [
|
||||
'baseUrl' => $router->named('public:index', [
|
||||
'station_id' => $station->getShortName(),
|
||||
]),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,41 +16,50 @@ final class Podcast
|
|||
use HasLinks;
|
||||
|
||||
#[OA\Property]
|
||||
public ?string $id = null;
|
||||
public string $id;
|
||||
|
||||
#[OA\Property]
|
||||
public ?int $storage_location_id = null;
|
||||
public int $storage_location_id;
|
||||
|
||||
#[OA\Property]
|
||||
public ?string $title = null;
|
||||
public string $title;
|
||||
|
||||
#[OA\Property]
|
||||
public ?string $link = null;
|
||||
|
||||
#[OA\Property]
|
||||
public ?string $description = null;
|
||||
public string $description;
|
||||
|
||||
#[OA\Property]
|
||||
public ?string $language = null;
|
||||
public string $description_short;
|
||||
|
||||
#[OA\Property]
|
||||
public ?string $author = null;
|
||||
public string $language;
|
||||
|
||||
#[OA\Property]
|
||||
public ?string $email = null;
|
||||
public string $language_name;
|
||||
|
||||
#[OA\Property]
|
||||
public string $author;
|
||||
|
||||
#[OA\Property]
|
||||
public string $email;
|
||||
|
||||
#[OA\Property]
|
||||
public bool $has_custom_art = false;
|
||||
|
||||
#[OA\Property]
|
||||
public ?string $art = null;
|
||||
public string $art;
|
||||
|
||||
#[OA\Property]
|
||||
public int $art_updated_at = 0;
|
||||
|
||||
/**
|
||||
* @var PodcastCategory[]
|
||||
*/
|
||||
#[OA\Property(
|
||||
type: 'array',
|
||||
items: new OA\Items(type: 'string')
|
||||
items: new OA\Items(type: PodcastCategory::class)
|
||||
)]
|
||||
public array $categories = [];
|
||||
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity\Api;
|
||||
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
#[OA\Schema(
|
||||
schema: 'Api_PodcastCategory',
|
||||
type: 'object'
|
||||
)]
|
||||
final class PodcastCategory
|
||||
{
|
||||
#[OA\Property]
|
||||
public string $category;
|
||||
|
||||
#[OA\Property]
|
||||
public string $text;
|
||||
|
||||
#[OA\Property]
|
||||
public string $title;
|
||||
|
||||
#[OA\Property]
|
||||
public ?string $subtitle = null;
|
||||
}
|
|
@ -16,17 +16,23 @@ final class PodcastEpisode
|
|||
use HasLinks;
|
||||
|
||||
#[OA\Property]
|
||||
public ?string $id = null;
|
||||
public string $id;
|
||||
|
||||
#[OA\Property]
|
||||
public ?string $title = null;
|
||||
public string $title;
|
||||
|
||||
#[OA\Property]
|
||||
public ?string $description = null;
|
||||
public string $description;
|
||||
|
||||
#[OA\Property]
|
||||
public string $description_short;
|
||||
|
||||
#[OA\Property]
|
||||
public bool $explicit = false;
|
||||
|
||||
#[OA\Property]
|
||||
public int $created_at;
|
||||
|
||||
#[OA\Property]
|
||||
public ?int $publish_at = null;
|
||||
|
||||
|
|
|
@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||
namespace App\Entity\Repository;
|
||||
|
||||
use App\Doctrine\Repository;
|
||||
use App\Entity\Podcast;
|
||||
use App\Entity\PodcastEpisode;
|
||||
use App\Entity\PodcastMedia;
|
||||
use App\Entity\Station;
|
||||
|
@ -14,7 +13,7 @@ use App\Exception\StorageLocationFullException;
|
|||
use App\Flysystem\ExtendedFilesystemInterface;
|
||||
use App\Media\AlbumArt;
|
||||
use App\Media\MetadataManager;
|
||||
use http\Exception\InvalidArgumentException;
|
||||
use InvalidArgumentException;
|
||||
use League\Flysystem\UnableToDeleteFile;
|
||||
use League\Flysystem\UnableToRetrieveMetadata;
|
||||
|
||||
|
@ -56,28 +55,6 @@ final class PodcastEpisodeRepository extends Repository
|
|||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return PodcastEpisode[]
|
||||
*/
|
||||
public function fetchPublishedEpisodesForPodcast(Podcast $podcast): array
|
||||
{
|
||||
$episodes = $this->em->createQueryBuilder()
|
||||
->select('pe')
|
||||
->from(PodcastEpisode::class, 'pe')
|
||||
->where('pe.podcast = :podcast')
|
||||
->setParameter('podcast', $podcast)
|
||||
->orderBy('pe.created_at', 'DESC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
return array_filter(
|
||||
$episodes,
|
||||
static function (PodcastEpisode $episode) {
|
||||
return $episode->isPublished();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public function writeEpisodeArt(
|
||||
PodcastEpisode $episode,
|
||||
string $rawArtworkString
|
||||
|
|
|
@ -44,35 +44,6 @@ final class PodcastRepository extends Repository
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Podcast[]
|
||||
*/
|
||||
public function fetchPublishedPodcastsForStation(Station $station): array
|
||||
{
|
||||
$podcasts = $this->em->createQuery(
|
||||
<<<'DQL'
|
||||
SELECT p, pe
|
||||
FROM App\Entity\Podcast p
|
||||
LEFT JOIN p.episodes pe
|
||||
WHERE p.storage_location = :storageLocation
|
||||
DQL
|
||||
)->setParameter('storageLocation', $station->getPodcastsStorageLocation())
|
||||
->getResult();
|
||||
|
||||
return array_filter(
|
||||
$podcasts,
|
||||
static function (Podcast $podcast) {
|
||||
foreach ($podcast->getEpisodes() as $episode) {
|
||||
if ($episode->isPublished()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public function writePodcastArt(
|
||||
Podcast $podcast,
|
||||
string $rawArtworkString,
|
||||
|
|
|
@ -1,95 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* @var \App\Http\RouterInterface $router
|
||||
* @var \App\View\GlobalSections $sections
|
||||
*/
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
$this->layout('minimal', [
|
||||
'page_class' => 'podcasts station-' . $station->getShortName(),
|
||||
'title' => 'Podcasts - ' . $station->getName(),
|
||||
'hide_footer' => true,
|
||||
]);
|
||||
|
||||
$this->fetch('frontend/public/partials/station-custom', ['station' => $station]);
|
||||
|
||||
$episodeAudioSrc = $router->named(
|
||||
'api:stations:podcast:episode:download',
|
||||
[
|
||||
'station_id' => $station->getId(),
|
||||
'podcast_id' => $episode->getPodcast()->getId(),
|
||||
'episode_id' => $episode->getId(),
|
||||
],
|
||||
[],
|
||||
true
|
||||
);
|
||||
|
||||
$publishedAt = CarbonImmutable::createFromTimestamp($episode->getCreatedAt());
|
||||
|
||||
if ($episode->getPublishAt() !== null) {
|
||||
$publishedAt = CarbonImmutable::createFromTimestamp($episode->getPublishAt());
|
||||
}
|
||||
|
||||
$sections->append(
|
||||
'head',
|
||||
<<<HTML
|
||||
<link rel="alternate" type="application/rss+xml" title="{$this->e($podcast->getTitle())}" href="{$feedLink}">
|
||||
HTML
|
||||
);
|
||||
?>
|
||||
<div class="public-page">
|
||||
<section class="card" role="region">
|
||||
<div class="card-body">
|
||||
<div class="d-flex mb-3">
|
||||
<div class="flex-fill">
|
||||
<h1 class="card-title mb-1">
|
||||
<?= $this->e($podcast->getTitle()) ?>
|
||||
</h1>
|
||||
<h2 class="card-subtitle mb-3">
|
||||
<?= $this->e($episode->getTitle()) ?>
|
||||
</h2>
|
||||
|
||||
<div class="mb-3">
|
||||
<span class="badge text-bg-dark">
|
||||
<?= $publishedAt->format('d. M. Y') ?>
|
||||
</span>
|
||||
|
||||
<?php
|
||||
if ($episode->getExplicit()) : ?>
|
||||
<span class="badge text-bg-danger"><?= __('Explicit') ?></span>
|
||||
<?php
|
||||
endif; ?>
|
||||
</div>
|
||||
|
||||
<p class="card-text"><?= $this->e($episode->getDescription()) ?></p>
|
||||
</div>
|
||||
<div class="flex-shrink-0 ps-2" style="max-width: 128px;">
|
||||
<img src="<?= $router->named(
|
||||
'api:stations:podcast:episode:art',
|
||||
[
|
||||
'station_id' => $station->getId(),
|
||||
'podcast_id' => $podcast->getId(),
|
||||
'episode_id' => $episode->getId() . '|' . $episode->getArtUpdatedAt(),
|
||||
]
|
||||
); ?>" class="card-img img-fluid"
|
||||
alt="<?= $this->e($podcast->getTitle()) ?>">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<audio src="<?= $episodeAudioSrc ?>" controls style="width: 100%;"></audio>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<a href="<?= $podcastEpisodesLink ?>" class="btn btn-sm btn-primary">
|
||||
<span><?= __('Back') ?></span>
|
||||
</a>
|
||||
<a href="<?= $feedLink ?>" class="btn btn-sm btn-warning" target="_blank">
|
||||
<?= $this->fetch('icons/rss') ?>
|
||||
<span><?= __('RSS Feed') ?></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
|
@ -1,116 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* @var \App\Http\RouterInterface $router
|
||||
* @var \App\View\GlobalSections $sections
|
||||
*/
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
$this->layout(
|
||||
'minimal',
|
||||
[
|
||||
'page_class' => 'podcasts station-' . $station->getShortName(),
|
||||
'title' => 'Podcasts - ' . $station->getName(),
|
||||
'hide_footer' => true,
|
||||
]
|
||||
);
|
||||
|
||||
$this->fetch('frontend/public/partials/station-custom', ['station' => $station]);
|
||||
|
||||
$sections->append(
|
||||
'head',
|
||||
<<<HTML
|
||||
<link rel="alternate" type="application/rss+xml" title="{$this->e($podcast->getTitle())}" href="{$feedLink}">
|
||||
HTML
|
||||
);
|
||||
?>
|
||||
|
||||
<div class="public-page">
|
||||
<section class="card" role="region">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title mb-1">
|
||||
<?= $this->e($podcast->getTitle()) ?>
|
||||
</h1>
|
||||
<h2 class="card-subtitle mb-3">
|
||||
<?= __('Episodes') ?>
|
||||
</h2>
|
||||
|
||||
<div class="buttons mb-3">
|
||||
<a href="<?= $podcastsLink ?>" class="btn btn-sm btn-primary">
|
||||
<span><?= __('Back') ?></span>
|
||||
</a>
|
||||
<a href="<?= $feedLink ?>" class="btn btn-sm btn-warning" target="_blank">
|
||||
<?= $this->fetch('icons/rss') ?>
|
||||
<span><?= __('RSS Feed') ?></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
/** @var App\Entity\PodcastEpisode $episode */
|
||||
foreach ($episodes as $episode) : ?>
|
||||
<?php
|
||||
$episodePageLink = $router->named(
|
||||
'public:podcast:episode',
|
||||
[
|
||||
'station_id' => $station->getId(),
|
||||
'podcast_id' => $podcast->getId(),
|
||||
'episode_id' => $episode->getId(),
|
||||
]
|
||||
) ?>
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div class="flex-shrink-0 pe-2" style="max-width: 128px">
|
||||
<a href="<?= $this->e($episodePageLink) ?>" title="<?= __('View Details') ?>">
|
||||
<img src="<?= $router->named(
|
||||
'api:stations:podcast:episode:art',
|
||||
[
|
||||
'station_id' => $station->getId(),
|
||||
'podcast_id' => $podcast->getId(),
|
||||
'episode_id' => $episode->getId() . '|' . $episode->getArtUpdatedAt(),
|
||||
]
|
||||
); ?>" class="card-img img-fluid" alt="<?= $this->e($podcast->getTitle()) ?>">
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex-fill">
|
||||
<h5 class="card-title"><?= $this->e($episode->getTitle()) ?></h5>
|
||||
<p class="card-text"><?= $this->e($episode->getDescription()) ?></p>
|
||||
|
||||
<?php
|
||||
if ($episode->getExplicit()) : ?>
|
||||
<p class="card-text">
|
||||
<small class="text-warning-emphasis"><?= __('Contains explicit content') ?></small>
|
||||
</p>
|
||||
<?php
|
||||
endif; ?>
|
||||
|
||||
<p class="card-text">
|
||||
<?php
|
||||
$publishedAt = CarbonImmutable::createFromTimestamp(
|
||||
$episode->getCreatedAt()
|
||||
); ?>
|
||||
<?php
|
||||
if ($episode->getPublishAt() !== null) : ?>
|
||||
<?php
|
||||
$publishedAt = CarbonImmutable::createFromTimestamp(
|
||||
$episode->getPublishAt()
|
||||
); ?>
|
||||
<?php
|
||||
endif; ?>
|
||||
<span class="badge badge-pill badge-dark" data-toggle="tooltip"
|
||||
data-placement="right" data-html="true"
|
||||
title="<?= $publishedAt->format(
|
||||
'H:i'
|
||||
) ?>"><?= $publishedAt->format('d. M. Y') ?></span>
|
||||
</p>
|
||||
|
||||
<div class="block-buttons">
|
||||
<a href="<?= $this->e($episodePageLink) ?>" class="btn btn-primary">
|
||||
<?= __('View Details') ?>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
endforeach; ?>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
|
@ -1,92 +0,0 @@
|
|||
<?php
|
||||
|
||||
$this->layout('minimal', [
|
||||
'page_class' => 'podcasts station-' . $station->getShortName(),
|
||||
'title' => 'Podcasts - ' . $station->getName(),
|
||||
'hide_footer' => true,
|
||||
]);
|
||||
|
||||
$this->fetch('frontend/public/partials/station-custom', ['station' => $station]);
|
||||
|
||||
?>
|
||||
<div class="public-page">
|
||||
<section class="card" role="region">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title mb-3">
|
||||
<?= $this->e($station->getName()) ?>
|
||||
</h1>
|
||||
|
||||
<?php
|
||||
/** @var App\Entity\Podcast $podcast */
|
||||
foreach ($podcasts as $podcast) : ?>
|
||||
<?php
|
||||
$episodesPageLink = (string)$router->named(
|
||||
'public:podcast:episodes',
|
||||
[
|
||||
'station_id' => $station->getId(),
|
||||
'podcast_id' => $podcast->getId(),
|
||||
]
|
||||
) ?>
|
||||
<?php
|
||||
$feedLink = (string)$router->named(
|
||||
'public:podcast:feed',
|
||||
['station_id' => $station->getId(), 'podcast_id' => $podcast->getId()]
|
||||
) ?>
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div class="flex-shrink-0 pe-2" style="max-width: 128px">
|
||||
<a href="<?= $this->e($episodesPageLink) ?>" title="<?= __('Episodes') ?>">
|
||||
<img src="<?= $router->named(
|
||||
'api:stations:podcast:art',
|
||||
[
|
||||
'station_id' => $station->getId(),
|
||||
'podcast_id' => $podcast->getId(),
|
||||
]
|
||||
); ?>" class="card-img img-fluid" alt="<?= $this->e($podcast->getTitle()) ?>">
|
||||
</a>
|
||||
</div>
|
||||
<div class="d-fill">
|
||||
<h5 class="card-subtitle"><?= $this->e($podcast->getTitle()) ?></h5>
|
||||
<p class="card-text"><?= $this->e($podcast->getDescription()) ?></p>
|
||||
|
||||
<p class="card-text">
|
||||
<small class="text-muted"><?= __('Language') ?>: <?= strtoupper(
|
||||
$podcast->getLanguage()
|
||||
) ?></small>
|
||||
<br/>
|
||||
<small class="text-muted"><?= __('Categories') ?>: <?= implode(
|
||||
$podcast->getCategories()->map(
|
||||
function ($category) {
|
||||
$title = $category->getTitle();
|
||||
$subtitle = $category->getSubTitle();
|
||||
|
||||
return (!empty($subtitle))
|
||||
? $title . ' - ' . $subtitle
|
||||
: $title;
|
||||
}
|
||||
)->getValues()
|
||||
); ?></small>
|
||||
</p>
|
||||
|
||||
<div class="buttons">
|
||||
<a href="<?= $episodesPageLink ?>" class="btn btn-sm btn-primary">
|
||||
<?= __('Episodes') ?>
|
||||
</a>
|
||||
<a href="<?= $feedLink ?>" class="btn btn-sm btn-warning"
|
||||
target="_blank">
|
||||
<?= $this->fetch('icons/rss') ?>
|
||||
<span><?= __('RSS Feed') ?></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
endforeach; ?>
|
||||
|
||||
<?php
|
||||
if (count($podcasts) === 0) : ?>
|
||||
<p class="card-text"><?= __('No entries found.') ?></p>
|
||||
<?php
|
||||
endif; ?>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
|
@ -1,7 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="48" viewBox="0 -960 960 960" width="48"
|
||||
class="icon <?= $class ?? null ?>"
|
||||
fill="currentColor"
|
||||
focusable="false"
|
||||
aria-hidden="true">
|
||||
<path d="M188-109q-40 0-69-28.5T90-207q0-40 29-69t69-29q40 0 69 29t29 69q0 41-29 69.5T188-109Zm532 0q0-133-48.5-248t-133-200Q454-642 339.091-690.5 224.182-739 90-739v-110q156 0 291 57.5T615.5-634q99.5 100 157 235T830-109H720Zm-278 0q0-164-93.5-262T90-469v-110q104 0 189 34.5t145.5 97q60.5 62.5 94 149T552-109H442Z"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 529 B |
Loading…
Reference in New Issue