├── log └── .gitkeep ├── temp └── .gitkeep ├── .gitignore ├── config ├── users.neon ├── front.neon ├── admin.neon ├── model.neon └── main.neon ├── public ├── .htaccess └── index.php ├── storage └── articles │ ├── random-article.md │ └── hello-world.md ├── app ├── Module │ ├── Front │ │ ├── @Templates │ │ │ ├── Error4xx │ │ │ │ ├── 4xx.latte │ │ │ │ ├── 405.latte │ │ │ │ ├── 410.latte │ │ │ │ ├── 403.latte │ │ │ │ └── 404.latte │ │ │ ├── Auth │ │ │ │ └── default.latte │ │ │ ├── Article │ │ │ │ └── default.latte │ │ │ ├── Homepage │ │ │ │ └── default.latte │ │ │ ├── Error │ │ │ │ ├── 503.phtml │ │ │ │ └── 500.phtml │ │ │ └── @Layout │ │ │ │ ├── header.latte │ │ │ │ └── layout.latte │ │ ├── Auth │ │ │ ├── AuthTemplate.php │ │ │ ├── Form │ │ │ │ └── SignInFormFactory.php │ │ │ └── AuthPresenter.php │ │ ├── Error4xx │ │ │ ├── Error4xxTemplate.php │ │ │ └── Error4xxPresenter.php │ │ ├── Article │ │ │ ├── ArticleTemplate.php │ │ │ └── ArticlePresenter.php │ │ ├── BaseFrontTemplate.php │ │ ├── Homepage │ │ │ ├── HomepageTemplate.php │ │ │ └── HomepagePresenter.php │ │ ├── BaseFrontPresenter.php │ │ └── Error │ │ │ └── ErrorPresenter.php │ └── Admin │ │ ├── Dashboard │ │ ├── DashboardTemplate.php │ │ └── DashboardPresenter.php │ │ ├── @Templates │ │ ├── Article │ │ │ ├── edit.latte │ │ │ └── default.latte │ │ ├── Dashboard │ │ │ └── default.latte │ │ └── @Layout │ │ │ ├── header.latte │ │ │ └── layout.latte │ │ ├── BaseAdminTemplate.php │ │ ├── Article │ │ ├── ArticleTemplate.php │ │ ├── Form │ │ │ └── ArticleFormFactory.php │ │ └── ArticlePresenter.php │ │ └── BaseAdminPresenter.php ├── Model │ └── Article │ │ ├── Exception │ │ └── ArticleNotFoundException.php │ │ ├── ArticleData.php │ │ ├── ArticleFacade.php │ │ ├── ArticleDataFactory.php │ │ ├── Article.php │ │ └── ArticleRepository.php ├── Bootstrap.php ├── Router │ └── RouterFactory.php └── UI │ └── Form │ └── Renderer │ └── BootstrapRenderer.php ├── README.md └── composer.json /log/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /temp/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /vendor 3 | /composer.lock 4 | -------------------------------------------------------------------------------- /config/users.neon: -------------------------------------------------------------------------------- 1 | security: 2 | users: 3 | nette: framework 4 | -------------------------------------------------------------------------------- /config/front.neon: -------------------------------------------------------------------------------- 1 | search: 2 | front: 3 | in: %appDir%/Module/Front 4 | classes: 5 | - *Factory 6 | - *Component 7 | -------------------------------------------------------------------------------- /public/.htaccess: -------------------------------------------------------------------------------- 1 | RewriteEngine On 2 | RewriteCond %{REQUEST_FILENAME} !-f 3 | RewriteCond %{REQUEST_FILENAME} !-d 4 | RewriteRule ^(.*)$ index.php [L] 5 | -------------------------------------------------------------------------------- /config/admin.neon: -------------------------------------------------------------------------------- 1 | search: 2 | admin: 3 | in: %appDir%/Module/Admin 4 | classes: 5 | - *Factory 6 | - *Component 7 | -------------------------------------------------------------------------------- /storage/articles/random-article.md: -------------------------------------------------------------------------------- 1 | ```yaml 2 | title: Random Article 3 | author: Rixafy 4 | createdAt: '2020-10-10 10:00' 5 | ``` 6 | 7 | Hi, **this** is very random `article` 8 | -------------------------------------------------------------------------------- /config/model.neon: -------------------------------------------------------------------------------- 1 | search: 2 | model: 3 | in: %appDir%/Model 4 | classes: 5 | - *Facade 6 | - *Factory 7 | - *Repository 8 | - *Provider 9 | - *Manager 10 | -------------------------------------------------------------------------------- /app/Module/Front/@Templates/Error4xx/4xx.latte: -------------------------------------------------------------------------------- 1 | {block content} 2 |

Oops...

3 | 4 |

Your browser sent a request that this server could not understand or process.

5 | -------------------------------------------------------------------------------- /storage/articles/hello-world.md: -------------------------------------------------------------------------------- 1 | ```yaml 2 | title: Hello world 3 | author: Rixafy 4 | createdAt: '2020-10-10 10:00' 5 | ``` 6 | 7 | This is `Hello World` article *written* using **markdown** syntax 8 | -------------------------------------------------------------------------------- /app/Module/Front/@Templates/Error4xx/405.latte: -------------------------------------------------------------------------------- 1 | {block content} 2 |

Method Not Allowed

3 | 4 |

The requested method is not allowed for the URL.

5 | 6 |

error 405

7 | -------------------------------------------------------------------------------- /app/Module/Front/@Templates/Error4xx/410.latte: -------------------------------------------------------------------------------- 1 | {block content} 2 |

Page Not Found

3 | 4 |

The page you requested has been taken off the site. We apologize for the inconvenience.

5 | 6 |

error 410

7 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | createContainer() 9 | ->getByType(Nette\Application\Application::class) 10 | ->run(); 11 | -------------------------------------------------------------------------------- /app/Module/Front/Auth/AuthTemplate.php: -------------------------------------------------------------------------------- 1 | 4 |

Edit article {$article->getTitle()}

5 |
6 | {control articleForm} 7 |
8 | 9 | -------------------------------------------------------------------------------- /app/Module/Front/@Templates/Error4xx/403.latte: -------------------------------------------------------------------------------- 1 | {block content} 2 |

Access Denied

3 | 4 |

You do not have permission to view this page. Please try contact the web 5 | site administrator if you believe you should be able to view this page.

6 | 7 |

error 403

8 | -------------------------------------------------------------------------------- /app/Module/Front/Article/ArticleTemplate.php: -------------------------------------------------------------------------------- 1 | 4 |

Dashboard

5 |

There is nothing to see, try to create an article or return to the homepage

6 | 7 | -------------------------------------------------------------------------------- /app/Model/Article/ArticleData.php: -------------------------------------------------------------------------------- 1 | 3 |

Page Not Found

4 | 5 |

The page you requested could not be found. It is possible that the address is 6 | incorrect, or that the page no longer exists. Please use a search engine to find 7 | what you are looking for.

8 | 9 |

error 404

10 | 11 | -------------------------------------------------------------------------------- /app/Module/Front/@Templates/Auth/default.latte: -------------------------------------------------------------------------------- 1 | {templateType App\Module\Front\Auth\AuthTemplate} 2 | {block content} 3 |
4 |

Account

5 |

6 | Username: nette
7 | Password: framework 8 |

9 | {if $user->isLoggedIn()} 10 | Log out 11 | {else} 12 |
13 |
14 | {control signInForm} 15 |
16 |
17 | {/if} 18 |
19 | -------------------------------------------------------------------------------- /app/Module/Front/@Templates/Article/default.latte: -------------------------------------------------------------------------------- 1 | {templateType App\Module\Front\Article\ArticleTemplate} 2 | {block content} 3 |
4 |

{$article->getTitle()}

5 |

{$article->getAuthor()}, {$article->getCreatedAt()->format('Y-m-d H:i:s')} [edit]

6 |

{$article->getContent()|noescape}

7 |

last changed at {$article->getUpdatedAt()->format('Y-m-d H:i:s')}

8 |
9 | -------------------------------------------------------------------------------- /app/Module/Front/@Templates/Homepage/default.latte: -------------------------------------------------------------------------------- 1 | {templateType App\Module\Front\Homepage\HomepageTemplate} 2 | {block content} 3 |
4 |
5 |

{$article->getTitle()}

6 |

{$article->getAuthor()}, {$article->getCreatedAt()->format('Y-m-d H:i:s')} [edit]

7 |

{$article->getContent()|noescape}

8 |
9 |
10 | -------------------------------------------------------------------------------- /app/Module/Front/Auth/Form/SignInFormFactory.php: -------------------------------------------------------------------------------- 1 | addText('username', 'Username') 17 | ->setRequired(); 18 | 19 | $form->addPassword('password', 'Password') 20 | ->setRequired(); 21 | 22 | $form->addSubmit('submit', 'Log in'); 23 | 24 | $form->setRenderer(new BootstrapRenderer()); 25 | 26 | return $form; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/Bootstrap.php: -------------------------------------------------------------------------------- 1 | setDebugMode(true) 17 | ->setTimeZone('Europe/Prague') 18 | ->setTempDirectory(__DIR__ . '/../temp'); 19 | 20 | $configurator->addConfig(__DIR__ . '/../config/main.neon'); 21 | 22 | $configurator->enableTracy(__DIR__ . '/../log'); 23 | 24 | return $configurator; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/Module/Admin/@Templates/Article/default.latte: -------------------------------------------------------------------------------- 1 | {templateType App\Module\Admin\Article\ArticleTemplate} 2 | {block title}Articles{/block} 3 | {block content} 4 |
5 |
6 |
7 |

Create new article

8 | {control articleForm} 9 |
10 |
11 |

Articles

12 |
13 | {$article->getTitle()} [edit] [delete] 14 |
15 |
16 |
17 |
18 | -------------------------------------------------------------------------------- /app/Module/Front/@Templates/Error/503.phtml: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 14 | 19 | 20 | Site is temporarily down for maintenance 21 | 22 |

We're Sorry

23 | 24 |

The site is temporarily down for maintenance. Please try again in a few minutes.

25 | -------------------------------------------------------------------------------- /app/Module/Front/Error4xx/Error4xxPresenter.php: -------------------------------------------------------------------------------- 1 | getName())[1] . '/' . $this->getHttpResponse()->getCode() .'.latte'; 19 | if (!file_exists($file)) { 20 | $this->getTemplate()->setFile(__DIR__ . '/../@Templates/' . Helpers::splitName($this->getName())[1] . '/4xx.latte'); 21 | } else { 22 | $this->getTemplate()->setFile($file); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/Module/Front/Homepage/HomepagePresenter.php: -------------------------------------------------------------------------------- 1 | articles = $this->articleFacade->getAll(); 28 | } 29 | 30 | public function renderDefault(): void 31 | { 32 | $this->template->articles = $this->articles; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nette flat-file blog example 2 | This repository demonstrates how to use multiple modules in nette application 3 | 4 | ## Run the blog 5 | Minimum required PHP version is **8.0** 6 | ```bash 7 | composer update 8 | ``` 9 | ```bash 10 | php -S localhost:80 -t public/ 11 | ``` 12 | 13 | ## What can you learn? 14 | - Automatically registering services using SearchExtension 15 | - How to design an optimal skeleton for bigger applications 16 | - How to create links between multiple modules 17 | - Using a better module mapping (in /config/main.neon) 18 | - How to use nette/forms with factories, and where to put handling logic 19 | - Creating custom form renderer 20 | - Neon config files can be separated and included to main file 21 | - How to change layout and template paths (in base presenters) 22 | - Router can be used as a service 23 | - How to delegate model logic to keep codebase clean 24 | -------------------------------------------------------------------------------- /app/Module/Admin/Article/Form/ArticleFormFactory.php: -------------------------------------------------------------------------------- 1 | addText('title', 'Title') 18 | ->setRequired(); 19 | 20 | $form->addTextArea('content', 'Content') 21 | ->setRequired(); 22 | 23 | $form->addText('author', 'Author') 24 | ->setRequired(); 25 | 26 | if ($article !== null) { 27 | $form->addSubmit('submit', 'Save'); 28 | $form->setDefaults($article->getData()); 29 | 30 | } else { 31 | $form->addSubmit('submit', 'Create'); 32 | } 33 | 34 | $form->setRenderer(new BootstrapRenderer()); 35 | 36 | return $form; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/Module/Front/BaseFrontPresenter.php: -------------------------------------------------------------------------------- 1 | setLayout(__DIR__ . '/@Templates/@Layout/layout.latte'); 19 | $this->getTemplate()->setFile(__DIR__ . '/@Templates/' . Helpers::splitName($this->getName())[1] . '/' . $this->getAction() .'.latte'); 20 | 21 | // Here is possible to assign common variables among presenters, user is just an example, nette would automatically passed user to template anyway 22 | $this->template->user = $this->getUser(); 23 | } 24 | 25 | /** 26 | * @throws AbortException 27 | */ 28 | public function handleLogout(): void 29 | { 30 | $this->getUser()->logout(); 31 | $this->redirect('this'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nette/simple-blog", 3 | "description": "Simple flat-file blog using nette framework", 4 | "type": "project", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Rixafy", 9 | "email": "rixafy@gmail.com" 10 | } 11 | ], 12 | "minimum-stability": "stable", 13 | "require": { 14 | "php": ">=8.0", 15 | "nette/application": "^3.1", 16 | "nette/bootstrap": "^3.1", 17 | "nette/caching": "^3.0", 18 | "nette/di": "^3.0", 19 | "nette/finder": "^2.5", 20 | "nette/forms": "^3.1", 21 | "nette/http": "^3.1", 22 | "nette/security": "^3.1", 23 | "nette/utils": "^3.2", 24 | "tracy/tracy": "^2.9", 25 | "latte/latte": "^3.0", 26 | "erusev/parsedown": "^1.7" 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "App\\": "app" 31 | } 32 | }, 33 | "config": { 34 | "platform": { 35 | "php": "8.0" 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/Module/Front/@Templates/@Layout/header.latte: -------------------------------------------------------------------------------- 1 | 19 | -------------------------------------------------------------------------------- /app/Module/Admin/@Templates/@Layout/header.latte: -------------------------------------------------------------------------------- 1 | 19 | -------------------------------------------------------------------------------- /app/Module/Front/Article/ArticlePresenter.php: -------------------------------------------------------------------------------- 1 | article = $this->articleFacade->get($slug); 33 | } catch (ArticleNotFoundException) { 34 | $this->error('Article not found', 404); 35 | } 36 | } 37 | 38 | public function renderDefault(string $slug): void 39 | { 40 | $this->template->article = $this->article; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/Router/RouterFactory.php: -------------------------------------------------------------------------------- 1 | add($this->createAdminRouter()); 17 | $router->add($this->createFrontRouter()); 18 | 19 | return $router; 20 | } 21 | 22 | private function createFrontRouter(): RouteList 23 | { 24 | $frontRouter = new RouteList('Front'); 25 | 26 | $frontRouter->addRoute('/blog/', [ 27 | Presenter::PRESENTER_KEY => 'Article', 28 | Presenter::ACTION_KEY => 'default', 29 | ]); 30 | 31 | $frontRouter->addRoute('/[][/][/]'); 32 | 33 | return $frontRouter; 34 | } 35 | 36 | private function createAdminRouter(): RouteList 37 | { 38 | $adminRouter = new RouteList('Admin'); 39 | 40 | $adminRouter->addRoute('admin[/][/][/]'); 41 | 42 | return $adminRouter; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/Module/Front/Auth/AuthPresenter.php: -------------------------------------------------------------------------------- 1 | signInFormFactory->create(); 26 | 27 | $form->onSuccess[] = function (array $data) { 28 | try { 29 | $this->getUser()->login($data['username'], $data['password']); 30 | $this->flashMessage('Successfully logged in', 'success'); 31 | 32 | } catch (AuthenticationException) { 33 | $this->flashMessage('Bad credentials', 'danger'); 34 | } 35 | 36 | $this->redirect('this'); 37 | }; 38 | 39 | return $form; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /app/Module/Admin/@Templates/@Layout/layout.latte: -------------------------------------------------------------------------------- 1 | {templateType App\Module\Front\BaseFrontTemplate} 2 | 3 | 4 | 5 | 6 | 7 | 8 | {ifset title}{include title|stripHtml|trim} | {/ifset}Simple Blog 9 | 10 | 11 | 12 | 13 | 14 | {include 'header.latte'} 15 | 16 |
17 |
{$flash->message}
18 |
19 | 20 | {include content} 21 | 22 | {block scripts} 23 | 24 | {/block} 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /app/Module/Front/@Templates/@Layout/layout.latte: -------------------------------------------------------------------------------- 1 | {templateType App\Module\Front\BaseFrontTemplate} 2 | 3 | 4 | 5 | 6 | 7 | 8 | {ifset title}{include title|stripHtml|trim} | {/ifset}Simple Blog 9 | 10 | 11 | 12 | 13 | 14 | {include 'header.latte'} 15 | 16 |
17 |
{$flash->message}
18 |
19 | 20 | {include content} 21 | 22 | {block scripts} 23 | 24 | {/block} 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /app/Model/Article/ArticleFacade.php: -------------------------------------------------------------------------------- 1 | save($article); 18 | 19 | return $article; 20 | } 21 | 22 | /** 23 | * @throws ArticleNotFoundException 24 | */ 25 | public function edit(string $slug, ArticleData $data): void 26 | { 27 | $article = $this->get($slug); 28 | 29 | $article->edit($data); 30 | 31 | $this->save($article); 32 | } 33 | 34 | private function save(Article $article): void 35 | { 36 | FileSystem::write(__DIR__ . '/../../../storage/articles/' . $article->getSlug() . '.md', "```yaml\n" . Neon::encode([ 37 | 'title' => $article->getTitle(), 38 | 'author' => $article->getAuthor(), 39 | 'createdAt' => $article->getCreatedAt()->format('Y-m-d H:i:s') 40 | ], Neon::BLOCK) . "```\n\n" . $article->getData()->content . "\n"); 41 | } 42 | 43 | public function delete(string $slug): void 44 | { 45 | @unlink(__DIR__ . '/../../../storage/articles/' . $slug . '.md'); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/Module/Admin/BaseAdminPresenter.php: -------------------------------------------------------------------------------- 1 | getUser()->isLoggedIn()) { 25 | $this->flashMessage('You shall not pass', 'danger'); 26 | $this->redirect(':Front:Auth:default'); 27 | } 28 | } 29 | 30 | public function beforeRender(): void 31 | { 32 | $this->setLayout(__DIR__ . '/@Templates/@Layout/layout.latte'); 33 | $this->getTemplate()->setFile(__DIR__ . '/@Templates/' . Helpers::splitName($this->getName())[1] . '/' . $this->getAction() .'.latte'); 34 | 35 | // Here is possible to assign common variables among presenters, user is just an example, nette would automatically passed user to template anyway 36 | $this->template->user = $this->getUser(); 37 | } 38 | 39 | /** 40 | * @throws AbortException 41 | */ 42 | public function handleLogout(): void 43 | { 44 | $this->getUser()->logout(); 45 | $this->redirect('this'); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/Module/Front/@Templates/Error/500.phtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Server Error 5 | 6 | 13 | 14 |
15 |
16 |

Server Error

17 | 18 |

We're sorry! The server encountered an internal error and 19 | was unable to complete your request. Please try again later.

20 | 21 |

error 500

22 |
23 |
24 | 25 | 28 | -------------------------------------------------------------------------------- /app/Model/Article/ArticleDataFactory.php: -------------------------------------------------------------------------------- 1 | getPathname()); 18 | 19 | preg_match('/```yaml(.*?)```/s', $fileContent, $match); 20 | $metaData = Neon::decode($match[1]); 21 | 22 | $data = new ArticleData(); 23 | $data->slug = $file->getBasename('.md'); 24 | $data->title = $metaData['title']; 25 | $data->author = $metaData['author']; 26 | $data->content = trim(str_replace($match[0], '', $fileContent)); 27 | $data->createdAt = DateTime::from($metaData['createdAt']); 28 | $data->updatedAt = DateTime::from($file->getMTime()); 29 | 30 | return $data; 31 | } 32 | 33 | public function createFromFormData(array $formData): ArticleData 34 | { 35 | $data = new ArticleData(); 36 | 37 | $data->slug = Strings::webalize($formData['title']); 38 | $data->title = $formData['title']; 39 | $data->author = $formData['author']; 40 | $data->content = $formData['content']; 41 | $data->createdAt = new DateTime(); 42 | $data->updatedAt = new DateTime(); 43 | 44 | return $data; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/Module/Front/Error/ErrorPresenter.php: -------------------------------------------------------------------------------- 1 | getParameter('exception'); 29 | 30 | if ($exception instanceof BadRequestException) { 31 | [$module, , $sep] = Helpers::splitName($request->getPresenterName()); 32 | return new ForwardResponse($request->setPresenterName($module . $sep . 'Error4xx')); 33 | } 34 | 35 | $this->logger->log($exception, ILogger::EXCEPTION); 36 | 37 | return new CallbackResponse(function (IRequest $httpRequest, IResponse $httpResponse): void { 38 | if (preg_match('#^text/html(?:;|$)#', (string) $httpResponse->getHeader('Content-Type'))) { 39 | $file = __DIR__ . '/../@Templates/Error/500.phtml'; 40 | if (is_file($file)) { 41 | echo file_get_contents($file); 42 | } else { 43 | echo 'Error 500'; 44 | } 45 | } 46 | }); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/Model/Article/Article.php: -------------------------------------------------------------------------------- 1 | slug = $data->slug; 22 | $this->createdAt = $data->createdAt; 23 | $this->edit($data); 24 | } 25 | 26 | public function edit(ArticleData $data) 27 | { 28 | $this->title = $data->title; 29 | $this->author = $data->author; 30 | $this->content = $data->content; 31 | $this->updatedAt = $data->updatedAt; 32 | } 33 | 34 | public function getData(): ArticleData 35 | { 36 | $data = new ArticleData(); 37 | $data->title = $this->title; 38 | $data->author = $this->author; 39 | $data->content = $this->content; 40 | 41 | return $data; 42 | } 43 | 44 | public function getSlug(): string 45 | { 46 | return $this->slug; 47 | } 48 | 49 | public function getTitle(): string 50 | { 51 | return $this->title; 52 | } 53 | 54 | public function getAuthor(): string 55 | { 56 | return $this->author; 57 | } 58 | 59 | public function getContent(): string 60 | { 61 | return (new Parsedown())->parse($this->content); 62 | } 63 | 64 | public function getCreatedAt(): DateTime 65 | { 66 | return $this->createdAt; 67 | } 68 | 69 | public function getUpdatedAt(): DateTime 70 | { 71 | return $this->updatedAt; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /app/Model/Article/ArticleRepository.php: -------------------------------------------------------------------------------- 1 | articleDataFactory->createFromFile(new SplFileInfo($path))); 29 | } 30 | 31 | /** 32 | * @return Article[] 33 | */ 34 | public function getAll(): array 35 | { 36 | $articles = []; 37 | 38 | /** @var SplFileInfo $file */ 39 | foreach (Finder::findFiles('*.md')->from(__DIR__ . '/../../../storage/articles') as $file) { 40 | $articles[] = new Article($this->articleDataFactory->createFromFile($file)); 41 | } 42 | 43 | return $articles; 44 | } 45 | 46 | /** 47 | * @return Article[] 48 | */ 49 | public function getSearchResults(string $query): array 50 | { 51 | $articles = []; 52 | 53 | foreach ($this->getAll() as $article) { 54 | $match = similar_text($lowerQuery = strtolower($query), $lowerTitle = strtolower($article->getTitle())); 55 | if (str_contains($lowerTitle, $lowerQuery) || $match >= 3.5) { 56 | $articles[] = $article; 57 | } 58 | } 59 | 60 | return $articles; 61 | } 62 | 63 | public function getAllSlugs(): array 64 | { 65 | $slugs = []; 66 | 67 | /** @var SplFileInfo $file */ 68 | foreach (Finder::findFiles('*.md')->from(__DIR__ . '/../../../storage/articles') as $file) { 69 | $slugs[] = $file->getBasename('.md'); 70 | } 71 | 72 | return $slugs; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /app/Module/Admin/Article/ArticlePresenter.php: -------------------------------------------------------------------------------- 1 | articles = $this->articleFacade->getAll(); 35 | } 36 | 37 | public function renderDefault(): void 38 | { 39 | $this->template->articles = $this->articles; 40 | } 41 | 42 | /** 43 | * @throws ArticleNotFoundException 44 | */ 45 | public function actionEdit(string $slug): void 46 | { 47 | $this->article = $this->articleFacade->get($slug); 48 | } 49 | 50 | public function renderEdit(string $slug): void 51 | { 52 | $this->template->article = $this->article; 53 | } 54 | 55 | public function createComponentArticleForm(): Form 56 | { 57 | $form = $this->articleFormFactory->create($this->article); 58 | 59 | $form->onSuccess[] = function (array $data): void { 60 | if ($this->article !== null) { 61 | $this->articleFacade->edit($this->article->getSlug(), $this->articleDataFactory->createFromFormData($data)); 62 | $this->flashMessage('Article was successfully updated', 'success'); 63 | 64 | } else { 65 | $this->articleFacade->create($this->articleDataFactory->createFromFormData($data)); 66 | $this->flashMessage('Article was successfully created', 'success'); 67 | } 68 | 69 | $this->redirect('this'); 70 | }; 71 | 72 | return $form; 73 | } 74 | 75 | /** 76 | * @throws AbortException 77 | */ 78 | public function handleDelete(string $slug): void 79 | { 80 | $this->articleFacade->delete($slug); 81 | $this->flashMessage('Article was successfully deleted', 'success'); 82 | $this->redirect('this'); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /app/UI/Form/Renderer/BootstrapRenderer.php: -------------------------------------------------------------------------------- 1 | wrappers['controls']['container'] = null; 25 | $this->wrappers['pair']['container'] = 'div class="form-group row mb-4"'; 26 | $this->wrappers['pair']['.error'] = 'has-error'; 27 | $this->wrappers['control']['container'] = 'div class=col-sm-12'; 28 | $this->wrappers['label']['container'] = 'div class="col-sm-12 control-label"'; 29 | $this->wrappers['control']['description'] = 'div class="form-text text-muted"'; 30 | $this->wrappers['control']['errorcontainer'] = 'div class=note'; 31 | $this->wrappers['error']['container'] = 'section'; 32 | $this->wrappers['error']['item'] = 'p class="alert alert-danger alert-form"'; 33 | } 34 | 35 | public function renderBegin(): string 36 | { 37 | $this->controlsInit(); 38 | return parent::renderBegin(); 39 | } 40 | 41 | public function renderEnd(): string 42 | { 43 | $this->controlsInit(); 44 | return parent::renderEnd(); 45 | } 46 | 47 | public function renderBody(): string 48 | { 49 | $this->controlsInit(); 50 | return parent::renderBody(); 51 | } 52 | 53 | public function renderControls($parent): string 54 | { 55 | $this->controlsInit(); 56 | return parent::renderControls($parent); 57 | } 58 | 59 | public function renderPair(Control $control): string 60 | { 61 | $this->controlsInit(); 62 | 63 | $pair = $this->getWrapper('pair container'); 64 | 65 | if ($control instanceof RadioList || $control instanceof CheckboxList) { 66 | $pair->addHtml(Html::el('label', ['class' => 'col-12'])->addText($control->getLabel()->getText())); 67 | $pair->addHtml($this->generateControls($control)); 68 | 69 | } elseif ($control instanceof Checkbox) { 70 | $pair->addHtml($this->generateControls($control)); 71 | 72 | } elseif ($control instanceof UploadControl) { 73 | $pair->addHtml(Html::el('label', ['class' => 'col-12'])->addText($control->getLabel()->getText())); 74 | $pair->addHtml($this->generateControls($control)); 75 | 76 | } else { 77 | $pair->addHtml($this->renderLabel($control)); 78 | $pair->addHtml($this->renderControl($control)); 79 | } 80 | 81 | $pair->setAttribute('id', $control->getName() . '-container'); 82 | $pair->class($this->getValue($control->isRequired() ? 'pair .required' : 'pair .optional'), true); 83 | $pair->class($control->hasErrors() ? $this->getValue('pair .error') : null, true); 84 | $pair->class($control->getOption('class'), true); 85 | if (++$this->counter % 2) { 86 | $pair->class($this->getValue('pair .odd'), true); 87 | } 88 | 89 | return $pair->render(0); 90 | } 91 | 92 | protected function generateControls(BaseControl $control): Html 93 | { 94 | $wrapper = Html::el('div', ['class' => 'col-12']); 95 | 96 | if ($control instanceof RadioList || $control instanceof CheckboxList) { 97 | foreach ($control->getItems() as $key => $labelTitle) { 98 | if ($control instanceof RadioList) { 99 | $container = Html::el('div', ['class' => 'custom-control custom-radio custom-control-inline mb-5']); 100 | } else { 101 | $container = Html::el('div', ['class' => 'custom-control custom-checkbox custom-control-inline mb-5']); 102 | } 103 | 104 | $input = $control->getControlPart($key); 105 | $label = $control->getLabelPart($key); 106 | 107 | $label->setAttribute('class', 'custom-control-label'); 108 | $input->setAttribute('class', 'custom-control-input'); 109 | 110 | $container->addHtml($input); 111 | $container->addHtml($label); 112 | 113 | $wrapper->addHtml($container); 114 | } 115 | } elseif ($control instanceof Checkbox) { 116 | $container = Html::el('div', ['class' => 'custom-control custom-checkbox custom-control-inline']); 117 | 118 | $input = $control->getControlPart(); 119 | $input->setAttribute('class', 'custom-control-input'); 120 | 121 | $label = Html::el('label', ['class' => 'custom-control-label', 'for' => $control->getHtmlId()]); 122 | $label->addText($control->getCaption()); 123 | 124 | $container->addHtml($input); 125 | $container->addHtml($label); 126 | 127 | $wrapper->addHtml($container); 128 | 129 | } elseif ($control instanceof UploadControl) { 130 | $container = Html::el('div', ['class' => 'custom-file']); 131 | 132 | $input = $control->getControlPart(); 133 | $input->setAttribute('class', 'custom-file-input js-custom-file-input-enabled'); 134 | 135 | $label = Html::el('label', ['class' => 'custom-file-label', 'for' => $control->getHtmlId()]); 136 | 137 | if ($control->getControl()->multiple) { 138 | $label->addText('Choose files'); 139 | } else { 140 | $label->addText('Choose file'); 141 | } 142 | 143 | $container->addHtml($input); 144 | $container->addHtml($label); 145 | 146 | $wrapper->addHtml($container); 147 | } 148 | 149 | return $wrapper; 150 | } 151 | 152 | public function renderPairMulti(array $controls): string 153 | { 154 | $this->controlsInit(); 155 | return parent::renderPairMulti($controls); 156 | } 157 | 158 | public function renderLabel(Control $control): Html 159 | { 160 | $this->controlsInit(); 161 | return parent::renderLabel($control); 162 | } 163 | 164 | public function renderControl(Control $control): Html 165 | { 166 | $this->controlsInit(); 167 | return parent::renderControl($control); 168 | } 169 | 170 | protected function controlsInit(): void 171 | { 172 | if ($this->controlsInit) { 173 | return; 174 | } 175 | 176 | $this->controlsInit = true; 177 | $this->form->getElementPrototype()->appendAttribute('class', 'form-horizontal'); 178 | 179 | foreach ($this->form->getControls() as $control) { 180 | $type = $control->getOption('type'); 181 | if (in_array($type, ['text', 'textarea', 'select'], true)) { 182 | $control->getControlPrototype()->addClass('form-control'); 183 | 184 | } elseif (in_array($type, ['checkbox', 'radio'], true)) { 185 | $control->getSeparatorPrototype()->setName('div')->addClass($type); 186 | 187 | } elseif ($control instanceof Controls\SubmitButton) { 188 | $class = empty($usedPrimary) ? 'btn btn-primary btn-block' : 'btn btn-default btn-block'; 189 | $control->getControlPrototype()->setAttribute('class', $class); 190 | $usedPrimary = true; 191 | 192 | } elseif ($control instanceof Controls\TextBase) { 193 | $control->getControlPrototype()->appendAttribute('class', 'form-control'); 194 | 195 | } elseif ($control instanceof DateInput) { 196 | $control->getControlPrototype()->appendAttribute('class', 'form-control form-control-date'); 197 | 198 | } elseif ($control instanceof Controls\Checkbox) { 199 | $control->getControlPrototype()->appendAttribute('class', 'switch'); 200 | $control->getControlPart()->appendAttribute('class', 'default'); 201 | } 202 | 203 | if ($control instanceof Controls\SelectBox || $control instanceof Controls\MultiSelectBox) { 204 | $control->getControlPrototype()->appendAttribute('class', 'select2'); 205 | } 206 | } 207 | 208 | foreach ($this->form->getControls() as $control) { 209 | $type = $control->getOption('type'); 210 | 211 | if (in_array($type, ['text', 'textarea', 'select'], true)) { 212 | $control->getControlPrototype()->appendAttribute('class', 'form-control'); 213 | } 214 | } 215 | } 216 | } 217 | --------------------------------------------------------------------------------