Asset overhaul.
This commit is contained in:
parent
49e3cda913
commit
1c6c8cb31d
50 changed files with 788 additions and 1311 deletions
|
@ -1,215 +0,0 @@
|
|||
<?php
|
||||
|
||||
use App\Http\ServerRequest;
|
||||
use App\Middleware\Auth\ApiAuth;
|
||||
use App\Session\Csrf;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
|
||||
/**
|
||||
* Static assets referenced in AzuraCast.
|
||||
* Stored here to easily resolve dependencies on individual pages.
|
||||
*/
|
||||
|
||||
return [
|
||||
'jquery' => [
|
||||
'order' => 0,
|
||||
'files' => [
|
||||
'js' => [
|
||||
[
|
||||
'src' => 'dist/lib/jquery/jquery.min.js',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'minimal' => [
|
||||
'order' => 2,
|
||||
'require' => ['jquery'],
|
||||
'files' => [
|
||||
'js' => [
|
||||
[
|
||||
'src' => 'dist/lib/bootstrap/bootstrap.bundle.min.js',
|
||||
],
|
||||
[
|
||||
'src' => 'dist/lib/bootstrap-notify/bootstrap-notify.min.js',
|
||||
'defer' => true,
|
||||
],
|
||||
[
|
||||
'src' => 'dist/app.js',
|
||||
'defer' => true,
|
||||
],
|
||||
[
|
||||
'src' => 'dist/material.js',
|
||||
],
|
||||
],
|
||||
'css' => [
|
||||
[
|
||||
'href' => 'dist/lib/roboto-fontface/css/roboto/roboto-fontface.css',
|
||||
],
|
||||
[
|
||||
'href' => 'dist/style.css',
|
||||
],
|
||||
],
|
||||
],
|
||||
'inline' => [
|
||||
'js' => [
|
||||
function (Request $request) {
|
||||
/** @var App\Session\Flash|null $flashObj */
|
||||
$flashObj = $request->getAttribute(ServerRequest::ATTR_SESSION_FLASH);
|
||||
|
||||
if (null === $flashObj || !$flashObj->hasMessages()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$notifies = [];
|
||||
foreach ($flashObj->getMessages() as $message) {
|
||||
$notifyMessage = str_replace(['"', "\n"], ['\'', '<br>'], $message['text']);
|
||||
$notifies[] = 'notify("' . $notifyMessage . '", "' . $message['color'] . '");';
|
||||
}
|
||||
|
||||
return '$(function () { ' . implode('', $notifies) . ' });';
|
||||
},
|
||||
function (Request $request) {
|
||||
$localeObj = $request->getAttribute(ServerRequest::ATTR_LOCALE);
|
||||
|
||||
$locale = ($localeObj instanceof App\Enums\SupportedLocales)
|
||||
? $localeObj->value
|
||||
: App\Enums\SupportedLocales::default()->value;
|
||||
|
||||
$locale = explode('.', $locale, 2)[0];
|
||||
$localeShort = substr($locale, 0, 2);
|
||||
$localeWithDashes = str_replace('_', '-', $locale);
|
||||
|
||||
// User profile-specific 24-hour display setting.
|
||||
$userObj = $request->getAttribute(ServerRequest::ATTR_USER);
|
||||
$show24Hours = ($userObj instanceof App\Entity\User)
|
||||
? $userObj->getShow24HourTime()
|
||||
: null;
|
||||
|
||||
$timeConfig = new \stdClass();
|
||||
if (null !== $show24Hours) {
|
||||
$timeConfig->hour12 = !$show24Hours;
|
||||
}
|
||||
|
||||
$app = [
|
||||
'lang' => [
|
||||
'confirm' => __('Are you sure?'),
|
||||
'advanced' => __('Advanced'),
|
||||
],
|
||||
'locale' => $locale,
|
||||
'locale_short' => $localeShort,
|
||||
'locale_with_dashes' => $localeWithDashes,
|
||||
'time_config' => $timeConfig,
|
||||
'api_csrf' => null,
|
||||
];
|
||||
|
||||
return 'document.body.App = ' . json_encode($app, JSON_THROW_ON_ERROR) . ';';
|
||||
},
|
||||
<<<'JS'
|
||||
document.body.currentTheme = document.documentElement.getAttribute('data-theme');
|
||||
if (document.body.currentTheme === 'browser') {
|
||||
document.body.currentTheme = (window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
|
||||
}
|
||||
document.body.App.theme = document.body.currentTheme;
|
||||
JS,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'main' => [
|
||||
'order' => 3,
|
||||
'require' => ['minimal'],
|
||||
'files' => [
|
||||
'js' => [
|
||||
[
|
||||
'src' => 'dist/lib/sweetalert2/sweetalert2.min.js',
|
||||
'defer' => true,
|
||||
],
|
||||
[
|
||||
'src' => 'dist/lib/turbo/turbo.es2017-umd.js',
|
||||
],
|
||||
],
|
||||
],
|
||||
'inline' => [
|
||||
'js' => [
|
||||
function (Request $request) {
|
||||
$csrfJson = 'null';
|
||||
$csrf = $request->getAttribute(ServerRequest::ATTR_SESSION_CSRF);
|
||||
if ($csrf instanceof Csrf) {
|
||||
$csrfToken = $csrf->generate(ApiAuth::API_CSRF_NAMESPACE);
|
||||
$csrfJson = json_encode($csrfToken, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
return "document.body.App.api_csrf = ${csrfJson};";
|
||||
},
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'luxon' => [
|
||||
'order' => 8,
|
||||
'files' => [
|
||||
'js' => [
|
||||
[
|
||||
'src' => 'dist/lib/luxon/luxon.min.js',
|
||||
],
|
||||
],
|
||||
],
|
||||
'inline' => [
|
||||
'js' => [
|
||||
function (Request $request) {
|
||||
return <<<'JS'
|
||||
luxon.Settings.defaultLocale = App.locale_with_dashes;
|
||||
luxon.Settings.defaultZoneName = 'UTC';
|
||||
JS;
|
||||
},
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'humanize-duration' => [
|
||||
'order' => 8,
|
||||
'files' => [
|
||||
'js' => [
|
||||
[
|
||||
'src' => 'dist/lib/humanize-duration/humanize-duration.js',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'clipboard' => [
|
||||
'order' => 10,
|
||||
'files' => [
|
||||
'js' => [
|
||||
[
|
||||
'src' => 'dist/lib/clipboard/clipboard.min.js',
|
||||
],
|
||||
],
|
||||
],
|
||||
'inline' => [
|
||||
'js' => [
|
||||
"new ClipboardJS('.btn-copy');",
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'Vue_PublicWebDJ' => [
|
||||
'order' => 10,
|
||||
'files' => [
|
||||
'js' => [
|
||||
[
|
||||
'src' => 'dist/lib/webcaster/libshine.js',
|
||||
],
|
||||
[
|
||||
'src' => 'dist/lib/webcaster/libsamplerate.js',
|
||||
],
|
||||
[
|
||||
'src' => 'dist/lib/webcaster/taglib.js',
|
||||
],
|
||||
[
|
||||
'src' => 'dist/lib/webcaster/webcast.js',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
|
@ -55,28 +55,12 @@ var jsFiles = {
|
|||
'node_modules/luxon/build/global/luxon.min.js'
|
||||
]
|
||||
},
|
||||
'humanize-duration': {
|
||||
files: [
|
||||
'node_modules/humanize-duration/humanize-duration.js'
|
||||
]
|
||||
},
|
||||
'clipboard': {
|
||||
base: 'node_modules/clipboard/dist',
|
||||
files: [
|
||||
'node_modules/clipboard/dist/clipboard.min.js'
|
||||
]
|
||||
},
|
||||
'webcaster': {
|
||||
base: null,
|
||||
files: [
|
||||
'js/webcaster/*.js'
|
||||
]
|
||||
},
|
||||
'turbo': {
|
||||
files: [
|
||||
'node_modules/@hotwired/turbo/dist/turbo.es2017-umd.js'
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
var defaultTasks = Object.keys(jsFiles);
|
||||
|
|
|
@ -5,7 +5,7 @@ function confirmDangerousAction (el) {
|
|||
$el = $el.closest('a');
|
||||
}
|
||||
|
||||
let confirmTitle = document.body.App.lang.confirm;
|
||||
let confirmTitle = App.lang.confirm;
|
||||
if ($el.data('confirm-title')) {
|
||||
confirmTitle = $el.data('confirm-title');
|
||||
}
|
||||
|
|
14
frontend/npm-shrinkwrap.json
generated
14
frontend/npm-shrinkwrap.json
generated
|
@ -16,7 +16,6 @@
|
|||
"@fullcalendar/luxon2": "^5.10.2",
|
||||
"@fullcalendar/timegrid": "^5.9.0",
|
||||
"@fullcalendar/vue": "^5.9.0",
|
||||
"@hotwired/turbo": "^7.2.4",
|
||||
"axios": "^1",
|
||||
"bootstrap": "^4.6.0 <5",
|
||||
"bootstrap-notify": "^3.1.3",
|
||||
|
@ -1736,14 +1735,6 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hotwired/turbo": {
|
||||
"version": "7.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@hotwired/turbo/-/turbo-7.2.4.tgz",
|
||||
"integrity": "sha512-c3xlOroHp/cCZHDOuLp6uzQYEbvXBUVaal0puXoGJ9M8L/KHwZ3hQozD4dVeSN9msHWLxxtmPT1TlCN7gFhj4w==",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz",
|
||||
|
@ -11332,11 +11323,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"@hotwired/turbo": {
|
||||
"version": "7.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@hotwired/turbo/-/turbo-7.2.4.tgz",
|
||||
"integrity": "sha512-c3xlOroHp/cCZHDOuLp6uzQYEbvXBUVaal0puXoGJ9M8L/KHwZ3hQozD4dVeSN9msHWLxxtmPT1TlCN7gFhj4w=="
|
||||
},
|
||||
"@jridgewell/gen-mapping": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz",
|
||||
|
|
|
@ -17,7 +17,6 @@
|
|||
"@fullcalendar/luxon2": "^5.10.2",
|
||||
"@fullcalendar/timegrid": "^5.9.0",
|
||||
"@fullcalendar/vue": "^5.9.0",
|
||||
"@hotwired/turbo": "^7.2.4",
|
||||
"axios": "^1",
|
||||
"bootstrap": "^4.6.0 <5",
|
||||
"bootstrap-notify": "^3.1.3",
|
||||
|
|
|
@ -12,13 +12,13 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||
silent: true
|
||||
});
|
||||
|
||||
if (typeof document.body.App.locale !== 'undefined') {
|
||||
Vue.config.language = document.body.App.locale;
|
||||
if (typeof App.locale !== 'undefined') {
|
||||
Vue.config.language = App.locale;
|
||||
}
|
||||
|
||||
// Configure auto-CSRF on requests
|
||||
if (typeof document.body.App.api_csrf !== 'undefined') {
|
||||
axios.defaults.headers.common['X-API-CSRF'] = document.body.App.api_csrf;
|
||||
if (typeof App.api_csrf !== 'undefined') {
|
||||
axios.defaults.headers.common['X-API-CSRF'] = App.api_csrf;
|
||||
}
|
||||
|
||||
Vue.use(VueAxios, axios);
|
||||
|
|
|
@ -144,7 +144,7 @@ export default {
|
|||
},
|
||||
formatTimestamp(unix_timestamp) {
|
||||
return DateTime.fromSeconds(unix_timestamp).toLocaleString(
|
||||
{...DateTime.DATETIME_SHORT, ...document.body.App.time_config}
|
||||
{...DateTime.DATETIME_SHORT, ...App.time_config}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -184,7 +184,7 @@ export default {
|
|||
},
|
||||
toLocaleTime(timestamp) {
|
||||
return DateTime.fromSeconds(timestamp).toLocaleString(
|
||||
{...DateTime.DATETIME_SHORT, ...document.body.App.time_config}
|
||||
{...DateTime.DATETIME_SHORT, ...App.time_config}
|
||||
);
|
||||
},
|
||||
formatFileSize(size) {
|
||||
|
|
|
@ -105,7 +105,7 @@ export default {
|
|||
singleFile: !this.allowMultiple,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'X-API-CSRF': document.body.App.api_csrf
|
||||
'X-API-CSRF': App.api_csrf
|
||||
},
|
||||
withCredentials: true,
|
||||
allowDuplicateUploads: true,
|
||||
|
|
|
@ -19,7 +19,7 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
calendarOptions: {
|
||||
locale: document.body.App.locale_short,
|
||||
locale: App.locale_short,
|
||||
locales: allLocales,
|
||||
plugins: [luxon2Plugin, timeGridPlugin],
|
||||
initialView: 'timeGridWeek',
|
||||
|
@ -36,7 +36,7 @@ export default {
|
|||
views: {
|
||||
timeGridWeek: {
|
||||
slotLabelFormat: {
|
||||
...document.body.App.time_config,
|
||||
...App.time_config,
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
omitZeroMinute: true,
|
||||
|
|
|
@ -268,7 +268,7 @@ export default {
|
|||
return '';
|
||||
}
|
||||
return DateTime.fromSeconds(value).setZone(this.stationTimeZone).toLocaleString(
|
||||
{...DateTime.DATETIME_MED, ...document.body.App.time_config}
|
||||
{...DateTime.DATETIME_MED, ...App.time_config}
|
||||
);
|
||||
},
|
||||
selectable: true,
|
||||
|
|
|
@ -193,13 +193,13 @@ export default {
|
|||
},
|
||||
formatTime (time) {
|
||||
return DateTime.fromSeconds(time).setZone(this.stationTimeZone).toLocaleString(
|
||||
{...DateTime.DATETIME_MED, ...document.body.App.time_config}
|
||||
{...DateTime.DATETIME_MED, ...App.time_config}
|
||||
);
|
||||
},
|
||||
formatLength (length) {
|
||||
return humanizeDuration(length * 1000, {
|
||||
round: true,
|
||||
language: document.body.App.locale_short,
|
||||
language: App.locale_short,
|
||||
fallbacks: ['en']
|
||||
});
|
||||
},
|
||||
|
|
|
@ -61,21 +61,21 @@ export default {
|
|||
|
||||
if (start_moment.hasSame(now, 'day')) {
|
||||
row.start_formatted = start_moment.toLocaleString(
|
||||
{...DateTime.TIME_SIMPLE, ...document.body.App.time_config}
|
||||
{...DateTime.TIME_SIMPLE, ...App.time_config}
|
||||
);
|
||||
} else {
|
||||
row.start_formatted = start_moment.toLocaleString(
|
||||
{...DateTime.DATETIME_MED, ...document.body.App.time_config}
|
||||
{...DateTime.DATETIME_MED, ...App.time_config}
|
||||
);
|
||||
}
|
||||
|
||||
if (end_moment.hasSame(start_moment, 'day')) {
|
||||
row.end_formatted = end_moment.toLocaleString(
|
||||
{...DateTime.TIME_SIMPLE, ...document.body.App.time_config}
|
||||
{...DateTime.TIME_SIMPLE, ...App.time_config}
|
||||
);
|
||||
} else {
|
||||
row.end_formatted = end_moment.toLocaleString(
|
||||
{...DateTime.DATETIME_MED, ...document.body.App.time_config}
|
||||
{...DateTime.DATETIME_MED, ...App.time_config}
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -82,7 +82,7 @@ export default {
|
|||
methods: {
|
||||
formatTime(time) {
|
||||
return this.getDateTime(time).toLocaleString(
|
||||
{...DateTime.TIME_WITH_SECONDS, ...document.body.App.time_config}
|
||||
{...DateTime.TIME_WITH_SECONDS, ...App.time_config}
|
||||
);
|
||||
},
|
||||
formatRelativeTime(time) {
|
||||
|
|
|
@ -41,7 +41,7 @@ export default {
|
|||
},
|
||||
data() {
|
||||
return {
|
||||
tileUrl: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/' + document.body.App.theme + '_all/{z}/{x}/{y}.png',
|
||||
tileUrl: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/' + App.theme + '_all/{z}/{x}/{y}.png',
|
||||
tileAttribution: 'Map tiles by Carto, under CC BY 3.0. Data by OpenStreetMap, under ODbL.',
|
||||
}
|
||||
},
|
||||
|
|
|
@ -105,7 +105,7 @@ export default {
|
|||
},
|
||||
formatTime(time) {
|
||||
return DateTime.fromSeconds(time).setZone(this.stationTimeZone).toLocaleString(
|
||||
{...DateTime.DATETIME_MED, ...document.body.App.time_config}
|
||||
{...DateTime.DATETIME_MED, ...App.time_config}
|
||||
);
|
||||
},
|
||||
doDelete(url) {
|
||||
|
|
|
@ -166,12 +166,12 @@ export default {
|
|||
},
|
||||
formatTimestamp(unix_timestamp) {
|
||||
return DateTime.fromSeconds(unix_timestamp).toLocaleString(
|
||||
{...DateTime.DATETIME_SHORT, ...document.body.App.time_config}
|
||||
{...DateTime.DATETIME_SHORT, ...App.time_config}
|
||||
);
|
||||
},
|
||||
formatTimestampStation(unix_timestamp) {
|
||||
return DateTime.fromSeconds(unix_timestamp).setZone(this.stationTimeZone).toLocaleString(
|
||||
{...DateTime.DATETIME_SHORT, ...document.body.App.time_config}
|
||||
{...DateTime.DATETIME_SHORT, ...App.time_config}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -63,7 +63,7 @@ export default {
|
|||
sortable: false,
|
||||
formatter: (value, key, item) => {
|
||||
return DateTime.fromSeconds(value).toLocaleString(
|
||||
{...DateTime.DATETIME_MED, ...document.body.App.time_config}
|
||||
{...DateTime.DATETIME_MED, ...App.time_config}
|
||||
);
|
||||
},
|
||||
class: 'pl-3'
|
||||
|
@ -77,7 +77,7 @@ export default {
|
|||
return this.$gettext('Live');
|
||||
}
|
||||
return DateTime.fromSeconds(value).toLocaleString(
|
||||
{...DateTime.DATETIME_MED, ...document.body.App.time_config}
|
||||
{...DateTime.DATETIME_MED, ...App.time_config}
|
||||
);
|
||||
}
|
||||
},
|
||||
|
|
2
frontend/vue/vendor/luxon.js
vendored
2
frontend/vue/vendor/luxon.js
vendored
|
@ -1,7 +1,7 @@
|
|||
import {Settings} from 'luxon';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
Settings.defaultLocale = document.body.App.locale_with_dashes;
|
||||
Settings.defaultLocale = App.locale_with_dashes;
|
||||
|
||||
Settings.defaultZoneName = 'UTC';
|
||||
});
|
||||
|
|
567
src/Assets.php
567
src/Assets.php
|
@ -1,567 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use App\Traits\RequestAwareTrait;
|
||||
use App\Utilities\Json;
|
||||
use GuzzleHttp\Psr7\Uri;
|
||||
use InvalidArgumentException;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
use function base64_encode;
|
||||
use function is_array;
|
||||
use function is_callable;
|
||||
use function preg_replace;
|
||||
use function random_bytes;
|
||||
|
||||
/**
|
||||
* Asset management helper class.
|
||||
* Inspired by Asseter by Adam Banaszkiewicz: https://github.com/requtize
|
||||
* @link https://github.com/requtize/assetter
|
||||
*/
|
||||
final class Assets
|
||||
{
|
||||
use RequestAwareTrait;
|
||||
|
||||
/** @var array<string, array> Known libraries loaded in initialization. */
|
||||
private array $libraries = [];
|
||||
|
||||
/** @var array<string, string> An optional array lookup for versioned files. */
|
||||
private array $versioned_files = [];
|
||||
|
||||
/** @var array<string, array> Loaded libraries. */
|
||||
private array $loaded = [];
|
||||
|
||||
/** @var bool Whether the current loaded libraries have been sorted by order. */
|
||||
private bool $is_sorted = true;
|
||||
|
||||
/** @var string A randomly generated number-used-once (nonce) for inline CSP. */
|
||||
private string $csp_nonce;
|
||||
|
||||
/** @var array The loaded domains that should be included in the CSP header. */
|
||||
private array $csp_domains;
|
||||
|
||||
public function __construct(
|
||||
private readonly Environment $environment,
|
||||
array $libraries,
|
||||
) {
|
||||
foreach ($libraries as $library_name => $library) {
|
||||
$this->addLibrary($library, $library_name);
|
||||
}
|
||||
|
||||
$versioned_files = Json::loadFromFile($environment->getBaseDirectory() . '/web/static/assets.json');
|
||||
$this->versioned_files = $versioned_files;
|
||||
|
||||
$vueComponents = Json::loadFromFile($environment->getBaseDirectory() . '/web/static/webpack.json');
|
||||
$this->addVueComponents($vueComponents);
|
||||
|
||||
$this->csp_nonce = (string)preg_replace('/[^A-Za-z\d+\/=]/', '', base64_encode(random_bytes(18)));
|
||||
$this->csp_domains = [];
|
||||
}
|
||||
|
||||
private function addVueComponents(array $vueComponents = []): void
|
||||
{
|
||||
if (!empty($vueComponents['entrypoints'])) {
|
||||
foreach ($vueComponents['entrypoints'] as $componentName => $componentDeps) {
|
||||
$componentName = 'Vue_' . $componentName;
|
||||
|
||||
$library = $this->libraries[$componentName] ?? [
|
||||
'order' => 10,
|
||||
'require' => [],
|
||||
'files' => [],
|
||||
];
|
||||
|
||||
foreach ($componentDeps['assets']['js'] as $componentDep) {
|
||||
$library['files']['js'][] = [
|
||||
'src' => $componentDep,
|
||||
];
|
||||
}
|
||||
|
||||
$this->addLibrary($library, $componentName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a library to the collection.
|
||||
*
|
||||
* @param array $data Array with asset data.
|
||||
* @param string|null $library_name
|
||||
*/
|
||||
public function addLibrary(array $data, string $library_name = null): self
|
||||
{
|
||||
$library_name = $library_name ?? uniqid('', false);
|
||||
|
||||
$this->libraries[$library_name] = [
|
||||
'name' => $library_name,
|
||||
'order' => $data['order'] ?? 0,
|
||||
'files' => $data['files'] ?? [],
|
||||
'inline' => $data['inline'] ?? [],
|
||||
'require' => $data['require'] ?? [],
|
||||
'replace' => $data['replace'] ?? [],
|
||||
];
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $library_name
|
||||
*
|
||||
* @return mixed[]|null
|
||||
*/
|
||||
public function getLibrary(string $library_name): ?array
|
||||
{
|
||||
return $this->libraries[$library_name] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the randomly generated nonce for inline CSP for this request.
|
||||
*/
|
||||
public function getCspNonce(): string
|
||||
{
|
||||
return $this->csp_nonce;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of approved domains for CSP header inclusion.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function getCspDomains(): array
|
||||
{
|
||||
return $this->csp_domains;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single javascript file.
|
||||
*
|
||||
* @param array|string $js_script
|
||||
*/
|
||||
public function addJs(array|string $js_script): self
|
||||
{
|
||||
$this->load(
|
||||
[
|
||||
'order' => 100,
|
||||
'files' => [
|
||||
'js' => [
|
||||
(is_array($js_script)) ? $js_script : ['src' => $js_script],
|
||||
],
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads assets from given name or array definition.
|
||||
*
|
||||
* @param mixed $data Name or array definition of library/asset.
|
||||
*/
|
||||
public function load(mixed $data): self
|
||||
{
|
||||
if (is_array($data)) {
|
||||
$item = [
|
||||
'name' => $data['name'] ?? uniqid('', false),
|
||||
'order' => $data['order'] ?? 0,
|
||||
'files' => $data['files'] ?? [],
|
||||
'inline' => $data['inline'] ?? [],
|
||||
'require' => $data['require'] ?? [],
|
||||
'replace' => $data['replace'] ?? [],
|
||||
];
|
||||
} elseif (isset($this->libraries[$data])) {
|
||||
$item = $this->libraries[$data];
|
||||
} else {
|
||||
throw new InvalidArgumentException(sprintf('Library %s not found!', $data));
|
||||
}
|
||||
|
||||
/** @var string $name */
|
||||
$name = $item['name'];
|
||||
|
||||
// Check if a library is "replaced" by other libraries already loaded.
|
||||
$is_replaced = false;
|
||||
foreach ($this->loaded as $loaded_item) {
|
||||
if (!empty($loaded_item['replace']) && in_array($name, (array)$loaded_item['replace'], true)) {
|
||||
$is_replaced = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$is_replaced && !isset($this->loaded[$name])) {
|
||||
if (!empty($item['replace'])) {
|
||||
foreach ((array)$item['replace'] as $replace_name) {
|
||||
$this->unload($replace_name);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($item['require'])) {
|
||||
foreach ((array)$item['require'] as $require_name) {
|
||||
$this->load($require_name);
|
||||
}
|
||||
}
|
||||
|
||||
$this->loaded[$name] = $item;
|
||||
$this->is_sorted = false;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload a given library if it's already loaded.
|
||||
*
|
||||
* @param string $name
|
||||
*/
|
||||
public function unload(string $name): self
|
||||
{
|
||||
if (isset($this->loaded[$name])) {
|
||||
unset($this->loaded[$name]);
|
||||
$this->is_sorted = false;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single javascript inline script.
|
||||
*
|
||||
* @param array|string $js_script
|
||||
* @param int $order
|
||||
*/
|
||||
public function addInlineJs(array|string $js_script, int $order = 100): self
|
||||
{
|
||||
$this->load(
|
||||
[
|
||||
'order' => $order,
|
||||
'inline' => [
|
||||
'js' => (is_array($js_script)) ? $js_script : [$js_script],
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function addVueRender(string $name, string $elementId, array $props = []): self
|
||||
{
|
||||
$this->load($name);
|
||||
|
||||
$nameWithoutPrefix = str_replace('Vue_', '', $name);
|
||||
$propsJson = json_encode($props, JSON_THROW_ON_ERROR);
|
||||
|
||||
$this->addInlineJs(
|
||||
<<<JS
|
||||
$(function () {
|
||||
document.body.{$name} = {$nameWithoutPrefix}.default('{$elementId}', {$propsJson});
|
||||
});
|
||||
JS
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single CSS file.
|
||||
*
|
||||
* @param array|string $css_script
|
||||
* @param int $order
|
||||
*/
|
||||
public function addCss(array|string $css_script, int $order = 100): self
|
||||
{
|
||||
$this->load(
|
||||
[
|
||||
'order' => $order,
|
||||
'files' => [
|
||||
'css' => [
|
||||
(is_array($css_script)) ? $css_script : ['src' => $css_script],
|
||||
],
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single inline CSS file[s].
|
||||
*
|
||||
* @param array|string $css_script
|
||||
*/
|
||||
public function addInlineCss(array|string $css_script): self
|
||||
{
|
||||
$this->load(
|
||||
[
|
||||
'order' => 100,
|
||||
'inline' => [
|
||||
'css' => (is_array($css_script)) ? $css_script : [$css_script],
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all CSS includes and inline styles.
|
||||
*
|
||||
* @return string HTML tags as string.
|
||||
*/
|
||||
public function css(): string
|
||||
{
|
||||
$this->sort();
|
||||
|
||||
$result = [];
|
||||
foreach ($this->loaded as $item) {
|
||||
if (!empty($item['files']['css'])) {
|
||||
foreach ($item['files']['css'] as $file) {
|
||||
$attributes = $this->resolveAttributes(
|
||||
$file,
|
||||
[
|
||||
'rel' => 'stylesheet',
|
||||
'type' => 'text/css',
|
||||
]
|
||||
);
|
||||
|
||||
$key = $attributes['href'];
|
||||
if (isset($result[$key])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[$key] = '<link ' . implode(' ', $this->compileAttributes($attributes)) . ' />';
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($item['inline']['css'])) {
|
||||
foreach ($item['inline']['css'] as $inline) {
|
||||
if (!empty($inline)) {
|
||||
$result[] = sprintf(
|
||||
'<style type="text/css" nonce="%s">%s%s</style>',
|
||||
$this->csp_nonce,
|
||||
"\n",
|
||||
$inline
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return implode("\n", $result) . "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all script include tags.
|
||||
*
|
||||
* @return string HTML tags as string.
|
||||
*/
|
||||
public function js(): string
|
||||
{
|
||||
$this->sort();
|
||||
|
||||
$result = [];
|
||||
foreach ($this->loaded as $item) {
|
||||
if (!empty($item['files']['js'])) {
|
||||
foreach ($item['files']['js'] as $file) {
|
||||
$attributes = $this->resolveAttributes(
|
||||
$file,
|
||||
[
|
||||
'type' => 'text/javascript',
|
||||
]
|
||||
);
|
||||
|
||||
$key = $attributes['src'];
|
||||
if (isset($result[$key])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[$key] = '<script ' . implode(' ', $this->compileAttributes($attributes)) . '></script>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return implode("\n", $result) . "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Return any inline JavaScript.
|
||||
*/
|
||||
public function inlineJs(): string
|
||||
{
|
||||
$this->sort();
|
||||
|
||||
$result = [];
|
||||
foreach ($this->loaded as $item) {
|
||||
if (!empty($item['inline']['js'])) {
|
||||
foreach ($item['inline']['js'] as $inline) {
|
||||
if (is_callable($inline)) {
|
||||
$inline = $inline($this->request);
|
||||
}
|
||||
|
||||
if (!empty($inline)) {
|
||||
$result[] = sprintf(
|
||||
'<script type="text/javascript" nonce="%s">%s%s</script>',
|
||||
$this->csp_nonce,
|
||||
"\n",
|
||||
$inline
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return implode("\n", $result) . "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort the list of loaded libraries.
|
||||
*/
|
||||
private function sort(): void
|
||||
{
|
||||
if (!$this->is_sorted) {
|
||||
uasort(
|
||||
$this->loaded,
|
||||
static function ($a, $b): int {
|
||||
return $a['order'] <=> $b['order']; // SPACESHIP!
|
||||
}
|
||||
);
|
||||
|
||||
$this->is_sorted = true;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveAttributes(array $file, array $defaults): array
|
||||
{
|
||||
if (isset($file['src'])) {
|
||||
$defaults['src'] = $this->getUrl($file['src']);
|
||||
unset($file['src']);
|
||||
}
|
||||
|
||||
if (isset($file['href'])) {
|
||||
$defaults['href'] = $this->getUrl($file['href']);
|
||||
unset($file['href']);
|
||||
}
|
||||
|
||||
if (isset($file['integrity'])) {
|
||||
$defaults['crossorigin'] = 'anonymous';
|
||||
}
|
||||
|
||||
return array_merge($defaults, $file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the proper include tag for a JS/CSS include.
|
||||
*
|
||||
* @param array $attributes
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
private function compileAttributes(array $attributes): array
|
||||
{
|
||||
$compiled_attributes = [];
|
||||
foreach ($attributes as $attr_key => $attr_val) {
|
||||
// Check for attributes like "defer"
|
||||
if ($attr_val === true) {
|
||||
$compiled_attributes[] = $attr_key;
|
||||
} else {
|
||||
$compiled_attributes[] = $attr_key . '="' . $attr_val . '"';
|
||||
}
|
||||
}
|
||||
|
||||
return $compiled_attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[] The paths to all currently loaded files.
|
||||
*/
|
||||
public function getLoadedFiles(): array
|
||||
{
|
||||
$this->sort();
|
||||
|
||||
$result = [];
|
||||
foreach ($this->loaded as $item) {
|
||||
if (!empty($item['files']['js'])) {
|
||||
foreach ($item['files']['js'] as $file) {
|
||||
if (isset($file['src'])) {
|
||||
$result[] = $this->getUrl($file['src']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($item['files']['css'])) {
|
||||
foreach ($item['files']['css'] as $file) {
|
||||
if (isset($file['href'])) {
|
||||
$result[] = $this->getUrl($file['href']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the URI of the resource, whether local or remote/CDN-based.
|
||||
*
|
||||
* @param string $resource_uri
|
||||
*
|
||||
* @return string The resolved resource URL.
|
||||
*/
|
||||
public function getUrl(string $resource_uri): string
|
||||
{
|
||||
if (isset($this->versioned_files[$resource_uri])) {
|
||||
$resource_uri = $this->versioned_files[$resource_uri];
|
||||
}
|
||||
|
||||
if (str_starts_with($resource_uri, 'http')) {
|
||||
$this->addDomainToCsp($resource_uri);
|
||||
return $resource_uri;
|
||||
}
|
||||
|
||||
if (str_starts_with($resource_uri, '/')) {
|
||||
return $resource_uri;
|
||||
}
|
||||
|
||||
return $this->environment->getAssetUrl() . '/' . $resource_uri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the loaded domain to the full list of CSP-approved domains.
|
||||
*
|
||||
* @param string $src
|
||||
*/
|
||||
private function addDomainToCsp(string $src): void
|
||||
{
|
||||
$uri = new Uri($src);
|
||||
|
||||
$domain = $uri->getScheme() . '://' . $uri->getHost();
|
||||
if (!isset($this->csp_domains[$domain])) {
|
||||
$this->csp_domains[$domain] = $domain;
|
||||
}
|
||||
}
|
||||
|
||||
public function writeCsp(ResponseInterface $response): ResponseInterface
|
||||
{
|
||||
$csp = [];
|
||||
if (null !== $this->request && 'https' === $this->request->getUri()->getScheme()) {
|
||||
$csp[] = 'upgrade-insecure-requests';
|
||||
}
|
||||
|
||||
// CSP JavaScript policy
|
||||
// Note: unsafe-eval included for Vue template compiling
|
||||
$cspScriptSrc = $this->getCspDomains();
|
||||
$cspScriptSrc[] = "'self'";
|
||||
$cspScriptSrc[] = "'unsafe-eval'";
|
||||
$cspScriptSrc[] = "'unsafe-inline'";
|
||||
// $cspScriptSrc[] = "'nonce-" . $this->getCspNonce() . "'";
|
||||
$csp[] = 'script-src ' . implode(' ', $cspScriptSrc);
|
||||
|
||||
$cspWorkerSrc = [];
|
||||
$cspWorkerSrc[] = "blob:";
|
||||
$cspWorkerSrc[] = "'self'";
|
||||
|
||||
$csp[] = 'worker-src ' . implode(' ', $cspWorkerSrc);
|
||||
|
||||
return $response->withHeader('Content-Security-Policy', implode('; ', $csp));
|
||||
}
|
||||
}
|
|
@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Controller\Frontend\PublicPages;
|
||||
|
||||
use App\Assets;
|
||||
use App\Exception\StationNotFoundException;
|
||||
use App\Exception\StationUnsupportedException;
|
||||
use App\Http\Response;
|
||||
|
@ -15,7 +14,6 @@ use Psr\Http\Message\ResponseInterface;
|
|||
final class WebDjAction
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Assets $assets,
|
||||
private readonly Adapters $adapters,
|
||||
) {
|
||||
}
|
||||
|
@ -40,34 +38,16 @@ final class WebDjAction
|
|||
throw new StationUnsupportedException();
|
||||
}
|
||||
|
||||
$router = $request->getRouter();
|
||||
|
||||
$wss_url = (string)$backend->getWebStreamingUrl($station, $request->getRouter()->getBaseUrl());
|
||||
$wss_url = str_replace('wss://', '', $wss_url);
|
||||
|
||||
$libUrls = [];
|
||||
$lib = $this->assets->getLibrary('Vue_PublicWebDJ');
|
||||
if (null !== $lib) {
|
||||
foreach (array_slice($lib['files']['js'], 0, -1) as $script) {
|
||||
$libUrls[] = (string)($router->getBaseUrl()->withPath($this->assets->getUrl($script['src'])));
|
||||
}
|
||||
}
|
||||
|
||||
return $request->getView()->renderVuePage(
|
||||
return $request->getView()->renderToResponse(
|
||||
response: $response->withHeader('X-Frame-Options', '*'),
|
||||
component: 'Vue_PublicWebDJ',
|
||||
id: 'web_dj',
|
||||
layout: 'minimal',
|
||||
title: __('Web DJ') . ' - ' . $station->getName(),
|
||||
layoutParams: [
|
||||
'page_class' => 'dj station-' . $station->getShortName(),
|
||||
'hide_footer' => true,
|
||||
],
|
||||
props: [
|
||||
'stationName' => $station->getName(),
|
||||
'libUrls' => $libUrls,
|
||||
'baseUri' => $wss_url,
|
||||
],
|
||||
templateName: 'frontend/public/webdj',
|
||||
templateArgs: [
|
||||
'station' => $station,
|
||||
'wss_url' => $wss_url,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -48,15 +48,14 @@ final class Admin
|
|||
);
|
||||
|
||||
// These two intentionally separated (the sidebar needs admin_panels).
|
||||
$view->addData(
|
||||
[
|
||||
'sidebar' => $view->render(
|
||||
$view->getSections()->set(
|
||||
'sidebar',
|
||||
$view->render(
|
||||
'admin/sidebar',
|
||||
[
|
||||
'active_tab' => $active_tab,
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
return $handler->handle($request);
|
||||
|
|
|
@ -47,16 +47,15 @@ final class Stations
|
|||
$active_tab = $route_parts[1];
|
||||
}
|
||||
|
||||
$view->addData(
|
||||
[
|
||||
'sidebar' => $view->render(
|
||||
$view->getSections()->set(
|
||||
'sidebar',
|
||||
$view->render(
|
||||
'stations/sidebar',
|
||||
[
|
||||
'menu' => $event->getFilteredMenu(),
|
||||
'active' => $active_tab,
|
||||
]
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
return $handler->handle($request);
|
||||
|
|
126
src/View.php
126
src/View.php
|
@ -7,10 +7,10 @@ namespace App;
|
|||
use App\Http\RouterInterface;
|
||||
use App\Http\ServerRequest;
|
||||
use App\Traits\RequestAwareTrait;
|
||||
use Doctrine\Inflector\InflectorFactory;
|
||||
use App\Utilities\Json;
|
||||
use App\View\GlobalSections;
|
||||
use League\Plates\Engine;
|
||||
use League\Plates\Template\Data;
|
||||
use League\Plates\Template\Template;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
@ -21,22 +21,25 @@ final class View extends Engine
|
|||
{
|
||||
use RequestAwareTrait;
|
||||
|
||||
private readonly GlobalSections $sections;
|
||||
|
||||
public function __construct(
|
||||
Environment $environment,
|
||||
EventDispatcherInterface $dispatcher,
|
||||
Version $version,
|
||||
RouterInterface $router,
|
||||
private Assets $assets
|
||||
RouterInterface $router
|
||||
) {
|
||||
parent::__construct($environment->getViewsDirectory(), 'phtml');
|
||||
|
||||
$this->sections = new GlobalSections();
|
||||
|
||||
// Add non-request-dependent content.
|
||||
$this->addData(
|
||||
[
|
||||
'sections' => $this->sections,
|
||||
'environment' => $environment,
|
||||
'version' => $version,
|
||||
'router' => $router,
|
||||
'assets' => $this->assets,
|
||||
]
|
||||
);
|
||||
|
||||
|
@ -62,52 +65,29 @@ final class View extends Engine
|
|||
}
|
||||
);
|
||||
|
||||
$vueComponents = Json::loadFromFile($environment->getBaseDirectory() . '/web/static/webpack.json');
|
||||
$this->registerFunction(
|
||||
'mailto',
|
||||
function ($address, $link_text = null) {
|
||||
$address = substr(chunk_split(bin2hex(" $address"), 2, ';&#x'), 3, -3);
|
||||
$link_text = $link_text ?? $address;
|
||||
return '<a href="mailto:' . $address . '">' . $link_text . '</a>';
|
||||
}
|
||||
'getVueComponentInfo',
|
||||
fn(string $component) => $vueComponents['entrypoints'][$component] ?? null
|
||||
);
|
||||
|
||||
$versionedFiles = Json::loadFromFile($environment->getBaseDirectory() . '/web/static/assets.json');
|
||||
$this->registerFunction(
|
||||
'pluralize',
|
||||
function ($word, $num = 0) {
|
||||
if ((int)$num === 1) {
|
||||
return $word;
|
||||
'assetUrl',
|
||||
function (string $url) use ($environment, $versionedFiles): string {
|
||||
if (isset($versionedFiles[$url])) {
|
||||
$url = $versionedFiles[$url];
|
||||
}
|
||||
|
||||
return InflectorFactory::create()->build()->pluralize($word);
|
||||
}
|
||||
);
|
||||
|
||||
$this->registerFunction(
|
||||
'truncate',
|
||||
function ($text, $length = 80) {
|
||||
return Utilities\Strings::truncateText($text, $length);
|
||||
}
|
||||
);
|
||||
|
||||
$this->registerFunction(
|
||||
'truncateUrl',
|
||||
function ($url) {
|
||||
return Utilities\Strings::truncateUrl($url);
|
||||
}
|
||||
);
|
||||
|
||||
$this->registerFunction(
|
||||
'link',
|
||||
function ($url, $external = true, $truncate = true) {
|
||||
$url = htmlspecialchars($url, ENT_QUOTES);
|
||||
|
||||
$a = ['href="' . $url . '"'];
|
||||
if ($external) {
|
||||
$a[] = 'target="_blank"';
|
||||
if (str_starts_with($url, 'http')) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
$a_body = ($truncate) ? Utilities\Strings::truncateUrl($url) : $url;
|
||||
return '<a ' . implode(' ', $a) . '>' . $a_body . '</a>';
|
||||
if (str_starts_with($url, '/')) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return $environment->getAssetUrl() . '/' . $url;
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -116,13 +96,11 @@ final class View extends Engine
|
|||
|
||||
public function setRequest(?ServerRequestInterface $request): void
|
||||
{
|
||||
$this->assets = $this->assets->withRequest($request);
|
||||
$this->request = $request;
|
||||
|
||||
if (null !== $request) {
|
||||
$this->addData(
|
||||
[
|
||||
'assets' => $this->assets,
|
||||
'request' => $request,
|
||||
'router' => $request->getAttribute(ServerRequest::ATTR_ROUTER),
|
||||
'auth' => $request->getAttribute(ServerRequest::ATTR_AUTH),
|
||||
|
@ -135,6 +113,11 @@ final class View extends Engine
|
|||
}
|
||||
}
|
||||
|
||||
public function getSections(): GlobalSections
|
||||
{
|
||||
return $this->sections;
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->data = new Data();
|
||||
|
@ -161,8 +144,10 @@ final class View extends Engine
|
|||
string $templateName,
|
||||
array $templateArgs = []
|
||||
): ResponseInterface {
|
||||
$template = $this->render($templateName, $templateArgs);
|
||||
return $this->writeStringToResponse($response, $template);
|
||||
$response->getBody()->write(
|
||||
$this->render($templateName, $templateArgs)
|
||||
);
|
||||
return $response->withHeader('Content-type', 'text/html; charset=utf-8');
|
||||
}
|
||||
|
||||
public function renderVuePage(
|
||||
|
@ -176,46 +161,17 @@ final class View extends Engine
|
|||
): ResponseInterface {
|
||||
$id ??= $component;
|
||||
|
||||
$vueTemplate = new class ($this, 'vue') extends Template {
|
||||
public function render(array $data = []): string
|
||||
{
|
||||
$this->data($data);
|
||||
|
||||
/** @noinspection UselessUnsetInspection */
|
||||
unset($data);
|
||||
|
||||
$content = '<div id="' . $this->data['id'] . '"></div>';
|
||||
|
||||
$layout = $this->engine->make($this->layoutName);
|
||||
$layout->sections = array_merge($this->sections, ['content' => $content]);
|
||||
return $layout->render($this->layoutData);
|
||||
}
|
||||
};
|
||||
|
||||
$vueTemplate->layout(
|
||||
$layout,
|
||||
array_merge(
|
||||
return $this->renderToResponse(
|
||||
$response,
|
||||
'system/vue_page',
|
||||
[
|
||||
'component' => $component,
|
||||
'id' => $id,
|
||||
'layout' => $layout,
|
||||
'title' => $title,
|
||||
'manual' => true,
|
||||
],
|
||||
$layoutParams
|
||||
)
|
||||
'layoutParams' => $layoutParams,
|
||||
'props' => $props,
|
||||
]
|
||||
);
|
||||
|
||||
$this->assets->addVueRender($component, '#' . $id, $props);
|
||||
|
||||
$body = $vueTemplate->render(['id' => $id]);
|
||||
return $this->writeStringToResponse($response, $body);
|
||||
}
|
||||
|
||||
private function writeStringToResponse(
|
||||
ResponseInterface $response,
|
||||
string $body
|
||||
): ResponseInterface {
|
||||
$response->getBody()->write($body);
|
||||
$response = $response->withHeader('Content-type', 'text/html; charset=utf-8');
|
||||
|
||||
return $this->assets->writeCsp($response);
|
||||
}
|
||||
}
|
||||
|
|
89
src/View/GlobalSections.php
Normal file
89
src/View/GlobalSections.php
Normal file
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\View;
|
||||
|
||||
use League\Plates\Template\Template;
|
||||
use LogicException;
|
||||
|
||||
/**
|
||||
* A global section container for templates.
|
||||
*/
|
||||
final class GlobalSections
|
||||
{
|
||||
private int $sectionMode = Template::SECTION_MODE_REWRITE;
|
||||
private array $sections = [];
|
||||
private ?string $sectionName = null;
|
||||
|
||||
public function has(string $section): bool
|
||||
{
|
||||
return !empty($this->sections[$section]);
|
||||
}
|
||||
|
||||
public function get(string $section, ?string $default = null): ?string
|
||||
{
|
||||
return $this->sections[$section] ?? $default;
|
||||
}
|
||||
|
||||
public function set(
|
||||
string $section,
|
||||
?string $value,
|
||||
int $mode = Template::SECTION_MODE_REWRITE
|
||||
): void {
|
||||
$initialValue = $this->sections[$section] ?? '';
|
||||
|
||||
$this->sections[$section] = match ($mode) {
|
||||
Template::SECTION_MODE_REWRITE => $value,
|
||||
Template::SECTION_MODE_PREPEND => $value . $initialValue,
|
||||
Template::SECTION_MODE_APPEND => $initialValue . $value
|
||||
};
|
||||
}
|
||||
|
||||
public function prepend(string $section, ?string $value): void
|
||||
{
|
||||
$this->set($section, $value, Template::SECTION_MODE_PREPEND);
|
||||
}
|
||||
|
||||
public function append(string $section, ?string $value): void
|
||||
{
|
||||
$this->set($section, $value, Template::SECTION_MODE_APPEND);
|
||||
}
|
||||
|
||||
public function start(string $name): void
|
||||
{
|
||||
if ($this->sectionName) {
|
||||
throw new LogicException('You cannot nest sections within other sections.');
|
||||
}
|
||||
|
||||
$this->sectionName = $name;
|
||||
|
||||
ob_start();
|
||||
}
|
||||
|
||||
public function appendStart($name): void
|
||||
{
|
||||
$this->sectionMode = Template::SECTION_MODE_APPEND;
|
||||
$this->start($name);
|
||||
}
|
||||
|
||||
public function prependStart($name)
|
||||
{
|
||||
$this->sectionMode = Template::SECTION_MODE_PREPEND;
|
||||
$this->start($name);
|
||||
}
|
||||
|
||||
public function end(): void
|
||||
{
|
||||
if (is_null($this->sectionName)) {
|
||||
throw new LogicException(
|
||||
'You must start a section before you can stop it.'
|
||||
);
|
||||
}
|
||||
|
||||
$this->set($this->sectionName, ob_get_clean(), $this->sectionMode);
|
||||
|
||||
$this->sectionName = null;
|
||||
$this->sectionMode = Template::SECTION_MODE_REWRITE;
|
||||
}
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
jQuery(function ($) {
|
||||
$('time[data-duration]').each(function () {
|
||||
$(this).text(luxon.DateTime.fromSeconds($(this).data('duration')).toRelative());
|
||||
});
|
||||
});
|
|
@ -1,6 +1,10 @@
|
|||
<?php
|
||||
/**
|
||||
* @var \App\Assets $assets
|
||||
* @var App\View\GlobalSections $sections
|
||||
* @var App\Http\RouterInterface $router
|
||||
* @var array $stations
|
||||
* @var array $sync_times
|
||||
* @var array $queue_totals
|
||||
*/
|
||||
|
||||
$this->layout('main', [
|
||||
|
@ -8,9 +12,19 @@ $this->layout('main', [
|
|||
'manual' => true,
|
||||
]);
|
||||
|
||||
$assets
|
||||
->load('luxon')
|
||||
->addInlineJs($this->fetch('admin/debug/index.js'), 99);
|
||||
$sections->appendStart('bodyjs');
|
||||
?>
|
||||
<script src="<?= $this->assetUrl('dist/lib/luxon/luxon.min.js') ?>"></script>
|
||||
|
||||
<script>
|
||||
jQuery(function ($) {
|
||||
$('time[data-duration]').each(function () {
|
||||
$(this).text(luxon.DateTime.fromSeconds($(this).data('duration')).toRelative());
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<?php
|
||||
$sections->end();
|
||||
?>
|
||||
|
||||
<h2 class="outside-card-header mb-1"><?= __('System Debugger') ?></h2>
|
||||
|
@ -183,7 +197,7 @@ $assets
|
|||
</div>
|
||||
</div>
|
||||
<?php
|
||||
if ($station['backend_type'] === \App\Radio\Enums\BackendAdapters::Liquidsoap->value): ?>
|
||||
if ($station['backend_type'] === App\Radio\Enums\BackendAdapters::Liquidsoap->value): ?>
|
||||
<div class="col-md-4">
|
||||
<h5><?= __('Send Liquidsoap Telnet Command') ?></h5>
|
||||
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
$(function () {
|
||||
$('time[data-content]').each(function () {
|
||||
let tz_display = $(this).data('content');
|
||||
$(this).text(luxon.DateTime.fromSeconds(tz_display).toLocaleString(luxon.DateTime.TIME_SIMPLE));
|
||||
});
|
||||
});
|
|
@ -1,13 +1,28 @@
|
|||
<?php
|
||||
/**
|
||||
* @var App\View\GlobalSections $sections
|
||||
* @var array $relays
|
||||
*/
|
||||
|
||||
$this->layout('main', [
|
||||
'title' => __('Connected AzuraRelays'),
|
||||
'manual' => true
|
||||
'manual' => true,
|
||||
]);
|
||||
|
||||
/** @var \App\Assets $assets */
|
||||
$assets
|
||||
->load('luxon')
|
||||
->addInlineJs($this->fetch('admin/relays/index.js'));
|
||||
$sections->appendStart('bodyjs');
|
||||
?>
|
||||
<script src="<?= $this->assetUrl('dist/lib/luxon/luxon.min.js') ?>"></script>
|
||||
|
||||
<script>
|
||||
$(function () {
|
||||
$('time[data-content]').each(function () {
|
||||
let tz_display = $(this).data('content');
|
||||
$(this).text(luxon.DateTime.fromSeconds(tz_display).toLocaleString(luxon.DateTime.TIME_SIMPLE));
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<?php
|
||||
$sections->end();
|
||||
?>
|
||||
|
||||
<div class="card">
|
||||
|
@ -24,7 +39,8 @@ $assets
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach($relays as $row): ?>
|
||||
<?php
|
||||
foreach ($relays as $row): ?>
|
||||
<tr class="align-middle">
|
||||
<td class="text-center">
|
||||
<big>
|
||||
|
|
|
@ -1,3 +1,11 @@
|
|||
<?php
|
||||
/**
|
||||
* @var App\Http\RouterInterface $router
|
||||
* @var array $admin_panels
|
||||
*/
|
||||
|
||||
?>
|
||||
|
||||
<div class="navdrawer-header">
|
||||
<a class="navbar-brand px-0" href="<?= $router->named('admin:index:index') ?>">
|
||||
<?= __('Administration') ?>
|
||||
|
|
|
@ -1,4 +1,9 @@
|
|||
<?php
|
||||
/**
|
||||
* @var App\Customization $customization
|
||||
* @var App\Environment $environment
|
||||
* @var App\Http\RouterInterface $router
|
||||
*/
|
||||
|
||||
$this->layout(
|
||||
'minimal',
|
||||
|
@ -70,7 +75,7 @@ $this->layout(
|
|||
|
||||
<p class="text-center"><?=__('Please log in to continue.')?> <?=sprintf(
|
||||
'<a href="%s">%s</a>',
|
||||
(string)$router->named('account:forgot'),
|
||||
$router->named('account:forgot'),
|
||||
__('Forgot your password?')
|
||||
)?></p>
|
||||
</div>
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
<?php
|
||||
/**
|
||||
* @var \App\Entity\Station $station
|
||||
* @var \App\Customization $customization
|
||||
* @var App\Entity\Station $station
|
||||
* @var App\Customization $customization
|
||||
* @var App\View\GlobalSections $sections
|
||||
* @var App\Http\RouterInterface $router
|
||||
* @var array $props
|
||||
* @var string $nowPlayingArtUri
|
||||
*/
|
||||
|
||||
$this->layout(
|
||||
|
@ -12,23 +16,10 @@ $this->layout(
|
|||
]
|
||||
);
|
||||
|
||||
/** @var \App\Assets $assets */
|
||||
$assets->addVueRender('Vue_PublicFullPlayer', '#public-radio-player', $props);
|
||||
|
||||
// Register PWA service worker
|
||||
$swJsRoute = (string)$router->named('public:sw');
|
||||
$swJsRoute = $router->named('public:sw');
|
||||
|
||||
$assets->addInlineJs(
|
||||
<<<JS
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function() {
|
||||
navigator.serviceWorker.register('${swJsRoute}');
|
||||
});
|
||||
}
|
||||
JS
|
||||
);
|
||||
|
||||
$this->push('head');
|
||||
$sections->appendStart('head');
|
||||
?>
|
||||
<link rel="manifest" href="<?= $router->fromHere('public:manifest') ?>">
|
||||
|
||||
|
@ -50,9 +41,27 @@ $this->push('head');
|
|||
<meta property="twitter:player:width" content="400">
|
||||
<meta property="twitter:player:height" content="125">
|
||||
<?php
|
||||
$this->end();
|
||||
?>
|
||||
$sections->end();
|
||||
|
||||
<div id="public-radio-player">
|
||||
Loading...
|
||||
</div>
|
||||
$sections->appendStart('bodyjs');
|
||||
?>
|
||||
<script>
|
||||
$(function () {
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function () {
|
||||
navigator.serviceWorker.register('${swJsRoute}');
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<?php
|
||||
$sections->end();
|
||||
|
||||
echo $this->fetch(
|
||||
'partials/vue_body',
|
||||
[
|
||||
'component' => 'Vue_PublicFullPlayer',
|
||||
'id' => 'public-radio-player',
|
||||
'props' => $props,
|
||||
]
|
||||
);
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
$(function () {
|
||||
$('[data-toggle="tooltip"]').tooltip();
|
||||
});
|
|
@ -1,4 +1,8 @@
|
|||
<?php
|
||||
/**
|
||||
* @var \App\Http\RouterInterface $router
|
||||
* @var \App\View\GlobalSections $sections
|
||||
*/
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
|
@ -8,12 +12,7 @@ $this->layout('minimal', [
|
|||
'hide_footer' => true,
|
||||
]);
|
||||
|
||||
/** @var \App\Assets $assets */
|
||||
$assets->addInlineJs(
|
||||
$this->fetch('frontend/public/podcast-episode.js', [])
|
||||
);
|
||||
|
||||
$episodeAudioSrc = (string) $router->named(
|
||||
$episodeAudioSrc = $router->named(
|
||||
'api:stations:podcast:episode:download',
|
||||
[
|
||||
'station_id' => $station->getId(),
|
||||
|
@ -30,11 +29,20 @@ if ($episode->getPublishAt() !== null) {
|
|||
$publishedAt = CarbonImmutable::createFromTimestamp($episode->getPublishAt());
|
||||
}
|
||||
|
||||
$this->push('head');
|
||||
$sections->append(
|
||||
'head',
|
||||
<<<HTML
|
||||
<link rel="alternate" type="application/rss+xml" title="{$this->e($podcast->getTitle())}" href="{$feedLink}">
|
||||
HTML
|
||||
);
|
||||
|
||||
$sections->appendStart('bodyjs');
|
||||
?>
|
||||
|
||||
<link rel="alternate" type="application/rss+xml" title="<?=$this->e($podcast->getTitle())?>" href="<?=$feedLink?>">
|
||||
|
||||
<script>
|
||||
$(function () {
|
||||
$('[data-toggle="tooltip"]').tooltip();
|
||||
});
|
||||
</script>
|
||||
<?php
|
||||
$this->end();
|
||||
?>
|
||||
|
@ -50,7 +58,8 @@ $this->end();
|
|||
<a href="<?= $podcastEpisodesLink ?>" class="btn btn-primary mr-2"><span class="material-icons">chevron_left</span><?= __(
|
||||
'Back'
|
||||
) ?></a>
|
||||
<a href="<?=$feedLink?>" class="btn btn-warning" target="_blank"><span class="material-icons">rss_feed</span> <?=__(
|
||||
<a href="<?= $feedLink ?>" class="btn btn-warning" target="_blank"><span
|
||||
class="material-icons">rss_feed</span> <?= __(
|
||||
'RSS Feed'
|
||||
) ?></a>
|
||||
</div>
|
||||
|
@ -74,7 +83,9 @@ $this->end();
|
|||
<div class="card-body d-flex flex-column h-100">
|
||||
<div class="row justify-content-between">
|
||||
<div class="col-6">
|
||||
<span class="badge badge-pill badge-dark" data-toggle="tooltip" data-placement="right" data-html="true" title="<span class='material-icons'>schedule</span> <?=$publishedAt->format(
|
||||
<span class="badge badge-pill badge-dark" data-toggle="tooltip"
|
||||
data-placement="right" data-html="true"
|
||||
title="<span class='material-icons'>schedule</span> <?= $publishedAt->format(
|
||||
'H:i'
|
||||
) ?>"><?= $publishedAt->format('d. M. Y') ?></span>
|
||||
</div>
|
||||
|
@ -84,7 +95,8 @@ $this->end();
|
|||
<div class="col-6 text-right">
|
||||
<span class="badge badge-pill badge-danger"><?= __('Explicit') ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="row mt-3 mb-3">
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
$(function () {
|
||||
$('[data-toggle="tooltip"]').tooltip();
|
||||
});
|
|
@ -1,4 +1,8 @@
|
|||
<?php
|
||||
/**
|
||||
* @var \App\Http\RouterInterface $router
|
||||
* @var \App\View\GlobalSections $sections
|
||||
*/
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
|
@ -11,16 +15,20 @@ $this->layout(
|
|||
]
|
||||
);
|
||||
|
||||
/** @var \App\Assets $assets */
|
||||
$assets->addInlineJs(
|
||||
$this->fetch('frontend/public/podcast-episodes.js', [])
|
||||
$sections->append(
|
||||
'head',
|
||||
<<<HTML
|
||||
<link rel="alternate" type="application/rss+xml" title="{$this->e($podcast->getTitle())}" href="{$feedLink}">
|
||||
HTML
|
||||
);
|
||||
|
||||
$this->push('head');
|
||||
$sections->appendStart('bodyjs');
|
||||
?>
|
||||
|
||||
<link rel="alternate" type="application/rss+xml" title="<?=$this->e($podcast->getTitle())?>" href="<?=$feedLink?>">
|
||||
|
||||
<script>
|
||||
$(function () {
|
||||
$('[data-toggle="tooltip"]').tooltip();
|
||||
});
|
||||
</script>
|
||||
<?php
|
||||
$this->end();
|
||||
?>
|
||||
|
@ -37,10 +45,12 @@ $this->end();
|
|||
</div>
|
||||
|
||||
<div class="row justify-content-center mb-4">
|
||||
<a href="<?=$podcastsLink?>" class="btn btn-primary mr-2"><span class="material-icons">chevron_left</span><?=__(
|
||||
<a href="<?= $podcastsLink ?>" class="btn btn-primary mr-2"><span
|
||||
class="material-icons">chevron_left</span><?= __(
|
||||
'Back'
|
||||
) ?></a>
|
||||
<a href="<?=$feedLink?>" class="btn btn-warning" target="_blank"><span class="material-icons">rss_feed</span> <?=__(
|
||||
<a href="<?= $feedLink ?>" class="btn btn-warning" target="_blank"><span
|
||||
class="material-icons">rss_feed</span> <?= __(
|
||||
'RSS Feed'
|
||||
) ?></a>
|
||||
</div>
|
||||
|
|
48
templates/frontend/public/webdj.phtml
Normal file
48
templates/frontend/public/webdj.phtml
Normal file
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
/**
|
||||
* @var App\View\GlobalSections $sections
|
||||
*/
|
||||
|
||||
$this->layout(
|
||||
'minimal',
|
||||
[
|
||||
'page_class' => 'dj station-' . $station->getShortName(),
|
||||
'hide_footer' => true,
|
||||
'title' => __('Web DJ') . ' - ' . $station->getName(),
|
||||
'manual' => true,
|
||||
]
|
||||
);
|
||||
|
||||
$jsLibs = [
|
||||
$this->assetUrl('dist/lib/webcaster/libshine.js'),
|
||||
$this->assetUrl('dist/lib/webcaster/libsamplerate.js'),
|
||||
$this->assetUrl('dist/lib/webcaster/taglib.js'),
|
||||
$this->assetUrl('dist/lib/webcaster/webcast.js'),
|
||||
];
|
||||
|
||||
$libUrls = [];
|
||||
foreach ($jsLibs as $script) {
|
||||
$libUrls[] = (string)($router->getBaseUrl()->withPath($script));
|
||||
}
|
||||
|
||||
$scriptLines = [];
|
||||
foreach ($jsLibs as $jsLib) {
|
||||
$scriptLines[] = <<<HTML
|
||||
<script src="{$jsLib}"></script>
|
||||
HTML;
|
||||
}
|
||||
|
||||
$sections->append('bodyjs', implode("\n", $scriptLines));
|
||||
|
||||
echo $this->fetch(
|
||||
'partials/vue_body',
|
||||
[
|
||||
'component' => 'Vue_PublicWebDJ',
|
||||
'id' => 'web_dj',
|
||||
'props' => [
|
||||
'stationName' => $station->getName(),
|
||||
'libUrls' => $libUrls,
|
||||
'baseUri' => $wss_url,
|
||||
],
|
||||
]
|
||||
);
|
|
@ -1,18 +1,18 @@
|
|||
<?php
|
||||
/** @var */
|
||||
/**
|
||||
* @var App\Http\RouterInterface $router
|
||||
* @var App\Environment $environment
|
||||
* @var string $token
|
||||
*/
|
||||
|
||||
?><?= __('Account Recovery') ?>
|
||||
|
||||
|
||||
<?= sprintf(__('An account recovery link has been requested for your account on "%s".'), $environment->getAppName()) ?>
|
||||
|
||||
|
||||
<?= __('Click the link below to log in to your account.') ?>
|
||||
|
||||
|
||||
<?= $router->named(
|
||||
'account:recover',
|
||||
['token' => $token],
|
||||
[],
|
||||
true
|
||||
routeName: 'account:recover',
|
||||
routeParams: ['token' => $token],
|
||||
absolute: true
|
||||
)?>
|
||||
|
|
|
@ -6,11 +6,11 @@
|
|||
* @var App\Http\Router $router
|
||||
* @var App\Session\Flash $flash
|
||||
* @var App\Customization $customization
|
||||
* @var App\Assets $assets
|
||||
* @var App\Version $version
|
||||
* @var App\Http\ServerRequest $request
|
||||
* @var App\Environment $environment
|
||||
* @var App\Entity\User $user
|
||||
* @var App\View\GlobalSections $sections
|
||||
*/
|
||||
|
||||
$manual ??= false;
|
||||
|
@ -26,29 +26,44 @@ $header ??= null;
|
|||
|
||||
<title><?= $customization->getPageTitle($title) ?></title>
|
||||
|
||||
<?= $this->fetch('partials/icons') ?>
|
||||
<?= $this->fetch('partials/head') ?>
|
||||
|
||||
<?= $this->section('head') ?>
|
||||
<script src="<?= $this->assetUrl('dist/lib/sweetalert2/sweetalert2.min.js') ?>" defer></script>
|
||||
|
||||
<?php
|
||||
$assets
|
||||
->load('main')
|
||||
->addInlineCss($customization->getCustomInternalCss());
|
||||
<?= $sections->get('head') ?>
|
||||
|
||||
echo $assets->css();
|
||||
echo $assets->js();
|
||||
?>
|
||||
<style>
|
||||
<?=$customization->getCustomInternalCss() ?>
|
||||
</style>
|
||||
</head>
|
||||
<body class="page-full <?= $page_class ?? '' ?> <?php
|
||||
if (!empty($sidebar)): ?>has-sidebar<?php
|
||||
if ($sections->has('sidebar')): ?>has-sidebar<?php
|
||||
endif; ?>">
|
||||
<?=$assets->inlineJs()?>
|
||||
|
||||
<?= $this->fetch('partials/bodyjs') ?>
|
||||
|
||||
<?= $sections->get('bodyjs') ?>
|
||||
|
||||
<script>
|
||||
<?php
|
||||
$csrfJson = 'null';
|
||||
$csrf = $request->getAttribute(App\Http\ServerRequest::ATTR_SESSION_CSRF);
|
||||
if ($csrf instanceof App\Session\Csrf) {
|
||||
$csrfToken = $csrf->generate(App\Middleware\Auth\ApiAuth::API_CSRF_NAMESPACE);
|
||||
$csrfJson = json_encode($csrfToken, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
?>
|
||||
|
||||
$(function () {
|
||||
App.api_csrf = <?=$csrfJson ?>;
|
||||
});
|
||||
</script>
|
||||
|
||||
<a class="sr-only sr-only-focusable" href="#content"><?= __('Skip to main content') ?></a>
|
||||
|
||||
<header class="navbar bg-primary-dark shadow-sm fixed-top">
|
||||
<?php
|
||||
if (!empty($sidebar)): ?>
|
||||
if ($sections->has('sidebar')): ?>
|
||||
<button aria-controls="sidebar" aria-expanded="false" aria-label="<?= __(
|
||||
'Toggle Sidebar'
|
||||
) ?>" class="navbar-toggler d-inline-flex d-lg-none mr-3" data-breakpoint="lg" data-target="#sidebar"
|
||||
|
@ -136,10 +151,10 @@ endif; ?>">
|
|||
</header>
|
||||
|
||||
<?php
|
||||
if (!empty($sidebar)): ?>
|
||||
if ($sections->has('sidebar')): ?>
|
||||
<div class="navdrawer navdrawer-permanent-lg navdrawer-permanent-clipped" id="sidebar" tabindex="-1">
|
||||
<nav class="navdrawer-content">
|
||||
<?=$sidebar?>
|
||||
<?= $sections->get('sidebar') ?>
|
||||
</nav>
|
||||
</div>
|
||||
<?php
|
||||
|
@ -147,7 +162,7 @@ endif; ?>
|
|||
|
||||
<section id="main">
|
||||
<section id="content" <?php
|
||||
if (empty($sidebar)): ?>class="content-alt"<?php
|
||||
if (!$sections->has('sidebar')): ?>class="content-alt"<?php
|
||||
endif; ?> role="main">
|
||||
<div class="container">
|
||||
<?php
|
||||
|
@ -177,7 +192,7 @@ endif; ?>
|
|||
</section>
|
||||
|
||||
<footer id="footer" <?php
|
||||
if (empty($sidebar)): ?>class="footer-alt"<?php
|
||||
if (!$sections->has('sidebar')): ?>class="footer-alt"<?php
|
||||
endif; ?> role="contentinfo">
|
||||
<?= sprintf(
|
||||
__('Powered by %s'),
|
||||
|
|
|
@ -7,10 +7,10 @@
|
|||
* @var App\Http\Router $router
|
||||
* @var App\Session\Flash $flash
|
||||
* @var App\Customization $customization
|
||||
* @var App\Assets $assets
|
||||
* @var App\Version $version
|
||||
* @var App\Http\ServerRequest $request
|
||||
* @var App\Environment $environment
|
||||
* @var App\View\GlobalSections $sections
|
||||
*/
|
||||
|
||||
$title ??= null;
|
||||
|
@ -24,23 +24,24 @@ $hide_footer ??= false;
|
|||
|
||||
<title><?= $customization->getPageTitle($title) ?></title>
|
||||
|
||||
<?= $this->fetch('partials/icons') ?>
|
||||
<?= $this->fetch('partials/head') ?>
|
||||
|
||||
<?= $this->section('head') ?>
|
||||
<?= $sections->get('head') ?>
|
||||
|
||||
<?php
|
||||
$assets
|
||||
->load('minimal')
|
||||
->addInlineCss($customization->getCustomPublicCss())
|
||||
->addInlineJs($customization->getCustomPublicJs());
|
||||
|
||||
echo $assets->css();
|
||||
echo $assets->js();
|
||||
?>
|
||||
<style>
|
||||
<?=$customization->getCustomPublicCss() ?>
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="page-minimal <?= $page_class ?? '' ?>">
|
||||
<?=$assets->inlineJs()?>
|
||||
|
||||
<?= $this->fetch('partials/bodyjs') ?>
|
||||
|
||||
<?= $sections->get('bodyjs') ?>
|
||||
|
||||
<script>
|
||||
<?=$customization->getCustomPublicJs() ?>
|
||||
</script>
|
||||
|
||||
<?= $this->section('content') ?>
|
||||
|
||||
|
|
65
templates/partials/bodyjs.phtml
Normal file
65
templates/partials/bodyjs.phtml
Normal file
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
/** @var Psr\Http\Message\RequestInterface $request */
|
||||
|
||||
/** @var App\Session\Flash|null $flashObj */
|
||||
$flashObj = $request->getAttribute(App\Http\ServerRequest::ATTR_SESSION_FLASH);
|
||||
|
||||
$notifies = [];
|
||||
|
||||
if (null !== $flashObj && $flashObj->hasMessages()) {
|
||||
foreach ($flashObj->getMessages() as $message) {
|
||||
$notifyMessage = str_replace(['"', "\n"], ['\'', '<br>'], $message['text']);
|
||||
$notifies[] = 'notify("' . $notifyMessage . '", "' . $message['color'] . '");';
|
||||
}
|
||||
}
|
||||
|
||||
$localeObj = $request->getAttribute(App\Http\ServerRequest::ATTR_LOCALE);
|
||||
|
||||
$locale = ($localeObj instanceof App\Enums\SupportedLocales)
|
||||
? $localeObj->value
|
||||
: App\Enums\SupportedLocales::default()->value;
|
||||
|
||||
$locale = explode('.', $locale, 2)[0];
|
||||
$localeShort = substr($locale, 0, 2);
|
||||
$localeWithDashes = str_replace('_', '-', $locale);
|
||||
|
||||
// User profile-specific 24-hour display setting.
|
||||
$userObj = $request->getAttribute(App\Http\ServerRequest::ATTR_USER);
|
||||
$show24Hours = ($userObj instanceof App\Entity\User)
|
||||
? $userObj->getShow24HourTime()
|
||||
: null;
|
||||
|
||||
$timeConfig = new \stdClass();
|
||||
if (null !== $show24Hours) {
|
||||
$timeConfig->hour12 = !$show24Hours;
|
||||
}
|
||||
|
||||
$app = [
|
||||
'lang' => [
|
||||
'confirm' => __('Are you sure?'),
|
||||
'advanced' => __('Advanced'),
|
||||
],
|
||||
'locale' => $locale,
|
||||
'locale_short' => $localeShort,
|
||||
'locale_with_dashes' => $localeWithDashes,
|
||||
'time_config' => $timeConfig,
|
||||
'api_csrf' => null,
|
||||
];
|
||||
?>
|
||||
|
||||
<script type="text/javascript">
|
||||
<?php if (!empty($notifies)): ?>
|
||||
$(function () {
|
||||
<?=implode('', $notifies) ?>;
|
||||
});
|
||||
<?php endif; ?>
|
||||
|
||||
let App = <?=json_encode($app, JSON_THROW_ON_ERROR) ?>;
|
||||
|
||||
let currentTheme = document.documentElement.getAttribute('data-theme');
|
||||
if (currentTheme === 'browser') {
|
||||
currentTheme = (window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
App.theme = currentTheme;
|
||||
</script>
|
31
templates/partials/head.phtml
Normal file
31
templates/partials/head.phtml
Normal file
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
/**
|
||||
* @var App\Customization $customization
|
||||
*/
|
||||
?>
|
||||
|
||||
<script src="<?= $this->assetUrl('dist/lib/jquery/jquery.min.js') ?>"></script>
|
||||
<script src="<?= $this->assetUrl('dist/lib/bootstrap/bootstrap.bundle.min.js') ?>"></script>
|
||||
<script src="<?= $this->assetUrl('dist/lib/bootstrap-notify/bootstrap-notify.min.js') ?>" defer></script>
|
||||
<script src="<?= $this->assetUrl('dist/app.js') ?>" defer></script>
|
||||
<script src="<?= $this->assetUrl('dist/material.js') ?>" defer></script>
|
||||
|
||||
<link rel="stylesheet" href="<?= $this->assetUrl('dist/lib/roboto-fontface/css/roboto/roboto-fontface.css') ?>">
|
||||
<link rel="stylesheet" href="<?= $this->assetUrl('dist/style.css') ?>">
|
||||
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="<?= $customization->getBrowserIconUrl(57) ?>">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="<?= $customization->getBrowserIconUrl(60) ?>">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="<?= $customization->getBrowserIconUrl(72) ?>">
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="<?= $customization->getBrowserIconUrl(76) ?>">
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="<?= $customization->getBrowserIconUrl(114) ?>">
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="<?= $customization->getBrowserIconUrl(120) ?>">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="<?= $customization->getBrowserIconUrl(144) ?>">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="<?= $customization->getBrowserIconUrl(152) ?>">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="<?= $customization->getBrowserIconUrl(180) ?>">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="<?= $customization->getBrowserIconUrl(192) ?>">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="<?= $customization->getBrowserIconUrl(32) ?>">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="<?= $customization->getBrowserIconUrl(96) ?>">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="<?= $customization->getBrowserIconUrl(16) ?>">
|
||||
<meta name="msapplication-TileColor" content="#2196F3">
|
||||
<meta name="msapplication-TileImage" content="<?= $customization->getBrowserIconUrl(144) ?>">
|
||||
<meta name="theme-color" content="#2196F3">
|
|
@ -1,22 +0,0 @@
|
|||
<?php
|
||||
/**
|
||||
* @var App\Customization $customization
|
||||
*/
|
||||
|
||||
?>
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="<?=$customization->getBrowserIconUrl(57)?>">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="<?=$customization->getBrowserIconUrl(60)?>">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="<?=$customization->getBrowserIconUrl(72)?>">
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="<?=$customization->getBrowserIconUrl(76)?>">
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="<?=$customization->getBrowserIconUrl(114)?>">
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="<?=$customization->getBrowserIconUrl(120)?>">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="<?=$customization->getBrowserIconUrl(144)?>">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="<?=$customization->getBrowserIconUrl(152)?>">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="<?=$customization->getBrowserIconUrl(180)?>">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="<?=$customization->getBrowserIconUrl(192)?>">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="<?=$customization->getBrowserIconUrl(32)?>">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="<?=$customization->getBrowserIconUrl(96)?>">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="<?=$customization->getBrowserIconUrl(16)?>">
|
||||
<meta name="msapplication-TileColor" content="#2196F3">
|
||||
<meta name="msapplication-TileImage" content="<?=$customization->getBrowserIconUrl(144)?>">
|
||||
<meta name="theme-color" content="#2196F3">
|
|
@ -1,8 +0,0 @@
|
|||
$(function() {
|
||||
$('.navdrawer-nav a[title]').tooltip({
|
||||
'html': true,
|
||||
'placement': 'right',
|
||||
'boundary': 'viewport'
|
||||
});
|
||||
});
|
||||
|
|
@ -1,15 +1,26 @@
|
|||
<?php
|
||||
|
||||
/** @var \App\Assets $assets */
|
||||
$assets->addInlineJs($this->fetch('partials/sidebar_menu.js'), 99);
|
||||
|
||||
/**
|
||||
* @var array $menu
|
||||
* @var string $active
|
||||
* @var \App\Http\Router $router
|
||||
* @var \App\View\GlobalSections $sections
|
||||
*/
|
||||
|
||||
$active ??= null;
|
||||
|
||||
$sections->appendStart('bodyjs');
|
||||
?>
|
||||
<script>
|
||||
$(function () {
|
||||
$('.navdrawer-nav a[title]').tooltip({
|
||||
'html': true,
|
||||
'placement': 'right',
|
||||
'boundary': 'viewport'
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<?php
|
||||
$sections->end();
|
||||
?>
|
||||
<ul class="navdrawer-nav">
|
||||
<?php
|
||||
|
|
32
templates/partials/vue_body.phtml
Normal file
32
templates/partials/vue_body.phtml
Normal file
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
/**
|
||||
* @var string $component
|
||||
* @var ?string $id
|
||||
* @var array $props
|
||||
* @var App\View\GlobalSections $sections
|
||||
*/
|
||||
|
||||
$nameWithoutPrefix = str_replace('Vue_', '', $component);
|
||||
|
||||
$componentDeps = $this->getVueComponentInfo($nameWithoutPrefix);
|
||||
$propsJson = json_encode($props, JSON_THROW_ON_ERROR);
|
||||
|
||||
$scriptLines = [];
|
||||
foreach ($componentDeps['assets']['js'] ?? [] as $componentDep) {
|
||||
$scriptLines[] = <<<HTML
|
||||
<script src="{$this->assetUrl($componentDep)}"></script>
|
||||
HTML;
|
||||
}
|
||||
|
||||
$scriptLines[] = <<<HTML
|
||||
<script>
|
||||
$(function () {
|
||||
{$component} = {$nameWithoutPrefix}.default('#{$id}', {$propsJson});
|
||||
});
|
||||
</script>
|
||||
HTML;
|
||||
|
||||
$sections->append('bodyjs', implode("\n", $scriptLines));
|
||||
?>
|
||||
|
||||
<div id="<?= $id ?>">Loading...</div>
|
|
@ -1,6 +1,9 @@
|
|||
<?php
|
||||
/**
|
||||
* @var App\Entity\Station $station
|
||||
* @var App\Http\RouterInterface $router
|
||||
*/
|
||||
|
||||
/** @var App\Entity\Station $station */
|
||||
$this->layout('main', [
|
||||
'title' =>
|
||||
__('Station Broadcasting Disabled'),
|
||||
|
|
|
@ -1,64 +0,0 @@
|
|||
$(document).on('station-needs-restart', function (e) {
|
||||
$('.btn-restart-station').removeClass('d-none');
|
||||
});
|
||||
|
||||
$(document).on('click', '.api-call', function (e) {
|
||||
e.stopPropagation();
|
||||
|
||||
var btn = $(this);
|
||||
|
||||
var btn_original_text = btn.html();
|
||||
var trigger_reload = !btn.hasClass('no-reload');
|
||||
|
||||
confirmDangerousAction(e.target).then(function (result) {
|
||||
if (result.value) {
|
||||
btn.text(<?=$this->escapeJs(__('Please wait...')) ?>);
|
||||
btn.addClass('disabled');
|
||||
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
headers: {
|
||||
"X-API-CSRF": document.body.App.api_csrf
|
||||
},
|
||||
url: btn.attr('href'),
|
||||
success: function (data) {
|
||||
// Only restart if the user isn't on a form page
|
||||
if (trigger_reload && $('form.form').length === 0) {
|
||||
setTimeout('location.reload()', 2000);
|
||||
} else {
|
||||
btn.removeClass('disabled').html(btn_original_text);
|
||||
}
|
||||
|
||||
var notify_type = (data.success) ? 'success' : 'warning';
|
||||
notify(data.formatted_message, notify_type);
|
||||
},
|
||||
error: function (response) {
|
||||
data = jQuery.parseJSON(response.responseText);
|
||||
notify(data.formatted_message, 'danger');
|
||||
|
||||
btn.removeClass('disabled').html(btn_original_text);
|
||||
},
|
||||
dataType: 'json'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
$(function () {
|
||||
function updateClock() {
|
||||
let d = new Date();
|
||||
|
||||
let timeConfig = {...document.body.App.time_config};
|
||||
timeConfig.timeZone = <?=$this->escapeJs($station->getTimezone()) ?>;
|
||||
timeConfig.timeStyle = 'long';
|
||||
|
||||
let time = d.toLocaleString(document.body.App.locale_with_dashes, timeConfig);
|
||||
|
||||
$('#station-time').text(time);
|
||||
}
|
||||
|
||||
setInterval(updateClock, 1000);
|
||||
updateClock();
|
||||
});
|
|
@ -1,11 +1,80 @@
|
|||
<?php
|
||||
/**
|
||||
* @var \App\Entity\Station $station
|
||||
* @var \App\Assets $assets
|
||||
* @var \App\Acl $acl
|
||||
* @var App\Entity\Station $station
|
||||
* @var App\Acl $acl
|
||||
* @var App\View\GlobalSections $sections
|
||||
*/
|
||||
|
||||
$assets->addInlineJs($this->fetch('stations/sidebar.js', ['station' => $station]), 95);
|
||||
$sections->appendStart('bodyjs');
|
||||
?>
|
||||
<script>
|
||||
$(document).on('station-needs-restart', function (e) {
|
||||
$('.btn-restart-station').removeClass('d-none');
|
||||
});
|
||||
|
||||
$(document).on('click', '.api-call', function (e) {
|
||||
e.stopPropagation();
|
||||
|
||||
var btn = $(this);
|
||||
|
||||
var btn_original_text = btn.html();
|
||||
var trigger_reload = !btn.hasClass('no-reload');
|
||||
|
||||
confirmDangerousAction(e.target).then(function (result) {
|
||||
if (result.value) {
|
||||
btn.text(<?=$this->escapeJs(__('Please wait...')) ?>);
|
||||
btn.addClass('disabled');
|
||||
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
headers: {
|
||||
"X-API-CSRF": App.api_csrf
|
||||
},
|
||||
url: btn.attr('href'),
|
||||
success: function (data) {
|
||||
// Only restart if the user isn't on a form page
|
||||
if (trigger_reload && $('form.form').length === 0) {
|
||||
setTimeout('location.reload()', 2000);
|
||||
} else {
|
||||
btn.removeClass('disabled').html(btn_original_text);
|
||||
}
|
||||
|
||||
var notify_type = (data.success) ? 'success' : 'warning';
|
||||
notify(data.formatted_message, notify_type);
|
||||
},
|
||||
error: function (response) {
|
||||
data = jQuery.parseJSON(response.responseText);
|
||||
notify(data.formatted_message, 'danger');
|
||||
|
||||
btn.removeClass('disabled').html(btn_original_text);
|
||||
},
|
||||
dataType: 'json'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
$(function () {
|
||||
function updateClock() {
|
||||
let d = new Date();
|
||||
|
||||
let timeConfig = {...App.time_config};
|
||||
timeConfig.timeZone = <?=$this->escapeJs($station->getTimezone()) ?>;
|
||||
timeConfig.timeStyle = 'long';
|
||||
|
||||
let time = d.toLocaleString(App.locale_with_dashes, timeConfig);
|
||||
|
||||
$('#station-time').text(time);
|
||||
}
|
||||
|
||||
setInterval(updateClock, 1000);
|
||||
updateClock();
|
||||
});
|
||||
</script>
|
||||
<?php
|
||||
$sections->end();
|
||||
?>
|
||||
|
||||
<div class="navdrawer-header">
|
||||
|
|
29
templates/system/vue_page.phtml
Normal file
29
templates/system/vue_page.phtml
Normal file
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
/**
|
||||
* @var string $component
|
||||
* @var ?string $id
|
||||
* @var string $layout
|
||||
* @var ?string $title
|
||||
* @var array $layoutParams
|
||||
* @var array $props
|
||||
*/
|
||||
|
||||
$this->layout(
|
||||
$layout ?? 'main',
|
||||
array_merge(
|
||||
[
|
||||
'title' => $title ?? $id,
|
||||
'manual' => true,
|
||||
],
|
||||
$layoutParams ?? []
|
||||
)
|
||||
);
|
||||
|
||||
echo $this->fetch(
|
||||
'partials/vue_body',
|
||||
[
|
||||
'component' => $component,
|
||||
'id' => $id,
|
||||
'props' => $props,
|
||||
]
|
||||
);
|
Loading…
Reference in a new issue