Update to Kirby 4.7.0
This commit is contained in:
parent
02a9ab387c
commit
ba25a9a198
509 changed files with 26604 additions and 14872 deletions
339
kirby/src/Panel/Assets.php
Normal file
339
kirby/src/Panel/Assets.php
Normal file
|
@ -0,0 +1,339 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\Url;
|
||||
use Kirby\Exception\Exception;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Filesystem\Asset;
|
||||
use Kirby\Filesystem\Dir;
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Toolkit\A;
|
||||
|
||||
/**
|
||||
* The Assets class collects all js, css, icons and other
|
||||
* files for the Panel. It pushes them into the media folder
|
||||
* on demand and also makes sure to create proper asset URLs
|
||||
* depending on dev mode
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 4.0.0
|
||||
*/
|
||||
class Assets
|
||||
{
|
||||
protected bool $dev;
|
||||
protected App $kirby;
|
||||
protected string $nonce;
|
||||
protected Plugins $plugins;
|
||||
protected string $url;
|
||||
protected bool $vite;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->kirby = App::instance();
|
||||
$this->nonce = $this->kirby->nonce();
|
||||
$this->plugins = new Plugins();
|
||||
|
||||
$vite = $this->kirby->roots()->panel() . '/.vite-running';
|
||||
$this->vite = is_file($vite) === true;
|
||||
|
||||
// get the assets from the Vite dev server in dev mode;
|
||||
// dev mode = explicitly enabled in the config AND Vite is running
|
||||
$dev = $this->kirby->option('panel.dev', false);
|
||||
$this->dev = $dev !== false && $this->vite === true;
|
||||
|
||||
// get the base URL
|
||||
$this->url = $this->url();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all CSS files
|
||||
*/
|
||||
public function css(): array
|
||||
{
|
||||
$css = [
|
||||
'index' => $this->url . '/css/style.min.css',
|
||||
'plugins' => $this->plugins->url('css'),
|
||||
...$this->custom('panel.css')
|
||||
];
|
||||
|
||||
// during dev mode we do not need to load
|
||||
// the general stylesheet (as styling will be inlined)
|
||||
if ($this->dev === true) {
|
||||
$css['index'] = null;
|
||||
}
|
||||
|
||||
return array_filter($css);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for a custom asset file from the
|
||||
* config (e.g. panel.css or panel.js)
|
||||
*/
|
||||
public function custom(string $option): array
|
||||
{
|
||||
$customs = [];
|
||||
|
||||
if ($assets = $this->kirby->option($option)) {
|
||||
$assets = A::wrap($assets);
|
||||
|
||||
foreach ($assets as $index => $path) {
|
||||
if (Url::isAbsolute($path) === true) {
|
||||
$customs['custom-' . $index] = $path;
|
||||
continue;
|
||||
}
|
||||
|
||||
$asset = new Asset($path);
|
||||
|
||||
if ($asset->exists() === true) {
|
||||
$customs['custom-' . $index] = $asset->url() . '?' . $asset->modified();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $customs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an array with all assets
|
||||
* that need to be loaded for the panel (js, css, icons)
|
||||
*/
|
||||
public function external(): array
|
||||
{
|
||||
return [
|
||||
'css' => $this->css(),
|
||||
'icons' => $this->favicons(),
|
||||
// loader for plugins' index.dev.mjs files – inlined,
|
||||
// so we provide the code instead of the asset URL
|
||||
'plugin-imports' => $this->plugins->read('mjs'),
|
||||
'js' => $this->js()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns array of favicon icons
|
||||
* based on config option
|
||||
*
|
||||
* @todo Deprecate `url` option in v5, use `href` option instead
|
||||
* @todo Deprecate `rel` usage as array key in v5, use `rel` option instead
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException
|
||||
*/
|
||||
public function favicons(): array
|
||||
{
|
||||
$icons = $this->kirby->option('panel.favicon', [
|
||||
[
|
||||
'rel' => 'apple-touch-icon',
|
||||
'type' => 'image/png',
|
||||
'href' => $this->url . '/apple-touch-icon.png'
|
||||
],
|
||||
[
|
||||
'rel' => 'alternate icon',
|
||||
'type' => 'image/png',
|
||||
'href' => $this->url . '/favicon.png'
|
||||
],
|
||||
[
|
||||
'rel' => 'shortcut icon',
|
||||
'type' => 'image/svg+xml',
|
||||
'href' => $this->url . '/favicon.svg'
|
||||
],
|
||||
[
|
||||
'rel' => 'apple-touch-icon',
|
||||
'type' => 'image/png',
|
||||
'href' => $this->url . '/apple-touch-icon-dark.png',
|
||||
'media' => '(prefers-color-scheme: dark)'
|
||||
],
|
||||
[
|
||||
'rel' => 'alternate icon',
|
||||
'type' => 'image/png',
|
||||
'href' => $this->url . '/favicon-dark.png',
|
||||
'media' => '(prefers-color-scheme: dark)'
|
||||
]
|
||||
]);
|
||||
|
||||
if (is_array($icons) === true) {
|
||||
// normalize options
|
||||
foreach ($icons as $rel => &$icon) {
|
||||
// TODO: remove this backward compatibility check in v6
|
||||
if (isset($icon['url']) === true) {
|
||||
$icon['href'] = $icon['url'];
|
||||
unset($icon['url']);
|
||||
}
|
||||
|
||||
// TODO: remove this backward compatibility check in v6
|
||||
if (is_string($rel) === true && isset($icon['rel']) === false) {
|
||||
$icon['rel'] = $rel;
|
||||
}
|
||||
|
||||
$icon['href'] = Url::to($icon['href']);
|
||||
$icon['nonce'] = $this->nonce;
|
||||
}
|
||||
|
||||
return array_values($icons);
|
||||
}
|
||||
|
||||
// make sure to convert favicon string to array
|
||||
if (is_string($icons) === true) {
|
||||
return [
|
||||
[
|
||||
'rel' => 'shortcut icon',
|
||||
'type' => F::mime($icons),
|
||||
'href' => Url::to($icons),
|
||||
'nonce' => $this->nonce
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('Invalid panel.favicon option');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the SVG icon sprite
|
||||
* This will be injected in the
|
||||
* initial HTML document for the Panel
|
||||
*/
|
||||
public function icons(): string
|
||||
{
|
||||
$dir = $this->kirby->root('panel') . '/';
|
||||
$dir .= $this->dev ? 'public' : 'dist';
|
||||
$icons = F::read($dir . '/img/icons.svg');
|
||||
$icons = preg_replace('/<!--(.|\s)*?-->/', '', $icons);
|
||||
return $icons;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all js files
|
||||
*/
|
||||
public function js(): array
|
||||
{
|
||||
$js = [
|
||||
'vue' => [
|
||||
'nonce' => $this->nonce,
|
||||
'src' => $this->vue(),
|
||||
],
|
||||
'vendor' => [
|
||||
'nonce' => $this->nonce,
|
||||
'src' => $this->url . '/js/vendor.min.js',
|
||||
'type' => 'module'
|
||||
],
|
||||
'pluginloader' => [
|
||||
'nonce' => $this->nonce,
|
||||
'src' => $this->url . '/js/plugins.js',
|
||||
'type' => 'module'
|
||||
],
|
||||
'plugins' => [
|
||||
'nonce' => $this->nonce,
|
||||
'src' => $this->plugins->url('js'),
|
||||
'defer' => true
|
||||
],
|
||||
...A::map($this->custom('panel.js'), fn ($src) => [
|
||||
'nonce' => $this->nonce,
|
||||
'src' => $src,
|
||||
'type' => 'module'
|
||||
]),
|
||||
'index' => [
|
||||
'nonce' => $this->nonce,
|
||||
'src' => $this->url . '/js/index.min.js',
|
||||
'type' => 'module'
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
// during dev mode, add vite client and adapt
|
||||
// path to `index.js` - vendor does not need
|
||||
// to be loaded in dev mode
|
||||
if ($this->dev === true) {
|
||||
// load the non-minified index.js, remove vendor script and
|
||||
// development version of Vue
|
||||
$js['vendor']['src'] = null;
|
||||
$js['index']['src'] = $this->url . '/src/index.js';
|
||||
$js['vue']['src'] = $this->vue(production: false);
|
||||
|
||||
// add vite dev client
|
||||
$js['vite'] = [
|
||||
'nonce' => $this->nonce,
|
||||
'src' => $this->url . '/@vite/client',
|
||||
'type' => 'module'
|
||||
];
|
||||
}
|
||||
|
||||
return array_filter($js, fn ($js) => empty($js['src']) === false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Links all dist files in the media folder
|
||||
* and returns the link to the requested asset
|
||||
*
|
||||
* @throws \Kirby\Exception\Exception If Panel assets could not be moved to the public directory
|
||||
*/
|
||||
public function link(): bool
|
||||
{
|
||||
$mediaRoot = $this->kirby->root('media') . '/panel';
|
||||
$panelRoot = $this->kirby->root('panel') . '/dist';
|
||||
$versionHash = $this->kirby->versionHash();
|
||||
$versionRoot = $mediaRoot . '/' . $versionHash;
|
||||
|
||||
// check if the version already exists
|
||||
if (is_dir($versionRoot) === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// delete the panel folder and all previous versions
|
||||
Dir::remove($mediaRoot);
|
||||
|
||||
// recreate the panel folder
|
||||
Dir::make($mediaRoot, true);
|
||||
|
||||
// copy assets to the dist folder
|
||||
if (Dir::copy($panelRoot, $versionRoot) !== true) {
|
||||
throw new Exception('Panel assets could not be linked');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base URL for all assets depending on dev mode
|
||||
*/
|
||||
public function url(): string
|
||||
{
|
||||
// vite is not running, use production assets
|
||||
if ($this->dev === false) {
|
||||
return $this->kirby->url('media') . '/panel/' . $this->kirby->versionHash();
|
||||
}
|
||||
|
||||
// explicitly configured base URL
|
||||
$dev = $this->kirby->option('panel.dev');
|
||||
if (is_string($dev) === true) {
|
||||
return $dev;
|
||||
}
|
||||
|
||||
// port 3000 of the current Kirby request
|
||||
return rtrim($this->kirby->request()->url([
|
||||
'port' => 3000,
|
||||
'path' => null,
|
||||
'params' => null,
|
||||
'query' => null
|
||||
])->toString(), '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the correct Vue script URL depending on dev mode
|
||||
* and the enabled/disabled template compiler
|
||||
*/
|
||||
public function vue(bool $production = true): string
|
||||
{
|
||||
$script = $this->kirby->option('panel.vue.compiler', true) === true ? 'vue' : 'vue.runtime';
|
||||
|
||||
if ($production === false) {
|
||||
return $this->url . '/node_modules/vue/dist/' . $script . '.js';
|
||||
}
|
||||
|
||||
return $this->url . '/js/' . $script . '.min.js';
|
||||
}
|
||||
}
|
71
kirby/src/Panel/ChangesDialog.php
Normal file
71
kirby/src/Panel/ChangesDialog.php
Normal file
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\Find;
|
||||
use Kirby\Http\Uri;
|
||||
use Kirby\Toolkit\Escape;
|
||||
use Throwable;
|
||||
|
||||
class ChangesDialog
|
||||
{
|
||||
public function changes(array $ids = []): array
|
||||
{
|
||||
$kirby = App::instance();
|
||||
$multilang = $kirby->multilang();
|
||||
$changes = [];
|
||||
|
||||
foreach ($ids as $id) {
|
||||
try {
|
||||
// parse the given ID to extract
|
||||
// the path and an optional query
|
||||
$uri = new Uri($id);
|
||||
$path = $uri->path()->toString();
|
||||
$query = $uri->query();
|
||||
$model = Find::parent($path);
|
||||
$item = $model->panel()->dropdownOption();
|
||||
|
||||
// add the language to each option, if it is included in the query
|
||||
// of the given ID and the language actually exists
|
||||
if (
|
||||
$multilang &&
|
||||
$query->language &&
|
||||
$language = $kirby->language($query->language)
|
||||
) {
|
||||
$item['text'] .= ' (' . $language->code() . ')';
|
||||
$item['link'] .= '?language=' . $language->code();
|
||||
}
|
||||
|
||||
$item['text'] = Escape::html($item['text']);
|
||||
|
||||
$changes[] = $item;
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return $changes;
|
||||
}
|
||||
|
||||
public function load(): array
|
||||
{
|
||||
return $this->state();
|
||||
}
|
||||
|
||||
public function state(bool $loading = true, array $changes = [])
|
||||
{
|
||||
return [
|
||||
'component' => 'k-changes-dialog',
|
||||
'props' => [
|
||||
'changes' => $changes,
|
||||
'loading' => $loading
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function submit(array $ids): array
|
||||
{
|
||||
return $this->state(false, $this->changes($ids));
|
||||
}
|
||||
}
|
|
@ -7,7 +7,7 @@ use Kirby\Http\Response;
|
|||
/**
|
||||
* The Dialog response class handles Fiber
|
||||
* requests to render the JSON object for
|
||||
* Panel dialogs
|
||||
* Panel dialogs and creates the routes
|
||||
* @since 3.6.0
|
||||
*
|
||||
* @package Kirby Panel
|
||||
|
@ -34,4 +34,39 @@ class Dialog extends Json
|
|||
|
||||
return parent::response($data, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the routes for a dialog
|
||||
*/
|
||||
public static function routes(
|
||||
string $id,
|
||||
string $areaId,
|
||||
string $prefix = '',
|
||||
array $options = []
|
||||
) {
|
||||
$routes = [];
|
||||
|
||||
// create the full pattern with dialogs prefix
|
||||
$pattern = trim($prefix . '/' . ($options['pattern'] ?? $id), '/');
|
||||
$type = str_replace('$', '', static::$key);
|
||||
|
||||
// load event
|
||||
$routes[] = [
|
||||
'pattern' => $pattern,
|
||||
'type' => $type,
|
||||
'area' => $areaId,
|
||||
'action' => $options['load'] ?? fn () => 'The load handler is missing'
|
||||
];
|
||||
|
||||
// submit event
|
||||
$routes[] = [
|
||||
'pattern' => $pattern,
|
||||
'type' => $type,
|
||||
'area' => $areaId,
|
||||
'method' => 'POST',
|
||||
'action' => $options['submit'] ?? fn () => 'The submit handler is missing'
|
||||
];
|
||||
|
||||
return $routes;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,11 +3,6 @@
|
|||
namespace Kirby\Panel;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Exception\Exception;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Filesystem\Asset;
|
||||
use Kirby\Filesystem\Dir;
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Http\Response;
|
||||
use Kirby\Http\Uri;
|
||||
use Kirby\Toolkit\Tpl;
|
||||
|
@ -27,234 +22,18 @@ use Throwable;
|
|||
*/
|
||||
class Document
|
||||
{
|
||||
/**
|
||||
* Generates an array with all assets
|
||||
* that need to be loaded for the panel (js, css, icons)
|
||||
*/
|
||||
public static function assets(): array
|
||||
{
|
||||
$kirby = App::instance();
|
||||
$nonce = $kirby->nonce();
|
||||
|
||||
// get the assets from the Vite dev server in dev mode;
|
||||
// dev mode = explicitly enabled in the config AND Vite is running
|
||||
$dev = $kirby->option('panel.dev', false);
|
||||
$isDev = $dev !== false && is_file($kirby->roots()->panel() . '/.vite-running') === true;
|
||||
|
||||
if ($isDev === true) {
|
||||
// vite on explicitly configured base URL or port 3000
|
||||
// of the current Kirby request
|
||||
if (is_string($dev) === true) {
|
||||
$url = $dev;
|
||||
} else {
|
||||
$url = rtrim($kirby->request()->url([
|
||||
'port' => 3000,
|
||||
'path' => null,
|
||||
'params' => null,
|
||||
'query' => null
|
||||
])->toString(), '/');
|
||||
}
|
||||
} else {
|
||||
// vite is not running, use production assets
|
||||
$url = $kirby->url('media') . '/panel/' . $kirby->versionHash();
|
||||
}
|
||||
|
||||
// fetch all plugins
|
||||
$plugins = new Plugins();
|
||||
|
||||
$assets = [
|
||||
'css' => [
|
||||
'index' => $url . '/css/style.css',
|
||||
'plugins' => $plugins->url('css'),
|
||||
'custom' => static::customAsset('panel.css'),
|
||||
],
|
||||
'icons' => static::favicon($url),
|
||||
// loader for plugins' index.dev.mjs files –
|
||||
// inlined, so we provide the code instead of the asset URL
|
||||
'plugin-imports' => $plugins->read('mjs'),
|
||||
'js' => [
|
||||
'vue' => [
|
||||
'nonce' => $nonce,
|
||||
'src' => $url . '/js/vue.js'
|
||||
],
|
||||
'vendor' => [
|
||||
'nonce' => $nonce,
|
||||
'src' => $url . '/js/vendor.js',
|
||||
'type' => 'module'
|
||||
],
|
||||
'pluginloader' => [
|
||||
'nonce' => $nonce,
|
||||
'src' => $url . '/js/plugins.js',
|
||||
'type' => 'module'
|
||||
],
|
||||
'plugins' => [
|
||||
'nonce' => $nonce,
|
||||
'src' => $plugins->url('js'),
|
||||
'defer' => true
|
||||
],
|
||||
'custom' => [
|
||||
'nonce' => $nonce,
|
||||
'src' => static::customAsset('panel.js'),
|
||||
'type' => 'module'
|
||||
],
|
||||
'index' => [
|
||||
'nonce' => $nonce,
|
||||
'src' => $url . '/js/index.js',
|
||||
'type' => 'module'
|
||||
],
|
||||
]
|
||||
];
|
||||
|
||||
// during dev mode, add vite client and adapt
|
||||
// path to `index.js` - vendor and stylesheet
|
||||
// don't need to be loaded in dev mode
|
||||
if ($isDev === true) {
|
||||
$assets['js']['vite'] = [
|
||||
'nonce' => $nonce,
|
||||
'src' => $url . '/@vite/client',
|
||||
'type' => 'module'
|
||||
];
|
||||
$assets['js']['index'] = [
|
||||
'nonce' => $nonce,
|
||||
'src' => $url . '/src/index.js',
|
||||
'type' => 'module'
|
||||
];
|
||||
|
||||
// load the development version of Vue
|
||||
$assets['js']['vue']['src'] = $url . '/node_modules/vue/dist/vue.js';
|
||||
|
||||
unset($assets['css']['index'], $assets['js']['vendor']);
|
||||
}
|
||||
|
||||
// remove missing files
|
||||
$assets['css'] = array_filter($assets['css']);
|
||||
$assets['js'] = array_filter(
|
||||
$assets['js'],
|
||||
fn ($js) => empty($js['src']) === false
|
||||
);
|
||||
|
||||
return $assets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for a custom asset file from the
|
||||
* config (e.g. panel.css or panel.js)
|
||||
* @since 3.7.0
|
||||
*
|
||||
* @param string $option asset option name
|
||||
*/
|
||||
public static function customAsset(string $option): string|null
|
||||
{
|
||||
if ($path = App::instance()->option($option)) {
|
||||
$asset = new Asset($path);
|
||||
|
||||
if ($asset->exists() === true) {
|
||||
return $asset->url() . '?' . $asset->modified();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns array of favicon icons
|
||||
* based on config option
|
||||
* @since 3.7.0
|
||||
*
|
||||
* @param string $url URL prefix for default icons
|
||||
* @throws \Kirby\Exception\InvalidArgumentException
|
||||
*/
|
||||
public static function favicon(string $url = ''): array
|
||||
{
|
||||
$kirby = App::instance();
|
||||
$icons = $kirby->option('panel.favicon', [
|
||||
'apple-touch-icon' => [
|
||||
'type' => 'image/png',
|
||||
'url' => $url . '/apple-touch-icon.png',
|
||||
],
|
||||
'alternate icon' => [
|
||||
'type' => 'image/png',
|
||||
'url' => $url . '/favicon.png',
|
||||
],
|
||||
'shortcut icon' => [
|
||||
'type' => 'image/svg+xml',
|
||||
'url' => $url . '/favicon.svg',
|
||||
]
|
||||
]);
|
||||
|
||||
if (is_array($icons) === true) {
|
||||
return $icons;
|
||||
}
|
||||
|
||||
// make sure to convert favicon string to array
|
||||
if (is_string($icons) === true) {
|
||||
return [
|
||||
'shortcut icon' => [
|
||||
'type' => F::mime($icons),
|
||||
'url' => $icons,
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('Invalid panel.favicon option');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the SVG icon sprite
|
||||
* This will be injected in the
|
||||
* initial HTML document for the Panel
|
||||
*/
|
||||
public static function icons(): string
|
||||
{
|
||||
$dev = App::instance()->option('panel.dev', false);
|
||||
$dir = $dev ? 'public' : 'dist';
|
||||
return F::read(App::instance()->root('kirby') . '/panel/' . $dir . '/img/icons.svg');
|
||||
}
|
||||
|
||||
/**
|
||||
* Links all dist files in the media folder
|
||||
* and returns the link to the requested asset
|
||||
*
|
||||
* @throws \Kirby\Exception\Exception If Panel assets could not be moved to the public directory
|
||||
*/
|
||||
public static function link(): bool
|
||||
{
|
||||
$kirby = App::instance();
|
||||
$mediaRoot = $kirby->root('media') . '/panel';
|
||||
$panelRoot = $kirby->root('panel') . '/dist';
|
||||
$versionHash = $kirby->versionHash();
|
||||
$versionRoot = $mediaRoot . '/' . $versionHash;
|
||||
|
||||
// check if the version already exists
|
||||
if (is_dir($versionRoot) === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// delete the panel folder and all previous versions
|
||||
Dir::remove($mediaRoot);
|
||||
|
||||
// recreate the panel folder
|
||||
Dir::make($mediaRoot, true);
|
||||
|
||||
// copy assets to the dist folder
|
||||
if (Dir::copy($panelRoot, $versionRoot) !== true) {
|
||||
throw new Exception('Panel assets could not be linked');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the panel document
|
||||
*/
|
||||
public static function response(array $fiber): Response
|
||||
{
|
||||
$kirby = App::instance();
|
||||
$kirby = App::instance();
|
||||
$assets = new Assets();
|
||||
|
||||
// Full HTML response
|
||||
// @codeCoverageIgnoreStart
|
||||
try {
|
||||
if (static::link() === true) {
|
||||
if ($assets->link() === true) {
|
||||
usleep(1);
|
||||
Response::go($kirby->url('base') . '/' . $kirby->path());
|
||||
}
|
||||
|
@ -264,15 +43,15 @@ class Document
|
|||
// @codeCoverageIgnoreEnd
|
||||
|
||||
// get the uri object for the panel url
|
||||
$uri = new Uri($url = $kirby->url('panel'));
|
||||
$uri = new Uri($kirby->url('panel'));
|
||||
|
||||
// proper response code
|
||||
$code = $fiber['$view']['code'] ?? 200;
|
||||
|
||||
// load the main Panel view template
|
||||
$body = Tpl::load($kirby->root('kirby') . '/views/panel.php', [
|
||||
'assets' => static::assets(),
|
||||
'icons' => static::icons(),
|
||||
'assets' => $assets->external(),
|
||||
'icons' => $assets->icons(),
|
||||
'nonce' => $kirby->nonce(),
|
||||
'fiber' => $fiber,
|
||||
'panelUrl' => $uri->path()->toString(true) . '/',
|
||||
|
|
21
kirby/src/Panel/Drawer.php
Normal file
21
kirby/src/Panel/Drawer.php
Normal file
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel;
|
||||
|
||||
use Kirby\Http\Response;
|
||||
|
||||
/**
|
||||
* The Drawer response class handles Fiber
|
||||
* requests to render the JSON object for
|
||||
* Panel drawers
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Drawer extends Dialog
|
||||
{
|
||||
protected static string $key = '$drawer';
|
||||
}
|
|
@ -2,13 +2,8 @@
|
|||
|
||||
namespace Kirby\Panel;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\Find;
|
||||
use Kirby\Exception\LogicException;
|
||||
use Closure;
|
||||
use Kirby\Http\Response;
|
||||
use Kirby\Http\Uri;
|
||||
use Kirby\Toolkit\Str;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* The Dropdown response class handles Fiber
|
||||
|
@ -26,49 +21,6 @@ class Dropdown extends Json
|
|||
{
|
||||
protected static string $key = '$dropdown';
|
||||
|
||||
/**
|
||||
* Returns the options for the changes dropdown
|
||||
*/
|
||||
public static function changes(): array
|
||||
{
|
||||
$kirby = App::instance();
|
||||
$multilang = $kirby->multilang();
|
||||
$ids = Str::split($kirby->request()->get('ids'));
|
||||
$options = [];
|
||||
|
||||
foreach ($ids as $id) {
|
||||
try {
|
||||
// parse the given ID to extract
|
||||
// the path and an optional query
|
||||
$uri = new Uri($id);
|
||||
$path = $uri->path()->toString();
|
||||
$query = $uri->query();
|
||||
$option = Find::parent($path)->panel()->dropdownOption();
|
||||
|
||||
// add the language to each option, if it is included in the query
|
||||
// of the given ID and the language actually exists
|
||||
if ($multilang && $query->language && $language = $kirby->language($query->language)) {
|
||||
$option['text'] .= ' (' . $language->code() . ')';
|
||||
$option['link'] .= '?language=' . $language->code();
|
||||
}
|
||||
|
||||
$options[] = $option;
|
||||
} catch (Throwable) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// the given set of ids does not match any
|
||||
// real models. This means that the stored ids
|
||||
// in local storage are not correct and the changes
|
||||
// store needs to be cleared
|
||||
if (empty($options) === true) {
|
||||
throw new LogicException('No changes for given models');
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders dropdowns
|
||||
*/
|
||||
|
@ -82,4 +34,38 @@ class Dropdown extends Json
|
|||
|
||||
return parent::response($data, $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes for the dropdown
|
||||
*/
|
||||
public static function routes(
|
||||
string $id,
|
||||
string $areaId,
|
||||
string $prefix = '',
|
||||
Closure|array $options = []
|
||||
): array {
|
||||
// Handle shortcuts for dropdowns. The name is the pattern
|
||||
// and options are defined in a Closure
|
||||
if ($options instanceof Closure) {
|
||||
$options = [
|
||||
'pattern' => $id,
|
||||
'action' => $options
|
||||
];
|
||||
}
|
||||
|
||||
// create the full pattern with dialogs prefix
|
||||
$pattern = trim($prefix . '/' . ($options['pattern'] ?? $id), '/');
|
||||
$type = str_replace('$', '', static::$key);
|
||||
|
||||
return [
|
||||
// load event
|
||||
[
|
||||
'pattern' => $pattern,
|
||||
'type' => $type,
|
||||
'area' => $areaId,
|
||||
'method' => 'GET|POST',
|
||||
'action' => $options['options'] ?? $options['action']
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,8 +4,13 @@ namespace Kirby\Panel;
|
|||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\File;
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Cms\Roles;
|
||||
use Kirby\Form\Form;
|
||||
use Kirby\Http\Router;
|
||||
use Kirby\Toolkit\I18n;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* Provides common field prop definitions
|
||||
|
@ -20,6 +25,58 @@ use Kirby\Toolkit\I18n;
|
|||
*/
|
||||
class Field
|
||||
{
|
||||
/**
|
||||
* Creates the routes for a field dialog
|
||||
* This is most definitely not a good place for this
|
||||
* method, but as long as the other classes are
|
||||
* not fully refactored, it still feels appropriate
|
||||
*/
|
||||
public static function dialog(
|
||||
ModelWithContent $model,
|
||||
string $fieldName,
|
||||
string|null $path = null,
|
||||
string $method = 'GET',
|
||||
) {
|
||||
$field = Form::for($model)->field($fieldName);
|
||||
$routes = [];
|
||||
|
||||
foreach ($field->dialogs() as $dialogId => $dialog) {
|
||||
$routes = array_merge($routes, Dialog::routes(
|
||||
id: $dialogId,
|
||||
areaId: 'site',
|
||||
options: $dialog
|
||||
));
|
||||
}
|
||||
|
||||
return Router::execute($path, $method, $routes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the routes for a field drawer
|
||||
* This is most definitely not a good place for this
|
||||
* method, but as long as the other classes are
|
||||
* not fully refactored, it still feels appropriate
|
||||
*/
|
||||
public static function drawer(
|
||||
ModelWithContent $model,
|
||||
string $fieldName,
|
||||
string|null $path = null,
|
||||
string $method = 'GET',
|
||||
) {
|
||||
$field = Form::for($model)->field($fieldName);
|
||||
$routes = [];
|
||||
|
||||
foreach ($field->drawers() as $drawerId => $drawer) {
|
||||
$routes = array_merge($routes, Drawer::routes(
|
||||
id: $drawerId,
|
||||
areaId: 'site',
|
||||
options: $drawer
|
||||
));
|
||||
}
|
||||
|
||||
return Router::execute($path, $method, $routes);
|
||||
}
|
||||
|
||||
/**
|
||||
* A standard email field
|
||||
*/
|
||||
|
@ -73,7 +130,7 @@ class Field
|
|||
|
||||
public static function hidden(): array
|
||||
{
|
||||
return ['type' => 'hidden'];
|
||||
return ['hidden' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -135,30 +192,33 @@ class Field
|
|||
/**
|
||||
* User role radio buttons
|
||||
*/
|
||||
public static function role(array $props = []): array
|
||||
{
|
||||
$kirby = App::instance();
|
||||
$user = $kirby->user();
|
||||
$isAdmin = $user && $user->isAdmin();
|
||||
$roles = [];
|
||||
public static function role(
|
||||
array $props = [],
|
||||
Roles|null $roles = null
|
||||
): array {
|
||||
$kirby = App::instance();
|
||||
|
||||
foreach ($kirby->roles() as $role) {
|
||||
// exclude the admin role, if the user
|
||||
// is not allowed to change role to admin
|
||||
if ($role->name() === 'admin' && $isAdmin === false) {
|
||||
continue;
|
||||
}
|
||||
// if no $roles where provided, fall back to all roles
|
||||
$roles ??= $kirby->roles();
|
||||
|
||||
$roles[] = [
|
||||
'text' => $role->title(),
|
||||
'info' => $role->description() ?? I18n::translate('role.description.placeholder'),
|
||||
'value' => $role->name()
|
||||
];
|
||||
}
|
||||
// exclude the admin role, if the user
|
||||
// is not allowed to change role to admin
|
||||
$roles = $roles->filter(
|
||||
fn ($role) =>
|
||||
$role->name() !== 'admin' ||
|
||||
$kirby->user()?->isAdmin() === true
|
||||
);
|
||||
|
||||
// turn roles into radio field options
|
||||
$roles = $roles->values(fn ($role) => [
|
||||
'text' => $role->title(),
|
||||
'info' => $role->description() ?? I18n::translate('role.description.placeholder'),
|
||||
'value' => $role->name()
|
||||
]);
|
||||
|
||||
return array_merge([
|
||||
'label' => I18n::translate('role'),
|
||||
'type' => count($roles) <= 1 ? 'hidden' : 'radio',
|
||||
'type' => count($roles) < 1 ? 'hidden' : 'radio',
|
||||
'options' => $roles
|
||||
], $props);
|
||||
}
|
||||
|
@ -168,11 +228,14 @@ class Field
|
|||
return array_merge([
|
||||
'label' => I18n::translate('slug'),
|
||||
'type' => 'slug',
|
||||
'allow' => Str::$defaults['slug']['allowed']
|
||||
], $props);
|
||||
}
|
||||
|
||||
public static function template(array|null $blueprints = [], array|null $props = []): array
|
||||
{
|
||||
public static function template(
|
||||
array|null $blueprints = [],
|
||||
array|null $props = []
|
||||
): array {
|
||||
$options = [];
|
||||
|
||||
foreach ($blueprints as $blueprint) {
|
||||
|
@ -217,7 +280,7 @@ class Field
|
|||
return array_merge([
|
||||
'label' => I18n::translate('language'),
|
||||
'type' => 'select',
|
||||
'icon' => 'globe',
|
||||
'icon' => 'translate',
|
||||
'options' => $translations,
|
||||
'empty' => false
|
||||
], $props);
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
namespace Kirby\Panel;
|
||||
|
||||
use Kirby\Cms\File as CmsFile;
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
use Kirby\Filesystem\Asset;
|
||||
use Kirby\Toolkit\I18n;
|
||||
use Throwable;
|
||||
|
@ -19,6 +20,11 @@ use Throwable;
|
|||
*/
|
||||
class File extends Model
|
||||
{
|
||||
/**
|
||||
* @var \Kirby\Cms\File
|
||||
*/
|
||||
protected ModelWithContent $model;
|
||||
|
||||
/**
|
||||
* Breadcrumb array
|
||||
*/
|
||||
|
@ -41,10 +47,12 @@ class File extends Model
|
|||
break;
|
||||
case 'page':
|
||||
/** @var \Kirby\Cms\Page $parent */
|
||||
$breadcrumb = $this->model->parents()->flip()->values(fn ($parent) => [
|
||||
'label' => $parent->title()->toString(),
|
||||
'link' => $parent->panel()->url(true),
|
||||
]);
|
||||
$breadcrumb = $this->model->parents()->flip()->values(
|
||||
fn ($parent) => [
|
||||
'label' => $parent->title()->toString(),
|
||||
'link' => $parent->panel()->url(true),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// add the file
|
||||
|
@ -65,8 +73,10 @@ class File extends Model
|
|||
* @internal
|
||||
* @param string|null $type (`auto`|`kirbytext`|`markdown`)
|
||||
*/
|
||||
public function dragText(string|null $type = null, bool $absolute = false): string
|
||||
{
|
||||
public function dragText(
|
||||
string|null $type = null,
|
||||
bool $absolute = false
|
||||
): string {
|
||||
$type = $this->dragTextType($type);
|
||||
$url = $this->model->filename();
|
||||
$file = $this->model->type();
|
||||
|
@ -76,14 +86,17 @@ class File extends Model
|
|||
// for markdown notation or the UUID for Kirbytext (since
|
||||
// Kirbytags support can resolve UUIDs directly)
|
||||
if ($absolute === true) {
|
||||
$url = $type === 'markdown' ? $this->model->permalink() : $this->model->uuid();
|
||||
$url = match ($type) {
|
||||
'markdown' => $this->model->permalink(),
|
||||
default => $this->model->uuid()
|
||||
};
|
||||
|
||||
// if UUIDs are disabled, fall back to URL
|
||||
$url ??= $this->model->url();
|
||||
}
|
||||
|
||||
|
||||
if ($dragTextFromCallback = $this->dragTextFromCallback($type, $url)) {
|
||||
return $dragTextFromCallback;
|
||||
if ($callback = $this->dragTextFromCallback($type, $url)) {
|
||||
return $callback;
|
||||
}
|
||||
|
||||
if ($type === 'markdown') {
|
||||
|
@ -104,9 +117,9 @@ class File extends Model
|
|||
*/
|
||||
public function dropdown(array $options = []): array
|
||||
{
|
||||
$file = $this->model;
|
||||
|
||||
$defaults = $file->kirby()->request()->get(['view', 'update', 'delete']);
|
||||
$file = $this->model;
|
||||
$request = $file->kirby()->request();
|
||||
$defaults = $request->get(['view', 'update', 'delete']);
|
||||
$options = array_merge($defaults, $options);
|
||||
|
||||
$permissions = $this->options(['preview']);
|
||||
|
@ -131,15 +144,7 @@ class File extends Model
|
|||
'disabled' => $this->isDisabledDropdownOption('changeName', $options, $permissions)
|
||||
];
|
||||
|
||||
$result[] = [
|
||||
'click' => 'replace',
|
||||
'icon' => 'upload',
|
||||
'text' => I18n::translate('replace'),
|
||||
'disabled' => $this->isDisabledDropdownOption('replace', $options, $permissions)
|
||||
];
|
||||
|
||||
if ($view === 'list') {
|
||||
$result[] = '-';
|
||||
$result[] = [
|
||||
'dialog' => $url . '/changeSort',
|
||||
'icon' => 'sort',
|
||||
|
@ -148,6 +153,22 @@ class File extends Model
|
|||
];
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'dialog' => $url . '/changeTemplate',
|
||||
'icon' => 'template',
|
||||
'text' => I18n::translate('file.changeTemplate'),
|
||||
'disabled' => $this->isDisabledDropdownOption('changeTemplate', $options, $permissions)
|
||||
];
|
||||
|
||||
$result[] = '-';
|
||||
|
||||
$result[] = [
|
||||
'click' => 'replace',
|
||||
'icon' => 'upload',
|
||||
'text' => I18n::translate('replace'),
|
||||
'disabled' => $this->isDisabledDropdownOption('replace', $options, $permissions)
|
||||
];
|
||||
|
||||
$result[] = '-';
|
||||
$result[] = [
|
||||
'dialog' => $url . '/delete',
|
||||
|
@ -178,22 +199,22 @@ class File extends Model
|
|||
protected function imageColor(): string
|
||||
{
|
||||
$types = [
|
||||
'image' => 'orange-400',
|
||||
'video' => 'yellow-400',
|
||||
'document' => 'red-400',
|
||||
'audio' => 'aqua-400',
|
||||
'code' => 'blue-400',
|
||||
'archive' => 'gray-500'
|
||||
'archive' => 'gray-500',
|
||||
'audio' => 'aqua-500',
|
||||
'code' => 'pink-500',
|
||||
'document' => 'red-500',
|
||||
'image' => 'orange-500',
|
||||
'video' => 'yellow-500',
|
||||
];
|
||||
|
||||
$extensions = [
|
||||
'indd' => 'purple-400',
|
||||
'xls' => 'green-400',
|
||||
'xlsx' => 'green-400',
|
||||
'csv' => 'green-400',
|
||||
'docx' => 'blue-400',
|
||||
'doc' => 'blue-400',
|
||||
'rtf' => 'blue-400'
|
||||
'csv' => 'green-500',
|
||||
'doc' => 'blue-500',
|
||||
'docx' => 'blue-500',
|
||||
'indd' => 'purple-500',
|
||||
'rtf' => 'blue-500',
|
||||
'xls' => 'green-500',
|
||||
'xlsx' => 'green-500',
|
||||
];
|
||||
|
||||
return
|
||||
|
@ -219,23 +240,23 @@ class File extends Model
|
|||
protected function imageIcon(): string
|
||||
{
|
||||
$types = [
|
||||
'image' => 'image',
|
||||
'video' => 'video',
|
||||
'document' => 'document',
|
||||
'archive' => 'archive',
|
||||
'audio' => 'audio',
|
||||
'code' => 'code',
|
||||
'archive' => 'archive'
|
||||
'document' => 'document',
|
||||
'image' => 'image',
|
||||
'video' => 'video',
|
||||
];
|
||||
|
||||
$extensions = [
|
||||
'csv' => 'table',
|
||||
'doc' => 'pen',
|
||||
'docx' => 'pen',
|
||||
'md' => 'markdown',
|
||||
'mdown' => 'markdown',
|
||||
'rtf' => 'pen',
|
||||
'xls' => 'table',
|
||||
'xlsx' => 'table',
|
||||
'csv' => 'table',
|
||||
'docx' => 'pen',
|
||||
'doc' => 'pen',
|
||||
'rtf' => 'pen',
|
||||
'mdown' => 'markdown',
|
||||
'md' => 'markdown'
|
||||
];
|
||||
|
||||
return
|
||||
|
@ -258,6 +279,40 @@ class File extends Model
|
|||
return parent::imageSource($query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether focus can be added in Panel view
|
||||
*/
|
||||
public function isFocusable(): bool
|
||||
{
|
||||
// blueprint option
|
||||
$option = $this->model->blueprint()->focus();
|
||||
// fallback to whether the file is viewable
|
||||
// (images should be focusable by default, others not)
|
||||
$option ??= $this->model->isViewable();
|
||||
|
||||
if ($option === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// ensure that user can update content file
|
||||
if ($this->options()['update'] === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$kirby = $this->model->kirby();
|
||||
|
||||
// ensure focus is only added when editing primary/only language
|
||||
if (
|
||||
$kirby->multilang() === false ||
|
||||
$kirby->languages()->count() === 0 ||
|
||||
$kirby->language()->isDefault() === true
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of all actions
|
||||
* that can be performed in the Panel
|
||||
|
@ -298,10 +353,14 @@ class File extends Model
|
|||
|
||||
if (empty($params['model']) === false) {
|
||||
$parent = $this->model->parent();
|
||||
$absolute = $parent !== $params['model'];
|
||||
|
||||
// if the file belongs to the current parent model,
|
||||
// store only name as ID to keep its path relative to the model
|
||||
$id = $parent === $params['model'] ? $name : $id;
|
||||
$absolute = $parent !== $params['model'];
|
||||
$id = match ($absolute) {
|
||||
true => $id,
|
||||
false => $name
|
||||
};
|
||||
}
|
||||
|
||||
$params['text'] ??= '{{ file.filename }}';
|
||||
|
@ -324,13 +383,6 @@ class File extends Model
|
|||
{
|
||||
$file = $this->model;
|
||||
$dimensions = $file->dimensions();
|
||||
$siblings = $file->templateSiblings()->sortBy(
|
||||
'sort',
|
||||
'asc',
|
||||
'filename',
|
||||
'asc'
|
||||
);
|
||||
|
||||
|
||||
return array_merge(
|
||||
parent::props(),
|
||||
|
@ -350,14 +402,16 @@ class File extends Model
|
|||
'template' => $file->template(),
|
||||
'type' => $file->type(),
|
||||
'url' => $file->url(),
|
||||
'uuid' => fn () => $file->uuid()?->toString(),
|
||||
],
|
||||
'preview' => [
|
||||
'image' => $this->image([
|
||||
'focusable' => $this->isFocusable(),
|
||||
'image' => $this->image([
|
||||
'back' => 'transparent',
|
||||
'ratio' => '1/1'
|
||||
], 'cards'),
|
||||
'url' => $url = $file->previewUrl(),
|
||||
'details' => [
|
||||
'url' => $url = $file->previewUrl(),
|
||||
'details' => [
|
||||
[
|
||||
'title' => I18n::translate('template'),
|
||||
'text' => $file->template() ?? '—'
|
||||
|
|
|
@ -50,7 +50,8 @@ class Home
|
|||
|
||||
// needed to create a proper menu
|
||||
$areas = Panel::areas();
|
||||
$menu = View::menu($areas, $permissions->toArray());
|
||||
$menu = new Menu($areas, $permissions->toArray());
|
||||
$menu = $menu->entries();
|
||||
|
||||
// go through the menu and search for the first
|
||||
// available view we can go to
|
||||
|
@ -65,11 +66,16 @@ class Home
|
|||
continue;
|
||||
}
|
||||
|
||||
// skip the logout button
|
||||
if ($menuItem['id'] === 'logout') {
|
||||
// skip buttons that don't open a link
|
||||
// (but e.g. a dialog)
|
||||
if (isset($menuItem['link']) === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// skip the logout button
|
||||
if ($menuItem['link'] === 'logout') {
|
||||
continue;
|
||||
}
|
||||
|
||||
return Panel::url($menuItem['link']);
|
||||
}
|
||||
|
@ -100,9 +106,10 @@ class Home
|
|||
// create a dummy router to check if we can access this route at all
|
||||
try {
|
||||
return Router::execute($path, 'GET', $routes, function ($route) use ($user) {
|
||||
$auth = $route->attributes()['auth'] ?? true;
|
||||
$areaId = $route->attributes()['area'] ?? null;
|
||||
$type = $route->attributes()['type'] ?? 'view';
|
||||
$attrs = $route->attributes();
|
||||
$auth = $attrs['auth'] ?? true;
|
||||
$areaId = $attrs['area'] ?? null;
|
||||
$type = $attrs['type'] ?? 'view';
|
||||
|
||||
// only allow redirects to views
|
||||
if ($type !== 'view') {
|
||||
|
@ -131,7 +138,8 @@ class Home
|
|||
public static function hasValidDomain(Uri $uri): bool
|
||||
{
|
||||
$rootUrl = App::instance()->site()->url();
|
||||
return $uri->domain() === (new Uri($rootUrl))->domain();
|
||||
$rootUri = new Uri($rootUrl);
|
||||
return $uri->domain() === $rootUri->domain();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -139,7 +147,8 @@ class Home
|
|||
*/
|
||||
public static function isPanelUrl(string $url): bool
|
||||
{
|
||||
return Str::startsWith($url, App::instance()->url('panel'));
|
||||
$panel = App::instance()->url('panel');
|
||||
return Str::startsWith($url, $panel);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -161,10 +170,12 @@ class Home
|
|||
public static function remembered(): string|null
|
||||
{
|
||||
// check for a stored path after login
|
||||
$remembered = App::instance()->session()->pull('panel.path');
|
||||
if ($remembered = App::instance()->session()->pull('panel.path')) {
|
||||
// convert the result to an absolute URL if available
|
||||
return Panel::url($remembered);
|
||||
}
|
||||
|
||||
// convert the result to an absolute URL if available
|
||||
return $remembered ? Panel::url($remembered) : null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace Kirby\Panel;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Exception\Exception;
|
||||
use Kirby\Http\Response;
|
||||
use Throwable;
|
||||
|
@ -39,35 +40,45 @@ abstract class Json
|
|||
*/
|
||||
public static function response($data, array $options = []): Response
|
||||
{
|
||||
// handle redirects
|
||||
if ($data instanceof Redirect) {
|
||||
$data = [
|
||||
'redirect' => $data->location(),
|
||||
'code' => $data->code()
|
||||
];
|
||||
|
||||
// handle Kirby exceptions
|
||||
} elseif ($data instanceof Exception) {
|
||||
$data = static::error($data->getMessage(), $data->getHttpCode());
|
||||
|
||||
// handle exceptions
|
||||
} elseif ($data instanceof Throwable) {
|
||||
$data = static::error($data->getMessage(), 500);
|
||||
|
||||
// only expect arrays from here on
|
||||
} elseif (is_array($data) === false) {
|
||||
$data = static::error('Invalid response', 500);
|
||||
}
|
||||
|
||||
if (empty($data) === true) {
|
||||
$data = static::error('The response is empty', 404);
|
||||
}
|
||||
$data = static::responseData($data);
|
||||
|
||||
// always inject the response code
|
||||
$data['code'] ??= 200;
|
||||
$data['path'] = $options['path'] ?? null;
|
||||
$data['query'] = App::instance()->request()->query()->toArray();
|
||||
$data['referrer'] = Panel::referrer();
|
||||
|
||||
return Panel::json([static::$key => $data], $data['code']);
|
||||
}
|
||||
|
||||
public static function responseData(mixed $data): array
|
||||
{
|
||||
// handle redirects
|
||||
if ($data instanceof Redirect) {
|
||||
return [
|
||||
'redirect' => $data->location(),
|
||||
];
|
||||
}
|
||||
|
||||
// handle Kirby exceptions
|
||||
if ($data instanceof Exception) {
|
||||
return static::error($data->getMessage(), $data->getHttpCode());
|
||||
}
|
||||
|
||||
// handle exceptions
|
||||
if ($data instanceof Throwable) {
|
||||
return static::error($data->getMessage(), 500);
|
||||
}
|
||||
|
||||
// only expect arrays from here on
|
||||
if (is_array($data) === false) {
|
||||
return static::error('Invalid response', 500);
|
||||
}
|
||||
|
||||
if (empty($data) === true) {
|
||||
return static::error('The response is empty', 404);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
|
134
kirby/src/Panel/Lab/Category.php
Normal file
134
kirby/src/Panel/Lab/Category.php
Normal file
|
@ -0,0 +1,134 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Lab;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Filesystem\Dir;
|
||||
use Kirby\Toolkit\A;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* Category of lab examples located in
|
||||
* `kirby/panel/lab` and `site/lab`.
|
||||
*
|
||||
* @internal
|
||||
* @since 4.0.0
|
||||
* @codeCoverageIgnore
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Category
|
||||
{
|
||||
protected string $root;
|
||||
|
||||
public function __construct(
|
||||
protected string $id,
|
||||
string|null $root = null,
|
||||
protected array $props = []
|
||||
) {
|
||||
$this->root = $root ?? static::base() . '/' . $this->id;
|
||||
|
||||
if (file_exists($this->root . '/index.php') === true) {
|
||||
$this->props = array_merge(
|
||||
require $this->root . '/index.php',
|
||||
$this->props
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static function all(): array
|
||||
{
|
||||
// all core lab examples from `kirby/panel/lab`
|
||||
$examples = A::map(
|
||||
Dir::inventory(static::base())['children'],
|
||||
fn ($props) => (new static($props['dirname']))->toArray()
|
||||
);
|
||||
|
||||
// all custom lab examples from `site/lab`
|
||||
$custom = static::factory('site')->toArray();
|
||||
|
||||
array_push($examples, $custom);
|
||||
|
||||
return $examples;
|
||||
}
|
||||
|
||||
public static function base(): string
|
||||
{
|
||||
return App::instance()->root('panel') . '/lab';
|
||||
}
|
||||
|
||||
public function example(string $id, string|null $tab = null): Example
|
||||
{
|
||||
return new Example(parent: $this, id: $id, tab: $tab);
|
||||
}
|
||||
|
||||
public function examples(): array
|
||||
{
|
||||
return A::map(
|
||||
Dir::inventory($this->root)['children'],
|
||||
fn ($props) => $this->example($props['dirname'])->toArray()
|
||||
);
|
||||
}
|
||||
|
||||
public static function factory(string $id)
|
||||
{
|
||||
return match ($id) {
|
||||
'site' => static::site(),
|
||||
default => new static($id)
|
||||
};
|
||||
}
|
||||
|
||||
public function icon(): string
|
||||
{
|
||||
return $this->props['icon'] ?? 'palette';
|
||||
}
|
||||
|
||||
public function id(): string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public static function installed(): bool
|
||||
{
|
||||
return Dir::exists(static::base()) === true;
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return $this->props['name'] ?? ucfirst($this->id);
|
||||
}
|
||||
|
||||
public function root(): string
|
||||
{
|
||||
return $this->root;
|
||||
}
|
||||
|
||||
public static function site(): static
|
||||
{
|
||||
return new static(
|
||||
'site',
|
||||
App::instance()->root('site') . '/lab',
|
||||
[
|
||||
'name' => 'Your examples',
|
||||
'icon' => 'live'
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->name(),
|
||||
'examples' => $this->examples(),
|
||||
'icon' => $this->icon(),
|
||||
'path' => Str::after(
|
||||
$this->root(),
|
||||
App::instance()->root('index')
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
340
kirby/src/Panel/Lab/Docs.php
Normal file
340
kirby/src/Panel/Lab/Docs.php
Normal file
|
@ -0,0 +1,340 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Lab;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Data\Data;
|
||||
use Kirby\Filesystem\Dir;
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Toolkit\A;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* Docs for a single Vue component
|
||||
*
|
||||
* @internal
|
||||
* @since 4.0.0
|
||||
* @codeCoverageIgnore
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Docs
|
||||
{
|
||||
protected array $json;
|
||||
protected App $kirby;
|
||||
|
||||
public function __construct(
|
||||
protected string $name
|
||||
) {
|
||||
$this->kirby = App::instance();
|
||||
$this->json = $this->read();
|
||||
}
|
||||
|
||||
public static function all(): array
|
||||
{
|
||||
$dist = static::root();
|
||||
$tmp = static::root(true);
|
||||
$files = Dir::inventory($dist)['files'];
|
||||
|
||||
if (Dir::exists($tmp) === true) {
|
||||
$files = [...Dir::inventory($tmp)['files'], ...$files];
|
||||
}
|
||||
|
||||
$docs = A::map(
|
||||
$files,
|
||||
function ($file) {
|
||||
$component = 'k-' . Str::camelToKebab(F::name($file['filename']));
|
||||
|
||||
return [
|
||||
'image' => [
|
||||
'icon' => 'book',
|
||||
'back' => 'white',
|
||||
],
|
||||
'text' => $component,
|
||||
'link' => '/lab/docs/' . $component,
|
||||
];
|
||||
}
|
||||
);
|
||||
|
||||
usort($docs, fn ($a, $b) => $a['text'] <=> $b['text']);
|
||||
|
||||
return array_values($docs);
|
||||
}
|
||||
|
||||
public function deprecated(): string|null
|
||||
{
|
||||
return $this->kt($this->json['tags']['deprecated'][0]['description'] ?? '');
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return $this->kt($this->json['description'] ?? '');
|
||||
}
|
||||
|
||||
public function docBlock(): string
|
||||
{
|
||||
return $this->kt($this->json['docsBlocks'][0] ?? '');
|
||||
}
|
||||
|
||||
public function events(): array
|
||||
{
|
||||
$events = A::map(
|
||||
$this->json['events'] ?? [],
|
||||
fn ($event) => [
|
||||
'name' => $event['name'],
|
||||
'description' => $this->kt($event['description'] ?? ''),
|
||||
'deprecated' => $this->kt($event['tags']['deprecated'][0]['description'] ?? ''),
|
||||
'since' => $event['tags']['since'][0]['description'] ?? null,
|
||||
'properties' => A::map(
|
||||
$event['properties'] ?? [],
|
||||
fn ($property) => [
|
||||
'name' => $property['name'],
|
||||
'type' => $property['type']['names'][0] ?? '',
|
||||
'description' => $this->kt($property['description'] ?? '', true),
|
||||
]
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
usort($events, fn ($a, $b) => $a['name'] <=> $b['name']);
|
||||
|
||||
return $events;
|
||||
}
|
||||
|
||||
public function examples(): array
|
||||
{
|
||||
if (empty($this->json['tags']['examples']) === false) {
|
||||
return $this->json['tags']['examples'];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public function file(string $context): string
|
||||
{
|
||||
$root = match ($context) {
|
||||
'dev' => $this->kirby->root('panel') . '/tmp',
|
||||
'dist' => $this->kirby->root('panel') . '/dist/ui',
|
||||
};
|
||||
|
||||
$name = Str::after($this->name, 'k-');
|
||||
$name = Str::kebabToCamel($name);
|
||||
return $root . '/' . $name . '.json';
|
||||
}
|
||||
|
||||
public function github(): string
|
||||
{
|
||||
return 'https://github.com/getkirby/kirby/tree/main/panel/' . $this->json['sourceFile'];
|
||||
}
|
||||
|
||||
public static function installed(): bool
|
||||
{
|
||||
return Dir::exists(static::root()) === true;
|
||||
}
|
||||
|
||||
protected function kt(string $text, bool $inline = false): string
|
||||
{
|
||||
return $this->kirby->kirbytext($text, [
|
||||
'markdown' => [
|
||||
'breaks' => false,
|
||||
'inline' => $inline,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
public function lab(): string|null
|
||||
{
|
||||
$root = $this->kirby->root('panel') . '/lab';
|
||||
|
||||
foreach (glob($root . '/{,*/,*/*/,*/*/*/}index.php', GLOB_BRACE) as $example) {
|
||||
$props = require $example;
|
||||
|
||||
if (($props['docs'] ?? null) === $this->name) {
|
||||
return Str::before(Str::after($example, $root), 'index.php');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function methods(): array
|
||||
{
|
||||
$methods = A::map(
|
||||
$this->json['methods'] ?? [],
|
||||
fn ($method) => [
|
||||
'name' => $method['name'],
|
||||
'description' => $this->kt($method['description'] ?? ''),
|
||||
'deprecated' => $this->kt($method['tags']['deprecated'][0]['description'] ?? ''),
|
||||
'since' => $method['tags']['since'][0]['description'] ?? null,
|
||||
'params' => A::map(
|
||||
$method['params'] ?? [],
|
||||
fn ($param) => [
|
||||
'name' => $param['name'],
|
||||
'type' => $param['type']['name'] ?? '',
|
||||
'description' => $this->kt($param['description'] ?? '', true),
|
||||
]
|
||||
),
|
||||
'returns' => $method['returns']['type']['name'] ?? null,
|
||||
]
|
||||
);
|
||||
|
||||
usort($methods, fn ($a, $b) => $a['name'] <=> $b['name']);
|
||||
|
||||
return $methods;
|
||||
}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function prop(string|int $key): array|null
|
||||
{
|
||||
$prop = $this->json['props'][$key];
|
||||
|
||||
// filter private props
|
||||
if (($prop['tags']['access'][0]['description'] ?? null) === 'private') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// filter unset props
|
||||
if (($type = $prop['type']['name'] ?? null) === 'null') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$default = $prop['defaultValue']['value'] ?? null;
|
||||
$deprecated = $this->kt($prop['tags']['deprecated'][0]['description'] ?? '');
|
||||
|
||||
return [
|
||||
'name' => Str::camelToKebab($prop['name']),
|
||||
'type' => $type,
|
||||
'description' => $this->kt($prop['description'] ?? ''),
|
||||
'default' => $this->propDefault($default, $type),
|
||||
'deprecated' => $deprecated,
|
||||
'example' => $prop['tags']['example'][0]['description'] ?? null,
|
||||
'required' => $prop['required'] ?? false,
|
||||
'since' => $prop['tags']['since'][0]['description'] ?? null,
|
||||
'value' => $prop['tags']['value'][0]['description'] ?? null,
|
||||
'values' => $prop['values'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
protected function propDefault(
|
||||
string|null $default,
|
||||
string|null $type
|
||||
): string|null {
|
||||
if ($default !== null) {
|
||||
// normalize longform function
|
||||
if (preg_match('/function\(\) {.*return (.*);.*}/si', $default, $matches) === 1) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
// normalize object shorthand function
|
||||
if (preg_match('/\(\) => \((.*)\)/si', $default, $matches) === 1) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
// normalize all other defaults from shorthand function
|
||||
if (preg_match('/\(\) => (.*)/si', $default, $matches) === 1) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
// if type is boolean primarily and no default
|
||||
// value has been set, add `false` as default
|
||||
// for clarity
|
||||
if (Str::startsWith($type, 'boolean')) {
|
||||
return 'false';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function props(): array
|
||||
{
|
||||
$props = A::map(
|
||||
array_keys($this->json['props'] ?? []),
|
||||
fn ($key) => $this->prop($key)
|
||||
);
|
||||
|
||||
// remove empty props
|
||||
$props = array_filter($props);
|
||||
|
||||
usort($props, fn ($a, $b) => $a['name'] <=> $b['name']);
|
||||
|
||||
// always return an array
|
||||
return array_values($props);
|
||||
}
|
||||
|
||||
protected function read(): array
|
||||
{
|
||||
$file = $this->file('dev');
|
||||
|
||||
if (file_exists($file) === false) {
|
||||
$file = $this->file('dist');
|
||||
}
|
||||
|
||||
return Data::read($file);
|
||||
}
|
||||
|
||||
public static function root(bool $tmp = false): string
|
||||
{
|
||||
return App::instance()->root('panel') . '/' . match ($tmp) {
|
||||
true => 'tmp',
|
||||
default => 'dist/ui',
|
||||
};
|
||||
}
|
||||
|
||||
public function since(): string|null
|
||||
{
|
||||
return $this->json['tags']['since'][0]['description'] ?? null;
|
||||
}
|
||||
|
||||
public function slots(): array
|
||||
{
|
||||
$slots = A::map(
|
||||
$this->json['slots'] ?? [],
|
||||
fn ($slot) => [
|
||||
'name' => $slot['name'],
|
||||
'description' => $this->kt($slot['description'] ?? ''),
|
||||
'deprecated' => $this->kt($slot['tags']['deprecated'][0]['description'] ?? ''),
|
||||
'since' => $slot['tags']['since'][0]['description'] ?? null,
|
||||
'bindings' => A::map(
|
||||
$slot['bindings'] ?? [],
|
||||
fn ($binding) => [
|
||||
'name' => $binding['name'],
|
||||
'type' => $binding['type']['name'] ?? '',
|
||||
'description' => $this->kt($binding['description'] ?? '', true),
|
||||
]
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
usort($slots, fn ($a, $b) => $a['name'] <=> $b['name']);
|
||||
|
||||
return $slots;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'component' => $this->name(),
|
||||
'deprecated' => $this->deprecated(),
|
||||
'description' => $this->description(),
|
||||
'docBlock' => $this->docBlock(),
|
||||
'events' => $this->events(),
|
||||
'examples' => $this->examples(),
|
||||
'github' => $this->github(),
|
||||
'methods' => $this->methods(),
|
||||
'props' => $this->props(),
|
||||
'since' => $this->since(),
|
||||
'slots' => $this->slots(),
|
||||
];
|
||||
}
|
||||
}
|
296
kirby/src/Panel/Lab/Example.php
Normal file
296
kirby/src/Panel/Lab/Example.php
Normal file
|
@ -0,0 +1,296 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Lab;
|
||||
|
||||
use Kirby\Exception\NotFoundException;
|
||||
use Kirby\Filesystem\Dir;
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Http\Response;
|
||||
|
||||
/**
|
||||
* One or multiple lab examples with one or multiple tabs
|
||||
*
|
||||
* @internal
|
||||
* @since 4.0.0
|
||||
* @codeCoverageIgnore
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Example
|
||||
{
|
||||
protected string $root;
|
||||
protected string|null $tab = null;
|
||||
protected array $tabs;
|
||||
|
||||
public function __construct(
|
||||
protected Category $parent,
|
||||
protected string $id,
|
||||
string|null $tab = null,
|
||||
) {
|
||||
$this->root = $this->parent->root() . '/' . $this->id;
|
||||
|
||||
if ($this->exists() === false) {
|
||||
throw new NotFoundException('The example could not be found');
|
||||
}
|
||||
|
||||
$this->tabs = $this->collectTabs();
|
||||
$this->tab = $this->collectTab($tab);
|
||||
}
|
||||
|
||||
public function collectTab(string|null $tab): string|null
|
||||
{
|
||||
if (empty($this->tabs) === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (array_key_exists($tab, $this->tabs) === true) {
|
||||
return $tab;
|
||||
}
|
||||
|
||||
return array_key_first($this->tabs);
|
||||
}
|
||||
|
||||
public function collectTabs(): array
|
||||
{
|
||||
$tabs = [];
|
||||
|
||||
foreach (Dir::inventory($this->root)['children'] as $child) {
|
||||
$tabs[$child['dirname']] = [
|
||||
'name' => $child['dirname'],
|
||||
'label' => $child['slug'],
|
||||
'link' => '/lab/' . $this->parent->id() . '/' . $this->id . '/' . $child['dirname']
|
||||
];
|
||||
}
|
||||
|
||||
return $tabs;
|
||||
}
|
||||
|
||||
public function exists(): bool
|
||||
{
|
||||
return is_dir($this->root) === true;
|
||||
}
|
||||
|
||||
public function file(string $filename): string
|
||||
{
|
||||
return $this->parent->root() . '/' . $this->path() . '/' . $filename;
|
||||
}
|
||||
|
||||
public function id(): string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function load(string $filename): array|null
|
||||
{
|
||||
if ($file = $this->file($filename)) {
|
||||
return F::load($file);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function module(): string
|
||||
{
|
||||
return $this->url() . '/index.vue';
|
||||
}
|
||||
|
||||
public function path(): string
|
||||
{
|
||||
return match ($this->tab) {
|
||||
null => $this->id,
|
||||
default => $this->id . '/' . $this->tab
|
||||
};
|
||||
}
|
||||
|
||||
public function props(): array
|
||||
{
|
||||
if ($this->tab !== null) {
|
||||
$props = $this->load('../index.php');
|
||||
}
|
||||
|
||||
return array_replace_recursive(
|
||||
$props ?? [],
|
||||
$this->load('index.php') ?? []
|
||||
);
|
||||
}
|
||||
|
||||
public function read(string $filename): string|null
|
||||
{
|
||||
$file = $this->file($filename);
|
||||
|
||||
if (is_file($file) === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return F::read($file);
|
||||
}
|
||||
|
||||
public function root(): string
|
||||
{
|
||||
return $this->root;
|
||||
}
|
||||
|
||||
public function serve(): Response
|
||||
{
|
||||
return new Response($this->vue()['script'], 'application/javascript');
|
||||
}
|
||||
|
||||
public function tab(): string|null
|
||||
{
|
||||
return $this->tab;
|
||||
}
|
||||
|
||||
public function tabs(): array
|
||||
{
|
||||
return $this->tabs;
|
||||
}
|
||||
|
||||
public function template(string $filename): string|null
|
||||
{
|
||||
$file = $this->file($filename);
|
||||
|
||||
if (is_file($file) === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $this->props();
|
||||
return (new Template($file))->render($data);
|
||||
}
|
||||
|
||||
public function title(): string
|
||||
{
|
||||
return basename($this->id);
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'image' => [
|
||||
'icon' => $this->parent->icon(),
|
||||
'back' => 'white',
|
||||
],
|
||||
'text' => $this->title(),
|
||||
'link' => $this->url()
|
||||
];
|
||||
}
|
||||
|
||||
public function url(): string
|
||||
{
|
||||
return '/lab/' . $this->parent->id() . '/' . $this->path();
|
||||
}
|
||||
|
||||
public function vue(): array
|
||||
{
|
||||
// read the index.vue file (or programmabel Vue PHP file)
|
||||
$file = $this->read('index.vue');
|
||||
$file ??= $this->template('index.vue.php');
|
||||
$file ??= '';
|
||||
|
||||
// extract parts
|
||||
$parts['script'] = $this->vueScript($file);
|
||||
$parts['template'] = $this->vueTemplate($file);
|
||||
$parts['examples'] = $this->vueExamples($parts['template'], $parts['script']);
|
||||
$parts['style'] = $this->vueStyle($file);
|
||||
|
||||
return $parts;
|
||||
}
|
||||
|
||||
public function vueExamples(string|null $template, string|null $script): array
|
||||
{
|
||||
$template ??= '';
|
||||
$examples = [];
|
||||
$scripts = [];
|
||||
|
||||
if (preg_match_all('!\/\*\* \@script: (.*?)\*\/(.*?)\/\*\* \@script-end \*\/!s', $script, $matches)) {
|
||||
foreach ($matches[1] as $key => $name) {
|
||||
$code = $matches[2][$key];
|
||||
$code = preg_replace('!const (.*?) \=!', 'default', $code);
|
||||
|
||||
$scripts[trim($name)] = $code;
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match_all('!<k-lab-example[\s|\n].*?label="(.*?)"(.*?)>(.*?)<\/k-lab-example>!s', $template, $matches)) {
|
||||
foreach ($matches[1] as $key => $name) {
|
||||
$tail = $matches[2][$key];
|
||||
$code = $matches[3][$key];
|
||||
|
||||
$scriptId = trim(preg_replace_callback(
|
||||
'!script="(.*?)"!',
|
||||
fn ($match) => trim($match[1]),
|
||||
$tail
|
||||
));
|
||||
|
||||
$scriptBlock = $scripts[$scriptId] ?? null;
|
||||
|
||||
if (empty($scriptBlock) === false) {
|
||||
$js = PHP_EOL . PHP_EOL;
|
||||
$js .= '<script>';
|
||||
$js .= $scriptBlock;
|
||||
$js .= '</script>';
|
||||
} else {
|
||||
$js = '';
|
||||
}
|
||||
|
||||
// only use the code between the @code and @code-end comments
|
||||
if (preg_match('$<!-- @code -->(.*?)<!-- @code-end -->$s', $code, $match)) {
|
||||
$code = $match[1];
|
||||
}
|
||||
|
||||
if (preg_match_all('/^(\t*)\S/m', $code, $indents)) {
|
||||
// get minimum indent
|
||||
$indents = array_map(fn ($i) => strlen($i), $indents[1]);
|
||||
$indents = min($indents);
|
||||
|
||||
if (empty($js) === false) {
|
||||
$indents--;
|
||||
}
|
||||
|
||||
// strip minimum indent from each line
|
||||
$code = preg_replace('/^\t{' . $indents . '}/m', '', $code);
|
||||
}
|
||||
|
||||
$code = trim($code);
|
||||
|
||||
if (empty($js) === false) {
|
||||
$code = '<template>' . PHP_EOL . "\t" . $code . PHP_EOL . '</template>';
|
||||
}
|
||||
|
||||
$examples[$name] = $code . $js;
|
||||
}
|
||||
}
|
||||
|
||||
return $examples;
|
||||
}
|
||||
|
||||
public function vueScript(string $file): string
|
||||
{
|
||||
if (preg_match('!<script>(.*)</script>!s', $file, $match)) {
|
||||
return trim($match[1]);
|
||||
}
|
||||
|
||||
return 'export default {}';
|
||||
}
|
||||
|
||||
public function vueStyle(string $file): string|null
|
||||
{
|
||||
if (preg_match('!<style>(.*)</style>!s', $file, $match)) {
|
||||
return trim($match[1]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function vueTemplate(string $file): string|null
|
||||
{
|
||||
if (preg_match('!<template>(.*)</template>!s', $file, $match)) {
|
||||
return preg_replace('!^\n!', '', $match[1]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
26
kirby/src/Panel/Lab/Snippet.php
Normal file
26
kirby/src/Panel/Lab/Snippet.php
Normal file
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Lab;
|
||||
|
||||
use Kirby\Template\Snippet as BaseSnippet;
|
||||
|
||||
/**
|
||||
* Custom snippet class for lab examples
|
||||
*
|
||||
* @internal
|
||||
* @since 4.0.0
|
||||
* @codeCoverageIgnore
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Snippet extends BaseSnippet
|
||||
{
|
||||
public static function root(): string
|
||||
{
|
||||
return __DIR__ . '/snippets';
|
||||
}
|
||||
}
|
34
kirby/src/Panel/Lab/Template.php
Normal file
34
kirby/src/Panel/Lab/Template.php
Normal file
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Lab;
|
||||
|
||||
use Kirby\Template\Template as BaseTemplate;
|
||||
|
||||
/**
|
||||
* Custom template class for lab examples
|
||||
*
|
||||
* @internal
|
||||
* @since 4.0.0
|
||||
* @codeCoverageIgnore
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Template extends BaseTemplate
|
||||
{
|
||||
public function __construct(
|
||||
public string $file
|
||||
) {
|
||||
parent::__construct(
|
||||
name: basename($this->file)
|
||||
);
|
||||
}
|
||||
|
||||
public function file(): string|null
|
||||
{
|
||||
return $this->file;
|
||||
}
|
||||
}
|
221
kirby/src/Panel/Menu.php
Normal file
221
kirby/src/Panel/Menu.php
Normal file
|
@ -0,0 +1,221 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel;
|
||||
|
||||
use Closure;
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Toolkit\I18n;
|
||||
|
||||
/**
|
||||
* The Menu class takes care of gathering
|
||||
* all menu entries for the Panel
|
||||
* @since 4.0.0
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Menu
|
||||
{
|
||||
public function __construct(
|
||||
protected array $areas = [],
|
||||
protected array $permissions = [],
|
||||
protected string|null $current = null
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all areas that are configured for the menu
|
||||
* @internal
|
||||
*/
|
||||
public function areas(): array
|
||||
{
|
||||
// get from config option which areas should be listed in the menu
|
||||
$kirby = App::instance();
|
||||
$areas = $kirby->option('panel.menu');
|
||||
|
||||
if ($areas instanceof Closure) {
|
||||
$areas = $areas($kirby);
|
||||
}
|
||||
|
||||
// if no config is defined…
|
||||
if ($areas === null) {
|
||||
// ensure that some defaults are on top in the right order
|
||||
$defaults = ['site', 'languages', 'users', 'system'];
|
||||
// add all other areas after that
|
||||
$additionals = array_diff(array_keys($this->areas), $defaults);
|
||||
$areas = array_merge($defaults, $additionals);
|
||||
}
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($areas as $id => $area) {
|
||||
// separator, keep as is in array
|
||||
if ($area === '-') {
|
||||
$result[] = '-';
|
||||
continue;
|
||||
}
|
||||
|
||||
// for a simple id, get global area definition
|
||||
if (is_numeric($id) === true) {
|
||||
$id = $area;
|
||||
$area = $this->areas[$id] ?? null;
|
||||
}
|
||||
|
||||
// did not receive custom entry definition in config,
|
||||
// but also is not a global area
|
||||
if ($area === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// merge area definition (e.g. from config)
|
||||
// with global area definition
|
||||
if (is_array($area) === true) {
|
||||
$area = array_merge(
|
||||
$this->areas[$id] ?? [],
|
||||
['menu' => true],
|
||||
$area
|
||||
);
|
||||
$area = Panel::area($id, $area);
|
||||
}
|
||||
|
||||
$result[] = $area;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms an area definition into a menu entry
|
||||
* @internal
|
||||
*/
|
||||
public function entry(array $area): array|false
|
||||
{
|
||||
// areas without access permissions get skipped entirely
|
||||
if ($this->hasPermission($area['id']) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// check menu setting from the area definition
|
||||
$menu = $area['menu'] ?? false;
|
||||
|
||||
// menu setting can be a callback
|
||||
// that returns true, false or 'disabled'
|
||||
if ($menu instanceof Closure) {
|
||||
$menu = $menu($this->areas, $this->permissions, $this->current);
|
||||
}
|
||||
|
||||
// false will remove the area/entry entirely
|
||||
//just like with disabled permissions
|
||||
if ($menu === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$menu = match ($menu) {
|
||||
'disabled' => ['disabled' => true],
|
||||
true => [],
|
||||
default => $menu
|
||||
};
|
||||
|
||||
$entry = array_merge([
|
||||
'current' => $this->isCurrent(
|
||||
$area['id'],
|
||||
$area['current'] ?? null
|
||||
),
|
||||
'icon' => $area['icon'] ?? null,
|
||||
'link' => $area['link'] ?? null,
|
||||
'dialog' => $area['dialog'] ?? null,
|
||||
'drawer' => $area['drawer'] ?? null,
|
||||
'text' => I18n::translate($area['label'], $area['label'])
|
||||
], $menu);
|
||||
|
||||
// unset the link (which is always added by default to an area)
|
||||
// if a dialog or drawer should be opened instead
|
||||
if (isset($entry['dialog']) || isset($entry['drawer'])) {
|
||||
unset($entry['link']);
|
||||
}
|
||||
|
||||
return array_filter($entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all menu entries
|
||||
*/
|
||||
public function entries(): array
|
||||
{
|
||||
$entries = [];
|
||||
$areas = $this->areas();
|
||||
|
||||
foreach ($areas as $area) {
|
||||
if ($area === '-') {
|
||||
$entries[] = '-';
|
||||
} elseif ($entry = $this->entry($area)) {
|
||||
$entries[] = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
$entries[] = '-';
|
||||
|
||||
return array_merge($entries, $this->options());
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the access permission to a specific area is granted.
|
||||
* Defaults to allow access.
|
||||
* @internal
|
||||
*/
|
||||
public function hasPermission(string $id): bool
|
||||
{
|
||||
return $this->permissions['access'][$id] ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the menu entry should receive aria-current
|
||||
* @internal
|
||||
*/
|
||||
public function isCurrent(
|
||||
string $id,
|
||||
bool|Closure|null $callback = null
|
||||
): bool {
|
||||
if ($callback !== null) {
|
||||
if ($callback instanceof Closure) {
|
||||
$callback = $callback($this->current);
|
||||
}
|
||||
|
||||
return $callback;
|
||||
}
|
||||
|
||||
return $this->current === $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default options entries for bottom of menu
|
||||
* @internal
|
||||
*/
|
||||
public function options(): array
|
||||
{
|
||||
$options = [
|
||||
[
|
||||
'icon' => 'edit-line',
|
||||
'dialog' => 'changes',
|
||||
'text' => I18n::translate('changes'),
|
||||
],
|
||||
[
|
||||
'current' => $this->isCurrent('account'),
|
||||
'icon' => 'account',
|
||||
'link' => 'account',
|
||||
'disabled' => $this->hasPermission('account') === false,
|
||||
'text' => I18n::translate('view.account'),
|
||||
],
|
||||
[
|
||||
'icon' => 'logout',
|
||||
'link' => 'logout',
|
||||
'text' => I18n::translate('logout')
|
||||
]
|
||||
];
|
||||
|
||||
return $options;
|
||||
}
|
||||
}
|
|
@ -22,11 +22,9 @@ use Kirby\Toolkit\A;
|
|||
*/
|
||||
abstract class Model
|
||||
{
|
||||
protected ModelWithContent $model;
|
||||
|
||||
public function __construct(ModelWithContent $model)
|
||||
{
|
||||
$this->model = $model;
|
||||
public function __construct(
|
||||
protected ModelWithContent $model
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -85,9 +83,10 @@ abstract class Model
|
|||
public function dropdownOption(): array
|
||||
{
|
||||
return [
|
||||
'icon' => 'page',
|
||||
'link' => $this->url(),
|
||||
'text' => $this->model->id(),
|
||||
'icon' => 'page',
|
||||
'image' => $this->image(['back' => 'black']),
|
||||
'link' => $this->url(true),
|
||||
'text' => $this->model->id(),
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -104,59 +103,49 @@ abstract class Model
|
|||
return null;
|
||||
}
|
||||
|
||||
// switched off from blueprint,
|
||||
// only if not overwritten by $settings
|
||||
$blueprint = $this->model->blueprint()->image();
|
||||
|
||||
if ($blueprint === false) {
|
||||
if (empty($settings) === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$blueprint = null;
|
||||
}
|
||||
|
||||
// convert string blueprint settings to proper array
|
||||
if (is_string($blueprint) === true) {
|
||||
$blueprint = ['query' => $blueprint];
|
||||
}
|
||||
|
||||
// skip image thumbnail if option
|
||||
// is explicitly set to show the icon
|
||||
if ($settings === 'icon') {
|
||||
$settings = [
|
||||
'query' => false
|
||||
];
|
||||
} elseif (is_string($settings) === true) {
|
||||
// convert string settings to proper array
|
||||
$settings = [
|
||||
'query' => $settings
|
||||
];
|
||||
$settings = ['query' => false];
|
||||
}
|
||||
|
||||
// convert string settings to proper array
|
||||
if (is_string($settings) === true) {
|
||||
$settings = ['query' => $settings];
|
||||
}
|
||||
|
||||
// merge with defaults and blueprint option
|
||||
$settings = array_merge(
|
||||
$this->imageDefaults(),
|
||||
$settings ?? [],
|
||||
$this->model->blueprint()->image() ?? [],
|
||||
$blueprint ?? [],
|
||||
);
|
||||
|
||||
if ($image = $this->imageSource($settings['query'] ?? null)) {
|
||||
// main url
|
||||
$settings['url'] = $image->url();
|
||||
|
||||
// only create srcsets for resizable files
|
||||
if ($image->isResizable() === true) {
|
||||
$settings['src'] = static::imagePlaceholder();
|
||||
|
||||
$sizes = match ($layout) {
|
||||
'cards' => [352, 864, 1408],
|
||||
'cardlets' => [96, 192],
|
||||
default => [38, 76]
|
||||
};
|
||||
|
||||
if (
|
||||
($settings['cover'] ?? false) === false ||
|
||||
$layout === 'cards'
|
||||
) {
|
||||
$settings['srcset'] = $image->srcset($sizes);
|
||||
} else {
|
||||
$settings['srcset'] = $image->srcset([
|
||||
'1x' => [
|
||||
'width' => $sizes[0],
|
||||
'height' => $sizes[0],
|
||||
'crop' => 'center'
|
||||
],
|
||||
'2x' => [
|
||||
'width' => $sizes[1],
|
||||
'height' => $sizes[1],
|
||||
'crop' => 'center'
|
||||
]
|
||||
]);
|
||||
}
|
||||
// only create srcsets for resizable files
|
||||
$settings['src'] = static::imagePlaceholder();
|
||||
$settings['srcset'] = $this->imageSrcset($image, $layout, $settings);
|
||||
} elseif ($image->isViewable() === true) {
|
||||
$settings['src'] = $image->url();
|
||||
}
|
||||
|
@ -183,8 +172,7 @@ abstract class Model
|
|||
'back' => 'pattern',
|
||||
'color' => 'gray-500',
|
||||
'cover' => false,
|
||||
'icon' => 'page',
|
||||
'ratio' => '3/2',
|
||||
'icon' => 'page'
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -217,14 +205,85 @@ abstract class Model
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the correct srcset string based on
|
||||
* the layout and settings
|
||||
* @internal
|
||||
*/
|
||||
protected function imageSrcset(
|
||||
CmsFile|Asset $image,
|
||||
string $layout,
|
||||
array $settings
|
||||
): string|null {
|
||||
// depending on layout type, set different sizes
|
||||
// to have multiple options for the srcset attribute
|
||||
$sizes = match ($layout) {
|
||||
'cards' => [352, 864, 1408],
|
||||
'cardlets' => [96, 192],
|
||||
default => [38, 76]
|
||||
};
|
||||
|
||||
// no additional modfications needed if `cover: false`
|
||||
if (($settings['cover'] ?? false) === false) {
|
||||
return $image->srcset($sizes);
|
||||
}
|
||||
|
||||
// for card layouts with `cover: true` provide
|
||||
// crops based on the card ratio
|
||||
if ($layout === 'cards') {
|
||||
$ratio = explode('/', $settings['ratio'] ?? '1/1');
|
||||
$ratio = $ratio[0] / $ratio[1];
|
||||
|
||||
return $image->srcset([
|
||||
$sizes[0] . 'w' => [
|
||||
'width' => $sizes[0],
|
||||
'height' => round($sizes[0] / $ratio),
|
||||
'crop' => true
|
||||
],
|
||||
$sizes[1] . 'w' => [
|
||||
'width' => $sizes[1],
|
||||
'height' => round($sizes[1] / $ratio),
|
||||
'crop' => true
|
||||
],
|
||||
$sizes[2] . 'w' => [
|
||||
'width' => $sizes[2],
|
||||
'height' => round($sizes[2] / $ratio),
|
||||
'crop' => true
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
// for list and cardlets with `cover: true`
|
||||
// provide square crops in two resolutions
|
||||
return $image->srcset([
|
||||
'1x' => [
|
||||
'width' => $sizes[0],
|
||||
'height' => $sizes[0],
|
||||
'crop' => true
|
||||
],
|
||||
'2x' => [
|
||||
'width' => $sizes[1],
|
||||
'height' => $sizes[1],
|
||||
'crop' => true
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for disabled dropdown options according
|
||||
* to the given permissions
|
||||
*/
|
||||
public function isDisabledDropdownOption(string $action, array $options, array $permissions): bool
|
||||
{
|
||||
public function isDisabledDropdownOption(
|
||||
string $action,
|
||||
array $options,
|
||||
array $permissions
|
||||
): bool {
|
||||
$option = $options[$action] ?? true;
|
||||
return $permissions[$action] === false || $option === false || $option === 'false';
|
||||
|
||||
return
|
||||
$permissions[$action] === false ||
|
||||
$option === false ||
|
||||
$option === 'false';
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -235,11 +294,7 @@ abstract class Model
|
|||
*/
|
||||
public function lock(): array|false
|
||||
{
|
||||
if ($lock = $this->model->lock()) {
|
||||
return $lock->toArray();
|
||||
}
|
||||
|
||||
return false;
|
||||
return $this->model->lock()?->toArray() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -287,7 +342,7 @@ abstract class Model
|
|||
'link' => $this->url(true),
|
||||
'sortable' => true,
|
||||
'text' => $this->model->toSafeString($params['text'] ?? false),
|
||||
'uuid' => $this->model->uuid()?->toString() ?? $this->model->id(),
|
||||
'uuid' => $this->model->uuid()?->toString()
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -320,33 +375,34 @@ abstract class Model
|
|||
}
|
||||
|
||||
/**
|
||||
* Returns link url and tooltip
|
||||
* for model (e.g. used for prev/next
|
||||
* navigation)
|
||||
* Returns link url and title
|
||||
* for model (e.g. used for prev/next navigation)
|
||||
* @internal
|
||||
*/
|
||||
public function toLink(string $tooltip = 'title'): array
|
||||
public function toLink(string $title = 'title'): array
|
||||
{
|
||||
return [
|
||||
'link' => $this->url(true),
|
||||
'tooltip' => (string)$this->model->{$tooltip}()
|
||||
'title' => $title = (string)$this->model->{$title}()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns link url and tooltip
|
||||
* Returns link url and title
|
||||
* for optional sibling model and
|
||||
* preserves tab selection
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
protected function toPrevNextLink(ModelWithContent|null $model = null, string $tooltip = 'title'): array|null
|
||||
{
|
||||
protected function toPrevNextLink(
|
||||
ModelWithContent|null $model = null,
|
||||
string $title = 'title'
|
||||
): array|null {
|
||||
if ($model === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $model->panel()->toLink($tooltip);
|
||||
$data = $model->panel()->toLink($title);
|
||||
|
||||
if ($tab = $model->kirby()->request()->get('tab')) {
|
||||
$uri = new Uri($data['link'], [
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
namespace Kirby\Panel;
|
||||
|
||||
use Kirby\Cms\File as CmsFile;
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
use Kirby\Filesystem\Asset;
|
||||
use Kirby\Toolkit\I18n;
|
||||
|
||||
|
@ -18,16 +19,24 @@ use Kirby\Toolkit\I18n;
|
|||
*/
|
||||
class Page extends Model
|
||||
{
|
||||
/**
|
||||
* @var \Kirby\Cms\Page
|
||||
*/
|
||||
protected ModelWithContent $model;
|
||||
|
||||
/**
|
||||
* Breadcrumb array
|
||||
*/
|
||||
public function breadcrumb(): array
|
||||
{
|
||||
$parents = $this->model->parents()->flip()->merge($this->model);
|
||||
return $parents->values(fn ($parent) => [
|
||||
'label' => $parent->title()->toString(),
|
||||
'link' => $parent->panel()->url(true),
|
||||
]);
|
||||
|
||||
return $parents->values(
|
||||
fn ($parent) => [
|
||||
'label' => $parent->title()->toString(),
|
||||
'link' => $parent->panel()->url(true),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -65,9 +74,9 @@ class Page extends Model
|
|||
*/
|
||||
public function dropdown(array $options = []): array
|
||||
{
|
||||
$page = $this->model;
|
||||
|
||||
$defaults = $page->kirby()->request()->get(['view', 'sort', 'delete']);
|
||||
$page = $this->model;
|
||||
$request = $page->kirby()->request();
|
||||
$defaults = $request->get(['view', 'sort', 'delete']);
|
||||
$options = array_merge($defaults, $options);
|
||||
|
||||
$permissions = $this->options(['preview']);
|
||||
|
@ -98,15 +107,6 @@ class Page extends Model
|
|||
'disabled' => $this->isDisabledDropdownOption('changeTitle', $options, $permissions)
|
||||
];
|
||||
|
||||
$result['duplicate'] = [
|
||||
'dialog' => $url . '/duplicate',
|
||||
'icon' => 'copy',
|
||||
'text' => I18n::translate('duplicate'),
|
||||
'disabled' => $this->isDisabledDropdownOption('duplicate', $options, $permissions)
|
||||
];
|
||||
|
||||
$result[] = '-';
|
||||
|
||||
$result['changeSlug'] = [
|
||||
'dialog' => [
|
||||
'url' => $url . '/changeTitle',
|
||||
|
@ -143,6 +143,23 @@ class Page extends Model
|
|||
];
|
||||
|
||||
$result[] = '-';
|
||||
|
||||
$result['move'] = [
|
||||
'dialog' => $url . '/move',
|
||||
'icon' => 'parent',
|
||||
'text' => I18n::translate('page.move'),
|
||||
'disabled' => $this->isDisabledDropdownOption('move', $options, $permissions)
|
||||
];
|
||||
|
||||
$result['duplicate'] = [
|
||||
'dialog' => $url . '/duplicate',
|
||||
'icon' => 'copy',
|
||||
'text' => I18n::translate('duplicate'),
|
||||
'disabled' => $this->isDisabledDropdownOption('duplicate', $options, $permissions)
|
||||
];
|
||||
|
||||
$result[] = '-';
|
||||
|
||||
$result['delete'] = [
|
||||
'dialog' => $url . '/delete',
|
||||
'icon' => 'trash',
|
||||
|
@ -290,7 +307,7 @@ class Page extends Model
|
|||
->filter('status', $page->status());
|
||||
}
|
||||
|
||||
return $siblings->filter('isReadable', true);
|
||||
return $siblings->filter('isListable', true);
|
||||
};
|
||||
|
||||
return [
|
||||
|
@ -322,6 +339,7 @@ class Page extends Model
|
|||
'previewUrl' => $page->previewUrl(),
|
||||
'status' => $page->status(),
|
||||
'title' => $page->title()->toString(),
|
||||
'uuid' => fn () => $page->uuid()?->toString(),
|
||||
],
|
||||
'status' => function () use ($page) {
|
||||
if ($status = $page->status()) {
|
||||
|
|
386
kirby/src/Panel/PageCreateDialog.php
Normal file
386
kirby/src/Panel/PageCreateDialog.php
Normal file
|
@ -0,0 +1,386 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel;
|
||||
|
||||
use Kirby\Cms\File;
|
||||
use Kirby\Cms\Find;
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Cms\PageBlueprint;
|
||||
use Kirby\Cms\PageRules;
|
||||
use Kirby\Cms\Site;
|
||||
use Kirby\Cms\User;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Form\Form;
|
||||
use Kirby\Toolkit\A;
|
||||
use Kirby\Toolkit\I18n;
|
||||
|
||||
/**
|
||||
* Manages the Panel dialog to create new pages
|
||||
* @since 4.0.0
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class PageCreateDialog
|
||||
{
|
||||
protected PageBlueprint $blueprint;
|
||||
protected Page $model;
|
||||
protected Page|Site $parent;
|
||||
protected string $parentId;
|
||||
protected string|null $sectionId;
|
||||
protected string|null $slug;
|
||||
protected string|null $template;
|
||||
protected string|null $title;
|
||||
protected Page|Site|User|File $view;
|
||||
protected string|null $viewId;
|
||||
|
||||
public static array $fieldTypes = [
|
||||
'checkboxes',
|
||||
'date',
|
||||
'email',
|
||||
'info',
|
||||
'line',
|
||||
'link',
|
||||
'list',
|
||||
'number',
|
||||
'multiselect',
|
||||
'radio',
|
||||
'range',
|
||||
'select',
|
||||
'slug',
|
||||
'tags',
|
||||
'tel',
|
||||
'text',
|
||||
'toggle',
|
||||
'toggles',
|
||||
'time',
|
||||
'url'
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
string|null $parentId,
|
||||
string|null $sectionId,
|
||||
string|null $template,
|
||||
string|null $viewId,
|
||||
|
||||
// optional
|
||||
string|null $slug = null,
|
||||
string|null $title = null,
|
||||
) {
|
||||
$this->parentId = $parentId ?? 'site';
|
||||
$this->parent = Find::parent($this->parentId);
|
||||
$this->sectionId = $sectionId;
|
||||
$this->slug = $slug;
|
||||
$this->template = $template;
|
||||
$this->title = $title;
|
||||
$this->viewId = $viewId;
|
||||
$this->view = Find::parent($this->viewId ?? $this->parentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the blueprint settings for the new page
|
||||
*/
|
||||
public function blueprint(): PageBlueprint
|
||||
{
|
||||
// create a temporary page object
|
||||
return $this->blueprint ??= $this->model()->blueprint();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array of all blueprints for the parent view
|
||||
*/
|
||||
public function blueprints(): array
|
||||
{
|
||||
return A::map(
|
||||
$this->view->blueprints($this->sectionId),
|
||||
function ($blueprint) {
|
||||
$blueprint['name'] ??= $blueprint['value'] ?? null;
|
||||
return $blueprint;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* All the default fields for the dialog
|
||||
*/
|
||||
public function coreFields(): array
|
||||
{
|
||||
$fields = [];
|
||||
|
||||
$title = $this->blueprint()->create()['title'] ?? null;
|
||||
$slug = $this->blueprint()->create()['slug'] ?? null;
|
||||
|
||||
if ($title === false || $slug === false) {
|
||||
throw new InvalidArgumentException('Page create dialog: title and slug must not be false');
|
||||
}
|
||||
|
||||
// title field
|
||||
if ($title === null || is_array($title) === true) {
|
||||
$label = $title['label'] ?? 'title';
|
||||
$fields['title'] = Field::title([
|
||||
...$title ?? [],
|
||||
'label' => I18n::translate($label, $label),
|
||||
'required' => true,
|
||||
'preselect' => true
|
||||
]);
|
||||
}
|
||||
|
||||
// slug field
|
||||
if ($slug === null) {
|
||||
$fields['slug'] = Field::slug([
|
||||
'required' => true,
|
||||
'sync' => 'title',
|
||||
'path' => $this->parent instanceof Page ? '/' . $this->parent->id() . '/' : '/'
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
...$fields,
|
||||
'parent' => Field::hidden(),
|
||||
'section' => Field::hidden(),
|
||||
'template' => Field::hidden(),
|
||||
'view' => Field::hidden(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads custom fields for the page type
|
||||
*/
|
||||
public function customFields(): array
|
||||
{
|
||||
$custom = [];
|
||||
$ignore = ['title', 'slug', 'parent', 'template'];
|
||||
$blueprint = $this->blueprint();
|
||||
$fields = $blueprint->fields();
|
||||
|
||||
foreach ($blueprint->create()['fields'] ?? [] as $name) {
|
||||
if (!$field = ($fields[$name] ?? null)) {
|
||||
throw new InvalidArgumentException('Unknown field "' . $name . '" in create dialog');
|
||||
}
|
||||
|
||||
if (in_array($field['type'], static::$fieldTypes) === false) {
|
||||
throw new InvalidArgumentException('Field type "' . $field['type'] . '" not supported in create dialog');
|
||||
}
|
||||
|
||||
if (in_array($name, $ignore) === true) {
|
||||
throw new InvalidArgumentException('Field name "' . $name . '" not allowed as custom field in create dialog');
|
||||
}
|
||||
|
||||
// switch all fields to 1/1
|
||||
$field['width'] = '1/1';
|
||||
|
||||
// add the field to the form
|
||||
$custom[$name] = $field;
|
||||
}
|
||||
|
||||
// create form so that field props, options etc.
|
||||
// can be properly resolved
|
||||
$form = new Form([
|
||||
'fields' => $custom,
|
||||
'model' => $this->model(),
|
||||
'strict' => true
|
||||
]);
|
||||
|
||||
return $form->fields()->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all the fields for the dialog
|
||||
*/
|
||||
public function fields(): array
|
||||
{
|
||||
return [
|
||||
...$this->coreFields(),
|
||||
...$this->customFields()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides all the props for the
|
||||
* dialog, including the fields and
|
||||
* initial values
|
||||
*/
|
||||
public function load(): array
|
||||
{
|
||||
$blueprints = $this->blueprints();
|
||||
|
||||
$this->template ??= $blueprints[0]['name'];
|
||||
|
||||
$status = $this->blueprint()->create()['status'] ?? 'draft';
|
||||
$status = $this->blueprint()->status()[$status]['label'] ?? null;
|
||||
$status ??= I18n::translate('page.status.' . $status);
|
||||
|
||||
$fields = $this->fields();
|
||||
$visible = array_filter(
|
||||
$fields,
|
||||
fn ($field) => ($field['hidden'] ?? null) !== true
|
||||
);
|
||||
|
||||
// immediately submit the dialog if there is no editable field
|
||||
if (count($visible) === 0 && count($blueprints) < 2) {
|
||||
$input = $this->value();
|
||||
$response = $this->submit($input);
|
||||
$response['redirect'] ??= $this->parent->panel()->url(true);
|
||||
Panel::go($response['redirect']);
|
||||
}
|
||||
|
||||
return [
|
||||
'component' => 'k-page-create-dialog',
|
||||
'props' => [
|
||||
'blueprints' => $blueprints,
|
||||
'fields' => $fields,
|
||||
'submitButton' => I18n::template('page.create', [
|
||||
'status' => $status
|
||||
]),
|
||||
'template' => $this->template,
|
||||
'value' => $this->value()
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporary model for the page to
|
||||
* be created, used to properly render
|
||||
* the blueprint for fields
|
||||
*/
|
||||
public function model(): Page
|
||||
{
|
||||
// TODO: use actual in-memory page in v5
|
||||
return $this->model ??= Page::factory([
|
||||
'slug' => '__new__',
|
||||
'template' => $this->template,
|
||||
'model' => $this->template,
|
||||
'parent' => $this->parent instanceof Page ? $this->parent : null
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates values for title and slug
|
||||
* from template strings from the blueprint
|
||||
*/
|
||||
public function resolveFieldTemplates(array $input): array
|
||||
{
|
||||
$title = $this->blueprint()->create()['title'] ?? null;
|
||||
$slug = $this->blueprint()->create()['slug'] ?? null;
|
||||
|
||||
// create temporary page object
|
||||
// to resolve the template strings
|
||||
$page = $this->model()->clone(['content' => $input]);
|
||||
|
||||
if (is_string($title)) {
|
||||
$input['title'] = $page->toSafeString($title);
|
||||
}
|
||||
|
||||
if (is_string($slug)) {
|
||||
$input['slug'] = $page->toSafeString($slug);
|
||||
}
|
||||
|
||||
return $input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares and cleans up the input data
|
||||
*/
|
||||
public function sanitize(array $input): array
|
||||
{
|
||||
$input['title'] ??= $this->title ?? '';
|
||||
$input['slug'] ??= $this->slug ?? '';
|
||||
|
||||
$input = $this->resolveFieldTemplates($input);
|
||||
$content = ['title' => trim($input['title'])];
|
||||
|
||||
foreach ($this->customFields() as $name => $field) {
|
||||
$content[$name] = $input[$name] ?? null;
|
||||
}
|
||||
|
||||
// create temporary form to sanitize the input
|
||||
// and add default values
|
||||
$form = Form::for($this->model(), ['values' => $content]);
|
||||
|
||||
return [
|
||||
'content' => $form->strings(true),
|
||||
'slug' => $input['slug'],
|
||||
'template' => $this->template,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits the dialog form and creates the new page
|
||||
*/
|
||||
public function submit(array $input): array
|
||||
{
|
||||
$input = $this->sanitize($input);
|
||||
$status = $this->blueprint()->create()['status'] ?? 'draft';
|
||||
|
||||
// validate the input before creating the page
|
||||
$this->validate($input, $status);
|
||||
|
||||
$page = $this->parent->createChild($input);
|
||||
|
||||
if ($status !== 'draft') {
|
||||
// grant all permissions as the status is set in the blueprint and
|
||||
// should not be treated as if the user would try to change it
|
||||
$page->kirby()->impersonate(
|
||||
'kirby',
|
||||
fn () => $page->changeStatus($status)
|
||||
);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'event' => 'page.create'
|
||||
];
|
||||
|
||||
// add redirect, if not explicitly disabled
|
||||
if (($this->blueprint()->create()['redirect'] ?? null) !== false) {
|
||||
$payload['redirect'] = $page->panel()->url(true);
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
public function validate(array $input, string $status = 'draft'): bool
|
||||
{
|
||||
// basic validation
|
||||
PageRules::validateTitleLength($input['content']['title']);
|
||||
PageRules::validateSlugLength($input['slug']);
|
||||
|
||||
// if the page is supposed to be published directly,
|
||||
// ensure that all field validations are met
|
||||
if ($status !== 'draft') {
|
||||
// create temporary form to validate the input
|
||||
$form = Form::for($this->model(), ['values' => $input['content']]);
|
||||
|
||||
if ($form->isInvalid() === true) {
|
||||
throw new InvalidArgumentException([
|
||||
'key' => 'page.changeStatus.incomplete'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function value(): array
|
||||
{
|
||||
$value = [
|
||||
'parent' => $this->parentId,
|
||||
'section' => $this->sectionId,
|
||||
'slug' => $this->slug ?? '',
|
||||
'template' => $this->template,
|
||||
'title' => $this->title ?? '',
|
||||
'view' => $this->viewId,
|
||||
];
|
||||
|
||||
// add default values for custom fields
|
||||
foreach ($this->customFields() as $name => $field) {
|
||||
if ($default = $field['default'] ?? null) {
|
||||
$value[$name] = $default;
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
|
@ -35,7 +35,7 @@ class Panel
|
|||
/**
|
||||
* Normalize a panel area
|
||||
*/
|
||||
public static function area(string $id, array|string $area): array
|
||||
public static function area(string $id, array $area): array
|
||||
{
|
||||
$area['id'] = $id;
|
||||
$area['label'] ??= $id;
|
||||
|
@ -60,9 +60,15 @@ class Panel
|
|||
$areas = $kirby->load()->areas();
|
||||
|
||||
// the system is not ready
|
||||
if ($system->isOk() === false || $system->isInstalled() === false) {
|
||||
if (
|
||||
$system->isOk() === false ||
|
||||
$system->isInstalled() === false
|
||||
) {
|
||||
return [
|
||||
'installation' => static::area('installation', $areas['installation']),
|
||||
'installation' => static::area(
|
||||
'installation',
|
||||
$areas['installation']
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -70,7 +76,6 @@ class Panel
|
|||
if (!$user) {
|
||||
return [
|
||||
'logout' => static::area('logout', $areas['logout']),
|
||||
|
||||
// login area last because it defines a fallback route
|
||||
'login' => static::area('login', $areas['login']),
|
||||
];
|
||||
|
@ -85,24 +90,8 @@ class Panel
|
|||
unset($areas['languages']);
|
||||
}
|
||||
|
||||
$menu = $kirby->option('panel.menu', [
|
||||
'site',
|
||||
'languages',
|
||||
'users',
|
||||
'system',
|
||||
]);
|
||||
|
||||
$result = [];
|
||||
|
||||
// add the sorted areas
|
||||
foreach ($menu as $id) {
|
||||
if ($area = ($areas[$id] ?? null)) {
|
||||
$result[$id] = static::area($id, $area);
|
||||
unset($areas[$id]);
|
||||
}
|
||||
}
|
||||
|
||||
// add the remaining areas
|
||||
foreach ($areas as $id => $area) {
|
||||
$result[$id] = static::area($id, $area);
|
||||
}
|
||||
|
@ -185,7 +174,9 @@ class Panel
|
|||
$request = App::instance()->request();
|
||||
|
||||
if ($request->method() === 'GET') {
|
||||
return (bool)($request->get('_json') ?? $request->header('X-Fiber'));
|
||||
return
|
||||
(bool)($request->get('_json') ??
|
||||
$request->header('X-Fiber'));
|
||||
}
|
||||
|
||||
return false;
|
||||
|
@ -200,7 +191,7 @@ class Panel
|
|||
$request = App::instance()->request();
|
||||
|
||||
return Response::json($data, $code, $request->get('_pretty'), [
|
||||
'X-Fiber' => 'true',
|
||||
'X-Fiber' => 'true',
|
||||
'Cache-Control' => 'no-store, private'
|
||||
]);
|
||||
}
|
||||
|
@ -244,7 +235,7 @@ class Panel
|
|||
if ($result === null || $result === false) {
|
||||
$result = new NotFoundException('The data could not be found');
|
||||
|
||||
// interpret strings as errors
|
||||
// interpret strings as errors
|
||||
} elseif (is_string($result) === true) {
|
||||
$result = new Exception($result);
|
||||
}
|
||||
|
@ -252,7 +243,9 @@ class Panel
|
|||
// handle different response types (view, dialog, ...)
|
||||
return match ($options['type'] ?? null) {
|
||||
'dialog' => Dialog::response($result, $options),
|
||||
'drawer' => Drawer::response($result, $options),
|
||||
'dropdown' => Dropdown::response($result, $options),
|
||||
'request' => Request::response($result, $options),
|
||||
'search' => Search::response($result, $options),
|
||||
default => View::response($result, $options)
|
||||
};
|
||||
|
@ -291,7 +284,11 @@ class Panel
|
|||
// call the route action to check the result
|
||||
try {
|
||||
// trigger hook
|
||||
$route = $kirby->apply('panel.route:before', compact('route', 'path', 'method'), 'route');
|
||||
$route = $kirby->apply(
|
||||
'panel.route:before',
|
||||
compact('route', 'path', 'method'),
|
||||
'route'
|
||||
);
|
||||
|
||||
// check for access before executing area routes
|
||||
if ($auth !== false) {
|
||||
|
@ -310,7 +307,11 @@ class Panel
|
|||
'type' => $type
|
||||
]);
|
||||
|
||||
return $kirby->apply('panel.route:after', compact('route', 'path', 'method', 'response'), 'response');
|
||||
return $kirby->apply(
|
||||
'panel.route:after',
|
||||
compact('route', 'path', 'method', 'response'),
|
||||
'response'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -341,7 +342,9 @@ class Panel
|
|||
static::routesForViews($areaId, $area),
|
||||
static::routesForSearches($areaId, $area),
|
||||
static::routesForDialogs($areaId, $area),
|
||||
static::routesForDrawers($areaId, $area),
|
||||
static::routesForDropdowns($areaId, $area),
|
||||
static::routesForRequests($areaId, $area),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -362,7 +365,7 @@ class Panel
|
|||
// catch all route
|
||||
$routes[] = [
|
||||
'pattern' => '(:all)',
|
||||
'action' => fn () => 'The view could not be found'
|
||||
'action' => fn (string $pattern) => 'Could not find Panel view for route: ' . $pattern
|
||||
];
|
||||
|
||||
return $routes;
|
||||
|
@ -376,26 +379,33 @@ class Panel
|
|||
$dialogs = $area['dialogs'] ?? [];
|
||||
$routes = [];
|
||||
|
||||
foreach ($dialogs as $key => $dialog) {
|
||||
// create the full pattern with dialogs prefix
|
||||
$pattern = 'dialogs/' . trim(($dialog['pattern'] ?? $key), '/');
|
||||
foreach ($dialogs as $dialogId => $dialog) {
|
||||
$routes = array_merge($routes, Dialog::routes(
|
||||
id: $dialogId,
|
||||
areaId: $areaId,
|
||||
prefix: 'dialogs',
|
||||
options: $dialog
|
||||
));
|
||||
}
|
||||
|
||||
// load event
|
||||
$routes[] = [
|
||||
'pattern' => $pattern,
|
||||
'type' => 'dialog',
|
||||
'area' => $areaId,
|
||||
'action' => $dialog['load'] ?? fn () => 'The load handler for your dialog is missing'
|
||||
];
|
||||
return $routes;
|
||||
}
|
||||
|
||||
// submit event
|
||||
$routes[] = [
|
||||
'pattern' => $pattern,
|
||||
'type' => 'dialog',
|
||||
'area' => $areaId,
|
||||
'method' => 'POST',
|
||||
'action' => $dialog['submit'] ?? fn () => 'Your dialog does not define a submit handler'
|
||||
];
|
||||
/**
|
||||
* Extract all routes from an area
|
||||
*/
|
||||
public static function routesForDrawers(string $areaId, array $area): array
|
||||
{
|
||||
$drawers = $area['drawers'] ?? [];
|
||||
$routes = [];
|
||||
|
||||
foreach ($drawers as $drawerId => $drawer) {
|
||||
$routes = array_merge($routes, Drawer::routes(
|
||||
id: $drawerId,
|
||||
areaId: $areaId,
|
||||
prefix: 'drawers',
|
||||
options: $drawer
|
||||
));
|
||||
}
|
||||
|
||||
return $routes;
|
||||
|
@ -409,27 +419,28 @@ class Panel
|
|||
$dropdowns = $area['dropdowns'] ?? [];
|
||||
$routes = [];
|
||||
|
||||
foreach ($dropdowns as $name => $dropdown) {
|
||||
// Handle shortcuts for dropdowns. The name is the pattern
|
||||
// and options are defined in a Closure
|
||||
if ($dropdown instanceof Closure) {
|
||||
$dropdown = [
|
||||
'pattern' => $name,
|
||||
'action' => $dropdown
|
||||
];
|
||||
}
|
||||
foreach ($dropdowns as $dropdownId => $dropdown) {
|
||||
$routes = array_merge($routes, Dropdown::routes(
|
||||
id: $dropdownId,
|
||||
areaId: $areaId,
|
||||
prefix: 'dropdowns',
|
||||
options: $dropdown
|
||||
));
|
||||
}
|
||||
|
||||
// create the full pattern with dropdowns prefix
|
||||
$pattern = 'dropdowns/' . trim(($dropdown['pattern'] ?? $name), '/');
|
||||
return $routes;
|
||||
}
|
||||
|
||||
// load event
|
||||
$routes[] = [
|
||||
'pattern' => $pattern,
|
||||
'type' => 'dropdown',
|
||||
'area' => $areaId,
|
||||
'method' => 'GET|POST',
|
||||
'action' => $dropdown['options'] ?? $dropdown['action']
|
||||
];
|
||||
/**
|
||||
* Extract all routes from an area
|
||||
*/
|
||||
public static function routesForRequests(string $areaId, array $area): array
|
||||
{
|
||||
$routes = $area['requests'] ?? [];
|
||||
|
||||
foreach ($routes as $key => $route) {
|
||||
$routes[$key]['area'] = $areaId;
|
||||
$routes[$key]['type'] = 'request';
|
||||
}
|
||||
|
||||
return $routes;
|
||||
|
@ -453,9 +464,13 @@ class Panel
|
|||
'type' => 'search',
|
||||
'area' => $areaId,
|
||||
'action' => function () use ($params) {
|
||||
$request = App::instance()->request();
|
||||
$kirby = App::instance();
|
||||
$request = $kirby->request();
|
||||
$query = $request->get('query');
|
||||
$limit = (int)$request->get('limit', $kirby->option('panel.search.limit', 10));
|
||||
$page = (int)$request->get('page', 1);
|
||||
|
||||
return $params['query']($request->get('query'));
|
||||
return $params['query']($query, $limit, $page);
|
||||
}
|
||||
];
|
||||
}
|
||||
|
@ -474,7 +489,18 @@ class Panel
|
|||
foreach ($views as $view) {
|
||||
$view['area'] = $areaId;
|
||||
$view['type'] = 'view';
|
||||
$routes[] = $view;
|
||||
|
||||
$when = $view['when'] ?? null;
|
||||
unset($view['when']);
|
||||
|
||||
// enable the route by default, but if there is a
|
||||
// when condition closure, it must return `true`
|
||||
if (
|
||||
$when instanceof Closure === false ||
|
||||
$when($view, $area) === true
|
||||
) {
|
||||
$routes[] = $view;
|
||||
}
|
||||
}
|
||||
|
||||
return $routes;
|
||||
|
@ -537,7 +563,7 @@ class Panel
|
|||
* Creates an absolute Panel URL
|
||||
* independent of the Panel slug config
|
||||
*/
|
||||
public static function url(string|null $url = null): string
|
||||
public static function url(string|null $url = null, array $options = []): string
|
||||
{
|
||||
// only touch relative paths
|
||||
if (Url::isAbsolute($url) === false) {
|
||||
|
@ -559,7 +585,7 @@ class Panel
|
|||
}
|
||||
|
||||
// create an absolute URL
|
||||
$url = CmsUrl::to($path);
|
||||
$url = CmsUrl::to($path, $options);
|
||||
}
|
||||
|
||||
return $url;
|
||||
|
|
24
kirby/src/Panel/Request.php
Normal file
24
kirby/src/Panel/Request.php
Normal file
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel;
|
||||
|
||||
use Kirby\Http\Response;
|
||||
|
||||
/**
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Request
|
||||
{
|
||||
/**
|
||||
* Renders request responses
|
||||
*/
|
||||
public static function response($data, array $options = []): Response
|
||||
{
|
||||
$data = Json::responseData($data);
|
||||
return Panel::json($data, $data['code'] ?? 200);
|
||||
}
|
||||
}
|
|
@ -22,9 +22,17 @@ class Search extends Json
|
|||
|
||||
public static function response($data, array $options = []): Response
|
||||
{
|
||||
if (is_array($data) === true) {
|
||||
if (
|
||||
is_array($data) === true &&
|
||||
array_key_exists('results', $data) === false
|
||||
) {
|
||||
$data = [
|
||||
'results' => $data
|
||||
'results' => $data,
|
||||
'pagination' => [
|
||||
'page' => 1,
|
||||
'limit' => $total = count($data),
|
||||
'total' => $total
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
namespace Kirby\Panel;
|
||||
|
||||
use Kirby\Cms\File as CmsFile;
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
use Kirby\Filesystem\Asset;
|
||||
|
||||
/**
|
||||
|
@ -17,6 +18,11 @@ use Kirby\Filesystem\Asset;
|
|||
*/
|
||||
class Site extends Model
|
||||
{
|
||||
/**
|
||||
* @var \Kirby\Cms\Site
|
||||
*/
|
||||
protected ModelWithContent $model;
|
||||
|
||||
/**
|
||||
* Returns the setup for a dropdown option
|
||||
* which is used in the changes dropdown
|
||||
|
@ -65,6 +71,7 @@ class Site extends Model
|
|||
'link' => $this->url(true),
|
||||
'previewUrl' => $this->model->previewUrl(),
|
||||
'title' => $this->model->title()->toString(),
|
||||
'uuid' => fn () => $this->model->uuid()?->toString(),
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
namespace Kirby\Panel;
|
||||
|
||||
use Kirby\Cms\File as CmsFile;
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
use Kirby\Cms\Translation;
|
||||
use Kirby\Cms\Url;
|
||||
use Kirby\Filesystem\Asset;
|
||||
|
@ -20,6 +21,11 @@ use Kirby\Toolkit\I18n;
|
|||
*/
|
||||
class User extends Model
|
||||
{
|
||||
/**
|
||||
* @var \Kirby\Cms\User
|
||||
*/
|
||||
protected ModelWithContent $model;
|
||||
|
||||
/**
|
||||
* Breadcrumb array
|
||||
*/
|
||||
|
@ -64,9 +70,18 @@ class User extends Model
|
|||
'dialog' => $url . '/changeRole',
|
||||
'icon' => 'bolt',
|
||||
'text' => I18n::translate('user.changeRole'),
|
||||
'disabled' => $this->isDisabledDropdownOption('changeRole', $options, $permissions)
|
||||
'disabled' => $this->isDisabledDropdownOption('changeRole', $options, $permissions) || $this->model->roles()->count() < 2
|
||||
];
|
||||
|
||||
$result[] = [
|
||||
'dialog' => $url . '/changeLanguage',
|
||||
'icon' => 'translate',
|
||||
'text' => I18n::translate('user.changeLanguage'),
|
||||
'disabled' => $this->isDisabledDropdownOption('changeLanguage', $options, $permissions)
|
||||
];
|
||||
|
||||
$result[] = '-';
|
||||
|
||||
$result[] = [
|
||||
'dialog' => $url . '/changePassword',
|
||||
'icon' => 'key',
|
||||
|
@ -74,12 +89,23 @@ class User extends Model
|
|||
'disabled' => $this->isDisabledDropdownOption('changePassword', $options, $permissions)
|
||||
];
|
||||
|
||||
$result[] = [
|
||||
'dialog' => $url . '/changeLanguage',
|
||||
'icon' => 'globe',
|
||||
'text' => I18n::translate('user.changeLanguage'),
|
||||
'disabled' => $this->isDisabledDropdownOption('changeLanguage', $options, $permissions)
|
||||
];
|
||||
if ($this->model->kirby()->system()->is2FAWithTOTP() === true) {
|
||||
if ($account || $this->model->kirby()->user()->isAdmin()) {
|
||||
if ($this->model->secret('totp') !== null) {
|
||||
$result[] = [
|
||||
'dialog' => $url . '/totp/disable',
|
||||
'icon' => 'qr-code',
|
||||
'text' => I18n::translate('login.totp.disable.option'),
|
||||
];
|
||||
} elseif ($account) {
|
||||
$result[] = [
|
||||
'dialog' => $url . '/totp/enable',
|
||||
'icon' => 'qr-code',
|
||||
'text' => I18n::translate('login.totp.enable.option')
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$result[] = '-';
|
||||
|
||||
|
@ -192,18 +218,22 @@ class User extends Model
|
|||
*/
|
||||
public function props(): array
|
||||
{
|
||||
$user = $this->model;
|
||||
$account = $user->isLoggedIn();
|
||||
$avatar = $user->avatar();
|
||||
$user = $this->model;
|
||||
$account = $user->isLoggedIn();
|
||||
$permissions = $this->options();
|
||||
|
||||
return array_merge(
|
||||
parent::props(),
|
||||
$account ? [] : $this->prevNext(),
|
||||
$this->prevNext(),
|
||||
[
|
||||
'blueprint' => $this->model->role()->name(),
|
||||
'blueprint' => $this->model->role()->name(),
|
||||
'canChangeEmail' => $permissions['changeEmail'],
|
||||
'canChangeLanguage' => $permissions['changeLanguage'],
|
||||
'canChangeName' => $permissions['changeName'],
|
||||
'canChangeRole' => $this->model->roles()->count() > 1,
|
||||
'model' => [
|
||||
'account' => $account,
|
||||
'avatar' => $avatar ? $avatar->url() : null,
|
||||
'avatar' => $user->avatar()?->url(),
|
||||
'content' => $this->content(),
|
||||
'email' => $user->email(),
|
||||
'id' => $user->id(),
|
||||
|
@ -212,6 +242,7 @@ class User extends Model
|
|||
'name' => $user->name()->toString(),
|
||||
'role' => $user->role()->title(),
|
||||
'username' => $user->username(),
|
||||
'uuid' => fn () => $user->uuid()?->toString()
|
||||
]
|
||||
]
|
||||
);
|
||||
|
|
114
kirby/src/Panel/UserTotpDisableDialog.php
Normal file
114
kirby/src/Panel/UserTotpDisableDialog.php
Normal file
|
@ -0,0 +1,114 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\Find;
|
||||
use Kirby\Cms\User;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Exception\PermissionException;
|
||||
use Kirby\Toolkit\Escape;
|
||||
use Kirby\Toolkit\I18n;
|
||||
|
||||
/**
|
||||
* Manages the Panel dialog to disable TOTP auth for a user
|
||||
* @since 4.0.0
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class UserTotpDisableDialog
|
||||
{
|
||||
public App $kirby;
|
||||
public User $user;
|
||||
|
||||
public function __construct(
|
||||
string|null $id = null
|
||||
) {
|
||||
$this->kirby = App::instance();
|
||||
$this->user = $id ? Find::user($id) : $this->kirby->user();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Panel dialog state when opening the dialog
|
||||
*/
|
||||
public function load(): array
|
||||
{
|
||||
$currentUser = $this->kirby->user();
|
||||
$submitBtn = [
|
||||
'text' => I18n::translate('disable'),
|
||||
'icon' => 'protected',
|
||||
'theme' => 'negative'
|
||||
];
|
||||
|
||||
// admins can disable TOTP for other users without
|
||||
// entering their password (but not for themselves)
|
||||
if (
|
||||
$currentUser->isAdmin() === true &&
|
||||
$currentUser->is($this->user) === false
|
||||
) {
|
||||
$name = $this->user->name()->or($this->user->email());
|
||||
|
||||
return [
|
||||
'component' => 'k-remove-dialog',
|
||||
'props' => [
|
||||
'text' => I18n::template('login.totp.disable.admin', ['user' => Escape::html($name)]),
|
||||
'submitButton' => $submitBtn,
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
// everybody else
|
||||
return [
|
||||
'component' => 'k-form-dialog',
|
||||
'props' => [
|
||||
'fields' => [
|
||||
'password' => [
|
||||
'type' => 'password',
|
||||
'required' => true,
|
||||
'counter' => false,
|
||||
'label' => I18n::translate('login.totp.disable.label'),
|
||||
'help' => I18n::translate('login.totp.disable.help'),
|
||||
]
|
||||
],
|
||||
'submitButton' => $submitBtn,
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the user's TOTP secret when the dialog is submitted
|
||||
*/
|
||||
public function submit(): array
|
||||
{
|
||||
$password = $this->kirby->request()->get('password');
|
||||
|
||||
try {
|
||||
if ($this->kirby->user()->is($this->user) === true) {
|
||||
$this->user->validatePassword($password);
|
||||
} elseif ($this->kirby->user()->isAdmin() === false) {
|
||||
throw new PermissionException('You are not allowed to disable TOTP for other users');
|
||||
}
|
||||
|
||||
// Remove the TOTP secret from the account
|
||||
$this->user->changeTotp(null);
|
||||
|
||||
return [
|
||||
'message' => I18n::translate('login.totp.disable.success')
|
||||
];
|
||||
} catch (InvalidArgumentException $e) {
|
||||
// Catch and re-throw exception so that any
|
||||
// Unauthenticated exception for incorrect passwords
|
||||
// does not trigger a logout
|
||||
throw new InvalidArgumentException([
|
||||
'key' => $e->getKey(),
|
||||
'data' => $e->getData(),
|
||||
'fallback' => $e->getMessage(),
|
||||
'previous' => $e
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
95
kirby/src/Panel/UserTotpEnableDialog.php
Normal file
95
kirby/src/Panel/UserTotpEnableDialog.php
Normal file
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\User;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Image\QrCode;
|
||||
use Kirby\Toolkit\I18n;
|
||||
use Kirby\Toolkit\Totp;
|
||||
|
||||
/**
|
||||
* Manages the Panel dialog to enable TOTP auth for the current user
|
||||
* @since 4.0.0
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class UserTotpEnableDialog
|
||||
{
|
||||
public App $kirby;
|
||||
public Totp $totp;
|
||||
public User $user;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->kirby = App::instance();
|
||||
$this->user = $this->kirby->user();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Panel dialog state when opening the dialog
|
||||
*/
|
||||
public function load(): array
|
||||
{
|
||||
return [
|
||||
'component' => 'k-totp-dialog',
|
||||
'props' => [
|
||||
'qr' => $this->qr()->toSvg(size: '100%'),
|
||||
'value' => ['secret' => $this->secret()]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a QR code with a new TOTP secret for the user
|
||||
*/
|
||||
public function qr(): QrCode
|
||||
{
|
||||
$issuer = $this->kirby->site()->title();
|
||||
$label = $this->user->email();
|
||||
$uri = $this->totp()->uri($issuer, $label);
|
||||
return new QrCode($uri);
|
||||
}
|
||||
|
||||
public function secret(): string
|
||||
{
|
||||
return $this->totp()->secret();
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the user's TOTP secret when the dialog is submitted
|
||||
*/
|
||||
public function submit(): array
|
||||
{
|
||||
$secret = $this->kirby->request()->get('secret');
|
||||
$confirm = $this->kirby->request()->get('confirm');
|
||||
|
||||
if ($confirm === null) {
|
||||
throw new InvalidArgumentException(
|
||||
['key' => 'login.totp.confirm.missing']
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->totp($secret)->verify($confirm) === false) {
|
||||
throw new InvalidArgumentException(
|
||||
['key' => 'login.totp.confirm.invalid']
|
||||
);
|
||||
}
|
||||
|
||||
$this->user->changeTotp($secret);
|
||||
|
||||
return [
|
||||
'message' => I18n::translate('login.totp.enable.success')
|
||||
];
|
||||
}
|
||||
|
||||
public function totp(string|null $secret = null): Totp
|
||||
{
|
||||
return $this->totp ??= new Totp($secret);
|
||||
}
|
||||
}
|
|
@ -2,12 +2,10 @@
|
|||
|
||||
namespace Kirby\Panel;
|
||||
|
||||
use Closure;
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Exception\Exception;
|
||||
use Kirby\Http\Response;
|
||||
use Kirby\Toolkit\A;
|
||||
use Kirby\Toolkit\I18n;
|
||||
use Kirby\Toolkit\Str;
|
||||
use Throwable;
|
||||
|
||||
|
@ -40,7 +38,9 @@ class View
|
|||
return static::applyOnly($data, $only);
|
||||
}
|
||||
|
||||
$globals = $request->header('X-Fiber-Globals') ?? $request->get('_globals');
|
||||
$globals =
|
||||
$request->header('X-Fiber-Globals') ??
|
||||
$request->get('_globals');
|
||||
|
||||
if (empty($globals) === false) {
|
||||
return static::applyGlobals($data, $globals);
|
||||
|
@ -56,8 +56,10 @@ class View
|
|||
* A global request can be activated with the `X-Fiber-Globals` header or the
|
||||
* `_globals` query parameter.
|
||||
*/
|
||||
public static function applyGlobals(array $data, string|null $globals = null): array
|
||||
{
|
||||
public static function applyGlobals(
|
||||
array $data,
|
||||
string|null $globals = null
|
||||
): array {
|
||||
// split globals string into an array of fields
|
||||
$globalKeys = Str::split($globals, ',');
|
||||
|
||||
|
@ -86,8 +88,10 @@ class View
|
|||
* Such requests can fetch shared data or globals.
|
||||
* Globals will be loaded on demand.
|
||||
*/
|
||||
public static function applyOnly(array $data, string|null $only = null): array
|
||||
{
|
||||
public static function applyOnly(
|
||||
array $data,
|
||||
string|null $only = null
|
||||
): array {
|
||||
// split include string into an array of fields
|
||||
$onlyKeys = Str::split($only, ',');
|
||||
|
||||
|
@ -115,9 +119,7 @@ class View
|
|||
}
|
||||
|
||||
// Nest dotted keys in array but ignore $translation
|
||||
return A::nest($result, [
|
||||
'$translation'
|
||||
]);
|
||||
return A::nest($result, ['$translation']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -146,14 +148,18 @@ class View
|
|||
return [
|
||||
'$direction' => function () use ($kirby, $multilang, $language, $user) {
|
||||
if ($multilang === true && $language && $user) {
|
||||
$isDefault = $language->direction() === $kirby->defaultLanguage()->direction();
|
||||
$isFromUser = $language->code() === $user->language();
|
||||
$default = $kirby->defaultLanguage();
|
||||
|
||||
if ($isDefault === false && $isFromUser === false) {
|
||||
if (
|
||||
$language->direction() !== $default->direction() &&
|
||||
$language->code() !== $user->language()
|
||||
) {
|
||||
return $language->direction();
|
||||
}
|
||||
}
|
||||
},
|
||||
'$dialog' => null,
|
||||
'$drawer' => null,
|
||||
'$language' => function () use ($kirby, $multilang, $language) {
|
||||
if ($multilang === true && $language) {
|
||||
return [
|
||||
|
@ -178,15 +184,20 @@ class View
|
|||
|
||||
return [];
|
||||
},
|
||||
'$menu' => function () use ($options, $permissions) {
|
||||
return static::menu($options['areas'] ?? [], $permissions, $options['area']['id'] ?? null);
|
||||
'$menu' => function () use ($options, $permissions) {
|
||||
$menu = new Menu(
|
||||
$options['areas'] ?? [],
|
||||
$permissions,
|
||||
$options['area']['id'] ?? null
|
||||
);
|
||||
return $menu->entries();
|
||||
},
|
||||
'$permissions' => $permissions,
|
||||
'$license' => (bool)$kirby->system()->license(),
|
||||
'$multilang' => $multilang,
|
||||
'$searches' => static::searches($options['areas'] ?? [], $permissions),
|
||||
'$url' => $kirby->request()->url()->toString(),
|
||||
'$user' => function () use ($user) {
|
||||
'$license' => $kirby->system()->license()->status()->value(),
|
||||
'$multilang' => $multilang,
|
||||
'$searches' => static::searches($options['areas'] ?? [], $permissions),
|
||||
'$url' => $kirby->request()->url()->toString(),
|
||||
'$user' => function () use ($user) {
|
||||
if ($user) {
|
||||
return [
|
||||
'email' => $user->email(),
|
||||
|
@ -204,17 +215,25 @@ class View
|
|||
'breadcrumb' => [],
|
||||
'code' => 200,
|
||||
'path' => Str::after($kirby->path(), '/'),
|
||||
'timestamp' => (int)(microtime(true) * 1000),
|
||||
'props' => [],
|
||||
'search' => $kirby->option('panel.search.type', 'pages')
|
||||
'query' => App::instance()->request()->query()->toArray(),
|
||||
'referrer' => Panel::referrer(),
|
||||
'search' => $kirby->option('panel.search.type', 'pages'),
|
||||
'timestamp' => (int)(microtime(true) * 1000),
|
||||
];
|
||||
|
||||
$view = array_replace_recursive($defaults, $options['area'] ?? [], $view);
|
||||
$view = array_replace_recursive(
|
||||
$defaults,
|
||||
$options['area'] ?? [],
|
||||
$view
|
||||
);
|
||||
|
||||
// make sure that views and dialogs are gone
|
||||
unset(
|
||||
$view['dialogs'],
|
||||
$view['drawers'],
|
||||
$view['dropdowns'],
|
||||
$view['requests'],
|
||||
$view['searches'],
|
||||
$view['views']
|
||||
);
|
||||
|
@ -255,17 +274,14 @@ class View
|
|||
$kirby = App::instance();
|
||||
|
||||
return [
|
||||
'$config' => function () use ($kirby) {
|
||||
return [
|
||||
'debug' => $kirby->option('debug', false),
|
||||
'kirbytext' => $kirby->option('panel.kirbytext', true),
|
||||
'search' => [
|
||||
'limit' => $kirby->option('panel.search.limit', 10),
|
||||
'type' => $kirby->option('panel.search.type', 'pages')
|
||||
],
|
||||
'translation' => $kirby->option('panel.language', 'en'),
|
||||
];
|
||||
},
|
||||
'$config' => fn () => [
|
||||
'api' => [
|
||||
'methodOverwrite' => $kirby->option('api.methodOverwrite', true)
|
||||
],
|
||||
'debug' => $kirby->option('debug', false),
|
||||
'kirbytext' => $kirby->option('panel.kirbytext', true),
|
||||
'translation' => $kirby->option('panel.language', 'en'),
|
||||
],
|
||||
'$system' => function () use ($kirby) {
|
||||
$locales = [];
|
||||
|
||||
|
@ -303,67 +319,6 @@ class View
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the menu for the topbar
|
||||
*/
|
||||
public static function menu(array|null $areas = [], array|null $permissions = [], string|null $current = null): array
|
||||
{
|
||||
$menu = [];
|
||||
|
||||
// areas
|
||||
foreach ($areas as $areaId => $area) {
|
||||
$access = $permissions['access'][$areaId] ?? true;
|
||||
|
||||
// areas without access permissions get skipped entirely
|
||||
if ($access === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// fetch custom menu settings from the area definition
|
||||
$menuSetting = $area['menu'] ?? false;
|
||||
|
||||
// menu settings can be a callback that can return true, false or disabled
|
||||
if ($menuSetting instanceof Closure) {
|
||||
$menuSetting = $menuSetting($areas, $permissions, $current);
|
||||
}
|
||||
|
||||
// false will remove the area entirely just like with
|
||||
// disabled permissions
|
||||
if ($menuSetting === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$menu[] = [
|
||||
'current' => $areaId === $current,
|
||||
'disabled' => $menuSetting === 'disabled',
|
||||
'icon' => $area['icon'],
|
||||
'id' => $areaId,
|
||||
'link' => $area['link'],
|
||||
'text' => $area['label'],
|
||||
];
|
||||
}
|
||||
|
||||
$menu[] = '-';
|
||||
$menu[] = [
|
||||
'current' => $current === 'account',
|
||||
'icon' => 'account',
|
||||
'id' => 'account',
|
||||
'link' => 'account',
|
||||
'disabled' => ($permissions['access']['account'] ?? false) === false,
|
||||
'text' => I18n::translate('view.account'),
|
||||
];
|
||||
$menu[] = '-';
|
||||
|
||||
// logout
|
||||
$menu[] = [
|
||||
'icon' => 'logout',
|
||||
'id' => 'logout',
|
||||
'link' => 'logout',
|
||||
'text' => I18n::translate('logout')
|
||||
];
|
||||
return $menu;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the main panel view either as
|
||||
* JSON response or full HTML document based
|
||||
|
@ -375,15 +330,15 @@ class View
|
|||
if ($data instanceof Redirect) {
|
||||
return Response::redirect($data->location(), $data->code());
|
||||
|
||||
// handle Kirby exceptions
|
||||
// handle Kirby exceptions
|
||||
} elseif ($data instanceof Exception) {
|
||||
$data = static::error($data->getMessage(), $data->getHttpCode());
|
||||
|
||||
// handle regular exceptions
|
||||
// handle regular exceptions
|
||||
} elseif ($data instanceof Throwable) {
|
||||
$data = static::error($data->getMessage(), 500);
|
||||
|
||||
// only expect arrays from here on
|
||||
// only expect arrays from here on
|
||||
} elseif (is_array($data) === false) {
|
||||
$data = static::error('Invalid Panel response', 500);
|
||||
}
|
||||
|
@ -414,13 +369,17 @@ class View
|
|||
{
|
||||
$searches = [];
|
||||
|
||||
foreach ($areas as $area) {
|
||||
foreach ($area['searches'] ?? [] as $id => $params) {
|
||||
$searches[$id] = [
|
||||
'icon' => $params['icon'] ?? 'search',
|
||||
'label' => $params['label'] ?? Str::ucfirst($id),
|
||||
'id' => $id
|
||||
];
|
||||
foreach ($areas as $areaId => $area) {
|
||||
// by default, all areas are accessible unless
|
||||
// the permissions are explicitly set to false
|
||||
if (($permissions['access'][$areaId] ?? true) !== false) {
|
||||
foreach ($area['searches'] ?? [] as $id => $params) {
|
||||
$searches[$id] = [
|
||||
'icon' => $params['icon'] ?? 'search',
|
||||
'label' => $params['label'] ?? Str::ucfirst($id),
|
||||
'id' => $id
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
return $searches;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue