Migrate the Administration panel to its own Mini-SPA.
This commit is contained in:
parent
b502406766
commit
631dda96d8
|
@ -10,7 +10,6 @@ return static function (App $app) {
|
|||
$app->group(
|
||||
'',
|
||||
function (RouteCollectorProxy $group) {
|
||||
call_user_func(include(__DIR__ . '/routes/admin.php'), $group);
|
||||
call_user_func(include(__DIR__ . '/routes/base.php'), $group);
|
||||
call_user_func(include(__DIR__ . '/routes/public.php'), $group);
|
||||
call_user_func(include(__DIR__ . '/routes/stations.php'), $group);
|
||||
|
|
|
@ -1,92 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Controller;
|
||||
use App\Enums\GlobalPermissions;
|
||||
use App\Middleware;
|
||||
use Slim\Routing\RouteCollectorProxy;
|
||||
|
||||
return static function (RouteCollectorProxy $app) {
|
||||
$app->group(
|
||||
'/admin',
|
||||
function (RouteCollectorProxy $group) {
|
||||
$group->get('', Controller\Admin\IndexAction::class)
|
||||
->setName('admin:index:index');
|
||||
|
||||
$group->get('/debug', Controller\Admin\DebugAction::class)
|
||||
->setName('admin:debug:index');
|
||||
|
||||
$group->group(
|
||||
'/install',
|
||||
function (RouteCollectorProxy $group) {
|
||||
$group->get('/shoutcast', Controller\Admin\ShoutcastAction::class)
|
||||
->setName('admin:install_shoutcast:index');
|
||||
|
||||
$group->get('/stereo_tool', Controller\Admin\StereoToolAction::class)
|
||||
->setName('admin:install_stereo_tool:index');
|
||||
|
||||
$group->get('/geolite', Controller\Admin\GeoLiteAction::class)
|
||||
->setName('admin:install_geolite:index');
|
||||
}
|
||||
)->add(new Middleware\Permissions(GlobalPermissions::Settings));
|
||||
|
||||
$group->get('/auditlog', Controller\Admin\AuditLogAction::class)
|
||||
->setName('admin:auditlog:index')
|
||||
->add(new Middleware\Permissions(GlobalPermissions::Logs));
|
||||
|
||||
$group->get('/api-keys', Controller\Admin\ApiKeysAction::class)
|
||||
->setName('admin:api:index')
|
||||
->add(new Middleware\Permissions(GlobalPermissions::ApiKeys));
|
||||
|
||||
$group->get('/backups', Controller\Admin\BackupsAction::class)
|
||||
->setName('admin:backups:index')
|
||||
->add(new Middleware\Permissions(GlobalPermissions::Backups));
|
||||
|
||||
$group->get('/branding', Controller\Admin\BrandingAction::class)
|
||||
->setName('admin:branding:index')
|
||||
->add(new Middleware\Permissions(GlobalPermissions::Settings));
|
||||
|
||||
$group->get('/custom_fields', Controller\Admin\CustomFieldsAction::class)
|
||||
->setName('admin:custom_fields:index')
|
||||
->add(new Middleware\Permissions(GlobalPermissions::CustomFields));
|
||||
|
||||
$group->get('/logs', Controller\Admin\LogsAction::class)
|
||||
->setName('admin:logs:index')
|
||||
->add(new Middleware\Permissions(GlobalPermissions::Logs));
|
||||
|
||||
$group->get('/permissions', Controller\Admin\PermissionsAction::class)
|
||||
->setName('admin:permissions:index')
|
||||
->add(new Middleware\Permissions(GlobalPermissions::All));
|
||||
|
||||
$group->get('/relays', Controller\Admin\RelaysAction::class)
|
||||
->setName('admin:relays:index')
|
||||
->add(new Middleware\Permissions(GlobalPermissions::Stations));
|
||||
|
||||
$group->map(['GET', 'POST'], '/settings', Controller\Admin\SettingsAction::class)
|
||||
->setName('admin:settings:index')
|
||||
->add(new Middleware\Permissions(GlobalPermissions::Settings));
|
||||
|
||||
$group->get('/stations', Controller\Admin\StationsAction::class)
|
||||
->setName('admin:stations:index')
|
||||
->add(new Middleware\Permissions(GlobalPermissions::Stations));
|
||||
|
||||
$group->get('/storage_locations', Controller\Admin\StorageLocationsAction::class)
|
||||
->setName('admin:storage_locations:index')
|
||||
->add(new Middleware\Permissions(GlobalPermissions::StorageLocations));
|
||||
|
||||
$group->get('/updates', Controller\Admin\UpdatesAction::class)
|
||||
->setName('admin:updates:index')
|
||||
->add(new Middleware\Permissions(GlobalPermissions::All));
|
||||
|
||||
$group->get('/users', Controller\Admin\UsersAction::class)
|
||||
->setName('admin:users:index')
|
||||
->add(new Middleware\Permissions(GlobalPermissions::All));
|
||||
}
|
||||
)
|
||||
->add(Middleware\Module\Admin::class)
|
||||
->add(Middleware\Module\PanelLayout::class)
|
||||
->add(Middleware\EnableView::class)
|
||||
->add(new Middleware\Permissions(GlobalPermissions::View))
|
||||
->add(Middleware\RequireLogin::class);
|
||||
};
|
|
@ -95,6 +95,7 @@ return static function (RouteCollectorProxy $app) {
|
|||
call_user_func(include(__DIR__ . '/api_admin.php'), $group);
|
||||
call_user_func(include(__DIR__ . '/api_frontend.php'), $group);
|
||||
call_user_func(include(__DIR__ . '/api_station.php'), $group);
|
||||
call_user_func(include(__DIR__ . '/api_vue.php'), $group);
|
||||
}
|
||||
)->add(Middleware\Module\Api::class);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Controller;
|
||||
use App\Enums\GlobalPermissions;
|
||||
use App\Middleware;
|
||||
use Slim\Routing\RouteCollectorProxy;
|
||||
|
||||
return static function (RouteCollectorProxy $app) {
|
||||
$app->group(
|
||||
'/vue/admin',
|
||||
function (RouteCollectorProxy $group) {
|
||||
$group->get('/backups', Controller\Api\VueProps\Admin\BackupsAction::class)
|
||||
->setName('api:vue:admin:backups')
|
||||
->add(new Middleware\Permissions(GlobalPermissions::Backups));
|
||||
|
||||
$group->get('/custom_fields', Controller\Api\VueProps\Admin\CustomFieldsAction::class)
|
||||
->setName('api:vue:admin:custom_fields')
|
||||
->add(new Middleware\Permissions(GlobalPermissions::CustomFields));
|
||||
|
||||
$group->get('/debug', Controller\Api\VueProps\Admin\DebugAction::class)
|
||||
->setName('api:vue:admin:debug')
|
||||
->add(new Middleware\Permissions(GlobalPermissions::All));
|
||||
|
||||
$group->get('/logs', Controller\Api\VueProps\Admin\LogsAction::class)
|
||||
->setName('api:vue:admin:logs')
|
||||
->add(new Middleware\Permissions(GlobalPermissions::Logs));
|
||||
|
||||
$group->get('/permissions', Controller\Api\VueProps\Admin\PermissionsAction::class)
|
||||
->setName('api:vue:admin:permissions')
|
||||
->add(new Middleware\Permissions(GlobalPermissions::All));
|
||||
|
||||
$group->get('/settings', Controller\Api\VueProps\Admin\SettingsAction::class)
|
||||
->setName('api:vue:admin:settings')
|
||||
->add(new Middleware\Permissions(GlobalPermissions::Settings));
|
||||
|
||||
$group->get('/stations', Controller\Api\VueProps\Admin\StationsAction::class)
|
||||
->setName('api:vue:admin:stations')
|
||||
->add(new Middleware\Permissions(GlobalPermissions::Stations));
|
||||
|
||||
$group->get('/updates', Controller\Api\VueProps\Admin\UpdatesAction::class)
|
||||
->setName('api:vue:admin:updates')
|
||||
->add(new Middleware\Permissions(GlobalPermissions::All));
|
||||
|
||||
$group->get('/users', Controller\Api\VueProps\Admin\UsersAction::class)
|
||||
->setName('api:vue:admin:users')
|
||||
->add(new Middleware\Permissions(GlobalPermissions::All));
|
||||
}
|
||||
)->add(new Middleware\Permissions(GlobalPermissions::View))
|
||||
->add(Middleware\RequireLogin::class);
|
||||
};
|
|
@ -24,7 +24,6 @@ return static function (RouteCollectorProxy $app) {
|
|||
->setName('account:endmasquerade')
|
||||
->add(Middleware\RequireLogin::class);
|
||||
|
||||
|
||||
$app->group(
|
||||
'',
|
||||
function (RouteCollectorProxy $group) {
|
||||
|
@ -75,4 +74,11 @@ return static function (RouteCollectorProxy $app) {
|
|||
->add(Middleware\Module\PanelLayout::class);
|
||||
}
|
||||
)->add(Middleware\EnableView::class);
|
||||
|
||||
$app->get('/admin', Controller\Admin\IndexAction::class)
|
||||
->setName('admin:index:index')
|
||||
->add(Middleware\Module\PanelLayout::class)
|
||||
->add(Middleware\EnableView::class)
|
||||
->add(new Middleware\Permissions(GlobalPermissions::View))
|
||||
->add(Middleware\RequireLogin::class);
|
||||
};
|
||||
|
|
|
@ -41,12 +41,14 @@
|
|||
"vue-axios": "^3.5",
|
||||
"vue-codemirror6": "^1.0",
|
||||
"vue-easy-lightbox": "^1.16",
|
||||
"vue-router": "^4.2.4",
|
||||
"vue3-gettext": "^2.3.4",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"wavesurfer.js": "^7",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.14.196",
|
||||
"@types/luxon": "^3.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "^6.2.1",
|
||||
"@typescript-eslint/parser": "^6.2.1",
|
||||
|
@ -949,6 +951,12 @@
|
|||
"integrity": "sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.14.196",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.196.tgz",
|
||||
"integrity": "sha512-22y3o88f4a94mKljsZcanlNWPzO0uBsBdzLAngf2tp533LzZcQzb6+eZPJ+vCTt+bqF2XnvT9gejTLsAcJAJyQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/luxon": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.1.tgz",
|
||||
|
@ -4519,6 +4527,20 @@
|
|||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.2.4.tgz",
|
||||
"integrity": "sha512-9PISkmaCO02OzPVOMq2w82ilty6+xJmQrarYZDkjZBfl4RvYAlt4PKnEX21oW4KTtWfa9OuO/b3qk1Od3AEdCQ==",
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^6.5.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/posva"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue3-gettext": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/vue3-gettext/-/vue3-gettext-2.4.0.tgz",
|
||||
|
|
|
@ -41,12 +41,14 @@
|
|||
"vue-axios": "^3.5",
|
||||
"vue-codemirror6": "^1.0",
|
||||
"vue-easy-lightbox": "^1.16",
|
||||
"vue-router": "^4.2.4",
|
||||
"vue3-gettext": "^2.3.4",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"wavesurfer.js": "^7",
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.14.196",
|
||||
"@types/luxon": "^3.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "^6.2.1",
|
||||
"@typescript-eslint/parser": "^6.2.1",
|
||||
|
|
|
@ -4,14 +4,15 @@
|
|||
}
|
||||
|
||||
@include media-breakpoint-up(lg) {
|
||||
body.has-sidebar {
|
||||
#main {
|
||||
margin-left: $navdrawer-width;
|
||||
}
|
||||
#main.has-sidebar {
|
||||
margin-left: $navdrawer-width;
|
||||
}
|
||||
footer.has-sidebar {
|
||||
left: $navdrawer-width;
|
||||
}
|
||||
|
||||
#footer {
|
||||
left: $navdrawer-width;
|
||||
}
|
||||
.offcanvas-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.offcanvas.navdrawer {
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
<template>
|
||||
<panel-layout v-bind="panelProps">
|
||||
<template
|
||||
v-if="!isHome"
|
||||
#sidebar
|
||||
>
|
||||
<sidebar v-bind="sidebarProps" />
|
||||
</template>
|
||||
<template #default>
|
||||
<router-view />
|
||||
</template>
|
||||
</panel-layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import PanelLayout from "~/components/PanelLayout.vue";
|
||||
import {useAzuraCast} from "~/vendor/azuracast.ts";
|
||||
import {useRoute} from "vue-router";
|
||||
import {ref, watch} from "vue";
|
||||
import Sidebar from "~/components/Admin/Sidebar.vue";
|
||||
|
||||
const {panelProps, sidebarProps} = useAzuraCast();
|
||||
|
||||
const isHome = ref(true);
|
||||
const route = useRoute();
|
||||
|
||||
watch(route, (newRoute) => {
|
||||
isHome.value = newRoute.name === 'admin:index';
|
||||
});
|
||||
</script>
|
|
@ -195,6 +195,7 @@ const settings = ref({...blankSettings});
|
|||
|
||||
const {$gettext} = useTranslate();
|
||||
const {timeConfig} = useAzuraCast();
|
||||
const {DateTime} = useLuxon();
|
||||
|
||||
const fields = [
|
||||
{
|
||||
|
|
|
@ -6,8 +6,8 @@
|
|||
|
||||
<div class="row row-of-cards">
|
||||
<div
|
||||
v-for="(panel, key) in adminPanels"
|
||||
:key="key"
|
||||
v-for="panel in menuItems"
|
||||
:key="panel.key"
|
||||
class="col-sm-12 col-lg-4"
|
||||
>
|
||||
<section class="card">
|
||||
|
@ -26,14 +26,14 @@
|
|||
</div>
|
||||
|
||||
<div class="list-group list-group-flush">
|
||||
<a
|
||||
v-for="(item, itemKey) in panel.items"
|
||||
:key="itemKey"
|
||||
:href="item.url"
|
||||
<router-link
|
||||
v-for="item in panel.items"
|
||||
:key="item.key"
|
||||
:to="item.url"
|
||||
class="list-group-item list-group-item-action"
|
||||
>
|
||||
{{ item.label }}
|
||||
</a>
|
||||
</router-link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
@ -415,14 +415,13 @@ import RunningBadge from "~/components/Common/Badges/RunningBadge.vue";
|
|||
import {onMounted, ref, shallowRef} from "vue";
|
||||
import {useAxios} from "~/vendor/axios";
|
||||
import {useNotify} from "~/functions/useNotify";
|
||||
import {useAzuraCast} from "~/vendor/azuracast";
|
||||
import {getApiUrl} from "~/router";
|
||||
import {useAdminMenu} from "~/components/Admin/menu";
|
||||
|
||||
const statsUrl = getApiUrl('/admin/server/stats');
|
||||
const servicesUrl = getApiUrl('/admin/services');
|
||||
|
||||
const {sidebarProps} = useAzuraCast();
|
||||
const adminPanels = sidebarProps.menu;
|
||||
const menuItems = useAdminMenu();
|
||||
|
||||
const stats = shallowRef({
|
||||
cpu: {
|
||||
|
|
|
@ -1,34 +1,19 @@
|
|||
<template>
|
||||
<div class="navdrawer-header">
|
||||
<a
|
||||
<router-link
|
||||
:to="{ name: 'admin:index'}"
|
||||
class="navbar-brand px-0"
|
||||
:href="homeUrl"
|
||||
>
|
||||
{{ $gettext('Administration') }}
|
||||
</a>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<sidebar-menu
|
||||
:menu="menu"
|
||||
:active="active"
|
||||
/>
|
||||
<sidebar-menu :menu="menuItems" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SidebarMenu from "~/components/Common/SidebarMenu.vue";
|
||||
import SidebarMenu from "~/components/Common/SidebarMenuNew.vue";
|
||||
import {useAdminMenu} from "~/components/Admin/menu";
|
||||
|
||||
const props = defineProps({
|
||||
homeUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
menu: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
active: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
const menuItems = useAdminMenu();
|
||||
</script>
|
||||
|
|
|
@ -101,16 +101,15 @@
|
|||
v-if="enableWebUpdates"
|
||||
#footer_actions
|
||||
>
|
||||
<a
|
||||
<router-link
|
||||
:to="{ name: 'admin:backups:index' }"
|
||||
class="btn btn-dark"
|
||||
:href="backupUrl"
|
||||
target="_blank"
|
||||
>
|
||||
<icon icon="backup" />
|
||||
<span>
|
||||
{{ $gettext('Backup') }}
|
||||
</span>
|
||||
</a>
|
||||
</router-link>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success"
|
||||
|
@ -172,10 +171,6 @@ const props = defineProps({
|
|||
return {};
|
||||
}
|
||||
},
|
||||
backupUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
updatesApiUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
|
|
|
@ -0,0 +1,169 @@
|
|||
import {useTranslate} from "~/vendor/gettext.ts";
|
||||
import {GlobalPermission, userAllowed} from "~/acl.ts";
|
||||
import filterMenu from "~/functions/filterMenu.ts";
|
||||
|
||||
export function useAdminMenu(): array {
|
||||
const {$gettext} = useTranslate();
|
||||
|
||||
const menu = [
|
||||
{
|
||||
key: 'maintenance',
|
||||
label: $gettext('System Maintenance'),
|
||||
icon: 'router',
|
||||
items: [
|
||||
{
|
||||
key: 'settings',
|
||||
label: $gettext('System Settings'),
|
||||
url: {
|
||||
name: 'admin:settings:index'
|
||||
},
|
||||
visible: userAllowed(GlobalPermission.Settings)
|
||||
},
|
||||
{
|
||||
key: 'branding',
|
||||
label: $gettext('Custom Branding'),
|
||||
url: {
|
||||
name: 'admin:branding:index'
|
||||
},
|
||||
visible: userAllowed(GlobalPermission.Settings)
|
||||
},
|
||||
{
|
||||
key: 'logs',
|
||||
label: $gettext('System Logs'),
|
||||
url: {
|
||||
name: 'admin:logs:index'
|
||||
},
|
||||
visible: userAllowed(GlobalPermission.Logs)
|
||||
},
|
||||
{
|
||||
key: 'storage_locations',
|
||||
label: $gettext('Storage Locations'),
|
||||
url: {
|
||||
name: 'admin:storage_locations:index'
|
||||
},
|
||||
visible: userAllowed(GlobalPermission.StorageLocations)
|
||||
},
|
||||
{
|
||||
key: 'backups',
|
||||
label: $gettext('Backups'),
|
||||
url: {
|
||||
name: 'admin:backups:index'
|
||||
},
|
||||
visible: userAllowed(GlobalPermission.Backups)
|
||||
},
|
||||
{
|
||||
key: 'debug',
|
||||
label: $gettext('System Debugger'),
|
||||
url: {
|
||||
name: 'admin:debug:index'
|
||||
},
|
||||
visible: userAllowed(GlobalPermission.All)
|
||||
},
|
||||
{
|
||||
key: 'updates',
|
||||
label: $gettext('Update AzuraCast'),
|
||||
url: {
|
||||
name: 'admin:updates:index'
|
||||
},
|
||||
visible: userAllowed(GlobalPermission.All)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'users',
|
||||
label: $gettext('Users'),
|
||||
icon: 'group',
|
||||
items: [
|
||||
{
|
||||
key: 'manage_users',
|
||||
label: $gettext('User Accounts'),
|
||||
url: {
|
||||
name: 'admin:users:index'
|
||||
},
|
||||
visible: userAllowed(GlobalPermission.All)
|
||||
},
|
||||
{
|
||||
key: 'permissions',
|
||||
label: $gettext('Roles & Permissions'),
|
||||
url: {
|
||||
name: 'admin:permissions:index'
|
||||
},
|
||||
visible: userAllowed(GlobalPermission.All)
|
||||
},
|
||||
{
|
||||
key: 'auditlog',
|
||||
label: $gettext('Audit Log'),
|
||||
url: {
|
||||
name: 'admin:auditlog:index'
|
||||
},
|
||||
visible: userAllowed(GlobalPermission.Logs)
|
||||
},
|
||||
{
|
||||
key: 'api_keys',
|
||||
label: $gettext('API Keys'),
|
||||
url: {
|
||||
name: 'admin:api:index'
|
||||
},
|
||||
visible: userAllowed(GlobalPermission.ApiKeys)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'stations',
|
||||
label: $gettext('Stations'),
|
||||
icon: 'volume_up',
|
||||
items: [
|
||||
{
|
||||
key: 'manage_stations',
|
||||
label: $gettext('Stations'),
|
||||
url: {
|
||||
name: 'admin:stations:index'
|
||||
},
|
||||
visible: userAllowed(GlobalPermission.Stations)
|
||||
},
|
||||
{
|
||||
key: 'custom_fields',
|
||||
label: $gettext('Custom Fields'),
|
||||
url: {
|
||||
name: 'admin:custom_fields:index'
|
||||
},
|
||||
visible: userAllowed(GlobalPermission.CustomFields)
|
||||
},
|
||||
{
|
||||
key: 'relays',
|
||||
label: $gettext('Connected AzuraRelays'),
|
||||
url: {
|
||||
name: 'admin:relays:index',
|
||||
},
|
||||
visible: userAllowed(GlobalPermission.Stations)
|
||||
},
|
||||
{
|
||||
key: 'shoutcast',
|
||||
label: $gettext('Install Shoutcast'),
|
||||
url: {
|
||||
name: 'admin:install_shoutcast:index'
|
||||
},
|
||||
visible: userAllowed(GlobalPermission.All)
|
||||
},
|
||||
{
|
||||
key: 'stereo_tool',
|
||||
label: $gettext('Install Stereo Tool'),
|
||||
url: {
|
||||
name: 'admin:stereo_tool:index'
|
||||
},
|
||||
visible: userAllowed(GlobalPermission.All)
|
||||
},
|
||||
{
|
||||
key: 'geolite',
|
||||
label: $gettext('Install GeoLite IP Database'),
|
||||
url: {
|
||||
name: 'admin:install_geolite:index'
|
||||
},
|
||||
visible: userAllowed(GlobalPermission.All)
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
return filterMenu(menu);
|
||||
}
|
|
@ -0,0 +1,134 @@
|
|||
<template>
|
||||
<ul class="offcanvas-body navdrawer-nav">
|
||||
<li
|
||||
v-for="category in menu"
|
||||
:key="category.key"
|
||||
class="nav-item"
|
||||
>
|
||||
<router-link
|
||||
v-if="isRouteLink(category)"
|
||||
:class="getLinkClass(category)"
|
||||
:to="category.url"
|
||||
class="nav-link"
|
||||
>
|
||||
<icon
|
||||
class="navdrawer-nav-icon"
|
||||
:icon="category.icon"
|
||||
/>
|
||||
{{ category.label }}
|
||||
</router-link>
|
||||
<a
|
||||
v-bind="getCategoryLink(category)"
|
||||
class="nav-link"
|
||||
:class="getLinkClass(category)"
|
||||
>
|
||||
<icon
|
||||
class="navdrawer-nav-icon"
|
||||
:icon="category.icon"
|
||||
/>
|
||||
{{ category.label }}
|
||||
<icon
|
||||
v-if="category.external"
|
||||
class="sm ms-2"
|
||||
icon="open_in_new"
|
||||
:aria-label="$gettext('External')"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<div
|
||||
v-if="category.items"
|
||||
:id="'sidebar-submenu-'+category.key"
|
||||
class="collapse pb-2"
|
||||
:class="(isActiveItem(category)) ? 'show' : ''"
|
||||
>
|
||||
<ul class="navdrawer-nav">
|
||||
<li
|
||||
v-for="item in category.items"
|
||||
:key="item.key"
|
||||
class="nav-item"
|
||||
>
|
||||
<router-link
|
||||
v-if="isRouteLink(item)"
|
||||
:to="item.url"
|
||||
class="nav-link ps-4 py-2"
|
||||
:class="getLinkClass(item)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</router-link>
|
||||
<a
|
||||
v-else
|
||||
class="nav-link ps-4 py-2"
|
||||
:class="item.class"
|
||||
:href="item.url"
|
||||
:target="(item.external) ? '_blank' : ''"
|
||||
:title="item.title"
|
||||
>
|
||||
{{ item.label }}
|
||||
<icon
|
||||
v-if="item.external"
|
||||
class="sm ms-2"
|
||||
icon="open_in_new"
|
||||
:aria-label="$gettext('External')"
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Icon from "~/components/Common/Icon.vue";
|
||||
import {useRoute} from "vue-router";
|
||||
import {some} from "lodash";
|
||||
|
||||
const props = defineProps({
|
||||
menu: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
});
|
||||
|
||||
const currentRoute = useRoute();
|
||||
|
||||
const isRouteLink = (item) => {
|
||||
return (typeof (item.url) !== 'undefined')
|
||||
&& (typeof (item.url) !== 'string');
|
||||
};
|
||||
|
||||
const isActiveItem = (item) => {
|
||||
if (item.items && some(item.items, isActiveItem)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isRouteLink(item) && item.url.name === currentRoute.name;
|
||||
};
|
||||
|
||||
const getLinkClass = (item) => {
|
||||
return [
|
||||
item.class ?? null,
|
||||
isActiveItem(item) ? 'active' : ''
|
||||
];
|
||||
}
|
||||
|
||||
const getCategoryLink = (item) => {
|
||||
const linkAttrs = {};
|
||||
|
||||
if (item.items) {
|
||||
linkAttrs['data-bs-toggle'] = 'collapse';
|
||||
linkAttrs.href = '#sidebar-submenu-' + item.key;
|
||||
} else {
|
||||
linkAttrs.href = category.url;
|
||||
}
|
||||
|
||||
if (item.external) {
|
||||
linkAttrs.target = '_blank';
|
||||
}
|
||||
if (item.title) {
|
||||
linkAttrs.title = category.title;
|
||||
}
|
||||
|
||||
return linkAttrs;
|
||||
}
|
||||
</script>
|
|
@ -132,11 +132,11 @@
|
|||
<slot name="sidebar" />
|
||||
</nav>
|
||||
|
||||
<section id="main">
|
||||
<main
|
||||
id="content"
|
||||
:class="[(slots.sidebar) ? 'content-alt' : '']"
|
||||
>
|
||||
<section
|
||||
id="main"
|
||||
:class="[(slots.sidebar) ? 'has-sidebar' : '']"
|
||||
>
|
||||
<main id="content">
|
||||
<div class="container">
|
||||
<slot />
|
||||
</div>
|
||||
|
@ -145,7 +145,7 @@
|
|||
|
||||
<footer
|
||||
id="footer"
|
||||
:class="[(slots.sidebar) ? 'footer-alt' : '']"
|
||||
:class="[(slots.sidebar) ? 'has-sidebar' : '']"
|
||||
>
|
||||
{{ $gettext('Powered by') }}
|
||||
<a
|
||||
|
@ -167,7 +167,7 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import {onMounted, onUnmounted, useSlots} from "vue";
|
||||
import {useSlots, watch} from "vue";
|
||||
import Icon from "~/components/Common/Icon.vue";
|
||||
import {switchTheme} from "!/js/layout";
|
||||
|
||||
|
@ -205,20 +205,26 @@ const props = defineProps({
|
|||
required: true
|
||||
},
|
||||
platform: {
|
||||
type: String,
|
||||
required: true
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
onMounted(() => {
|
||||
const handleSidebar = () => {
|
||||
if (slots.sidebar) {
|
||||
document.body.classList.add('has-sidebar');
|
||||
} else {
|
||||
document.body.classList.remove('has-sidebar');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
document.body.classList.remove('has-sidebar');
|
||||
});
|
||||
watch(
|
||||
() => slots.sidebar,
|
||||
handleSidebar,
|
||||
{
|
||||
immediate: true
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
<template>
|
||||
<async-comp v-bind="state" />
|
||||
</template>
|
||||
<script setup>
|
||||
import {defineAsyncComponent} from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
component: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
state: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const AsyncComp = defineAsyncComponent(props.component);
|
||||
</script>
|
|
@ -0,0 +1,32 @@
|
|||
import {cloneDeep, filter, forEach, get} from "lodash";
|
||||
|
||||
export default function filterMenu(menuItems) {
|
||||
const newMenu = [];
|
||||
|
||||
forEach(cloneDeep(menuItems), (menuRow) => {
|
||||
const itemIsVisible: boolean = get(menuRow, 'visible', true);
|
||||
if (!itemIsVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newMenuRow = {
|
||||
...menuRow
|
||||
};
|
||||
|
||||
if ('items' in menuRow) {
|
||||
const newMenuRowItems = filter(menuRow.items, (item) => {
|
||||
return get(item, 'visible', true);
|
||||
});
|
||||
|
||||
if (newMenuRowItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
newMenuRow.items = newMenuRowItems;
|
||||
}
|
||||
|
||||
newMenu.push(newMenuRow);
|
||||
});
|
||||
|
||||
return newMenu;
|
||||
}
|
|
@ -0,0 +1,142 @@
|
|||
import initApp from "~/layout";
|
||||
import {h, toValue} from "vue";
|
||||
import {createRouter, createWebHashHistory} from "vue-router";
|
||||
import AdminLayout from "~/components/Admin/AdminLayout.vue";
|
||||
import {getApiUrl} from "~/router";
|
||||
import axios from "axios";
|
||||
|
||||
const {vueApp} = initApp({
|
||||
render() {
|
||||
return h(AdminLayout);
|
||||
}
|
||||
});
|
||||
|
||||
const populateComponentRemotely = (url) => {
|
||||
return {
|
||||
beforeEnter: (to, from, next) => {
|
||||
axios.get(toValue(url)).then((resp) => {
|
||||
Object.assign(to.meta, {
|
||||
state: resp.data
|
||||
});
|
||||
next();
|
||||
});
|
||||
},
|
||||
props: (route) => ({
|
||||
...route.meta.state
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('~/components/Admin/Index.vue'),
|
||||
name: 'admin:index'
|
||||
},
|
||||
{
|
||||
path: '/api-keys',
|
||||
component: () => import('~/components/Admin/ApiKeys.vue'),
|
||||
name: 'admin:api-keys:index'
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'admin:settings:index',
|
||||
component: () => import('~/components/Admin/Settings.vue'),
|
||||
...populateComponentRemotely(getApiUrl('/vue/admin/settings'))
|
||||
},
|
||||
{
|
||||
path: '/branding',
|
||||
component: () => import('~/components/Admin/Branding.vue'),
|
||||
name: 'admin:branding:index'
|
||||
},
|
||||
{
|
||||
path: '/logs',
|
||||
name: 'admin:logs:index',
|
||||
component: () => import('~/components/Admin/Logs.vue'),
|
||||
...populateComponentRemotely(getApiUrl('/vue/admin/logs'))
|
||||
},
|
||||
{
|
||||
path: '/storage_locations',
|
||||
component: () => import('~/components/Admin/StorageLocations.vue'),
|
||||
name: 'admin:storage_locations:index'
|
||||
},
|
||||
{
|
||||
path: '/backups',
|
||||
component: () => import('~/components/Admin/Backups.vue'),
|
||||
name: 'admin:backups:index',
|
||||
...populateComponentRemotely(getApiUrl('/vue/admin/backups'))
|
||||
},
|
||||
{
|
||||
path: '/debug',
|
||||
component: () => import('~/components/Admin/Debug.vue'),
|
||||
name: 'admin:debug:index',
|
||||
...populateComponentRemotely(getApiUrl('/vue/admin/debug'))
|
||||
},
|
||||
{
|
||||
path: '/updates',
|
||||
component: () => import('~/components/Admin/Updates.vue'),
|
||||
name: 'admin:updates:index',
|
||||
...populateComponentRemotely(getApiUrl('/vue/admin/updates'))
|
||||
},
|
||||
{
|
||||
path: '/users',
|
||||
component: () => import('~/components/Admin/Users.vue'),
|
||||
name: 'admin:users:index',
|
||||
...populateComponentRemotely(getApiUrl('/vue/admin/users'))
|
||||
},
|
||||
{
|
||||
path: '/permissions',
|
||||
component: () => import('~/components/Admin/Permissions.vue'),
|
||||
name: 'admin:permissions:index',
|
||||
...populateComponentRemotely(getApiUrl('/vue/admin/permissions'))
|
||||
},
|
||||
{
|
||||
path: '/auditlog',
|
||||
component: () => import('~/components/Admin/AuditLog.vue'),
|
||||
name: 'admin:auditlog:index'
|
||||
},
|
||||
{
|
||||
path: '/api_keys',
|
||||
component: () => import('~/components/Admin/ApiKeys.vue'),
|
||||
name: 'admin:api:index'
|
||||
},
|
||||
{
|
||||
path: '/stations',
|
||||
component: () => import('~/components/Admin/Stations.vue'),
|
||||
name: 'admin:stations:index',
|
||||
...populateComponentRemotely(getApiUrl('/vue/admin/stations'))
|
||||
},
|
||||
{
|
||||
path: '/custom_fields',
|
||||
component: () => import('~/components/Admin/CustomFields.vue'),
|
||||
name: 'admin:custom_fields:index',
|
||||
...populateComponentRemotely(getApiUrl('/vue/admin/custom_fields'))
|
||||
},
|
||||
{
|
||||
path: '/relays',
|
||||
component: () => import('~/components/Admin/Relays.vue'),
|
||||
name: 'admin:relays:index',
|
||||
},
|
||||
{
|
||||
path: '/install_shoutcast',
|
||||
component: () => import('~/components/Admin/Shoutcast.vue'),
|
||||
name: 'admin:install_shoutcast:index'
|
||||
},
|
||||
{
|
||||
path: '/install_stereo_tool',
|
||||
component: () => import('~/components/Admin/StereoTool.vue'),
|
||||
name: 'admin:stereo_tool:index'
|
||||
},
|
||||
{
|
||||
path: '/install_geolite',
|
||||
component: () => import('~/components/Admin/GeoLite.vue'),
|
||||
name: 'admin:install_geolite:index'
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
});
|
||||
|
||||
vueApp.use(router);
|
|
@ -1,5 +0,0 @@
|
|||
import AdminApiKeys from '~/components/Admin/ApiKeys.vue';
|
||||
import initApp from "~/layout";
|
||||
import useAdminPanelLayout from "~/layouts/AdminPanelLayout";
|
||||
|
||||
initApp(useAdminPanelLayout(AdminApiKeys));
|
|
@ -1,5 +0,0 @@
|
|||
import AuditLog from '~/components/Admin/AuditLog.vue';
|
||||
import initApp from "~/layout";
|
||||
import useAdminPanelLayout from "~/layouts/AdminPanelLayout";
|
||||
|
||||
initApp(useAdminPanelLayout(AuditLog));
|
|
@ -1,5 +0,0 @@
|
|||
import AdminBackups from '~/components/Admin/Backups.vue';
|
||||
import initApp from "~/layout";
|
||||
import useAdminPanelLayout from "~/layouts/AdminPanelLayout";
|
||||
|
||||
initApp(useAdminPanelLayout(AdminBackups));
|
|
@ -1,5 +0,0 @@
|
|||
import AdminBranding from '~/components/Admin/Branding.vue';
|
||||
import initApp from "~/layout";
|
||||
import useAdminPanelLayout from "~/layouts/AdminPanelLayout";
|
||||
|
||||
initApp(useAdminPanelLayout(AdminBranding));
|
|
@ -1,5 +0,0 @@
|
|||
import AdminCustomFields from '~/components/Admin/CustomFields.vue';
|
||||
import initApp from "~/layout";
|
||||
import useAdminPanelLayout from "~/layouts/AdminPanelLayout";
|
||||
|
||||
initApp(useAdminPanelLayout(AdminCustomFields));
|
|
@ -1,5 +0,0 @@
|
|||
import AdminDebug from '~/components/Admin/Debug.vue';
|
||||
import initApp from "~/layout";
|
||||
import useAdminPanelLayout from "~/layouts/AdminPanelLayout";
|
||||
|
||||
initApp(useAdminPanelLayout(AdminDebug));
|
|
@ -1,5 +0,0 @@
|
|||
import AdminGeoLite from '~/components/Admin/GeoLite.vue';
|
||||
import initApp from "~/layout";
|
||||
import useAdminPanelLayout from "~/layouts/AdminPanelLayout";
|
||||
|
||||
initApp(useAdminPanelLayout(AdminGeoLite));
|
|
@ -1,5 +0,0 @@
|
|||
import AdminIndex from '~/components/Admin/Index.vue';
|
||||
import initApp from "~/layout";
|
||||
import usePanelLayout from "~/layouts/PanelLayout";
|
||||
|
||||
initApp(usePanelLayout(AdminIndex));
|
|
@ -1,5 +0,0 @@
|
|||
import AdminLogs from '~/components/Admin/Logs.vue';
|
||||
import initApp from "~/layout";
|
||||
import useAdminPanelLayout from "~/layouts/AdminPanelLayout";
|
||||
|
||||
initApp(useAdminPanelLayout(AdminLogs));
|
|
@ -1,5 +0,0 @@
|
|||
import AdminPermissions from '~/components/Admin/Permissions.vue';
|
||||
import initApp from "~/layout";
|
||||
import useAdminPanelLayout from "~/layouts/AdminPanelLayout";
|
||||
|
||||
initApp(useAdminPanelLayout(AdminPermissions));
|
|
@ -1,5 +0,0 @@
|
|||
import AdminRelays from '~/components/Admin/Relays.vue';
|
||||
import initApp from "~/layout";
|
||||
import useAdminPanelLayout from "~/layouts/AdminPanelLayout";
|
||||
|
||||
initApp(useAdminPanelLayout(AdminRelays));
|
|
@ -1,5 +0,0 @@
|
|||
import AdminSettings from '~/components/Admin/Settings.vue';
|
||||
import initApp from "~/layout";
|
||||
import useAdminPanelLayout from "~/layouts/AdminPanelLayout";
|
||||
|
||||
initApp(useAdminPanelLayout(AdminSettings));
|
|
@ -1,5 +0,0 @@
|
|||
import AdminShoutcast from '~/components/Admin/Shoutcast.vue';
|
||||
import initApp from "~/layout";
|
||||
import useAdminPanelLayout from "~/layouts/AdminPanelLayout";
|
||||
|
||||
initApp(useAdminPanelLayout(AdminShoutcast));
|
|
@ -1,5 +0,0 @@
|
|||
import AdminStations from '~/components/Admin/Stations.vue';
|
||||
import initApp from "~/layout";
|
||||
import useAdminPanelLayout from "~/layouts/AdminPanelLayout";
|
||||
|
||||
initApp(useAdminPanelLayout(AdminStations));
|
|
@ -1,5 +0,0 @@
|
|||
import AdminStereoTool from '~/components/Admin/StereoTool.vue';
|
||||
import initApp from "~/layout";
|
||||
import useAdminPanelLayout from "~/layouts/AdminPanelLayout";
|
||||
|
||||
initApp(useAdminPanelLayout(AdminStereoTool));
|
|
@ -1,5 +0,0 @@
|
|||
import StorageLocations from '~/components/Admin/StorageLocations.vue';
|
||||
import initApp from "~/layout";
|
||||
import useAdminPanelLayout from "~/layouts/AdminPanelLayout";
|
||||
|
||||
initApp(useAdminPanelLayout(StorageLocations));
|
|
@ -1,5 +0,0 @@
|
|||
import AdminUpdates from '~/components/Admin/Updates.vue';
|
||||
import initApp from "~/layout";
|
||||
import useAdminPanelLayout from "~/layouts/AdminPanelLayout";
|
||||
|
||||
initApp(useAdminPanelLayout(AdminUpdates));
|
|
@ -1,5 +0,0 @@
|
|||
import AdminUsers from '~/components/Admin/Users.vue';
|
||||
import initApp from "~/layout";
|
||||
import useAdminPanelLayout from "~/layouts/AdminPanelLayout";
|
||||
|
||||
initApp(useAdminPanelLayout(AdminUsers));
|
|
@ -1,26 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Controller\SingleActionInterface;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
final class ApiKeysAction implements SingleActionInterface
|
||||
{
|
||||
public function __invoke(
|
||||
ServerRequest $request,
|
||||
Response $response,
|
||||
array $params
|
||||
): ResponseInterface {
|
||||
return $request->getView()->renderVuePage(
|
||||
response: $response,
|
||||
component: 'Admin/ApiKeys',
|
||||
id: 'admin-api-keys',
|
||||
title: __('API Keys'),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Controller\SingleActionInterface;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
final class AuditLogAction implements SingleActionInterface
|
||||
{
|
||||
public function __invoke(
|
||||
ServerRequest $request,
|
||||
Response $response,
|
||||
array $params
|
||||
): ResponseInterface {
|
||||
return $request->getView()->renderVuePage(
|
||||
response: $response,
|
||||
component: 'Admin/AuditLog',
|
||||
id: 'admin-audit-log',
|
||||
title: __('Audit Log'),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Controller\SingleActionInterface;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
final class BrandingAction implements SingleActionInterface
|
||||
{
|
||||
public function __invoke(
|
||||
ServerRequest $request,
|
||||
Response $response,
|
||||
array $params
|
||||
): ResponseInterface {
|
||||
return $request->getView()->renderVuePage(
|
||||
response: $response,
|
||||
component: 'Admin/Branding',
|
||||
id: 'admin-branding',
|
||||
title: __('Custom Branding'),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Controller\SingleActionInterface;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
final class GeoLiteAction implements SingleActionInterface
|
||||
{
|
||||
public function __invoke(
|
||||
ServerRequest $request,
|
||||
Response $response,
|
||||
array $params
|
||||
): ResponseInterface {
|
||||
return $request->getView()->renderVuePage(
|
||||
response: $response,
|
||||
component: 'Admin/GeoLite',
|
||||
id: 'admin-geolite',
|
||||
title: __('Install GeoLite IP Database'),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -18,7 +18,7 @@ final class IndexAction implements SingleActionInterface
|
|||
): ResponseInterface {
|
||||
return $request->getView()->renderVuePage(
|
||||
response: $response,
|
||||
component: 'Admin/Index',
|
||||
component: 'Admin',
|
||||
id: 'admin-index',
|
||||
title: __('Administration'),
|
||||
);
|
||||
|
|
|
@ -1,26 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Controller\SingleActionInterface;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
final class RelaysAction implements SingleActionInterface
|
||||
{
|
||||
public function __invoke(
|
||||
ServerRequest $request,
|
||||
Response $response,
|
||||
array $params
|
||||
): ResponseInterface {
|
||||
return $request->getView()->renderVuePage(
|
||||
response: $response,
|
||||
component: 'Admin/Relays',
|
||||
id: 'admin-relays',
|
||||
title: __('Connected AzuraRelays')
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,31 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Controller\SingleActionInterface;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use RuntimeException;
|
||||
|
||||
final class ShoutcastAction implements SingleActionInterface
|
||||
{
|
||||
public function __invoke(
|
||||
ServerRequest $request,
|
||||
Response $response,
|
||||
array $params
|
||||
): ResponseInterface {
|
||||
if ('x86_64' !== php_uname('m')) {
|
||||
throw new RuntimeException('Shoutcast cannot be installed on non-X86_64 systems.');
|
||||
}
|
||||
|
||||
return $request->getView()->renderVuePage(
|
||||
response: $response,
|
||||
component: 'Admin/Shoutcast',
|
||||
id: 'admin-shoutcast',
|
||||
title: __('Install Shoutcast 2 DNAS'),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Controller\SingleActionInterface;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
final class StereoToolAction implements SingleActionInterface
|
||||
{
|
||||
public function __invoke(
|
||||
ServerRequest $request,
|
||||
Response $response,
|
||||
array $params
|
||||
): ResponseInterface {
|
||||
return $request->getView()->renderVuePage(
|
||||
response: $response,
|
||||
component: 'Admin/StereoTool',
|
||||
id: 'admin-stereo-tool',
|
||||
title: __('Install Stereo Tool'),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Controller\SingleActionInterface;
|
||||
use App\Http\Response;
|
||||
use App\Http\ServerRequest;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
final class StorageLocationsAction implements SingleActionInterface
|
||||
{
|
||||
public function __invoke(
|
||||
ServerRequest $request,
|
||||
Response $response,
|
||||
array $params
|
||||
): ResponseInterface {
|
||||
return $request->getView()->renderVuePage(
|
||||
response: $response,
|
||||
component: 'Admin/StorageLocations',
|
||||
id: 'admin-storage-locations',
|
||||
title: __('Storage Locations'),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
namespace App\Controller\Api\VueProps\Admin;
|
||||
|
||||
use App\Container\EnvironmentAwareTrait;
|
||||
use App\Controller\SingleActionInterface;
|
||||
|
@ -26,17 +26,11 @@ final class BackupsAction implements SingleActionInterface
|
|||
Response $response,
|
||||
array $params
|
||||
): ResponseInterface {
|
||||
return $request->getView()->renderVuePage(
|
||||
response: $response,
|
||||
component: 'Admin/Backups',
|
||||
id: 'admin-backups',
|
||||
title: __('Backups'),
|
||||
props: [
|
||||
'isDocker' => $this->environment->isDocker(),
|
||||
'storageLocations' => $this->storageLocationRepo->fetchSelectByType(
|
||||
StorageLocationTypes::Backup
|
||||
),
|
||||
],
|
||||
);
|
||||
return $response->withJson([
|
||||
'isDocker' => $this->environment->isDocker(),
|
||||
'storageLocations' => $this->storageLocationRepo->fetchSelectByType(
|
||||
StorageLocationTypes::Backup
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
namespace App\Controller\Api\VueProps\Admin;
|
||||
|
||||
use App\Controller\SingleActionInterface;
|
||||
use App\Http\Response;
|
||||
|
@ -17,14 +17,8 @@ final class CustomFieldsAction implements SingleActionInterface
|
|||
Response $response,
|
||||
array $params
|
||||
): ResponseInterface {
|
||||
return $request->getView()->renderVuePage(
|
||||
response: $response,
|
||||
component: 'Admin/CustomFields',
|
||||
id: 'admin-custom-fields',
|
||||
title: __('Custom Fields'),
|
||||
props: [
|
||||
'autoAssignTypes' => MetadataTags::getNames(),
|
||||
]
|
||||
);
|
||||
return $response->withJson([
|
||||
'autoAssignTypes' => MetadataTags::getNames(),
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
namespace App\Controller\Api\VueProps\Admin;
|
||||
|
||||
use App\Cache\DatabaseCache;
|
||||
use App\Console\Command\Sync\SingleTaskCommand;
|
||||
|
@ -91,18 +91,12 @@ final class DebugAction implements SingleActionInterface
|
|||
];
|
||||
}
|
||||
|
||||
return $request->getView()->renderVuePage(
|
||||
response: $response,
|
||||
component: 'Admin/Debug',
|
||||
id: 'admin-debug',
|
||||
title: __('System Debugger'),
|
||||
props: [
|
||||
'clearCacheUrl' => $router->named('api:admin:debug:clear-cache'),
|
||||
'clearQueuesUrl' => $router->named('api:admin:debug:clear-queue'),
|
||||
'syncTasks' => $syncTasks,
|
||||
'queueTotals' => $queueTotals,
|
||||
'stations' => $stations,
|
||||
]
|
||||
);
|
||||
return $response->withJson([
|
||||
'clearCacheUrl' => $router->named('api:admin:debug:clear-cache'),
|
||||
'clearQueuesUrl' => $router->named('api:admin:debug:clear-queue'),
|
||||
'syncTasks' => $syncTasks,
|
||||
'queueTotals' => $queueTotals,
|
||||
'stations' => $stations,
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
namespace App\Controller\Api\VueProps\Admin;
|
||||
|
||||
use App\Controller\SingleActionInterface;
|
||||
use App\Entity\Repository\StationRepository;
|
||||
|
@ -39,15 +39,9 @@ final class LogsAction implements SingleActionInterface
|
|||
}
|
||||
}
|
||||
|
||||
return $request->getView()->renderVuePage(
|
||||
response: $response,
|
||||
component: 'Admin/Logs',
|
||||
id: 'admin-logs',
|
||||
title: __('System Logs'),
|
||||
props: [
|
||||
'systemLogsUrl' => $router->fromHere('api:admin:logs'),
|
||||
'stationLogs' => $stationLogs,
|
||||
],
|
||||
);
|
||||
return $response->withJson([
|
||||
'systemLogsUrl' => $router->fromHere('api:admin:logs'),
|
||||
'stationLogs' => $stationLogs,
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
namespace App\Controller\Api\VueProps\Admin;
|
||||
|
||||
use App\Controller\SingleActionInterface;
|
||||
use App\Entity\Repository\StationRepository;
|
||||
|
@ -26,17 +26,11 @@ final class PermissionsAction implements SingleActionInterface
|
|||
|
||||
$actions = $request->getAcl()->listPermissions();
|
||||
|
||||
return $request->getView()->renderVuePage(
|
||||
response: $response,
|
||||
component: 'Admin/Permissions',
|
||||
id: 'admin-permissions',
|
||||
title: __('Roles & Permissions'),
|
||||
props: [
|
||||
'listUrl' => $router->fromHere('api:admin:roles'),
|
||||
'stations' => $this->stationRepo->fetchSelect(),
|
||||
'globalPermissions' => $actions['global'],
|
||||
'stationPermissions' => $actions['station'],
|
||||
]
|
||||
);
|
||||
return $response->withJson([
|
||||
'listUrl' => $router->fromHere('api:admin:roles'),
|
||||
'stations' => $this->stationRepo->fetchSelect(),
|
||||
'globalPermissions' => $actions['global'],
|
||||
'stationPermissions' => $actions['station'],
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
namespace App\Controller\Api\VueProps\Admin;
|
||||
|
||||
use App\Controller\SingleActionInterface;
|
||||
use App\Http\Response;
|
||||
|
@ -22,12 +22,6 @@ final class SettingsAction implements SingleActionInterface
|
|||
Response $response,
|
||||
array $params
|
||||
): ResponseInterface {
|
||||
return $request->getView()->renderVuePage(
|
||||
response: $response,
|
||||
component: 'Admin/Settings',
|
||||
id: 'admin-settings',
|
||||
title: __('System Settings'),
|
||||
props: $this->settingsComponent->getProps($request),
|
||||
);
|
||||
return $response->withJson($this->settingsComponent->getProps($request));
|
||||
}
|
||||
}
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
namespace App\Controller\Api\VueProps\Admin;
|
||||
|
||||
use App\Controller\SingleActionInterface;
|
||||
use App\Http\Response;
|
||||
|
@ -26,12 +26,8 @@ final class StationsAction implements SingleActionInterface
|
|||
): ResponseInterface {
|
||||
$router = $request->getRouter();
|
||||
|
||||
return $request->getView()->renderVuePage(
|
||||
response: $response,
|
||||
component: 'Admin/Stations',
|
||||
id: 'admin-stations',
|
||||
title: __('Stations'),
|
||||
props: array_merge(
|
||||
return $response->withJson(
|
||||
array_merge(
|
||||
$this->stationFormComponent->getProps($request),
|
||||
[
|
||||
'listUrl' => $router->fromHere('api:admin:stations'),
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
namespace App\Controller\Api\VueProps\Admin;
|
||||
|
||||
use App\Container\EnvironmentAwareTrait;
|
||||
use App\Container\SettingsAwareTrait;
|
||||
|
@ -31,18 +31,11 @@ final class UpdatesAction implements SingleActionInterface
|
|||
|
||||
$router = $request->getRouter();
|
||||
|
||||
return $request->getView()->renderVuePage(
|
||||
response: $response,
|
||||
component: 'Admin/Updates',
|
||||
id: 'admin-updates',
|
||||
title: __('Update AzuraCast'),
|
||||
props: [
|
||||
'releaseChannel' => $this->version->getReleaseChannelEnum()->value,
|
||||
'initialUpdateInfo' => $settings->getUpdateResults(),
|
||||
'backupUrl' => $router->named('admin:backups:index'),
|
||||
'updatesApiUrl' => $router->named('api:admin:updates'),
|
||||
'enableWebUpdates' => $this->environment->enableWebUpdater(),
|
||||
],
|
||||
);
|
||||
return $response->withJson([
|
||||
'releaseChannel' => $this->version->getReleaseChannelEnum()->value,
|
||||
'initialUpdateInfo' => $settings->getUpdateResults(),
|
||||
'updatesApiUrl' => $router->named('api:admin:updates'),
|
||||
'enableWebUpdates' => $this->environment->enableWebUpdater(),
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
namespace App\Controller\Api\VueProps\Admin;
|
||||
|
||||
use App\Controller\SingleActionInterface;
|
||||
use App\Entity\Repository\RoleRepository;
|
||||
|
@ -24,15 +24,9 @@ final class UsersAction implements SingleActionInterface
|
|||
): ResponseInterface {
|
||||
$router = $request->getRouter();
|
||||
|
||||
return $request->getView()->renderVuePage(
|
||||
response: $response,
|
||||
component: 'Admin/Users',
|
||||
id: 'admin-users',
|
||||
title: __('Users'),
|
||||
props: [
|
||||
'listUrl' => $router->fromHere('api:admin:users'),
|
||||
'roles' => $this->roleRepo->fetchSelect(),
|
||||
]
|
||||
);
|
||||
return $response->withJson([
|
||||
'listUrl' => $router->fromHere('api:admin:users'),
|
||||
'roles' => $this->roleRepo->fetchSelect(),
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -19,6 +19,8 @@ final class EndMasqueradeAction implements SingleActionInterface
|
|||
$auth = $request->getAuth();
|
||||
$auth->endMasquerade();
|
||||
|
||||
return $response->withRedirect($request->getRouter()->named('admin:users:index'));
|
||||
$router = $request->getRouter();
|
||||
|
||||
return $response->withRedirect($router->named('admin:index:index') . '#/users');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -41,7 +41,7 @@ final class DashboardAction implements SingleActionInterface
|
|||
'notificationsUrl' => $router->named('api:frontend:dashboard:notifications'),
|
||||
'showCharts' => $showCharts,
|
||||
'chartsUrl' => $router->named('api:frontend:dashboard:charts'),
|
||||
'manageStationsUrl' => $router->named('admin:stations:index'),
|
||||
'manageStationsUrl' => $router->named('admin:index:index') . '#/stations',
|
||||
'stationsUrl' => $router->named('api:frontend:dashboard:stations'),
|
||||
'showAlbumArt' => !$settings->getHideAlbumArt(),
|
||||
]
|
||||
|
|
|
@ -1,57 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Middleware\Module;
|
||||
|
||||
use App\Container\SettingsAwareTrait;
|
||||
use App\Event;
|
||||
use App\Http\ServerRequest;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Slim\Interfaces\RouteInterface;
|
||||
use Slim\Routing\RouteContext;
|
||||
|
||||
/**
|
||||
* Module middleware for the /admin pages.
|
||||
*/
|
||||
final class Admin
|
||||
{
|
||||
use SettingsAwareTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly EventDispatcherInterface $dispatcher,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(ServerRequest $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$settings = $this->readSettings();
|
||||
|
||||
$event = new Event\BuildAdminMenu($request, $settings);
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
$view = $request->getView();
|
||||
|
||||
$activeTab = null;
|
||||
$currentRoute = RouteContext::fromRequest($request)->getRoute();
|
||||
|
||||
if ($currentRoute instanceof RouteInterface) {
|
||||
$routeParts = explode(':', $currentRoute->getName() ?? '');
|
||||
$activeTab = $routeParts[1];
|
||||
}
|
||||
|
||||
$globalProps = $view->getGlobalProps();
|
||||
|
||||
$router = $request->getRouter();
|
||||
|
||||
$globalProps->set('sidebarProps', [
|
||||
'homeUrl' => $router->named('admin:index:index'),
|
||||
'menu' => $event->getFilteredMenu(),
|
||||
'active' => $activeTab,
|
||||
]);
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
|
@ -56,7 +56,7 @@ final class BaseUrlCheck
|
|||
$notification->body = implode(' ', $notificationBodyParts);
|
||||
$notification->type = FlashLevels::Warning->value;
|
||||
$notification->actionLabel = __('System Settings');
|
||||
$notification->actionUrl = $router->named('admin:settings:index');
|
||||
$notification->actionUrl = $router->named('admin:index:index') . '#/settings';
|
||||
|
||||
$event->addNotification($notification);
|
||||
}
|
||||
|
|
|
@ -50,7 +50,7 @@ final class RecentBackupCheck
|
|||
|
||||
$router = $request->getRouter();
|
||||
$notification->actionLabel = __('Backups');
|
||||
$notification->actionUrl = $router->named('admin:backups:index');
|
||||
$notification->actionUrl = $router->named('admin:index:index') . '#/backups';
|
||||
|
||||
$event->addNotification($notification);
|
||||
}
|
||||
|
|
|
@ -57,7 +57,7 @@ final class SyncTaskCheck
|
|||
$router = $request->getRouter();
|
||||
|
||||
$notification->actionLabel = __('System Debugger');
|
||||
$notification->actionUrl = $router->named('admin:debug:index');
|
||||
$notification->actionUrl = $router->named('admin:index:index') . '#/debug';
|
||||
// phpcs:enable
|
||||
|
||||
$event->addNotification($notification);
|
||||
|
|
|
@ -42,7 +42,7 @@ final class UpdateCheck
|
|||
$router = $event->getRequest()->getRouter();
|
||||
|
||||
$actionLabel = __('Update AzuraCast');
|
||||
$actionUrl = $router->named('admin:updates:index');
|
||||
$actionUrl = $router->named('admin:index:index') . '#/updates';
|
||||
|
||||
$releaseChannel = $this->version->getReleaseChannelEnum();
|
||||
|
||||
|
|
33
src/View.php
33
src/View.php
|
@ -85,7 +85,7 @@ final class View extends Engine
|
|||
function (string $componentPath) use ($vueComponents, $environment) {
|
||||
$assetRoot = '/static/vite_dist';
|
||||
|
||||
if ($environment->isDevelopment()) {
|
||||
if ($environment->isDevelopment() || $environment->isTesting()) {
|
||||
return [
|
||||
'js' => $assetRoot . '/' . $componentPath,
|
||||
'css' => [],
|
||||
|
@ -157,7 +157,6 @@ final class View extends Engine
|
|||
'auth' => $request->getAttribute(ServerRequest::ATTR_AUTH),
|
||||
'acl' => $request->getAttribute(ServerRequest::ATTR_ACL),
|
||||
'flash' => $request->getAttribute(ServerRequest::ATTR_SESSION_FLASH),
|
||||
'user' => $request->getAttribute(ServerRequest::ATTR_USER),
|
||||
];
|
||||
|
||||
$router = $request->getAttribute(ServerRequest::ATTR_ROUTER);
|
||||
|
@ -192,13 +191,33 @@ final class View extends Engine
|
|||
|
||||
// User profile-specific 24-hour display setting.
|
||||
$userObj = $request->getAttribute(ServerRequest::ATTR_USER);
|
||||
$show24Hours = ($userObj instanceof User)
|
||||
? $userObj->getShow24HourTime()
|
||||
: null;
|
||||
$requestData['user'] = $userObj;
|
||||
|
||||
$timeConfig = new stdClass();
|
||||
if (null !== $show24Hours) {
|
||||
$timeConfig->hour12 = !$show24Hours;
|
||||
|
||||
if ($userObj instanceof User) {
|
||||
$timeConfig->hour12 = !$userObj->getShow24HourTime();
|
||||
|
||||
$globalPermissions = [];
|
||||
$stationPermissions = [];
|
||||
|
||||
foreach ($userObj->getRoles() as $role) {
|
||||
foreach ($role->getPermissions() as $permission) {
|
||||
$station = $permission->getStation();
|
||||
if (null !== $station) {
|
||||
$stationPermissions[$station->getIdRequired()][] = $permission->getActionName();
|
||||
} else {
|
||||
$globalPermissions[] = $permission->getActionName();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->globalProps->set('user', [
|
||||
'id' => $userObj->getIdRequired(),
|
||||
'displayName' => $userObj->getDisplayName(),
|
||||
'globalPermissions' => $globalPermissions,
|
||||
'stationPermissions' => $stationPermissions,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->globalProps->set('timeConfig', $timeConfig);
|
||||
|
|
|
@ -1,94 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Functional;
|
||||
|
||||
use FunctionalTester;
|
||||
|
||||
class Admin_RecordsCest extends CestAbstract
|
||||
{
|
||||
/**
|
||||
* @before setupComplete
|
||||
* @before login
|
||||
*/
|
||||
public function manageUsers(FunctionalTester $I): void
|
||||
{
|
||||
$I->wantTo('Manage users.');
|
||||
|
||||
// User homepage
|
||||
$I->amOnPage('/admin/users');
|
||||
$I->seeResponseCodeIs(200);
|
||||
/*
|
||||
* TODO: Acceptance Testing with Vue Rendering
|
||||
|
||||
$I->see($this->login_username);
|
||||
|
||||
// Edit existing user
|
||||
$I->click('Edit');
|
||||
|
||||
$I->submitForm('.form', []);
|
||||
|
||||
$I->seeCurrentUrlEquals('/admin/users');
|
||||
$I->see($this->login_username);
|
||||
|
||||
// Add a secondary user
|
||||
$I->click('Add User', '#content');
|
||||
|
||||
$I->submitForm('.form', [
|
||||
'name' => 'ZZZ Test Administrator',
|
||||
'email' => 'test@azuracast.com',
|
||||
'auth_password' => 'CorrectBatteryStapleHorse',
|
||||
]);
|
||||
|
||||
$I->seeCurrentUrlEquals('/admin/users');
|
||||
$I->see('test@azuracast.com');
|
||||
|
||||
// Delete the secondary user
|
||||
$I->click(\Codeception\Util\Locator::lastElement('.btn-danger'));
|
||||
|
||||
$I->seeCurrentUrlEquals('/admin/users');
|
||||
$I->dontSee('test@azuracast.com');
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
* @before setupComplete
|
||||
* @before login
|
||||
*/
|
||||
public function manageStations(FunctionalTester $I): void
|
||||
{
|
||||
$I->wantTo('Manage stations.');
|
||||
|
||||
$I->amOnPage('/admin/stations');
|
||||
$I->seeResponseCodeIs(200);
|
||||
/*
|
||||
* TODO: Acceptance Testing with Vue Rendering
|
||||
|
||||
$I->see('Functional Test Radio');
|
||||
|
||||
|
||||
$I->click('Edit');
|
||||
|
||||
$I->submitForm('.form', [
|
||||
'name' => 'Modification Test Radio',
|
||||
]);
|
||||
|
||||
$I->seeCurrentUrlEquals('/admin/stations');
|
||||
$I->see('Modification Test Radio');
|
||||
*/
|
||||
}
|
||||
|
||||
/**
|
||||
* @before setupComplete
|
||||
* @before login
|
||||
*/
|
||||
public function manageSettings(FunctionalTester $I): void
|
||||
{
|
||||
$I->wantTo('Manage settings.');
|
||||
|
||||
$I->amOnPage('/admin/settings');
|
||||
$I->seeResponseCodeIs(200);
|
||||
$I->seeInTitle('System Settings');
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue