Release 0.249: banner edit fixes and thumbnail popup

This commit is contained in:
2026-02-08 17:29:52 +01:00
parent 0b80524d71
commit 926b6fcbca
24 changed files with 2273 additions and 47 deletions

View File

@@ -801,4 +801,4 @@ echo $grid -> draw();
});
}
</script>
<script>CKEDITOR.dtd.$removeEmpty['span'] = false;</script>
<script>CKEDITOR.dtd.$removeEmpty['span'] = false;</script>

View File

@@ -85,7 +85,7 @@ ob_start();
'id' => 'src_' . $lg['id'],
'value' => $this -> banner['languages'][ $lg['id'] ]['src'],
'icon_content' => 'przeglądaj',
'icon_js' => "window.open ( 'http://" . $_SERVER['SERVER_NAME'] . "/libraries/filemanager-9.14.2/dialog.php?type=1&popup=1&field_id=src_" . $lg['id'] . "&akey=" . $rfmAkeyJS . "', 'mywindow', 'location=1,status=1,scrollbars=1, width=1100,height=700');"
'icon_js' => "window.open ( '/libraries/filemanager-9.14.2/dialog.php?type=1&popup=1&field_id=src_" . $lg['id'] . "&akey=" . $rfmAkeyJS . "', 'mywindow', 'location=1,status=1,scrollbars=1, width=1100,height=700');"
)
);
?>
@@ -182,4 +182,4 @@ echo $grid -> draw();
});
</script>
<script>CKEDITOR.dtd.$removeEmpty['span'] = false;</script>
<script>CKEDITOR.dtd.$removeEmpty['span'] = false;</script>

View File

@@ -1,5 +1,105 @@
<?= \Tpl::view('components/table-list', ['list' => $this->viewModel]); ?>
<style type="text/css">
.banner-thumb-wrap {
display: inline-block;
}
.banner-thumb-image {
width: 72px;
height: 42px;
object-fit: cover;
border-radius: 4px;
cursor: zoom-in;
}
.banner-thumb-popup {
position: fixed;
top: 0;
left: 0;
width: min(70vw, 760px);
max-height: 80vh;
padding: 6px;
border-radius: 6px;
background: #fff;
box-shadow: 0 14px 30px rgba(0, 0, 0, .35);
z-index: 3000;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity .1s ease;
}
.banner-thumb-popup.is-visible {
opacity: 1;
visibility: visible;
}
.banner-thumb-popup img {
display: block;
width: 100%;
max-height: calc(80vh - 12px);
object-fit: contain;
border-radius: 4px;
}
</style>
<script type="text/javascript">
(function($) {
if (!$) {
return;
}
var $popup = $('<div class="banner-thumb-popup" aria-hidden="true"><img src="" alt=""></div>');
var $popupImage = $popup.find('img');
$('body').append($popup);
function positionPopup(event) {
var offset = 18;
var viewportWidth = $(window).width();
var viewportHeight = $(window).height();
var popupWidth = $popup.outerWidth();
var popupHeight = $popup.outerHeight();
var left = (event.clientX || 0) + offset;
var top = (event.clientY || 0) + offset;
if (left + popupWidth + 12 > viewportWidth) {
left = Math.max(12, (event.clientX || 0) - popupWidth - offset);
}
if (top + popupHeight + 12 > viewportHeight) {
top = Math.max(12, viewportHeight - popupHeight - 12);
}
$popup.css({ left: left + 'px', top: top + 'px' });
}
$(document).off('.bannerThumbPopup');
$(document).on('mouseenter.bannerThumbPopup', '.js-banner-thumb-preview', function(event) {
var src = $(this).data('previewSrc');
if (!src) {
return;
}
$popupImage.attr('src', String(src));
$popup.addClass('is-visible');
positionPopup(event);
});
$(document).on('mousemove.bannerThumbPopup', '.js-banner-thumb-preview', function(event) {
if ($popup.hasClass('is-visible')) {
positionPopup(event);
}
});
$(document).on('mouseleave.bannerThumbPopup', '.js-banner-thumb-preview', function() {
$popup.removeClass('is-visible');
$popupImage.attr('src', '');
});
})(window.jQuery);
</script>
<?php if (!empty($this->viewModel->customScriptView)): ?>
<?= \Tpl::view($this->viewModel->customScriptView, ['list' => $this->viewModel]); ?>
<?php endif; ?>

View File

@@ -0,0 +1,278 @@
<?php
/**
* Uniwersalny szablon formularza edycji
*
* @var FormEditViewModel $form
*/
use admin\Support\Forms\FormFieldRenderer;
use admin\ViewModels\Forms\FormFieldType;
$form = $this->form;
$renderer = new FormFieldRenderer($form);
// Przygotuj filemanager key
\S::set_session('admin', true);
if (
empty($_SESSION['rfm_akey']) ||
(($_SESSION['rfm_akey_expires'] ?? 0) < time())
) {
$_SESSION['rfm_akey'] = bin2hex(random_bytes(16));
}
$_SESSION['rfm_akey_expires'] = time() + 20 * 60;
$_SESSION['can_use_rfm'] = true;
?>
<script type="text/javascript" src="/libraries/ckeditor/ckeditor.js"></script>
<script type="text/javascript" src="/libraries/ckeditor/adapters/jquery.js"></script>
<!-- iCheck -->
<link rel="stylesheet" type="text/css" href="/libraries/grid/plugins/icheck/skins/minimal/minimal.css" />
<link rel="stylesheet" type="text/css" href="/libraries/grid/plugins/icheck/skins/minimal/blue.css" />
<script type="text/javascript" src="/libraries/grid/plugins/icheck/icheck.min.js"></script>
<style>
.icheckbox_minimal-blue { margin-top: 10px; }
</style>
<div class="row">
<div class="col col-xs-12">
<div class="g-container" data="table:<?= $form->formId ?>">
<div class="panel panel-info panel-border top">
<div class="panel-heading">
<span class="panel-title"><?= htmlspecialchars($form->title) ?></span>
</div>
<div class="panel-heading p10 pl15" id="g-menu" style="height: auto;">
<?php foreach ($form->actions as $action): ?>
<?php if ($action->name === 'save'): ?>
<?php if ($form->persist): ?>
<a href="#" id="g-edit-save-close" class="btn btn-system btn-sm"
persist_edit="0"
back_url="<?= htmlspecialchars($action->backUrl ?? '') ?>"
url="<?= htmlspecialchars($action->url) ?>">
<i class="fa fa-check-circle mr5"></i>Zatwierdź i zamknij
</a>
<?php endif; ?>
<a href="#" id="g-edit-save" class="btn btn-success btn-sm"
persist_edit="<?= $form->persist ? '1' : '0' ?>"
back_url="<?= htmlspecialchars($action->backUrl ?? '') ?>"
url="<?= htmlspecialchars($action->url) ?>">
<i class="fa fa-check-circle mr5"></i>Zatwierdź
</a>
<?php elseif ($action->name === 'cancel'): ?>
<a href="<?= htmlspecialchars($action->url) ?>" class="btn btn-dark btn-sm" id="g-edit-cancel">
<i class="fa fa-reply mr5"></i>Wstecz
</a>
<?php else: ?>
<a href="<?= htmlspecialchars($action->url) ?>" class="btn <?= htmlspecialchars($action->cssClass) ?> btn-sm">
<?= htmlspecialchars($action->label) ?>
</a>
<?php endif; ?>
<?php endforeach; ?>
</div>
<div class="panel-body">
<form method="<?= $form->method ?>" id="fg-<?= $form->formId ?>" class="g-form form-horizontal"
action="<?= htmlspecialchars($form->action) ?>" enctype="multipart/form-data">
<input type="hidden" name="_form_id" value="<?= htmlspecialchars($form->formId) ?>">
<?php foreach ($form->hiddenFields as $name => $value): ?>
<input type="hidden" name="<?= htmlspecialchars($name) ?>" value="<?= htmlspecialchars($value ?? '') ?>">
<?php endforeach; ?>
<?php if ($form->hasTabs()): ?>
<!-- Formularz z zakładkami -->
<div id="form-tabs-<?= $form->formId ?>">
<ul class="resp-tabs-list form-tabs">
<?php foreach ($form->tabs as $tab): ?>
<li><i class="fa <?= htmlspecialchars($tab->icon) ?>"></i><?= htmlspecialchars($tab->label) ?></li>
<?php endforeach; ?>
</ul>
<div class="resp-tabs-container form-tabs">
<?php foreach ($form->tabs as $tab): ?>
<div>
<?php
$tabFields = $form->getFieldsForTab($tab->id);
$langSections = $form->getLangSectionsForTab($tab->id);
?>
<?php foreach ($tabFields as $field): ?>
<?= $renderer->renderField($field) ?>
<?php endforeach; ?>
<?php foreach ($langSections as $section): ?>
<?= $renderer->renderLangSection($section) ?>
<?php endforeach; ?>
</div>
<?php endforeach; ?>
</div>
</div>
<?php else: ?>
<!-- Formularz bez zakładek -->
<?php foreach ($form->fields as $field): ?>
<?php if ($field->type === FormFieldType::LANG_SECTION): ?>
<?= $renderer->renderLangSection($field) ?>
<?php else: ?>
<?= $renderer->renderField($field) ?>
<?php endif; ?>
<?php endforeach; ?>
<?php endif; ?>
</form>
</div>
</div>
</div>
</div>
</div>
<script type="text/javascript">
$(function() {
// Inicjalizacja datepickerów
$('input.date').datetimepicker({
format: "YYYY-MM-DD",
pickTime: false
});
$('input.datetime').datetimepicker({
format: "YYYY-MM-DD HH:mm"
});
// Inicjalizacja zakładek
<?php if ($form->hasTabs()): ?>
$('#form-tabs-<?= $form->formId ?>').easyResponsiveTabs({
width: 'auto',
fit: true,
tabidentify: 'form-tabs',
type: 'vertical'
});
<?php endif; ?>
// Inicjalizacja iCheck
$('.icheck').iCheck({
checkboxClass: 'icheckbox_minimal-blue',
radioClass: 'iradio_minimal-blue'
});
function showFormMessage(type, text) {
var safeText = $('<div/>').text(text || '').html();
var alertClass = type === 'error' ? 'alert-danger' : 'alert-primary';
var html = '' +
'<div class="row js-form-message">' +
'<div class="col col-xs-12">' +
'<div class="alert ' + alertClass + ' alert-dismissable">' +
'<button type="button" class="close" data-dismiss="alert" aria-hidden="true">×</button>' +
'<i class="fa fa-info pr10"></i>' + safeText +
'</div>' +
'</div>' +
'</div>';
$('#content .js-form-message').remove();
$('#content').prepend(html);
}
// Obsługa przycisków zapisu
$('#g-edit-save, #g-edit-save-close').on('click', function(e) {
e.preventDefault();
var $btn = $(this);
var url = $btn.attr('url');
var backUrl = $btn.attr('back_url');
var persist = $btn.attr('persist_edit');
var formId = 'fg-<?= $form->formId ?>';
// Synchronizuj zawartosc CKEditor do textarea przed serializacja
if (typeof CKEDITOR !== 'undefined' && CKEDITOR.instances) {
for (var instanceName in CKEDITOR.instances) {
if (Object.prototype.hasOwnProperty.call(CKEDITOR.instances, instanceName)) {
CKEDITOR.instances[instanceName].updateElement();
}
}
}
// Zbierz dane formularza
var formData = $('#' + formId).serialize();
$.ajax({
url: url,
type: 'POST',
data: formData,
dataType: 'json',
success: function(response) {
if (response.success) {
var successMessage = response.message || 'Zmiany zostały zapisane.';
if (backUrl && persist === '0') {
window.location.href = backUrl;
} else {
showFormMessage('success', successMessage);
}
} else {
var errors = response.errors ? Object.values(response.errors).join(', ') : 'Błąd walidacji';
showFormMessage('error', 'Błąd: ' + errors);
}
},
error: function(xhr, status, error) {
// Fallback - tradycyjne submit formularza
$('#' + formId).attr('action', url).submit();
}
});
});
disable_menu();
});
</script>
<?php
// Renderowanie CKEditor dla pól edytora (zwykłych)
foreach ($form->fields as $field):
if ($field->type === FormFieldType::EDITOR):
?>
<script type="text/javascript">
$(function() {
$('#<?= $field->id ?>').ckeditor({
toolbar: '<?= $field->editorToolbar ?>',
height: '<?= $field->editorHeight ?>'
});
});
</script>
<?php
endif;
endforeach;
?>
<?php if ($form->hasLangSections()): ?>
<script type="text/javascript">
$(function() {
// Inicjalizacja zakładek językowych
$('[id^="languages-"].languages-tabs').each(function() {
$(this).easyResponsiveTabs({
width: 'auto',
fit: true,
tabidentify: 'languages-tabs',
type: 'horizontal'
});
});
// Inicjalizacja CKEditor dla pól w sekcjach językowych
<?php
foreach ($form->fields as $section):
if ($section->type === FormFieldType::LANG_SECTION && $section->langFields):
foreach ($section->langFields as $langField):
if ($langField->type === FormFieldType::EDITOR && $form->languages):
foreach ($form->languages as $lang):
if ($lang['status']):
?>
$('#<?= $langField->getLocalizedId($lang['id']) ?>').ckeditor({
toolbar: '<?= $langField->editorToolbar ?>',
height: '<?= $langField->editorHeight ?>'
});
<?php
endif;
endforeach;
endif;
endforeach;
endif;
endforeach;
?>
});
</script>
<?php endif; ?>
<script>CKEDITOR.dtd.$removeEmpty['span'] = false;</script>

View File

@@ -1 +1 @@
<iframe src="/libraries/filemanager-9.14.2/dialog.php?akey=c3cb2537d25c0efc9e573d059d79c3b8" style="border: 0px; width: 100%; height: 800px; background: #FFF; padding: 5px;"></iframe>
<iframe src="/libraries/filemanager-9.14.2/dialog.php?akey=c3cb2537d25c0efc9e573d059d79c3b8" style="border: 0px; width: 100%; height: 800px; background: #FFF; padding: 5px;"></iframe>

View File

@@ -15,7 +15,7 @@
$out .= 'id="' . $this -> params['id'] . '" ';
else
$out .= 'id="' . $this -> params['name'] . '" ';
$out .= 'name="' . $this -> params['name'] . '" type="checkbox"';
$out .= 'name="' . $this -> params['name'] . '" type="checkbox" value="1"';
if ( $this -> params['checked'] )
$out .= 'checked="checked" ';

View File

@@ -36,7 +36,7 @@ ob_start();
'id' => 'img',
'value' => $this -> producer['img'],
'icon_content' => 'przeglądaj',
'icon_js' => "window.open ( 'http://" . $_SERVER['SERVER_NAME'] . "/libraries/filemanager-9.14.2/dialog.php?type=1&popup=1&field_id=img&akey=" . $rfmAkeyJS . "', 'mywindow', 'location=1,status=1,scrollbars=1, width=1100,height=700');"
'icon_js' => "window.open ( '/libraries/filemanager-9.14.2/dialog.php?type=1&popup=1&field_id=img&akey=" . $rfmAkeyJS . "', 'mywindow', 'location=1,status=1,scrollbars=1, width=1100,height=700');"
] );
?>
</div>
@@ -177,4 +177,4 @@ echo $grid -> draw();
tabidentify: 'languages-main'
});
});
</script>
</script>

View File

@@ -1343,4 +1343,4 @@ echo $grid->draw();
</script>
<script>
CKEDITOR.dtd.$removeEmpty['span'] = false;
</script>
</script>

View File

@@ -29,7 +29,10 @@ class BannerRepository
return null;
}
$results = $this->db->select('pp_banners_langs', '*', ['id_banner' => $bannerId]);
$results = $this->db->select('pp_banners_langs', '*', [
'id_banner' => $bannerId,
'ORDER' => ['id' => 'ASC'],
]);
if (is_array($results)) {
foreach ($results as $row) {
$banner['languages'][$row['id_lang']] = $row;
@@ -54,19 +57,30 @@ class BannerRepository
/**
* Zapisuje baner (insert lub update)
*
* @param array $data Dane banera
* @param array $data Dane banera (obsługuje format z FormRequestHandler lub stary format)
* @return int|false ID banera lub false
*/
public function save(array $data)
{
$bannerId = $data['id'] ?? null;
// Obsługa obu formatów: nowy (int) i stary ('on'/string)
$status = $data['status'] ?? 0;
if ($status === 'on') {
$status = 1;
}
$homePage = $data['home_page'] ?? 0;
if ($homePage === 'on') {
$homePage = 1;
}
$bannerData = [
'name' => $data['name'],
'status' => $data['status'] == 'on' ? 1 : 0,
'date_start' => $data['date_start'] != '' ? $data['date_start'] : null,
'date_end' => $data['date_end'] != '' ? $data['date_end'] : null,
'home_page' => $data['home_page'] == 'on' ? 1 : 0,
'status' => (int)$status,
'date_start' => !empty($data['date_start']) ? $data['date_start'] : null,
'date_end' => !empty($data['date_end']) ? $data['date_end'] : null,
'home_page' => (int)$homePage,
];
if (!$bannerId) {
@@ -79,7 +93,14 @@ class BannerRepository
$this->db->update('pp_banners', $bannerData, ['id' => (int)$bannerId]);
}
$this->saveTranslations($bannerId, $data['src'], $data['url'], $data['html'], $data['text']);
// Obsługa danych językowych - nowy format (translations) lub stary (src/url/html/text)
if (isset($data['translations']) && is_array($data['translations'])) {
// Nowy format z FormRequestHandler
$this->saveTranslationsFromArray($bannerId, $data['translations']);
} elseif (isset($data['src']) && is_array($data['src'])) {
// Stary format (backward compatibility)
$this->saveTranslations($bannerId, $data['src'], $data['url'], $data['html'], $data['text']);
}
return (int)$bannerId;
}
@@ -159,35 +180,134 @@ class BannerRepository
$stmt = $this->db->query($sql, $params);
$items = $stmt ? $stmt->fetchAll() : [];
$items = is_array($items) ? $items : [];
if (!empty($items)) {
$bannerIds = array_map('intval', array_column($items, 'id'));
$thumbByBannerId = $this->fetchThumbnailsByBannerIds($bannerIds);
foreach ($items as &$item) {
$item['thumbnail_src'] = $thumbByBannerId[(int)($item['id'] ?? 0)] ?? '';
}
unset($item);
}
return [
'items' => is_array($items) ? $items : [],
'items' => $items,
'total' => $total,
];
}
/**
* Zapisuje tłumaczenia banera
* Pobiera pierwsza dostepna sciezke obrazka (src) dla kazdego banera.
*
* @param array<int, int> $bannerIds
* @return array<int, string> [id_banner => src]
*/
private function fetchThumbnailsByBannerIds(array $bannerIds): array
{
$bannerIds = array_values(array_unique(array_filter($bannerIds, static function ($id): bool {
return (int)$id > 0;
})));
if (empty($bannerIds)) {
return [];
}
$in = [];
$params = [];
foreach ($bannerIds as $index => $bannerId) {
$placeholder = ':id' . $index;
$in[] = $placeholder;
$params[$placeholder] = (int)$bannerId;
}
$sql = '
SELECT id_banner, src
FROM pp_banners_langs
WHERE id_banner IN (' . implode(', ', $in) . ')
AND src IS NOT NULL
AND src <> \'\'
ORDER BY id_lang ASC, id ASC
';
$stmt = $this->db->query($sql, $params);
$rows = $stmt ? $stmt->fetchAll() : [];
if (!is_array($rows)) {
return [];
}
$thumbByBannerId = [];
foreach ($rows as $row) {
$bannerId = (int)($row['id_banner'] ?? 0);
if ($bannerId <= 0 || isset($thumbByBannerId[$bannerId])) {
continue;
}
$src = trim((string)($row['src'] ?? ''));
if ($src !== '') {
$thumbByBannerId[$bannerId] = $src;
}
}
return $thumbByBannerId;
}
/**
* Zapisuje tłumaczenia banera (stary format - zachowano dla kompatybilności)
*/
private function saveTranslations(int $bannerId, array $src, array $url, array $html, array $text): void
{
foreach ($src as $langId => $val) {
$translationData = [
'id_banner' => $bannerId,
'id_lang' => $langId,
'src' => $src[$langId],
'url' => $url[$langId],
'html' => $html[$langId],
'text' => $text[$langId],
];
$existingId = $this->db->get('pp_banners_langs', 'id', ['AND' => ['banner_id' => $bannerId, 'lang_id' => $langId]]);
if ($existingId) {
$this->db->update('pp_banners_langs', $translationData, ['id' => $existingId]);
} else {
$this->db->insert('pp_banners_langs', $translationData);
}
$this->upsertTranslation($bannerId, $langId, [
'src' => $src[$langId] ?? '',
'url' => $url[$langId] ?? '',
'html' => $html[$langId] ?? '',
'text' => $text[$langId] ?? '',
]);
}
}
/**
* Zapisuje tłumaczenia banera z nowego formatu (z FormRequestHandler)
* Format: [lang_id => [field => value]]
*/
private function saveTranslationsFromArray(int $bannerId, array $translations): void
{
foreach ($translations as $langId => $fields) {
$this->upsertTranslation($bannerId, $langId, [
'src' => $fields['src'] ?? '',
'url' => $fields['url'] ?? '',
'html' => $fields['html'] ?? '',
'text' => $fields['text'] ?? '',
]);
}
}
/**
* Upsert tlumaczenia banera.
* Aktualizuje wszystkie rekordy dla pary id_banner + id_lang,
* co usuwa problem z historycznymi duplikatami.
*/
private function upsertTranslation(int $bannerId, $langId, array $fields): void
{
$where = ['AND' => ['id_banner' => $bannerId, 'id_lang' => $langId]];
$translationData = [
'id_banner' => $bannerId,
'id_lang' => $langId,
'src' => $fields['src'] ?? '',
'url' => $fields['url'] ?? '',
'html' => $fields['html'] ?? '',
'text' => $fields['text'] ?? '',
];
$hasExisting = (int)$this->db->count('pp_banners_langs', $where) > 0;
if ($hasExisting) {
$this->db->update('pp_banners_langs', $translationData, $where);
return;
}
$this->db->insert('pp_banners_langs', $translationData);
}
}

View File

@@ -2,14 +2,21 @@
namespace admin\Controllers;
use Domain\Banner\BannerRepository;
use admin\ViewModels\Forms\FormEditViewModel;
use admin\ViewModels\Forms\FormField;
use admin\ViewModels\Forms\FormTab;
use admin\ViewModels\Forms\FormAction;
use admin\Support\Forms\FormRequestHandler;
class BannerController
{
private BannerRepository $repository;
private FormRequestHandler $formHandler;
public function __construct(BannerRepository $repository)
{
$this->repository = $repository;
$this->formHandler = new FormRequestHandler();
}
/**
@@ -64,9 +71,24 @@ class BannerController
$name = (string)($item['name'] ?? '');
$homePage = (int)($item['home_page'] ?? 0);
$isActive = (int)($item['status'] ?? 0) === 1;
$thumbnailSrc = trim((string)($item['thumbnail_src'] ?? ''));
if ($thumbnailSrc !== '' && !preg_match('#^(https?:)?//#i', $thumbnailSrc) && strpos($thumbnailSrc, '/') !== 0) {
$thumbnailSrc = '/' . ltrim($thumbnailSrc, '/');
}
$thumbnail = '<span class="text-muted">-</span>';
if ($thumbnailSrc !== '') {
$thumbnail = '<div class="banner-thumb-wrap">'
. '<img src="' . htmlspecialchars($thumbnailSrc, ENT_QUOTES, 'UTF-8') . '" alt="" '
. 'data-preview-src="' . htmlspecialchars($thumbnailSrc, ENT_QUOTES, 'UTF-8') . '" '
. 'class="banner-thumb-image js-banner-thumb-preview" '
. 'loading="lazy">'
. '</div>';
}
$rows[] = [
'lp' => $lp++ . '.',
'thumbnail' => $thumbnail,
'name' => '<a href="/admin/banners/banner_edit/id=' . $id . '">' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '</a>',
'status' => $isActive ? 'tak' : '<span style="color: #FF0000;">nie</span>',
'home_page' => $homePage === 1 ? '<span class="text-system">tak</span>' : 'nie',
@@ -95,6 +117,7 @@ class BannerController
$viewModel = new \admin\ViewModels\Common\PaginatedTableViewModel(
[
['key' => 'lp', 'label' => 'Lp.', 'class' => 'text-center', 'sortable' => false],
['key' => 'thumbnail', 'label' => 'Miniatura', 'class' => 'text-center', 'sortable' => false, 'raw' => true],
['key' => 'name', 'sort_key' => 'name', 'label' => 'Nazwa', 'sortable' => true, 'raw' => true],
['key' => 'status', 'sort_key' => 'status', 'label' => 'Aktywny', 'class' => 'text-center', 'sortable' => true, 'raw' => true],
['key' => 'home_page', 'sort_key' => 'home_page', 'label' => 'Strona glowna', 'class' => 'text-center', 'sortable' => true, 'raw' => true],
@@ -141,7 +164,15 @@ class BannerController
$banner = $this->repository->find($bannerId);
$languages = \admin\factory\Languages::languages_list();
return \admin\view\Banners::banner_edit($banner, $languages);
// Sprawdź czy są błędy walidacji z poprzedniego requestu
$validationErrors = $_SESSION['form_errors'][$this->getFormId()] ?? null;
if ($validationErrors) {
unset($_SESSION['form_errors'][$this->getFormId()]);
}
$viewModel = $this->buildFormViewModel($banner, $languages, $validationErrors);
return \Tpl::view('components/form-edit', ['form' => $viewModel]);
}
/**
@@ -149,13 +180,40 @@ class BannerController
*/
public function save(): void
{
$response = ['status' => 'error', 'msg' => 'Podczas zapisywania baneru wystapil blad. Prosze sprobowac ponownie.'];
$response = ['success' => false, 'errors' => []];
$values = json_decode(\S::get('values'), true);
$bannerId = $this->repository->save($values);
if ($bannerId) {
$bannerId = (int)\S::get('id');
$banner = $this->repository->find($bannerId);
$languages = \admin\factory\Languages::languages_list();
$viewModel = $this->buildFormViewModel($banner, $languages);
// Przetwórz dane z POST
$result = $this->formHandler->handleSubmit($viewModel, $_POST);
if (!$result['success']) {
// Zapisz błędy w sesji i zwróć jako JSON
$_SESSION['form_errors'][$this->getFormId()] = $result['errors'];
$response['errors'] = $result['errors'];
echo json_encode($response);
exit;
}
// Zapisz dane
$data = $result['data'];
$data['id'] = $bannerId ?: null;
$savedId = $this->repository->save($data);
if ($savedId) {
\S::delete_dir('../temp/');
$response = ['status' => 'ok', 'msg' => 'Baner zostal zapisany.', 'id' => $bannerId];
$response = [
'success' => true,
'id' => $savedId,
'message' => 'Baner został zapisany.'
];
} else {
$response['errors'] = ['general' => 'Błąd podczas zapisywania do bazy.'];
}
echo json_encode($response);
@@ -176,4 +234,103 @@ class BannerController
header('Location: /admin/banners/view_list/');
exit;
}
/**
* Buduje model widoku formularza
*/
private function buildFormViewModel(array $banner, array $languages, ?array $errors = null): FormEditViewModel
{
$bannerId = $banner['id'] ?? 0;
$isNew = empty($bannerId);
// Domyślne wartości dla nowego banera
if ($isNew) {
$banner['status'] = 1;
$banner['home_page'] = 0;
}
$tabs = [
new FormTab('settings', 'Ustawienia', 'fa-wrench'),
new FormTab('content', 'Zawartość', 'fa-file'),
];
$fields = [
// Zakładka Ustawienia
FormField::text('name', [
'label' => 'Nazwa',
'tab' => 'settings',
'required' => true,
]),
FormField::switch('status', [
'label' => 'Aktywny',
'tab' => 'settings',
'value' => ($banner['status'] ?? 1) == 1,
]),
FormField::date('date_start', [
'label' => 'Data rozpoczęcia',
'tab' => 'settings',
]),
FormField::date('date_end', [
'label' => 'Data zakończenia',
'tab' => 'settings',
]),
FormField::switch('home_page', [
'label' => 'Slajder / Strona główna',
'tab' => 'settings',
'value' => ($banner['home_page'] ?? 0) == 1,
]),
// Sekcja językowa w zakładce Zawartość
FormField::langSection('translations', 'content', [
FormField::image('src', [
'label' => 'Obraz',
'filemanager' => true,
]),
FormField::text('url', [
'label' => 'Url',
]),
FormField::textarea('html', [
'label' => 'Kod HTML',
'rows' => 6,
]),
FormField::editor('text', [
'label' => 'Treść',
'toolbar' => 'MyTool',
'height' => 300,
]),
]),
];
$actions = [
FormAction::save(
'/admin/banners/banner_save/' . ($isNew ? '' : 'id=' . $bannerId),
'/admin/banners/view_list/'
),
FormAction::cancel('/admin/banners/view_list/'),
];
return new FormEditViewModel(
$this->getFormId(),
$isNew ? 'Nowy baner' : 'Edycja banera',
$banner,
$fields,
$tabs,
$actions,
'POST',
'/admin/banners/banner_save/' . ($isNew ? '' : 'id=' . $bannerId),
'/admin/banners/view_list/',
true,
['id' => $bannerId],
$languages,
$errors
);
}
/**
* Zwraca identyfikator formularza
*/
private function getFormId(): string
{
return 'banner-edit';
}
}

View File

@@ -0,0 +1,430 @@
<?php
namespace admin\Support\Forms;
use admin\ViewModels\Forms\FormEditViewModel;
use admin\ViewModels\Forms\FormField;
use admin\ViewModels\Forms\FormFieldType;
/**
* Renderer pól formularza
*/
class FormFieldRenderer
{
private FormEditViewModel $form;
public function __construct(FormEditViewModel $form)
{
$this->form = $form;
}
/**
* Renderuje pojedyncze pole
*/
public function renderField(FormField $field): string
{
$method = 'render' . ucfirst($field->type);
if (method_exists($this, $method)) {
return $this->$method($field);
}
// Fallback dla nieznanych typów - renderuj jako text
return $this->renderText($field);
}
/**
* Renderuje pole tekstowe
*/
public function renderText(FormField $field): string
{
$value = $this->form->getFieldValue($field);
$error = $this->form->getError($field->name);
$params = [
'label' => $field->label,
'name' => $field->name,
'id' => $field->id,
'value' => $value ?? '',
'type' => 'text',
'class' => ($field->required ? 'require ' : '') . ($field->attributes['class'] ?? ''),
];
if ($field->placeholder) {
$params['placeholder'] = $field->placeholder;
}
if ($error) {
$params['class'] .= ' error';
}
return $this->wrapWithError(\Html::input($params), $error);
}
/**
* Renderuje pole number
*/
public function renderNumber(FormField $field): string
{
$value = $this->form->getFieldValue($field);
$error = $this->form->getError($field->name);
$params = [
'label' => $field->label,
'name' => $field->name,
'id' => $field->id,
'value' => $value ?? '',
'type' => 'number',
'class' => ($field->required ? 'require ' : '') . ($field->attributes['class'] ?? ''),
];
if ($error) {
$params['class'] .= ' error';
}
return $this->wrapWithError(\Html::input($params), $error);
}
/**
* Renderuje pole email
*/
public function renderEmail(FormField $field): string
{
$value = $this->form->getFieldValue($field);
$error = $this->form->getError($field->name);
$params = [
'label' => $field->label,
'name' => $field->name,
'id' => $field->id,
'value' => $value ?? '',
'type' => 'email',
'class' => ($field->required ? 'require ' : '') . ($field->attributes['class'] ?? ''),
];
if ($error) {
$params['class'] .= ' error';
}
return $this->wrapWithError(\Html::input($params), $error);
}
/**
* Renderuje pole password
*/
public function renderPassword(FormField $field): string
{
$value = $this->form->getFieldValue($field);
return \Html::input([
'label' => $field->label,
'name' => $field->name,
'id' => $field->id,
'value' => $value ?? '',
'type' => 'password',
'class' => ($field->required ? 'require ' : '') . ($field->attributes['class'] ?? ''),
]);
}
/**
* Renderuje pole daty
*/
public function renderDate(FormField $field): string
{
$value = $this->form->getFieldValue($field);
$error = $this->form->getError($field->name);
$params = [
'label' => $field->label,
'name' => $field->name,
'id' => $field->id,
'value' => $value ?? '',
'type' => 'text',
'class' => 'date ' . ($field->required ? 'require ' : '') . ($field->attributes['class'] ?? ''),
];
if ($error) {
$params['class'] .= ' error';
}
return $this->wrapWithError(\Html::input($params), $error);
}
/**
* Renderuje pole daty i czasu
*/
public function renderDatetime(FormField $field): string
{
$value = $this->form->getFieldValue($field);
return \Html::input([
'label' => $field->label,
'name' => $field->name,
'id' => $field->id,
'value' => $value ?? '',
'type' => 'text',
'class' => 'datetime ' . ($field->required ? 'require ' : '') . ($field->attributes['class'] ?? ''),
]);
}
/**
* Renderuje przełącznik (switch)
*/
public function renderSwitch(FormField $field): string
{
$value = $this->form->getFieldValue($field);
// Domyślna wartość dla nowego rekordu
if ($value === null && $field->value === true) {
$checked = true;
} else {
$checked = (bool) $value;
}
return \Html::input_switch([
'label' => $field->label,
'name' => $field->name,
'id' => $field->id,
'checked' => $checked,
]);
}
/**
* Renderuje select
*/
public function renderSelect(FormField $field): string
{
$value = $this->form->getFieldValue($field);
$error = $this->form->getError($field->name);
$params = [
'label' => $field->label,
'name' => $field->name,
'id' => $field->id,
'value' => $value ?? '',
'options' => $field->options,
'class' => ($field->required ? 'require ' : '') . ($field->attributes['class'] ?? ''),
];
if ($error) {
$params['class'] .= ' error';
}
return $this->wrapWithError(\Html::select($params), $error);
}
/**
* Renderuje textarea
*/
public function renderTextarea(FormField $field): string
{
$value = $this->form->getFieldValue($field);
return \Html::textarea([
'label' => $field->label,
'name' => $field->name,
'id' => $field->id,
'value' => $value ?? '',
'rows' => $field->attributes['rows'] ?? 4,
'class' => ($field->required ? 'require ' : '') . ($field->attributes['class'] ?? ''),
]);
}
/**
* Renderuje edytor (CKEditor)
*/
public function renderEditor(FormField $field): string
{
$value = $this->form->getFieldValue($field);
return \Html::textarea([
'label' => $field->label,
'name' => $field->name,
'id' => $field->id,
'value' => $value ?? '',
'rows' => max(10, ($field->attributes['rows'] ?? 10)),
'class' => 'editor ' . ($field->required ? 'require ' : '') . ($field->attributes['class'] ?? ''),
]);
}
/**
* Renderuje pole obrazu z filemanagerem
*/
public function renderImage(FormField $field): string
{
$value = $this->form->getFieldValue($field);
$filemanagerUrl = $field->filemanagerUrl ?? $this->generateFilemanagerUrl($field->id);
return \Html::input_icon([
'label' => $field->label,
'name' => $field->name,
'id' => $field->id,
'value' => $value ?? '',
'type' => 'text',
'icon_content' => 'przeglądaj',
'icon_js' => "window.open('{$filemanagerUrl}', 'filemanager', 'location=1,status=1,scrollbars=1,width=1100,height=700')",
]);
}
/**
* Renderuje pole pliku
*/
public function renderFile(FormField $field): string
{
$value = $this->form->getFieldValue($field);
if ($field->useFilemanager) {
$filemanagerUrl = $field->filemanagerUrl ?? $this->generateFilemanagerUrl($field->id);
return \Html::input_icon([
'label' => $field->label,
'name' => $field->name,
'id' => $field->id,
'value' => $value ?? '',
'type' => 'text',
'icon_content' => 'przeglądaj',
'icon_js' => "window.open('{$filemanagerUrl}', 'filemanager', 'location=1,status=1,scrollbars=1,width=1100,height=700')",
]);
}
return \Html::input([
'label' => $field->label,
'name' => $field->name,
'id' => $field->id,
'type' => 'file',
'class' => ($field->required ? 'require ' : '') . ($field->attributes['class'] ?? ''),
]);
}
/**
* Renderuje ukryte pole
*/
public function renderHidden(FormField $field): string
{
$value = $this->form->getFieldValue($field);
return '<input type="hidden" name="' . htmlspecialchars($field->name) . '" ' .
'id="' . htmlspecialchars($field->id) . '" ' .
'value="' . htmlspecialchars($value ?? '') . '">';
}
/**
* Renderuje sekcję językową
*/
public function renderLangSection(FormField $section): string
{
if ($section->langFields === null || $this->form->languages === null) {
return '';
}
$out = '<div id="languages-' . $section->name . '" class="languages-tabs">';
// Zakładki języków
$out .= '<ul class="resp-tabs-list languages-tabs htabs">';
foreach ($this->form->languages as $lang) {
if ($lang['status']) {
$out .= '<li>' . htmlspecialchars($lang['name']) . '</li>';
}
}
$out .= '</ul>';
// Kontenery języków
$out .= '<div class="resp-tabs-container languages-tabs">';
foreach ($this->form->languages as $lang) {
if ($lang['status']) {
$out .= '<div>';
foreach ($section->langFields as $field) {
$out .= $this->renderLangField($field, $lang['id'], $section->name);
}
$out .= '</div>';
}
}
$out .= '</div>';
$out .= '</div>';
return $out;
}
/**
* Renderuje pole w sekcji językowej
*/
private function renderLangField(FormField $field, $languageId, string $sectionName): string
{
$value = $this->form->getFieldValue($field, $languageId, $field->name);
$error = $this->form->getError($sectionName . '_' . $field->name, $languageId);
$name = $field->getLocalizedName($languageId);
$id = $field->getLocalizedId($languageId);
switch ($field->type) {
case FormFieldType::IMAGE:
$filemanagerUrl = $field->filemanagerUrl ?? $this->generateFilemanagerUrl($id);
return $this->wrapWithError(\Html::input_icon([
'label' => $field->label,
'name' => $name,
'id' => $id,
'value' => $value ?? '',
'type' => 'text',
'icon_content' => 'przeglądaj',
'icon_js' => "window.open('{$filemanagerUrl}', 'filemanager', 'location=1,status=1,scrollbars=1,width=1100,height=700')",
]), $error);
case FormFieldType::TEXTAREA:
case FormFieldType::EDITOR:
return $this->wrapWithError(\Html::textarea([
'label' => $field->label,
'name' => $name,
'id' => $id,
'value' => $value ?? '',
'rows' => $field->type === FormFieldType::EDITOR ? 10 : ($field->attributes['rows'] ?? 4),
'class' => $field->type === FormFieldType::EDITOR ? 'editor' : '',
]), $error);
case FormFieldType::SWITCH:
return \Html::input_switch([
'label' => $field->label,
'name' => $name,
'id' => $id,
'checked' => (bool) $value,
]);
default: // TEXT, URL, etc.
return $this->wrapWithError(\Html::input([
'label' => $field->label,
'name' => $name,
'id' => $id,
'value' => $value ?? '',
'type' => $field->type === FormFieldType::EMAIL ? 'email' : 'text',
'class' => ($field->required ? 'require ' : '') . ($field->attributes['class'] ?? ''),
]), $error);
}
}
/**
* Generuje URL do filemanagera
*/
private function generateFilemanagerUrl(string $fieldId): string
{
$rfmAkey = $_SESSION['rfm_akey'] ?? bin2hex(random_bytes(16));
$_SESSION['rfm_akey'] = $rfmAkey;
$_SESSION['rfm_akey_expires'] = time() + 20 * 60;
$_SESSION['can_use_rfm'] = true;
$fieldIdParam = rawurlencode($fieldId);
$akeyParam = rawurlencode($rfmAkey);
return "/libraries/filemanager-9.14.2/dialog.php?type=1&popup=1&field_id={$fieldIdParam}&akey={$akeyParam}";
}
/**
* Opakowuje pole w kontener błędu
*/
private function wrapWithError(string $html, ?string $error): string
{
if ($error) {
return '<div class="field-with-error">' . $html .
'<span class="error-message">' . htmlspecialchars($error) . '</span></div>';
}
return $html;
}
}

View File

@@ -0,0 +1,152 @@
<?php
namespace admin\Support\Forms;
use admin\ViewModels\Forms\FormEditViewModel;
use admin\ViewModels\Forms\FormFieldType;
use admin\Validation\FormValidator;
/**
* Obsługa żądań formularza (POST, persist, walidacja)
*/
class FormRequestHandler
{
private FormValidator $validator;
public function __construct()
{
$this->validator = new FormValidator();
}
/**
* Przetwarza żądanie POST formularza
*
* @param FormEditViewModel $formViewModel
* @param array $postData Dane z $_POST
* @return array Wynik przetwarzania ['success' => bool, 'errors' => array, 'data' => array]
*/
public function handleSubmit(FormEditViewModel $formViewModel, array $postData): array
{
$result = [
'success' => false,
'errors' => [],
'data' => []
];
// Walidacja
$errors = $this->validator->validate($postData, $formViewModel->fields, $formViewModel->languages);
if (!empty($errors)) {
$result['errors'] = $errors;
// Zapisz dane do persist przy błędzie walidacji
if ($formViewModel->persist) {
$formViewModel->saveToPersist($postData);
}
return $result;
}
// Przetwórz dane (np. konwersja typów)
$processedData = $this->processData($postData, $formViewModel->fields);
$result['success'] = true;
$result['data'] = $processedData;
// Wyczyść persist po sukcesie
if ($formViewModel->persist) {
$formViewModel->clearPersist();
}
return $result;
}
/**
* Przetwarza dane z formularza (konwersja typów)
*/
private function processData(array $postData, array $fields): array
{
$processed = [];
foreach ($fields as $field) {
$value = $postData[$field->name] ?? null;
// Konwersja typów
switch ($field->type) {
case FormFieldType::SWITCH:
$processed[$field->name] = $value ? 1 : 0;
break;
case FormFieldType::NUMBER:
$processed[$field->name] = $value !== null && $value !== '' ? (float)$value : null;
break;
case FormFieldType::LANG_SECTION:
if ($field->langFields !== null) {
$processed[$field->name] = $this->processLangSection($postData, $field);
}
break;
default:
$processed[$field->name] = $value;
}
}
return $processed;
}
/**
* Przetwarza sekcję językową
*/
private function processLangSection(array $postData, $section): array
{
$result = [];
if ($section->langFields === null) {
return $result;
}
foreach ($section->langFields as $field) {
$fieldName = $field->name;
$langData = $postData[$fieldName] ?? [];
foreach ($langData as $langId => $value) {
if (!isset($result[$langId])) {
$result[$langId] = [];
}
// Konwersja typów dla pól językowych
switch ($field->type) {
case FormFieldType::SWITCH:
$result[$langId][$fieldName] = $value ? 1 : 0;
break;
case FormFieldType::NUMBER:
$result[$langId][$fieldName] = $value !== null && $value !== '' ? (float)$value : null;
break;
default:
$result[$langId][$fieldName] = $value;
}
}
}
return $result;
}
/**
* Przywraca dane z persist do POST (przy błędzie walidacji)
*/
public function restoreFromPersist(FormEditViewModel $formViewModel): ?array
{
if (!$formViewModel->persist) {
return null;
}
return $_SESSION['form_persist'][$formViewModel->formId] ?? null;
}
/**
* Sprawdza czy żądanie jest submitowaniem formularza
*/
public function isFormSubmit(string $formId): bool
{
return $_SERVER['REQUEST_METHOD'] === 'POST' &&
(isset($_POST['_form_id']) && $_POST['_form_id'] === $formId);
}
}

View File

@@ -0,0 +1,196 @@
<?php
namespace admin\Validation;
use admin\ViewModels\Forms\FormField;
use admin\ViewModels\Forms\FormFieldType;
/**
* Walidator formularzy
*/
class FormValidator
{
private array $errors = [];
/**
* Waliduje dane na podstawie definicji pól
*
* @param array $data Dane z POST
* @param array $fields Definicje pól (FormField[])
* @param array|null $languages Języki (dla walidacji pól językowych)
* @return array Tablica błędów (pusta jeśli OK)
*/
public function validate(array $data, array $fields, ?array $languages = null): array
{
$this->errors = [];
foreach ($fields as $field) {
if ($field->type === FormFieldType::LANG_SECTION) {
$this->validateLangSection($data, $field, $languages ?? []);
} else {
$this->validateField($data, $field);
}
}
return $this->errors;
}
/**
* Waliduje pojedyncze pole
*/
private function validateField(array $data, FormField $field): void
{
$value = $data[$field->name] ?? null;
// Walidacja wymagalności
if ($field->required && $this->isEmpty($value)) {
$this->errors[$field->name] = "Pole \"{$field->label}\" jest wymagane.";
return;
}
// Jeśli pole puste i nie jest wymagane - pomijamy dalszą walidację
if ($this->isEmpty($value)) {
return;
}
// Walidacja typu
switch ($field->type) {
case FormFieldType::EMAIL:
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
$this->errors[$field->name] = "Pole \"{$field->label}\" musi być poprawnym adresem e-mail.";
}
break;
case FormFieldType::NUMBER:
if (!is_numeric($value)) {
$this->errors[$field->name] = "Pole \"{$field->label}\" musi być liczbą.";
}
break;
case FormFieldType::DATE:
if (!$this->isValidDate($value)) {
$this->errors[$field->name] = "Pole \"{$field->label}\" musi być poprawną datą (YYYY-MM-DD).";
}
break;
case FormFieldType::DATETIME:
if (!$this->isValidDateTime($value)) {
$this->errors[$field->name] = "Pole \"{$field->label}\" musi być poprawną datą i czasem.";
}
break;
}
// Walidacja customowa (callback)
if (isset($field->attributes['validate_callback']) && is_callable($field->attributes['validate_callback'])) {
$result = call_user_func($field->attributes['validate_callback'], $value, $data);
if ($result !== true) {
$this->errors[$field->name] = is_string($result) ? $result : "Pole \"{$field->label}\" zawiera nieprawidłową wartość.";
}
}
}
/**
* Waliduje sekcję językową
*/
private function validateLangSection(array $data, FormField $section, array $languages): void
{
if ($section->langFields === null) {
return;
}
foreach ($languages as $language) {
if (!($language['status'] ?? false)) {
continue;
}
$langId = $language['id'];
foreach ($section->langFields as $field) {
$fieldName = $field->name;
$value = $data[$fieldName][$langId] ?? null;
// Walidacja wymagalności
if ($field->required && $this->isEmpty($value)) {
$errorKey = "{$section->name}_{$fieldName}";
$this->errors[$errorKey][$langId] = "Pole \"{$field->label}\" ({$language['name']}) jest wymagane.";
continue;
}
// Walidacja typu dla pól językowych
if (!$this->isEmpty($value)) {
switch ($field->type) {
case FormFieldType::EMAIL:
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
$errorKey = "{$section->name}_{$fieldName}";
$this->errors[$errorKey][$langId] = "Pole \"{$field->label}\" ({$language['name']}) musi być poprawnym e-mailem.";
}
break;
}
}
}
}
}
/**
* Sprawdza czy wartość jest pusta
*/
private function isEmpty($value): bool
{
return $value === null || $value === '' || (is_array($value) && empty($value));
}
/**
* Sprawdza czy data jest poprawna (YYYY-MM-DD)
*/
private function isValidDate(string $date): bool
{
$d = \DateTime::createFromFormat('Y-m-d', $date);
return $d && $d->format('Y-m-d') === $date;
}
/**
* Sprawdza czy data i czas są poprawne
*/
private function isValidDateTime(string $datetime): bool
{
$d = \DateTime::createFromFormat('Y-m-d H:i:s', $datetime);
if ($d && $d->format('Y-m-d H:i:s') === $datetime) {
return true;
}
// Spróbuj bez sekund
$d = \DateTime::createFromFormat('Y-m-d H:i', $datetime);
return $d && $d->format('Y-m-d H:i') === $datetime;
}
/**
* Sprawdza czy walidacja zakończyła się sukcesem
*/
public function isValid(): bool
{
return empty($this->errors);
}
/**
* Zwraca wszystkie błędy
*/
public function getErrors(): array
{
return $this->errors;
}
/**
* Zwraca pierwszy błąd
*/
public function getFirstError(): ?string
{
if (empty($this->errors)) {
return null;
}
$first = reset($this->errors);
if (is_array($first)) {
return reset($first);
}
return $first;
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace admin\ViewModels\Forms;
/**
* Definicja akcji formularza (przycisku)
*/
class FormAction
{
public string $name;
public string $label;
public string $type;
public string $url;
public ?string $backUrl;
public string $cssClass;
public array $attributes;
/**
* @param string $name Nazwa akcji (save, cancel, delete)
* @param string $label Etykieta przycisku
* @param string $url URL akcji (dla save)
* @param string|null $backUrl URL powrotu po zapisie
* @param string $cssClass Klasy CSS przycisku
* @param string $type Typ przycisku (submit, button, link)
* @param array $attributes Dodatkowe atrybuty HTML
*/
public function __construct(
string $name,
string $label,
string $url = '',
?string $backUrl = null,
string $cssClass = 'btn btn-primary',
string $type = 'submit',
array $attributes = []
) {
$this->name = $name;
$this->label = $label;
$this->url = $url;
$this->backUrl = $backUrl;
$this->cssClass = $cssClass;
$this->type = $type;
$this->attributes = $attributes;
}
/**
* Predefiniowana akcja Zapisz
*/
public static function save(string $url, string $backUrl = '', string $label = 'Zapisz'): self
{
return new self(
'save',
$label,
$url,
$backUrl,
'btn btn-primary',
'submit'
);
}
/**
* Predefiniowana akcja Anuluj
*/
public static function cancel(string $backUrl, string $label = 'Anuluj'): self
{
return new self(
'cancel',
$label,
$backUrl,
null,
'btn btn-default',
'link'
);
}
}

View File

@@ -0,0 +1,178 @@
<?php
namespace admin\ViewModels\Forms;
/**
* Główny model widoku formularza edycji
*/
class FormEditViewModel
{
public string $formId;
public string $title;
public string $method;
public string $action;
public ?string $backUrl;
public array $tabs;
public array $fields;
public array $hiddenFields;
public array $actions;
public bool $persist;
public array $data;
public ?array $validationErrors;
public ?array $languages;
/**
* @param string $formId Unikalny identyfikator formularza
* @param string $title Tytuł formularza
* @param array $data Dane obiektu (np. banner)
* @param array $fields Pola formularza
* @param array $tabs Zakładki formularza
* @param array $actions Akcje (przyciski)
* @param string $method Metoda HTTP (POST, GET)
* @param string $action URL akcji formularza
* @param string|null $backUrl URL powrotu
* @param bool $persist Czy zapamiętywać dane w sesji
* @param array $hiddenFields Dodatkowe ukryte pola
* @param array|null $languages Dostępne języki (dla sekcji językowych)
* @param array|null $validationErrors Błędy walidacji
*/
public function __construct(
string $formId,
string $title,
array $data = [],
array $fields = [],
array $tabs = [],
array $actions = [],
string $method = 'POST',
string $action = '',
?string $backUrl = null,
bool $persist = true,
array $hiddenFields = [],
?array $languages = null,
?array $validationErrors = null
) {
$this->formId = $formId;
$this->title = $title;
$this->data = $data;
$this->fields = $fields;
$this->tabs = $tabs;
$this->actions = $actions;
$this->method = $method;
$this->action = $action;
$this->backUrl = $backUrl;
$this->persist = $persist;
$this->hiddenFields = $hiddenFields;
$this->languages = $languages;
$this->validationErrors = $validationErrors;
}
/**
* Sprawdza czy formularz ma zakładki
*/
public function hasTabs(): bool
{
return count($this->tabs) > 0;
}
/**
* Sprawdza czy formularz ma sekcje językowe
*/
public function hasLangSections(): bool
{
foreach ($this->fields as $field) {
if ($field->type === FormFieldType::LANG_SECTION) {
return true;
}
}
return false;
}
/**
* Zwraca pola dla konkretnej zakładki
*/
public function getFieldsForTab(string $tabId): array
{
return array_filter($this->fields, function (FormField $field) use ($tabId) {
return $field->tabId === $tabId && $field->type !== FormFieldType::LANG_SECTION;
});
}
/**
* Zwraca sekcje językowe dla konkretnej zakładki
*/
public function getLangSectionsForTab(string $tabId): array
{
return array_filter($this->fields, function (FormField $field) use ($tabId) {
return $field->type === FormFieldType::LANG_SECTION &&
$field->langSectionParentTab === $tabId;
});
}
/**
* Pobiera wartość pola z danych lub sesji (persist)
*/
public function getFieldValue(FormField $field, $languageId = null, ?string $langFieldName = null)
{
$fieldName = $field->name;
// Dla sekcji językowych - pobierz wartość z data[lang_id][field_name]
if ($languageId !== null && $langFieldName !== null) {
$fieldName = $langFieldName;
return $this->data['languages'][$languageId][$fieldName] ?? null;
}
// Zwykłe pole - najpierw sprawdź sesję (persist), potem dane
if ($this->persist && isset($_SESSION['form_persist'][$this->formId][$fieldName])) {
return $_SESSION['form_persist'][$this->formId][$fieldName];
}
return $this->data[$fieldName] ?? $field->value;
}
/**
* Sprawdza czy pole ma błąd walidacji
*/
public function hasError(string $fieldName, $languageId = null): bool
{
if ($this->validationErrors === null) {
return false;
}
if ($languageId !== null) {
return isset($this->validationErrors[$fieldName][$languageId]);
}
return isset($this->validationErrors[$fieldName]);
}
/**
* Pobiera komunikat błędu dla pola
*/
public function getError(string $fieldName, $languageId = null): ?string
{
if ($languageId !== null) {
return $this->validationErrors[$fieldName][$languageId] ?? null;
}
return $this->validationErrors[$fieldName] ?? null;
}
/**
* Czyści dane persist z sesji
*/
public function clearPersist(): void
{
if (isset($_SESSION['form_persist'][$this->formId])) {
unset($_SESSION['form_persist'][$this->formId]);
}
}
/**
* Zapisuje dane do sesji (persist)
*/
public function saveToPersist(array $data): void
{
if (!isset($_SESSION['form_persist'])) {
$_SESSION['form_persist'] = [];
}
$_SESSION['form_persist'][$this->formId] = $data;
}
}

View File

@@ -0,0 +1,323 @@
<?php
namespace admin\ViewModels\Forms;
/**
* Definicja pojedynczego pola formularza
*/
class FormField
{
public string $name;
public string $type;
public string $label;
public $value;
public string $tabId;
public bool $required;
public array $attributes;
public array $options;
public ?string $helpText;
public ?string $placeholder;
public ?string $id;
// Specyficzne dla obrazów/plików
public bool $useFilemanager;
public ?string $filemanagerUrl;
// Specyficzne dla edytora
public string $editorToolbar;
public int $editorHeight;
// Specyficzne dla lang_section
public ?array $langFields;
public ?string $langSectionParentTab;
/**
* @param string $name Nazwa pola (name)
* @param string $type Typ pola (z FormFieldType)
* @param string $label Etykieta pola
* @param mixed $value Wartość domyślna
* @param string $tabId Identyfikator zakładki
* @param bool $required Czy pole wymagane
* @param array $attributes Atrybuty HTML
* @param array $options Opcje dla select
* @param string|null $helpText Tekst pomocniczy
* @param string|null $placeholder Placeholder
* @param bool $useFilemanager Czy używać filemanagera
* @param string|null $filemanagerUrl URL filemanagera
* @param string $editorToolbar Konfiguracja toolbar CKEditor
* @param int $editorHeight Wysokość edytora
* @param array|null $langFields Pola w sekcji językowej
* @param string|null $langSectionParentTab Zakładka nadrzędna dla sekcji językowej
*/
public function __construct(
string $name,
string $type = FormFieldType::TEXT,
string $label = '',
$value = null,
string $tabId = 'default',
bool $required = false,
array $attributes = [],
array $options = [],
?string $helpText = null,
?string $placeholder = null,
bool $useFilemanager = false,
?string $filemanagerUrl = null,
string $editorToolbar = 'MyTool',
int $editorHeight = 300,
?array $langFields = null,
?string $langSectionParentTab = null
) {
$this->name = $name;
$this->type = $type;
$this->label = $label;
$this->value = $value;
$this->tabId = $tabId;
$this->required = $required;
$this->attributes = $attributes;
$this->options = $options;
$this->helpText = $helpText;
$this->placeholder = $placeholder;
$this->useFilemanager = $useFilemanager;
$this->filemanagerUrl = $filemanagerUrl;
$this->editorToolbar = $editorToolbar;
$this->editorHeight = $editorHeight;
$this->langFields = $langFields;
$this->langSectionParentTab = $langSectionParentTab;
$this->id = $attributes['id'] ?? $name;
}
// Factory methods dla różnych typów pól
public static function text(string $name, array $config = []): self
{
return new self(
$name,
FormFieldType::TEXT,
$config['label'] ?? '',
$config['value'] ?? null,
$config['tab'] ?? 'default',
$config['required'] ?? false,
$config['attributes'] ?? [],
[],
$config['help'] ?? null,
$config['placeholder'] ?? null
);
}
public static function number(string $name, array $config = []): self
{
return new self(
$name,
FormFieldType::NUMBER,
$config['label'] ?? '',
$config['value'] ?? null,
$config['tab'] ?? 'default',
$config['required'] ?? false,
$config['attributes'] ?? [],
[],
$config['help'] ?? null
);
}
public static function email(string $name, array $config = []): self
{
return new self(
$name,
FormFieldType::EMAIL,
$config['label'] ?? '',
$config['value'] ?? null,
$config['tab'] ?? 'default',
$config['required'] ?? false,
$config['attributes'] ?? []
);
}
public static function password(string $name, array $config = []): self
{
return new self(
$name,
FormFieldType::PASSWORD,
$config['label'] ?? '',
$config['value'] ?? null,
$config['tab'] ?? 'default',
$config['required'] ?? false,
$config['attributes'] ?? []
);
}
public static function date(string $name, array $config = []): self
{
return new self(
$name,
FormFieldType::DATE,
$config['label'] ?? '',
$config['value'] ?? null,
$config['tab'] ?? 'default',
$config['required'] ?? false,
array_merge(['class' => 'date'], $config['attributes'] ?? [])
);
}
public static function datetime(string $name, array $config = []): self
{
return new self(
$name,
FormFieldType::DATETIME,
$config['label'] ?? '',
$config['value'] ?? null,
$config['tab'] ?? 'default',
$config['required'] ?? false,
array_merge(['class' => 'datetime'], $config['attributes'] ?? [])
);
}
public static function switch(string $name, array $config = []): self
{
return new self(
$name,
FormFieldType::SWITCH,
$config['label'] ?? '',
$config['value'] ?? false,
$config['tab'] ?? 'default',
false,
$config['attributes'] ?? []
);
}
public static function select(string $name, array $config = []): self
{
return new self(
$name,
FormFieldType::SELECT,
$config['label'] ?? '',
$config['value'] ?? null,
$config['tab'] ?? 'default',
$config['required'] ?? false,
$config['attributes'] ?? [],
$config['options'] ?? []
);
}
public static function textarea(string $name, array $config = []): self
{
return new self(
$name,
FormFieldType::TEXTAREA,
$config['label'] ?? '',
$config['value'] ?? null,
$config['tab'] ?? 'default',
$config['required'] ?? false,
array_merge(['rows' => $config['rows'] ?? 4], $config['attributes'] ?? [])
);
}
public static function editor(string $name, array $config = []): self
{
return new self(
$name,
FormFieldType::EDITOR,
$config['label'] ?? '',
$config['value'] ?? null,
$config['tab'] ?? 'default',
$config['required'] ?? false,
$config['attributes'] ?? [],
[],
null,
null,
false,
null,
$config['toolbar'] ?? 'MyTool',
$config['height'] ?? 300
);
}
public static function image(string $name, array $config = []): self
{
return new self(
$name,
FormFieldType::IMAGE,
$config['label'] ?? '',
$config['value'] ?? null,
$config['tab'] ?? 'default',
$config['required'] ?? false,
$config['attributes'] ?? [],
[],
null,
null,
$config['filemanager'] ?? true,
$config['filemanager_url'] ?? null
);
}
public static function file(string $name, array $config = []): self
{
return new self(
$name,
FormFieldType::FILE,
$config['label'] ?? '',
$config['value'] ?? null,
$config['tab'] ?? 'default',
$config['required'] ?? false,
$config['attributes'] ?? [],
[],
null,
null,
$config['filemanager'] ?? true
);
}
public static function hidden(string $name, $value = null): self
{
return new self(
$name,
FormFieldType::HIDDEN,
'',
$value,
'default'
);
}
/**
* Sekcja językowa - grupa pól powtarzana dla każdego języka
*
* @param string $name Nazwa sekcji (prefiks dla pól)
* @param string $parentTab Identyfikator zakładki nadrzędnej
* @param array $fields Pola w sekcji językowej (tablica FormField)
*/
public static function langSection(string $name, string $parentTab, array $fields): self
{
return new self(
$name,
FormFieldType::LANG_SECTION,
'',
null,
$parentTab,
false,
[],
[],
null,
null,
false,
null,
'MyTool',
300,
$fields,
$parentTab
);
}
/**
* Zwraca nazwę pola z sufiksem dla konkretnego języka
*/
public function getLocalizedName($languageId): string
{
return "{$this->name}[{$languageId}]";
}
/**
* Zwraca ID pola z sufiksem dla konkretnego języka
*/
public function getLocalizedId($languageId): string
{
return "{$this->id}_{$languageId}";
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace admin\ViewModels\Forms;
/**
* Dostępne typy pól formularza
*/
class FormFieldType
{
public const TEXT = 'text';
public const NUMBER = 'number';
public const EMAIL = 'email';
public const PASSWORD = 'password';
public const DATE = 'date';
public const DATETIME = 'datetime';
public const SWITCH = 'switch';
public const SELECT = 'select';
public const TEXTAREA = 'textarea';
public const EDITOR = 'editor';
public const IMAGE = 'image';
public const FILE = 'file';
public const HIDDEN = 'hidden';
public const LANG_SECTION = 'lang_section';
}

View File

@@ -0,0 +1,31 @@
<?php
namespace admin\ViewModels\Forms;
/**
* Definicja zakładki formularza
*/
class FormTab
{
public string $id;
public string $label;
public string $icon;
public ?string $parentTabId;
/**
* @param string $id Unikalny identyfikator zakładki
* @param string $label Etykieta wyświetlana
* @param string $icon Klasa FontAwesome (np. 'fa-wrench')
* @param string|null $parentTabId Identyfikator zakładki nadrzędnej (dla zagnieżdżenia)
*/
public function __construct(
string $id,
string $label,
string $icon = '',
?string $parentTabId = null
) {
$this->id = $id;
$this->label = $label;
$this->icon = $icon;
$this->parentTabId = $parentTabId;
}
}

View File

@@ -1,6 +1,9 @@
# Wyłącz listowanie
Options -Indexes
# Zezwol na wykonywanie PHP tylko dla legacy filemanagera
SetEnvIf Request_URI "^/libraries/filemanager-9\.14\.[12]/.*\.(php|phtml|php[0-9]?|phar|pht)$" allow_legacy_filemanager_php=1
# Domyślnie blokujemy wszystko…
Require all denied
@@ -11,7 +14,10 @@ Require all denied
# Twardo blokuj cokolwiek, co mogłoby się wykonać
<FilesMatch "\.(php|phtml|php[0-9]?|phar|pht|cgi|pl|py|sh)$">
Require all denied
<RequireAny>
Require env allow_legacy_filemanager_php
Require all denied
</RequireAny>
</FilesMatch>
<Files "thumb.php">
@@ -41,4 +47,4 @@ Require all denied
# Nie serwuj plików ukrytych (.env itp.)
<FilesMatch "^\.(.*)$">
Require all denied
</FilesMatch>
</FilesMatch>

View File

@@ -21,7 +21,10 @@ class BannerRepositoryTest extends TestCase
$mockDb->expects($this->once())
->method('select')
->with('pp_banners_langs', '*', ['id_banner' => 1])
->with('pp_banners_langs', '*', [
'id_banner' => 1,
'ORDER' => ['id' => 'ASC'],
])
->willReturn([
['id_lang' => 'pl', 'src' => 'banner.jpg', 'url' => '/promo'],
['id_lang' => 'en', 'src' => 'banner-en.jpg', 'url' => '/promo-en'],
@@ -80,7 +83,7 @@ class BannerRepositoryTest extends TestCase
}
/**
* Test zapisywania nowego banera
* Test zapisywania nowego banera (stary format danych - zachowano kompatybilność)
*/
public function testSaveInsertsNewBanner()
{
@@ -100,9 +103,51 @@ class BannerRepositoryTest extends TestCase
$repository = new BannerRepository($mockDb);
// Act
// Act - nowy format z FormRequestHandler (przetworzone dane)
$result = $repository->save([
'name' => 'Nowy baner',
'status' => 1, // już przetworzone na int
'date_start' => null,
'date_end' => null,
'home_page' => 1, // już przetworzone na int
'translations' => [
1 => [ // id języka jako klucz
'src' => 'banner.jpg',
'url' => '/promo',
'html' => '',
'text' => 'Tekst',
],
],
]);
// Assert
$this->assertEquals(10, $result);
}
/**
* Test zapisywania banera ze starym formatem danych (backward compatibility)
*/
public function testSaveWithLegacyFormat()
{
// Arrange
$mockDb = $this->createMock(\medoo::class);
// insert() wywoływane 2x: raz dla banera, raz dla tłumaczenia
$mockDb->expects($this->exactly(2))
->method('insert');
$mockDb->expects($this->once())
->method('id')
->willReturn(11);
// get() for checking existing translations - returns false (no existing)
$mockDb->method('get')->willReturn(false);
$repository = new BannerRepository($mockDb);
// Act - stary format (dla kompatybilności wstecznej)
$result = $repository->save([
'name' => 'Baner legacy',
'status' => 'on',
'date_start' => '',
'date_end' => '',
@@ -114,6 +159,114 @@ class BannerRepositoryTest extends TestCase
]);
// Assert
$this->assertEquals(10, $result);
$this->assertEquals(11, $result);
}
/**
* Test zapisu istniejacego banera - aktualizacja tlumaczen po id_banner + id_lang
*/
public function testSaveUpdatesExistingTranslationsByBannerAndLang(): void
{
$mockDb = $this->createMock(\medoo::class);
$mockDb->expects($this->exactly(2))
->method('update')
->withConsecutive(
[
'pp_banners',
$this->arrayHasKey('name'),
['id' => 5],
],
[
'pp_banners_langs',
$this->callback(function (array $data): bool {
return $data['id_banner'] === 5
&& $data['id_lang'] === 'pl'
&& $data['src'] === 'banner-new.jpg';
}),
['AND' => ['id_banner' => 5, 'id_lang' => 'pl']],
]
);
$mockDb->expects($this->once())
->method('count')
->with('pp_banners_langs', ['AND' => ['id_banner' => 5, 'id_lang' => 'pl']])
->willReturn(2);
$mockDb->expects($this->never())
->method('insert');
$repository = new BannerRepository($mockDb);
$result = $repository->save([
'id' => 5,
'name' => 'Baner update',
'status' => 1,
'date_start' => null,
'date_end' => null,
'home_page' => 0,
'translations' => [
'pl' => [
'src' => 'banner-new.jpg',
'url' => '/promo-new',
'html' => '<b>promo</b>',
'text' => 'Nowa tresc',
],
],
]);
$this->assertSame(5, $result);
}
public function testListForAdminIncludesThumbnailSrc(): void
{
$mockDb = $this->createMock(\medoo::class);
$countStmt = $this->createMock(\PDOStatement::class);
$countStmt->expects($this->once())
->method('fetchAll')
->willReturn([[2]]);
$itemsStmt = $this->createMock(\PDOStatement::class);
$itemsStmt->expects($this->once())
->method('fetchAll')
->willReturn([
[
'id' => 10,
'name' => 'Baner A',
'status' => 1,
'home_page' => 0,
'date_start' => null,
'date_end' => null,
],
[
'id' => 11,
'name' => 'Baner B',
'status' => 1,
'home_page' => 1,
'date_start' => null,
'date_end' => null,
],
]);
$thumbsStmt = $this->createMock(\PDOStatement::class);
$thumbsStmt->expects($this->once())
->method('fetchAll')
->willReturn([
['id_banner' => 10, 'src' => '/uploads/banner-a.jpg'],
]);
$mockDb->expects($this->exactly(3))
->method('query')
->willReturnOnConsecutiveCalls($countStmt, $itemsStmt, $thumbsStmt);
$repository = new BannerRepository($mockDb);
$result = $repository->listForAdmin([], 'name', 'ASC', 1, 15);
$this->assertSame(2, $result['total']);
$this->assertCount(2, $result['items']);
$this->assertSame('/uploads/banner-a.jpg', $result['items'][0]['thumbnail_src']);
$this->assertSame('', $result['items'][1]['thumbnail_src']);
}
}

BIN
updates/0.20/ver_0.249.zip Normal file

Binary file not shown.

View File

View File

@@ -1,4 +1,10 @@
<b>ver. 0.248</b><br />
<b>ver. 0.249</b><br />
- FIX - banner edit: poprawiono zapisywanie danych jezykowych i synchronizacje CKEditor przed zapisem
- FIX - banner edit: naprawiono hash zakladek (usunieto `undefined` w URL)
- FIX - filemanager: przywrocono dzialanie popupa wyboru obrazka z banera
- UPDATE - komunikaty zapisu w nowym formularzu sa wyswietlane w stylu panelu (bez natywnego alertu JS)
- UPDATE - lista banerow: dodano kolumne miniatury oraz podglad duzego obrazka w popup po najechaniu
<hr><b>ver. 0.248</b><br />
- UPDATE - filtry w nowych tabelach dzialaja automatycznie na `onchange`
- UPDATE - `components/table-list`: auto-submit formularza filtrow po zmianie pola (select, date, text)
<hr><b>ver. 0.247</b><br />

View File

@@ -1,5 +1,5 @@
<?
$current_ver = 248;
$current_ver = 249;
for ($i = 1; $i <= $current_ver; $i++)
{