Asset overhaul.

This commit is contained in:
Buster Neece 2022-11-15 18:16:04 -06:00
parent 49e3cda913
commit 1c6c8cb31d
No known key found for this signature in database
GPG Key ID: F1D2E64A0005E80E
50 changed files with 788 additions and 1311 deletions

View File

@ -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',
],
],
],
],
];

View File

@ -14,125 +14,109 @@ const mode = require('gulp-mode')();
const run = require('gulp-run-command').default;
var jsFiles = {
'jquery': {
base: 'node_modules/jquery/dist',
files: [
'node_modules/jquery/dist/jquery.min.js'
]
},
'bootstrap': {
base: null,
files: [
'node_modules/bootstrap/dist/js/bootstrap.bundle.min.js'
]
},
'bootstrap-notify': {
base: 'node_modules/bootstrap-notify',
files: [
'node_modules/bootstrap-notify/bootstrap-notify.min.js'
]
},
'sweetalert2': {
base: 'node_modules/sweetalert2/dist',
files: [
'node_modules/sweetalert2/dist/sweetalert2.min.js'
]
},
'material-icons': {
files: [
'font/*'
]
},
'roboto-fontface': {
base: 'node_modules/roboto-fontface',
files: [
'node_modules/roboto-fontface/css/roboto/roboto-fontface.css',
'node_modules/roboto-fontface/fonts/roboto/*'
]
},
'jquery': {
base: 'node_modules/jquery/dist',
files: [
'node_modules/jquery/dist/jquery.min.js'
]
},
'bootstrap': {
base: null,
files: [
'node_modules/bootstrap/dist/js/bootstrap.bundle.min.js'
]
},
'bootstrap-notify': {
base: 'node_modules/bootstrap-notify',
files: [
'node_modules/bootstrap-notify/bootstrap-notify.min.js'
]
},
'sweetalert2': {
base: 'node_modules/sweetalert2/dist',
files: [
'node_modules/sweetalert2/dist/sweetalert2.min.js'
]
},
'material-icons': {
files: [
'font/*'
]
},
'roboto-fontface': {
base: 'node_modules/roboto-fontface',
files: [
'node_modules/roboto-fontface/css/roboto/roboto-fontface.css',
'node_modules/roboto-fontface/fonts/roboto/*'
]
},
'luxon': {
files: [
'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);
defaultTasks.forEach(function (libName) {
gulp.task('scripts:' + libName, function () {
return gulp.src(jsFiles[libName].files, {
base: jsFiles[libName].base
}).pipe(gulp.dest('../web/static/dist/lib/' + libName));
});
gulp.task('scripts:' + libName, function () {
return gulp.src(jsFiles[libName].files, {
base: jsFiles[libName].base
}).pipe(gulp.dest('../web/static/dist/lib/' + libName));
});
});
gulp.task('bundle-deps', gulp.parallel(
defaultTasks.map(function (name) {
return 'scripts:' + name;
})
defaultTasks.map(function (name) {
return 'scripts:' + name;
})
));
gulp.task('clean', function () {
return del([
'../web/static/dist/**/*',
'../web/static/webpack_dist/**/*',
'../web/static/assets.json',
'../web/static/webpack.json'
], { force: true });
return del([
'../web/static/dist/**/*',
'../web/static/webpack_dist/**/*',
'../web/static/assets.json',
'../web/static/webpack.json'
], {force: true});
});
gulp.task('concat-js', function () {
return gulp.src('./js/inc/*.js')
.pipe(sourcemaps.init())
.pipe(babel({
presets: ['@babel/env']
}))
.pipe(concat('app.js'))
.pipe(uglify())
.pipe(sourcemaps.write())
.pipe(gulp.dest('../web/static/dist'));
return gulp.src('./js/inc/*.js')
.pipe(sourcemaps.init())
.pipe(babel({
presets: ['@babel/env']
}))
.pipe(concat('app.js'))
.pipe(uglify())
.pipe(sourcemaps.write())
.pipe(gulp.dest('../web/static/dist'));
});
gulp.task('build-vue', run('webpack -c webpack.config.js'));
gulp.task('build-js', function () {
return gulp.src(['./js/*.js'])
.pipe(sourcemaps.init())
.pipe(uglify())
.pipe(sourcemaps.write())
.pipe(gulp.dest('../web/static/dist'));
return gulp.src(['./js/*.js'])
.pipe(sourcemaps.init())
.pipe(uglify())
.pipe(sourcemaps.write())
.pipe(gulp.dest('../web/static/dist'));
});
gulp.task('build-css', function () {
return gulp.src(['./scss/style.scss'])
.pipe(mode.development(sourcemaps.init()))
.pipe(sass())
.pipe(clean_css())
.pipe(mode.development(sourcemaps.write()))
.pipe(gulp.dest('../web/static/dist'));
return gulp.src(['./scss/style.scss'])
.pipe(mode.development(sourcemaps.init()))
.pipe(sass())
.pipe(clean_css())
.pipe(mode.development(sourcemaps.write()))
.pipe(gulp.dest('../web/static/dist'));
});
gulp.task('watch', function () {
@ -144,12 +128,12 @@ gulp.task('watch', function () {
});
const buildAll = gulp.series('clean', gulp.parallel('concat-js', 'build-vue', 'build-js', 'build-css', 'bundle-deps'), function () {
return gulp.src(['../web/static/dist/**/*.{js,css}'], { base: '../web/static/' })
.pipe(rev())
.pipe(revdel())
.pipe(gulp.dest('../web/static/'))
.pipe(rev.manifest('assets.json'))
.pipe(gulp.dest('../web/static/'));
return gulp.src(['../web/static/dist/**/*.{js,css}'], {base: '../web/static/'})
.pipe(rev())
.pipe(revdel())
.pipe(gulp.dest('../web/static/'))
.pipe(rev.manifest('assets.json'))
.pipe(gulp.dest('../web/static/'));
});
exports.default = buildAll

View File

@ -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');
}

View File

@ -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",

View File

@ -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",

View File

@ -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);

View File

@ -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}
);
}
}

View File

@ -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) {

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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']
});
},

View File

@ -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}
);
}

View File

@ -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) {

View File

@ -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.',
}
},

View File

@ -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) {

View File

@ -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}
);
}
}

View File

@ -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}
);
}
},

View File

@ -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';
});

View File

@ -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));
}
}

View File

@ -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,
]
);
}
}

View File

@ -48,15 +48,14 @@ final class Admin
);
// These two intentionally separated (the sidebar needs admin_panels).
$view->addData(
[
'sidebar' => $view->render(
'admin/sidebar',
[
'active_tab' => $active_tab,
]
),
]
$view->getSections()->set(
'sidebar',
$view->render(
'admin/sidebar',
[
'active_tab' => $active_tab,
]
)
);
return $handler->handle($request);

View File

@ -47,16 +47,15 @@ final class Stations
$active_tab = $route_parts[1];
}
$view->addData(
[
'sidebar' => $view->render(
'stations/sidebar',
[
'menu' => $event->getFilteredMenu(),
'active' => $active_tab,
]
),
]
$view->getSections()->set(
'sidebar',
$view->render(
'stations/sidebar',
[
'menu' => $event->getFilteredMenu(),
'active' => $active_tab,
]
),
);
return $handler->handle($request);

View File

@ -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(
[
'title' => $title,
'manual' => true,
],
$layoutParams
)
return $this->renderToResponse(
$response,
'system/vue_page',
[
'component' => $component,
'id' => $id,
'layout' => $layout,
'title' => $title,
'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);
}
}

View 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;
}
}

View File

@ -1,5 +0,0 @@
jQuery(function ($) {
$('time[data-duration]').each(function () {
$(this).text(luxon.DateTime.fromSeconds($(this).data('duration')).toRelative());
});
});

View File

@ -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>

View File

@ -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));
});
});

View File

@ -1,34 +1,50 @@
<?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">
<div class="card-header bg-primary-dark">
<h2 class="card-title"><?=__('Connected AzuraRelays') ?></h2>
<h2 class="card-title"><?= __('Connected AzuraRelays') ?></h2>
</div>
<table class="table table-responsive-md table-striped mb-0">
<thead>
<tr>
<th style="width: 35%"><?=__('Relay') ?></th>
<th style="width: 15%"><?=__('Is Public') ?></th>
<th style="width: 25%"><?=__('First Connected') ?></th>
<th style="width: 25%"><?=__('Latest Update') ?></th>
</tr>
<tr>
<th style="width: 35%"><?= __('Relay') ?></th>
<th style="width: 15%"><?= __('Is Public') ?></th>
<th style="width: 25%"><?= __('First Connected') ?></th>
<th style="width: 25%"><?= __('Latest Update') ?></th>
</tr>
</thead>
<tbody>
<?php foreach($relays as $row): ?>
<?php
foreach ($relays as $row): ?>
<tr class="align-middle">
<td class="text-center">
<big>
<a href="<?=$this->e($row['base_url']) ?>" target="_blank"><?=$this->e($row['name']) ?></a>
<a href="<?= $this->e($row['base_url']) ?>" target="_blank"><?= $this->e($row['name']) ?></a>
</big>
</td>
<td class="text-center">

View File

@ -1,7 +1,15 @@
<?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') ?>
<a class="navbar-brand px-0" href="<?= $router->named('admin:index:index') ?>">
<?= __('Administration') ?>
</a>
</div>
<?=$this->fetch('partials/sidebar_menu', ['menu' => $admin_panels]) ?>
<?= $this->fetch('partials/sidebar_menu', ['menu' => $admin_panels]) ?>

View File

@ -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>

View File

@ -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,47 +16,52 @@ $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') ?>">
<link rel="manifest" href="<?= $router->fromHere('public:manifest') ?>">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="<?= $this->e($station->getName() ?? '') ?>">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="<?= $this->e($station->getName() ?? '') ?>">
<meta property="og:title" content="<?= $this->e($station->getName() ?? '') ?>">
<meta property="og:url" content="<?= $this->e($station->getUrl() ?? '') ?>">
<meta property="og:image" content="<?= $nowPlayingArtUri ?>">
<meta property="og:title" content="<?= $this->e($station->getName() ?? '') ?>">
<meta property="og:url" content="<?= $this->e($station->getUrl() ?? '') ?>">
<meta property="og:image" content="<?= $nowPlayingArtUri ?>">
<meta property="twitter:card" content="player">
<meta property="twitter:player" content="<?= $router->named(
'public:index',
['station_id' => $station->getShortName(), 'embed' => 'social'],
[],
true
) ?>">
<meta property="twitter:player:width" content="400">
<meta property="twitter:player:height" content="125">
<meta property="twitter:card" content="player">
<meta property="twitter:player" content="<?= $router->named(
'public:index',
['station_id' => $station->getShortName(), 'embed' => 'social'],
[],
true
) ?>">
<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,
]
);

View File

@ -1,3 +0,0 @@
$(function () {
$('[data-toggle="tooltip"]').tooltip();
});

View File

@ -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();
?>
@ -43,16 +51,17 @@ $this->end();
<div class="container pt-5 pb-5 h-100" style="flex: 1;">
<div id="station_podcast_episode">
<div class="row mb-4">
<h1 class="mx-auto"><?=$this->e($podcast->getTitle())?></h1>
<h1 class="mx-auto"><?= $this->e($podcast->getTitle()) ?></h1>
</div>
<div class="row justify-content-center mb-4">
<a href="<?=$podcastEpisodesLink?>" class="btn btn-primary mr-2"><span class="material-icons">chevron_left</span><?=__(
<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>
<a href="<?= $feedLink ?>" class="btn btn-warning" target="_blank"><span
class="material-icons">rss_feed</span> <?= __(
'RSS Feed'
)?></a>
) ?></a>
</div>
<div class="row justify-content-center mb-4">
@ -60,31 +69,34 @@ $this->end();
<div class="card">
<div class="row no-gutters">
<div class="col-3 col-lg-2">
<img src="<?=$router->named(
<img src="<?= $router->named(
'api:stations:podcast:episode:art',
[
'station_id' => $station->getId(),
'podcast_id' => $podcast->getId(),
'episode_id' => $episode->getId() . '|' . $episode->getArtUpdatedAt(),
]
);?>" class="card-img img-fluid" alt="<?=$this->e($podcast->getTitle())?>">
); ?>" class="card-img img-fluid" alt="<?= $this->e($podcast->getTitle()) ?>">
</div>
<div class="col-9 col-lg-10">
<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(
'H:i'
)?>"><?=$publishedAt->format('d. M. Y')?></span>
<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>
<?php
if ($episode->getExplicit()) : ?>
<div class="col-6 text-right">
<span class="badge badge-pill badge-danger"><?=__('Explicit') ?></span>
<span class="badge badge-pill badge-danger"><?= __('Explicit') ?></span>
</div>
<?php endif; ?>
<?php
endif; ?>
</div>
<div class="row mt-3 mb-3">

View File

@ -1,3 +0,0 @@
$(function () {
$('[data-toggle="tooltip"]').tooltip();
});

View File

@ -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();
?>
@ -29,20 +37,22 @@ $this->end();
<div class="container pt-5 pb-5 h-100" style="flex: 1;">
<div id="station_podcast_episodes">
<div class="row">
<h1 class="mx-auto"><?=__('Episodes')?></h1>
<h1 class="mx-auto"><?= __('Episodes') ?></h1>
</div>
<div class="row mb-4">
<h2 class="mx-auto"><?=$this->e($podcast->getTitle())?></h2>
<h2 class="mx-auto"><?= $this->e($podcast->getTitle()) ?></h2>
</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>
<a href="<?= $feedLink ?>" class="btn btn-warning" target="_blank"><span
class="material-icons">rss_feed</span> <?= __(
'RSS Feed'
)?></a>
) ?></a>
</div>
<div class="row justify-content-center">

View 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,
],
]
);

View File

@ -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
)?>

View File

@ -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
<body class="page-full <?= $page_class ?? '' ?> <?php
if ($sections->has('sidebar')): ?>has-sidebar<?php
endif; ?>">
<?=$assets->inlineJs()?>
<a class="sr-only sr-only-focusable" href="#content"><?=__('Skip to main content')?></a>
<?= $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'),

View File

@ -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,34 +24,35 @@ $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()?>
<body class="page-minimal <?= $page_class ?? '' ?>">
<?=$this->section('content')?>
<?= $this->fetch('partials/bodyjs') ?>
<?= $sections->get('bodyjs') ?>
<script>
<?=$customization->getCustomPublicJs() ?>
</script>
<?= $this->section('content') ?>
<?php
$hide_footer ??= false;
if (!$customization->hideProductName() && !$hide_footer): ?>
<footer id="footer" class="footer-alt" role="contentinfo">
<?=sprintf(
<?= sprintf(
__('Powered by %s'),
'<a href="https://azuracast.com/" target="_blank">' . $environment->getAppName() . '</a>' . ' '
)?>
) ?>
</footer>
<?php
endif; ?>

View 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>

View 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">

View File

@ -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">

View File

@ -1,8 +0,0 @@
$(function() {
$('.navdrawer-nav a[title]').tooltip({
'html': true,
'placement': 'right',
'boundary': 'viewport'
});
});

View File

@ -1,35 +1,46 @@
<?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
foreach ($menu as $category_id => $category): ?>
<li class="nav-item">
<a class="nav-link <?=($category['class'] ?? '')?> <?php
<a class="nav-link <?= ($category['class'] ?? '') ?> <?php
if ($active === $category_id): ?>active<?php
endif; ?>"
<?php
if (empty($category['items'])): ?>href="<?=$category['url']?>" <?php
else: ?>data-toggle="collapse" href="#sidebar-submenu-<?=$category_id?>"<?php
if (empty($category['items'])): ?>href="<?= $category['url'] ?>" <?php
else: ?>data-toggle="collapse" href="#sidebar-submenu-<?= $category_id ?>"<?php
endif; ?>
<?php
if ($category['external'] ?? false): ?>target="_blank"<?php
endif; ?>
<?php
if (isset($category['confirm'])): ?>data-confirm-title="<?=$this->e($category['confirm'])?>"<?php
if (isset($category['confirm'])): ?>data-confirm-title="<?= $this->e($category['confirm']) ?>"<?php
endif; ?>
<?php
if (isset($category['title'])): ?>title="<?=$this->e($category['title'])?>"<?php
if (isset($category['title'])): ?>title="<?= $this->e($category['title']) ?>"<?php
endif; ?>>
<i class="navdrawer-nav-icon material-icons" aria-hidden="true"><?=$category['icon']?></i>
<?=$category['label']?>

View 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>

View File

@ -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'),

View File

@ -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();
});

View File

@ -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">

View 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,
]
);