1
0

downgrade to kirby v3

This commit is contained in:
Philip Wagner
2024-09-01 10:47:15 +02:00
parent a4b2aece7b
commit af86acb7a1
1085 changed files with 54743 additions and 65042 deletions

View File

@@ -1,324 +0,0 @@
<?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->url . '/js/vue.min.js'
],
'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->url . '/node_modules/vue/dist/vue.js';
// 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(), '/');
}
}

View File

@@ -1,71 +0,0 @@
<?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));
}
}

View File

@@ -2,12 +2,10 @@
namespace Kirby\Panel;
use Kirby\Http\Response;
/**
* The Dialog response class handles Fiber
* requests to render the JSON object for
* Panel dialogs and creates the routes
* Panel dialogs
* @since 3.6.0
*
* @package Kirby Panel
@@ -18,12 +16,16 @@ use Kirby\Http\Response;
*/
class Dialog extends Json
{
protected static string $key = '$dialog';
protected static $key = '$dialog';
/**
* Renders dialogs
*
* @param mixed $data
* @param array $options
* @return \Kirby\Http\Response
*/
public static function response($data, array $options = []): Response
public static function response($data, array $options = [])
{
// interpret true as success
if ($data === true) {
@@ -34,39 +36,4 @@ 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;
}
}

View File

@@ -3,6 +3,12 @@
namespace Kirby\Panel;
use Kirby\Cms\App;
use Kirby\Cms\Helpers;
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;
@@ -23,17 +29,254 @@ use Throwable;
class Document
{
/**
* Renders the panel document
* Generates an array with all assets
* that need to be loaded for the panel (js, css, icons)
*
* @return array
*/
public static function response(array $fiber): Response
public static function assets(): array
{
$kirby = App::instance();
$assets = new Assets();
$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' => [
'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'
];
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
* @return string|null
*/
public static function customAsset(string $option): ?string
{
if ($path = App::instance()->option($option)) {
$asset = new Asset($path);
if ($asset->exists() === true) {
return $asset->url() . '?' . $asset->modified();
}
}
return null;
}
/**
* @deprecated 3.7.0 Use `Document::customAsset('panel.css)` instead
* @todo remove in 3.8.0
* @codeCoverageIgnore
*/
public static function customCss(): ?string
{
Helpers::deprecated('Panel\Document::customCss() has been deprecated and will be removed in Kirby 3.8.0. Use Panel\Document::customAsset(\'panel.css\') instead.');
return static::customAsset('panel.css');
}
/**
* @deprecated 3.7.0 Use `Document::customAsset('panel.js)` instead
* @todo remove in 3.8.0
* @codeCoverageIgnore
*/
public static function customJs(): ?string
{
Helpers::deprecated('Panel\Document::customJs() has been deprecated and will be removed in Kirby 3.8.0. Use Panel\Document::customAsset(\'panel.js\') instead.');
return static::customAsset('panel.js');
}
/**
* Returns array of favion icons
* based on config option
* @since 3.7.0
*
* @param string $url URL prefix for default icons
* @return array
*/
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',
],
'shortcut icon' => [
'type' => 'image/svg+xml',
'url' => $url . '/favicon.svg',
],
'alternate icon' => [
'type' => 'image/png',
'url' => $url . '/favicon.png',
]
]);
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
*
* @return string
*/
public static function icons(): string
{
return F::read(App::instance()->root('kirby') . '/panel/dist/img/icons.svg');
}
/**
* Links all dist files in the media folder
* and returns the link to the requested asset
*
* @return bool
* @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
*
* @param array $fiber
* @return \Kirby\Http\Response
*/
public static function response(array $fiber)
{
$kirby = App::instance();
// Full HTML response
// @codeCoverageIgnoreStart
try {
if ($assets->link() === true) {
if (static::link() === true) {
usleep(1);
Response::go($kirby->url('base') . '/' . $kirby->path());
}
@@ -43,27 +286,30 @@ class Document
// @codeCoverageIgnoreEnd
// get the uri object for the panel url
$uri = new Uri($kirby->url('panel'));
$uri = new Uri($url = $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' => $assets->external(),
'icons' => $assets->icons(),
'assets' => static::assets(),
'icons' => static::icons(),
'nonce' => $kirby->nonce(),
'fiber' => $fiber,
'panelUrl' => $uri->path()->toString(true) . '/',
]);
$frameAncestors = $kirby->option('panel.frameAncestors');
$frameAncestors = match (true) {
$frameAncestors === true => "'self'",
is_array($frameAncestors) => "'self' " . implode(' ', $frameAncestors),
is_string($frameAncestors) => $frameAncestors,
default => "'none'"
};
$frameAncestorsOption = $kirby->option('panel.frameAncestors');
if ($frameAncestorsOption === true) {
$frameAncestors = "'self'";
} elseif (is_array($frameAncestorsOption)) {
$frameAncestors = "'self' " . implode(' ', $frameAncestorsOption);
} elseif (is_string($frameAncestorsOption)) {
$frameAncestors = $frameAncestorsOption;
} else {
$frameAncestors = "'none'";
}
return new Response($body, 'text/html', $code, [
'Content-Security-Policy' => 'frame-ancestors ' . $frameAncestors

View File

@@ -1,21 +0,0 @@
<?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';
}

View File

@@ -2,8 +2,12 @@
namespace Kirby\Panel;
use Closure;
use Kirby\Http\Response;
use Kirby\Cms\App;
use Kirby\Cms\Find;
use Kirby\Exception\LogicException;
use Kirby\Http\Uri;
use Kirby\Toolkit\Str;
use Throwable;
/**
* The Dropdown response class handles Fiber
@@ -19,12 +23,61 @@ use Kirby\Http\Response;
*/
class Dropdown extends Json
{
protected static string $key = '$dropdown';
protected static $key = '$dropdown';
/**
* Returns the options for the changes dropdown
*
* @return array
*/
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 $e) {
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
*
* @param mixed $data
* @param array $options
* @return \Kirby\Http\Response
*/
public static function response($data, array $options = []): Response
public static function response($data, array $options = [])
{
if (is_array($data) === true) {
$data = [
@@ -34,38 +87,4 @@ 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']
]
];
}
}

View File

@@ -4,12 +4,8 @@ namespace Kirby\Panel;
use Kirby\Cms\App;
use Kirby\Cms\File;
use Kirby\Cms\ModelWithContent;
use Kirby\Cms\Page;
use Kirby\Form\Form;
use Kirby\Http\Router;
use Kirby\Toolkit\I18n;
use Kirby\Toolkit\Str;
/**
* Provides common field prop definitions
@@ -24,60 +20,11 @@ use Kirby\Toolkit\Str;
*/
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
*
* @param array $props
* @return array
*/
public static function email(array $props = []): array
{
@@ -90,6 +37,10 @@ class Field
/**
* File position
*
* @param \Kirby\Cms\File
* @param array $props
* @return array
*/
public static function filePosition(File $file, array $props = []): array
{
@@ -127,13 +78,20 @@ class Field
}
/**
* @return array
*/
public static function hidden(): array
{
return ['hidden' => true];
return ['type' => 'hidden'];
}
/**
* Page position
*
* @param \Kirby\Cms\Page
* @param array $props
* @return array
*/
public static function pagePosition(Page $page, array $props = []): array
{
@@ -179,6 +137,9 @@ class Field
/**
* A regular password field
*
* @param array $props
* @return array
*/
public static function password(array $props = []): array
{
@@ -190,11 +151,15 @@ class Field
/**
* User role radio buttons
*
* @param array $props
* @return array
*/
public static function role(array $props = []): array
{
$kirby = App::instance();
$isAdmin = $kirby->user()?->isAdmin() ?? false;
$user = $kirby->user();
$isAdmin = $user && $user->isAdmin();
$roles = [];
foreach ($kirby->roles() as $role) {
@@ -218,19 +183,25 @@ class Field
], $props);
}
/**
* @param array $props
* @return array
*/
public static function slug(array $props = []): array
{
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 {
/**
* @param array $blueprints
* @param array $props
* @return array
*/
public static function template(?array $blueprints = [], ?array $props = []): array
{
$options = [];
foreach ($blueprints as $blueprint) {
@@ -250,6 +221,10 @@ class Field
], $props);
}
/**
* @param array $props
* @return array
*/
public static function title(array $props = []): array
{
return array_merge([
@@ -261,6 +236,9 @@ class Field
/**
* Panel translation select box
*
* @param array $props
* @return array
*/
public static function translation(array $props = []): array
{
@@ -275,12 +253,16 @@ class Field
return array_merge([
'label' => I18n::translate('language'),
'type' => 'select',
'icon' => 'translate',
'icon' => 'globe',
'options' => $translations,
'empty' => false
], $props);
}
/**
* @param array $props
* @return array
*/
public static function username(array $props = []): array
{
return array_merge([

View File

@@ -2,9 +2,6 @@
namespace Kirby\Panel;
use Kirby\Cms\File as CmsFile;
use Kirby\Cms\ModelWithContent;
use Kirby\Filesystem\Asset;
use Kirby\Toolkit\I18n;
use Throwable;
@@ -23,10 +20,12 @@ class File extends Model
/**
* @var \Kirby\Cms\File
*/
protected ModelWithContent $model;
protected $model;
/**
* Breadcrumb array
*
* @return array
*/
public function breadcrumb(): array
{
@@ -35,7 +34,6 @@ class File extends Model
switch ($parent::CLASS_ALIAS) {
case 'user':
/** @var \Kirby\Cms\User $parent */
// The breadcrumb is not necessary
// on the account view
if ($parent->isLoggedIn() === false) {
@@ -46,13 +44,10 @@ 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
@@ -72,54 +67,47 @@ class File extends Model
*
* @internal
* @param string|null $type (`auto`|`kirbytext`|`markdown`)
* @param bool $absolute
* @return string
*/
public function dragText(
string|null $type = null,
bool $absolute = false
): string {
public function dragText(string $type = null, bool $absolute = false): string
{
$type = $this->dragTextType($type);
$url = $this->model->filename();
$file = $this->model->type();
$url = $absolute ? $this->model->id() : $this->model->filename();
// By default only the filename is added as relative URL.
// If an absolute URL is required, either use the permalink
// for markdown notation or the UUID for Kirbytext (since
// Kirbytags support can resolve UUIDs directly)
if ($absolute === true) {
$url = match ($type) {
'markdown' => $this->model->permalink(),
default => $this->model->uuid()
};
// if UUIDs are disabled, fall back to URL
$url ??= $this->model->url();
}
if ($callback = $this->dragTextFromCallback($type, $url)) {
return $callback;
if ($dragTextFromCallback = $this->dragTextFromCallback($type, $url)) {
return $dragTextFromCallback;
}
if ($type === 'markdown') {
return match ($file) {
'image' => '![' . $this->model->alt() . '](' . $url . ')',
default => '[' . $this->model->filename() . '](' . $url . ')'
};
if ($this->model->type() === 'image') {
return '![' . $this->model->alt() . '](' . $url . ')';
}
return '[' . $this->model->filename() . '](' . $url . ')';
}
return match ($file) {
'image', 'video' => '(' . $file . ': ' . $url . ')',
default => '(file: ' . $url . ')'
};
if ($this->model->type() === 'image') {
return '(image: ' . $url . ')';
}
if ($this->model->type() === 'video') {
return '(video: ' . $url . ')';
}
return '(file: ' . $url . ')';
}
/**
* Provides options for the file dropdown
*
* @param array $options
* @return array
*/
public function dropdown(array $options = []): array
{
$file = $this->model;
$request = $file->kirby()->request();
$defaults = $request->get(['view', 'update', 'delete']);
$file = $this->model;
$defaults = $file->kirby()->request()->get(['view', 'update', 'delete']);
$options = array_merge($defaults, $options);
$permissions = $this->options(['preview']);
@@ -144,7 +132,15 @@ 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',
@@ -153,22 +149,6 @@ 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',
@@ -183,7 +163,9 @@ class File extends Model
/**
* Returns the setup for a dropdown option
* which is used in the changes dropdown
* for example
* for example.
*
* @return array
*/
public function dropdownOption(): array
{
@@ -195,36 +177,39 @@ class File extends Model
/**
* Returns the Panel icon color
*
* @return string
*/
protected function imageColor(): string
{
$types = [
'archive' => 'gray-500',
'audio' => 'aqua-500',
'code' => 'pink-500',
'document' => 'red-500',
'image' => 'orange-500',
'video' => 'yellow-500',
'image' => 'orange-400',
'video' => 'yellow-400',
'document' => 'red-400',
'audio' => 'aqua-400',
'code' => 'blue-400',
'archive' => 'gray-500'
];
$extensions = [
'csv' => 'green-500',
'doc' => 'blue-500',
'docx' => 'blue-500',
'indd' => 'purple-500',
'rtf' => 'blue-500',
'xls' => 'green-500',
'xlsx' => 'green-500',
'indd' => 'purple-400',
'xls' => 'green-400',
'xlsx' => 'green-400',
'csv' => 'green-400',
'docx' => 'blue-400',
'doc' => 'blue-400',
'rtf' => 'blue-400'
];
return
$extensions[$this->model->extension()] ??
$types[$this->model->type()] ??
parent::imageDefaults()['color'];
return $extensions[$this->model->extension()] ??
$types[$this->model->type()] ??
parent::imageDefaults()['color'];
}
/**
* Default settings for the file's Panel image
*
* @return array
*/
protected function imageDefaults(): array
{
@@ -236,42 +221,45 @@ class File extends Model
/**
* Returns the Panel icon type
*
* @return string
*/
protected function imageIcon(): string
{
$types = [
'archive' => 'archive',
'audio' => 'audio',
'code' => 'code',
'document' => 'document',
'image' => 'image',
'video' => 'video',
'document' => 'document',
'audio' => 'audio',
'code' => 'code',
'archive' => 'archive'
];
$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
$extensions[$this->model->extension()] ??
$types[$this->model->type()] ??
'file';
return $extensions[$this->model->extension()] ??
$types[$this->model->type()] ??
'file';
}
/**
* Returns the image file object based on provided query
*
* @internal
* @param string|null $query
* @return \Kirby\Cms\File|\Kirby\Filesystem\Asset|null
*/
protected function imageSource(
string|null $query = null
): CmsFile|Asset|null {
protected function imageSource(string $query = null)
{
if ($query === null && $this->model->isViewable()) {
return $this->model;
}
@@ -279,45 +267,12 @@ 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
*
* @param array $unlock An array of options that will be force-unlocked
* @return array
*/
public function options(array $unlock = []): array
{
@@ -327,7 +282,7 @@ class File extends Model
// check if the file type is allowed at all,
// otherwise it cannot be replaced
$this->model->match($this->model->blueprint()->accept());
} catch (Throwable) {
} catch (Throwable $e) {
$options['replace'] = false;
}
@@ -336,6 +291,8 @@ class File extends Model
/**
* Returns the full path without leading slash
*
* @return string
*/
public function path(): string
{
@@ -345,41 +302,51 @@ class File extends Model
/**
* Prepares the response data for file pickers
* and file fields
*
* @param array|null $params
* @return array
*/
public function pickerData(array $params = []): array
{
$name = $this->model->filename();
$id = $this->model->id();
$name = $this->model->filename();
if (empty($params['model']) === false) {
$parent = $this->model->parent();
// 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;
$parent = $this->model->parent();
$uuid = $parent === $params['model'] ? $name : $id;
$absolute = $parent !== $params['model'];
}
$params['text'] ??= '{{ file.filename }}';
return array_merge(parent::pickerData($params), [
'dragText' => $this->dragText('auto', $absolute ?? false),
'filename' => $name,
'id' => $id,
'dragText' => $this->dragText('auto', $absolute ?? false),
'type' => $this->model->type(),
'url' => $this->model->url()
'url' => $this->model->url(),
'uuid' => $uuid ?? $id,
]);
}
/**
* Returns the data array for the
* view's component props
*
* @internal
*
* @return array
*/
public function props(): array
{
$file = $this->model;
$dimensions = $file->dimensions();
$siblings = $file->templateSiblings()->sortBy(
'sort',
'asc',
'filename',
'asc'
);
return array_merge(
parent::props(),
@@ -401,13 +368,12 @@ class File extends Model
'url' => $file->url(),
],
'preview' => [
'focusable' => $this->isFocusable(),
'image' => $this->image([
'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() ?? '—'
@@ -442,7 +408,10 @@ class File extends Model
/**
* Returns navigation array with
* previous and next file
*
* @internal
*
* @return array
*/
public function prevNext(): array
{
@@ -455,11 +424,11 @@ class File extends Model
);
return [
'next' => function () use ($file, $siblings): array|null {
'next' => function () use ($file, $siblings): ?array {
$next = $siblings->nth($siblings->indexOf($file) + 1);
return $this->toPrevNextLink($next, 'filename');
},
'prev' => function () use ($file, $siblings): array|null {
'prev' => function () use ($file, $siblings): ?array {
$prev = $siblings->nth($siblings->indexOf($file) - 1);
return $this->toPrevNextLink($prev, 'filename');
}
@@ -468,6 +437,9 @@ class File extends Model
/**
* Returns the url to the editing view
* in the panel
*
* @param bool $relative
* @return string
*/
public function url(bool $relative = false): string
{
@@ -478,16 +450,21 @@ class File extends Model
/**
* Returns the data array for
* this model's Panel view
*
* @internal
*
* @return array
*/
public function view(): array
{
$file = $this->model;
return [
'breadcrumb' => fn (): array => $this->model->panel()->breadcrumb(),
'breadcrumb' => fn (): array => $file->panel()->breadcrumb(),
'component' => 'k-file-view',
'props' => $this->props(),
'search' => 'files',
'title' => $this->model->filename(),
'title' => $file->filename(),
];
}
}

View File

@@ -38,6 +38,9 @@ class Home
* It will go through the entire menu and
* take the first area which is not disabled
* or locked in other ways
*
* @param \Kirby\Cms\User $user
* @return string
*/
public static function alternative(User $user): string
{
@@ -50,8 +53,7 @@ class Home
// needed to create a proper menu
$areas = Panel::areas();
$menu = new Menu($areas, $permissions->toArray());
$menu = $menu->entries();
$menu = View::menu($areas, $permissions->toArray());
// go through the menu and search for the first
// available view we can go to
@@ -66,16 +68,11 @@ class Home
continue;
}
// skip buttons that don't open a link
// (but e.g. a dialog)
if (isset($menuItem['link']) === false) {
// skip the logout button
if ($menuItem['id'] === 'logout') {
continue;
}
// skip the logout button
if ($menuItem['link'] === 'logout') {
continue;
}
return Panel::url($menuItem['link']);
}
@@ -88,6 +85,10 @@ class Home
* panel path. This is quite tricky, because we
* need to call a trimmed down router to check
* for available routes and their firewall status.
*
* @param \Kirby\Cms\User
* @param string $path
* @return bool
*/
public static function hasAccess(User $user, string $path): bool
{
@@ -106,10 +107,9 @@ 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) {
$attrs = $route->attributes();
$auth = $attrs['auth'] ?? true;
$areaId = $attrs['area'] ?? null;
$type = $attrs['type'] ?? 'view';
$auth = $route->attributes()['auth'] ?? true;
$areaId = $route->attributes()['area'] ?? null;
$type = $route->attributes()['type'] ?? 'view';
// only allow redirects to views
if ($type !== 'view') {
@@ -124,7 +124,7 @@ class Home
// check the firewall
return Panel::hasAccess($user, $areaId);
});
} catch (Throwable) {
} catch (Throwable $e) {
return false;
}
}
@@ -134,28 +134,35 @@ class Home
* as the index URL of the Kirby installation.
* This is used to block external URLs to third-party
* domains as redirect options.
*
* @param \Kirby\Http\Uri $uri
* @return bool
*/
public static function hasValidDomain(Uri $uri): bool
{
$rootUrl = App::instance()->site()->url();
$rootUri = new Uri($rootUrl);
return $uri->domain() === $rootUri->domain();
return $uri->domain() === (new Uri($rootUrl))->domain();
}
/**
* Checks if the given URL is a Panel Url
* Checks if the given URL is a Panel Url.
*
* @param string $url
* @return bool
*/
public static function isPanelUrl(string $url): bool
{
$panel = App::instance()->url('panel');
return Str::startsWith($url, $panel);
return Str::startsWith($url, App::instance()->url('panel'));
}
/**
* Returns the path after /panel/ which can then
* be used in the router or to find a matching view
*
* @param string $url
* @return string|null
*/
public static function panelPath(string $url): string|null
public static function panelPath(string $url): ?string
{
$after = Str::after($url, App::instance()->url('panel'));
return trim($after, '/');
@@ -166,16 +173,16 @@ class Home
* before the last logout. We take this Url if possible
* to redirect the user back to the last point where they
* left before they got logged out.
*
* @return string|null
*/
public static function remembered(): string|null
public static function remembered(): ?string
{
// check for a stored path after login
if ($remembered = App::instance()->session()->pull('panel.path')) {
// convert the result to an absolute URL if available
return Panel::url($remembered);
}
$remembered = App::instance()->session()->pull('panel.path');
return null;
// convert the result to an absolute URL if available
return $remembered ? Panel::url($remembered) : null;
}
/**
@@ -199,6 +206,8 @@ class Home
* Afterwards, we also check for permissions before the redirect happens
* to avoid redirects to inaccessible Panel views. In such a case
* the next best accessible view is picked from the menu.
*
* @return string
*/
public static function url(): string
{

View File

@@ -2,11 +2,6 @@
namespace Kirby\Panel;
use Kirby\Cms\App;
use Kirby\Exception\Exception;
use Kirby\Http\Response;
use Throwable;
/**
* The Json abstract response class provides
* common framework for Fiber requests
@@ -22,12 +17,16 @@ use Throwable;
*/
abstract class Json
{
protected static string $key = '$response';
protected static $key = '$response';
/**
* Renders the error response with the provided message
*
* @param string $message
* @param int $code
* @return array
*/
public static function error(string $message, int $code = 404): array
public static function error(string $message, int $code = 404)
{
return [
'code' => $code,
@@ -37,48 +36,42 @@ abstract class Json
/**
* Prepares the JSON response for the Panel
*
* @param mixed $data
* @param array $options
* @return mixed
*/
public static function response($data, array $options = []): Response
public static function response($data, array $options = [])
{
$data = static::responseData($data);
// handle redirects
if (is_a($data, 'Kirby\Panel\Redirect') === true) {
$data = [
'redirect' => $data->location(),
'code' => $data->code()
];
// handle Kirby exceptions
} elseif (is_a($data, 'Kirby\Exception\Exception') === true) {
$data = static::error($data->getMessage(), $data->getHttpCode());
// handle exceptions
} elseif (is_a($data, 'Throwable') === true) {
$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);
}
// 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;
}
}

View File

@@ -1,134 +0,0 @@
<?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')
),
];
}
}

View File

@@ -1,340 +0,0 @@
<?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(),
];
}
}

View File

@@ -1,296 +0,0 @@
<?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;
}
}

View File

@@ -1,26 +0,0 @@
<?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';
}
}

View File

@@ -1,34 +0,0 @@
<?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;
}
}

View File

@@ -1,221 +0,0 @@
<?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;
}
}

View File

@@ -2,10 +2,6 @@
namespace Kirby\Panel;
use Closure;
use Kirby\Cms\File as CmsFile;
use Kirby\Cms\ModelWithContent;
use Kirby\Filesystem\Asset;
use Kirby\Form\Form;
use Kirby\Http\Uri;
use Kirby\Toolkit\A;
@@ -22,13 +18,23 @@ use Kirby\Toolkit\A;
*/
abstract class Model
{
public function __construct(
protected ModelWithContent $model
) {
/**
* @var \Kirby\Cms\ModelWithContent
*/
protected $model;
/**
* @param \Kirby\Cms\ModelWithContent $model
*/
public function __construct($model)
{
$this->model = $model;
}
/**
* Get the content values for the model
*
* @return array
*/
public function content(): array
{
@@ -41,14 +47,20 @@ abstract class Model
* @internal
*
* @param string $type markdown or kirbytext
* @param mixed ...$args
* @return string|null
*/
public function dragTextFromCallback(string $type, ...$args): string|null
public function dragTextFromCallback(string $type, ...$args): ?string
{
$option = 'panel.' . $type . '.' . $this->model::CLASS_ALIAS . 'DragText';
$callback = $this->model->kirby()->option($option);
if ($callback instanceof Closure) {
return $callback($this->model, ...$args);
if (
empty($callback) === false &&
is_a($callback, 'Closure') === true &&
($dragText = $callback($this->model, ...$args)) !== null
) {
return $dragText;
}
return null;
@@ -62,8 +74,9 @@ abstract class Model
* @internal
*
* @param string|null $type (`auto`|`kirbytext`|`markdown`)
* @return string
*/
public function dragTextType(string|null $type = null): string
public function dragTextType(string $type = null): string
{
$type ??= 'auto';
@@ -79,72 +92,98 @@ abstract class Model
* Returns the setup for a dropdown option
* which is used in the changes dropdown
* for example.
*
* @return array
*/
public function dropdownOption(): array
{
return [
'icon' => 'page',
'image' => $this->image(['back' => 'black']),
'link' => $this->url(true),
'text' => $this->model->id(),
'icon' => 'page',
'link' => $this->url(),
'text' => $this->model->id(),
];
}
/**
* Returns the Panel image definition
*
* @internal
*
* @param string|array|false|null $settings
* @return array|null
*/
public function image(
string|array|false|null $settings = [],
string $layout = 'list'
): array|null {
public function image($settings = [], string $layout = 'list'): ?array
{
// completely switched off
if ($settings === false) {
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;
}
// skip image thumbnail if option
// is explicitly set to show the icon
if ($settings === 'icon') {
$settings = ['query' => false];
$settings = [
'query' => false
];
} elseif (is_string($settings) === true) {
// convert string settings to proper array
$settings = ['query' => $settings];
$settings = [
'query' => $settings
];
}
// merge with defaults and blueprint option
$settings = array_merge(
$this->imageDefaults(),
$settings ?? [],
$blueprint ?? [],
$this->model->blueprint()->image() ?? [],
);
if ($image = $this->imageSource($settings['query'] ?? null)) {
// main url
$settings['url'] = $image->url();
// only create srcsets for resizable files
if ($image->isResizable() === true) {
// only create srcsets for resizable files
$settings['src'] = static::imagePlaceholder();
$settings['srcset'] = $this->imageSrcset($image, $layout, $settings);
$settings['src'] = static::imagePlaceholder();
switch ($layout) {
case 'cards':
$sizes = [352, 864, 1408];
break;
case 'cardlets':
$sizes = [96, 192];
break;
case 'list':
default:
$sizes = [38, 76];
break;
}
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'
]
]);
}
} elseif ($image->isViewable() === true) {
$settings['src'] = $image->url();
}
}
unset($settings['query']);
if (isset($settings['query']) === true) {
unset($settings['query']);
}
// resolve remaining options defined as query
return A::map($settings, function ($option) {
@@ -158,6 +197,8 @@ abstract class Model
/**
* Default settings for Panel image
*
* @return array
*/
protected function imageDefaults(): array
{
@@ -165,13 +206,17 @@ abstract class Model
'back' => 'pattern',
'color' => 'gray-500',
'cover' => false,
'icon' => 'page'
'icon' => 'page',
'ratio' => '3/2',
];
}
/**
* Data URI placeholder string for Panel image
*
* @internal
*
* @return string
*/
public static function imagePlaceholder(): string
{
@@ -180,17 +225,20 @@ abstract class Model
/**
* Returns the image file object based on provided query
*
* @internal
*
* @param string|null $query
* @return \Kirby\Cms\File|\Kirby\Filesystem\Asset|null
*/
protected function imageSource(
string|null $query = null
): CmsFile|Asset|null {
protected function imageSource(?string $query = null)
{
$image = $this->model->query($query ?? null);
// validate the query result
if (
$image instanceof CmsFile ||
$image instanceof Asset
is_a($image, 'Kirby\Cms\File') === true ||
is_a($image, 'Kirby\Filesystem\Asset') === true
) {
return $image;
}
@@ -198,85 +246,19 @@ 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
*
* @param string $action
* @param array $options
* @param array $permissions
* @return bool
*/
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';
}
/**
@@ -285,9 +267,24 @@ abstract class Model
* @return array|false array with lock info,
* false if locking is not supported
*/
public function lock(): array|false
public function lock()
{
return $this->model->lock()?->toArray() ?? false;
if ($lock = $this->model->lock()) {
if ($lock->isUnlocked() === true) {
return ['state' => 'unlock'];
}
if ($lock->isLocked() === true) {
return [
'state' => 'lock',
'data' => $lock->get()
];
}
return ['state' => null];
}
return false;
}
/**
@@ -296,6 +293,7 @@ abstract class Model
* This also checks for the lock status
*
* @param array $unlock An array of options that will be force-unlocked
* @return array
*/
public function options(array $unlock = []): array
{
@@ -316,12 +314,17 @@ abstract class Model
/**
* Returns the full path without leading slash
*
* @return string
*/
abstract public function path(): string;
/**
* Prepares the response data for page pickers
* and page fields
*
* @param array|null $params
* @return array
*/
public function pickerData(array $params = []): array
{
@@ -335,14 +338,16 @@ abstract class Model
'link' => $this->url(true),
'sortable' => true,
'text' => $this->model->toSafeString($params['text'] ?? false),
'uuid' => $this->model->uuid()?->toString() ?? $this->model->id(),
];
}
/**
* Returns the data array for the
* view's component props
*
* @internal
*
* @return array
*/
public function props(): array
{
@@ -368,34 +373,41 @@ abstract class Model
}
/**
* Returns link url and title
* for model (e.g. used for prev/next navigation)
* Returns link url and tooltip
* for model (e.g. used for prev/next
* navigation)
*
* @internal
*
* @param string $tooltip
* @return array
*/
public function toLink(string $title = 'title'): array
public function toLink(string $tooltip = 'title'): array
{
return [
'link' => $this->url(true),
'title' => $title = (string)$this->model->{$title}()
'tooltip' => (string)$this->model->{$tooltip}()
];
}
/**
* Returns link url and title
* Returns link url and tooltip
* for optional sibling model and
* preserves tab selection
*
* @internal
*
* @param \Kirby\Cms\ModelWithContent|null $model
* @param string $tooltip
* @return array
*/
protected function toPrevNextLink(
ModelWithContent|null $model = null,
string $title = 'title'
): array|null {
protected function toPrevNextLink($model = null, string $tooltip = 'title'): ?array
{
if ($model === null) {
return null;
}
$data = $model->panel()->toLink($title);
$data = $model->panel()->toLink($tooltip);
if ($tab = $model->kirby()->request()->get('tab')) {
$uri = new Uri($data['link'], [
@@ -413,6 +425,9 @@ abstract class Model
* in the Panel
*
* @internal
*
* @param bool $relative
* @return string
*/
public function url(bool $relative = false): string
{
@@ -428,6 +443,8 @@ abstract class Model
* this model's Panel view
*
* @internal
*
* @return array
*/
abstract public function view(): array;
}

View File

@@ -2,9 +2,6 @@
namespace Kirby\Panel;
use Kirby\Cms\File as CmsFile;
use Kirby\Cms\ModelWithContent;
use Kirby\Filesystem\Asset;
use Kirby\Toolkit\I18n;
/**
@@ -22,21 +19,20 @@ class Page extends Model
/**
* @var \Kirby\Cms\Page
*/
protected ModelWithContent $model;
protected $model;
/**
* Breadcrumb array
*
* @return 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),
]);
}
/**
@@ -47,8 +43,9 @@ class Page extends Model
*
* @internal
* @param string|null $type (`auto`|`kirbytext`|`markdown`)
* @return string
*/
public function dragText(string|null $type = null): string
public function dragText(string $type = null): string
{
$type = $this->dragTextType($type);
@@ -56,27 +53,24 @@ class Page extends Model
return $callback;
}
$title = $this->model->title();
// type: markdown
if ($type === 'markdown') {
$url = $this->model->permalink() ?? $this->model->url();
return '[' . $title . '](' . $url . ')';
return '[' . $this->model->title() . '](' . $this->model->url() . ')';
}
// type: kirbytext
$link = $this->model->uuid() ?? $this->model->uri();
return '(link: ' . $link . ' text: ' . $title . ')';
return '(link: ' . $this->model->id() . ' text: ' . $this->model->title() . ')';
}
/**
* Provides options for the page dropdown
*
* @param array $options
* @return array
*/
public function dropdown(array $options = []): array
{
$page = $this->model;
$request = $page->kirby()->request();
$defaults = $request->get(['view', 'sort', 'delete']);
$page = $this->model;
$defaults = $page->kirby()->request()->get(['view', 'sort', 'delete']);
$options = array_merge($defaults, $options);
$permissions = $this->options(['preview']);
@@ -107,6 +101,15 @@ 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,23 +146,6 @@ 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',
@@ -174,6 +160,8 @@ class Page extends Model
* Returns the setup for a dropdown option
* which is used in the changes dropdown
* for example.
*
* @return array
*/
public function dropdownOption(): array
{
@@ -185,6 +173,8 @@ class Page extends Model
/**
* Returns the escaped Id, which is
* used in the panel to make routing work properly
*
* @return string
*/
public function id(): string
{
@@ -193,6 +183,8 @@ class Page extends Model
/**
* Default settings for the page's Panel image
*
* @return array
*/
protected function imageDefaults(): array
{
@@ -209,11 +201,15 @@ class Page extends Model
* Returns the image file object based on provided query
*
* @internal
* @param string|null $query
* @return \Kirby\Cms\File|\Kirby\Filesystem\Asset|null
*/
protected function imageSource(
string|null $query = null
): CmsFile|Asset|null {
$query ??= 'page.image';
protected function imageSource(string $query = null)
{
if ($query === null) {
$query = 'page.image';
}
return parent::imageSource($query);
}
@@ -221,6 +217,7 @@ class Page extends Model
* Returns the full path without leading slash
*
* @internal
* @return string
*/
public function path(): string
{
@@ -230,6 +227,9 @@ class Page extends Model
/**
* Prepares the response data for page pickers
* and page fields
*
* @param array|null $params
* @return array
*/
public function pickerData(array $params = []): array
{
@@ -245,12 +245,12 @@ class Page extends Model
/**
* The best applicable position for
* the position/status dialog
*
* @return int
*/
public function position(): int
{
return
$this->model->num() ??
$this->model->parentModel()->children()->listed()->not($this->model)->count() + 1;
return $this->model->num() ?? $this->model->parentModel()->children()->listed()->not($this->model)->count() + 1;
}
/**
@@ -259,6 +259,8 @@ class Page extends Model
* based on blueprint definition
*
* @internal
*
* @return array
*/
public function prevNext(): array
{
@@ -307,7 +309,7 @@ class Page extends Model
->filter('status', $page->status());
}
return $siblings->filter('isListable', true);
return $siblings->filter('isReadable', true);
};
return [
@@ -321,6 +323,8 @@ class Page extends Model
* view's component props
*
* @internal
*
* @return array
*/
public function props(): array
{
@@ -330,7 +334,7 @@ class Page extends Model
parent::props(),
$this->prevNext(),
[
'blueprint' => $page->intendedTemplate()->name(),
'blueprint' => $this->model->intendedTemplate()->name(),
'model' => [
'content' => $this->content(),
'id' => $page->id(),
@@ -354,6 +358,8 @@ class Page extends Model
* this model's Panel view
*
* @internal
*
* @return array
*/
public function view(): array
{

View File

@@ -1,389 +0,0 @@
<?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',
'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
{
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 = new Page([
'slug' => 'tmp',
'template' => $this->template,
'parent' => $this->model(),
'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;
}
}

View File

@@ -2,7 +2,6 @@
namespace Kirby\Panel;
use Closure;
use Kirby\Cms\App;
use Kirby\Cms\Url as CmsUrl;
use Kirby\Cms\User;
@@ -11,7 +10,6 @@ use Kirby\Exception\NotFoundException;
use Kirby\Exception\PermissionException;
use Kirby\Http\Response;
use Kirby\Http\Router;
use Kirby\Http\Uri;
use Kirby\Http\Url;
use Kirby\Toolkit\Str;
use Kirby\Toolkit\Tpl;
@@ -34,8 +32,12 @@ class Panel
{
/**
* Normalize a panel area
*
* @param string $id
* @param array|string $area
* @return array
*/
public static function area(string $id, array $area): array
public static function area(string $id, $area): array
{
$area['id'] = $id;
$area['label'] ??= $id;
@@ -51,6 +53,8 @@ class Panel
/**
* Collect all registered areas
*
* @return array
*/
public static function areas(): array
{
@@ -60,15 +64,9 @@ 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']),
];
}
@@ -76,6 +74,7 @@ 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']),
];
@@ -90,8 +89,24 @@ 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);
}
@@ -101,11 +116,13 @@ class Panel
/**
* Check for access permissions
*
* @param \Kirby\Cms\User|null $user
* @param string|null $areaId
* @return bool
*/
public static function firewall(
User|null $user = null,
string|null $areaId = null
): bool {
public static function firewall(?User $user = null, ?string $areaId = null): bool
{
// a user has to be logged in
if ($user === null) {
throw new PermissionException(['key' => 'access.panel']);
@@ -141,10 +158,13 @@ class Panel
/**
* Redirect to a Panel url
*
* @param string|null $path
* @param int $code
* @throws \Kirby\Panel\Redirect
* @return void
* @codeCoverageIgnore
*/
public static function go(string|null $url = null, int $code = 302): void
public static function go(?string $url = null, int $code = 302): void
{
throw new Redirect(static::url($url), $code);
}
@@ -152,15 +172,17 @@ class Panel
/**
* Check if the given user has access to the panel
* or to a given area
*
* @param \Kirby\Cms\User|null $user
* @param string|null $area
* @return bool
*/
public static function hasAccess(
User|null $user = null,
string|null $area = null
): bool {
public static function hasAccess(?User $user = null, string $area = null): bool
{
try {
static::firewall($user, $area);
return true;
} catch (Throwable) {
} catch (Throwable $e) {
return false;
}
}
@@ -168,15 +190,15 @@ class Panel
/**
* Checks for a Fiber request
* via get parameters or headers
*
* @return bool
*/
public static function isFiberRequest(): bool
{
$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;
@@ -185,19 +207,25 @@ class Panel
/**
* Returns a JSON response
* for Fiber calls
*
* @param array $data
* @param int $code
* @return \Kirby\Http\Response
*/
public static function json(array $data, int $code = 200): Response
public static function json(array $data, int $code = 200)
{
$request = App::instance()->request();
return Response::json($data, $code, $request->get('_pretty'), [
'X-Fiber' => 'true',
'X-Fiber' => 'true',
'Cache-Control' => 'no-store, private'
]);
}
/**
* Checks for a multilanguage installation
*
* @return bool
*/
public static function multilang(): bool
{
@@ -208,6 +236,8 @@ class Panel
/**
* Returns the referrer path if present
*
* @return string
*/
public static function referrer(): string
{
@@ -223,11 +253,15 @@ class Panel
/**
* Creates a Response object from the result of
* a Panel route call
*
* @params mixed $result
* @params array $options
* @return \Kirby\Http\Response
*/
public static function response($result, array $options = []): Response
public static function response($result, array $options = [])
{
// pass responses directly down to the Kirby router
if ($result instanceof Response) {
if (is_a($result, 'Kirby\Http\Response') === true) {
return $result;
}
@@ -241,20 +275,25 @@ 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)
};
switch ($options['type'] ?? null) {
case 'dialog':
return Dialog::response($result, $options);
case 'dropdown':
return Dropdown::response($result, $options);
case 'search':
return Search::response($result, $options);
default:
return View::response($result, $options);
}
}
/**
* Router for the Panel views
*
* @param string $path
* @return \Kirby\Http\Response|false
*/
public static function router(string|null $path = null): Response|null
public static function router(string $path = null)
{
$kirby = App::instance();
@@ -284,11 +323,7 @@ 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) {
@@ -307,17 +342,15 @@ 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');
});
}
/**
* Extract the routes from the given array
* of active areas.
*
* @return array
*/
public static function routes(array $areas): array
{
@@ -342,9 +375,7 @@ 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),
);
}
@@ -365,7 +396,7 @@ class Panel
// catch all route
$routes[] = [
'pattern' => '(:all)',
'action' => fn (string $pattern) => 'Could not find Panel view for route: ' . $pattern
'action' => fn () => 'The view could not be found'
];
return $routes;
@@ -373,39 +404,36 @@ class Panel
/**
* Extract all routes from an area
*
* @param string $areaId
* @param array $area
* @return array
*/
public static function routesForDialogs(string $areaId, array $area): array
{
$dialogs = $area['dialogs'] ?? [];
$routes = [];
foreach ($dialogs as $dialogId => $dialog) {
$routes = array_merge($routes, Dialog::routes(
id: $dialogId,
areaId: $areaId,
prefix: 'dialogs',
options: $dialog
));
}
foreach ($dialogs as $key => $dialog) {
// create the full pattern with dialogs prefix
$pattern = 'dialogs/' . trim(($dialog['pattern'] ?? $key), '/');
return $routes;
}
// load event
$routes[] = [
'pattern' => $pattern,
'type' => 'dialog',
'area' => $areaId,
'action' => $dialog['load'] ?? fn () => 'The load handler for your dialog is missing'
];
/**
* 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
));
// submit event
$routes[] = [
'pattern' => $pattern,
'type' => 'dialog',
'area' => $areaId,
'method' => 'POST',
'action' => $dialog['submit'] ?? fn () => 'Your dialog does not define a submit handler'
];
}
return $routes;
@@ -413,34 +441,37 @@ class Panel
/**
* Extract all routes for dropdowns
*
* @param string $areaId
* @param array $area
* @return array
*/
public static function routesForDropdowns(string $areaId, array $area): array
{
$dropdowns = $area['dropdowns'] ?? [];
$routes = [];
foreach ($dropdowns as $dropdownId => $dropdown) {
$routes = array_merge($routes, Dropdown::routes(
id: $dropdownId,
areaId: $areaId,
prefix: 'dropdowns',
options: $dropdown
));
}
foreach ($dropdowns as $name => $dropdown) {
// Handle shortcuts for dropdowns. The name is the pattern
// and options are defined in a Closure
if (is_a($dropdown, 'Closure') === true) {
$dropdown = [
'pattern' => $name,
'action' => $dropdown
];
}
return $routes;
}
// create the full pattern with dropdowns prefix
$pattern = 'dropdowns/' . trim(($dropdown['pattern'] ?? $name), '/');
/**
* 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';
// load event
$routes[] = [
'pattern' => $pattern,
'type' => 'dropdown',
'area' => $areaId,
'method' => 'GET|POST',
'action' => $dropdown['options'] ?? $dropdown['action']
];
}
return $routes;
@@ -448,6 +479,10 @@ class Panel
/**
* Extract all routes for searches
*
* @param string $areaId
* @param array $area
* @return array
*/
public static function routesForSearches(string $areaId, array $area): array
{
@@ -464,13 +499,9 @@ class Panel
'type' => 'search',
'area' => $areaId,
'action' => function () use ($params) {
$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);
$request = App::instance()->request();
return $params['query']($query, $limit, $page);
return $params['query']($request->get('query'));
}
];
}
@@ -480,6 +511,10 @@ class Panel
/**
* Extract all views from an area
*
* @param string $areaId
* @param array $area
* @return array
*/
public static function routesForViews(string $areaId, array $area): array
{
@@ -489,18 +524,7 @@ class Panel
foreach ($views as $view) {
$view['area'] = $areaId;
$view['type'] = '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;
}
$routes[] = $view;
}
return $routes;
@@ -510,8 +534,10 @@ class Panel
* Set the current language in multi-lang
* installations based on the session or the
* query language query parameter
*
* @return string|null
*/
public static function setLanguage(): string|null
public static function setLanguage(): ?string
{
$kirby = App::instance();
@@ -544,15 +570,20 @@ class Panel
/**
* Set the currently active Panel translation
* based on the current user or config
*
* @return string
*/
public static function setTranslation(): string
{
$kirby = App::instance();
// use the user language for the default translation or
// fall back to the language from the config
$translation = $kirby->user()?->language() ??
$kirby->panelLanguage();
if ($user = $kirby->user()) {
// use the user language for the default translation
$translation = $user->language();
} else {
// fall back to the language from the config
$translation = $kirby->panelLanguage();
}
$kirby->setCurrentTranslation($translation);
@@ -562,30 +593,26 @@ class Panel
/**
* Creates an absolute Panel URL
* independent of the Panel slug config
*
* @param string|null $url
* @return string
*/
public static function url(string|null $url = null, array $options = []): string
public static function url(?string $url = null): string
{
$slug = App::instance()->option('panel.slug', 'panel');
// only touch relative paths
if (Url::isAbsolute($url) === false) {
$kirby = App::instance();
$slug = $kirby->option('panel.slug', 'panel');
$path = trim($url, '/');
$path = trim($url, '/');
$baseUri = new Uri($kirby->url());
$basePath = trim($baseUri->path()->toString(), '/');
// removes base path if relative path contains it
if (empty($basePath) === false && Str::startsWith($path, $basePath) === true) {
$path = Str::after($path, $basePath);
}
// add the panel slug prefix if it it's not
// included in the path yet
elseif (Str::startsWith($path, $slug . '/') === false) {
if (Str::startsWith($path, $slug . '/') === false) {
$path = $slug . '/' . $path;
}
// create an absolute URL
$url = CmsUrl::to($path, $options);
$url = CmsUrl::to($path);
}
return $url;

View File

@@ -23,11 +23,15 @@ class Plugins
{
/**
* Cache of all collected plugin files
*
* @var array
*/
public array|null $files = null;
public $files;
/**
* Collects and returns the plugin files for all plugins
*
* @return array
*/
public function files(): array
{
@@ -54,6 +58,8 @@ class Plugins
/**
* Returns the last modification
* of the collected plugin files
*
* @return int
*/
public function modified(): int
{
@@ -69,6 +75,9 @@ class Plugins
/**
* Read the files from all plugins and concatenate them
*
* @param string $type
* @return string
*/
public function read(string $type): string
{
@@ -131,6 +140,9 @@ class Plugins
/**
* Absolute url to the cache file
* This is used by the panel to link the plugins
*
* @param string $type
* @return string
*/
public function url(string $type): string
{

View File

@@ -20,6 +20,8 @@ class Redirect extends Exception
{
/**
* Returns the HTTP code for the redirect
*
* @return int
*/
public function code(): int
{
@@ -34,6 +36,8 @@ class Redirect extends Exception
/**
* Returns the URL for the redirect
*
* @return string
*/
public function location(): string
{

View File

@@ -1,24 +0,0 @@
<?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);
}
}

View File

@@ -2,8 +2,6 @@
namespace Kirby\Panel;
use Kirby\Http\Response;
/**
* The Search response class handles Fiber
* requests to render the JSON object for
@@ -18,21 +16,18 @@ use Kirby\Http\Response;
*/
class Search extends Json
{
protected static string $key = '$search';
protected static $key = '$search';
public static function response($data, array $options = []): Response
/**
* @param mixed $data
* @param array $options
* @return \Kirby\Http\Response
*/
public static function response($data, array $options = [])
{
if (
is_array($data) === true &&
array_key_exists('results', $data) === false
) {
if (is_array($data) === true) {
$data = [
'results' => $data,
'pagination' => [
'page' => 1,
'limit' => $total = count($data),
'total' => $total
]
'results' => $data
];
}

View File

@@ -2,10 +2,6 @@
namespace Kirby\Panel;
use Kirby\Cms\File as CmsFile;
use Kirby\Cms\ModelWithContent;
use Kirby\Filesystem\Asset;
/**
* Provides information about the site model for the Panel
* @since 3.6.0
@@ -21,12 +17,14 @@ class Site extends Model
/**
* @var \Kirby\Cms\Site
*/
protected ModelWithContent $model;
protected $model;
/**
* Returns the setup for a dropdown option
* which is used in the changes dropdown
* for example.
*
* @return array
*/
public function dropdownOption(): array
{
@@ -40,16 +38,22 @@ class Site extends Model
* Returns the image file object based on provided query
*
* @internal
* @param string|null $query
* @return \Kirby\Cms\File|\Kirby\Filesystem\Asset|null
*/
protected function imageSource(
string|null $query = null
): CmsFile|Asset|null {
$query ??= 'site.image';
protected function imageSource(string $query = null)
{
if ($query === null) {
$query = 'site.image';
}
return parent::imageSource($query);
}
/**
* Returns the full path without leading slash
*
* @return string
*/
public function path(): string
{
@@ -61,6 +65,8 @@ class Site extends Model
* view's component props
*
* @internal
*
* @return array
*/
public function props(): array
{
@@ -80,6 +86,8 @@ class Site extends Model
* this model's Panel view
*
* @internal
*
* @return array
*/
public function view(): array
{

View File

@@ -2,11 +2,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;
use Kirby\Toolkit\I18n;
/**
@@ -24,10 +20,12 @@ class User extends Model
/**
* @var \Kirby\Cms\User
*/
protected ModelWithContent $model;
protected $model;
/**
* Breadcrumb array
*
* @return array
*/
public function breadcrumb(): array
{
@@ -41,6 +39,9 @@ class User extends Model
/**
* Provides options for the user dropdown
*
* @param array $options
* @return array
*/
public function dropdown(array $options = []): array
{
@@ -73,15 +74,6 @@ class User extends Model
'disabled' => $this->isDisabledDropdownOption('changeRole', $options, $permissions)
];
$result[] = [
'dialog' => $url . '/changeLanguage',
'icon' => 'translate',
'text' => I18n::translate('user.changeLanguage'),
'disabled' => $this->isDisabledDropdownOption('changeLanguage', $options, $permissions)
];
$result[] = '-';
$result[] = [
'dialog' => $url . '/changePassword',
'icon' => 'key',
@@ -89,23 +81,12 @@ class User extends Model
'disabled' => $this->isDisabledDropdownOption('changePassword', $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[] = [
'dialog' => $url . '/changeLanguage',
'icon' => 'globe',
'text' => I18n::translate('user.changeLanguage'),
'disabled' => $this->isDisabledDropdownOption('changeLanguage', $options, $permissions)
];
$result[] = '-';
@@ -123,6 +104,8 @@ class User extends Model
* Returns the setup for a dropdown option
* which is used in the changes dropdown
* for example.
*
* @return array
*/
public function dropdownOption(): array
{
@@ -132,7 +115,10 @@ class User extends Model
] + parent::dropdownOption();
}
public function home(): string|null
/**
* @return string|null
*/
public function home(): ?string
{
if ($home = ($this->model->blueprint()->home() ?? null)) {
$url = $this->model->toString($home);
@@ -144,6 +130,8 @@ class User extends Model
/**
* Default settings for the user's Panel image
*
* @return array
*/
protected function imageDefaults(): array
{
@@ -156,11 +144,12 @@ class User extends Model
/**
* Returns the image file object based on provided query
* @internal
*
* @param string|null $query
* @return \Kirby\Cms\File|\Kirby\Filesystem\Asset|null
*/
protected function imageSource(
string|null $query = null
): CmsFile|Asset|null {
protected function imageSource(string $query = null)
{
if ($query === null) {
return $this->model->avatar();
}
@@ -170,6 +159,8 @@ class User extends Model
/**
* Returns the full path without leading slash
*
* @return string
*/
public function path(): string
{
@@ -183,8 +174,11 @@ class User extends Model
/**
* Returns prepared data for the panel user picker
*
* @param array|null $params
* @return array
*/
public function pickerData(array $params = []): array
public function pickerData(array $params = null): array
{
$params['text'] ??= '{{ user.username }}';
@@ -199,6 +193,8 @@ class User extends Model
* previous and next user
*
* @internal
*
* @return array
*/
public function prevNext(): array
{
@@ -215,11 +211,14 @@ class User extends Model
* view's component props
*
* @internal
*
* @return array
*/
public function props(): array
{
$user = $this->model;
$account = $user->isLoggedIn();
$avatar = $user->avatar();
return array_merge(
parent::props(),
@@ -228,7 +227,7 @@ class User extends Model
'blueprint' => $this->model->role()->name(),
'model' => [
'account' => $account,
'avatar' => $user->avatar()?->url(),
'avatar' => $avatar ? $avatar->url() : null,
'content' => $this->content(),
'email' => $user->email(),
'id' => $user->id(),
@@ -245,8 +244,10 @@ class User extends Model
/**
* Returns the Translation object
* for the selected Panel language
*
* @return \Kirby\Cms\Translation
*/
public function translation(): Translation
public function translation()
{
$kirby = $this->model->kirby();
$lang = $this->model->language();
@@ -258,6 +259,8 @@ class User extends Model
* this model's Panel view
*
* @internal
*
* @return array
*/
public function view(): array
{

View File

@@ -1,114 +0,0 @@
<?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
]);
}
}
}

View File

@@ -1,95 +0,0 @@
<?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);
}
}

View File

@@ -3,11 +3,10 @@
namespace Kirby\Panel;
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;
/**
* The View response class handles Fiber
@@ -28,6 +27,9 @@ class View
* query parameters. Requests can return only
* certain data fields that way or globals can
* be injected on demand.
*
* @param array $data
* @return array
*/
public static function apply(array $data): array
{
@@ -38,9 +40,7 @@ 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);
@@ -55,11 +55,13 @@ class View
*
* A global request can be activated with the `X-Fiber-Globals` header or the
* `_globals` query parameter.
*
* @param array $data
* @param string|null $globals
* @return array
*/
public static function applyGlobals(
array $data,
string|null $globals = null
): array {
public static function applyGlobals(array $data, ?string $globals = null): array
{
// split globals string into an array of fields
$globalKeys = Str::split($globals, ',');
@@ -87,11 +89,13 @@ class View
*
* Such requests can fetch shared data or globals.
* Globals will be loaded on demand.
*
* @param array $data
* @param string|null $only
* @return array
*/
public static function applyOnly(
array $data,
string|null $only = null
): array {
public static function applyOnly(array $data, ?string $only = null): array
{
// split include string into an array of fields
$onlyKeys = Str::split($only, ',');
@@ -119,7 +123,9 @@ class View
}
// Nest dotted keys in array but ignore $translation
return A::nest($result, ['$translation']);
return A::nest($result, [
'$translation'
]);
}
/**
@@ -127,6 +133,10 @@ class View
* The full shared data is always sent on every JSON and
* full document request unless the `X-Fiber-Only` header or
* the `_only` query parameter is set.
*
* @param array $view
* @param array $options
* @return array
*/
public static function data(array $view = [], array $options = []): array
{
@@ -139,7 +149,7 @@ class View
$user = $kirby->user();
// user permissions
$permissions = $user?->role()->permissions()->toArray() ?? [];
$permissions = $user ? $user->role()->permissions()->toArray() : [];
// current content language
$language = $kirby->language();
@@ -148,18 +158,14 @@ class View
return [
'$direction' => function () use ($kirby, $multilang, $language, $user) {
if ($multilang === true && $language && $user) {
$default = $kirby->defaultLanguage();
$isDefault = $language->direction() === $kirby->defaultLanguage()->direction();
$isFromUser = $language->code() === $user->language();
if (
$language->direction() !== $default->direction() &&
$language->code() !== $user->language()
) {
if ($isDefault === false && $isFromUser === false) {
return $language->direction();
}
}
},
'$dialog' => null,
'$drawer' => null,
'$language' => function () use ($kirby, $multilang, $language) {
if ($multilang === true && $language) {
return [
@@ -184,20 +190,15 @@ class View
return [];
},
'$menu' => function () use ($options, $permissions) {
$menu = new Menu(
$options['areas'] ?? [],
$permissions,
$options['area']['id'] ?? null
);
return $menu->entries();
'$menu' => function () use ($options, $permissions) {
return static::menu($options['areas'] ?? [], $permissions, $options['area']['id'] ?? null);
},
'$permissions' => $permissions,
'$license' => $kirby->system()->license()->status()->value(),
'$multilang' => $multilang,
'$searches' => static::searches($options['areas'] ?? [], $permissions),
'$url' => $kirby->request()->url()->toString(),
'$user' => function () use ($user) {
'$license' => (bool)$kirby->system()->license(),
'$multilang' => $multilang,
'$searches' => static::searches($options['areas'] ?? [], $permissions),
'$url' => $kirby->request()->url()->toString(),
'$user' => function () use ($user) {
if ($user) {
return [
'email' => $user->email(),
@@ -215,25 +216,17 @@ class View
'breadcrumb' => [],
'code' => 200,
'path' => Str::after($kirby->path(), '/'),
'props' => [],
'query' => App::instance()->request()->query()->toArray(),
'referrer' => Panel::referrer(),
'search' => $kirby->option('panel.search.type', 'pages'),
'timestamp' => (int)(microtime(true) * 1000),
'props' => [],
'search' => $kirby->option('panel.search.type', 'pages')
];
$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']
);
@@ -246,6 +239,10 @@ class View
/**
* Renders the error view with provided message
*
* @param string $message
* @param int $code
* @return array
*/
public static function error(string $message, int $code = 404)
{
@@ -268,17 +265,25 @@ class View
* is only requested once on the first page load.
* It can be loaded partially later if needed,
* but is otherwise not included in Fiber calls.
*
* @return array
*/
public static function globals(): array
{
$kirby = App::instance();
return [
'$config' => fn () => [
'debug' => $kirby->option('debug', false),
'kirbytext' => $kirby->option('panel.kirbytext', true),
'translation' => $kirby->option('panel.language', 'en'),
],
'$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'),
];
},
'$system' => function () use ($kirby) {
$locales = [];
@@ -316,23 +321,93 @@ class View
];
}
/**
* Creates the menu for the topbar
*
* @param array $areas
* @param array $permissions
* @param string|null $current
* @return array
*/
public static function menu(?array $areas = [], ?array $permissions = [], ?string $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 (is_a($menuSetting, 'Closure') === true) {
$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
* on the request header or query params
*
* @param mixed $data
* @param array $options
* @return \Kirby\Http\Response
*/
public static function response($data, array $options = []): Response
public static function response($data, array $options = [])
{
// handle redirects
if ($data instanceof Redirect) {
if (is_a($data, 'Kirby\Panel\Redirect') === true) {
return Response::redirect($data->location(), $data->code());
// handle Kirby exceptions
} elseif ($data instanceof Exception) {
} elseif (is_a($data, 'Kirby\Exception\Exception') === true) {
$data = static::error($data->getMessage(), $data->getHttpCode());
// handle regular exceptions
} elseif ($data instanceof Throwable) {
} elseif (is_a($data, 'Throwable') === true) {
$data = static::error($data->getMessage(), 500);
// only expect arrays from here on
@@ -362,21 +437,17 @@ class View
return Document::response($fiber);
}
public static function searches(array $areas, array $permissions): array
public static function searches(array $areas, array $permissions)
{
$searches = [];
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
];
}
foreach ($areas as $area) {
foreach ($area['searches'] ?? [] as $id => $params) {
$searches[$id] = [
'icon' => $params['icon'] ?? 'search',
'label' => $params['label'] ?? Str::ucfirst($id),
'id' => $id
];
}
}
return $searches;