diff --git a/Plugin.php b/Plugin.php index 519a3c6..48906fc 100644 --- a/Plugin.php +++ b/Plugin.php @@ -2,15 +2,19 @@ namespace Winter\Blocks; +use Backend\Classes\NavigationManager; use Backend\Classes\WidgetManager; +use Backend\Facades\Backend; +use Backend\Models\UserRole; use Cms\Classes\AutoDatasource; use Cms\Classes\Theme; -use Event; use System\Classes\PluginBase; +use Winter\Blocks\Classes\Block as BlockModel; use Winter\Blocks\Classes\BlockManager; use Winter\Blocks\Classes\BlocksDatasource; -use Winter\Blocks\Classes\Block as BlockModel; use Winter\Blocks\FormWidgets\Block; +use Winter\Storm\Support\Facades\Config; +use Winter\Storm\Support\Facades\Event; /** * Blocks Plugin Information File @@ -103,6 +107,27 @@ public function boot(): void { $this->extendThemeDatasource(); $this->extendControlLibraryBlocks(); + + if ($this->app->runningInBackend() && in_array('Cms', Config::get('cms.loadModules'))) { + $this->extendCms(); + } + } + + /** + * Registers any back-end permissions used by this plugin. + * + * @return array + */ + public function registerPermissions() + { + return [ + 'winter.blocks.manage_blocks' => [ + 'tab' => 'winter.blocks::lang.plugin.name', + 'order' => 200, + 'roles' => [UserRole::CODE_DEVELOPER, UserRole::CODE_PUBLISHER], + 'label' => 'winter.blocks::lang.blocks.manage_blocks' + ], + ]; } /** @@ -176,4 +201,21 @@ protected function extendControlLibraryBlocks(): void } }); } + + /** + * Extend the CMS to implement the BlocksController as a child of the CMS + */ + public function extendCms(): void + { + Event::listen('backend.menu.extendItems', function (NavigationManager $manager) { + $manager->addSideMenuItem('winter.cms', 'cms', 'blocks', [ + 'label' => 'winter.blocks::lang.plugin.name', + 'icon' => 'icon-cubes', + 'url' => Backend::url('winter/blocks/blockscontroller'), + // TODO: Make good + 'attributes' => 'onclick="window.location.href = this.querySelector(\'a\').href;"', + 'permissions' => ['winter.blocks.manage_blocks'] + ]); + }); + } } diff --git a/assets/dist/js/winter.cmspage.extension.js b/assets/dist/js/winter.cmspage.extension.js new file mode 100644 index 0000000..60195eb --- /dev/null +++ b/assets/dist/js/winter.cmspage.extension.js @@ -0,0 +1 @@ +(()=>{var t;(t=window.jQuery).wn.cmsPage.updateModifiedCounter=function(){var n={page:{menu:"pages",count:0},partial:{menu:"partials",count:0},layout:{menu:"layouts",count:0},content:{menu:"content",count:0},asset:{menu:"assets",count:0},block:{menu:"blocks",count:0}};t("> div.tab-content > div.tab-pane[data-modified]","#cms-master-tabs").each((function(){var e=t("> form > input[name=templateType]",this).val();n[e].count++})),t.each(n,(function(n,e){t.wn.sideNav.setCounter("cms/"+e.menu,e.count)}))}})(); \ No newline at end of file diff --git a/assets/src/js/winter.cmspage.extension.js b/assets/src/js/winter.cmspage.extension.js new file mode 100644 index 0000000..8318d4f --- /dev/null +++ b/assets/src/js/winter.cmspage.extension.js @@ -0,0 +1,21 @@ +(($) => { + $.wn.cmsPage.updateModifiedCounter = function () { + var counters = { + page: {menu: 'pages', count: 0}, + partial: {menu: 'partials', count: 0}, + layout: {menu: 'layouts', count: 0}, + content: {menu: 'content', count: 0}, + asset: {menu: 'assets', count: 0}, + block: {menu: 'blocks', count: 0}, + } + + $('> div.tab-content > div.tab-pane[data-modified]', '#cms-master-tabs').each(function () { + var inputType = $('> form > input[name=templateType]', this).val(); + counters[inputType].count++; + }); + + $.each(counters, function (type, data) { + $.wn.sideNav.setCounter('cms/' + data.menu, data.count); + }); + }; +})(window.jQuery); diff --git a/classes/Block.php b/classes/Block.php index 9962b2c..4f928eb 100644 --- a/classes/Block.php +++ b/classes/Block.php @@ -26,6 +26,16 @@ class Block extends CmsCompoundObject */ protected $allowedExtensions = ['block']; + /** + * @var array The attributes that are mass assignable. + */ + protected $fillable = [ + 'markup', + 'settings', + 'code', + 'yaml' + ]; + protected PartialStack $partialStack; public function __construct(array $attributes = []) diff --git a/classes/BlockParser.php b/classes/BlockParser.php index 6e7d9d0..a2cc42d 100644 --- a/classes/BlockParser.php +++ b/classes/BlockParser.php @@ -15,7 +15,9 @@ class BlockParser extends SectionParser */ public static function parseSettings(string $settings): array { - return Yaml::parse($settings); + $parsed = Yaml::parse($settings); + // Ensure that the parsed settings returns an array (errors return input string) + return is_array($parsed) ? $parsed : []; } /** @@ -23,6 +25,62 @@ public static function parseSettings(string $settings): array */ public static function renderSettings(array $data): string { - return Yaml::render($data); + return is_string($data['yaml']) ? $data['yaml'] : Yaml::render($data); + } + + /** + * Parses Halcyon section content. + * The expected file format is following: + * + * INI settings section + * == + * PHP code section + * == + * Twig markup section + * + * If the content has only 2 sections they are parsed as settings and markup. + * If there is only a single section, it is parsed as markup. + * + * Returns an array with the following elements: (array|null) 'settings', + * (string|null) 'markup', (string|null) 'code'. + */ + public static function parse(string $content, array $options = []): array + { + $sectionOptions = array_merge([ + 'isCompoundObject' => true + ], $options); + extract($sectionOptions); + + $result = [ + 'settings' => [], + 'code' => null, + 'markup' => null, + 'yaml' => null + ]; + + if (!isset($isCompoundObject) || $isCompoundObject === false || !strlen($content)) { + return $result; + } + + $sections = static::parseIntoSections($content); + $count = count($sections); + foreach ($sections as &$section) { + $section = trim($section); + } + + if ($count >= 3) { + $result['yaml'] = $sections[0]; + $result['settings'] = static::parseSettings($sections[0]); + $result['code'] = static::parseCode($sections[1]); + $result['markup'] = static::parseMarkup($sections[2]); + } elseif ($count == 2) { + $result['yaml'] = $sections[0]; + $result['settings'] = static::parseSettings($sections[0]); + $result['markup'] = static::parseMarkup($sections[1]); + } elseif ($count == 1) { + $result['markup'] = static::parseMarkup($sections[0]); + } + + return $result; } } diff --git a/classes/BlockProcessor.php b/classes/BlockProcessor.php index 7794ab4..f8efa97 100644 --- a/classes/BlockProcessor.php +++ b/classes/BlockProcessor.php @@ -32,7 +32,8 @@ protected function parseTemplateContent($query, $result, $fileName) 'content' => $content, 'mtime' => array_get($result, 'mtime'), 'markup' => $processed['markup'], - 'code' => $processed['code'] + 'code' => $processed['code'], + 'yaml' => $processed['yaml'], ] + $processed['settings']; } diff --git a/controllers/BlocksController.php b/controllers/BlocksController.php new file mode 100644 index 0000000..1d2b6db --- /dev/null +++ b/controllers/BlocksController.php @@ -0,0 +1,238 @@ +theme = $theme; + + new TemplateList($this, 'blockList', function () use ($theme) { + return Block::listInTheme($theme, true); + }); + } catch (\Exception $ex) { + $this->handleError($ex); + } + + // Dynamically re-write the cms menu item urls to allow the user to return back to those pages + BackendMenu::registerCallback(function (NavigationManager $navigationManager) { + foreach ($navigationManager->getMainMenuItem('Winter.Cms', 'cms')->sideMenu as $menuItem) { + if ($menuItem->url === 'javascript:;') { + $menuItem->url = Backend::url('cms#' . $menuItem->code); + $menuItem->attributes = 'onclick="window.location.href = this.querySelector(\'a\').href;"'; + } + } + }); + } + + /** + * Index page action + * @return void + */ + public function index() + { + parent::index(); + $this->addJs('/plugins/winter/blocks/assets/dist/js/winter.cmspage.extension.js', 'core'); + } + + /** + * Resolves a template type to its class name + * @param string $type + * @return string + */ + protected function resolveTypeClassName($type) + { + if ($type !== 'block') { + throw new ApplicationException(Lang::get('cms::lang.template.invalid_type')); + } + + return Block::class; + } + + /** + * Returns the text for a template tab + * @param string $type + * @param string $template + * @return string + */ + protected function getTabTitle($type, $template) + { + if ($type !== 'block') { + throw new ApplicationException(Lang::get('cms::lang.template.invalid_type')); + } + + return $template->getFileName() ?? Lang::get('winter.blocks::lang.editor.new'); + } + + /** + * Returns a form widget for a specified template type. + * @param string $type + * @param string $template + * @param string $alias + * @return Backend\Widgets\Form + */ + protected function makeTemplateFormWidget($type, $template, $alias = null) + { + if ($type !== 'block') { + throw new ApplicationException(Lang::get('cms::lang.template.not_found')); + } + + $formConfig = '~/plugins/winter/blocks/controllers/blockscontroller/block_fields.yaml'; + + $widgetConfig = $this->makeConfig($formConfig); + + $ext = pathinfo($template->fileName, PATHINFO_EXTENSION); + if ($type === 'content') { + switch ($ext) { + case 'htm': + $type = 'richeditor'; + break; + case 'md': + $type = 'markdown'; + break; + default: + $type = 'codeeditor'; + break; + } + array_set($widgetConfig->secondaryTabs, 'fields.markup.type', $type); + } + + $lang = 'php'; + if (array_get($widgetConfig->secondaryTabs, 'fields.markup.type') === 'codeeditor') { + switch ($ext) { + case 'htm': + $lang = 'twig'; + break; + case 'html': + $lang = 'html'; + break; + case 'css': + $lang = 'css'; + break; + case 'js': + case 'json': + $lang = 'javascript'; + break; + } + } + + $widgetConfig->model = $template; + $widgetConfig->alias = $alias ?: 'form'.studly_case($type).md5($template->exists ? $template->getFileName() : uniqid()); + + return $this->makeWidget('Backend\Widgets\Form', $widgetConfig); + } + + /** + * Saves the template currently open + * @return array + */ + public function onSave() + { + $this->validateRequestTheme(); + $type = Request::input('templateType'); + $templatePath = trim(Request::input('templatePath')); + $template = $templatePath ? $this->loadTemplate($type, $templatePath) : $this->createTemplate($type); + $formWidget = $this->makeTemplateFormWidget($type, $template); + + $saveData = $formWidget->getSaveData(); + $postData = post(); + $templateData = []; + + $settings = array_get($saveData, 'settings', []) + Request::input('settings', []); + $settings = $this->upgradeSettings($settings, $template->settings); + + if ($settings) { + $templateData['settings'] = $settings; + } + + $fields = ['markup', 'code', 'fileName', 'content', 'yaml']; + + foreach ($fields as $field) { + if (array_key_exists($field, $saveData)) { + $templateData[$field] = $saveData[$field]; + } + elseif (array_key_exists($field, $postData)) { + $templateData[$field] = $postData[$field]; + } + } + + if (!empty($templateData['markup']) && Config::get('cms.convertLineEndings', false) === true) { + $templateData['markup'] = $this->convertLineEndings($templateData['markup']); + } + + if (!empty($templateData['code']) && Config::get('cms.convertLineEndings', false) === true) { + $templateData['code'] = $this->convertLineEndings($templateData['code']); + } + + if ( + !Request::input('templateForceSave') && $template->mtime + && Request::input('templateMtime') != $template->mtime + ) { + throw new ApplicationException('mtime-mismatch'); + } + + $template->attributes = []; + $template->fill($templateData); + + $template->save(); + + /** + * @event cms.template.save + * Fires after a CMS template (page|partial|layout|content|asset) has been saved. + * + * Example usage: + * + * Event::listen('cms.template.save', function ((\Cms\Controllers\Index) $controller, (mixed) $templateObject, (string) $type) { + * \Log::info("A $type has been saved"); + * }); + * + * Or + * + * $CmsIndexController->bindEvent('template.save', function ((mixed) $templateObject, (string) $type) { + * \Log::info("A $type has been saved"); + * }); + * + */ + $this->fireSystemEvent('cms.template.save', [$template, $type]); + + Flash::success(Lang::get('cms::lang.template.saved')); + + return $this->getUpdateResponse($template, $type); + } +} diff --git a/controllers/blockscontroller/_button_commit.php b/controllers/blockscontroller/_button_commit.php new file mode 100644 index 0000000..90a4abd --- /dev/null +++ b/controllers/blockscontroller/_button_commit.php @@ -0,0 +1,14 @@ + diff --git a/controllers/blockscontroller/_button_lastmodified.php b/controllers/blockscontroller/_button_lastmodified.php new file mode 100644 index 0000000..daff6f3 --- /dev/null +++ b/controllers/blockscontroller/_button_lastmodified.php @@ -0,0 +1,8 @@ + + + \ No newline at end of file diff --git a/controllers/blockscontroller/_button_reset.php b/controllers/blockscontroller/_button_reset.php new file mode 100644 index 0000000..a5a8c87 --- /dev/null +++ b/controllers/blockscontroller/_button_reset.php @@ -0,0 +1,14 @@ + diff --git a/controllers/blockscontroller/_common_toolbar_actions.php b/controllers/blockscontroller/_common_toolbar_actions.php new file mode 100644 index 0000000..2e923a1 --- /dev/null +++ b/controllers/blockscontroller/_common_toolbar_actions.php @@ -0,0 +1,19 @@ += $this->makePartial('button_commit'); ?> + += $this->makePartial('button_reset'); ?> + + + += $this->makePartial('button_lastmodified'); ?> diff --git a/controllers/blockscontroller/_concurrency_resolve_form.php b/controllers/blockscontroller/_concurrency_resolve_form.php new file mode 100644 index 0000000..03ec6ca --- /dev/null +++ b/controllers/blockscontroller/_concurrency_resolve_form.php @@ -0,0 +1,29 @@ += Form::open(['onsubmit'=>'return false']) ?> +
= e(trans('backend::lang.form.concurrency_file_changed_description')) ?>
+