Compare commits

...

6 Commits

15 changed files with 187 additions and 8 deletions

View File

@ -8,6 +8,7 @@
"license": "AGPL-3.0",
"dependencies": {
"@codemirror/lang-css": "^6.0.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.1.2",
"@flowjs/flow.js": "^2.14.1",
"@fullcalendar/bootstrap5": "^6.1.8",
@ -231,6 +232,22 @@
"@lezer/css": "^1.0.0"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.9",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz",
"integrity": "sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.0"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.2.tgz",
@ -932,6 +949,16 @@
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.9",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.9.tgz",
"integrity": "sha512-MXxeCMPyrcemSLGaTQEZx0dBUH0i+RPl8RN5GwMAzo53nTsd/Unc/t5ZxACeQoyPUM5/GkPLRUs2WliOImzkRA==",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.4.14",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.4.14.tgz",

View File

@ -11,6 +11,7 @@
},
"dependencies": {
"@codemirror/lang-css": "^6.0.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-javascript": "^6.1.2",
"@flowjs/flow.js": "^2.14.1",
"@fullcalendar/bootstrap5": "^6.1.8",

View File

@ -13,6 +13,7 @@ import {useVModel} from "@vueuse/core";
import {computed} from "vue";
import {css} from "@codemirror/lang-css";
import {javascript} from "@codemirror/lang-javascript";
import {html} from "@codemirror/lang-html";
import {liquidsoap} from "codemirror-lang-liquidsoap";
import useTheme from "~/functions/theme";
@ -31,6 +32,8 @@ const lang = computed(() => {
return css();
case 'javascript':
return javascript();
case 'html':
return html();
case 'liquidsoap':
return liquidsoap();
default:

View File

@ -36,7 +36,7 @@
<h5 class="m-0">
{{ item.title }}
</h5>
<div v-if="item.is_published">
<div v-if="item.is_published && item.is_enabled">
<a
:href="item.links.public_episodes"
target="_blank"
@ -59,6 +59,12 @@
>
{{ $gettext('Unpublished') }}
</span>
<span
v-if="!item.is_enabled"
class="badge text-bg-danger"
>
{{ $gettext('Disabled') }}
</span>
</div>
</template>
<template #cell(actions)="{item}">

View File

@ -10,13 +10,13 @@
>
<tabs>
<podcast-form-basic-info
:form="form"
v-model:form="form"
:categories-options="categoriesOptions"
:language-options="languageOptions"
/>
<podcast-form-source
:form="form"
v-model:form="form"
/>
<podcast-common-artwork
@ -24,6 +24,10 @@
:artwork-src="record.links.art"
:new-art-url="newArtUrl"
/>
<podcast-form-branding
v-model:form="form"
/>
</tabs>
</modal-form>
</template>
@ -31,6 +35,7 @@
<script setup lang="ts">
import PodcastFormBasicInfo from './PodcastForm/BasicInfo.vue';
import PodcastFormSource from './PodcastForm/Source.vue';
import PodcastFormBranding from './PodcastForm/Branding.vue';
import PodcastCommonArtwork from './Common/Artwork.vue';
import mergeExisting from "~/functions/mergeExisting";
import {baseEditModalProps, ModalFormTemplateRef, useBaseEditModal} from "~/functions/useBaseEditModal";
@ -99,8 +104,11 @@ const {
(row) => row.category
);
record.value = data;
formRef.value = mergeExisting(formRef.value, data);
console.log(formRef.value);
},
},
);

View File

@ -63,6 +63,14 @@
:label="$gettext('Categories')"
:description="$gettext('Select the category/categories that best reflects the content of your podcast.')"
/>
<form-group-checkbox
id="edit_form_is_enabled"
class="col-md-12"
:field="v$.is_enabled"
:label="$gettext('Enable on Public Pages')"
:description="$gettext('If disabled, the station will not be visible on public-facing pages or APIs.')"
/>
</div>
</tab>
</template>
@ -74,6 +82,7 @@ import {useVModel} from "@vueuse/core";
import {useVuelidateOnFormTab} from "~/functions/useVuelidateOnFormTab";
import {required} from "@vuelidate/validators";
import Tab from "~/components/Common/Tab.vue";
import FormGroupCheckbox from "~/components/Form/FormGroupCheckbox.vue";
const props = defineProps({
form: {
@ -102,6 +111,7 @@ const {v$, tabClass} = useVuelidateOnFormTab(
author: {},
email: {},
categories: {required},
is_enabled: {},
},
form,
{
@ -112,6 +122,7 @@ const {v$, tabClass} = useVuelidateOnFormTab(
author: '',
email: '',
categories: [],
is_enabled: true
}
);
</script>

View File

@ -0,0 +1,55 @@
<template>
<tab
:label="$gettext('Branding')"
:item-header-class="tabClass"
>
<div class="row g-3">
<form-group-field
id="edit_form_public_custom_html"
class="col-md-12"
:field="v$.branding_config.public_custom_html"
:label="$gettext('Custom HTML for Public Pages')"
>
<template #default="slotProps">
<codemirror-textarea
:id="slotProps.id"
v-model="slotProps.field.$model"
mode="html"
/>
</template>
</form-group-field>
</div>
</tab>
</template>
<script setup lang="ts">
import {useVModel} from "@vueuse/core";
import {useVuelidateOnFormTab} from "~/functions/useVuelidateOnFormTab";
import Tab from "~/components/Common/Tab.vue";
import CodemirrorTextarea from "~/components/Common/CodemirrorTextarea.vue";
import FormGroupField from "~/components/Form/FormGroupField.vue";
const props = defineProps({
form: {
type: Object,
required: true
}
});
const emit = defineEmits(['update:form']);
const form = useVModel(props, 'form', emit);
const {v$, tabClass} = useVuelidateOnFormTab(
{
branding_config: {
public_custom_html: {}
},
},
form,
{
branding_config: {
public_custom_html: ''
}
}
);
</script>

View File

@ -35,6 +35,7 @@ final class ListPodcastsAction implements SingleActionInterface
->from(Podcast::class, 'p')
->leftJoin('p.categories', 'pc')
->where('p.storage_location = :storageLocation')
->andWhere('p.is_enabled = 1')
->setParameter('storageLocation', $station->getPodcastsStorageLocation())
->andWhere('p.id IN (:podcastIds)')
->setParameter('podcastIds', $this->podcastRepo->getPodcastIdsWithPublishedEpisodes($station))

View File

@ -29,9 +29,13 @@ abstract class AbstractStationConfiguration implements JsonSerializable
return $return;
}
public function jsonSerialize(): array
public function jsonSerialize(): array|object
{
return $this->toArray();
$result = $this->toArray();
return (0 !== count($result))
? $result
: (object)[];
}
protected function get(string $key, mixed $default = null): mixed

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Entity\Api;
use App\Entity\Api\Traits\HasLinks;
use App\Entity\PodcastBrandingConfiguration;
use OpenApi\Attributes as OA;
#[OA\Schema(
@ -42,12 +43,15 @@ final class Podcast
#[OA\Property]
public string $description_short;
#[OA\Property]
public bool $is_enabled = true;
#[OA\Property(
description: "An array containing podcast-specific branding configuration",
type: "array",
items: new OA\Items()
)]
public array $branding_config;
public PodcastBrandingConfiguration $branding_config;
#[OA\Property]
public string $language;

View File

@ -48,7 +48,9 @@ final class PodcastApiGenerator
$return->description = $record->getDescription();
$return->description_short = Strings::truncateText($return->description, 200);
$return->branding_config = $record->getBrandingConfig()->toArray();
$return->is_enabled = $record->isEnabled();
$return->branding_config = $record->getBrandingConfig();
$return->language = $record->getLanguage();
try {

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Entity\Migration;
use Doctrine\DBAL\Schema\Schema;
final class Version20240425151151 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add is_enabled flag for podcasts.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE podcast ADD is_enabled TINYINT(1) NOT NULL AFTER description');
$this->addSql(<<<'SQL'
UPDATE podcast
SET is_enabled=1
SQL);
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE podcast DROP is_enabled');
}
}

View File

@ -52,6 +52,9 @@ class Podcast implements Interfaces\IdentifiableEntityInterface
#[Assert\NotBlank]
protected string $description;
#[ORM\Column]
protected bool $is_enabled = true;
#[ORM\Column(type: 'json', nullable: true)]
protected ?array $branding_config = null;
@ -150,6 +153,16 @@ class Podcast implements Interfaces\IdentifiableEntityInterface
return $this;
}
public function isEnabled(): bool
{
return $this->is_enabled;
}
public function setIsEnabled(bool $is_enabled): void
{
$this->is_enabled = $is_enabled;
}
public function getBrandingConfig(): PodcastBrandingConfiguration
{
return new PodcastBrandingConfiguration((array)$this->branding_config);

View File

@ -4,6 +4,19 @@ declare(strict_types=1);
namespace App\Entity;
use App\Utilities\Types;
class PodcastBrandingConfiguration extends AbstractStationConfiguration
{
public const PUBLIC_CUSTOM_HTML = 'public_custom_html';
public function getPublicCustomHtml(): ?string
{
return Types::stringOrNull($this->get(self::PUBLIC_CUSTOM_HTML), true);
}
public function setPublicCustomHtml(?string $html): void
{
$this->set(self::PUBLIC_CUSTOM_HTML, $html);
}
}

View File

@ -27,7 +27,8 @@ final class RequirePublishedPodcastEpisodeMiddleware extends AbstractMiddleware
$publishedPodcastIds = $this->podcastRepository->getPodcastIdsWithPublishedEpisodes($station);
$podcast = $request->getPodcast();
if (!in_array($podcast->getIdRequired(), $publishedPodcastIds, true)) {
if (!$podcast->isEnabled() || !in_array($podcast->getIdRequired(), $publishedPodcastIds, true)) {
throw NotFoundException::podcast();
}