taktik-nette

This commit is contained in:
2025-01-22 22:52:26 +01:00
commit 8e2cd6ca68
45 changed files with 5410 additions and 0 deletions

19
app/UI/@layout.latte Normal file
View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<title>{ifset title}{include title|stripHtml} | {/ifset}Nette Web</title>
</head>
<body>
<div n:foreach="$flashes as $flash" n:class="flash, $flash->type, alert, alert . '-' . $flash->type">{$flash->message}</div>
{include content}
{block scripts}
<script src="https://unpkg.com/nette-forms@3"></script>
{/block}
</body>
</html>

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\UI\Accessory;
use Latte\Extension;
use Nette\Localization\Translator;
final class LatteExtension extends Extension
{
public function __construct(
private readonly Translator $translator
) {
}
public function getFilters(): array
{
return [];
}
public function getFunctions(): array
{
return [
'interests' => [$this, 'renderInterests'],
];
}
public function renderInterests(?string $interests): string
{
if ($interests === null) {
return '';
}
$explodedInterests = explode(',', $interests);
$rtVal = [];
foreach ($explodedInterests as $interest) {
$rtVal[] = $this->translator->translate('survey.interests.' . $interest);
}
return implode(',', $rtVal);
}
}

31
app/UI/DTO/FilterDTO.php Normal file
View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\UI\DTO;
class FilterDTO
{
/**
* @param array<string, mixed> $filters
*/
public function __construct(public readonly array $filters = [])
{
}
/**
* @param array<string, mixed>|null $data
* @param array<int, string> $acceptFilters
* @return FilterDTO
*/
public static function createFromData(?array $data, array $acceptFilters = []): FilterDTO
{
if ($data === null) {
return new self([]);
}
$result = array_intersect_key($data, array_flip($acceptFilters));
return new self($result);
}
}

34
app/UI/DTO/OrderDTO.php Normal file
View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\UI\DTO;
class OrderDTO
{
public function __construct(public readonly string $column, public readonly OrderDirection $direction)
{
}
/**
* @param array<int, string> $acceptColumns
*/
public static function createOrReturnNullFromData(?string $order, ?string $direction, array $acceptColumns = []): ?OrderDTO
{
if ($order === null || $direction === null) {
return null;
}
if (!in_array($order, $acceptColumns, true)) {
return null;
}
$direction = OrderDirection::tryFrom($direction);
if ($direction === null) {
return null;
}
return new self($order, $direction);
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\UI\DTO;
enum OrderDirection: string
{
case ASC = "ASC";
case DESC = "DESC";
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\UI\Error\Error4xx;
use Nette;
use Nette\Application\Attributes\Requires;
/**
* Handles 4xx HTTP error responses.
*/
#[Requires(methods: '*', forward: true)]
final class Error4xxPresenter extends Nette\Application\UI\Presenter
{
public function renderDefault(Nette\Application\BadRequestException $exception): void
{
$this->redirect(':Survey:default');
}
}

View File

@@ -0,0 +1,27 @@
<!DOCTYPE html><!-- "' --></textarea></script></style></pre></xmp></a></audio></button></canvas></datalist></details></dialog></iframe></listing></meter></noembed></noframes></noscript></optgroup></option></progress></rp></select></table></template></title></video>
<meta charset="utf-8">
<meta name="robots" content="noindex">
<title>Server Error</title>
<style>
#nette-error { all: initial; position: absolute; top: 0; left: 0; right: 0; height: 70vh; min-height: 400px; display: flex; align-items: center; justify-content: center; z-index: 1000 }
#nette-error div { all: initial; max-width: 550px; background: white; color: #333; display: block }
#nette-error h1 { all: initial; font: bold 50px/1.1 sans-serif; display: block; margin: 40px }
#nette-error p { all: initial; font: 20px/1.4 sans-serif; margin: 40px; display: block }
#nette-error small { color: gray }
</style>
<div id=nette-error>
<div>
<h1>Server Error</h1>
<p>We're sorry! The server encountered an internal error and
was unable to complete your request. Please try again later.</p>
<p><small>error 500</small></p>
</div>
</div>
<script>
document.body.insertBefore(document.getElementById('nette-error'), document.body.firstChild);
</script>

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
header('HTTP/1.1 503 Service Unavailable');
header('Retry-After: 300'); // 5 minutes in seconds
?>
<!DOCTYPE html>
<meta charset="utf-8">
<meta name="robots" content="noindex">
<meta name="generator" content="Nette Framework">
<style>
body { color: #333; background: white; width: 500px; margin: 100px auto }
h1 { font: bold 47px/1.5 sans-serif; margin: .6em 0 }
p { font: 21px/1.5 Georgia,serif; margin: 1.5em 0 }
</style>
<title>Site is temporarily down for maintenance</title>
<h1>We're Sorry</h1>
<p>The site is temporarily down for maintenance. Please try again in a few minutes.</p>

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\UI\Error\Error5xx;
use Nette;
use Nette\Application\Attributes\Requires;
use Nette\Application\Responses;
use Nette\Http;
use Tracy\ILogger;
/**
* Handles uncaught exceptions and errors, and logs them.
*/
#[Requires(forward: true)]
final class Error5xxPresenter implements Nette\Application\IPresenter
{
public function __construct(
private ILogger $logger,
) {
}
public function run(Nette\Application\Request $request): Nette\Application\Response
{
// Log the exception
$exception = $request->getParameter('exception');
$this->logger->log($exception, ILogger::EXCEPTION);
// Display a generic error message to the user
return new Responses\CallbackResponse(function (Http\IRequest $httpRequest, Http\IResponse $httpResponse): void {
if (preg_match('#^text/html(?:;|$)#', (string) $httpResponse->getHeader('Content-Type'))) {
require __DIR__ . '/500.phtml';
}
});
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\UI\Form;
use Nette\Application\UI\Form;
use Nette\Localization\Translator;
class ResultFilteringFactory
{
public function __construct(
private readonly Translator $translator,
) {
}
public function create(): Form
{
$form = new Form();
$form->setTranslator($this->translator);
$form->addText('name', 'survey.name');
$form->addTextArea('comments', 'survey.comments');
$form->addMultiSelect('interest', 'survey.interests', [
'sport' => 'survey.interests.sport',
'music' => 'survey.interests.music',
'travel' => 'survey.interests.travel',
]);
$form->addSubmit('send', 'survey.search');
return $form;
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\UI\Form;
use Nette\Application\UI\Form;
use Nette\Forms\Form as NetteForm;
use Nette\Localization\Translator;
class SurveyFormFactory
{
public function __construct(
protected readonly Translator $translator,
) {
}
public function create(): Form
{
$form = new Form();
$form->setTranslator($this->translator);
$form->addText('name', 'survey.name')
->setRequired('survey.name_required')
->addRule(NetteForm::MaxLength, $this->translator->translate('survey.name_is_too_long'), 255);
$form->addTextArea('comments', 'survey.comments');
$form->addCheckbox('agreement', 'survey.agreement')
->setRequired('survey.agreement_required');
$form->addMultiSelect('interests', 'survey.interests', [
'sport' => 'survey.interests.sport',
'music' => 'survey.interests.music',
'travel' => 'survey.interests.travel',
])->setRequired('survey.interests_required')
->addRule(NetteForm::MaxLength, $this->translator->translate('survey.interest_is_too_long'), 255);
$form->addSubmit('send', 'survey.send');
$form->addProtection('survey.protection');
return $form;
}
}

View File

@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\UI\Results;
use App\Repository\SurveyRepository;
use App\UI\DTO\FilterDTO;
use App\UI\DTO\OrderDTO;
use App\UI\Form\ResultFilteringFactory;
use Nette\Application\Attributes\Persistent;
use Nette\Application\UI\Form;
use Nette\Application\UI\Presenter;
use Nette\Http\Session;
use Nette\Utils\Paginator;
use Nubium\Exception\ThisShouldNeverHappenException;
class ResultsPresenter extends Presenter
{
#[Persistent]
public ?string $order = null;
#[Persistent]
public ?string $direction = null;
/**
* @var array<string, mixed>|null
*/
#[Persistent]
public ?array $filter = null;
protected const SESSION_STORE_KEY = 'survey_results_v1';
public function __construct(
protected readonly SurveyRepository $surveyRepository,
protected readonly ResultFilteringFactory $resultFilteringFactory,
protected readonly Session $session,
) {
parent::__construct();
$this->restoreStateFromSession();
if ($this->order === null) {
$this->order = 'survey_id';
}
if ($this->direction === null) {
$this->direction = 'desc';
}
if ($this->filter === null) {
$this->filter = null;
}
}
public function renderDefault(int $page = 1): void
{
$order = OrderDTO::createOrReturnNullFromData($this->order, $this->direction, ['survey_name', 'comments', 'survey_id']);
$filter = FilterDTO::createFromData($this->filter, ['name', 'comments', 'agreement', 'interest']);
$itemsCount = $this->surveyRepository->getSuveysCount($filter);
$paginator = new Paginator();
$paginator->setItemCount($itemsCount);
$paginator->setItemsPerPage(10);
$paginator->setPage($page);
$articles = $this->surveyRepository->findSurveys($paginator->getLength(), $paginator->getOffset(), $order, $filter);
$this->template->surveys = $articles; // @phpstan-ignore property.notFound
$this->template->paginator = $paginator; // @phpstan-ignore property.notFound
$this->template->order = $this->order; // @phpstan-ignore property.notFound
$this->template->direction = $this->direction; // @phpstan-ignore property.notFound
}
protected function createComponentFilteringForm(): Form
{
$form = $this->resultFilteringFactory->create();
$form->setDefaults($this->filter ?? []);
// @phpstan-ignore-next-line assign.propertyType
$form->onSuccess[] = function (\Nette\Forms\Form $form, array $values): void {
if ($form->isValid()) {
$filter = [];
if (!empty($values['name'])) {
$filter['name'] = $values['name'];
}
if (!empty($values['agreement'])) {
$filter['agreement'] = $values['agreement'];
}
if (!empty($values['comments'])) {
$filter['comments'] = $values['comments'];
}
if (!empty($values['interest'])) {
$filter['interest'] = $values['interest'];
}
$this->filter = $filter;
$this->storeStateIntoSession();
$this->redirect('default', [
'page' => 1,
'filter' => $filter,
]);
}
};
return $form;
}
protected function restoreStateFromSession(): void
{
$session = $this->session->getSection(self::SESSION_STORE_KEY);
if ($session->get('order') !== null) {
$this->order = $session->get('order');
}
if ($session->get('direction') !== null) {
$this->direction = $session->get('direction');
}
if ($session->get('filter') !== null) {
$this->filter = $session->get('filter');
}
}
protected function storeStateIntoSession(): void
{
$session = $this->session->getSection(self::SESSION_STORE_KEY);
$session->set('order', $this->order);
$session->set('direction', $this->direction);
$session->set('filter', $this->filter);
}
public function terminate(): void
{
$this->storeStateIntoSession();
parent::terminate();
}
}

View File

@@ -0,0 +1,63 @@
{block content}
<h1>Ankety</h1>
<div class="suveys">
{control filteringForm}
{if count($surveys) == 0}
<div class="alert alert-danger">{_('survey.data_not_found')}</div>
{else}
<table class="table">
<thead>
<tr>
<th>
<a n:href="default, 1, order => 'survey_id', direction => $direction === 'ASC' && $order === 'survey_id' ? 'DESC' : 'ASC'">
{_('survey.id')} {$order === 'survey_id' ? ($direction === 'ASC' ? '▲' : '▼') : ''}
</a>
</th>
<th>
<a n:href="default, 1, order => 'survey_name', direction => $direction === 'ASC' && $order === 'survey_name' ? 'DESC' : 'ASC'">
{_('survey.name')} {$order === 'survey_name' ? ($direction === 'ASC' ? '▲' : '▼') : ''}
</a>
</th>
<th>
<a n:href="default, 1, order => 'comments', direction => $direction === 'ASC' && $order === 'comments' ? 'DESC' : 'ASC'">
{_('survey.comments')} {$order === 'comments' ? ($direction === 'ASC' ? '▲' : '▼') : ''}
</a>
</th>
<th>{_('survey.interests')}</th>
</tr>
</thead>
<tbody>
{foreach $surveys as $survey}
<tr >
<td>{$survey->survey_id}</td>
<td>{$survey->survey_name}</td>
<td>{$survey->comments}</td>
<td>{interests($survey->interests)}</td>
</tr>
{/foreach}
</tbody>
</table>
{/if}
</div>
{if count($surveys) > 0}
<div class="pagination">
{if !$paginator->isFirst()}
<a n:href="default, 1">{_('pagination.first')}</a>
&nbsp;|&nbsp;
<a n:href="default, $paginator->page-1">{_('pagination.prev')}</a>
&nbsp;|&nbsp;
{/if}
Stránka {$paginator->getPage()} z {$paginator->getPageCount()}
{if !$paginator->isLast()}
&nbsp;|&nbsp;
<a n:href="default, $paginator->getPage() + 1">{_('pagination.next')}</a>
&nbsp;|&nbsp;
<a n:href="default, $paginator->getPageCount()">{_('pagination.last')}</a>
{/if}
</div>
{/if}
{/block}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\UI\Survey;
use Nette;
use App\UI\Form\SurveyFormFactory;
use Nette\Application\UI\Form;
use Nubium\Exception\ThisShouldNeverHappenException;
class SurveyPresenter extends Nette\Application\UI\Presenter
{
public function __construct(
protected readonly SurveyFormFactory $surveyFormFactor,
protected readonly Nette\Database\Explorer $database,
protected readonly Nette\Localization\Translator $translator
) {
parent::__construct();
}
public function renderDefault(): void
{
}
/**
* @param array<string, mixed> $values
*/
public function processSurveyForm(Nette\Forms\Form $form, array $values): void
{
if ($form->isValid()) {
$this->database->beginTransaction();
try {
$recordId = $this->database->table('survey')->insert(
[
'name' => $values['name'],
'comments' => $values['comments'],
'agreement' => $values['agreement'],
]
);
foreach ($values['interests'] as $value) {
$this->database->table('interests')->insert(
[
'survey_id' => $recordId,
'interest' => $value,
]
);
}
$this->database->commit();
$this->flashMessage($this->translator->translate('survey.successfullyFilled'), 'success');
$this->redirect('this');
} catch (\PDOException $e) {
$this->database->rollback(); // TODO: Tady bych dal log do sentry a rekl uziteli at to zkusi pozdeji ;-)
throw $e;
}
}
}
protected function createComponentSurveyForm(): Nette\Application\UI\Form
{
$form = $this->surveyFormFactor->create();
$form->onSuccess[] = [$this, 'processSurveyForm']; // @phpstan-ignore assign.propertyType
return $form;
}
}

View File

@@ -0,0 +1,4 @@
{block content}
<h1>{_('survey.header')}</h1>
{control surveyForm}
{/block}