diff --git a/admin/templates/articles/article-edit.php b/admin/templates/articles/article-edit.php
index 248cb07..8cd9888 100644
--- a/admin/templates/articles/article-edit.php
+++ b/admin/templates/articles/article-edit.php
@@ -801,4 +801,4 @@ echo $grid -> draw();
});
}
-
\ No newline at end of file
+
diff --git a/admin/templates/banners/banner-edit.php b/admin/templates/banners/banner-edit.php
index dd811f6..469abb8 100644
--- a/admin/templates/banners/banner-edit.php
+++ b/admin/templates/banners/banner-edit.php
@@ -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();
});
-
\ No newline at end of file
+
diff --git a/admin/templates/banners/banners-list.php b/admin/templates/banners/banners-list.php
index 3e70c9a..45cd47a 100644
--- a/admin/templates/banners/banners-list.php
+++ b/admin/templates/banners/banners-list.php
@@ -1,5 +1,105 @@
= \Tpl::view('components/table-list', ['list' => $this->viewModel]); ?>
+
+
+
+
viewModel->customScriptView)): ?>
= \Tpl::view($this->viewModel->customScriptView, ['list' => $this->viewModel]); ?>
diff --git a/admin/templates/components/form-edit.php b/admin/templates/components/form-edit.php
new file mode 100644
index 0000000..9d4d853
--- /dev/null
+++ b/admin/templates/components/form-edit.php
@@ -0,0 +1,278 @@
+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;
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ = htmlspecialchars($form->title) ?>
+
+
+
+
+
+
+
+
+
+
+
+fields as $field):
+ if ($field->type === FormFieldType::EDITOR):
+?>
+
+
+
+hasLangSections()): ?>
+
+
+
+
+
+
diff --git a/admin/templates/filemanager/filemanager.php b/admin/templates/filemanager/filemanager.php
index 3e132fd..114d499 100644
--- a/admin/templates/filemanager/filemanager.php
+++ b/admin/templates/filemanager/filemanager.php
@@ -1 +1 @@
-
\ No newline at end of file
+
diff --git a/admin/templates/html/input-switch.php b/admin/templates/html/input-switch.php
index 1a87d6b..0d9aca4 100644
--- a/admin/templates/html/input-switch.php
+++ b/admin/templates/html/input-switch.php
@@ -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" ';
diff --git a/admin/templates/shop-producer/edit.php b/admin/templates/shop-producer/edit.php
index ffe97ce..6a03486 100644
--- a/admin/templates/shop-producer/edit.php
+++ b/admin/templates/shop-producer/edit.php
@@ -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');"
] );
?>
@@ -177,4 +177,4 @@ echo $grid -> draw();
tabidentify: 'languages-main'
});
});
-
\ No newline at end of file
+
diff --git a/admin/templates/shop-product/product-edit.php b/admin/templates/shop-product/product-edit.php
index 9bef6f7..9289ad8 100644
--- a/admin/templates/shop-product/product-edit.php
+++ b/admin/templates/shop-product/product-edit.php
@@ -1343,4 +1343,4 @@ echo $grid->draw();
\ No newline at end of file
+
diff --git a/autoload/Domain/Banner/BannerRepository.php b/autoload/Domain/Banner/BannerRepository.php
index 16ef283..083a13a 100644
--- a/autoload/Domain/Banner/BannerRepository.php
+++ b/autoload/Domain/Banner/BannerRepository.php
@@ -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 $bannerIds
+ * @return array [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);
+ }
}
diff --git a/autoload/admin/Controllers/BannerController.php b/autoload/admin/Controllers/BannerController.php
index 3978ff2..d2c8a74 100644
--- a/autoload/admin/Controllers/BannerController.php
+++ b/autoload/admin/Controllers/BannerController.php
@@ -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 = '-';
+ if ($thumbnailSrc !== '') {
+ $thumbnail = ''
+ . '
 . ')
'
+ . '
';
+ }
$rows[] = [
'lp' => $lp++ . '.',
+ 'thumbnail' => $thumbnail,
'name' => '' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '',
'status' => $isActive ? 'tak' : 'nie',
'home_page' => $homePage === 1 ? 'tak' : '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';
+ }
}
diff --git a/autoload/admin/Support/Forms/FormFieldRenderer.php b/autoload/admin/Support/Forms/FormFieldRenderer.php
new file mode 100644
index 0000000..5a35c10
--- /dev/null
+++ b/autoload/admin/Support/Forms/FormFieldRenderer.php
@@ -0,0 +1,430 @@
+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 'id) . '" ' .
+ 'value="' . htmlspecialchars($value ?? '') . '">';
+ }
+
+ /**
+ * Renderuje sekcję językową
+ */
+ public function renderLangSection(FormField $section): string
+ {
+ if ($section->langFields === null || $this->form->languages === null) {
+ return '';
+ }
+
+ $out = '';
+
+ // Zakładki języków
+ $out .= '
';
+ foreach ($this->form->languages as $lang) {
+ if ($lang['status']) {
+ $out .= '- ' . htmlspecialchars($lang['name']) . '
';
+ }
+ }
+ $out .= '
';
+
+ // Kontenery języków
+ $out .= '
';
+ foreach ($this->form->languages as $lang) {
+ if ($lang['status']) {
+ $out .= '
';
+ foreach ($section->langFields as $field) {
+ $out .= $this->renderLangField($field, $lang['id'], $section->name);
+ }
+ $out .= '
';
+ }
+ }
+ $out .= '
';
+
+ $out .= '
';
+
+ 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 '' . $html .
+ '' . htmlspecialchars($error) . '
';
+ }
+ return $html;
+ }
+}
diff --git a/autoload/admin/Support/Forms/FormRequestHandler.php b/autoload/admin/Support/Forms/FormRequestHandler.php
new file mode 100644
index 0000000..2bf5105
--- /dev/null
+++ b/autoload/admin/Support/Forms/FormRequestHandler.php
@@ -0,0 +1,152 @@
+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);
+ }
+}
diff --git a/autoload/admin/Validation/FormValidator.php b/autoload/admin/Validation/FormValidator.php
new file mode 100644
index 0000000..73e7b05
--- /dev/null
+++ b/autoload/admin/Validation/FormValidator.php
@@ -0,0 +1,196 @@
+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;
+ }
+}
diff --git a/autoload/admin/ViewModels/Forms/FormAction.php b/autoload/admin/ViewModels/Forms/FormAction.php
new file mode 100644
index 0000000..98d6529
--- /dev/null
+++ b/autoload/admin/ViewModels/Forms/FormAction.php
@@ -0,0 +1,73 @@
+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'
+ );
+ }
+}
diff --git a/autoload/admin/ViewModels/Forms/FormEditViewModel.php b/autoload/admin/ViewModels/Forms/FormEditViewModel.php
new file mode 100644
index 0000000..663d08f
--- /dev/null
+++ b/autoload/admin/ViewModels/Forms/FormEditViewModel.php
@@ -0,0 +1,178 @@
+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;
+ }
+}
diff --git a/autoload/admin/ViewModels/Forms/FormField.php b/autoload/admin/ViewModels/Forms/FormField.php
new file mode 100644
index 0000000..faaf46d
--- /dev/null
+++ b/autoload/admin/ViewModels/Forms/FormField.php
@@ -0,0 +1,323 @@
+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}";
+ }
+}
diff --git a/autoload/admin/ViewModels/Forms/FormFieldType.php b/autoload/admin/ViewModels/Forms/FormFieldType.php
new file mode 100644
index 0000000..66e68dc
--- /dev/null
+++ b/autoload/admin/ViewModels/Forms/FormFieldType.php
@@ -0,0 +1,23 @@
+id = $id;
+ $this->label = $label;
+ $this->icon = $icon;
+ $this->parentTabId = $parentTabId;
+ }
+}
diff --git a/libraries/.htaccess b/libraries/.htaccess
index eb2244f..6485d3f 100644
--- a/libraries/.htaccess
+++ b/libraries/.htaccess
@@ -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ć
- Require all denied
+
+ Require env allow_legacy_filemanager_php
+ Require all denied
+
@@ -41,4 +47,4 @@ Require all denied
# Nie serwuj plików ukrytych (.env itp.)
Require all denied
-
\ No newline at end of file
+
diff --git a/tests/Unit/Domain/Banner/BannerRepositoryTest.php b/tests/Unit/Domain/Banner/BannerRepositoryTest.php
index 9ebc7da..cc7dbab 100644
--- a/tests/Unit/Domain/Banner/BannerRepositoryTest.php
+++ b/tests/Unit/Domain/Banner/BannerRepositoryTest.php
@@ -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' => 'promo',
+ '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']);
}
}
diff --git a/updates/0.20/ver_0.249.zip b/updates/0.20/ver_0.249.zip
new file mode 100644
index 0000000..d3c4bfe
Binary files /dev/null and b/updates/0.20/ver_0.249.zip differ
diff --git a/updates/0.20/ver_0.249_files.txt b/updates/0.20/ver_0.249_files.txt
new file mode 100644
index 0000000..e69de29
diff --git a/updates/changelog.php b/updates/changelog.php
index 46d4c47..4891ce9 100644
--- a/updates/changelog.php
+++ b/updates/changelog.php
@@ -1,4 +1,10 @@
-ver. 0.248
+ver. 0.249
+- 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
+
ver. 0.248
- UPDATE - filtry w nowych tabelach dzialaja automatycznie na `onchange`
- UPDATE - `components/table-list`: auto-submit formularza filtrow po zmianie pola (select, date, text)
ver. 0.247
diff --git a/updates/versions.php b/updates/versions.php
index 56ebd7b..290433d 100644
--- a/updates/versions.php
+++ b/updates/versions.php
@@ -1,5 +1,5 @@
-$current_ver = 248;
+$current_ver = 249;
for ($i = 1; $i <= $current_ver; $i++)
{