Initial commit
This commit is contained in:
commit
efa5624dab
687 changed files with 162710 additions and 0 deletions
355
kirby/src/Panel/Assets.php
Normal file
355
kirby/src/Panel/Assets.php
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\Helpers;
|
||||
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 $isDev;
|
||||
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;
|
||||
|
||||
// Check if Panel is running in dev mode to
|
||||
// get the assets from the Vite dev server;
|
||||
// dev mode = explicitly enabled in the config AND Vite is running
|
||||
$this->isDev =
|
||||
$this->kirby->option('panel.dev', false) !== 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->isDev === 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(),
|
||||
'import-maps' => $this->importMaps(),
|
||||
'js' => $this->js(),
|
||||
// loader for plugins' index.dev.mjs files – inlined,
|
||||
// so we provide the code instead of the asset URL
|
||||
'plugin-imports' => $this->plugins->read('mjs'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns array of favicon icons based on config option
|
||||
*
|
||||
* @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) {
|
||||
Helpers::deprecated('`panel.favicon` option: use `href` instead of `url` attribute');
|
||||
|
||||
$icon['href'] = $icon['url'];
|
||||
unset($icon['url']);
|
||||
}
|
||||
|
||||
// TODO: remove this backward compatibility check in v6
|
||||
if (is_string($rel) === true && isset($icon['rel']) === false) {
|
||||
Helpers::deprecated('`panel.favicon` option: use `rel` attribute instead of passing string as key');
|
||||
|
||||
$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(
|
||||
message: '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->isDev ? 'public' : 'dist';
|
||||
$icons = F::read($dir . '/img/icons.svg');
|
||||
$icons = preg_replace('/<!--(.|\s)*?-->/', '', $icons);
|
||||
return $icons;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all import maps
|
||||
*/
|
||||
public function importMaps(): array
|
||||
{
|
||||
return array_filter([
|
||||
'vue' => $this->vue()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all js files
|
||||
*/
|
||||
public function js(): array
|
||||
{
|
||||
$js = [
|
||||
'vendor' => [
|
||||
'nonce' => $this->nonce,
|
||||
'src' => $this->url . '/js/vendor.min.js',
|
||||
'type' => 'module'
|
||||
],
|
||||
'plugin-registry' => [
|
||||
'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',
|
||||
'defer' => true
|
||||
]),
|
||||
'index' => [
|
||||
'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->isDev === true) {
|
||||
// Load the non-minified index.js, remove vendor script
|
||||
$js['index']['src'] = $this->url . '/src/index.js';
|
||||
$js['vendor'] = null;
|
||||
|
||||
// Add vite dev client
|
||||
$js['vite'] = [
|
||||
'nonce' => $this->nonce,
|
||||
'src' => $this->url . '/@vite/client',
|
||||
'type' => 'module'
|
||||
];
|
||||
}
|
||||
|
||||
return array_filter($js);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
message: '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->isDev === false) {
|
||||
return $this->kirby->url('media') . '/panel/' . $this->kirby->versionHash();
|
||||
}
|
||||
|
||||
// explicitly configured base URL
|
||||
$dev = $this->kirby->option('panel.dev');
|
||||
|
||||
if (is_string($dev) === true) {
|
||||
return $dev;
|
||||
}
|
||||
|
||||
// port 3000 of the current Kirby request
|
||||
return rtrim($this->kirby->request()->url([
|
||||
'port' => 3000,
|
||||
'path' => null,
|
||||
'params' => null,
|
||||
'query' => null
|
||||
])->toString(), '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the correct Vue script URL depending on dev mode
|
||||
* and the enabled/disabled template compiler
|
||||
*/
|
||||
public function vue(): string
|
||||
{
|
||||
// During dev mode, load the dev version of Vue
|
||||
if ($this->isDev === true) {
|
||||
return $this->url . '/node_modules/vue/dist/vue.esm.browser.js';
|
||||
}
|
||||
|
||||
if ($this->kirby->option('panel.vue.compiler', true) === true) {
|
||||
return $this->url . '/js/vue.esm.browser.min.js';
|
||||
}
|
||||
|
||||
return $this->url . '/js/vue.runtime.esm.min.js';
|
||||
}
|
||||
}
|
||||
78
kirby/src/Panel/ChangesDialog.php
Normal file
78
kirby/src/Panel/ChangesDialog.php
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel;
|
||||
|
||||
use Kirby\Cms\Collection;
|
||||
use Kirby\Content\Changes;
|
||||
|
||||
/**
|
||||
* Manages the Panel dialog for content changes in
|
||||
* pages, users and files
|
||||
* @since 5.0.0
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class ChangesDialog
|
||||
{
|
||||
public function __construct(
|
||||
protected Changes $changes = new Changes()
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the item props for all changed files
|
||||
*/
|
||||
public function files(): array
|
||||
{
|
||||
return $this->items($this->changes->files());
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to return item props for the given models
|
||||
*/
|
||||
public function items(Collection $models): array
|
||||
{
|
||||
return $models->values(
|
||||
fn ($model) => $model->panel()->dropdownOption()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the backend full definition for dialog
|
||||
*/
|
||||
public function load(): array
|
||||
{
|
||||
if ($this->changes->cacheExists() === false) {
|
||||
$this->changes->generateCache();
|
||||
}
|
||||
|
||||
return [
|
||||
'component' => 'k-changes-dialog',
|
||||
'props' => [
|
||||
'files' => $this->files(),
|
||||
'pages' => $this->pages(),
|
||||
'users' => $this->users(),
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the item props for all changed pages
|
||||
*/
|
||||
public function pages(): array
|
||||
{
|
||||
return $this->items($this->changes->pages());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the item props for all changed users
|
||||
*/
|
||||
public function users(): array
|
||||
{
|
||||
return $this->items($this->changes->users());
|
||||
}
|
||||
}
|
||||
73
kirby/src/Panel/Collector/FilesCollector.php
Normal file
73
kirby/src/Panel/Collector/FilesCollector.php
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Collector;
|
||||
|
||||
use Kirby\Cms\Files;
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Cms\Pages;
|
||||
use Kirby\Cms\Site;
|
||||
use Kirby\Cms\User;
|
||||
use Kirby\Cms\Users;
|
||||
|
||||
/**
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class FilesCollector extends ModelsCollector
|
||||
{
|
||||
public function __construct(
|
||||
protected bool $flip = false,
|
||||
protected int|null $limit = null,
|
||||
protected int $page = 1,
|
||||
protected Site|Page|User|null $parent = null,
|
||||
protected string|null $query = null,
|
||||
protected string|null $search = null,
|
||||
protected string|null $sortBy = null,
|
||||
protected string|null $template = null,
|
||||
) {
|
||||
}
|
||||
|
||||
protected function collect(): Files
|
||||
{
|
||||
return $this->parent()->files();
|
||||
}
|
||||
|
||||
protected function collectByQuery(): Files
|
||||
{
|
||||
return $this->parent()->query($this->query, Files::class) ?? new Files([]);
|
||||
}
|
||||
|
||||
protected function filter(Files|Pages|Users $models): Files
|
||||
{
|
||||
return $models->filter(function ($file) {
|
||||
// remove all protected and hidden files
|
||||
if ($file->isListable() === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// filter by template
|
||||
if ($this->template !== null && $file->template() !== $this->template) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public function isSorting(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function sort(Files|Pages|Users $models): Files
|
||||
{
|
||||
if ($this->sortBy === null || $this->isSearching() === true) {
|
||||
return $models->sorted();
|
||||
}
|
||||
|
||||
return parent::sort($models);
|
||||
}
|
||||
}
|
||||
130
kirby/src/Panel/Collector/ModelsCollector.php
Normal file
130
kirby/src/Panel/Collector/ModelsCollector.php
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Collector;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\Files;
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Cms\Pages;
|
||||
use Kirby\Cms\Pagination;
|
||||
use Kirby\Cms\Site;
|
||||
use Kirby\Cms\User;
|
||||
use Kirby\Cms\Users;
|
||||
|
||||
/**
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
abstract class ModelsCollector
|
||||
{
|
||||
protected Files|Pages|Users $models;
|
||||
protected Files|Pages|Users $paginated;
|
||||
|
||||
public function __construct(
|
||||
protected int|null $limit = null,
|
||||
protected int $page = 1,
|
||||
protected Site|Page|User|null $parent = null,
|
||||
protected string|null $query = null,
|
||||
protected string|null $search = null,
|
||||
protected string|null $sortBy = null,
|
||||
protected bool $flip = false,
|
||||
) {
|
||||
}
|
||||
|
||||
abstract protected function collect(): Files|Pages|Users;
|
||||
abstract protected function collectByQuery(): Files|Pages|Users;
|
||||
abstract protected function filter(Files|Pages|Users $models): Files|Pages|Users;
|
||||
|
||||
protected function flip(Files|Pages|Users $models): Files|Pages|Users
|
||||
{
|
||||
return $models->flip();
|
||||
}
|
||||
|
||||
public function isFlipping(): bool
|
||||
{
|
||||
if ($this->isSearching() === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->flip === true;
|
||||
}
|
||||
|
||||
public function isQuerying(): bool
|
||||
{
|
||||
return $this->query !== null;
|
||||
}
|
||||
|
||||
public function isSearching(): bool
|
||||
{
|
||||
return $this->search !== null && trim($this->search) !== '';
|
||||
}
|
||||
|
||||
public function isSorting(): bool
|
||||
{
|
||||
if ($this->isSearching() === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->sortBy !== null;
|
||||
}
|
||||
|
||||
public function models(bool $paginated = false): Files|Pages|Users
|
||||
{
|
||||
if ($paginated === true) {
|
||||
return $this->paginated ??= $this->models()->paginate([
|
||||
'limit' => $this->limit ?? 1000,
|
||||
'page' => $this->page,
|
||||
'method' => 'none' // the page is manually provided
|
||||
]);
|
||||
}
|
||||
|
||||
if (isset($this->models) === true) {
|
||||
return $this->models;
|
||||
}
|
||||
|
||||
if ($this->isQuerying() === true) {
|
||||
$models = $this->collectByQuery();
|
||||
} else {
|
||||
$models = $this->collect();
|
||||
}
|
||||
|
||||
$models = $this->filter($models);
|
||||
|
||||
if ($this->isSearching() === true) {
|
||||
$models = $this->search($models);
|
||||
}
|
||||
|
||||
if ($this->isSorting() === true) {
|
||||
$models = $this->sort($models);
|
||||
}
|
||||
|
||||
if ($this->isFlipping() === true) {
|
||||
$models = $this->flip($models);
|
||||
}
|
||||
|
||||
return $this->models ??= $models;
|
||||
}
|
||||
|
||||
public function pagination(): Pagination
|
||||
{
|
||||
return $this->models(paginated: true)->pagination();
|
||||
}
|
||||
|
||||
protected function parent(): Site|Page|User
|
||||
{
|
||||
return $this->parent ?? App::instance()->site();
|
||||
}
|
||||
|
||||
protected function search(Files|Pages|Users $models): Files|Pages|Users
|
||||
{
|
||||
return $models->search($this->search);
|
||||
}
|
||||
|
||||
protected function sort(Files|Pages|Users $models): Files|Pages|Users
|
||||
{
|
||||
return $models->sort(...$models::sortArgs($this->sortBy));
|
||||
}
|
||||
}
|
||||
85
kirby/src/Panel/Collector/PagesCollector.php
Normal file
85
kirby/src/Panel/Collector/PagesCollector.php
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Collector;
|
||||
|
||||
use Kirby\Cms\Files;
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Cms\Pages;
|
||||
use Kirby\Cms\Site;
|
||||
use Kirby\Cms\User;
|
||||
use Kirby\Cms\Users;
|
||||
|
||||
/**
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class PagesCollector extends ModelsCollector
|
||||
{
|
||||
public function __construct(
|
||||
protected int|null $limit = null,
|
||||
protected int $page = 1,
|
||||
protected Site|Page|User|null $parent = null,
|
||||
protected string|null $query = null,
|
||||
protected string|null $status = null,
|
||||
protected array $templates = [],
|
||||
protected array $templatesIgnore = [],
|
||||
protected string|null $search = null,
|
||||
protected string|null $sortBy = null,
|
||||
protected bool $flip = false,
|
||||
) {
|
||||
}
|
||||
|
||||
protected function collect(): Pages
|
||||
{
|
||||
return match ($this->status) {
|
||||
'draft' => $this->parent()->drafts(),
|
||||
'listed' => $this->parent()->children()->listed(),
|
||||
'published' => $this->parent()->children(),
|
||||
'unlisted' => $this->parent()->children()->unlisted(),
|
||||
default => $this->parent()->childrenAndDrafts()
|
||||
};
|
||||
}
|
||||
|
||||
protected function collectByQuery(): Pages
|
||||
{
|
||||
return $this->parent()->query($this->query, Pages::class) ?? new Pages([]);
|
||||
}
|
||||
|
||||
protected function filter(Files|Pages|Users $models): Pages
|
||||
{
|
||||
// filters pages that are protected and not in the templates list
|
||||
// internal `filter()` method used instead of foreach loop that previously included `unset()`
|
||||
// because `unset()` is updating the original data, `filter()` is just filtering
|
||||
// also it has been tested that there is no performance difference
|
||||
// even in 0.1 seconds on 100k virtual pages
|
||||
return $models->filter(function (Page $model): bool {
|
||||
// remove all protected and hidden pages
|
||||
if ($model->isListable() === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$intendedTemplate = $model->intendedTemplate()->name();
|
||||
|
||||
// filter by all set templates
|
||||
if (
|
||||
$this->templates &&
|
||||
in_array($intendedTemplate, $this->templates, true) === false
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// exclude by all ignored templates
|
||||
if (
|
||||
$this->templatesIgnore &&
|
||||
in_array($intendedTemplate, $this->templatesIgnore, true) === true
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
62
kirby/src/Panel/Collector/UsersCollector.php
Normal file
62
kirby/src/Panel/Collector/UsersCollector.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Collector;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\Files;
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Cms\Pages;
|
||||
use Kirby\Cms\Site;
|
||||
use Kirby\Cms\User;
|
||||
use Kirby\Cms\Users;
|
||||
|
||||
/**
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class UsersCollector extends ModelsCollector
|
||||
{
|
||||
public function __construct(
|
||||
protected bool $flip = false,
|
||||
protected int|null $limit = null,
|
||||
protected int $page = 1,
|
||||
protected Site|Page|User|null $parent = null,
|
||||
protected string|null $query = null,
|
||||
protected string|null $role = null,
|
||||
protected string|null $search = null,
|
||||
protected string|null $sortBy = null,
|
||||
) {
|
||||
}
|
||||
|
||||
protected function collect(): Users
|
||||
{
|
||||
return App::instance()->users();
|
||||
}
|
||||
|
||||
protected function collectByQuery(): Users
|
||||
{
|
||||
return $this->parent()->query($this->query, Users::class) ?? new Users([]);
|
||||
}
|
||||
|
||||
protected function filter(Files|Pages|Users $models): Users
|
||||
{
|
||||
$user = App::instance()->user();
|
||||
|
||||
if ($user === null) {
|
||||
return new Users([]);
|
||||
}
|
||||
|
||||
if ($user->role()->permissions()->for('access', 'users') === false) {
|
||||
return new Users([]);
|
||||
}
|
||||
|
||||
if ($this->role !== null) {
|
||||
$models = $models->role($this->role);
|
||||
}
|
||||
|
||||
return $models;
|
||||
}
|
||||
}
|
||||
113
kirby/src/Panel/Controller/PageTree.php
Normal file
113
kirby/src/Panel/Controller/PageTree.php
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Controller;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\Find;
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Cms\Site;
|
||||
use Kirby\Toolkit\I18n;
|
||||
|
||||
/**
|
||||
* The PageTree controller takes care of the request logic
|
||||
* for the `k-page-tree` component and similar
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class PageTree
|
||||
{
|
||||
protected Site $site;
|
||||
|
||||
public function __construct(
|
||||
) {
|
||||
$this->site = App::instance()->site();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns children for the parent as entries
|
||||
*/
|
||||
public function children(
|
||||
string|null $parent = null,
|
||||
string|null $moving = null
|
||||
): array {
|
||||
if ($moving !== null) {
|
||||
$moving = Find::parent($moving);
|
||||
}
|
||||
|
||||
if ($parent === null) {
|
||||
return [
|
||||
$this->entry($this->site, $moving)
|
||||
];
|
||||
}
|
||||
|
||||
return Find::parent($parent)
|
||||
->childrenAndDrafts()
|
||||
->filterBy('isListable', true)
|
||||
->values(
|
||||
fn ($child) => $this->entry($child, $moving)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the properties to display the site or page
|
||||
* as an entry in the page tree component
|
||||
*/
|
||||
public function entry(
|
||||
Site|Page $entry,
|
||||
Page|null $moving = null
|
||||
): array {
|
||||
$panel = $entry->panel();
|
||||
$id = $entry->id() ?? '/';
|
||||
$uuid = $entry->uuid()?->toString();
|
||||
$url = $entry->url();
|
||||
$value = $uuid ?? $id;
|
||||
|
||||
return [
|
||||
'children' => $panel->url(true),
|
||||
'disabled' => $moving?->isMovableTo($entry) === false,
|
||||
'hasChildren' =>
|
||||
$entry->hasChildren() === true ||
|
||||
$entry->hasDrafts() === true,
|
||||
'icon' => match (true) {
|
||||
$entry instanceof Site => 'home',
|
||||
default => $panel->image()['icon'] ?? null
|
||||
},
|
||||
'id' => $id,
|
||||
'open' => false,
|
||||
'label' => match (true) {
|
||||
$entry instanceof Site => I18n::translate('view.site'),
|
||||
default => $entry->title()->value()
|
||||
},
|
||||
'url' => $url,
|
||||
'uuid' => $uuid,
|
||||
'value' => $value
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the UUIDs/ids for all parents of the page
|
||||
*/
|
||||
public function parents(
|
||||
string|null $page = null,
|
||||
bool $includeSite = false,
|
||||
): array {
|
||||
$page = $this->site->page($page);
|
||||
$parents = $page?->parents()->flip();
|
||||
$parents = $parents?->values(
|
||||
fn ($parent) => $parent->uuid()?->toString() ?? $parent->id()
|
||||
);
|
||||
$parents ??= [];
|
||||
|
||||
if ($includeSite === true) {
|
||||
array_unshift($parents, $this->site->uuid()?->toString() ?? '/');
|
||||
}
|
||||
|
||||
return [
|
||||
'data' => $parents
|
||||
];
|
||||
}
|
||||
}
|
||||
88
kirby/src/Panel/Controller/Search.php
Normal file
88
kirby/src/Panel/Controller/Search.php
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Controller;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Panel\Ui\Item\FileItem;
|
||||
use Kirby\Panel\Ui\Item\PageItem;
|
||||
use Kirby\Panel\Ui\Item\UserItem;
|
||||
|
||||
/**
|
||||
* The Search controller takes care of the logic
|
||||
* for delivering Panel search results
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @unstable
|
||||
*/
|
||||
class Search
|
||||
{
|
||||
public static function files(
|
||||
string|null $query = null,
|
||||
int|null $limit = null,
|
||||
int $page = 1
|
||||
): array {
|
||||
$kirby = App::instance();
|
||||
$files = $kirby->site()
|
||||
->index(true)
|
||||
->filter('isListable', true)
|
||||
->files();
|
||||
|
||||
// add site files which aren't considered by the index
|
||||
$files = $files->add($kirby->site()->files());
|
||||
|
||||
// filter and search among those files
|
||||
$files = $files->filter('isListable', true)->search($query);
|
||||
|
||||
if ($limit !== null) {
|
||||
$files = $files->paginate($limit, $page);
|
||||
}
|
||||
|
||||
return [
|
||||
'results' => $files->values(fn ($file) => (new FileItem(file: $file, info: '{{ file.id }}'))->props()),
|
||||
'pagination' => $files->pagination()?->toArray()
|
||||
];
|
||||
}
|
||||
|
||||
public static function pages(
|
||||
string|null $query = null,
|
||||
int|null $limit = null,
|
||||
int $page = 1
|
||||
): array {
|
||||
$kirby = App::instance();
|
||||
$pages = $kirby->site()
|
||||
->index(true)
|
||||
->search($query)
|
||||
->filter('isListable', true);
|
||||
|
||||
if ($limit !== null) {
|
||||
$pages = $pages->paginate($limit, $page);
|
||||
}
|
||||
|
||||
return [
|
||||
'results' => $pages->values(fn ($page) => (new PageItem(page: $page, info: '{{ page.id }}'))->props()),
|
||||
'pagination' => $pages->pagination()?->toArray()
|
||||
];
|
||||
}
|
||||
|
||||
public static function users(
|
||||
string|null $query = null,
|
||||
int|null $limit = null,
|
||||
int $page = 1
|
||||
): array {
|
||||
$kirby = App::instance();
|
||||
$users = $kirby->users()->search($query);
|
||||
|
||||
if ($limit !== null) {
|
||||
$users = $users->paginate($limit, $page);
|
||||
}
|
||||
|
||||
return [
|
||||
'results' => $users->values(fn ($user) => (new UserItem(user: $user))->props()),
|
||||
'pagination' => $users->pagination()?->toArray()
|
||||
];
|
||||
}
|
||||
}
|
||||
86
kirby/src/Panel/Dialog.php
Normal file
86
kirby/src/Panel/Dialog.php
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<?php
|
||||
|
||||
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
|
||||
* @since 3.6.0
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Dialog extends Json
|
||||
{
|
||||
protected static string $key = '$dialog';
|
||||
|
||||
/**
|
||||
* Renders dialogs
|
||||
*/
|
||||
public static function response($data, array $options = []): Response
|
||||
{
|
||||
// interpret true as success
|
||||
if ($data === true) {
|
||||
$data = [
|
||||
'code' => 200
|
||||
];
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// create load/submit events from controller class
|
||||
if ($controller = $options['controller'] ?? null) {
|
||||
if (is_string($controller) === true) {
|
||||
if (method_exists($controller, 'for') === true) {
|
||||
$controller = $controller::for(...);
|
||||
} else {
|
||||
$controller = fn (...$args) => new $controller(...$args);
|
||||
}
|
||||
}
|
||||
|
||||
$options['load'] ??= fn (...$args) => $controller(...$args)->load();
|
||||
$options['submit'] ??= fn (...$args) => $controller(...$args)->submit();
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
72
kirby/src/Panel/Document.php
Normal file
72
kirby/src/Panel/Document.php
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Http\Response;
|
||||
use Kirby\Http\Uri;
|
||||
use Kirby\Toolkit\Tpl;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* The Document is used by the View class to render
|
||||
* the full Panel HTML document in Fiber calls that
|
||||
* should not return just JSON objects
|
||||
* @since 3.6.0
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Document
|
||||
{
|
||||
/**
|
||||
* Renders the panel document
|
||||
*/
|
||||
public static function response(array $fiber): Response
|
||||
{
|
||||
$kirby = App::instance();
|
||||
$assets = new Assets();
|
||||
|
||||
// Full HTML response
|
||||
// @codeCoverageIgnoreStart
|
||||
try {
|
||||
if ($assets->link() === true) {
|
||||
usleep(1);
|
||||
Response::go($kirby->url('base') . '/' . $kirby->path());
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
die('The Panel assets cannot be installed properly. ' . $e->getMessage());
|
||||
}
|
||||
// @codeCoverageIgnoreEnd
|
||||
|
||||
// get the uri object for the panel url
|
||||
$uri = new Uri($kirby->url('panel'));
|
||||
|
||||
// proper response code
|
||||
$code = $fiber['$view']['code'] ?? 200;
|
||||
|
||||
// load the main Panel view template
|
||||
$body = Tpl::load($kirby->root('kirby') . '/views/panel.php', [
|
||||
'assets' => $assets->external(),
|
||||
'icons' => $assets->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'"
|
||||
};
|
||||
|
||||
return new Response($body, 'text/html', $code, [
|
||||
'Content-Security-Policy' => 'frame-ancestors ' . $frameAncestors
|
||||
]);
|
||||
}
|
||||
}
|
||||
21
kirby/src/Panel/Drawer.php
Normal file
21
kirby/src/Panel/Drawer.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel;
|
||||
|
||||
use Kirby\Http\Response;
|
||||
|
||||
/**
|
||||
* The Drawer response class handles Fiber
|
||||
* requests to render the JSON object for
|
||||
* Panel drawers
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Drawer extends Dialog
|
||||
{
|
||||
protected static string $key = '$drawer';
|
||||
}
|
||||
71
kirby/src/Panel/Dropdown.php
Normal file
71
kirby/src/Panel/Dropdown.php
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel;
|
||||
|
||||
use Closure;
|
||||
use Kirby\Http\Response;
|
||||
|
||||
/**
|
||||
* The Dropdown response class handles Fiber
|
||||
* requests to render the JSON object for
|
||||
* dropdown menus
|
||||
* @since 3.6.0
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Dropdown extends Json
|
||||
{
|
||||
protected static string $key = '$dropdown';
|
||||
|
||||
/**
|
||||
* Renders dropdowns
|
||||
*/
|
||||
public static function response($data, array $options = []): Response
|
||||
{
|
||||
if (is_array($data) === true) {
|
||||
$data = [
|
||||
'options' => array_values($data)
|
||||
];
|
||||
}
|
||||
|
||||
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']
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
313
kirby/src/Panel/Field.php
Normal file
313
kirby/src/Panel/Field.php
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\File;
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Cms\Roles;
|
||||
use Kirby\Form\Form;
|
||||
use Kirby\Http\Router;
|
||||
use Kirby\Toolkit\I18n;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* Provides common field prop definitions
|
||||
* for dialogs and other places
|
||||
* @since 3.6.0
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
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 = [
|
||||
...$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 = [
|
||||
...$routes,
|
||||
...Drawer::routes(
|
||||
id: $drawerId,
|
||||
areaId: 'site',
|
||||
options: $drawer
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
return Router::execute($path, $method, $routes);
|
||||
}
|
||||
|
||||
/**
|
||||
* A standard email field
|
||||
*/
|
||||
public static function email(array $props = []): array
|
||||
{
|
||||
return [
|
||||
'label' => I18n::translate('email'),
|
||||
'type' => 'email',
|
||||
'counter' => false,
|
||||
...$props
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* File position
|
||||
*/
|
||||
public static function filePosition(File $file, array $props = []): array
|
||||
{
|
||||
$index = 0;
|
||||
$options = [];
|
||||
|
||||
foreach ($file->siblings(false)->sorted() as $sibling) {
|
||||
$index++;
|
||||
|
||||
$options[] = [
|
||||
'value' => $index,
|
||||
'text' => $index
|
||||
];
|
||||
|
||||
$options[] = [
|
||||
'value' => $sibling->id(),
|
||||
'text' => $sibling->filename(),
|
||||
'disabled' => true
|
||||
];
|
||||
}
|
||||
|
||||
$index++;
|
||||
|
||||
$options[] = [
|
||||
'value' => $index,
|
||||
'text' => $index
|
||||
];
|
||||
|
||||
return [
|
||||
'label' => I18n::translate('file.sort'),
|
||||
'type' => 'select',
|
||||
'empty' => false,
|
||||
'options' => $options,
|
||||
...$props
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
public static function hidden(): array
|
||||
{
|
||||
return ['hidden' => true];
|
||||
}
|
||||
|
||||
/**
|
||||
* Page position
|
||||
*/
|
||||
public static function pagePosition(Page $page, array $props = []): array
|
||||
{
|
||||
$index = 0;
|
||||
$options = [];
|
||||
$siblings = $page->parentModel()->children()->listed()->not($page);
|
||||
|
||||
foreach ($siblings as $sibling) {
|
||||
$index++;
|
||||
|
||||
$options[] = [
|
||||
'value' => $index,
|
||||
'text' => $index
|
||||
];
|
||||
|
||||
$options[] = [
|
||||
'value' => $sibling->id(),
|
||||
'text' => $sibling->title()->value(),
|
||||
'disabled' => true
|
||||
];
|
||||
}
|
||||
|
||||
$index++;
|
||||
|
||||
$options[] = [
|
||||
'value' => $index,
|
||||
'text' => $index
|
||||
];
|
||||
|
||||
// if only one available option,
|
||||
// hide field when not in debug mode
|
||||
if (count($options) < 2) {
|
||||
return static::hidden();
|
||||
}
|
||||
|
||||
return [
|
||||
'label' => I18n::translate('page.changeStatus.position'),
|
||||
'type' => 'select',
|
||||
'empty' => false,
|
||||
'options' => $options,
|
||||
...$props
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A regular password field
|
||||
*/
|
||||
public static function password(array $props = []): array
|
||||
{
|
||||
return [
|
||||
'label' => I18n::translate('password'),
|
||||
'type' => 'password',
|
||||
...$props
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* User role radio buttons
|
||||
*/
|
||||
public static function role(
|
||||
array $props = [],
|
||||
Roles|null $roles = null
|
||||
): array {
|
||||
$kirby = App::instance();
|
||||
|
||||
// if no $roles where provided, fall back to all roles
|
||||
$roles ??= $kirby->roles();
|
||||
|
||||
// exclude the admin role, if the user
|
||||
// is not allowed to change role to admin
|
||||
$roles = $roles->filter(
|
||||
fn ($role) =>
|
||||
$role->name() !== 'admin' ||
|
||||
$kirby->user()?->isAdmin() === true
|
||||
);
|
||||
|
||||
// turn roles into radio field options
|
||||
$roles = $roles->values(fn ($role) => [
|
||||
'text' => $role->title(),
|
||||
'info' => $role->description() ?? I18n::translate('role.description.placeholder'),
|
||||
'value' => $role->name()
|
||||
]);
|
||||
|
||||
return [
|
||||
'label' => I18n::translate('role'),
|
||||
'type' => count($roles) < 1 ? 'hidden' : 'radio',
|
||||
'options' => $roles,
|
||||
...$props
|
||||
];
|
||||
}
|
||||
|
||||
public static function slug(array $props = []): array
|
||||
{
|
||||
return [
|
||||
'label' => I18n::translate('slug'),
|
||||
'type' => 'slug',
|
||||
'allow' => Str::$defaults['slug']['allowed'],
|
||||
...$props
|
||||
];
|
||||
}
|
||||
|
||||
public static function template(
|
||||
array|null $blueprints = [],
|
||||
array|null $props = []
|
||||
): array {
|
||||
$options = [];
|
||||
|
||||
foreach ($blueprints as $blueprint) {
|
||||
$options[] = [
|
||||
'text' => $blueprint['title'] ?? $blueprint['text'] ?? null,
|
||||
'value' => $blueprint['name'] ?? $blueprint['value'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'label' => I18n::translate('template'),
|
||||
'type' => 'select',
|
||||
'empty' => false,
|
||||
'options' => $options,
|
||||
'icon' => 'template',
|
||||
'disabled' => count($options) <= 1,
|
||||
...$props
|
||||
];
|
||||
}
|
||||
|
||||
public static function title(array $props = []): array
|
||||
{
|
||||
return [
|
||||
'label' => I18n::translate('title'),
|
||||
'type' => 'text',
|
||||
'icon' => 'title',
|
||||
...$props
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Panel translation select box
|
||||
*/
|
||||
public static function translation(array $props = []): array
|
||||
{
|
||||
$translations = [];
|
||||
foreach (App::instance()->translations() as $translation) {
|
||||
$translations[] = [
|
||||
'text' => $translation->name(),
|
||||
'value' => $translation->code()
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'label' => I18n::translate('language'),
|
||||
'type' => 'select',
|
||||
'icon' => 'translate',
|
||||
'options' => $translations,
|
||||
'empty' => false,
|
||||
...$props
|
||||
];
|
||||
}
|
||||
|
||||
public static function username(array $props = []): array
|
||||
{
|
||||
return [
|
||||
'icon' => 'user',
|
||||
'label' => I18n::translate('name'),
|
||||
'type' => 'text',
|
||||
...$props
|
||||
];
|
||||
}
|
||||
}
|
||||
482
kirby/src/Panel/File.php
Normal file
482
kirby/src/Panel/File.php
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel;
|
||||
|
||||
use Kirby\Cms\File as CmsFile;
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
use Kirby\Filesystem\Asset;
|
||||
use Kirby\Panel\Ui\Buttons\ViewButtons;
|
||||
use Kirby\Panel\Ui\FilePreview;
|
||||
use Kirby\Panel\Ui\Item\FileItem;
|
||||
use Kirby\Toolkit\I18n;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Provides information about the file model for the Panel
|
||||
* @since 3.6.0
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class File extends Model
|
||||
{
|
||||
/**
|
||||
* @var \Kirby\Cms\File
|
||||
*/
|
||||
protected ModelWithContent $model;
|
||||
|
||||
/**
|
||||
* Breadcrumb array
|
||||
*/
|
||||
public function breadcrumb(): array
|
||||
{
|
||||
$breadcrumb = [];
|
||||
$parent = $this->model->parent();
|
||||
|
||||
switch ($parent::CLASS_ALIAS) {
|
||||
case 'user':
|
||||
/** @var \Kirby\Cms\User $parent */
|
||||
// The breadcrumb is not necessary
|
||||
// on the account view
|
||||
if ($parent->isLoggedIn() === false) {
|
||||
$breadcrumb[] = [
|
||||
'label' => $parent->username(),
|
||||
'link' => $parent->panel()->url(true)
|
||||
];
|
||||
}
|
||||
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),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// add the file
|
||||
$breadcrumb[] = [
|
||||
'label' => $this->model->filename(),
|
||||
'link' => $this->url(true),
|
||||
];
|
||||
|
||||
return $breadcrumb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns header button names which should be displayed
|
||||
* on the file view
|
||||
*/
|
||||
public function buttons(): array
|
||||
{
|
||||
return ViewButtons::view($this)->defaults(
|
||||
'open',
|
||||
'settings',
|
||||
'languages'
|
||||
)->render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a kirbytag or markdown
|
||||
* tag for the file, which will be
|
||||
* used in the panel, when the file
|
||||
* gets dragged onto a textarea
|
||||
*
|
||||
* @param string|null $type (`auto`|`kirbytext`|`markdown`)
|
||||
*/
|
||||
public function dragText(
|
||||
string|null $type = 'auto',
|
||||
bool $absolute = false
|
||||
): string {
|
||||
$type = $this->dragTextType($type);
|
||||
$file = $this->model->type();
|
||||
$url = match ($type) {
|
||||
'markdown' => $this->model->permalink(),
|
||||
default => $this->model->uuid()
|
||||
};
|
||||
|
||||
// if UUIDs are disabled, fall back to the filename
|
||||
// as relative link or the full absolute URL
|
||||
$url ??= match ($absolute) {
|
||||
false => $this->model->filename(),
|
||||
default => $this->model->url()
|
||||
};
|
||||
|
||||
|
||||
if ($callback = $this->dragTextFromCallback($type, $url)) {
|
||||
return $callback;
|
||||
}
|
||||
|
||||
if ($type === 'markdown') {
|
||||
return match ($file) {
|
||||
'image' => '',
|
||||
default => '[' . $this->model->filename() . '](' . $url . ')'
|
||||
};
|
||||
}
|
||||
|
||||
return match ($file) {
|
||||
'image', 'video' => '(' . $file . ': ' . $url . ')',
|
||||
default => '(file: ' . $url . ')'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides options for the file dropdown
|
||||
*/
|
||||
public function dropdown(array $options = []): array
|
||||
{
|
||||
$file = $this->model;
|
||||
$request = $file->kirby()->request();
|
||||
$defaults = $request->get(['delete', 'sort', 'view']);
|
||||
$options = [...$defaults, ...$options];
|
||||
|
||||
$permissions = $this->options(['preview']);
|
||||
$view = $options['view'] ?? 'view';
|
||||
$url = $this->url(true);
|
||||
$result = [];
|
||||
|
||||
if ($view === 'list') {
|
||||
$result[] = [
|
||||
'link' => $file->previewUrl(),
|
||||
'target' => '_blank',
|
||||
'icon' => 'open',
|
||||
'text' => I18n::translate('open')
|
||||
];
|
||||
$result[] = '-';
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'dialog' => $url . '/changeName',
|
||||
'icon' => 'title',
|
||||
'text' => I18n::translate('rename'),
|
||||
'disabled' => $this->isDisabledDropdownOption('changeName', $options, $permissions)
|
||||
];
|
||||
|
||||
if ($view === 'list') {
|
||||
$result[] = [
|
||||
'dialog' => $url . '/changeSort',
|
||||
'icon' => 'sort',
|
||||
'text' => I18n::translate('file.sort'),
|
||||
'disabled' => $this->isDisabledDropdownOption('sort', $options, $permissions)
|
||||
];
|
||||
}
|
||||
|
||||
$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',
|
||||
'icon' => 'trash',
|
||||
'text' => I18n::translate('delete'),
|
||||
'disabled' => $this->isDisabledDropdownOption('delete', $options, $permissions)
|
||||
];
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the setup for a dropdown option
|
||||
* which is used in the changes dropdown
|
||||
* for example
|
||||
*/
|
||||
public function dropdownOption(): array
|
||||
{
|
||||
return [
|
||||
'icon' => 'image',
|
||||
'text' => $this->model->filename(),
|
||||
] + parent::dropdownOption();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Panel icon color
|
||||
*/
|
||||
protected function imageColor(): string
|
||||
{
|
||||
$types = [
|
||||
'archive' => 'gray-500',
|
||||
'audio' => 'aqua-500',
|
||||
'code' => 'pink-500',
|
||||
'document' => 'red-500',
|
||||
'image' => 'orange-500',
|
||||
'video' => 'yellow-500',
|
||||
];
|
||||
|
||||
$extensions = [
|
||||
'csv' => 'green-500',
|
||||
'doc' => 'blue-500',
|
||||
'docx' => 'blue-500',
|
||||
'indd' => 'purple-500',
|
||||
'rtf' => 'blue-500',
|
||||
'xls' => 'green-500',
|
||||
'xlsx' => 'green-500',
|
||||
];
|
||||
|
||||
return
|
||||
$extensions[$this->model->extension()] ??
|
||||
$types[$this->model->type()] ??
|
||||
parent::imageDefaults()['color'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Default settings for the file's Panel image
|
||||
*/
|
||||
protected function imageDefaults(): array
|
||||
{
|
||||
return [
|
||||
...parent::imageDefaults(),
|
||||
'color' => $this->imageColor(),
|
||||
'icon' => $this->imageIcon(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Panel icon type
|
||||
*/
|
||||
protected function imageIcon(): string
|
||||
{
|
||||
$types = [
|
||||
'archive' => 'archive',
|
||||
'audio' => 'audio',
|
||||
'code' => 'code',
|
||||
'document' => 'document',
|
||||
'image' => 'image',
|
||||
'video' => 'video',
|
||||
];
|
||||
|
||||
$extensions = [
|
||||
'csv' => 'table',
|
||||
'doc' => 'pen',
|
||||
'docx' => 'pen',
|
||||
'md' => 'markdown',
|
||||
'mdown' => 'markdown',
|
||||
'rtf' => 'pen',
|
||||
'xls' => 'table',
|
||||
'xlsx' => 'table',
|
||||
];
|
||||
|
||||
return
|
||||
$extensions[$this->model->extension()] ??
|
||||
$types[$this->model->type()] ??
|
||||
'file';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the image file object based on provided query
|
||||
*/
|
||||
protected function imageSource(
|
||||
string|null $query = null
|
||||
): CmsFile|Asset|null {
|
||||
if ($query === null && $this->model->isViewable()) {
|
||||
return $this->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
|
||||
*/
|
||||
public function options(array $unlock = []): array
|
||||
{
|
||||
$options = parent::options($unlock);
|
||||
|
||||
try {
|
||||
// check if the file type is allowed at all,
|
||||
// otherwise it cannot be replaced
|
||||
$this->model->match($this->model->blueprint()->accept());
|
||||
} catch (Throwable) {
|
||||
$options['replace'] = false;
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full path without leading slash
|
||||
*/
|
||||
public function path(): string
|
||||
{
|
||||
return 'files/' . $this->model->filename();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the response data for file pickers
|
||||
* and file fields
|
||||
*/
|
||||
public function pickerData(array $params = []): array
|
||||
{
|
||||
$name = $this->model->filename();
|
||||
$id = $this->model->id();
|
||||
$absolute = false;
|
||||
|
||||
if (empty($params['model']) === false) {
|
||||
$parent = $this->model->parent();
|
||||
$absolute = $parent !== $params['model'];
|
||||
|
||||
// if the file belongs to the current parent model,
|
||||
// store only name as ID to keep its path relative to the model
|
||||
$id = match ($absolute) {
|
||||
true => $id,
|
||||
false => $name
|
||||
};
|
||||
}
|
||||
|
||||
$item = new FileItem(
|
||||
file: $this->model,
|
||||
dragTextIsAbsolute: $absolute,
|
||||
image: $params['image'] ?? null,
|
||||
info: $params['info'] ?? null,
|
||||
layout: $params['layout'] ?? null,
|
||||
text: $params['text'] ?? null,
|
||||
);
|
||||
|
||||
return [
|
||||
...$item->props(),
|
||||
'id' => $id,
|
||||
'sortable' => true,
|
||||
'type' => $this->model->type(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the data array for the view's component props
|
||||
*/
|
||||
public function props(): array
|
||||
{
|
||||
$props = parent::props();
|
||||
$file = $this->model;
|
||||
|
||||
// Additional model information
|
||||
// @deprecated Use the top-level props instead
|
||||
$model = [
|
||||
'dimensions' => $file->dimensions()->toArray(),
|
||||
'extension' => $file->extension(),
|
||||
'filename' => $file->filename(),
|
||||
'link' => $props['link'],
|
||||
'mime' => $file->mime(),
|
||||
'niceSize' => $file->niceSize(),
|
||||
'id' => $props['id'],
|
||||
'parent' => $file->parent()->panel()->path(),
|
||||
'template' => $file->template(),
|
||||
'type' => $file->type(),
|
||||
'url' => $file->url(),
|
||||
'uuid' => $props['uuid'],
|
||||
];
|
||||
|
||||
return [
|
||||
...$props,
|
||||
...$this->prevNext(),
|
||||
'blueprint' => $this->model->template() ?? 'default',
|
||||
'extension' => $model['extension'],
|
||||
'filename' => $model['filename'],
|
||||
'mime' => $model['mime'],
|
||||
'model' => $model,
|
||||
'preview' => FilePreview::factory($this->model)->render(),
|
||||
'type' => $model['type'],
|
||||
'url' => $model['url'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns navigation array with previous and next file
|
||||
*/
|
||||
public function prevNext(): array
|
||||
{
|
||||
$file = $this->model;
|
||||
$siblings = $file->templateSiblings()->sortBy(
|
||||
'sort',
|
||||
'asc',
|
||||
'filename',
|
||||
'asc'
|
||||
);
|
||||
|
||||
return [
|
||||
'next' => function () use ($file, $siblings): array|null {
|
||||
$next = $siblings->nth($siblings->indexOf($file) + 1);
|
||||
return $this->toPrevNextLink($next, 'filename');
|
||||
},
|
||||
'prev' => function () use ($file, $siblings): array|null {
|
||||
$prev = $siblings->nth($siblings->indexOf($file) - 1);
|
||||
return $this->toPrevNextLink($prev, 'filename');
|
||||
}
|
||||
];
|
||||
}
|
||||
/**
|
||||
* Returns the url to the editing view
|
||||
* in the panel
|
||||
*/
|
||||
public function url(bool $relative = false): string
|
||||
{
|
||||
$parent = $this->model->parent()->panel()->url($relative);
|
||||
return $parent . '/' . $this->path();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the data array for this model's Panel view
|
||||
*/
|
||||
public function view(): array
|
||||
{
|
||||
return [
|
||||
'breadcrumb' => fn (): array => $this->model->panel()->breadcrumb(),
|
||||
'component' => 'k-file-view',
|
||||
'props' => $this->props(),
|
||||
'search' => 'files',
|
||||
'title' => $this->model->filename(),
|
||||
];
|
||||
}
|
||||
}
|
||||
259
kirby/src/Panel/Home.php
Normal file
259
kirby/src/Panel/Home.php
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\User;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Exception\NotFoundException;
|
||||
use Kirby\Http\Router;
|
||||
use Kirby\Http\Uri;
|
||||
use Kirby\Toolkit\Str;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* The Home class creates the secure redirect
|
||||
* URL after logins. The URL can either come
|
||||
* from the session to remember the last view
|
||||
* before the automatic logout, or from a user
|
||||
* blueprint to redirect to custom views.
|
||||
*
|
||||
* The Home class also makes sure to check access
|
||||
* before a redirect happens and avoids redirects
|
||||
* to inaccessible views.
|
||||
* @since 3.6.0
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Home
|
||||
{
|
||||
/**
|
||||
* Returns an alternative URL if access
|
||||
* to the first choice is blocked.
|
||||
*
|
||||
* It will go through the entire menu and
|
||||
* take the first area which is not disabled
|
||||
* or locked in other ways
|
||||
*/
|
||||
public static function alternative(User $user): string
|
||||
{
|
||||
$permissions = $user->role()->permissions();
|
||||
|
||||
// no access to the panel? The only good alternative is the main url
|
||||
if ($permissions->for('access', 'panel') === false) {
|
||||
return App::instance()->site()->url();
|
||||
}
|
||||
|
||||
// needed to create a proper menu
|
||||
$areas = Panel::areas();
|
||||
$menu = new Menu($areas, $permissions->toArray());
|
||||
$menu = $menu->entries();
|
||||
|
||||
// go through the menu and search for the first
|
||||
// available view we can go to
|
||||
foreach ($menu as $menuItem) {
|
||||
// skip separators
|
||||
if ($menuItem === '-') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// skip disabled items
|
||||
if (($menuItem['disabled'] ?? false) === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// skip buttons that don't open a link
|
||||
// (but e.g. a dialog)
|
||||
if (isset($menuItem['link']) === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// skip the logout button
|
||||
if ($menuItem['link'] === 'logout') {
|
||||
continue;
|
||||
}
|
||||
|
||||
return Panel::url($menuItem['link']);
|
||||
}
|
||||
|
||||
throw new NotFoundException(
|
||||
message: 'There’s no available Panel page to redirect to'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user has access to the given
|
||||
* panel path. This is quite tricky, because we
|
||||
* need to call a trimmed down router to check
|
||||
* for available routes and their firewall status.
|
||||
*/
|
||||
public static function hasAccess(User $user, string $path): bool
|
||||
{
|
||||
$areas = Panel::areas();
|
||||
$routes = Panel::routes($areas);
|
||||
|
||||
// Remove fallback routes. Otherwise a route
|
||||
// would be found even if the view does
|
||||
// not exist at all.
|
||||
foreach ($routes as $index => $route) {
|
||||
if ($route['pattern'] === '(:all)') {
|
||||
unset($routes[$index]);
|
||||
}
|
||||
}
|
||||
|
||||
// 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';
|
||||
|
||||
// only allow redirects to views
|
||||
if ($type !== 'view') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// if auth is not required the redirect is allowed
|
||||
if ($auth === false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// check the firewall
|
||||
return Panel::hasAccess($user, $areaId);
|
||||
});
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given Uri has the same domain
|
||||
* as the index URL of the Kirby installation.
|
||||
* This is used to block external URLs to third-party
|
||||
* domains as redirect options.
|
||||
*/
|
||||
public static function hasValidDomain(Uri $uri): bool
|
||||
{
|
||||
$rootUrl = App::instance()->site()->url();
|
||||
$rootUri = new Uri($rootUrl);
|
||||
return $uri->domain() === $rootUri->domain();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given URL is a Panel Url
|
||||
*/
|
||||
public static function isPanelUrl(string $url): bool
|
||||
{
|
||||
$panel = App::instance()->url('panel');
|
||||
return Str::startsWith($url, $panel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path after /panel/ which can then
|
||||
* be used in the router or to find a matching view
|
||||
*/
|
||||
public static function panelPath(string $url): string|null
|
||||
{
|
||||
$after = Str::after($url, App::instance()->url('panel'));
|
||||
return trim($after, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Url that has been stored in the session
|
||||
* 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.
|
||||
*/
|
||||
public static function remembered(): string|null
|
||||
{
|
||||
// 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);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to find the best possible Url to redirect
|
||||
* the user to after the login.
|
||||
*
|
||||
* When the user got logged out, we try to send them back
|
||||
* to the point where they left.
|
||||
*
|
||||
* If they have a custom redirect Url defined in their blueprint
|
||||
* via the `home` option, we send them there if no Url is stored
|
||||
* in the session.
|
||||
*
|
||||
* If none of the options above find any result, we try to send
|
||||
* them to the site view.
|
||||
*
|
||||
* Before the redirect happens, the final Url is sanitized, the query
|
||||
* and params are removed to avoid any attacks and the domain is compared
|
||||
* to avoid redirects to external Urls.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
public static function url(): string
|
||||
{
|
||||
$user = App::instance()->user();
|
||||
|
||||
// if there's no authenticated user, all internal
|
||||
// redirects will be blocked and the user is redirected
|
||||
// to the login instead
|
||||
if (!$user) {
|
||||
return Panel::url('login');
|
||||
}
|
||||
|
||||
// get the last visited url from the session or the custom home
|
||||
$url = static::remembered() ?? $user->panel()->home();
|
||||
|
||||
// inspect the given URL
|
||||
$uri = new Uri($url);
|
||||
|
||||
// compare domains to avoid external redirects
|
||||
if (static::hasValidDomain($uri) !== true) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'External URLs are not allowed for Panel redirects'
|
||||
);
|
||||
}
|
||||
|
||||
// remove all params to avoid
|
||||
// possible attack vectors
|
||||
$uri->params = '';
|
||||
$uri->query = '';
|
||||
|
||||
// get a clean version of the URL
|
||||
$url = $uri->toString();
|
||||
|
||||
// Don't further inspect URLs outside of the Panel
|
||||
if (static::isPanelUrl($url) === false) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
// get the plain panel path
|
||||
$path = static::panelPath($url);
|
||||
|
||||
// a redirect to login, logout or installation
|
||||
// views would lead to an infinite redirect loop
|
||||
if (in_array($path, ['', 'login', 'logout', 'installation'], true) === true) {
|
||||
$path = 'site';
|
||||
}
|
||||
|
||||
// Check if the user can access the URL
|
||||
if (static::hasAccess($user, $path) === true) {
|
||||
return Panel::url($path);
|
||||
}
|
||||
|
||||
// Try to find an alternative
|
||||
return static::alternative($user);
|
||||
}
|
||||
}
|
||||
84
kirby/src/Panel/Json.php
Normal file
84
kirby/src/Panel/Json.php
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<?php
|
||||
|
||||
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
|
||||
* to render the JSON object for, e.g.
|
||||
* Panel dialogs, dropdowns etc.
|
||||
* @since 3.6.0
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
abstract class Json
|
||||
{
|
||||
protected static string $key = '$response';
|
||||
|
||||
/**
|
||||
* Renders the error response with the provided message
|
||||
*/
|
||||
public static function error(string $message, int $code = 404): array
|
||||
{
|
||||
return [
|
||||
'code' => $code,
|
||||
'error' => $message
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the JSON response for the Panel
|
||||
*/
|
||||
public static function response($data, array $options = []): Response
|
||||
{
|
||||
$data = static::responseData($data);
|
||||
|
||||
// always inject the response code
|
||||
$data['code'] ??= 200;
|
||||
$data['path'] = $options['path'] ?? null;
|
||||
$data['query'] = App::instance()->request()->query()->toArray();
|
||||
$data['referrer'] = Panel::referrer();
|
||||
|
||||
return Panel::json([static::$key => $data], $data['code']);
|
||||
}
|
||||
|
||||
public static function responseData(mixed $data): array
|
||||
{
|
||||
// handle redirects
|
||||
if ($data instanceof Redirect) {
|
||||
return [
|
||||
'redirect' => $data->location(),
|
||||
];
|
||||
}
|
||||
|
||||
// handle Kirby exceptions
|
||||
if ($data instanceof Exception) {
|
||||
return static::error($data->getMessage(), $data->getHttpCode());
|
||||
}
|
||||
|
||||
// handle exceptions
|
||||
if ($data instanceof Throwable) {
|
||||
return static::error($data->getMessage(), 500);
|
||||
}
|
||||
|
||||
// only expect arrays from here on
|
||||
if (is_array($data) === false) {
|
||||
return static::error('Invalid response', 500);
|
||||
}
|
||||
|
||||
if ($data === []) {
|
||||
return static::error('The response is empty', 404);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
134
kirby/src/Panel/Lab/Category.php
Normal file
134
kirby/src/Panel/Lab/Category.php
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Lab;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Filesystem\Dir;
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Toolkit\A;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* Category of lab examples located in
|
||||
* `kirby/panel/lab` and `site/lab`.
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 4.0.0
|
||||
* @internal
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
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 (F::exists($this->root . '/index.php', static::base()) === 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 isInstalled(): 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')
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
194
kirby/src/Panel/Lab/Doc.php
Normal file
194
kirby/src/Panel/Lab/Doc.php
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Lab;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Data\Data;
|
||||
use Kirby\Panel\Lab\Doc\Event;
|
||||
use Kirby\Panel\Lab\Doc\Method;
|
||||
use Kirby\Panel\Lab\Doc\Prop;
|
||||
use Kirby\Panel\Lab\Doc\Slot;
|
||||
use Kirby\Toolkit\A;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* Documentation for a single Vue component
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
* @internal
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class Doc
|
||||
{
|
||||
protected array $data;
|
||||
|
||||
public function __construct(
|
||||
public string $name,
|
||||
public string $source,
|
||||
public string|null $description = null,
|
||||
public string|null $deprecated = null,
|
||||
public string|null $docBlock = null,
|
||||
public array $events = [],
|
||||
public array $examples = [],
|
||||
public bool $isUnstable = false,
|
||||
public array $methods = [],
|
||||
public array $props = [],
|
||||
public string|null $since = null,
|
||||
public array $slots = [],
|
||||
) {
|
||||
$this->description = Doc::kt($this->description ?? '');
|
||||
$this->deprecated = Doc::kt($this->deprecated ?? '');
|
||||
$this->docBlock = Doc::kt($this->docBlock ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a documentation file exists for the component
|
||||
*/
|
||||
public static function exists(string $name): bool
|
||||
{
|
||||
return
|
||||
file_exists(static::file($name, 'dist')) ||
|
||||
file_exists(static::file($name, 'dev'));
|
||||
}
|
||||
|
||||
public static function factory(string $name): static|null
|
||||
{
|
||||
// protect against path traversal
|
||||
$name = basename($name);
|
||||
|
||||
// read data
|
||||
$file = static::file($name, 'dev');
|
||||
|
||||
if (file_exists($file) === false) {
|
||||
$file = static::file($name, 'dist');
|
||||
}
|
||||
|
||||
$data = Data::read($file);
|
||||
|
||||
// filter internal components
|
||||
if (isset($data['tags']['internal']) === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// helper function for gathering parts
|
||||
$gather = function (string $part, string $class) use ($data) {
|
||||
$parts = A::map(
|
||||
$data[$part] ?? [],
|
||||
fn ($x) => $class::factory($x)?->toArray()
|
||||
);
|
||||
|
||||
$parts = array_filter($parts);
|
||||
usort($parts, fn ($a, $b) => $a['name'] <=> $b['name']);
|
||||
|
||||
return $parts;
|
||||
};
|
||||
|
||||
return new static(
|
||||
name: $name,
|
||||
source: $data['sourceFile'],
|
||||
description: $data['description'] ?? null,
|
||||
deprecated: $data['tags']['deprecated'][0]['description'] ?? null,
|
||||
docBlock: $data['docsBlocks'][0] ?? null,
|
||||
examples: $data['tags']['examples'] ?? [],
|
||||
events: $gather('events', Event::class),
|
||||
isUnstable: isset($data['tags']['unstable']) === true,
|
||||
methods: $gather('methods', Method::class),
|
||||
props: $gather('props', Prop::class),
|
||||
since: $data['tags']['since'][0]['description'] ?? null,
|
||||
slots: $gather('slots', Slot::class)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the documentation file for the component
|
||||
*/
|
||||
public static function file(string $name, string $context): string
|
||||
{
|
||||
$root = match ($context) {
|
||||
'dev' => App::instance()->root('panel') . '/tmp',
|
||||
'dist' => App::instance()->root('panel') . '/dist/ui',
|
||||
};
|
||||
|
||||
$name = Str::after($name, 'k-');
|
||||
$name = Str::kebabToCamel($name);
|
||||
return $root . '/' . $name . '.json';
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to resolve KirbyText
|
||||
*/
|
||||
public static function kt(string $text, bool $inline = false): string
|
||||
{
|
||||
return App::instance()->kirbytext($text, [
|
||||
'markdown' => [
|
||||
'breaks' => false,
|
||||
'inline' => $inline,
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the Lab examples, if available
|
||||
*/
|
||||
public function lab(): string|null
|
||||
{
|
||||
$root = App::instance()->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 source(): string
|
||||
{
|
||||
return 'https://github.com/getkirby/kirby/tree/main/panel/' . $this->source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the data for this documentation
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'component' => $this->name,
|
||||
'deprecated' => $this->deprecated,
|
||||
'description' => $this->description,
|
||||
'docBlock' => $this->docBlock,
|
||||
'events' => $this->events,
|
||||
'examples' => $this->examples,
|
||||
'isUnstable' => $this->isUnstable,
|
||||
'methods' => $this->methods,
|
||||
'props' => $this->props,
|
||||
'since' => $this->since,
|
||||
'slots' => $this->slots,
|
||||
'source' => $this->source(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the information to display as
|
||||
* entry in a collection (e.g. on the Lab index view)
|
||||
*/
|
||||
public function toItem(): array
|
||||
{
|
||||
return [
|
||||
'image' => [
|
||||
'icon' => $this->isUnstable ? 'lab' : 'book',
|
||||
'back' => 'light-dark(white, var(--color-gray-800))',
|
||||
],
|
||||
'text' => $this->name,
|
||||
'link' => '/lab/docs/' . $this->name,
|
||||
];
|
||||
}
|
||||
}
|
||||
46
kirby/src/Panel/Lab/Doc/Argument.php
Normal file
46
kirby/src/Panel/Lab/Doc/Argument.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Lab\Doc;
|
||||
|
||||
use Kirby\Panel\Lab\Doc;
|
||||
|
||||
/**
|
||||
* Documentation for a single argument for an event, slot or method
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
* @internal
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class Argument
|
||||
{
|
||||
public function __construct(
|
||||
public string $name,
|
||||
public string|null $type = null,
|
||||
public string|null $description = null,
|
||||
) {
|
||||
$this->description = Doc::kt($this->description ?? '', true);
|
||||
}
|
||||
|
||||
public static function factory(array $data): static
|
||||
{
|
||||
return new static(
|
||||
name: $data['name'],
|
||||
type: $data['type']['names'][0] ?? null,
|
||||
description: $data['description'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'type' => $this->type,
|
||||
];
|
||||
}
|
||||
}
|
||||
57
kirby/src/Panel/Lab/Doc/Event.php
Normal file
57
kirby/src/Panel/Lab/Doc/Event.php
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Lab\Doc;
|
||||
|
||||
use Kirby\Panel\Lab\Doc;
|
||||
use Kirby\Toolkit\A;
|
||||
|
||||
/**
|
||||
* Documentation for a single Vue emittable event
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
* @internal
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class Event
|
||||
{
|
||||
public function __construct(
|
||||
public string $name,
|
||||
public string|null $description = null,
|
||||
public string|null $deprecated = null,
|
||||
public string|null $since = null,
|
||||
public array $properties = [],
|
||||
) {
|
||||
$this->description = Doc::kt($this->description ?? '');
|
||||
$this->deprecated = Doc::kt($this->deprecated ?? '');
|
||||
}
|
||||
|
||||
public static function factory(array $data): static
|
||||
{
|
||||
return new static(
|
||||
name: $data['name'],
|
||||
description: $data['description'] ?? null,
|
||||
deprecated: $data['tags']['deprecated'][0]['description'] ?? null,
|
||||
since: $data['tags']['since'][0]['description'] ?? null,
|
||||
properties: A::map(
|
||||
$data['properties'] ?? [],
|
||||
fn ($property) => Argument::factory($property)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'deprecated' => $this->deprecated,
|
||||
'properties' => $this->properties,
|
||||
'since' => $this->since,
|
||||
];
|
||||
}
|
||||
}
|
||||
60
kirby/src/Panel/Lab/Doc/Method.php
Normal file
60
kirby/src/Panel/Lab/Doc/Method.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Lab\Doc;
|
||||
|
||||
use Kirby\Panel\Lab\Doc;
|
||||
use Kirby\Toolkit\A;
|
||||
|
||||
/**
|
||||
* Documentation for a single Vue component method
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
* @internal
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class Method
|
||||
{
|
||||
public function __construct(
|
||||
public string $name,
|
||||
public string|null $description = null,
|
||||
public string|null $deprecated = null,
|
||||
public string|null $since = null,
|
||||
public string|null $returns = null,
|
||||
public array $params = [],
|
||||
) {
|
||||
$this->description = Doc::kt($this->description ?? '');
|
||||
$this->deprecated = Doc::kt($this->deprecated ?? '');
|
||||
}
|
||||
|
||||
public static function factory(array $data): static
|
||||
{
|
||||
return new static(
|
||||
name: $data['name'],
|
||||
description: $data['description'] ?? null,
|
||||
deprecated: $data['tags']['deprecated'][0]['description'] ?? null,
|
||||
since: $data['tags']['since'][0]['description'] ?? null,
|
||||
returns: $data['returns']['type']['name'] ?? null,
|
||||
params: A::map(
|
||||
$data['params'] ?? [],
|
||||
fn ($param) => Argument::factory($param)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'deprecated' => $this->deprecated,
|
||||
'params' => $this->params,
|
||||
'returns' => $this->returns,
|
||||
'since' => $this->since,
|
||||
];
|
||||
}
|
||||
}
|
||||
113
kirby/src/Panel/Lab/Doc/Prop.php
Normal file
113
kirby/src/Panel/Lab/Doc/Prop.php
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Lab\Doc;
|
||||
|
||||
use Kirby\Panel\Lab\Doc;
|
||||
use Kirby\Toolkit\A;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* Documentation for a single Vue component prop
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
* @internal
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class Prop
|
||||
{
|
||||
public function __construct(
|
||||
public string $name,
|
||||
public string|null $type = null,
|
||||
public string|null $description = null,
|
||||
public string|null $default = null,
|
||||
public string|null $deprecated = null,
|
||||
public string|null $example = null,
|
||||
public bool $required = false,
|
||||
public string|null $since = null,
|
||||
public string|null $value = null,
|
||||
public array $values = []
|
||||
) {
|
||||
$this->description = Doc::kt($this->description ?? '');
|
||||
$this->deprecated = Doc::kt($this->deprecated ?? '');
|
||||
}
|
||||
|
||||
public static function factory(array $data): static|null
|
||||
{
|
||||
// filter internal props
|
||||
if (isset($data['tags']['internal']) === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// filter unset props
|
||||
if (($type = $data['type']['name'] ?? null) === 'null') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new static(
|
||||
name: $data['name'],
|
||||
type: $type,
|
||||
default: self::normalizeDefault($data['defaultValue']['value'] ?? null, $type),
|
||||
description: $data['description'] ?? null,
|
||||
deprecated: $data['tags']['deprecated'][0]['description'] ?? null,
|
||||
example: $data['tags']['example'][0]['description'] ?? null,
|
||||
required: $data['required'] ?? false,
|
||||
since: $data['tags']['since'][0]['description'] ?? null,
|
||||
value: $data['tags']['value'][0]['description'] ?? null,
|
||||
values: $data['values'] ?? []
|
||||
);
|
||||
}
|
||||
|
||||
protected static function normalizeDefault(
|
||||
string|null $default,
|
||||
string|null $type
|
||||
): string|null {
|
||||
if ($default === null) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->name,
|
||||
'default' => $this->default,
|
||||
'description' => $this->description,
|
||||
'deprecated' => $this->deprecated,
|
||||
'example' => $this->example,
|
||||
'required' => $this->required,
|
||||
'since' => $this->since,
|
||||
'type' => $this->type,
|
||||
'value' => $this->value,
|
||||
'values' => $this->values,
|
||||
];
|
||||
}
|
||||
}
|
||||
57
kirby/src/Panel/Lab/Doc/Slot.php
Normal file
57
kirby/src/Panel/Lab/Doc/Slot.php
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Lab\Doc;
|
||||
|
||||
use Kirby\Panel\Lab\Doc;
|
||||
use Kirby\Toolkit\A;
|
||||
|
||||
/**
|
||||
* Documentation for a single Vue slot
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
* @internal
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class Slot
|
||||
{
|
||||
public function __construct(
|
||||
public string $name,
|
||||
public string|null $description = null,
|
||||
public string|null $deprecated = null,
|
||||
public string|null $since = null,
|
||||
public array $bindings = [],
|
||||
) {
|
||||
$this->description = Doc::kt($this->description ?? '');
|
||||
$this->deprecated = Doc::kt($this->deprecated ?? '');
|
||||
}
|
||||
|
||||
public static function factory(array $data): static
|
||||
{
|
||||
return new static(
|
||||
name: $data['name'],
|
||||
description: $data['description'] ?? null,
|
||||
deprecated: $data['tags']['deprecated'][0]['description'] ?? null,
|
||||
since: $data['tags']['since'][0]['description'] ?? null,
|
||||
bindings: A::map(
|
||||
$data['bindings'] ?? [],
|
||||
fn ($binding) => Argument::factory($binding)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->name,
|
||||
'bindings' => $this->bindings,
|
||||
'description' => $this->description,
|
||||
'deprecated' => $this->deprecated,
|
||||
'since' => $this->since,
|
||||
];
|
||||
}
|
||||
}
|
||||
73
kirby/src/Panel/Lab/Docs.php
Normal file
73
kirby/src/Panel/Lab/Docs.php
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Lab;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Filesystem\Dir;
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Toolkit\A;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* Docs for Vue components
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 4.0.0
|
||||
* @internal
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class Docs
|
||||
{
|
||||
/**
|
||||
* Returns list of all component docs
|
||||
* for the Lab index view
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
$docs = [];
|
||||
$dist = static::root();
|
||||
$tmp = static::root(true);
|
||||
$files = Dir::inventory($dist)['files'];
|
||||
|
||||
if (Dir::exists($tmp) === true) {
|
||||
$files = [...$files, ...Dir::inventory($tmp)['files']];
|
||||
}
|
||||
|
||||
$docs = A::map(
|
||||
$files,
|
||||
function ($file) {
|
||||
$component = 'k-' . Str::camelToKebab(F::name($file['filename']));
|
||||
return Doc::factory($component)?->toItem();
|
||||
}
|
||||
);
|
||||
|
||||
$docs = array_filter($docs);
|
||||
usort($docs, fn ($a, $b) => $a['text'] <=> $b['text']);
|
||||
|
||||
return $docs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the Lab docs are installed
|
||||
*/
|
||||
public static function isInstalled(): bool
|
||||
{
|
||||
return Dir::exists(static::root()) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the root path to directory where
|
||||
* the JSON files for each component are stored by vite
|
||||
*/
|
||||
public static function root(bool $tmp = false): string
|
||||
{
|
||||
return App::instance()->root('panel') . '/' . match ($tmp) {
|
||||
true => 'tmp',
|
||||
default => 'dist/ui',
|
||||
};
|
||||
}
|
||||
}
|
||||
297
kirby/src/Panel/Lab/Example.php
Normal file
297
kirby/src/Panel/Lab/Example.php
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
<?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
|
||||
*
|
||||
* @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
|
||||
* @internal
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
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(
|
||||
message: 'The example could not be found'
|
||||
);
|
||||
}
|
||||
|
||||
$this->tabs = $this->collectTabs();
|
||||
$this->tab = $this->collectTab($tab);
|
||||
}
|
||||
|
||||
public function collectTab(string|null $tab): string|null
|
||||
{
|
||||
if ($this->tabs === []) {
|
||||
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 Dir::exists($this->root, $this->parent->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' => 'light-dark(white, var(--color-gray-800))',
|
||||
],
|
||||
'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;
|
||||
}
|
||||
}
|
||||
25
kirby/src/Panel/Lab/Snippet.php
Normal file
25
kirby/src/Panel/Lab/Snippet.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Lab;
|
||||
|
||||
use Kirby\Template\Snippet as BaseSnippet;
|
||||
|
||||
/**
|
||||
* Custom snippet class for lab examples
|
||||
*
|
||||
* @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
|
||||
* @internal
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class Snippet extends BaseSnippet
|
||||
{
|
||||
public static function root(): string
|
||||
{
|
||||
return __DIR__ . '/snippets';
|
||||
}
|
||||
}
|
||||
33
kirby/src/Panel/Lab/Template.php
Normal file
33
kirby/src/Panel/Lab/Template.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Lab;
|
||||
|
||||
use Kirby\Template\Template as BaseTemplate;
|
||||
|
||||
/**
|
||||
* Custom template class for lab examples
|
||||
*
|
||||
* @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
|
||||
* @internal
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class Template extends BaseTemplate
|
||||
{
|
||||
public function __construct(
|
||||
public string $file
|
||||
) {
|
||||
parent::__construct(
|
||||
name: basename($this->file)
|
||||
);
|
||||
}
|
||||
|
||||
public function file(): string|null
|
||||
{
|
||||
return $this->file;
|
||||
}
|
||||
}
|
||||
219
kirby/src/Panel/Menu.php
Normal file
219
kirby/src/Panel/Menu.php
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
<?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
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 4.0.0
|
||||
* @unstable
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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 = [...$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 = Panel::area($id, [
|
||||
...$this->areas[$id] ?? [],
|
||||
'menu' => true,
|
||||
...$area
|
||||
]);
|
||||
}
|
||||
|
||||
$result[] = $area;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms an area definition into a menu entry
|
||||
*/
|
||||
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 = [
|
||||
'current' => $this->isCurrent(
|
||||
$area['id'],
|
||||
$area['current'] ?? null
|
||||
),
|
||||
'icon' => $area['icon'] ?? null,
|
||||
'link' => $area['link'] ?? null,
|
||||
'dialog' => $area['dialog'] ?? null,
|
||||
'drawer' => $area['drawer'] ?? null,
|
||||
'target' => $area['target'] ?? null,
|
||||
'text' => I18n::translate($area['label'], $area['label']),
|
||||
'title' => I18n::translate($area['title'] ?? null, $area['title'] ?? null),
|
||||
...$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 [...$entries, ...$this->options()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the access permission to a specific area is granted.
|
||||
* Defaults to allow access.
|
||||
*/
|
||||
public function hasPermission(string $id): bool
|
||||
{
|
||||
return $this->permissions['access'][$id] ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the menu entry should receive aria-current
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
479
kirby/src/Panel/Model.php
Normal file
479
kirby/src/Panel/Model.php
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel;
|
||||
|
||||
use Closure;
|
||||
use Kirby\Cms\File as CmsFile;
|
||||
use Kirby\Cms\Language;
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
use Kirby\Filesystem\Asset;
|
||||
use Kirby\Form\Fields;
|
||||
use Kirby\Http\Uri;
|
||||
use Kirby\Panel\Ui\Item\ModelItem;
|
||||
use Kirby\Toolkit\A;
|
||||
|
||||
/**
|
||||
* Provides information about the model for the Panel
|
||||
* @since 3.6.0
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
abstract class Model
|
||||
{
|
||||
public function __construct(
|
||||
protected ModelWithContent $model
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns header button names which should be displayed
|
||||
*/
|
||||
abstract public function buttons(): array;
|
||||
|
||||
/**
|
||||
* Get the content values for the model
|
||||
*
|
||||
* @deprecated 5.0.0 Use `self::versions()` instead
|
||||
*/
|
||||
public function content(): array
|
||||
{
|
||||
return $this->versions()['changes'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the drag text from a custom callback
|
||||
* if the callback is defined in the config
|
||||
* @internal
|
||||
*
|
||||
* @param string $type markdown or kirbytext
|
||||
*/
|
||||
public function dragTextFromCallback(string $type, ...$args): string|null
|
||||
{
|
||||
$option = 'panel.' . $type . '.' . $this->model::CLASS_ALIAS . 'DragText';
|
||||
$callback = $this->model->kirby()->option($option);
|
||||
|
||||
if ($callback instanceof Closure) {
|
||||
return $callback($this->model, ...$args);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the correct drag text type
|
||||
* depending on the given type or the
|
||||
* configuration
|
||||
*
|
||||
* @internal
|
||||
*
|
||||
* @param string|null $type (`auto`|`kirbytext`|`markdown`)
|
||||
*/
|
||||
public function dragTextType(string|null $type = 'auto'): string
|
||||
{
|
||||
$type ??= 'auto';
|
||||
|
||||
if ($type === 'auto') {
|
||||
$kirby = $this->model->kirby();
|
||||
$type = $kirby->option('panel.kirbytext', true) ? 'kirbytext' : 'markdown';
|
||||
}
|
||||
|
||||
return $type === 'markdown' ? 'markdown' : 'kirbytext';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the setup for a dropdown option
|
||||
* which is used in the changes dropdown
|
||||
* for example.
|
||||
*/
|
||||
public function dropdownOption(): array
|
||||
{
|
||||
return [
|
||||
'icon' => 'page',
|
||||
'image' => $this->image(['back' => 'black']),
|
||||
'link' => $this->url(true),
|
||||
'text' => $this->model->id(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Panel image definition
|
||||
*/
|
||||
public function image(
|
||||
string|array|false|null $settings = [],
|
||||
string $layout = 'list'
|
||||
): array|null {
|
||||
// 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;
|
||||
}
|
||||
|
||||
// convert string blueprint settings to proper array
|
||||
if (is_string($blueprint) === true) {
|
||||
$blueprint = ['query' => $blueprint];
|
||||
}
|
||||
|
||||
// skip image thumbnail if option
|
||||
// is explicitly set to show the icon
|
||||
if ($settings === 'icon') {
|
||||
$settings = ['query' => false];
|
||||
}
|
||||
|
||||
// convert string settings to proper array
|
||||
if (is_string($settings) === true) {
|
||||
$settings = ['query' => $settings];
|
||||
}
|
||||
|
||||
// merge with defaults and blueprint option
|
||||
$settings = [
|
||||
...$this->imageDefaults(),
|
||||
...$settings ?? [],
|
||||
...$blueprint ?? [],
|
||||
];
|
||||
|
||||
if ($image = $this->imageSource($settings['query'] ?? null)) {
|
||||
// main url
|
||||
$settings['url'] = $image->url();
|
||||
|
||||
if ($image->isResizable() === true) {
|
||||
// only create srcsets for resizable files
|
||||
$settings['src'] = static::imagePlaceholder();
|
||||
$settings['srcset'] = $this->imageSrcset($image, $layout, $settings);
|
||||
} elseif ($image->isViewable() === true) {
|
||||
$settings['src'] = $image->url();
|
||||
}
|
||||
}
|
||||
|
||||
unset($settings['query']);
|
||||
|
||||
// resolve remaining options defined as query
|
||||
return A::map($settings, function ($option) {
|
||||
if (is_string($option) === false) {
|
||||
return $option;
|
||||
}
|
||||
|
||||
return $this->model->toString($option);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Default settings for Panel image
|
||||
*/
|
||||
protected function imageDefaults(): array
|
||||
{
|
||||
return [
|
||||
'back' => 'pattern',
|
||||
'color' => 'gray-500',
|
||||
'cover' => false,
|
||||
'icon' => 'page'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Data URI placeholder string for Panel image
|
||||
*/
|
||||
public static function imagePlaceholder(): string
|
||||
{
|
||||
return 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the image file object based on provided query
|
||||
*/
|
||||
protected function imageSource(
|
||||
string|null $query = null
|
||||
): CmsFile|Asset|null {
|
||||
$image = $this->model->query($query ?? null);
|
||||
|
||||
// validate the query result
|
||||
if (
|
||||
$image instanceof CmsFile ||
|
||||
$image instanceof Asset
|
||||
) {
|
||||
return $image;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the correct srcset string based on
|
||||
* the layout and settings
|
||||
*/
|
||||
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 = $settings['ratio'] ?? '1/1';
|
||||
|
||||
if (is_numeric($ratio) === false) {
|
||||
$ratio = explode('/', $ratio);
|
||||
$ratio = $ratio[0] / $ratio[1];
|
||||
}
|
||||
|
||||
return $image->srcset([
|
||||
$sizes[0] . 'w' => [
|
||||
'width' => $sizes[0],
|
||||
'height' => round($sizes[0] / $ratio),
|
||||
'crop' => true
|
||||
],
|
||||
$sizes[1] . 'w' => [
|
||||
'width' => $sizes[1],
|
||||
'height' => round($sizes[1] / $ratio),
|
||||
'crop' => true
|
||||
],
|
||||
$sizes[2] . 'w' => [
|
||||
'width' => $sizes[2],
|
||||
'height' => round($sizes[2] / $ratio),
|
||||
'crop' => true
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
// for list and cardlets with `cover: true`
|
||||
// provide square crops in two resolutions
|
||||
return $image->srcset([
|
||||
'1x' => [
|
||||
'width' => $sizes[0],
|
||||
'height' => $sizes[0],
|
||||
'crop' => true
|
||||
],
|
||||
'2x' => [
|
||||
'width' => $sizes[1],
|
||||
'height' => $sizes[1],
|
||||
'crop' => true
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for disabled dropdown options according
|
||||
* to the given permissions
|
||||
*/
|
||||
public function isDisabledDropdownOption(
|
||||
string $action,
|
||||
array $options,
|
||||
array $permissions
|
||||
): bool {
|
||||
$option = $options[$action] ?? true;
|
||||
|
||||
return
|
||||
$permissions[$action] === false ||
|
||||
$option === false ||
|
||||
$option === 'false';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the corresponding model object
|
||||
* @since 5.0.0
|
||||
*/
|
||||
public function model(): ModelWithContent
|
||||
{
|
||||
return $this->model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of all actions
|
||||
* that can be performed in the Panel
|
||||
* This also checks for the lock status
|
||||
*
|
||||
* @param array $unlock An array of options that will be force-unlocked
|
||||
*/
|
||||
public function options(array $unlock = []): array
|
||||
{
|
||||
$options = $this->model->permissions()->toArray();
|
||||
|
||||
if ($this->model->lock()->isLocked() === true) {
|
||||
foreach ($options as $key => $value) {
|
||||
if (in_array($key, $unlock, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$options[$key] = false;
|
||||
}
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full path without leading slash
|
||||
*/
|
||||
abstract public function path(): string;
|
||||
|
||||
/**
|
||||
* Prepares the response data for page pickers
|
||||
* and page fields
|
||||
*/
|
||||
public function pickerData(array $params = []): array
|
||||
{
|
||||
$item = new ModelItem(
|
||||
model: $this->model,
|
||||
image: $params['image'] ?? null,
|
||||
info: $params['info'] ?? null,
|
||||
layout: $params['layout'] ?? null,
|
||||
text: $params['text'] ?? null,
|
||||
);
|
||||
|
||||
return [
|
||||
...$item->props(),
|
||||
'sortable' => true,
|
||||
'url' => $this->url(true)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the data array for the view's component props
|
||||
*/
|
||||
public function props(): array
|
||||
{
|
||||
$blueprint = $this->model->blueprint();
|
||||
$link = $this->url(true);
|
||||
$request = $this->model->kirby()->request();
|
||||
$tabs = $blueprint->tabs();
|
||||
$tab = $blueprint->tab($request->get('tab')) ?? $tabs[0] ?? null;
|
||||
$versions = $this->versions();
|
||||
|
||||
$props = [
|
||||
'api' => $link,
|
||||
'buttons' => fn () => $this->buttons(),
|
||||
'id' => $this->model->id(),
|
||||
'link' => $link,
|
||||
'lock' => $this->model->lock()->toArray(),
|
||||
'permissions' => $this->model->permissions()->toArray(),
|
||||
'tabs' => $tabs,
|
||||
'uuid' => fn () => $this->model->uuid()?->toString(),
|
||||
'versions' => [
|
||||
'latest' => (object)$versions['latest'],
|
||||
'changes' => (object)$versions['changes']
|
||||
]
|
||||
];
|
||||
|
||||
// only send the tab if it exists
|
||||
// this will let the vue component define
|
||||
// a proper default value
|
||||
if ($tab) {
|
||||
$props['tab'] = $tab;
|
||||
}
|
||||
|
||||
return $props;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns link url and title
|
||||
* for model (e.g. used for prev/next navigation)
|
||||
*/
|
||||
public function toLink(string $title = 'title'): array
|
||||
{
|
||||
return [
|
||||
'link' => $this->url(true),
|
||||
'title' => $title = (string)$this->model->{$title}()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns link url and title
|
||||
* for optional sibling model and
|
||||
* preserves tab selection
|
||||
*/
|
||||
protected function toPrevNextLink(
|
||||
ModelWithContent|null $model = null,
|
||||
string $title = 'title'
|
||||
): array|null {
|
||||
if ($model === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $model->panel()->toLink($title);
|
||||
|
||||
if ($tab = $model->kirby()->request()->get('tab')) {
|
||||
$uri = new Uri($data['link'], [
|
||||
'query' => ['tab' => $tab]
|
||||
]);
|
||||
|
||||
$data['link'] = $uri->toString();
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the url to the editing view
|
||||
* in the Panel
|
||||
*/
|
||||
public function url(bool $relative = false): string
|
||||
{
|
||||
if ($relative === true) {
|
||||
return '/' . $this->path();
|
||||
}
|
||||
|
||||
return $this->model->kirby()->url('panel') . '/' . $this->path();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an array with two versions of the content:
|
||||
* `latest` and `changes`.
|
||||
*
|
||||
* The content is passed through the Fields class
|
||||
* to ensure that the content is in the correct format
|
||||
* for the Panel. If there's no `changes` version, the `latest`
|
||||
* version is used for both.
|
||||
*/
|
||||
public function versions(): array
|
||||
{
|
||||
$language = Language::ensure('current');
|
||||
$fields = Fields::for($this->model, $language);
|
||||
|
||||
$latestVersion = $this->model->version('latest');
|
||||
$changesVersion = $this->model->version('changes');
|
||||
|
||||
$latestContent = $latestVersion->content($language)->toArray();
|
||||
$changesContent = $latestContent;
|
||||
|
||||
if ($changesVersion->exists($language) === true) {
|
||||
$changesContent = $changesVersion->content($language)->toArray();
|
||||
}
|
||||
|
||||
return [
|
||||
'latest' => $fields->reset()->fill($latestContent)->toFormValues(),
|
||||
'changes' => $fields->reset()->fill($changesContent)->toFormValues()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the data array for this model's Panel view
|
||||
*/
|
||||
abstract public function view(): array;
|
||||
}
|
||||
385
kirby/src/Panel/Page.php
Normal file
385
kirby/src/Panel/Page.php
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel;
|
||||
|
||||
use Kirby\Cms\File as CmsFile;
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
use Kirby\Filesystem\Asset;
|
||||
use Kirby\Panel\Ui\Buttons\ViewButtons;
|
||||
use Kirby\Panel\Ui\Item\PageItem;
|
||||
use Kirby\Toolkit\I18n;
|
||||
|
||||
/**
|
||||
* Provides information about the page model for the Panel
|
||||
* @since 3.6.0
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Page extends Model
|
||||
{
|
||||
/**
|
||||
* @var \Kirby\Cms\Page
|
||||
*/
|
||||
protected ModelWithContent $model;
|
||||
|
||||
/**
|
||||
* Breadcrumb array
|
||||
*/
|
||||
public function breadcrumb(): array
|
||||
{
|
||||
$parents = $this->model->parents()->flip()->merge($this->model);
|
||||
|
||||
return $parents->values(
|
||||
fn ($parent) => [
|
||||
'label' => $parent->title()->toString(),
|
||||
'link' => $parent->panel()->url(true),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns header buttons which should be displayed
|
||||
* on the page view
|
||||
*/
|
||||
public function buttons(): array
|
||||
{
|
||||
return ViewButtons::view($this)->defaults(
|
||||
'open',
|
||||
'preview',
|
||||
'settings',
|
||||
'languages',
|
||||
'status'
|
||||
)->render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a kirbytag or markdown
|
||||
* tag for the page, which will be
|
||||
* used in the panel, when the page
|
||||
* gets dragged onto a textarea
|
||||
*
|
||||
* @param string|null $type (`auto`|`kirbytext`|`markdown`)
|
||||
*/
|
||||
public function dragText(string|null $type = null): string
|
||||
{
|
||||
$type = $this->dragTextType($type);
|
||||
|
||||
if ($callback = $this->dragTextFromCallback($type)) {
|
||||
return $callback;
|
||||
}
|
||||
|
||||
$title = $this->model->title();
|
||||
|
||||
// type: markdown
|
||||
if ($type === 'markdown') {
|
||||
$url = $this->model->permalink() ?? $this->model->url();
|
||||
return '[' . $title . '](' . $url . ')';
|
||||
}
|
||||
|
||||
// type: kirbytext
|
||||
$link = $this->model->uuid() ?? $this->model->uri();
|
||||
return '(link: ' . $link . ' text: ' . $title . ')';
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides options for the page dropdown
|
||||
*/
|
||||
public function dropdown(array $options = []): array
|
||||
{
|
||||
$page = $this->model;
|
||||
$request = $page->kirby()->request();
|
||||
$defaults = $request->get(['view', 'sort', 'delete']);
|
||||
$options = [...$defaults, ...$options];
|
||||
$permissions = $this->options(['preview']);
|
||||
$view = $options['view'] ?? 'view';
|
||||
$url = $this->url(true);
|
||||
$result = [];
|
||||
|
||||
if ($view === 'list') {
|
||||
$result['open'] = [
|
||||
'link' => $page->previewUrl(),
|
||||
'target' => '_blank',
|
||||
'icon' => 'open',
|
||||
'text' => I18n::translate('open'),
|
||||
'disabled' => $isPreviewDisabled = $this->isDisabledDropdownOption('preview', $options, $permissions)
|
||||
];
|
||||
|
||||
$result['preview'] = [
|
||||
'icon' => 'window',
|
||||
'link' => $page->panel()->url(true) . '/preview/changes',
|
||||
'text' => I18n::translate('preview'),
|
||||
'disabled' => $isPreviewDisabled
|
||||
];
|
||||
|
||||
$result[] = '-';
|
||||
}
|
||||
|
||||
$result['changeTitle'] = [
|
||||
'dialog' => [
|
||||
'url' => $url . '/changeTitle',
|
||||
'query' => [
|
||||
'select' => 'title'
|
||||
]
|
||||
],
|
||||
'icon' => 'title',
|
||||
'text' => I18n::translate('rename'),
|
||||
'disabled' => $this->isDisabledDropdownOption('changeTitle', $options, $permissions)
|
||||
];
|
||||
|
||||
$result['changeSlug'] = [
|
||||
'dialog' => [
|
||||
'url' => $url . '/changeTitle',
|
||||
'query' => [
|
||||
'select' => 'slug'
|
||||
]
|
||||
],
|
||||
'icon' => 'url',
|
||||
'text' => I18n::translate('page.changeSlug'),
|
||||
'disabled' => $this->isDisabledDropdownOption('changeSlug', $options, $permissions)
|
||||
];
|
||||
|
||||
$result['changeStatus'] = [
|
||||
'dialog' => $url . '/changeStatus',
|
||||
'icon' => 'preview',
|
||||
'text' => I18n::translate('page.changeStatus'),
|
||||
'disabled' => $this->isDisabledDropdownOption('changeStatus', $options, $permissions)
|
||||
];
|
||||
|
||||
$siblings = $page->parentModel()->children()->listed()->not($page);
|
||||
|
||||
$result['changeSort'] = [
|
||||
'dialog' => $url . '/changeSort',
|
||||
'icon' => 'sort',
|
||||
'text' => I18n::translate('page.sort'),
|
||||
'disabled' => $siblings->count() === 0 || $this->isDisabledDropdownOption('sort', $options, $permissions)
|
||||
];
|
||||
|
||||
$result['changeTemplate'] = [
|
||||
'dialog' => $url . '/changeTemplate',
|
||||
'icon' => 'template',
|
||||
'text' => I18n::translate('page.changeTemplate'),
|
||||
'disabled' => $this->isDisabledDropdownOption('changeTemplate', $options, $permissions)
|
||||
];
|
||||
|
||||
$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',
|
||||
'text' => I18n::translate('delete'),
|
||||
'disabled' => $this->isDisabledDropdownOption('delete', $options, $permissions)
|
||||
];
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the setup for a dropdown option
|
||||
* which is used in the changes dropdown
|
||||
* for example.
|
||||
*/
|
||||
public function dropdownOption(): array
|
||||
{
|
||||
return [
|
||||
'text' => $this->model->title()->value(),
|
||||
] + parent::dropdownOption();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the escaped Id, which is
|
||||
* used in the panel to make routing work properly
|
||||
*/
|
||||
public function id(): string
|
||||
{
|
||||
return str_replace('/', '+', $this->model->id());
|
||||
}
|
||||
|
||||
/**
|
||||
* Default settings for the page's Panel image
|
||||
*/
|
||||
protected function imageDefaults(): array
|
||||
{
|
||||
$defaults = [];
|
||||
|
||||
if ($icon = $this->model->blueprint()->icon()) {
|
||||
$defaults['icon'] = $icon;
|
||||
}
|
||||
|
||||
return [
|
||||
...parent::imageDefaults(),
|
||||
...$defaults
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the image file object based on provided query
|
||||
*/
|
||||
protected function imageSource(
|
||||
string|null $query = null
|
||||
): CmsFile|Asset|null {
|
||||
$query ??= 'page.image';
|
||||
return parent::imageSource($query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full path without leading slash
|
||||
*/
|
||||
public function path(): string
|
||||
{
|
||||
return 'pages/' . $this->id();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the response data for page pickers
|
||||
* and page fields
|
||||
*/
|
||||
public function pickerData(array $params = []): array
|
||||
{
|
||||
$item = new PageItem(
|
||||
page: $this->model,
|
||||
image: $params['image'] ?? null,
|
||||
info: $params['info'] ?? null,
|
||||
layout: $params['layout'] ?? null,
|
||||
text: $params['text'] ?? null,
|
||||
);
|
||||
|
||||
return [
|
||||
...$item->props(),
|
||||
'hasChildren' => $this->model->hasChildren(),
|
||||
'sortable' => true
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The best applicable position for
|
||||
* the position/status dialog
|
||||
*/
|
||||
public function position(): int
|
||||
{
|
||||
return
|
||||
$this->model->num() ??
|
||||
$this->model->parentModel()->children()->listed()->not($this->model)->count() + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns navigation array with
|
||||
* previous and next page
|
||||
* based on blueprint definition
|
||||
*/
|
||||
public function prevNext(): array
|
||||
{
|
||||
$page = $this->model;
|
||||
|
||||
// create siblings collection based on
|
||||
// blueprint navigation
|
||||
$siblings = static function (string $direction) use ($page) {
|
||||
$navigation = $page->blueprint()->navigation();
|
||||
$sortBy = $navigation['sortBy'] ?? null;
|
||||
$status = $navigation['status'] ?? null;
|
||||
$template = $navigation['template'] ?? null;
|
||||
$direction = $direction === 'prev' ? 'prev' : 'next';
|
||||
|
||||
// if status is defined in navigation,
|
||||
// all items in the collection are used
|
||||
// (drafts, listed and unlisted) otherwise
|
||||
// it depends on the status of the page
|
||||
$siblings = $status !== null ? $page->parentModel()->childrenAndDrafts() : $page->siblings();
|
||||
|
||||
// sort the collection if custom sortBy
|
||||
// defined in navigation otherwise
|
||||
// default sorting will apply
|
||||
if ($sortBy !== null) {
|
||||
$siblings = $siblings->sort(...$siblings::sortArgs($sortBy));
|
||||
}
|
||||
|
||||
$siblings = $page->{$direction . 'All'}($siblings);
|
||||
|
||||
if (empty($navigation) === false) {
|
||||
$statuses = (array)($status ?? $page->status());
|
||||
$templates = (array)($template ?? $page->intendedTemplate());
|
||||
|
||||
// do not filter if template navigation is all
|
||||
if (in_array('all', $templates, true) === false) {
|
||||
$siblings = $siblings->filter('intendedTemplate', 'in', $templates);
|
||||
}
|
||||
|
||||
// do not filter if status navigation is all
|
||||
if (in_array('all', $statuses, true) === false) {
|
||||
$siblings = $siblings->filter('status', 'in', $statuses);
|
||||
}
|
||||
} else {
|
||||
$siblings = $siblings
|
||||
->filter('intendedTemplate', $page->intendedTemplate())
|
||||
->filter('status', $page->status());
|
||||
}
|
||||
|
||||
return $siblings->filter('isListable', true);
|
||||
};
|
||||
|
||||
return [
|
||||
'next' => fn () => $this->toPrevNextLink($siblings('next')->first()),
|
||||
'prev' => fn () => $this->toPrevNextLink($siblings('prev')->last())
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the data array for the view's component props
|
||||
*/
|
||||
public function props(): array
|
||||
{
|
||||
$props = parent::props();
|
||||
|
||||
// Additional model information
|
||||
// @deprecated Use the top-level props instead
|
||||
$model = [
|
||||
'id' => $props['id'],
|
||||
'link' => $props['link'],
|
||||
'parent' => $this->model->parentModel()->panel()->url(true),
|
||||
'previewUrl' => $this->model->previewUrl(),
|
||||
'status' => $this->model->status(),
|
||||
'title' => $this->model->title()->toString(),
|
||||
'uuid' => $props['uuid'],
|
||||
];
|
||||
|
||||
return [
|
||||
...$props,
|
||||
...$this->prevNext(),
|
||||
'blueprint' => $this->model->intendedTemplate()->name(),
|
||||
'model' => $model,
|
||||
'title' => $model['title'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the data array for this model's Panel view
|
||||
*/
|
||||
public function view(): array
|
||||
{
|
||||
return [
|
||||
'breadcrumb' => $this->model->panel()->breadcrumb(),
|
||||
'component' => 'k-page-view',
|
||||
'props' => $props = $this->props(),
|
||||
'title' => $props['title'],
|
||||
];
|
||||
}
|
||||
}
|
||||
432
kirby/src/Panel/PageCreateDialog.php
Normal file
432
kirby/src/Panel/PageCreateDialog.php
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
<?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\Content\MemoryStorage;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Form\Form;
|
||||
use Kirby\Toolkit\A;
|
||||
use Kirby\Toolkit\I18n;
|
||||
use Kirby\Uuid\Uuid;
|
||||
use Kirby\Uuid\Uuids;
|
||||
|
||||
/**
|
||||
* 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 string|null $uuid;
|
||||
protected Page|Site|User|File $view;
|
||||
protected string|null $viewId;
|
||||
|
||||
public static array $fieldTypes = [
|
||||
'checkboxes',
|
||||
'date',
|
||||
'email',
|
||||
'info',
|
||||
'line',
|
||||
'link',
|
||||
'list',
|
||||
'number',
|
||||
'multiselect',
|
||||
'radio',
|
||||
'range',
|
||||
'select',
|
||||
'slug',
|
||||
'tags',
|
||||
'tel',
|
||||
'text',
|
||||
'toggle',
|
||||
'toggles',
|
||||
'time',
|
||||
'url'
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
string|null $parentId,
|
||||
string|null $sectionId,
|
||||
string|null $template,
|
||||
string|null $viewId,
|
||||
|
||||
// optional
|
||||
string|null $slug = null,
|
||||
string|null $title = null,
|
||||
string|null $uuid = null,
|
||||
) {
|
||||
$this->parentId = $parentId ?? 'site';
|
||||
$this->parent = Find::parent($this->parentId);
|
||||
$this->sectionId = $sectionId;
|
||||
$this->slug = $slug;
|
||||
$this->template = $template;
|
||||
$this->title = $title;
|
||||
$this->uuid = $uuid;
|
||||
$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(
|
||||
message: '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() . '/' : '/'
|
||||
]);
|
||||
}
|
||||
|
||||
// pass uuid field to the dialog if uuids are enabled
|
||||
// to use the same uuid and prevent generating a new one
|
||||
// when the page is created
|
||||
if (Uuids::enabled() === true) {
|
||||
$fields['uuid'] = Field::hidden();
|
||||
}
|
||||
|
||||
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', 'uuid'];
|
||||
$blueprint = $this->blueprint();
|
||||
$fields = $blueprint->fields();
|
||||
|
||||
foreach ($blueprint->create()['fields'] ?? [] as $name) {
|
||||
if (!$field = ($fields[$name] ?? null)) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'Unknown field "' . $name . '" in create dialog'
|
||||
);
|
||||
}
|
||||
|
||||
if (in_array($field['type'], static::$fieldTypes, true) === false) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'Field type "' . $field['type'] . '" not supported in create dialog'
|
||||
);
|
||||
}
|
||||
|
||||
if (in_array($name, $ignore, true) === true) {
|
||||
throw new InvalidArgumentException(
|
||||
message: '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()
|
||||
);
|
||||
|
||||
return $form->fields()->toProps();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ($visible === [] && 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
|
||||
{
|
||||
if (isset($this->model) === true) {
|
||||
return $this->model;
|
||||
}
|
||||
|
||||
$props = [
|
||||
'slug' => '__new__',
|
||||
'template' => $this->template,
|
||||
'model' => $this->template,
|
||||
'parent' => $this->parent instanceof Page ? $this->parent : null
|
||||
];
|
||||
|
||||
// make sure that a UUID gets generated
|
||||
// and added to content right away
|
||||
if (Uuids::enabled() === true) {
|
||||
$props['content'] = [
|
||||
'uuid' => $this->uuid = Uuid::generate()
|
||||
];
|
||||
}
|
||||
|
||||
$this->model = Page::factory($props);
|
||||
|
||||
// change the storage to memory immediately
|
||||
// since this is a temporary model
|
||||
// so that the model does not write to disk
|
||||
$this->model->changeStorage(MemoryStorage::class);
|
||||
|
||||
return $this->model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates values for title and slug
|
||||
* from template strings from the blueprint
|
||||
*/
|
||||
public function resolveFieldTemplates(array $input): array
|
||||
{
|
||||
$title = $this->blueprint()->create()['title'] ?? null;
|
||||
$slug = $this->blueprint()->create()['slug'] ?? null;
|
||||
|
||||
// create temporary page object
|
||||
// to resolve the template strings
|
||||
$page = $this->model()->clone(['content' => $input]);
|
||||
|
||||
if (is_string($title)) {
|
||||
$input['title'] = $page->toSafeString($title);
|
||||
}
|
||||
|
||||
if (is_string($slug)) {
|
||||
$input['slug'] = $page->toSafeString($slug);
|
||||
}
|
||||
|
||||
return $input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares and cleans up the input data
|
||||
*/
|
||||
public function sanitize(array $input): array
|
||||
{
|
||||
$input['title'] ??= $this->title ?? '';
|
||||
$input['slug'] ??= $this->slug ?? '';
|
||||
$input['uuid'] ??= $this->uuid ?? null;
|
||||
|
||||
$input = $this->resolveFieldTemplates($input);
|
||||
$content = ['title' => trim($input['title'])];
|
||||
|
||||
if ($uuid = $input['uuid'] ?? null) {
|
||||
$content['uuid'] = $uuid;
|
||||
}
|
||||
|
||||
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())->fill(input: $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())->fill(input: $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 ?? '',
|
||||
'uuid' => $this->uuid,
|
||||
'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;
|
||||
}
|
||||
}
|
||||
643
kirby/src/Panel/Panel.php
Normal file
643
kirby/src/Panel/Panel.php
Normal file
|
|
@ -0,0 +1,643 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel;
|
||||
|
||||
use Closure;
|
||||
use Kirby\Api\Upload;
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\Url as CmsUrl;
|
||||
use Kirby\Cms\User;
|
||||
use Kirby\Exception\Exception;
|
||||
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\A;
|
||||
use Kirby\Toolkit\Str;
|
||||
use Kirby\Toolkit\Tpl;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* The Panel class is only responsible to create
|
||||
* a working panel view with all the right URLs
|
||||
* and other panel options. The view template is
|
||||
* located in `kirby/views/panel.php`
|
||||
* @since 3.6.0
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Panel
|
||||
{
|
||||
/**
|
||||
* Normalize a panel area
|
||||
*/
|
||||
public static function area(string $id, array $area): array
|
||||
{
|
||||
$area['id'] = $id;
|
||||
$area['label'] ??= $id;
|
||||
$area['breadcrumb'] ??= [];
|
||||
$area['breadcrumbLabel'] ??= $area['label'];
|
||||
$area['title'] = $area['label'];
|
||||
$area['menu'] ??= false;
|
||||
$area['link'] ??= $id;
|
||||
$area['search'] ??= null;
|
||||
|
||||
return $area;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all registered areas
|
||||
*/
|
||||
public static function areas(): array
|
||||
{
|
||||
$kirby = App::instance();
|
||||
$system = $kirby->system();
|
||||
$user = $kirby->user();
|
||||
$areas = $kirby->load()->areas();
|
||||
|
||||
// the system is not ready
|
||||
if (
|
||||
$system->isOk() === false ||
|
||||
$system->isInstalled() === false
|
||||
) {
|
||||
return [
|
||||
'installation' => static::area(
|
||||
'installation',
|
||||
$areas['installation']
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// not yet authenticated
|
||||
if (!$user) {
|
||||
return [
|
||||
'logout' => static::area('logout', $areas['logout']),
|
||||
// login area last because it defines a fallback route
|
||||
'login' => static::area('login', $areas['login']),
|
||||
];
|
||||
}
|
||||
|
||||
unset($areas['installation'], $areas['login']);
|
||||
|
||||
// Disable the language area for single-language installations
|
||||
// This does not check for installed languages. Otherwise you'd
|
||||
// not be able to add the first language through the view
|
||||
if (!$kirby->option('languages')) {
|
||||
unset($areas['languages']);
|
||||
}
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($areas as $id => $area) {
|
||||
$result[$id] = static::area($id, $area);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all registered buttons from areas
|
||||
* @since 5.0.0
|
||||
*/
|
||||
public static function buttons(): array
|
||||
{
|
||||
return array_merge(...array_values(
|
||||
A::map(
|
||||
Panel::areas(),
|
||||
fn ($area) => $area['buttons'] ?? []
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for access permissions
|
||||
*/
|
||||
public static function firewall(
|
||||
User|null $user = null,
|
||||
string|null $areaId = null
|
||||
): bool {
|
||||
// a user has to be logged in
|
||||
if ($user === null) {
|
||||
throw new PermissionException(
|
||||
key: 'access.panel'
|
||||
);
|
||||
}
|
||||
|
||||
// get all access permissions for the user role
|
||||
$permissions = $user->role()->permissions()->toArray()['access'];
|
||||
|
||||
// check for general panel access
|
||||
if (($permissions['panel'] ?? true) !== true) {
|
||||
throw new PermissionException(
|
||||
key: 'access.panel'
|
||||
);
|
||||
}
|
||||
|
||||
// don't check if the area is not defined
|
||||
if (empty($areaId) === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// undefined area permissions means access
|
||||
if (isset($permissions[$areaId]) === false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// no access
|
||||
if ($permissions[$areaId] !== true) {
|
||||
throw new PermissionException(
|
||||
key: 'access.view'
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Garbage collection which runs with a probability
|
||||
* of 10% on each Panel request
|
||||
*
|
||||
* @since 5.0.0
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
protected static function garbage(): void
|
||||
{
|
||||
// run garbage collection with a chance of 10%;
|
||||
if (mt_rand(1, 10000) <= 0.1 * 10000) {
|
||||
// clean up leftover upload chunks
|
||||
Upload::cleanTmpDir();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to a Panel url
|
||||
*
|
||||
* @throws \Kirby\Panel\Redirect
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public static function go(string|null $url = null, int $code = 302, int|false $refresh = false): void
|
||||
{
|
||||
throw new Redirect(static::url($url), $code, $refresh);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given user has access to the panel
|
||||
* or to a given area
|
||||
*/
|
||||
public static function hasAccess(
|
||||
User|null $user = null,
|
||||
string|null $area = null
|
||||
): bool {
|
||||
try {
|
||||
static::firewall($user, $area);
|
||||
return true;
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for a Fiber request
|
||||
* via get parameters or headers
|
||||
*/
|
||||
public static function isFiberRequest(): bool
|
||||
{
|
||||
$request = App::instance()->request();
|
||||
|
||||
if ($request->method() === 'GET') {
|
||||
return
|
||||
(bool)($request->get('_json') ??
|
||||
$request->header('X-Fiber'));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a JSON response
|
||||
* for Fiber calls
|
||||
*/
|
||||
public static function json(array $data, int $code = 200): Response
|
||||
{
|
||||
$request = App::instance()->request();
|
||||
|
||||
return Response::json($data, $code, $request->get('_pretty'), [
|
||||
'X-Fiber' => 'true',
|
||||
'Cache-Control' => 'no-store, private'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for a multilanguage installation
|
||||
*/
|
||||
public static function multilang(): bool
|
||||
{
|
||||
// multilang setup check
|
||||
$kirby = App::instance();
|
||||
return $kirby->option('languages') || $kirby->multilang();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the referrer path if present
|
||||
*/
|
||||
public static function referrer(): string
|
||||
{
|
||||
$request = App::instance()->request();
|
||||
|
||||
$referrer = $request->header('X-Fiber-Referrer')
|
||||
?? $request->get('_referrer')
|
||||
?? '';
|
||||
|
||||
return '/' . trim($referrer, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Response object from the result of
|
||||
* a Panel route call
|
||||
*/
|
||||
public static function response($result, array $options = []): Response
|
||||
{
|
||||
// pass responses directly down to the Kirby router
|
||||
if ($result instanceof Response) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// interpret missing/empty results as not found
|
||||
if ($result === null || $result === false) {
|
||||
$result = new NotFoundException(
|
||||
message: 'The data could not be found'
|
||||
);
|
||||
|
||||
// interpret strings as errors
|
||||
} elseif (is_string($result) === true) {
|
||||
$result = new Exception($result);
|
||||
}
|
||||
|
||||
// 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)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Router for the Panel views
|
||||
*/
|
||||
public static function router(string|null $path = null): Response|null
|
||||
{
|
||||
$kirby = App::instance();
|
||||
|
||||
if ($kirby->option('panel') === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// run garbage collection
|
||||
static::garbage();
|
||||
|
||||
// set the translation for Panel UI before
|
||||
// gathering areas and routes, so that the
|
||||
// `t()` helper can already be used
|
||||
static::setTranslation();
|
||||
|
||||
// set the language in multi-lang installations
|
||||
static::setLanguage();
|
||||
|
||||
$areas = static::areas();
|
||||
$routes = static::routes($areas);
|
||||
|
||||
// create a micro-router for the Panel
|
||||
return Router::execute($path, $method = $kirby->request()->method(), $routes, function ($route) use ($areas, $kirby, $method, $path) {
|
||||
// route needs authentication?
|
||||
$auth = $route->attributes()['auth'] ?? true;
|
||||
$areaId = $route->attributes()['area'] ?? null;
|
||||
$type = $route->attributes()['type'] ?? 'view';
|
||||
$area = $areas[$areaId] ?? null;
|
||||
|
||||
// call the route action to check the result
|
||||
try {
|
||||
// trigger hook
|
||||
$route = $kirby->apply(
|
||||
'panel.route:before',
|
||||
compact('route', 'path', 'method')
|
||||
);
|
||||
|
||||
// check for access before executing area routes
|
||||
if ($auth !== false) {
|
||||
static::firewall($kirby->user(), $areaId);
|
||||
}
|
||||
|
||||
$result = $route->action()->call($route, ...$route->arguments());
|
||||
} catch (Throwable $e) {
|
||||
$result = $e;
|
||||
}
|
||||
|
||||
$response = static::response($result, [
|
||||
'area' => $area,
|
||||
'areas' => $areas,
|
||||
'path' => $path,
|
||||
'type' => $type
|
||||
]);
|
||||
|
||||
return $kirby->apply(
|
||||
'panel.route:after',
|
||||
compact('route', 'path', 'method', 'response'),
|
||||
'response'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the routes from the given array
|
||||
* of active areas.
|
||||
*/
|
||||
public static function routes(array $areas): array
|
||||
{
|
||||
$kirby = App::instance();
|
||||
|
||||
// the browser incompatibility
|
||||
// warning is always needed
|
||||
$routes = [
|
||||
[
|
||||
'pattern' => 'browser',
|
||||
'auth' => false,
|
||||
'action' => fn () => new Response(
|
||||
Tpl::load($kirby->root('kirby') . '/views/browser.php')
|
||||
),
|
||||
]
|
||||
];
|
||||
|
||||
// register all routes from areas
|
||||
foreach ($areas as $areaId => $area) {
|
||||
$routes = [
|
||||
...$routes,
|
||||
...static::routesForViews($areaId, $area),
|
||||
...static::routesForSearches($areaId, $area),
|
||||
...static::routesForDialogs($areaId, $area),
|
||||
...static::routesForDrawers($areaId, $area),
|
||||
...static::routesForDropdowns($areaId, $area),
|
||||
...static::routesForRequests($areaId, $area),
|
||||
];
|
||||
}
|
||||
|
||||
// if the Panel is already installed and/or the
|
||||
// user is authenticated, those areas won't be
|
||||
// included, which is why we add redirect routes
|
||||
// to main Panel view as fallbacks
|
||||
$routes[] = [
|
||||
'pattern' => [
|
||||
'/',
|
||||
'installation',
|
||||
'login',
|
||||
],
|
||||
'action' => fn () => Panel::go(Home::url()),
|
||||
'auth' => false
|
||||
];
|
||||
|
||||
// catch all route
|
||||
$routes[] = [
|
||||
'pattern' => '(:all)',
|
||||
'action' => fn (string $pattern) => 'Could not find Panel view for route: ' . $pattern
|
||||
];
|
||||
|
||||
return $routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all routes from an area
|
||||
*/
|
||||
public static function routesForDialogs(string $areaId, array $area): array
|
||||
{
|
||||
$dialogs = $area['dialogs'] ?? [];
|
||||
$routes = [];
|
||||
|
||||
foreach ($dialogs as $dialogId => $dialog) {
|
||||
$routes = [
|
||||
...$routes,
|
||||
...Dialog::routes(
|
||||
id: $dialogId,
|
||||
areaId: $areaId,
|
||||
prefix: 'dialogs',
|
||||
options: $dialog
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
return $routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = [
|
||||
...$routes,
|
||||
...Drawer::routes(
|
||||
id: $drawerId,
|
||||
areaId: $areaId,
|
||||
prefix: 'drawers',
|
||||
options: $drawer
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
return $routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all routes for dropdowns
|
||||
*/
|
||||
public static function routesForDropdowns(string $areaId, array $area): array
|
||||
{
|
||||
$dropdowns = $area['dropdowns'] ?? [];
|
||||
$routes = [];
|
||||
|
||||
foreach ($dropdowns as $dropdownId => $dropdown) {
|
||||
$routes = [
|
||||
...$routes,
|
||||
...Dropdown::routes(
|
||||
id: $dropdownId,
|
||||
areaId: $areaId,
|
||||
prefix: 'dropdowns',
|
||||
options: $dropdown
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
return $routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all routes from an area
|
||||
*/
|
||||
public static function routesForRequests(string $areaId, array $area): array
|
||||
{
|
||||
$routes = $area['requests'] ?? [];
|
||||
|
||||
foreach ($routes as $key => $route) {
|
||||
$routes[$key]['area'] = $areaId;
|
||||
$routes[$key]['type'] = 'request';
|
||||
}
|
||||
|
||||
return $routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all routes for searches
|
||||
*/
|
||||
public static function routesForSearches(string $areaId, array $area): array
|
||||
{
|
||||
$searches = $area['searches'] ?? [];
|
||||
$routes = [];
|
||||
|
||||
foreach ($searches as $name => $params) {
|
||||
// create the full routing pattern
|
||||
$pattern = 'search/' . $name;
|
||||
|
||||
// load event
|
||||
$routes[] = [
|
||||
'pattern' => $pattern,
|
||||
'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);
|
||||
|
||||
return $params['query']($query, $limit, $page);
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
return $routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all views from an area
|
||||
*/
|
||||
public static function routesForViews(string $areaId, array $area): array
|
||||
{
|
||||
$views = $area['views'] ?? [];
|
||||
$routes = [];
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return $routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current language in multi-lang
|
||||
* installations based on the session or the
|
||||
* query language query parameter
|
||||
*/
|
||||
public static function setLanguage(): string|null
|
||||
{
|
||||
$kirby = App::instance();
|
||||
|
||||
// language switcher
|
||||
if (static::multilang()) {
|
||||
$fallback = 'en';
|
||||
|
||||
if ($defaultLanguage = $kirby->defaultLanguage()) {
|
||||
$fallback = $defaultLanguage->code();
|
||||
}
|
||||
|
||||
$session = $kirby->session();
|
||||
$sessionLanguage = $session->get('panel.language', $fallback);
|
||||
$language = $kirby->request()->get('language') ?? $sessionLanguage;
|
||||
|
||||
// keep the language for the next visit
|
||||
if ($language !== $sessionLanguage) {
|
||||
$session->set('panel.language', $language);
|
||||
}
|
||||
|
||||
// activate the current language in Kirby
|
||||
$kirby->setCurrentLanguage($language);
|
||||
|
||||
return $language;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the currently active Panel translation
|
||||
* based on the current user or config
|
||||
*/
|
||||
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();
|
||||
|
||||
$kirby->setCurrentTranslation($translation);
|
||||
|
||||
return $translation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an absolute Panel URL
|
||||
* independent of the Panel slug config
|
||||
*/
|
||||
public static function url(string|null $url = null, array $options = []): string
|
||||
{
|
||||
// only touch relative paths
|
||||
if (Url::isAbsolute($url) === false) {
|
||||
$kirby = App::instance();
|
||||
$slug = $kirby->option('panel.slug', 'panel');
|
||||
$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) {
|
||||
$path = $slug . '/' . $path;
|
||||
}
|
||||
|
||||
// create an absolute URL
|
||||
$url = CmsUrl::to($path, $options);
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
139
kirby/src/Panel/Plugins.php
Normal file
139
kirby/src/Panel/Plugins.php
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Data\Json;
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Toolkit\A;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* The Plugins class takes care of collecting
|
||||
* js and css plugin files for the panel and caches
|
||||
* them in the media folder
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Plugins
|
||||
{
|
||||
/**
|
||||
* Cache of all collected plugin files
|
||||
*/
|
||||
public array|null $files = null;
|
||||
|
||||
/**
|
||||
* Collects and returns the plugin files for all plugins
|
||||
*/
|
||||
public function files(): array
|
||||
{
|
||||
if ($this->files !== null) {
|
||||
return $this->files;
|
||||
}
|
||||
|
||||
$this->files = [];
|
||||
|
||||
foreach (App::instance()->plugins() as $plugin) {
|
||||
$this->files[] = $plugin->root() . '/index.css';
|
||||
$this->files[] = $plugin->root() . '/index.js';
|
||||
// During plugin development, kirbyup adds an index.dev.mjs as entry point, which
|
||||
// Kirby will load instead of the regular index.js. Since kirbyup is based on Vite,
|
||||
// it can't use the standard index.js as entry for its development server:
|
||||
// Vite requires an entry of type module so it can use JavaScript imports,
|
||||
// but Kirbyup needs index.js to load as a regular script, synchronously.
|
||||
$this->files[] = $plugin->root() . '/index.dev.mjs';
|
||||
}
|
||||
|
||||
return $this->files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last modification
|
||||
* of the collected plugin files
|
||||
*/
|
||||
public function modified(): int
|
||||
{
|
||||
$files = $this->files();
|
||||
$modified = [0];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$modified[] = F::modified($file);
|
||||
}
|
||||
|
||||
return max($modified);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the files from all plugins and concatenate them
|
||||
*/
|
||||
public function read(string $type): string
|
||||
{
|
||||
$dist = [];
|
||||
|
||||
foreach ($this->files() as $file) {
|
||||
// filter out files with a different type
|
||||
if (F::extension($file) !== $type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// filter out empty files and files that don't exist
|
||||
$content = F::read($file);
|
||||
if (!$content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === 'mjs') {
|
||||
// index.dev.mjs files are turned into data URIs so they
|
||||
// can be imported without having to copy them to /media
|
||||
// (avoids having to clean the files from /media again)
|
||||
$content = F::uri($file);
|
||||
}
|
||||
|
||||
if ($type === 'js') {
|
||||
// filter out all index.js files that shouldn't be loaded
|
||||
// because an index.dev.mjs exists
|
||||
if (F::exists(preg_replace('/\.js$/', '.dev.mjs', $file)) === true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = trim($content);
|
||||
|
||||
// make sure that each plugin is ended correctly
|
||||
if (Str::endsWith($content, ';') === false) {
|
||||
$content .= ';';
|
||||
}
|
||||
}
|
||||
|
||||
$dist[] = $content;
|
||||
}
|
||||
|
||||
if ($type === 'mjs') {
|
||||
// if no index.dev.mjs modules exist, we MUST return an empty string instead
|
||||
// of loading an empty array; this is because the module loader code uses
|
||||
// top level await, which is not compatible with Kirby's minimum browser
|
||||
// version requirements and therefore must not appear in a default setup
|
||||
if ($dist === []) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$modules = Json::encode($dist);
|
||||
$modulePromise = "Promise.all($modules.map(url => import(url)))";
|
||||
return "try { await $modulePromise } catch (e) { console.error(e) }" . PHP_EOL;
|
||||
}
|
||||
|
||||
return implode(PHP_EOL . PHP_EOL, $dist);
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute url to the cache file
|
||||
* This is used by the panel to link the plugins
|
||||
*/
|
||||
public function url(string $type): string
|
||||
{
|
||||
return App::instance()->url('media') . '/plugins/index.' . $type . '?' . $this->modified();
|
||||
}
|
||||
}
|
||||
60
kirby/src/Panel/Redirect.php
Normal file
60
kirby/src/Panel/Redirect.php
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel;
|
||||
|
||||
use Exception;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* The Redirect exception can be thrown in all Fiber
|
||||
* routes to send a redirect response. It is
|
||||
* primarily used in `Panel::go($location)`
|
||||
* @since 3.6.0
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Redirect extends Exception
|
||||
{
|
||||
public function __construct(
|
||||
string $location,
|
||||
int $code = 302,
|
||||
protected int|false $refresh = false,
|
||||
Throwable|null $previous = null
|
||||
) {
|
||||
parent::__construct($location, $code, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the HTTP code for the redirect
|
||||
*/
|
||||
public function code(): int
|
||||
{
|
||||
$codes = [301, 302, 303, 307, 308];
|
||||
|
||||
if (in_array($this->getCode(), $codes, true) === true) {
|
||||
return $this->getCode();
|
||||
}
|
||||
|
||||
return 302;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the URL for the redirect
|
||||
*/
|
||||
public function location(): string
|
||||
{
|
||||
return $this->getMessage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the refresh time in seconds
|
||||
*/
|
||||
public function refresh(): int|false
|
||||
{
|
||||
return $this->refresh;
|
||||
}
|
||||
}
|
||||
24
kirby/src/Panel/Request.php
Normal file
24
kirby/src/Panel/Request.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel;
|
||||
|
||||
use Kirby\Http\Response;
|
||||
|
||||
/**
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Request
|
||||
{
|
||||
/**
|
||||
* Renders request responses
|
||||
*/
|
||||
public static function response($data, array $options = []): Response
|
||||
{
|
||||
$data = Json::responseData($data);
|
||||
return Panel::json($data, $data['code'] ?? 200);
|
||||
}
|
||||
}
|
||||
41
kirby/src/Panel/Search.php
Normal file
41
kirby/src/Panel/Search.php
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel;
|
||||
|
||||
use Kirby\Http\Response;
|
||||
|
||||
/**
|
||||
* The Search response class handles Fiber
|
||||
* requests to render the JSON object for
|
||||
* search queries
|
||||
* @since 3.6.0
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Search extends Json
|
||||
{
|
||||
protected static string $key = '$search';
|
||||
|
||||
public static function response($data, array $options = []): Response
|
||||
{
|
||||
if (
|
||||
is_array($data) === true &&
|
||||
array_key_exists('results', $data) === false
|
||||
) {
|
||||
$data = [
|
||||
'results' => $data,
|
||||
'pagination' => [
|
||||
'page' => 1,
|
||||
'limit' => $total = count($data),
|
||||
'total' => $total
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
return parent::response($data, $options);
|
||||
}
|
||||
}
|
||||
110
kirby/src/Panel/Site.php
Normal file
110
kirby/src/Panel/Site.php
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel;
|
||||
|
||||
use Kirby\Cms\File as CmsFile;
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
use Kirby\Filesystem\Asset;
|
||||
use Kirby\Panel\Ui\Buttons\ViewButtons;
|
||||
|
||||
/**
|
||||
* Provides information about the site model for the Panel
|
||||
* @since 3.6.0
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class Site extends Model
|
||||
{
|
||||
/**
|
||||
* @var \Kirby\Cms\Site
|
||||
*/
|
||||
protected ModelWithContent $model;
|
||||
|
||||
/**
|
||||
* Returns header buttons which should be displayed
|
||||
* on the site view
|
||||
*/
|
||||
public function buttons(): array
|
||||
{
|
||||
return ViewButtons::view($this)->defaults(
|
||||
'open',
|
||||
'preview',
|
||||
'languages'
|
||||
)->render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the setup for a dropdown option
|
||||
* which is used in the changes dropdown
|
||||
* for example.
|
||||
*/
|
||||
public function dropdownOption(): array
|
||||
{
|
||||
return [
|
||||
'icon' => 'home',
|
||||
'text' => $this->model->title()->value(),
|
||||
] + parent::dropdownOption();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the image file object based on provided query
|
||||
*/
|
||||
protected function imageSource(
|
||||
string|null $query = null
|
||||
): CmsFile|Asset|null {
|
||||
$query ??= 'site.image';
|
||||
return parent::imageSource($query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full path without leading slash
|
||||
*/
|
||||
public function path(): string
|
||||
{
|
||||
return 'site';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the data array for the view's component props
|
||||
*/
|
||||
public function props(): array
|
||||
{
|
||||
$props = parent::props();
|
||||
|
||||
// Additional model information
|
||||
// @deprecated Use the top-level props instead
|
||||
$model = [
|
||||
'link' => $props['link'],
|
||||
'previewUrl' => $this->model->previewUrl(),
|
||||
'title' => $this->model->title()->toString(),
|
||||
'uuid' => $props['uuid'],
|
||||
];
|
||||
|
||||
return [
|
||||
...$props,
|
||||
'blueprint' => 'site',
|
||||
'id' => '/',
|
||||
'model' => $model,
|
||||
'title' => $model['title'],
|
||||
'permissions' => [
|
||||
...$props['permissions'],
|
||||
'preview' => $this->model->homePage()?->permissions()->can('preview') === true,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the data array for this model's Panel view
|
||||
*/
|
||||
public function view(): array
|
||||
{
|
||||
return [
|
||||
'component' => 'k-site-view',
|
||||
'props' => $this->props()
|
||||
];
|
||||
}
|
||||
}
|
||||
64
kirby/src/Panel/Ui/Button.php
Normal file
64
kirby/src/Panel/Ui/Button.php
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Ui;
|
||||
|
||||
use Kirby\Toolkit\I18n;
|
||||
|
||||
/**
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
*/
|
||||
class Button extends Component
|
||||
{
|
||||
public function __construct(
|
||||
public string $component = 'k-button',
|
||||
public array|null $badge = null,
|
||||
public string|null $class = null,
|
||||
public string|bool|null $current = null,
|
||||
public string|null $dialog = null,
|
||||
public bool $disabled = false,
|
||||
public string|null $drawer = null,
|
||||
public bool|null $dropdown = null,
|
||||
public string|null $icon = null,
|
||||
public string|null $link = null,
|
||||
public bool|string $responsive = true,
|
||||
public string|null $size = null,
|
||||
public string|null $style = null,
|
||||
public string|null $target = null,
|
||||
public string|array|null $text = null,
|
||||
public string|null $theme = null,
|
||||
public string|array|null $title = null,
|
||||
public string $type = 'button',
|
||||
public string|null $variant = null,
|
||||
...$attrs
|
||||
) {
|
||||
$this->attrs = $attrs;
|
||||
}
|
||||
|
||||
public function props(): array
|
||||
{
|
||||
return [
|
||||
...parent::props(),
|
||||
'badge' => $this->badge,
|
||||
'current' => $this->current,
|
||||
'dialog' => $this->dialog,
|
||||
'disabled' => $this->disabled,
|
||||
'drawer' => $this->drawer,
|
||||
'dropdown' => $this->dropdown,
|
||||
'icon' => $this->icon,
|
||||
'link' => $this->link,
|
||||
'responsive' => $this->responsive,
|
||||
'size' => $this->size,
|
||||
'target' => $this->target,
|
||||
'text' => I18n::translate($this->text, $this->text),
|
||||
'theme' => $this->theme,
|
||||
'title' => I18n::translate($this->title, $this->title),
|
||||
'type' => $this->type,
|
||||
'variant' => $this->variant,
|
||||
];
|
||||
}
|
||||
}
|
||||
33
kirby/src/Panel/Ui/Buttons/LanguageCreateButton.php
Normal file
33
kirby/src/Panel/Ui/Buttons/LanguageCreateButton.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Ui\Buttons;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Toolkit\I18n;
|
||||
|
||||
/**
|
||||
* View button to create a new language
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
* @unstable
|
||||
*/
|
||||
class LanguageCreateButton extends ViewButton
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$user = App::instance()->user();
|
||||
$permission = $user?->role()->permissions()->for('languages', 'create');
|
||||
|
||||
parent::__construct(
|
||||
dialog: 'languages/create',
|
||||
disabled: $permission !== true,
|
||||
icon: 'add',
|
||||
text: I18n::translate('language.create'),
|
||||
);
|
||||
}
|
||||
}
|
||||
34
kirby/src/Panel/Ui/Buttons/LanguageDeleteButton.php
Normal file
34
kirby/src/Panel/Ui/Buttons/LanguageDeleteButton.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Ui\Buttons;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\Language;
|
||||
use Kirby\Toolkit\I18n;
|
||||
|
||||
/**
|
||||
* View button to delete a language
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
* @unstable
|
||||
*/
|
||||
class LanguageDeleteButton extends ViewButton
|
||||
{
|
||||
public function __construct(Language $language)
|
||||
{
|
||||
$user = App::instance()->user();
|
||||
$permission = $user?->role()->permissions()->for('languages', 'delete');
|
||||
|
||||
parent::__construct(
|
||||
dialog: 'languages/' . $language->id() . '/delete',
|
||||
disabled: $permission !== true,
|
||||
icon: 'trash',
|
||||
title: I18n::translate('delete'),
|
||||
);
|
||||
}
|
||||
}
|
||||
34
kirby/src/Panel/Ui/Buttons/LanguageSettingsButton.php
Normal file
34
kirby/src/Panel/Ui/Buttons/LanguageSettingsButton.php
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Ui\Buttons;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\Language;
|
||||
use Kirby\Toolkit\I18n;
|
||||
|
||||
/**
|
||||
* View button to update settings of a language
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
* @unstable
|
||||
*/
|
||||
class LanguageSettingsButton extends ViewButton
|
||||
{
|
||||
public function __construct(Language $language)
|
||||
{
|
||||
$user = App::instance()->user();
|
||||
$permission = $user?->role()->permissions()->for('languages', 'update');
|
||||
|
||||
parent::__construct(
|
||||
dialog: 'languages/' . $language->id() . '/update',
|
||||
disabled: $permission !== true,
|
||||
icon: 'cog',
|
||||
title: I18n::translate('settings'),
|
||||
);
|
||||
}
|
||||
}
|
||||
120
kirby/src/Panel/Ui/Buttons/LanguagesDropdown.php
Normal file
120
kirby/src/Panel/Ui/Buttons/LanguagesDropdown.php
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Ui\Buttons;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\Language;
|
||||
use Kirby\Cms\Languages;
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* View button to switch content translation languages
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
* @unstable
|
||||
*/
|
||||
class LanguagesDropdown extends ViewButton
|
||||
{
|
||||
protected App $kirby;
|
||||
|
||||
public function __construct(
|
||||
ModelWithContent $model
|
||||
) {
|
||||
$this->kirby = $model->kirby();
|
||||
|
||||
parent::__construct(
|
||||
component: 'k-languages-dropdown',
|
||||
model: $model,
|
||||
class: 'k-languages-dropdown',
|
||||
icon: 'translate',
|
||||
// Fiber dropdown endpoint to load options
|
||||
// only when dropdown is opened
|
||||
options: $model->panel()->url(true) . '/languages',
|
||||
responsive: 'text',
|
||||
text: Str::upper($this->kirby->language()?->code())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if any translation other than the current one has unsaved changes
|
||||
* (the current language has to be handled in `k-languages-dropdown` as its
|
||||
* state can change dynamically without another backend request)
|
||||
*/
|
||||
public function hasDiff(): bool
|
||||
{
|
||||
foreach (Languages::ensure() as $language) {
|
||||
if ($this->kirby->language()?->code() !== $language->code()) {
|
||||
if ($this->model->version('changes')->exists($language) === true) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function option(Language $language): array
|
||||
{
|
||||
$changes = $this->model->version('changes');
|
||||
|
||||
return [
|
||||
'text' => $language->name(),
|
||||
'code' => $language->code(),
|
||||
'current' => $language->code() === $this->kirby->language()?->code(),
|
||||
'default' => $language->isDefault(),
|
||||
'changes' => $changes->exists($language),
|
||||
'lock' => $changes->isLocked('*')
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Options are used in the Fiber dropdown routes
|
||||
*/
|
||||
public function options(): array
|
||||
{
|
||||
$languages = $this->kirby->languages();
|
||||
$options = [];
|
||||
|
||||
if ($this->kirby->multilang() === false) {
|
||||
return $options;
|
||||
}
|
||||
|
||||
// add the primary/default language first
|
||||
if ($default = $languages->default()) {
|
||||
$options[] = $this->option($default);
|
||||
$options[] = '-';
|
||||
$languages = $languages->not($default);
|
||||
}
|
||||
|
||||
// add all secondary languages after the separator
|
||||
foreach ($languages as $language) {
|
||||
$options[] = $this->option($language);
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
public function props(): array
|
||||
{
|
||||
return [
|
||||
...parent::props(),
|
||||
'hasDiff' => $this->hasDiff()
|
||||
];
|
||||
}
|
||||
|
||||
public function render(): array|null
|
||||
{
|
||||
// hides the language selector when there are less than 2 languages
|
||||
if ($this->kirby->languages()->count() < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parent::render();
|
||||
}
|
||||
}
|
||||
32
kirby/src/Panel/Ui/Buttons/OpenButton.php
Normal file
32
kirby/src/Panel/Ui/Buttons/OpenButton.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Ui\Buttons;
|
||||
|
||||
use Kirby\Toolkit\I18n;
|
||||
|
||||
/**
|
||||
* Open view button
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
* @unstable
|
||||
*/
|
||||
class OpenButton extends ViewButton
|
||||
{
|
||||
public function __construct(
|
||||
public string|null $link,
|
||||
public string|null $target = '_blank'
|
||||
) {
|
||||
parent::__construct(
|
||||
class: 'k-open-view-button',
|
||||
icon: 'open',
|
||||
link: $link,
|
||||
target: $target,
|
||||
title: I18n::translate('open')
|
||||
);
|
||||
}
|
||||
}
|
||||
50
kirby/src/Panel/Ui/Buttons/PageStatusButton.php
Normal file
50
kirby/src/Panel/Ui/Buttons/PageStatusButton.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Ui\Buttons;
|
||||
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Toolkit\I18n;
|
||||
|
||||
/**
|
||||
* Status view button for pages
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
* @unstable
|
||||
*/
|
||||
class PageStatusButton extends ViewButton
|
||||
{
|
||||
public function __construct(
|
||||
Page $page
|
||||
) {
|
||||
$status = $page->status();
|
||||
$blueprint = $page->blueprint()->status()[$status] ?? null;
|
||||
$disabled = $page->permissions()->cannot('changeStatus');
|
||||
$text = $blueprint['label'] ?? I18n::translate('page.status.' . $status);
|
||||
$title = I18n::translate('page.status') . ': ' . $text;
|
||||
|
||||
if ($disabled === true) {
|
||||
$title .= ' (' . I18n::translate('disabled') . ')';
|
||||
}
|
||||
|
||||
parent::__construct(
|
||||
class: 'k-status-view-button k-page-status-button',
|
||||
component: 'k-status-view-button',
|
||||
dialog: $page->panel()->url(true) . '/changeStatus',
|
||||
disabled: $disabled,
|
||||
icon: 'status-' . $status,
|
||||
style: '--icon-size: 15px',
|
||||
text: $text,
|
||||
title: $title,
|
||||
theme: match($status) {
|
||||
'draft' => 'negative-icon',
|
||||
'unlisted' => 'info-icon',
|
||||
'listed' => 'positive-icon'
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
30
kirby/src/Panel/Ui/Buttons/PreviewButton.php
Normal file
30
kirby/src/Panel/Ui/Buttons/PreviewButton.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Ui\Buttons;
|
||||
|
||||
use Kirby\Toolkit\I18n;
|
||||
|
||||
/**
|
||||
* Preview view button
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
* @unstable
|
||||
*/
|
||||
class PreviewButton extends ViewButton
|
||||
{
|
||||
public function __construct(
|
||||
public string|null $link
|
||||
) {
|
||||
parent::__construct(
|
||||
class: 'k-preview-view-button',
|
||||
icon: 'window',
|
||||
link: $link,
|
||||
title: I18n::translate('preview')
|
||||
);
|
||||
}
|
||||
}
|
||||
32
kirby/src/Panel/Ui/Buttons/SettingsButton.php
Normal file
32
kirby/src/Panel/Ui/Buttons/SettingsButton.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Ui\Buttons;
|
||||
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
use Kirby\Toolkit\I18n;
|
||||
|
||||
/**
|
||||
* Settings view button for models
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
* @unstable
|
||||
*/
|
||||
class SettingsButton extends ViewButton
|
||||
{
|
||||
public function __construct(
|
||||
ModelWithContent $model
|
||||
) {
|
||||
parent::__construct(
|
||||
component: 'k-settings-view-button',
|
||||
class: 'k-settings-view-button',
|
||||
icon: 'cog',
|
||||
options: $model->panel()->url(true),
|
||||
title: I18n::translate('settings'),
|
||||
);
|
||||
}
|
||||
}
|
||||
57
kirby/src/Panel/Ui/Buttons/VersionsButton.php
Normal file
57
kirby/src/Panel/Ui/Buttons/VersionsButton.php
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Ui\Buttons;
|
||||
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
use Kirby\Content\VersionId;
|
||||
use Kirby\Toolkit\I18n;
|
||||
|
||||
/**
|
||||
* Versions view button for models
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
* @unstable
|
||||
*/
|
||||
class VersionsButton extends ViewButton
|
||||
{
|
||||
public function __construct(
|
||||
ModelWithContent $model,
|
||||
VersionId|string $versionId = 'latest'
|
||||
) {
|
||||
$versionId = $versionId === 'compare' ? 'compare' : VersionId::from($versionId)->value();
|
||||
$viewUrl = $model->panel()->url(true) . '/preview';
|
||||
|
||||
parent::__construct(
|
||||
class: 'k-versions-view-button',
|
||||
icon: $versionId === 'compare' ? 'layout-columns' : 'git-branch',
|
||||
options: [
|
||||
[
|
||||
'label' => I18n::translate('version.latest'),
|
||||
'icon' => 'git-branch',
|
||||
'link' => $viewUrl . '/latest',
|
||||
'current' => $versionId === 'latest'
|
||||
],
|
||||
[
|
||||
'label' => I18n::translate('version.changes'),
|
||||
'icon' => 'git-branch',
|
||||
'link' => $viewUrl . '/changes',
|
||||
'current' => $versionId === 'changes'
|
||||
],
|
||||
'-',
|
||||
[
|
||||
'label' => I18n::translate('version.compare'),
|
||||
'icon' => 'layout-columns',
|
||||
'link' => $viewUrl . '/compare',
|
||||
'current' => $versionId === 'compare'
|
||||
],
|
||||
|
||||
],
|
||||
text: I18n::translate('version.' . $versionId),
|
||||
);
|
||||
}
|
||||
}
|
||||
215
kirby/src/Panel/Ui/Buttons/ViewButton.php
Normal file
215
kirby/src/Panel/Ui/Buttons/ViewButton.php
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Ui\Buttons;
|
||||
|
||||
use Closure;
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\Language;
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
use Kirby\Panel\Panel;
|
||||
use Kirby\Panel\Ui\Button;
|
||||
use Kirby\Toolkit\Controller;
|
||||
|
||||
/**
|
||||
* A view button is a UI button, by default small in size and filles,
|
||||
* that optionally defines options for a dropdown
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
*/
|
||||
class ViewButton extends Button
|
||||
{
|
||||
public function __construct(
|
||||
public string $component = 'k-view-button',
|
||||
public readonly ModelWithContent|Language|null $model = null,
|
||||
public array|null $badge = null,
|
||||
public string|null $class = null,
|
||||
public string|bool|null $current = null,
|
||||
public string|null $dialog = null,
|
||||
public bool $disabled = false,
|
||||
public string|null $drawer = null,
|
||||
public bool|null $dropdown = null,
|
||||
public string|null $icon = null,
|
||||
public string|null $link = null,
|
||||
public array|string|null $options = null,
|
||||
public bool|string $responsive = true,
|
||||
public string|null $size = 'sm',
|
||||
public string|null $style = null,
|
||||
public string|null $target = null,
|
||||
public string|array|null $text = null,
|
||||
public string|null $theme = null,
|
||||
public string|array|null $title = null,
|
||||
public string $type = 'button',
|
||||
public string|null $variant = 'filled',
|
||||
...$attrs
|
||||
) {
|
||||
$this->attrs = $attrs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates new view button by looking up
|
||||
* the button in all areas, if referenced by name
|
||||
* and resolving to proper instance
|
||||
*/
|
||||
public static function factory(
|
||||
string|array|Closure|bool $button = true,
|
||||
string|int|null $name = null,
|
||||
string|null $view = null,
|
||||
ModelWithContent|Language|null $model = null,
|
||||
array $data = []
|
||||
): static|null {
|
||||
// if referenced by name (`name: false`),
|
||||
// don't render anything
|
||||
if ($button === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// transform `- name` notation to `name: true`
|
||||
if (
|
||||
is_string($name) === false &&
|
||||
is_string($button) === true
|
||||
) {
|
||||
$name = $button;
|
||||
$button = true;
|
||||
}
|
||||
|
||||
// if referenced by name (`name: true`),
|
||||
// try to get button definition from areas or config
|
||||
if ($button === true) {
|
||||
$button = static::find($name, $view);
|
||||
}
|
||||
|
||||
// resolve Closure to button object or array
|
||||
if ($button instanceof Closure) {
|
||||
$button = static::resolve($button, $model, $data);
|
||||
}
|
||||
|
||||
if (
|
||||
$button === null ||
|
||||
$button instanceof ViewButton
|
||||
) {
|
||||
return $button;
|
||||
}
|
||||
|
||||
// flatten array into list of arguments for this class
|
||||
$button = static::normalize($button);
|
||||
|
||||
// if button definition has a name, use it for the component name
|
||||
if (is_string($name) === true) {
|
||||
// if this specific component does not exist,
|
||||
// `k-view-buttons` will fall back to `k-view-button` again
|
||||
$button['component'] ??= 'k-' . $name . '-view-button';
|
||||
}
|
||||
|
||||
return new static(...$button, model: $model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a view button by name
|
||||
* among the defined buttons from all areas
|
||||
* @unstable
|
||||
*/
|
||||
public static function find(
|
||||
string $name,
|
||||
string|null $view = null
|
||||
): array|Closure {
|
||||
// collect all buttons from areas and config
|
||||
$buttons = [
|
||||
...Panel::buttons(),
|
||||
...App::instance()->option('panel.viewButtons.' . $view, [])
|
||||
];
|
||||
|
||||
// try to find by full name (view-prefixed)
|
||||
if ($view && $button = $buttons[$view . '.' . $name] ?? null) {
|
||||
return $button;
|
||||
}
|
||||
|
||||
// try to find by just name
|
||||
if ($button = $buttons[$name] ?? null) {
|
||||
return $button;
|
||||
}
|
||||
|
||||
// assume it must be a custom view button component
|
||||
return ['component' => 'k-' . $name . '-view-button'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms an array to be used as
|
||||
* named arguments in the constructor
|
||||
* @unstable
|
||||
*/
|
||||
public static function normalize(array $button): array
|
||||
{
|
||||
// if component and props are both not set, assume shortcut
|
||||
// where props were directly passed on top-level
|
||||
if (
|
||||
isset($button['component']) === false &&
|
||||
isset($button['props']) === false
|
||||
) {
|
||||
return $button;
|
||||
}
|
||||
|
||||
// flatten array
|
||||
if ($props = $button['props'] ?? null) {
|
||||
$button = [...$props, ...$button];
|
||||
unset($button['props']);
|
||||
}
|
||||
|
||||
return $button;
|
||||
}
|
||||
|
||||
public function props(): array
|
||||
{
|
||||
// helper for props that support Kirby queries
|
||||
$resolve = fn ($value) =>
|
||||
$value ?
|
||||
$this->model?->toSafeString($value) ?? $value :
|
||||
null;
|
||||
|
||||
return [
|
||||
...$props = parent::props(),
|
||||
'dialog' => $resolve($props['dialog']),
|
||||
'drawer' => $resolve($props['drawer']),
|
||||
'icon' => $resolve($props['icon']),
|
||||
'link' => $resolve($props['link']),
|
||||
'text' => $resolve($props['text']),
|
||||
'theme' => $resolve($props['theme']),
|
||||
'options' => $this->options
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a closure to the actual view button
|
||||
* by calling it with the provided arguments
|
||||
*/
|
||||
public static function resolve(
|
||||
Closure $button,
|
||||
ModelWithContent|Language|null $model = null,
|
||||
array $data = []
|
||||
): static|array|null {
|
||||
$kirby = App::instance();
|
||||
$controller = new Controller($button);
|
||||
|
||||
if (
|
||||
$model instanceof ModelWithContent ||
|
||||
$model instanceof Language
|
||||
) {
|
||||
$data = [
|
||||
'model' => $model,
|
||||
$model::CLASS_ALIAS => $model,
|
||||
...$data
|
||||
];
|
||||
}
|
||||
|
||||
return $controller->call(data: [
|
||||
'kirby' => $kirby,
|
||||
'site' => $kirby->site(),
|
||||
'user' => $kirby->user(),
|
||||
...$data
|
||||
]);
|
||||
}
|
||||
}
|
||||
104
kirby/src/Panel/Ui/Buttons/ViewButtons.php
Normal file
104
kirby/src/Panel/Ui/Buttons/ViewButtons.php
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Ui\Buttons;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\Language;
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
use Kirby\Panel\Model;
|
||||
|
||||
/**
|
||||
* Collects view buttons for a specific view
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
* @unstable
|
||||
*/
|
||||
class ViewButtons
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $view,
|
||||
public readonly ModelWithContent|Language|null $model = null,
|
||||
public array|false|null $buttons = null,
|
||||
public array $data = []
|
||||
) {
|
||||
// if no specific buttons are passed,
|
||||
// use default buttons for this view from config
|
||||
$this->buttons ??= App::instance()->option(
|
||||
'panel.viewButtons.' . $view
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds data passed to view button closures
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function bind(array $data): static
|
||||
{
|
||||
$this->data = [...$this->data, ...$data];
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Sets the default buttons
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function defaults(string ...$defaults): static
|
||||
{
|
||||
$this->buttons ??= $defaults;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns array of button component-props definitions
|
||||
*/
|
||||
public function render(): array
|
||||
{
|
||||
// hides all buttons when `buttons: false` set
|
||||
if ($this->buttons === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$buttons = [];
|
||||
|
||||
foreach ($this->buttons ?? [] as $name => $button) {
|
||||
$buttons[] = ViewButton::factory(
|
||||
button: $button,
|
||||
name: $name,
|
||||
view: $this->view,
|
||||
model: $this->model,
|
||||
data: $this->data
|
||||
)?->render();
|
||||
}
|
||||
|
||||
return array_values(array_filter($buttons));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates new instance for a view
|
||||
* with special support for model views
|
||||
*/
|
||||
public static function view(
|
||||
string|Model $view,
|
||||
ModelWithContent|Language|null $model = null
|
||||
): static {
|
||||
if ($view instanceof Model) {
|
||||
$model = $view->model();
|
||||
$blueprint = $model->blueprint()->buttons();
|
||||
$view = $model::CLASS_ALIAS;
|
||||
}
|
||||
|
||||
return new static(
|
||||
view: $view,
|
||||
model: $model ?? null,
|
||||
buttons: $blueprint ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
93
kirby/src/Panel/Ui/Component.php
Normal file
93
kirby/src/Panel/Ui/Component.php
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Ui;
|
||||
|
||||
use Kirby\Exception\LogicException;
|
||||
use Kirby\Toolkit\Str;
|
||||
|
||||
/**
|
||||
* Component that can be passed as component-props array
|
||||
* to the Vue Panel frontend
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
*/
|
||||
abstract class Component
|
||||
{
|
||||
protected string $key;
|
||||
public array $attrs = [];
|
||||
|
||||
public function __construct(
|
||||
public string $component,
|
||||
public string|null $class = null,
|
||||
public string|null $style = null,
|
||||
...$attrs
|
||||
) {
|
||||
$this->attrs = $attrs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic setter and getter for component properties
|
||||
*
|
||||
* ```php
|
||||
* $component->class('my-class')
|
||||
* ```
|
||||
*/
|
||||
public function __call(string $name, array $args = [])
|
||||
{
|
||||
if (property_exists($this, $name) === false) {
|
||||
throw new LogicException(
|
||||
message: 'The property "' . $name . '" does not exist on the UI component "' . $this->component . '"'
|
||||
);
|
||||
}
|
||||
|
||||
// getter
|
||||
if ($args === []) {
|
||||
return $this->$name;
|
||||
}
|
||||
|
||||
// setter
|
||||
$this->$name = $args[0];
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a (unique) key that can be used
|
||||
* for Vue's `:key` attribute
|
||||
*/
|
||||
public function key(): string
|
||||
{
|
||||
return $this->key ??= Str::random(10, 'alphaNum');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the props that will be passed to the Vue component
|
||||
*/
|
||||
public function props(): array
|
||||
{
|
||||
return [
|
||||
'class' => $this->class,
|
||||
'style' => $this->style,
|
||||
...$this->attrs
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns array with the Vue component name and props array
|
||||
*/
|
||||
public function render(): array|null
|
||||
{
|
||||
return [
|
||||
'component' => $this->component,
|
||||
'key' => $this->key(),
|
||||
'props' => array_filter(
|
||||
$this->props(),
|
||||
fn ($prop) => $prop !== null
|
||||
)
|
||||
];
|
||||
}
|
||||
}
|
||||
105
kirby/src/Panel/Ui/FilePreview.php
Normal file
105
kirby/src/Panel/Ui/FilePreview.php
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Ui;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\File;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Panel\Ui\FilePreviews\DefaultFilePreview;
|
||||
use Kirby\Toolkit\I18n;
|
||||
|
||||
/**
|
||||
* Defines a component that implements a file preview
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
*/
|
||||
abstract class FilePreview extends Component
|
||||
{
|
||||
public function __construct(
|
||||
public File $file,
|
||||
public string $component
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this class should
|
||||
* handle the preview of this file
|
||||
*/
|
||||
abstract public static function accepts(File $file): bool;
|
||||
|
||||
/**
|
||||
* Returns detail information about the file
|
||||
*/
|
||||
public function details(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'title' => I18n::translate('template'),
|
||||
'text' => $this->file->template() ?? '—'
|
||||
],
|
||||
[
|
||||
'title' => I18n::translate('mime'),
|
||||
'text' => $this->file->mime()
|
||||
],
|
||||
[
|
||||
'title' => I18n::translate('url'),
|
||||
'link' => $link = $this->file->previewUrl(),
|
||||
'text' => $link,
|
||||
],
|
||||
[
|
||||
'title' => I18n::translate('size'),
|
||||
'text' => $this->file->niceSize()
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a file preview instance by going through all
|
||||
* available handler classes and finding the first that
|
||||
* accepts the file
|
||||
*/
|
||||
final public static function factory(File $file): static
|
||||
{
|
||||
// get file preview classes providers from plugins
|
||||
$handlers = App::instance()->extensions('filePreviews');
|
||||
|
||||
foreach ($handlers as $handler) {
|
||||
if (is_subclass_of($handler, self::class) === false) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'File preview handler "' . $handler . '" must extend ' . self::class
|
||||
);
|
||||
}
|
||||
|
||||
if ($handler::accepts($file) === true) {
|
||||
return new $handler($file);
|
||||
}
|
||||
}
|
||||
|
||||
return new DefaultFilePreview($file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon or image to display as thumbnail
|
||||
*/
|
||||
public function image(): array|null
|
||||
{
|
||||
return $this->file->panel()->image([
|
||||
'back' => 'transparent',
|
||||
'ratio' => '1/1'
|
||||
], 'cards');
|
||||
}
|
||||
|
||||
public function props(): array
|
||||
{
|
||||
return [
|
||||
'details' => $this->details(),
|
||||
'image' => $this->image(),
|
||||
'url' => $this->file->previewUrl()
|
||||
];
|
||||
}
|
||||
}
|
||||
29
kirby/src/Panel/Ui/FilePreviews/AudioFilePreview.php
Normal file
29
kirby/src/Panel/Ui/FilePreviews/AudioFilePreview.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Ui\FilePreviews;
|
||||
|
||||
use Kirby\Cms\File;
|
||||
use Kirby\Panel\Ui\FilePreview;
|
||||
|
||||
/**
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
* @unstable
|
||||
*/
|
||||
class AudioFilePreview extends FilePreview
|
||||
{
|
||||
public function __construct(
|
||||
public File $file,
|
||||
public string $component = 'k-audio-file-preview'
|
||||
) {
|
||||
}
|
||||
|
||||
public static function accepts(File $file): bool
|
||||
{
|
||||
return $file->type() === 'audio';
|
||||
}
|
||||
}
|
||||
42
kirby/src/Panel/Ui/FilePreviews/DefaultFilePreview.php
Normal file
42
kirby/src/Panel/Ui/FilePreviews/DefaultFilePreview.php
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Ui\FilePreviews;
|
||||
|
||||
use Kirby\Cms\File;
|
||||
use Kirby\Panel\Ui\FilePreview;
|
||||
|
||||
/**
|
||||
* Fallback file preview component
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
* @unstable
|
||||
*/
|
||||
class DefaultFilePreview extends FilePreview
|
||||
{
|
||||
public function __construct(
|
||||
public File $file,
|
||||
public string $component = 'k-default-file-preview'
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts any file as last resort
|
||||
*/
|
||||
public static function accepts(File $file): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function props(): array
|
||||
{
|
||||
return [
|
||||
...parent::props(),
|
||||
'image' => $this->image()
|
||||
];
|
||||
}
|
||||
}
|
||||
53
kirby/src/Panel/Ui/FilePreviews/ImageFilePreview.php
Normal file
53
kirby/src/Panel/Ui/FilePreviews/ImageFilePreview.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Ui\FilePreviews;
|
||||
|
||||
use Kirby\Cms\File;
|
||||
use Kirby\Panel\Ui\FilePreview;
|
||||
use Kirby\Toolkit\I18n;
|
||||
|
||||
/**
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
* @unstable
|
||||
*/
|
||||
class ImageFilePreview extends FilePreview
|
||||
{
|
||||
public function __construct(
|
||||
public File $file,
|
||||
public string $component = 'k-image-file-preview'
|
||||
) {
|
||||
}
|
||||
|
||||
public static function accepts(File $file): bool
|
||||
{
|
||||
return $file->type() === 'image';
|
||||
}
|
||||
|
||||
public function details(): array
|
||||
{
|
||||
return [
|
||||
...parent::details(),
|
||||
[
|
||||
'title' => I18n::translate('dimensions'),
|
||||
'text' => $this->file->dimensions() . ' ' . I18n::translate('pixel')
|
||||
],
|
||||
[
|
||||
'title' => I18n::translate('orientation'),
|
||||
'text' => I18n::translate('orientation.' . $this->file->dimensions()->orientation())
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function props(): array
|
||||
{
|
||||
return [
|
||||
...parent::props(),
|
||||
'focusable' => $this->file->panel()->isFocusable()
|
||||
];
|
||||
}
|
||||
}
|
||||
29
kirby/src/Panel/Ui/FilePreviews/PdfFilePreview.php
Normal file
29
kirby/src/Panel/Ui/FilePreviews/PdfFilePreview.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Ui\FilePreviews;
|
||||
|
||||
use Kirby\Cms\File;
|
||||
use Kirby\Panel\Ui\FilePreview;
|
||||
|
||||
/**
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
* @unstable
|
||||
*/
|
||||
class PdfFilePreview extends FilePreview
|
||||
{
|
||||
public function __construct(
|
||||
public File $file,
|
||||
public string $component = 'k-pdf-file-preview'
|
||||
) {
|
||||
}
|
||||
|
||||
public static function accepts(File $file): bool
|
||||
{
|
||||
return $file->extension() === 'pdf';
|
||||
}
|
||||
}
|
||||
29
kirby/src/Panel/Ui/FilePreviews/VideoFilePreview.php
Normal file
29
kirby/src/Panel/Ui/FilePreviews/VideoFilePreview.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Ui\FilePreviews;
|
||||
|
||||
use Kirby\Cms\File;
|
||||
use Kirby\Panel\Ui\FilePreview;
|
||||
|
||||
/**
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.0.0
|
||||
* @unstable
|
||||
*/
|
||||
class VideoFilePreview extends FilePreview
|
||||
{
|
||||
public function __construct(
|
||||
public File $file,
|
||||
public string $component = 'k-video-file-preview'
|
||||
) {
|
||||
}
|
||||
|
||||
public static function accepts(File $file): bool
|
||||
{
|
||||
return $file->type() === 'video';
|
||||
}
|
||||
}
|
||||
74
kirby/src/Panel/Ui/Item/FileItem.php
Normal file
74
kirby/src/Panel/Ui/Item/FileItem.php
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Ui\Item;
|
||||
|
||||
use Kirby\Cms\File;
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
use Kirby\Panel\Model;
|
||||
|
||||
/**
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.1.0
|
||||
*/
|
||||
class FileItem extends ModelItem
|
||||
{
|
||||
/**
|
||||
* @var \Kirby\Cms\File
|
||||
*/
|
||||
protected ModelWithContent $model;
|
||||
|
||||
/**
|
||||
* @var \Kirby\Panel\File
|
||||
*/
|
||||
protected Model $panel;
|
||||
|
||||
public function __construct(
|
||||
File $file,
|
||||
protected bool $dragTextIsAbsolute = false,
|
||||
string|array|false|null $image = [],
|
||||
string|null $info = null,
|
||||
string|null $layout = null,
|
||||
string|null $text = null,
|
||||
) {
|
||||
parent::__construct(
|
||||
model: $file,
|
||||
image: $image,
|
||||
info: $info,
|
||||
layout: $layout,
|
||||
text: $text ?? '{{ file.filename }}',
|
||||
);
|
||||
}
|
||||
|
||||
protected function dragText(): string
|
||||
{
|
||||
return $this->panel->dragText(absolute: $this->dragTextIsAbsolute);
|
||||
}
|
||||
|
||||
protected function permissions(): array
|
||||
{
|
||||
$permissions = $this->model->permissions();
|
||||
|
||||
return [
|
||||
'delete' => $permissions->can('delete'),
|
||||
'sort' => $permissions->can('sort'),
|
||||
];
|
||||
}
|
||||
|
||||
public function props(): array
|
||||
{
|
||||
return [
|
||||
...parent::props(),
|
||||
'dragText' => $this->dragText(),
|
||||
'extension' => $this->model->extension(),
|
||||
'filename' => $this->model->filename(),
|
||||
'mime' => $this->model->mime(),
|
||||
'parent' => $this->model->parent()->panel()->path(),
|
||||
'template' => $this->model->template(),
|
||||
'url' => $this->model->url(),
|
||||
];
|
||||
}
|
||||
}
|
||||
74
kirby/src/Panel/Ui/Item/ModelItem.php
Normal file
74
kirby/src/Panel/Ui/Item/ModelItem.php
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Ui\Item;
|
||||
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
use Kirby\Panel\Model as Panel;
|
||||
use Kirby\Panel\Ui\Component;
|
||||
|
||||
/**
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.1.0
|
||||
*/
|
||||
class ModelItem extends Component
|
||||
{
|
||||
protected string $layout;
|
||||
protected Panel $panel;
|
||||
protected string $text;
|
||||
|
||||
public function __construct(
|
||||
protected ModelWithContent $model,
|
||||
protected string|array|false|null $image = [],
|
||||
protected string|null $info = null,
|
||||
string|null $layout = null,
|
||||
string|null $text = null,
|
||||
) {
|
||||
parent::__construct(component: 'k-item');
|
||||
|
||||
$this->layout = $layout ?? 'list';
|
||||
$this->panel = $this->model->panel();
|
||||
$this->text = $text ?? '{{ model.title }}';
|
||||
}
|
||||
|
||||
protected function info(): string|null
|
||||
{
|
||||
return $this->model->toSafeString($this->info ?? false);
|
||||
}
|
||||
|
||||
protected function image(): array|null
|
||||
{
|
||||
return $this->panel->image($this->image, $this->layout);
|
||||
}
|
||||
|
||||
protected function link(): string
|
||||
{
|
||||
return $this->panel->url(true);
|
||||
}
|
||||
|
||||
protected function permissions(): array
|
||||
{
|
||||
return $this->model->permissions()->toArray();
|
||||
}
|
||||
|
||||
public function props(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->model->id(),
|
||||
'image' => $this->image(),
|
||||
'info' => $this->info(),
|
||||
'link' => $this->link(),
|
||||
'permissions' => $this->permissions(),
|
||||
'text' => $this->text(),
|
||||
'uuid' => $this->model->uuid()?->toString(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function text(): string
|
||||
{
|
||||
return $this->model->toSafeString($this->text);
|
||||
}
|
||||
}
|
||||
74
kirby/src/Panel/Ui/Item/PageItem.php
Normal file
74
kirby/src/Panel/Ui/Item/PageItem.php
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Ui\Item;
|
||||
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
use Kirby\Cms\Page;
|
||||
use Kirby\Panel\Model;
|
||||
|
||||
/**
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.1.0
|
||||
*/
|
||||
class PageItem extends ModelItem
|
||||
{
|
||||
/**
|
||||
* @var \Kirby\Cms\Page
|
||||
*/
|
||||
protected ModelWithContent $model;
|
||||
|
||||
/**
|
||||
* @var \Kirby\Panel\Page
|
||||
*/
|
||||
protected Model $panel;
|
||||
|
||||
public function __construct(
|
||||
Page $page,
|
||||
string|array|false|null $image = [],
|
||||
string|null $info = null,
|
||||
string|null $layout = null,
|
||||
string|null $text = null,
|
||||
) {
|
||||
parent::__construct(
|
||||
model: $page,
|
||||
image: $image,
|
||||
info: $info,
|
||||
layout: $layout,
|
||||
text: $text ?? '{{ page.title }}',
|
||||
);
|
||||
}
|
||||
|
||||
protected function dragText(): string
|
||||
{
|
||||
return $this->panel->dragText();
|
||||
}
|
||||
|
||||
protected function permissions(): array
|
||||
{
|
||||
$permissions = $this->model->permissions();
|
||||
|
||||
return [
|
||||
'changeSlug' => $permissions->can('changeSlug'),
|
||||
'changeStatus' => $permissions->can('changeStatus'),
|
||||
'changeTitle' => $permissions->can('changeTitle'),
|
||||
'delete' => $permissions->can('delete'),
|
||||
'sort' => $permissions->can('sort'),
|
||||
];
|
||||
}
|
||||
|
||||
public function props(): array
|
||||
{
|
||||
return [
|
||||
...parent::props(),
|
||||
'dragText' => $this->dragText(),
|
||||
'parent' => $this->model->parentId(),
|
||||
'status' => $this->model->status(),
|
||||
'template' => $this->model->intendedTemplate()->name(),
|
||||
'url' => $this->model->url(),
|
||||
];
|
||||
}
|
||||
}
|
||||
38
kirby/src/Panel/Ui/Item/UserItem.php
Normal file
38
kirby/src/Panel/Ui/Item/UserItem.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Ui\Item;
|
||||
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
use Kirby\Cms\User;
|
||||
|
||||
/**
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.1.0
|
||||
*/
|
||||
class UserItem extends ModelItem
|
||||
{
|
||||
/**
|
||||
* @var \Kirby\Cms\User
|
||||
*/
|
||||
protected ModelWithContent $model;
|
||||
|
||||
public function __construct(
|
||||
User $user,
|
||||
string|array|false|null $image = [],
|
||||
string|null $info = '{{ user.role.title }}',
|
||||
string|null $layout = null,
|
||||
string|null $text = null,
|
||||
) {
|
||||
parent::__construct(
|
||||
model: $user,
|
||||
image: $image,
|
||||
info: $info,
|
||||
layout: $layout,
|
||||
text: $text ?? '{{ user.username }}',
|
||||
);
|
||||
}
|
||||
}
|
||||
140
kirby/src/Panel/Ui/Stat.php
Normal file
140
kirby/src/Panel/Ui/Stat.php
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Ui;
|
||||
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Toolkit\I18n;
|
||||
|
||||
/**
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.1.0
|
||||
*/
|
||||
class Stat extends Component
|
||||
{
|
||||
public function __construct(
|
||||
public array|string $label,
|
||||
public array|string $value,
|
||||
public string $component = 'k-stat',
|
||||
public array|string|null $dialog = null,
|
||||
public array|string|null $drawer = null,
|
||||
public string|null $icon = null,
|
||||
public array|string|null $info = null,
|
||||
public array|string|null $link = null,
|
||||
public ModelWithContent|null $model = null,
|
||||
public string|null $theme = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function dialog(): string|null
|
||||
{
|
||||
return $this->stringTemplate(
|
||||
$this->i18n($this->dialog)
|
||||
);
|
||||
}
|
||||
|
||||
public function drawer(): string|null
|
||||
{
|
||||
return $this->stringTemplate(
|
||||
$this->i18n($this->drawer)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-suppress TooFewArguments
|
||||
*/
|
||||
public static function from(
|
||||
array|string $input,
|
||||
ModelWithContent|null $model = null,
|
||||
): static {
|
||||
if ($model !== null) {
|
||||
if (is_string($input) === true) {
|
||||
$input = $model->query($input);
|
||||
|
||||
if (is_array($input) === false) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'Invalid data from stat query. The query must return an array.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$input['model'] = $model;
|
||||
}
|
||||
|
||||
return new static(...$input);
|
||||
}
|
||||
|
||||
public function icon(): string|null
|
||||
{
|
||||
return $this->stringTemplate($this->icon);
|
||||
}
|
||||
|
||||
public function info(): string|null
|
||||
{
|
||||
return $this->stringTemplate(
|
||||
$this->i18n($this->info)
|
||||
);
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return $this->stringTemplate(
|
||||
$this->i18n($this->label)
|
||||
);
|
||||
}
|
||||
|
||||
public function link(): string|null
|
||||
{
|
||||
return $this->stringTemplate(
|
||||
$this->i18n($this->link)
|
||||
);
|
||||
}
|
||||
|
||||
public function props(): array
|
||||
{
|
||||
return [
|
||||
'dialog' => $this->dialog(),
|
||||
'drawer' => $this->drawer(),
|
||||
'icon' => $this->icon(),
|
||||
'info' => $this->info(),
|
||||
'label' => $this->label(),
|
||||
'link' => $this->link(),
|
||||
'theme' => $this->theme(),
|
||||
'value' => $this->value(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function stringTemplate(string|null $string = null): string|null
|
||||
{
|
||||
if ($this->model === null) {
|
||||
return $string;
|
||||
}
|
||||
|
||||
if ($string !== null) {
|
||||
return $this->model->toString($string);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function theme(): string|null
|
||||
{
|
||||
return $this->stringTemplate($this->theme);
|
||||
}
|
||||
|
||||
protected function i18n(string|array|null $param = null): string|null
|
||||
{
|
||||
return empty($param) === false ? I18n::translate($param, $param) : null;
|
||||
}
|
||||
|
||||
public function value(): string
|
||||
{
|
||||
return $this->stringTemplate(
|
||||
$this->i18n($this->value)
|
||||
);
|
||||
}
|
||||
}
|
||||
83
kirby/src/Panel/Ui/Stats.php
Normal file
83
kirby/src/Panel/Ui/Stats.php
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Ui;
|
||||
|
||||
use Kirby\Cms\ModelWithContent;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.1.0
|
||||
*/
|
||||
class Stats extends Component
|
||||
{
|
||||
public function __construct(
|
||||
public string $component = 'k-stats',
|
||||
public ModelWithContent|null $model = null,
|
||||
public array $reports = [],
|
||||
public string $size = 'large',
|
||||
) {
|
||||
}
|
||||
|
||||
public static function from(
|
||||
ModelWithContent $model,
|
||||
array|string $reports,
|
||||
string $size = 'large'
|
||||
): static {
|
||||
if (is_string($reports) === true) {
|
||||
$reports = $model->query($reports);
|
||||
|
||||
if (is_array($reports) === false) {
|
||||
throw new InvalidArgumentException(
|
||||
message: 'Invalid data from stats query. The query must return an array.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return new static(
|
||||
model: $model,
|
||||
reports: $reports,
|
||||
size: $size
|
||||
);
|
||||
}
|
||||
|
||||
public function props(): array
|
||||
{
|
||||
return [
|
||||
'reports' => $this->reports(),
|
||||
'size' => $this->size(),
|
||||
];
|
||||
}
|
||||
|
||||
public function reports(): array
|
||||
{
|
||||
$reports = [];
|
||||
|
||||
foreach ($this->reports as $stat) {
|
||||
// if not already a Stat object, convert it
|
||||
if ($stat instanceof Stat === false) {
|
||||
try {
|
||||
$stat = Stat::from(
|
||||
input: $stat,
|
||||
model: $this->model
|
||||
);
|
||||
} catch (InvalidArgumentException) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$reports[] = $stat->props();
|
||||
}
|
||||
|
||||
return $reports;
|
||||
}
|
||||
|
||||
public function size(): string
|
||||
{
|
||||
return $this->size;
|
||||
}
|
||||
}
|
||||
62
kirby/src/Panel/Ui/Upload.php
Normal file
62
kirby/src/Panel/Ui/Upload.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel\Ui;
|
||||
|
||||
/**
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
* @since 5.1.0
|
||||
*/
|
||||
class Upload
|
||||
{
|
||||
public function __construct(
|
||||
protected string $api,
|
||||
protected string|null $accept = null,
|
||||
protected array $attributes = [],
|
||||
protected int|null $max = null,
|
||||
protected bool $multiple = true,
|
||||
protected array|bool|null $preview = null,
|
||||
protected int|null $sort = null,
|
||||
protected string|null $template = null,
|
||||
) {
|
||||
}
|
||||
|
||||
protected function attributes(): array
|
||||
{
|
||||
return [
|
||||
...$this->attributes,
|
||||
'sort' => $this->sort,
|
||||
'template' => $this->template()
|
||||
];
|
||||
}
|
||||
|
||||
protected function max(): int|null
|
||||
{
|
||||
return $this->multiple() === false ? 1 : $this->max;
|
||||
}
|
||||
|
||||
protected function multiple(): bool
|
||||
{
|
||||
return $this->multiple === true && ($this->max === null || $this->max > 1);
|
||||
}
|
||||
|
||||
public function props(): array
|
||||
{
|
||||
return [
|
||||
'accept' => $this->accept,
|
||||
'api' => $this->api,
|
||||
'attributes' => $this->attributes(),
|
||||
'max' => $this->max(),
|
||||
'multiple' => $this->multiple(),
|
||||
'preview' => $this->preview,
|
||||
];
|
||||
}
|
||||
|
||||
protected function template(): string|null
|
||||
{
|
||||
return $this->template === 'default' ? null : $this->template;
|
||||
}
|
||||
}
|
||||
299
kirby/src/Panel/User.php
Normal file
299
kirby/src/Panel/User.php
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
<?php
|
||||
|
||||
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\Panel\Ui\Buttons\ViewButtons;
|
||||
use Kirby\Panel\Ui\Item\UserItem;
|
||||
use Kirby\Toolkit\I18n;
|
||||
|
||||
/**
|
||||
* Provides information about the user model for the Panel
|
||||
* @since 3.6.0
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class User extends Model
|
||||
{
|
||||
/**
|
||||
* @var \Kirby\Cms\User
|
||||
*/
|
||||
protected ModelWithContent $model;
|
||||
|
||||
/**
|
||||
* Breadcrumb array
|
||||
*/
|
||||
public function breadcrumb(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'label' => $this->model->username(),
|
||||
'link' => $this->url(true),
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns header buttons which should be displayed
|
||||
* on the user view
|
||||
*/
|
||||
public function buttons(): array
|
||||
{
|
||||
return ViewButtons::view($this)->defaults(
|
||||
'theme',
|
||||
'settings',
|
||||
'languages'
|
||||
)->render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides options for the user dropdown
|
||||
*/
|
||||
public function dropdown(array $options = []): array
|
||||
{
|
||||
$account = $this->model->isLoggedIn();
|
||||
$i18nPrefix = $account ? 'account' : 'user';
|
||||
$permissions = $this->options(['preview']);
|
||||
$url = $this->url(true);
|
||||
$result = [];
|
||||
|
||||
$result[] = [
|
||||
'dialog' => $url . '/changeName',
|
||||
'icon' => 'title',
|
||||
'text' => I18n::translate($i18nPrefix . '.changeName'),
|
||||
'disabled' => $this->isDisabledDropdownOption('changeName', $options, $permissions)
|
||||
];
|
||||
|
||||
$result[] = '-';
|
||||
|
||||
$result[] = [
|
||||
'dialog' => $url . '/changeEmail',
|
||||
'icon' => 'email',
|
||||
'text' => I18n::translate('user.changeEmail'),
|
||||
'disabled' => $this->isDisabledDropdownOption('changeEmail', $options, $permissions)
|
||||
];
|
||||
|
||||
$result[] = [
|
||||
'dialog' => $url . '/changeRole',
|
||||
'icon' => 'bolt',
|
||||
'text' => I18n::translate('user.changeRole'),
|
||||
'disabled' => $this->isDisabledDropdownOption('changeRole', $options, $permissions) || $this->model->roles()->count() < 2
|
||||
];
|
||||
|
||||
$result[] = [
|
||||
'dialog' => $url . '/changeLanguage',
|
||||
'icon' => 'translate',
|
||||
'text' => I18n::translate('user.changeLanguage'),
|
||||
'disabled' => $this->isDisabledDropdownOption('changeLanguage', $options, $permissions)
|
||||
];
|
||||
|
||||
$result[] = '-';
|
||||
|
||||
$result[] = [
|
||||
'dialog' => $url . '/changePassword',
|
||||
'icon' => 'key',
|
||||
'text' => I18n::translate('user.changePassword'),
|
||||
'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[] = '-';
|
||||
|
||||
$result[] = [
|
||||
'dialog' => $url . '/delete',
|
||||
'icon' => 'trash',
|
||||
'text' => I18n::translate($i18nPrefix . '.delete'),
|
||||
'disabled' => $this->isDisabledDropdownOption('delete', $options, $permissions)
|
||||
];
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the setup for a dropdown option
|
||||
* which is used in the changes dropdown
|
||||
* for example.
|
||||
*/
|
||||
public function dropdownOption(): array
|
||||
{
|
||||
return [
|
||||
'icon' => 'user',
|
||||
'text' => $this->model->username(),
|
||||
] + parent::dropdownOption();
|
||||
}
|
||||
|
||||
public function home(): string|null
|
||||
{
|
||||
if ($home = ($this->model->blueprint()->home() ?? null)) {
|
||||
$url = $this->model->toString($home);
|
||||
return Url::to($url);
|
||||
}
|
||||
|
||||
return Panel::url('site');
|
||||
}
|
||||
|
||||
/**
|
||||
* Default settings for the user's Panel image
|
||||
*/
|
||||
protected function imageDefaults(): array
|
||||
{
|
||||
return [
|
||||
...parent::imageDefaults(),
|
||||
'back' => 'black',
|
||||
'icon' => 'user',
|
||||
'ratio' => '1/1',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the image file object based on provided query
|
||||
*/
|
||||
protected function imageSource(
|
||||
string|null $query = null
|
||||
): CmsFile|Asset|null {
|
||||
if ($query === null) {
|
||||
return $this->model->avatar();
|
||||
}
|
||||
|
||||
return parent::imageSource($query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full path without leading slash
|
||||
*/
|
||||
public function path(): string
|
||||
{
|
||||
// path to your own account
|
||||
if ($this->model->isLoggedIn() === true) {
|
||||
return 'account';
|
||||
}
|
||||
|
||||
return 'users/' . $this->model->id();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns prepared data for the panel user picker
|
||||
*/
|
||||
public function pickerData(array $params = []): array
|
||||
{
|
||||
$item = new UserItem(
|
||||
user: $this->model,
|
||||
image: $params['image'] ?? null,
|
||||
info: $params['info'] ?? null,
|
||||
layout: $params['layout'] ?? null,
|
||||
text: $params['text'] ?? null,
|
||||
);
|
||||
|
||||
return [
|
||||
...$item->props(),
|
||||
'email' => $this->model->email(),
|
||||
'sortable' => true,
|
||||
'username' => $this->model->username(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns navigation array with
|
||||
* previous and next user
|
||||
*/
|
||||
public function prevNext(): array
|
||||
{
|
||||
$user = $this->model;
|
||||
|
||||
return [
|
||||
'next' => fn () => $this->toPrevNextLink($user->next(), 'username'),
|
||||
'prev' => fn () => $this->toPrevNextLink($user->prev(), 'username')
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the data array for the view's component props
|
||||
*/
|
||||
public function props(): array
|
||||
{
|
||||
$props = parent::props();
|
||||
$user = $this->model;
|
||||
$permissions = $this->options();
|
||||
|
||||
// Additional model information
|
||||
// @deprecated Use the top-level props instead
|
||||
$model = [
|
||||
'account' => $user->isLoggedIn(),
|
||||
'avatar' => $user->avatar()?->url(),
|
||||
'email' => $user->email(),
|
||||
'id' => $props['id'],
|
||||
'language' => $this->translation()->name(),
|
||||
'link' => $props['link'],
|
||||
'name' => $user->name()->toString(),
|
||||
'role' => $user->role()->title(),
|
||||
'username' => $user->username(),
|
||||
'uuid' => $props['uuid'],
|
||||
];
|
||||
|
||||
return [
|
||||
...parent::props(),
|
||||
...$this->prevNext(),
|
||||
'avatar' => $model['avatar'],
|
||||
'blueprint' => $this->model->role()->name(),
|
||||
'canChangeEmail' => $permissions['changeEmail'],
|
||||
'canChangeLanguage' => $permissions['changeLanguage'],
|
||||
'canChangeName' => $permissions['changeName'],
|
||||
'canChangeRole' => $this->model->roles()->count() > 1,
|
||||
'email' => $model['email'],
|
||||
'language' => $model['language'],
|
||||
'model' => $model,
|
||||
'name' => $model['name'],
|
||||
'role' => $model['role'],
|
||||
'username' => $model['username'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Translation object
|
||||
* for the selected Panel language
|
||||
*/
|
||||
public function translation(): Translation
|
||||
{
|
||||
$kirby = $this->model->kirby();
|
||||
$lang = $this->model->language();
|
||||
return $kirby->translation($lang);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the data array for this model's Panel view
|
||||
*/
|
||||
public function view(): array
|
||||
{
|
||||
return [
|
||||
'breadcrumb' => $this->breadcrumb(),
|
||||
'component' => 'k-user-view',
|
||||
'props' => $this->props(),
|
||||
'title' => $this->model->username(),
|
||||
];
|
||||
}
|
||||
}
|
||||
116
kirby/src/Panel/UserTotpDisableDialog.php
Normal file
116
kirby/src/Panel/UserTotpDisableDialog.php
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
<?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(
|
||||
message: 'You are not allowed to disable TOTP for other users'
|
||||
);
|
||||
}
|
||||
|
||||
// Remove the TOTP secret from the account
|
||||
$this->user->changeTotp(null);
|
||||
|
||||
return [
|
||||
'message' => I18n::translate('login.totp.disable.success')
|
||||
];
|
||||
} catch (InvalidArgumentException $e) {
|
||||
// Catch and re-throw exception so that any
|
||||
// Unauthenticated exception for incorrect passwords
|
||||
// does not trigger a logout
|
||||
throw new InvalidArgumentException(
|
||||
key: $e->getKey(),
|
||||
data: $e->getData(),
|
||||
fallback: $e->getMessage(),
|
||||
previous: $e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
95
kirby/src/Panel/UserTotpEnableDialog.php
Normal file
95
kirby/src/Panel/UserTotpEnableDialog.php
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel;
|
||||
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Cms\User;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Image\QrCode;
|
||||
use Kirby\Toolkit\I18n;
|
||||
use Kirby\Toolkit\Totp;
|
||||
|
||||
/**
|
||||
* Manages the Panel dialog to enable TOTP auth for the current user
|
||||
* @since 4.0.0
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Nico Hoffmann <nico@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class UserTotpEnableDialog
|
||||
{
|
||||
public App $kirby;
|
||||
public Totp $totp;
|
||||
public User $user;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->kirby = App::instance();
|
||||
$this->user = $this->kirby->user();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Panel dialog state when opening the dialog
|
||||
*/
|
||||
public function load(): array
|
||||
{
|
||||
return [
|
||||
'component' => 'k-totp-dialog',
|
||||
'props' => [
|
||||
'qr' => $this->qr()->toSvg(size: '100%'),
|
||||
'value' => ['secret' => $this->secret()]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a QR code with a new TOTP secret for the user
|
||||
*/
|
||||
public function qr(): QrCode
|
||||
{
|
||||
$issuer = $this->kirby->site()->title();
|
||||
$label = $this->user->email();
|
||||
$uri = $this->totp()->uri($issuer, $label);
|
||||
return new QrCode($uri);
|
||||
}
|
||||
|
||||
public function secret(): string
|
||||
{
|
||||
return $this->totp()->secret();
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the user's TOTP secret when the dialog is submitted
|
||||
*/
|
||||
public function submit(): array
|
||||
{
|
||||
$secret = $this->kirby->request()->get('secret');
|
||||
$confirm = $this->kirby->request()->get('confirm');
|
||||
|
||||
if ($confirm === null) {
|
||||
throw new InvalidArgumentException(
|
||||
['key' => 'login.totp.confirm.missing']
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->totp($secret)->verify($confirm) === false) {
|
||||
throw new InvalidArgumentException(
|
||||
['key' => 'login.totp.confirm.invalid']
|
||||
);
|
||||
}
|
||||
|
||||
$this->user->changeTotp($secret);
|
||||
|
||||
return [
|
||||
'message' => I18n::translate('login.totp.enable.success')
|
||||
];
|
||||
}
|
||||
|
||||
public function totp(string|null $secret = null): Totp
|
||||
{
|
||||
return $this->totp ??= new Totp($secret);
|
||||
}
|
||||
}
|
||||
381
kirby/src/Panel/View.php
Normal file
381
kirby/src/Panel/View.php
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
<?php
|
||||
|
||||
namespace Kirby\Panel;
|
||||
|
||||
use Kirby\Api\Upload;
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Exception\Exception;
|
||||
use Kirby\Http\Response;
|
||||
use Kirby\Toolkit\A;
|
||||
use Kirby\Toolkit\Date;
|
||||
use Kirby\Toolkit\Str;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* The View response class handles Fiber
|
||||
* requests to render either a JSON object
|
||||
* or a full HTML document for Panel views
|
||||
* @since 3.6.0
|
||||
*
|
||||
* @package Kirby Panel
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://getkirby.com/license
|
||||
*/
|
||||
class View
|
||||
{
|
||||
/**
|
||||
* Filters the data array based on headers or
|
||||
* query parameters. Requests can return only
|
||||
* certain data fields that way or globals can
|
||||
* be injected on demand.
|
||||
*/
|
||||
public static function apply(array $data): array
|
||||
{
|
||||
$request = App::instance()->request();
|
||||
$only = $request->header('X-Fiber-Only') ?? $request->get('_only');
|
||||
|
||||
if (empty($only) === false) {
|
||||
return static::applyOnly($data, $only);
|
||||
}
|
||||
|
||||
$globals =
|
||||
$request->header('X-Fiber-Globals') ??
|
||||
$request->get('_globals');
|
||||
|
||||
if (empty($globals) === false) {
|
||||
return static::applyGlobals($data, $globals);
|
||||
}
|
||||
|
||||
return A::apply($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if globals should be included in a JSON Fiber request. They are normally
|
||||
* only loaded with the full document request, but sometimes need to be updated.
|
||||
*
|
||||
* A global request can be activated with the `X-Fiber-Globals` header or the
|
||||
* `_globals` query parameter.
|
||||
*/
|
||||
public static function applyGlobals(
|
||||
array $data,
|
||||
string|null $globals = null
|
||||
): array {
|
||||
// split globals string into an array of fields
|
||||
$globalKeys = Str::split($globals, ',');
|
||||
|
||||
// add requested globals
|
||||
if ($globalKeys === []) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
$globals = static::globals();
|
||||
|
||||
foreach ($globalKeys as $globalKey) {
|
||||
if (isset($globals[$globalKey]) === true) {
|
||||
$data[$globalKey] = $globals[$globalKey];
|
||||
}
|
||||
}
|
||||
|
||||
// merge with shared data
|
||||
return A::apply($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the request should only return a limited
|
||||
* set of data. This can be activated with the `X-Fiber-Only`
|
||||
* header or the `_only` query parameter in a request.
|
||||
*
|
||||
* Such requests can fetch shared data or globals.
|
||||
* Globals will be loaded on demand.
|
||||
*/
|
||||
public static function applyOnly(
|
||||
array $data,
|
||||
string|null $only = null
|
||||
): array {
|
||||
// split include string into an array of fields
|
||||
$onlyKeys = Str::split($only, ',');
|
||||
|
||||
// if a full request is made, return all data
|
||||
if ($onlyKeys === []) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
// otherwise filter data based on
|
||||
// dot notation, e.g. `$props.tab.columns`
|
||||
$result = [];
|
||||
|
||||
// check if globals are requested and need to be merged
|
||||
if (Str::contains($only, '$')) {
|
||||
$data = array_merge_recursive(static::globals(), $data);
|
||||
}
|
||||
|
||||
// make sure the data is already resolved to make
|
||||
// nested data fetching work
|
||||
$data = A::apply($data);
|
||||
|
||||
// build a new array with all requested data
|
||||
foreach ($onlyKeys as $onlyKey) {
|
||||
$result[$onlyKey] = A::get($data, $onlyKey);
|
||||
}
|
||||
|
||||
// Nest dotted keys in array but ignore $translation
|
||||
return A::nest($result, ['$translation']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the shared data array for the individual views
|
||||
* 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.
|
||||
*/
|
||||
public static function data(array $view = [], array $options = []): array
|
||||
{
|
||||
$kirby = App::instance();
|
||||
|
||||
// multilang setup check
|
||||
$multilang = Panel::multilang();
|
||||
|
||||
// get the authenticated user
|
||||
$user = $kirby->user();
|
||||
|
||||
// user permissions
|
||||
$permissions = $user?->role()->permissions()->toArray() ?? [];
|
||||
|
||||
// current content language
|
||||
$language = $kirby->language();
|
||||
|
||||
// shared data for all requests
|
||||
return [
|
||||
'$direction' => function () use ($kirby, $multilang, $language, $user) {
|
||||
if ($multilang === true && $language && $user) {
|
||||
$default = $kirby->defaultLanguage();
|
||||
|
||||
if (
|
||||
$language->direction() !== $default->direction() &&
|
||||
$language->code() !== $user->language()
|
||||
) {
|
||||
return $language->direction();
|
||||
}
|
||||
}
|
||||
},
|
||||
'$dialog' => null,
|
||||
'$drawer' => null,
|
||||
'$language' => fn () => match ($multilang) {
|
||||
false => null,
|
||||
true => $language?->toArray()
|
||||
},
|
||||
'$languages' => fn (): array => match ($multilang) {
|
||||
false => [],
|
||||
true => $kirby->languages()->values(
|
||||
fn ($language) => $language->toArray()
|
||||
)
|
||||
},
|
||||
'$menu' => function () use ($options, $permissions) {
|
||||
$menu = new Menu(
|
||||
$options['areas'] ?? [],
|
||||
$permissions,
|
||||
$options['area']['id'] ?? null
|
||||
);
|
||||
return $menu->entries();
|
||||
},
|
||||
'$permissions' => $permissions,
|
||||
'$license' => $kirby->system()->license()->status()->value(),
|
||||
'$multilang' => $multilang,
|
||||
'$searches' => static::searches($options['areas'] ?? [], $permissions),
|
||||
'$url' => $kirby->request()->url()->toString(),
|
||||
'$user' => fn () => match ($user) {
|
||||
null => null,
|
||||
default => [
|
||||
'email' => $user->email(),
|
||||
'id' => $user->id(),
|
||||
'language' => $user->language(),
|
||||
'role' => $user->role()->id(),
|
||||
'username' => $user->username(),
|
||||
]
|
||||
},
|
||||
'$view' => function () use ($kirby, $options, $view) {
|
||||
$defaults = [
|
||||
'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),
|
||||
];
|
||||
|
||||
$view = array_replace_recursive(
|
||||
$defaults,
|
||||
$options['area'] ?? [],
|
||||
$view
|
||||
);
|
||||
|
||||
// make sure that views and dialogs are gone
|
||||
unset(
|
||||
$view['buttons'],
|
||||
$view['dialogs'],
|
||||
$view['drawers'],
|
||||
$view['dropdowns'],
|
||||
$view['requests'],
|
||||
$view['searches'],
|
||||
$view['views']
|
||||
);
|
||||
|
||||
// resolve all callbacks in the view array
|
||||
return A::apply($view);
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the error view with provided message
|
||||
*/
|
||||
public static function error(string $message, int $code = 404)
|
||||
{
|
||||
return [
|
||||
'code' => $code,
|
||||
'component' => 'k-error-view',
|
||||
'error' => $message,
|
||||
'props' => [
|
||||
'error' => $message,
|
||||
'layout' => Panel::hasAccess(App::instance()->user()) ? 'inside' : 'outside'
|
||||
],
|
||||
'title' => 'Error'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates global data for the Panel.
|
||||
* This will be injected in the full Panel
|
||||
* view via the script tag. Global data
|
||||
* 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.
|
||||
*/
|
||||
public static function globals(): array
|
||||
{
|
||||
$kirby = App::instance();
|
||||
|
||||
return [
|
||||
'$config' => fn () => [
|
||||
'api' => [
|
||||
'methodOverride' => $kirby->option('api.methodOverride', true)
|
||||
],
|
||||
'debug' => $kirby->option('debug', false),
|
||||
'kirbytext' => $kirby->option('panel.kirbytext', true),
|
||||
'theme' => $kirby->option('panel.theme', 'system'),
|
||||
'translation' => $kirby->option('panel.language', 'en'),
|
||||
'upload' => Upload::chunkSize(),
|
||||
],
|
||||
'$system' => function () use ($kirby) {
|
||||
$locales = [];
|
||||
|
||||
foreach ($kirby->translations() as $translation) {
|
||||
$locales[$translation->code()] = $translation->locale();
|
||||
}
|
||||
|
||||
return [
|
||||
'ascii' => Str::$ascii,
|
||||
'csrf' => $kirby->auth()->csrfFromSession(),
|
||||
'isLocal' => $kirby->system()->isLocal(),
|
||||
'locales' => $locales,
|
||||
'slugs' => Str::$language,
|
||||
'title' => $kirby->site()->title()->or('Kirby Panel')->toString()
|
||||
];
|
||||
},
|
||||
'$translation' => function () use ($kirby) {
|
||||
$translation = match ($user = $kirby->user()) {
|
||||
null => $kirby->translation($kirby->panelLanguage()),
|
||||
default => $kirby->translation($user->language())
|
||||
};
|
||||
|
||||
return [
|
||||
'code' => $translation->code(),
|
||||
'data' => $translation->dataWithFallback(),
|
||||
'direction' => $translation->direction(),
|
||||
'name' => $translation->name(),
|
||||
'weekday' => Date::firstWeekday($translation->locale())
|
||||
];
|
||||
},
|
||||
'$urls' => fn () => [
|
||||
'api' => $kirby->url('api'),
|
||||
'site' => $kirby->url('index')
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the main panel view either as
|
||||
* JSON response or full HTML document based
|
||||
* on the request header or query params
|
||||
*/
|
||||
public static function response($data, array $options = []): Response
|
||||
{
|
||||
// handle redirects
|
||||
if ($data instanceof Redirect) {
|
||||
// if the redirect is a refresh, return a refresh response
|
||||
if ($data->refresh() !== false) {
|
||||
return Response::refresh($data->location(), $data->code(), $data->refresh());
|
||||
}
|
||||
|
||||
return Response::redirect($data->location(), $data->code());
|
||||
}
|
||||
|
||||
// handle Kirby exceptions
|
||||
if ($data instanceof Exception) {
|
||||
$data = static::error($data->getMessage(), $data->getHttpCode());
|
||||
|
||||
// handle regular exceptions
|
||||
} elseif ($data instanceof Throwable) {
|
||||
$data = static::error($data->getMessage(), 500);
|
||||
|
||||
// only expect arrays from here on
|
||||
} elseif (is_array($data) === false) {
|
||||
$data = static::error('Invalid Panel response', 500);
|
||||
}
|
||||
|
||||
// get all data for the request
|
||||
$fiber = static::data($data, $options);
|
||||
|
||||
// if requested, send $fiber data as JSON
|
||||
if (Panel::isFiberRequest() === true) {
|
||||
// filter data, if only or globals headers or
|
||||
// query parameters are set
|
||||
$fiber = static::apply($fiber);
|
||||
|
||||
return Panel::json($fiber, $fiber['$view']['code'] ?? 200);
|
||||
}
|
||||
|
||||
// load globals for the full document response
|
||||
$globals = static::globals();
|
||||
|
||||
// resolve and merge globals and shared data
|
||||
$fiber = array_merge_recursive(A::apply($globals), A::apply($fiber));
|
||||
|
||||
// render the full HTML document
|
||||
return Document::response($fiber);
|
||||
}
|
||||
|
||||
public static function searches(array $areas, array $permissions): array
|
||||
{
|
||||
$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
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
return $searches;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue