Add per-station branding support.
This commit is contained in:
parent
71510ee4a4
commit
a5bf63ed49
|
@ -5,6 +5,9 @@ release channel, you can take advantage of these new features and fixes.
|
|||
|
||||
## New Features/Changes
|
||||
|
||||
- **Per-Station Branding**: You can now provide custom album art, public page backgrounds, CSS and JavaScript on a
|
||||
per-station basis, using a new Station Branding page that is very similar to the system-wide Branding page.
|
||||
|
||||
## Code Quality/Technical Changes
|
||||
|
||||
- Redis was removed in version 0.17.6 in order to yield fewer running tasks on servers by default; we have noticed that,
|
||||
|
|
|
@ -26,15 +26,33 @@ return static function (RouteCollectorProxy $group) {
|
|||
->setName('api:stations:profile')
|
||||
->add(new Middleware\Permissions(StationPermissions::View, true));
|
||||
|
||||
$group->get(
|
||||
'/profile/edit',
|
||||
Controller\Api\Stations\ProfileEditController::class . ':getProfileAction'
|
||||
)->setName('api:stations:profile:edit')
|
||||
->add(new Middleware\Permissions(StationPermissions::Profile, true));
|
||||
$group->group(
|
||||
'',
|
||||
function (RouteCollectorProxy $group) {
|
||||
$group->get(
|
||||
'/profile/edit',
|
||||
Controller\Api\Stations\ProfileEditController::class . ':getProfileAction'
|
||||
)->setName('api:stations:profile:edit');
|
||||
|
||||
$group->put(
|
||||
'/profile/edit',
|
||||
Controller\Api\Stations\ProfileEditController::class . ':putProfileAction'
|
||||
$group->put(
|
||||
'/profile/edit',
|
||||
Controller\Api\Stations\ProfileEditController::class . ':putProfileAction'
|
||||
);
|
||||
|
||||
$group->get(
|
||||
'/custom_assets/{type}',
|
||||
Controller\Api\Stations\CustomAssets\GetCustomAssetAction::class
|
||||
)->setName('api:stations:custom_assets');
|
||||
|
||||
$group->post(
|
||||
'/custom_assets/{type}',
|
||||
Controller\Api\Stations\CustomAssets\PostCustomAssetAction::class
|
||||
);
|
||||
$group->delete(
|
||||
'/custom_assets/{type}',
|
||||
Controller\Api\Stations\CustomAssets\DeleteCustomAssetAction::class
|
||||
);
|
||||
}
|
||||
)->add(new Middleware\Permissions(StationPermissions::Profile, true));
|
||||
|
||||
$group->get('/schedule', Controller\Api\Stations\ScheduleAction::class)
|
||||
|
|
|
@ -21,6 +21,10 @@ return static function (RouteCollectorProxy $app) {
|
|||
}
|
||||
)->setName('stations:index:index');
|
||||
|
||||
$group->get('/branding', Controller\Stations\BrandingAction::class)
|
||||
->setName('stations:branding')
|
||||
->add(new Middleware\Permissions(StationPermissions::Profile, true));
|
||||
|
||||
$group->get('/bulk-media', Controller\Stations\BulkMediaAction::class)
|
||||
->setName('stations:bulk-media')
|
||||
->add(new Middleware\Permissions(StationPermissions::Media, true));
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
<template>
|
||||
<section
|
||||
class="card mb-3"
|
||||
role="region"
|
||||
>
|
||||
<div class="card-header bg-primary-dark">
|
||||
<h2 class="card-title">
|
||||
{{ $gettext('Upload Custom Assets') }}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="list-unstyled">
|
||||
<custom-asset-form
|
||||
id="asset_background"
|
||||
class="mb-3"
|
||||
:api-url="backgroundApiUrl"
|
||||
:caption="$gettext('Public Page Background')"
|
||||
/>
|
||||
<custom-asset-form
|
||||
id="asset_album_art"
|
||||
class="mb-3"
|
||||
:api-url="albumArtApiUrl"
|
||||
:caption="$gettext('Default Album Art')"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<branding-form :profile-edit-url="profileEditUrl" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import BrandingForm from "~/components/Stations/Branding/BrandingForm.vue";
|
||||
import CustomAssetForm from "~/components/Admin/Branding/CustomAssetForm.vue";
|
||||
|
||||
defineProps({
|
||||
profileEditUrl: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
backgroundApiUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
albumArtApiUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
<template>
|
||||
<form
|
||||
class="form vue-form"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<section
|
||||
class="card mb-3"
|
||||
role="region"
|
||||
>
|
||||
<div class="card-header bg-primary-dark">
|
||||
<h2 class="card-title">
|
||||
{{ $gettext('Branding Settings') }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<b-alert
|
||||
variant="danger"
|
||||
:show="error != null"
|
||||
>
|
||||
{{ error }}
|
||||
</b-alert>
|
||||
|
||||
<b-overlay
|
||||
variant="card"
|
||||
:show="loading"
|
||||
>
|
||||
<div class="card-body">
|
||||
<b-form-group>
|
||||
<div class="form-row">
|
||||
<b-wrapped-form-group
|
||||
id="form_edit_default_album_art_url"
|
||||
class="col-md-6"
|
||||
:field="v$.default_album_art_url"
|
||||
>
|
||||
<template #label>
|
||||
{{ $gettext('Default Album Art URL') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{
|
||||
$gettext('If a song has no album art, this URL will be listed instead. Leave blank to use the standard placeholder art.')
|
||||
}}
|
||||
</template>
|
||||
</b-wrapped-form-group>
|
||||
|
||||
<b-wrapped-form-group
|
||||
id="edit_form_public_custom_css"
|
||||
class="col-md-12"
|
||||
:field="v$.public_custom_css"
|
||||
>
|
||||
<template #label>
|
||||
{{ $gettext('Custom CSS for Public Pages') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{
|
||||
$gettext('This CSS will be applied to the station public pages.')
|
||||
}}
|
||||
</template>
|
||||
<template #default="slotProps">
|
||||
<codemirror-textarea
|
||||
:id="slotProps.id"
|
||||
v-model="slotProps.field.$model"
|
||||
mode="css"
|
||||
/>
|
||||
</template>
|
||||
</b-wrapped-form-group>
|
||||
|
||||
<b-wrapped-form-group
|
||||
id="edit_form_public_custom_js"
|
||||
class="col-md-12"
|
||||
:field="v$.public_custom_js"
|
||||
>
|
||||
<template #label>
|
||||
{{ $gettext('Custom JS for Public Pages') }}
|
||||
</template>
|
||||
<template #description>
|
||||
{{
|
||||
$gettext('This javascript code will be applied to the station public pages.')
|
||||
}}
|
||||
</template>
|
||||
<template #default="slotProps">
|
||||
<codemirror-textarea
|
||||
:id="slotProps.id"
|
||||
v-model="slotProps.field.$model"
|
||||
mode="javascript"
|
||||
/>
|
||||
</template>
|
||||
</b-wrapped-form-group>
|
||||
</div>
|
||||
|
||||
<b-button
|
||||
size="lg"
|
||||
type="submit"
|
||||
class="mt-3"
|
||||
variant="primary"
|
||||
>
|
||||
{{ $gettext('Save Changes') }}
|
||||
</b-button>
|
||||
</b-form-group>
|
||||
</div>
|
||||
</b-overlay>
|
||||
</section>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import CodemirrorTextarea from "~/components/Common/CodemirrorTextarea.vue";
|
||||
import BWrappedFormGroup from "~/components/Form/BWrappedFormGroup.vue";
|
||||
import {onMounted, ref} from "vue";
|
||||
import {useAxios} from "~/vendor/axios";
|
||||
import mergeExisting from "~/functions/mergeExisting";
|
||||
import {useNotify} from "~/vendor/bootstrapVue";
|
||||
import {useTranslate} from "~/vendor/gettext";
|
||||
import {useVuelidateOnForm} from "~/functions/useVuelidateOnForm";
|
||||
|
||||
const props = defineProps({
|
||||
profileEditUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
});
|
||||
|
||||
const loading = ref(true);
|
||||
const error = ref(null);
|
||||
|
||||
const {form, resetForm, v$, ifValid} = useVuelidateOnForm(
|
||||
{
|
||||
'default_album_art_url': {},
|
||||
'public_custom_css': {},
|
||||
'public_custom_js': {},
|
||||
},
|
||||
{
|
||||
'default_album_art_url': '',
|
||||
'public_custom_css': '',
|
||||
'public_custom_js': ''
|
||||
}
|
||||
);
|
||||
|
||||
const {$gettext} = useTranslate();
|
||||
const {axios} = useAxios();
|
||||
|
||||
const populateForm = (data) => {
|
||||
form.value = mergeExisting(form.value, data);
|
||||
};
|
||||
|
||||
const relist = () => {
|
||||
resetForm();
|
||||
|
||||
loading.value = true;
|
||||
|
||||
axios.get(props.profileEditUrl).then((resp) => {
|
||||
populateForm(resp.data.branding_config);
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(relist);
|
||||
|
||||
const {wrapWithLoading, notifySuccess} = useNotify();
|
||||
|
||||
const submit = () => {
|
||||
ifValid(() => {
|
||||
wrapWithLoading(
|
||||
axios({
|
||||
method: 'PUT',
|
||||
url: props.profileEditUrl,
|
||||
data: {
|
||||
branding_config: form.value
|
||||
}
|
||||
})
|
||||
).then(() => {
|
||||
notifySuccess();
|
||||
relist();
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
|
@ -50,13 +50,20 @@
|
|||
</table>
|
||||
<div class="card-actions">
|
||||
<a
|
||||
class="btn btn-outline-danger"
|
||||
class="btn btn-outline-default"
|
||||
@click.prevent="doOpenEmbed"
|
||||
>
|
||||
<icon icon="code" />
|
||||
{{ $gettext('Embed Widgets') }}
|
||||
</a>
|
||||
<template v-if="userCanManageProfile">
|
||||
<a
|
||||
class="btn btn-outline-default"
|
||||
:href="brandingUri"
|
||||
>
|
||||
<icon icon="design_services" />
|
||||
{{ $gettext('Edit Branding') }}
|
||||
</a>
|
||||
<a
|
||||
class="btn btn-outline-danger"
|
||||
:data-confirm-title="$gettext('Disable public pages?')"
|
||||
|
|
|
@ -1,13 +1,46 @@
|
|||
export default {
|
||||
numSongs: Number,
|
||||
numPlaylists: Number,
|
||||
backendType: String,
|
||||
hasStarted: Boolean,
|
||||
userCanManageBroadcasting: Boolean,
|
||||
userCanManageMedia: Boolean,
|
||||
manageMediaUri: String,
|
||||
managePlaylistsUri: String,
|
||||
backendRestartUri: String,
|
||||
backendStartUri: String,
|
||||
backendStopUri: String
|
||||
numSongs: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
numPlaylists: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
backendType: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
hasStarted: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
userCanManageBroadcasting: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
userCanManageMedia: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
manageMediaUri: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
managePlaylistsUri: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
backendRestartUri: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
backendStartUri: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
backendStopUri: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,13 +1,46 @@
|
|||
export default {
|
||||
stationSupportsStreamers: Boolean,
|
||||
stationSupportsRequests: Boolean,
|
||||
enablePublicPage: Boolean,
|
||||
enableStreamers: Boolean,
|
||||
enableOnDemand: Boolean,
|
||||
enableRequests: Boolean,
|
||||
publicPageEmbedUri: String,
|
||||
publicOnDemandEmbedUri: String,
|
||||
publicRequestEmbedUri: String,
|
||||
publicHistoryEmbedUri: String,
|
||||
publicScheduleEmbedUri: String
|
||||
stationSupportsStreamers: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
stationSupportsRequests: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
enablePublicPage: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
enableStreamers: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
enableOnDemand: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
enableRequests: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
publicPageEmbedUri: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
publicOnDemandEmbedUri: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
publicRequestEmbedUri: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
publicHistoryEmbedUri: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
publicScheduleEmbedUri: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,12 +1,42 @@
|
|||
export default {
|
||||
frontendType: String,
|
||||
frontendAdminUri: String,
|
||||
frontendAdminPassword: String,
|
||||
frontendSourcePassword: String,
|
||||
frontendRelayPassword: String,
|
||||
frontendRestartUri: String,
|
||||
frontendStartUri: String,
|
||||
frontendStopUri: String,
|
||||
hasStarted: Boolean,
|
||||
userCanManageBroadcasting: Boolean
|
||||
frontendType: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
frontendAdminUri: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
frontendAdminPassword: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
frontendSourcePassword: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
frontendRelayPassword: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
frontendRestartUri: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
frontendStartUri: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
frontendStopUri: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
hasStarted: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
userCanManageBroadcasting: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,18 @@
|
|||
export default {
|
||||
stationName: String,
|
||||
stationDescription: String,
|
||||
userCanManageProfile: Boolean,
|
||||
manageProfileUri: String
|
||||
stationName: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
stationDescription: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
userCanManageProfile: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
manageProfileUri: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,18 @@
|
|||
export default {
|
||||
backendType: String,
|
||||
userCanManageBroadcasting: Boolean,
|
||||
backendSkipSongUri: String,
|
||||
backendDisconnectStreamerUri: String
|
||||
backendType: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
userCanManageBroadcasting: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
backendSkipSongUri: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
backendDisconnectStreamerUri: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,15 +1,58 @@
|
|||
export default {
|
||||
stationSupportsStreamers: Boolean,
|
||||
stationSupportsRequests: Boolean,
|
||||
enablePublicPage: Boolean,
|
||||
enableStreamers: Boolean,
|
||||
enableOnDemand: Boolean,
|
||||
enableRequests: Boolean,
|
||||
userCanManageProfile: Boolean,
|
||||
publicPageUri: String,
|
||||
publicWebDjUri: String,
|
||||
publicOnDemandUri: String,
|
||||
publicPodcastsUri: String,
|
||||
publicScheduleUri: String,
|
||||
togglePublicPageUri: String
|
||||
stationSupportsStreamers: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
stationSupportsRequests: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
enablePublicPage: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
enableStreamers: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
enableOnDemand: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
enableRequests: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
userCanManageProfile: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
publicPageUri: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
publicWebDjUri: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
publicOnDemandUri: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
publicPodcastsUri: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
publicScheduleUri: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
togglePublicPageUri: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
brandingUri: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
}
|
||||
|
|
|
@ -1,7 +1,22 @@
|
|||
export default {
|
||||
enableRequests: Boolean,
|
||||
userCanManageReports: Boolean,
|
||||
userCanManageProfile: Boolean,
|
||||
requestsViewUri: String,
|
||||
requestsToggleUri: String
|
||||
enableRequests: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
userCanManageReports: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
userCanManageProfile: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
requestsViewUri: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
requestsToggleUri: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,22 @@
|
|||
export default {
|
||||
enableStreamers: Boolean,
|
||||
userCanManageProfile: Boolean,
|
||||
userCanManageStreamers: Boolean,
|
||||
streamersViewUri: String,
|
||||
streamersToggleUri: String
|
||||
enableStreamers: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
userCanManageProfile: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
userCanManageStreamers: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
streamersViewUri: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
streamersToggleUri: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
import initBase from '~/base.js';
|
||||
|
||||
import '~/vendor/fancybox';
|
||||
|
||||
import StationsBranding from '~/components/Stations/Branding.vue';
|
||||
|
||||
export default initBase(StationsBranding);
|
|
@ -35,6 +35,7 @@ module.exports = {
|
|||
SetupRegister: '~/pages/Setup/Register.js',
|
||||
SetupSettings: '~/pages/Setup/Settings.js',
|
||||
SetupStation: '~/pages/Setup/Station.js',
|
||||
StationsBranding: '~/pages/Stations/Branding.js',
|
||||
StationsBulkMedia: '~/pages/Stations/BulkMedia.js',
|
||||
StationsFallback: '~/pages/Stations/Fallback.js',
|
||||
StationsHelp: '~/pages/Stations/Help.js',
|
||||
|
|
|
@ -4,14 +4,17 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Assets;
|
||||
|
||||
use App\Entity\Station;
|
||||
use App\Environment;
|
||||
use GuzzleHttp\Psr7\Uri;
|
||||
use Psr\Http\Message\UriInterface;
|
||||
use Symfony\Component\Filesystem\Filesystem;
|
||||
|
||||
abstract class AbstractCustomAsset implements CustomAssetInterface
|
||||
{
|
||||
public function __construct(
|
||||
protected readonly Environment $environment
|
||||
protected readonly Environment $environment,
|
||||
protected readonly ?Station $station = null
|
||||
) {
|
||||
}
|
||||
|
||||
|
@ -22,7 +25,18 @@ abstract class AbstractCustomAsset implements CustomAssetInterface
|
|||
public function getPath(): string
|
||||
{
|
||||
$pattern = sprintf($this->getPattern(), '');
|
||||
return $this->environment->getUploadsDirectory() . '/' . $pattern;
|
||||
return $this->getBasePath() . '/' . $pattern;
|
||||
}
|
||||
|
||||
protected function getBasePath(): string
|
||||
{
|
||||
$basePath = $this->environment->getUploadsDirectory();
|
||||
|
||||
if (null !== $this->station) {
|
||||
$basePath .= '/' . $this->station->getShortName();
|
||||
}
|
||||
|
||||
return $basePath;
|
||||
}
|
||||
|
||||
public function getUrl(): string
|
||||
|
@ -32,7 +46,7 @@ abstract class AbstractCustomAsset implements CustomAssetInterface
|
|||
$pattern = $this->getPattern();
|
||||
$mtime = filemtime($path);
|
||||
|
||||
return $this->environment->getAssetUrl() . self::UPLOADS_URL_PREFIX . '/' . sprintf(
|
||||
return $this->getBaseUrl() . '/' . sprintf(
|
||||
$pattern,
|
||||
'.' . $mtime
|
||||
);
|
||||
|
@ -41,6 +55,17 @@ abstract class AbstractCustomAsset implements CustomAssetInterface
|
|||
return $this->getDefaultUrl();
|
||||
}
|
||||
|
||||
protected function getBaseUrl(): string
|
||||
{
|
||||
$baseUrl = $this->environment->getAssetUrl() . self::UPLOADS_URL_PREFIX;
|
||||
|
||||
if (null !== $this->station) {
|
||||
$baseUrl .= '/' . $this->station->getShortName();
|
||||
}
|
||||
|
||||
return $baseUrl;
|
||||
}
|
||||
|
||||
public function getUri(): UriInterface
|
||||
{
|
||||
return new Uri($this->getUrl());
|
||||
|
@ -55,4 +80,9 @@ abstract class AbstractCustomAsset implements CustomAssetInterface
|
|||
{
|
||||
@unlink($this->getPath());
|
||||
}
|
||||
|
||||
protected function ensureDirectoryExists(string $path): void
|
||||
{
|
||||
(new Filesystem())->mkdir(dirname($path));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@ abstract class AbstractMultiPatternCustomAsset extends AbstractCustomAsset
|
|||
protected function getPathForPattern(string $pattern): string
|
||||
{
|
||||
$pattern = sprintf($pattern, '');
|
||||
return $this->environment->getUploadsDirectory() . '/' . $pattern;
|
||||
return $this->getBasePath() . '/' . $pattern;
|
||||
}
|
||||
|
||||
public function getPath(): string
|
||||
|
@ -47,7 +47,7 @@ abstract class AbstractMultiPatternCustomAsset extends AbstractCustomAsset
|
|||
if (is_file($path)) {
|
||||
$mtime = filemtime($path);
|
||||
|
||||
return $this->environment->getAssetUrl() . self::UPLOADS_URL_PREFIX . '/' . sprintf(
|
||||
return $this->getBaseUrl() . '/' . sprintf(
|
||||
$pattern,
|
||||
'.' . $mtime
|
||||
);
|
||||
|
|
|
@ -36,6 +36,10 @@ final class AlbumArtCustomAsset extends AbstractMultiPatternCustomAsset
|
|||
$mimeType = $newImage->mime();
|
||||
|
||||
$pattern = $patterns[$mimeType] ?? $patterns['default'];
|
||||
$newImage->save($this->getPathForPattern($pattern), 90);
|
||||
|
||||
$destPath = $this->getPathForPattern($pattern);
|
||||
$this->ensureDirectoryExists($destPath);
|
||||
|
||||
$newImage->save($destPath, 90);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Assets;
|
||||
|
||||
use App\Entity\Station;
|
||||
use App\Environment;
|
||||
|
||||
enum AssetTypes: string
|
||||
|
@ -12,12 +13,14 @@ enum AssetTypes: string
|
|||
case Background = 'background';
|
||||
case BrowserIcon = 'browser_icon';
|
||||
|
||||
public function createObject(Environment $environment): CustomAssetInterface
|
||||
{
|
||||
public function createObject(
|
||||
Environment $environment,
|
||||
?Station $station = null
|
||||
): CustomAssetInterface {
|
||||
return match ($this) {
|
||||
self::AlbumArt => new AlbumArtCustomAsset($environment),
|
||||
self::Background => new BackgroundCustomAsset($environment),
|
||||
self::BrowserIcon => new BrowserIconCustomAsset($environment),
|
||||
self::AlbumArt => new AlbumArtCustomAsset($environment, $station),
|
||||
self::Background => new BackgroundCustomAsset($environment, $station),
|
||||
self::BrowserIcon => new BrowserIconCustomAsset($environment, $station),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,6 +36,10 @@ final class BackgroundCustomAsset extends AbstractMultiPatternCustomAsset
|
|||
$mimeType = $newImage->mime();
|
||||
|
||||
$pattern = $patterns[$mimeType] ?? $patterns['default'];
|
||||
$newImage->save($this->getPathForPattern($pattern), 90);
|
||||
|
||||
$destPath = $this->getPathForPattern($pattern);
|
||||
$this->ensureDirectoryExists($destPath);
|
||||
|
||||
$newImage->save($destPath, 90);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -41,7 +41,7 @@ final class BrowserIconCustomAsset extends AbstractCustomAsset
|
|||
public function upload(Image $image): void
|
||||
{
|
||||
$uploadsDir = $this->environment->getUploadsDirectory() . '/browser_icon';
|
||||
(new Filesystem())->mkdir($uploadsDir);
|
||||
$this->ensureDirectoryExists($uploadsDir);
|
||||
|
||||
$newImage = clone $image;
|
||||
$newImage->resize(256, 256);
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Api\Stations\CustomAssets;
|
||||
|
||||
use App\Assets\AssetTypes;
|
||||
use App\Entity;
|
||||
use App\Environment;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
final class DeleteCustomAssetAction
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Environment $environment
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(
|
||||
ServerRequest $request,
|
||||
Response $response,
|
||||
string $station_id,
|
||||
string $type
|
||||
): ResponseInterface {
|
||||
$customAsset = AssetTypes::from($type)->createObject(
|
||||
$this->environment,
|
||||
$request->getStation()
|
||||
);
|
||||
$customAsset->delete();
|
||||
|
||||
return $response->withJson(Entity\Api\Status::success());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Api\Stations\CustomAssets;
|
||||
|
||||
use App\Assets\AssetTypes;
|
||||
use App\Environment;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
final class GetCustomAssetAction
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Environment $environment
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(
|
||||
ServerRequest $request,
|
||||
Response $response,
|
||||
string $station_id,
|
||||
string $type
|
||||
): ResponseInterface {
|
||||
$customAsset = AssetTypes::from($type)->createObject(
|
||||
$this->environment,
|
||||
$request->getStation()
|
||||
);
|
||||
|
||||
return $response->withJson(
|
||||
[
|
||||
'is_uploaded' => $customAsset->isUploaded(),
|
||||
'url' => $customAsset->getUrl(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Api\Stations\CustomAssets;
|
||||
|
||||
use App\Assets\AssetTypes;
|
||||
use App\Entity;
|
||||
use App\Environment;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use App\Media\AlbumArt;
|
||||
use App\Service\Flow;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
final class PostCustomAssetAction
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Environment $environment
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(
|
||||
ServerRequest $request,
|
||||
Response $response,
|
||||
string $station_id,
|
||||
string $type
|
||||
): ResponseInterface {
|
||||
$customAsset = AssetTypes::from($type)->createObject(
|
||||
$this->environment,
|
||||
$request->getStation()
|
||||
);
|
||||
|
||||
$flowResponse = Flow::process($request, $response);
|
||||
if ($flowResponse instanceof ResponseInterface) {
|
||||
return $flowResponse;
|
||||
}
|
||||
|
||||
$imageContents = $flowResponse->readAndDeleteUploadedFile();
|
||||
$customAsset->upload(
|
||||
AlbumArt::getImageManager()->make($imageContents)
|
||||
);
|
||||
|
||||
return $response->withJson(Entity\Api\Status::success());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Stations;
|
||||
|
||||
use App\Assets\AssetTypes;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
final class BrandingAction
|
||||
{
|
||||
public function __invoke(
|
||||
ServerRequest $request,
|
||||
Response $response,
|
||||
string $station_id
|
||||
): ResponseInterface {
|
||||
$router = $request->getRouter();
|
||||
|
||||
return $request->getView()->renderVuePage(
|
||||
response: $response,
|
||||
component: 'Vue_StationsBranding',
|
||||
id: 'stations-branding',
|
||||
title: __('Custom Branding'),
|
||||
props: [
|
||||
'profileEditUrl' => $router->fromHere(
|
||||
'api:stations:profile:edit',
|
||||
),
|
||||
'backgroundApiUrl' => $router->fromHere('api:stations:custom_assets', [
|
||||
'type' => AssetTypes::Background->value,
|
||||
]),
|
||||
'albumArtApiUrl' => $router->fromHere('api:stations:custom_assets', [
|
||||
'type' => AssetTypes::AlbumArt->value,
|
||||
]),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -175,6 +175,9 @@ final class ProfileController
|
|||
routeName: 'stations:profile:toggle',
|
||||
routeParams: ['feature' => 'public', 'csrf' => $csrf]
|
||||
),
|
||||
'brandingUri' => $router->fromHere(
|
||||
routeName: 'stations:branding',
|
||||
),
|
||||
|
||||
// Frontend
|
||||
'frontendAdminUri' => (string)$frontend?->getAdminUrl($station, $router->getBaseUrl()),
|
||||
|
|
|
@ -138,6 +138,24 @@ final class Customization
|
|||
return $publicCss;
|
||||
}
|
||||
|
||||
public function getStationCustomPublicCss(Entity\Station $station): string
|
||||
{
|
||||
$publicCss = $station->getBrandingConfig()->getPublicCustomCss() ?? '';
|
||||
|
||||
$background = new BackgroundCustomAsset($this->environment, $station);
|
||||
if ($background->isUploaded()) {
|
||||
$backgroundUrl = $background->getUrl();
|
||||
|
||||
$publicCss .= <<<CSS
|
||||
[data-theme] body.page-minimal {
|
||||
background-image: url('{$backgroundUrl}');
|
||||
}
|
||||
CSS;
|
||||
}
|
||||
|
||||
return $publicCss;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the administrator-supplied custom JS for public (minimal layout) pages, if specified.
|
||||
*/
|
||||
|
@ -146,6 +164,11 @@ final class Customization
|
|||
return $this->settings->getPublicCustomJs() ?? '';
|
||||
}
|
||||
|
||||
public function getStationCustomPublicJs(Entity\Station $station): string
|
||||
{
|
||||
return $station->getBrandingConfig()->getPublicCustomJs() ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the administrator-supplied custom CSS for internal (full layout) pages, if specified.
|
||||
*/
|
||||
|
|
|
@ -181,6 +181,11 @@ final class StationRepository extends Repository
|
|||
public function getDefaultAlbumArtUrl(?Entity\Station $station = null): UriInterface
|
||||
{
|
||||
if (null !== $station) {
|
||||
$stationAlbumArt = new AlbumArtCustomAsset($this->environment, $station);
|
||||
if ($stationAlbumArt->isUploaded()) {
|
||||
return $stationAlbumArt->getUri();
|
||||
}
|
||||
|
||||
$stationCustomUri = $station->getBrandingConfig()->getDefaultAlbumArtUrlAsUri();
|
||||
if (null !== $stationCustomUri) {
|
||||
return $stationCustomUri;
|
||||
|
|
|
@ -29,4 +29,28 @@ class StationBrandingConfiguration extends AbstractStationConfiguration
|
|||
{
|
||||
$this->set(self::DEFAULT_ALBUM_ART_URL, $default_album_art_url);
|
||||
}
|
||||
|
||||
public const PUBLIC_CUSTOM_CSS = 'public_custom_css';
|
||||
|
||||
public function getPublicCustomCss(): ?string
|
||||
{
|
||||
return $this->get(self::PUBLIC_CUSTOM_CSS);
|
||||
}
|
||||
|
||||
public function setPublicCustomCss(?string $css): void
|
||||
{
|
||||
$this->set(self::PUBLIC_CUSTOM_CSS, $css);
|
||||
}
|
||||
|
||||
public const PUBLIC_CUSTOM_JS = 'public_custom_js';
|
||||
|
||||
public function getPublicCustomJs(): ?string
|
||||
{
|
||||
return $this->get(self::PUBLIC_CUSTOM_JS);
|
||||
}
|
||||
|
||||
public function setPublicCustomJs(?string $js): void
|
||||
{
|
||||
$this->set(self::PUBLIC_CUSTOM_JS, $js);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,6 +16,8 @@ $this->layout(
|
|||
]
|
||||
);
|
||||
|
||||
$this->fetch('frontend/public/partials/station-custom', ['station' => $station]);
|
||||
|
||||
// Register PWA service worker
|
||||
$swJsRoute = $router->named('public:sw');
|
||||
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
/**
|
||||
* @var \App\Customization $customization
|
||||
* @var \App\Entity\Station $station
|
||||
* @var App\View\GlobalSections $sections
|
||||
*/
|
||||
|
||||
$sections->append(
|
||||
'station_head',
|
||||
'<style>' . $customization->getStationCustomPublicCss($station) . '</style>'
|
||||
);
|
||||
|
||||
$sections->append(
|
||||
'station_bodyjs',
|
||||
'<script>' . $customization->getStationCustomPublicJs($station) . '</script>'
|
||||
);
|
|
@ -12,6 +12,8 @@ $this->layout('minimal', [
|
|||
'hide_footer' => true,
|
||||
]);
|
||||
|
||||
$this->fetch('frontend/public/partials/station-custom', ['station' => $station]);
|
||||
|
||||
$episodeAudioSrc = $router->named(
|
||||
'api:stations:podcast:episode:download',
|
||||
[
|
||||
|
|
|
@ -15,6 +15,8 @@ $this->layout(
|
|||
]
|
||||
);
|
||||
|
||||
$this->fetch('frontend/public/partials/station-custom', ['station' => $station]);
|
||||
|
||||
$sections->append(
|
||||
'head',
|
||||
<<<HTML
|
||||
|
|
|
@ -1,16 +1,19 @@
|
|||
<?php
|
||||
|
||||
$this->layout('minimal', [
|
||||
'page_class' => 'podcasts station-' . $station->getShortName(),
|
||||
'title' => 'Podcasts - ' . $this->e($station->getName()),
|
||||
'hide_footer' => true,
|
||||
]);
|
||||
|
||||
$this->fetch('frontend/public/partials/station-custom', ['station' => $station]);
|
||||
|
||||
?>
|
||||
<section id="content" role="main" class="d-flex align-items-stretch" style="height: 100vh;">
|
||||
<div class="container pt-5 pb-5 h-100" style="flex: 1;">
|
||||
<div id="station_podcasts">
|
||||
<div class="row mb-4">
|
||||
<h1 class="mx-auto"><?=$this->e($station->getName())?></h1>
|
||||
<h1 class="mx-auto"><?= $this->e($station->getName()) ?></h1>
|
||||
</div>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
|
|
|
@ -15,6 +15,8 @@ $this->layout(
|
|||
]
|
||||
);
|
||||
|
||||
$this->fetch('frontend/public/partials/station-custom', ['station' => $station]);
|
||||
|
||||
$sections->appendStart('bodyjs');
|
||||
?>
|
||||
<script src="<?= $this->assetUrl('dist/lib/webcaster/taglib.js') ?>"></script>
|
||||
|
|
|
@ -31,6 +31,8 @@ $hide_footer ??= false;
|
|||
<style>
|
||||
<?=$customization->getCustomPublicCss() ?>
|
||||
</style>
|
||||
|
||||
<?= $sections->get('station_head') ?>
|
||||
</head>
|
||||
|
||||
<body class="page-minimal <?= $page_class ?? '' ?>">
|
||||
|
@ -43,6 +45,8 @@ $hide_footer ??= false;
|
|||
<?=$customization->getCustomPublicJs() ?>
|
||||
</script>
|
||||
|
||||
<?= $sections->get('station_bodyjs') ?>
|
||||
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
<?php
|
||||
|
|
Loading…
Reference in New Issue