Add installer functionality for WordPress with FTP and database configuration

- Create SQL migration for prompt templates used in article and image generation.
- Add migration to change publish interval from days to hours in the sites table.
- Implement InstallerController to handle installation requests and validation.
- Develop FtpService for FTP connections and file uploads.
- Create InstallerService to manage the WordPress installation process, including downloading, extracting, and configuring WordPress.
- Add index view for the installer with form inputs for FTP, database, and WordPress admin settings.
- Implement progress tracking for the installation process with AJAX polling.
This commit is contained in:
2026-02-16 21:55:24 +01:00
parent 884ee9cc88
commit b653cea252
37 changed files with 2899 additions and 204 deletions

2
.env
View File

@@ -12,3 +12,5 @@ PEXELS_API_KEY=
APP_URL=https://backpro.projectpro.pl
APP_SECRET=bP7x9kR3mW2vN5qT8sY1
PUBLISH_TRIGGER_TOKEN=bP7x9kR3mW2vN5qT8sY1bP7x9kR3mW2vN5qT8sY1

View File

@@ -12,3 +12,4 @@ PEXELS_API_KEY=
APP_URL=https://backpro.projectpro.pl
APP_SECRET=change-this-to-random-string
PUBLISH_TRIGGER_TOKEN=change-this-to-long-random-token

570
.vscode/ftp-kr.sync.cache.json vendored Normal file
View File

@@ -0,0 +1,570 @@
{
"ftp://host700513.hostido.net.pl:21@www@backpro.projectpro.pl": {
"public_html": {
"assets": {
"css": {
"app.css": {
"type": "-",
"size": 1058,
"lmtime": 1771149935949,
"modified": false
}
},
"js": {
"app.js": {
"type": "-",
"size": 3372,
"lmtime": 1771151245969,
"modified": false
}
}
},
".claude": {
"settings.local.json": {
"type": "-",
"size": 383,
"lmtime": 1771150401075,
"modified": false
}
},
"composer.json": {
"type": "-",
"size": 329,
"lmtime": 1771149562456,
"modified": false
},
"composer.lock": {
"type": "-",
"size": 38524,
"lmtime": 1771150404757,
"modified": false
},
"composer.phar": {
"type": "-",
"size": 3288946,
"lmtime": 1771150388646,
"modified": false
},
"composer-setup.php": {
"type": "-",
"size": 59524,
"lmtime": 1771150386867,
"modified": false
},
"composer-temp.phar": {
"type": "-",
"size": 3288946,
"lmtime": 1771150388646,
"modified": false
},
"config": {
"routes.php": {
"type": "-",
"size": 2162,
"lmtime": 1771151153771,
"modified": false
}
},
"cron": {
"publish.php": {
"type": "-",
"size": 1078,
"lmtime": 1771149803282,
"modified": false
}
},
"docs": {
"API.md": {
"type": "-",
"size": 5465,
"lmtime": 1771149511946,
"modified": false
},
"CRON.md": {
"type": "-",
"size": 4340,
"lmtime": 1771149543293,
"modified": false
},
"DATABASE.md": {
"type": "-",
"size": 7265,
"lmtime": 1771151285551,
"modified": false
},
"PLAN.md": {
"type": "-",
"size": 6324,
"lmtime": 1771151257785,
"modified": false
}
},
".env": {
"type": "-",
"size": 267,
"lmtime": 1771150568472,
"modified": false
},
".env.example": {
"type": "-",
"size": 230,
"lmtime": 1771149564730,
"modified": false
},
".htaccess": {
"type": "-",
"size": 701,
"lmtime": 1771149568342,
"modified": false
},
"index.php": {
"type": "-",
"size": 129,
"lmtime": 1771149647799,
"modified": false
},
"install.php": {
"type": "-",
"size": 2372,
"lmtime": 1771151235557,
"modified": false
},
"migrations": {
"001_initial.sql": {
"type": "-",
"size": 3130,
"lmtime": 1771150614485,
"modified": false
},
"002_global_topics.sql": {
"type": "-",
"size": 5933,
"lmtime": 1771151084583,
"modified": false
}
},
"src": {
"Controllers": {
"ArticleController.php": {
"type": "-",
"size": 1070,
"lmtime": 1771149717983,
"modified": false
},
"AuthController.php": {
"type": "-",
"size": 2450,
"lmtime": 1771150758725,
"modified": false
},
"CategoryController.php": {
"type": "-",
"size": 1345,
"lmtime": 1771149714554,
"modified": false
},
"DashboardController.php": {
"type": "-",
"size": 858,
"lmtime": 1771149691435,
"modified": false
},
"GlobalTopicController.php": {
"type": "-",
"size": 2791,
"lmtime": 1771151114783,
"modified": false
},
"PublishController.php": {
"type": "-",
"size": 1160,
"lmtime": 1771149721444,
"modified": false
},
"SettingsController.php": {
"type": "-",
"size": 1108,
"lmtime": 1771149725522,
"modified": false
},
"SiteController.php": {
"type": "-",
"size": 5284,
"lmtime": 1771151386055,
"modified": false
},
"TopicController.php": {
"type": "-",
"size": 3007,
"lmtime": 1771151187064,
"modified": false
}
},
"Core": {
"App.php": {
"type": "-",
"size": 772,
"lmtime": 1771149963154,
"modified": false
},
"Auth.php": {
"type": "-",
"size": 1712,
"lmtime": 1771149625623,
"modified": false
},
"Config.php": {
"type": "-",
"size": 1471,
"lmtime": 1771149597340,
"modified": false
},
"Controller.php": {
"type": "-",
"size": 808,
"lmtime": 1771149609985,
"modified": false
},
"Database.php": {
"type": "-",
"size": 849,
"lmtime": 1771149600495,
"modified": false
},
"Model.php": {
"type": "-",
"size": 2280,
"lmtime": 1771149617300,
"modified": false
},
"Router.php": {
"type": "-",
"size": 2125,
"lmtime": 1771149607213,
"modified": false
},
"View.php": {
"type": "-",
"size": 1078,
"lmtime": 1771149621361,
"modified": false
}
},
"Helpers": {
"Logger.php": {
"type": "-",
"size": 1131,
"lmtime": 1771149676956,
"modified": false
},
"Validator.php": {
"type": "-",
"size": 1495,
"lmtime": 1771149682031,
"modified": false
}
},
"Models": {
"Article.php": {
"type": "-",
"size": 2641,
"lmtime": 1771149673238,
"modified": false
},
"GlobalTopic.php": {
"type": "-",
"size": 1067,
"lmtime": 1771151102980,
"modified": false
},
"Site.php": {
"type": "-",
"size": 856,
"lmtime": 1771149662297,
"modified": false
},
"Topic.php": {
"type": "-",
"size": 1272,
"lmtime": 1771151194863,
"modified": false
},
"User.php": {
"type": "-",
"size": 126,
"lmtime": 1771149659403,
"modified": false
}
},
"Services": {
"ImageService.php": {
"type": "-",
"size": 5398,
"lmtime": 1771149772358,
"modified": false
},
"OpenAIService.php": {
"type": "-",
"size": 3704,
"lmtime": 1771149756796,
"modified": false
},
"TopicBalancer.php": {
"type": "-",
"size": 435,
"lmtime": 1771149775990,
"modified": false
},
"WordPressService.php": {
"type": "-",
"size": 3832,
"lmtime": 1771149744155,
"modified": false
},
"PublisherService.php": {
"type": "-",
"size": 4762,
"lmtime": 1771149793307,
"modified": false
}
}
},
"storage": {
"logs": {}
},
"templates": {
"articles": {
"index.php": {
"type": "-",
"size": 3091,
"lmtime": 1771149908879,
"modified": false
},
"show.php": {
"type": "-",
"size": 2169,
"lmtime": 1771149917898,
"modified": false
}
},
"auth": {
"login.php": {
"type": "-",
"size": 1223,
"lmtime": 1771149825676,
"modified": false
},
"change-password.php": {
"type": "-",
"size": 1394,
"lmtime": 1771150766881,
"modified": false
}
},
"categories": {
"index.php": {
"type": "-",
"size": 1908,
"lmtime": 1771149900365,
"modified": false
}
},
"dashboard": {
"index.php": {
"type": "-",
"size": 5509,
"lmtime": 1771149840774,
"modified": false
}
},
"layout": {
"header.php": {
"type": "-",
"size": 623,
"lmtime": 1771149821599,
"modified": false
},
"main.php": {
"type": "-",
"size": 1764,
"lmtime": 1771149813250,
"modified": false
},
"sidebar.php": {
"type": "-",
"size": 1645,
"lmtime": 1771151160479,
"modified": false
}
},
"settings": {
"index.php": {
"type": "-",
"size": 4584,
"lmtime": 1771150927229,
"modified": false
}
},
"sites": {
"create.php": {
"type": "-",
"size": 4785,
"lmtime": 1771151739488,
"modified": false
},
"edit.php": {
"type": "-",
"size": 8662,
"lmtime": 1771151434705,
"modified": false
},
"index.php": {
"type": "-",
"size": 4210,
"lmtime": 1771149851437,
"modified": false
}
},
"topics": {
"index.php": {
"type": "-",
"size": 7946,
"lmtime": 1771151220487,
"modified": false
}
},
"global-topics": {
"index.php": {
"type": "-",
"size": 10389,
"lmtime": 1771151146370,
"modified": false
}
}
},
"vendor": {
"composer": {
"installed.json": {
"type": "-",
"size": 40002,
"lmtime": 1771150406683,
"modified": false
},
"installed.php": {
"type": "-",
"size": 6501,
"lmtime": 1771150406694,
"modified": false
},
"InstalledVersions.php": {
"type": "-",
"size": 17395,
"lmtime": 1771150406172,
"modified": false
},
"tmp-2dfd2a959f1c463e83d0bf5b824a1ffe.zip": {
"type": "-",
"size": 95,
"lmtime": 1771150405319,
"modified": true
},
"tmp-70450ac5e9a781eb1bd9ea14d04715d6.zip~": {
"type": "-",
"size": 96,
"lmtime": 1771150405410,
"modified": true
},
"ClassLoader.php": {
"type": "-",
"size": 16378,
"lmtime": 1769683253000,
"modified": false
},
"LICENSE": {
"type": "-",
"size": 1070,
"lmtime": 1769683253000,
"modified": false
},
"autoload_classmap.php": {
"type": "-",
"size": 689,
"lmtime": 1771150406998,
"modified": false
},
"autoload_files.php": {
"type": "-",
"size": 735,
"lmtime": 1771150406999,
"modified": false
},
"autoload_namespaces.php": {
"type": "-",
"size": 139,
"lmtime": 1771150406997,
"modified": false
},
"autoload_psr4.php": {
"type": "-",
"size": 1070,
"lmtime": 1771150406998,
"modified": false
},
"autoload_real.php": {
"type": "-",
"size": 1672,
"lmtime": 1771150407035,
"modified": false
},
"autoload_static.php": {
"type": "-",
"size": 4300,
"lmtime": 1771150407033,
"modified": false
},
"platform_check.php": {
"type": "-",
"size": 917,
"lmtime": 1771150407034,
"modified": false
}
},
"graham-campbell": {
"result-type": {}
},
"guzzlehttp": {
"guzzle": {},
"promises": {},
"psr7": {}
},
"phpoption": {
"phpoption": {}
},
"psr": {
"http-client": {},
"http-factory": {},
"http-message": {}
},
"ralouphie": {
"getallheaders": {}
},
"symfony": {
"deprecation-contracts": {},
"polyfill-ctype": {},
"polyfill-mbstring": {},
"polyfill-php80": {}
},
"vlucas": {
"phpdotenv": {}
},
"autoload.php": {
"type": "-",
"size": 748,
"lmtime": 1771150407034,
"modified": false
}
}
}
},
"$version": 1
}

View File

@@ -37,14 +37,24 @@ $router->post('/topics/{id}/delete', 'TopicController', 'destroy');
// Categories (sync from WordPress)
$router->get('/sites/{id}/categories', 'CategoryController', 'index');
$router->post('/sites/{id}/categories/sync', 'CategoryController', 'sync');
$router->post('/sites/{id}/categories/create', 'CategoryController', 'create');
$router->post('/sites/{id}/categories/from-topics', 'CategoryController', 'createFromTopics');
// Articles
$router->get('/articles', 'ArticleController', 'index');
$router->get('/articles/{id}', 'ArticleController', 'show');
$router->post('/articles/{id}/replace-image', 'ArticleController', 'replaceImage');
// Publish (manual trigger)
$router->post('/publish/run', 'PublishController', 'run');
$router->post('/publish/site/{id}', 'PublishController', 'runForSite');
$router->get('/publish/token-run', 'PublishController', 'runByToken');
$router->post('/publish/token-run', 'PublishController', 'runByToken');
// Installer (Remote WordPress Installation)
$router->get('/installer', 'InstallerController', 'index');
$router->post('/installer', 'InstallerController', 'install');
$router->get('/installer/status/{id}', 'InstallerController', 'status');
// Settings
$router->get('/settings', 'SettingsController', 'index');

View File

@@ -16,7 +16,7 @@ CREATE TABLE IF NOT EXISTS sites (
url VARCHAR(255) NOT NULL,
api_user VARCHAR(100) NOT NULL,
api_token VARCHAR(255) NOT NULL,
publish_interval_days INT NOT NULL DEFAULT 3,
publish_interval_hours INT NOT NULL DEFAULT 24,
last_published_at DATETIME NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
is_multisite TINYINT(1) NOT NULL DEFAULT 0,
@@ -79,4 +79,5 @@ INSERT INTO settings (`key`, value) VALUES
('openai_model', 'gpt-4o'),
('image_provider', 'freepik'),
('article_min_words', '800'),
('article_max_words', '1200');
('article_max_words', '1200')
ON DUPLICATE KEY UPDATE value = value;

View File

@@ -14,112 +14,107 @@ CREATE INDEX idx_global_topics_parent ON global_topics(parent_id);
-- Link topics to global_topics library
ALTER TABLE topics ADD COLUMN global_topic_id INT NULL AFTER site_id;
-- Seed: top-level categories
-- Seed: top-level categories (idempotent)
INSERT INTO global_topics (parent_id, name, description)
SELECT seed.parent_id, seed.name, seed.description
FROM (
SELECT NULL AS parent_id, 'Polityka' AS name, 'Tematy związane z polityką krajową i międzynarodową' AS description
UNION ALL SELECT NULL, 'Zdrowie', 'Zdrowie, medycyna, profilaktyka, wellness'
UNION ALL SELECT NULL, 'Sport', 'Dyscypliny sportowe, wydarzenia, treningi'
UNION ALL SELECT NULL, 'Technologia', 'IT, gadżety, sztuczna inteligencja, innowacje'
UNION ALL SELECT NULL, 'Biznes i Finanse', 'Ekonomia, inwestycje, przedsiębiorczość'
UNION ALL SELECT NULL, 'Rozrywka', 'Film, muzyka, gry, kultura popularna'
UNION ALL SELECT NULL, 'Nauka', 'Odkrycia naukowe, kosmos, badania'
UNION ALL SELECT NULL, 'Edukacja', 'Szkolnictwo, kursy, rozwój osobisty'
UNION ALL SELECT NULL, 'Podróże', 'Turystyka, przewodniki, inspiracje podróżnicze'
UNION ALL SELECT NULL, 'Motoryzacja', 'Samochody, motocykle, nowości motoryzacyjne'
UNION ALL SELECT NULL, 'Dom i Ogród', 'Aranżacja wnętrz, ogrodnictwo, DIY'
UNION ALL SELECT NULL, 'Kuchnia', 'Przepisy, diety, kuchnie świata'
UNION ALL SELECT NULL, 'Moda i Uroda', 'Trendy, pielęgnacja, kosmetyki'
UNION ALL SELECT NULL, 'Prawo', 'Porady prawne, zmiany w przepisach'
) AS seed
WHERE NOT EXISTS (
SELECT 1
FROM global_topics gt
WHERE gt.parent_id <=> seed.parent_id
AND gt.name = seed.name
);
INSERT INTO global_topics (id, parent_id, name, description) VALUES
(1, NULL, 'Polityka', 'Tematy związane z polityką krajową i międzynarodową'),
(2, NULL, 'Zdrowie', 'Zdrowie, medycyna, profilaktyka, wellness'),
(3, NULL, 'Sport', 'Dyscypliny sportowe, wydarzenia, treningi'),
(4, NULL, 'Technologia', 'IT, gadżety, sztuczna inteligencja, innowacje'),
(5, NULL, 'Biznes i Finanse', 'Ekonomia, inwestycje, przedsiębiorczość'),
(6, NULL, 'Rozrywka', 'Film, muzyka, gry, kultura popularna'),
(7, NULL, 'Nauka', 'Odkrycia naukowe, kosmos, badania'),
(8, NULL, 'Edukacja', 'Szkolnictwo, kursy, rozwój osobisty'),
(9, NULL, 'Podróże', 'Turystyka, przewodniki, inspiracje podróżnicze'),
(10, NULL, 'Motoryzacja', 'Samochody, motocykle, nowości motoryzacyjne'),
(11, NULL, 'Dom i Ogród', 'Aranżacja wnętrz, ogrodnictwo, DIY'),
(12, NULL, 'Kuchnia', 'Przepisy, diety, kuchnie świata'),
(13, NULL, 'Moda i Uroda', 'Trendy, pielęgnacja, kosmetyki'),
(14, NULL, 'Prawo', 'Porady prawne, zmiany w przepisach');
-- Seed: subtopics (idempotent)
INSERT INTO global_topics (parent_id, name, description)
SELECT parent.id, seed.name, seed.description
FROM (
SELECT 'Polityka' AS parent_name, 'Polityka krajowa' AS name, 'Sejm, rząd, partie polityczne, wybory w Polsce' AS description
UNION ALL SELECT 'Polityka', 'Polityka międzynarodowa', 'Dyplomacja, konflikty, organizacje międzynarodowe'
UNION ALL SELECT 'Polityka', 'Unia Europejska', 'Polityka UE, fundusze europejskie, regulacje'
-- Seed: subtopics
UNION ALL SELECT 'Zdrowie', 'Medycyna', 'Choroby, leczenie, nowinki medyczne'
UNION ALL SELECT 'Zdrowie', 'Zdrowy styl życia', 'Dieta, aktywność fizyczna, profilaktyka'
UNION ALL SELECT 'Zdrowie', 'Zdrowie psychiczne', 'Psychologia, stres, mindfulness, terapia'
UNION ALL SELECT 'Zdrowie', 'Suplementy i żywienie', 'Witaminy, suplementy diety, superfoods'
-- Polityka
INSERT INTO global_topics (parent_id, name, description) VALUES
(1, 'Polityka krajowa', 'Sejm, rząd, partie polityczne, wybory w Polsce'),
(1, 'Polityka międzynarodowa', 'Dyplomacja, konflikty, organizacje międzynarodowe'),
(1, 'Unia Europejska', 'Polityka UE, fundusze europejskie, regulacje');
UNION ALL SELECT 'Sport', 'Piłka nożna', 'Liga, transfery, reprezentacja, piłka nożna na świecie'
UNION ALL SELECT 'Sport', 'Koszykówka', 'NBA, polska liga, Euroliga'
UNION ALL SELECT 'Sport', 'Siatkówka', 'PlusLiga, reprezentacja Polski, siatkówka plażowa'
UNION ALL SELECT 'Sport', 'Sporty walki', 'MMA, boks, judo, karate'
UNION ALL SELECT 'Sport', 'Fitness i siłownia', 'Treningi, plany treningowe, ćwiczenia'
UNION ALL SELECT 'Sport', 'Bieganie', 'Maratony, technika biegu, sprzęt biegowy'
-- Zdrowie
INSERT INTO global_topics (parent_id, name, description) VALUES
(2, 'Medycyna', 'Choroby, leczenie, nowinki medyczne'),
(2, 'Zdrowy styl życia', 'Dieta, aktywność fizyczna, profilaktyka'),
(2, 'Zdrowie psychiczne', 'Psychologia, stres, mindfulness, terapia'),
(2, 'Suplementy i żywienie', 'Witaminy, suplementy diety, superfoods');
UNION ALL SELECT 'Technologia', 'Sztuczna inteligencja', 'AI, machine learning, ChatGPT, automatyzacja'
UNION ALL SELECT 'Technologia', 'Smartfony i gadżety', 'Recenzje, nowości, porównania urządzeń'
UNION ALL SELECT 'Technologia', 'Programowanie', 'Języki programowania, frameworki, poradniki'
UNION ALL SELECT 'Technologia', 'Cyberbezpieczeństwo', 'Ochrona danych, hakerzy, prywatność w sieci'
UNION ALL SELECT 'Technologia', 'Gaming', 'Gry komputerowe, konsole, esport'
-- Sport
INSERT INTO global_topics (parent_id, name, description) VALUES
(3, 'Piłka nożna', 'Liga, transfery, reprezentacja, piłka nożna na świecie'),
(3, 'Koszykówka', 'NBA, polska liga, Euroliga'),
(3, 'Siatkówka', 'PlusLiga, reprezentacja Polski, siatkówka plażowa'),
(3, 'Sporty walki', 'MMA, boks, judo, karate'),
(3, 'Fitness i siłownia', 'Treningi, plany treningowe, ćwiczenia'),
(3, 'Bieganie', 'Maratony, technika biegu, sprzęt biegowy');
UNION ALL SELECT 'Biznes i Finanse', 'Inwestycje', 'Giełda, kryptowaluty, nieruchomości, fundusze'
UNION ALL SELECT 'Biznes i Finanse', 'Przedsiębiorczość', 'Zakładanie firmy, startupy, zarządzanie'
UNION ALL SELECT 'Biznes i Finanse', 'Oszczędzanie', 'Finanse osobiste, budżet domowy, porady'
UNION ALL SELECT 'Biznes i Finanse', 'Marketing', 'Marketing cyfrowy, SEO, social media, reklama'
-- Technologia
INSERT INTO global_topics (parent_id, name, description) VALUES
(4, 'Sztuczna inteligencja', 'AI, machine learning, ChatGPT, automatyzacja'),
(4, 'Smartfony i gadżety', 'Recenzje, nowości, porównania urządzeń'),
(4, 'Programowanie', 'Języki programowania, frameworki, poradniki'),
(4, 'Cyberbezpieczeństwo', 'Ochrona danych, hakerzy, prywatność w sieci'),
(4, 'Gaming', 'Gry komputerowe, konsole, esport');
UNION ALL SELECT 'Rozrywka', 'Film i seriale', 'Recenzje, premiery, Netflix, kino'
UNION ALL SELECT 'Rozrywka', 'Muzyka', 'Artyści, festiwale, nowości muzyczne'
UNION ALL SELECT 'Rozrywka', 'Książki', 'Recenzje książek, bestsellery, literatura'
-- Biznes i Finanse
INSERT INTO global_topics (parent_id, name, description) VALUES
(5, 'Inwestycje', 'Giełda, kryptowaluty, nieruchomości, fundusze'),
(5, 'Przedsiębiorczość', 'Zakładanie firmy, startupy, zarządzanie'),
(5, 'Oszczędzanie', 'Finanse osobiste, budżet domowy, porady'),
(5, 'Marketing', 'Marketing cyfrowy, SEO, social media, reklama');
UNION ALL SELECT 'Nauka', 'Kosmos', 'NASA, SpaceX, odkrycia astronomiczne'
UNION ALL SELECT 'Nauka', 'Ekologia', 'Zmiany klimatu, recykling, energia odnawialna'
UNION ALL SELECT 'Nauka', 'Historia', 'Ciekawostki historyczne, ważne wydarzenia'
-- Rozrywka
INSERT INTO global_topics (parent_id, name, description) VALUES
(6, 'Film i seriale', 'Recenzje, premiery, Netflix, kino'),
(6, 'Muzyka', 'Artyści, festiwale, nowości muzyczne'),
(6, 'Książki', 'Recenzje książek, bestsellery, literatura');
UNION ALL SELECT 'Edukacja', 'Rozwój osobisty', 'Produktywność, nawyki, motywacja'
UNION ALL SELECT 'Edukacja', 'Języki obce', 'Nauka angielskiego, techniki zapamiętywania'
-- Nauka
INSERT INTO global_topics (parent_id, name, description) VALUES
(7, 'Kosmos', 'NASA, SpaceX, odkrycia astronomiczne'),
(7, 'Ekologia', 'Zmiany klimatu, recykling, energia odnawialna'),
(7, 'Historia', 'Ciekawostki historyczne, ważne wydarzenia');
UNION ALL SELECT 'Podróże', 'Podróże po Polsce', 'Atrakcje turystyczne, szlaki, regiony'
UNION ALL SELECT 'Podróże', 'Podróże zagraniczne', 'Egzotyczne kierunki, tanie loty, porady'
UNION ALL SELECT 'Podróże', 'Camping i outdoor', 'Turystyka górska, biwakowanie, sprzęt'
-- Edukacja
INSERT INTO global_topics (parent_id, name, description) VALUES
(8, 'Rozwój osobisty', 'Produktywność, nawyki, motywacja'),
(8, 'Języki obce', 'Nauka angielskiego, techniki zapamiętywania');
UNION ALL SELECT 'Motoryzacja', 'Samochody elektryczne', 'Tesla, EV, ładowarki, przyszłość elektromobilności'
UNION ALL SELECT 'Motoryzacja', 'Testy i recenzje aut', 'Porównania, testy drogowe, nowości'
UNION ALL SELECT 'Motoryzacja', 'Porady kierowców', 'Ubezpieczenia, prawo jazdy, serwis'
-- Podróże
INSERT INTO global_topics (parent_id, name, description) VALUES
(9, 'Podróże po Polsce', 'Atrakcje turystyczne, szlaki, regiony'),
(9, 'Podróże zagraniczne', 'Egzotyczne kierunki, tanie loty, porady'),
(9, 'Camping i outdoor', 'Turystyka górska, biwakowanie, sprzęt');
UNION ALL SELECT 'Dom i Ogród', 'Aranżacja wnętrz', 'Design, meble, dekoracje, trendy'
UNION ALL SELECT 'Dom i Ogród', 'Ogrodnictwo', 'Rośliny, uprawy, pielęgnacja ogrodu'
UNION ALL SELECT 'Dom i Ogród', 'Remonty i DIY', 'Majsterkowanie, poradniki remontowe'
-- Motoryzacja
INSERT INTO global_topics (parent_id, name, description) VALUES
(10, 'Samochody elektryczne', 'Tesla, EV, ładowarki, przyszłość elektromobilności'),
(10, 'Testy i recenzje aut', 'Porównania, testy drogowe, nowości'),
(10, 'Porady kierowców', 'Ubezpieczenia, prawo jazdy, serwis');
UNION ALL SELECT 'Kuchnia', 'Przepisy', 'Obiady, desery, dania na szybko'
UNION ALL SELECT 'Kuchnia', 'Diety', 'Keto, weganizm, intermittent fasting'
UNION ALL SELECT 'Kuchnia', 'Kuchnie świata', 'Włoska, azjatycka, meksykańska'
-- Dom i Ogród
INSERT INTO global_topics (parent_id, name, description) VALUES
(11, 'Aranżacja wnętrz', 'Design, meble, dekoracje, trendy'),
(11, 'Ogrodnictwo', 'Rośliny, uprawy, pielęgnacja ogrodu'),
(11, 'Remonty i DIY', 'Majsterkowanie, poradniki remontowe');
UNION ALL SELECT 'Moda i Uroda', 'Moda damska', 'Trendy, stylizacje, inspiracje'
UNION ALL SELECT 'Moda i Uroda', 'Moda męska', 'Garnitury, casual, street style'
UNION ALL SELECT 'Moda i Uroda', 'Pielęgnacja', 'Kosmetyki, zabiegi, skincare'
-- Kuchnia
INSERT INTO global_topics (parent_id, name, description) VALUES
(12, 'Przepisy', 'Obiady, desery, dania na szybko'),
(12, 'Diety', 'Keto, weganizm, intermittent fasting'),
(12, 'Kuchnie świata', 'Włoska, azjatycka, meksykańska');
-- Moda i Uroda
INSERT INTO global_topics (parent_id, name, description) VALUES
(13, 'Moda damska', 'Trendy, stylizacje, inspiracje'),
(13, 'Moda męska', 'Garnitury, casual, street style'),
(13, 'Pielęgnacja', 'Kosmetyki, zabiegi, skincare');
-- Prawo
INSERT INTO global_topics (parent_id, name, description) VALUES
(14, 'Prawo pracy', 'Umowy, zwolnienia, prawa pracownika'),
(14, 'Prawo cywilne', 'Umowy, spadki, nieruchomości'),
(14, 'Prawo gospodarcze', 'Działalność gospodarcza, podatki, ZUS');
UNION ALL SELECT 'Prawo', 'Prawo pracy', 'Umowy, zwolnienia, prawa pracownika'
UNION ALL SELECT 'Prawo', 'Prawo cywilne', 'Umowy, spadki, nieruchomości'
UNION ALL SELECT 'Prawo', 'Prawo gospodarcze', 'Działalność gospodarcza, podatki, ZUS'
) AS seed
INNER JOIN (
SELECT name, MIN(id) AS id
FROM global_topics
WHERE parent_id IS NULL
GROUP BY name
) AS parent ON parent.name = seed.parent_name
WHERE NOT EXISTS (
SELECT 1
FROM global_topics gt
WHERE gt.parent_id = parent.id
AND gt.name = seed.name
);

View File

@@ -0,0 +1,18 @@
-- Site credentials (FTP, database, WP admin)
-- Saved during remote WordPress installation for future reference
ALTER TABLE sites ADD COLUMN ftp_host VARCHAR(255) NULL AFTER is_multisite;
ALTER TABLE sites ADD COLUMN ftp_port INT NULL DEFAULT 21 AFTER ftp_host;
ALTER TABLE sites ADD COLUMN ftp_user VARCHAR(255) NULL AFTER ftp_port;
ALTER TABLE sites ADD COLUMN ftp_pass VARCHAR(255) NULL AFTER ftp_user;
ALTER TABLE sites ADD COLUMN ftp_path VARCHAR(255) NULL AFTER ftp_pass;
ALTER TABLE sites ADD COLUMN db_host VARCHAR(255) NULL AFTER ftp_path;
ALTER TABLE sites ADD COLUMN db_name VARCHAR(255) NULL AFTER db_host;
ALTER TABLE sites ADD COLUMN db_user VARCHAR(255) NULL AFTER db_name;
ALTER TABLE sites ADD COLUMN db_pass VARCHAR(255) NULL AFTER db_user;
ALTER TABLE sites ADD COLUMN db_prefix VARCHAR(50) NULL DEFAULT 'wp_' AFTER db_pass;
ALTER TABLE sites ADD COLUMN wp_admin_user VARCHAR(255) NULL AFTER db_prefix;
ALTER TABLE sites ADD COLUMN wp_admin_pass VARCHAR(255) NULL AFTER wp_admin_user;
ALTER TABLE sites ADD COLUMN wp_admin_email VARCHAR(255) NULL AFTER wp_admin_pass;

View File

@@ -0,0 +1,5 @@
-- Add prompt templates for article and image generation
INSERT INTO settings (`key`, value) VALUES
('article_generation_prompt', 'Jesteś doświadczonym copywriterem SEO. Pisz artykuły w języku polskim, optymalizowane pod SEO. Artykuł powinien mieć {min_words}-{max_words} słów, zawierać nagłówki H2 i H3, być angażujący i merytoryczny. Formatuj treść w HTML (bez tagów <html>, <body>, <head>). Zwróć odpowiedź WYŁĄCZNIE w formacie JSON: {"title": "tytuł artykułu", "content": "treść HTML artykułu"}'),
('image_generation_prompt', 'Professional blog header image about {topic_name}: {article_title}, high quality, photorealistic')
ON DUPLICATE KEY UPDATE value = value;

View File

@@ -0,0 +1,21 @@
-- Publish interval migration: days -> hours
ALTER TABLE sites ADD COLUMN publish_interval_hours INT NOT NULL DEFAULT 24 AFTER api_token;
SET @has_old_interval_days := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sites'
AND COLUMN_NAME = 'publish_interval_days'
);
SET @backpro_sql := IF(
@has_old_interval_days > 0,
'UPDATE sites SET publish_interval_hours = CASE WHEN publish_interval_days IS NULL OR publish_interval_days < 1 THEN 24 ELSE publish_interval_days * 24 END',
'SELECT 1'
);
PREPARE backpro_stmt FROM @backpro_sql;
EXECUTE backpro_stmt;
DEALLOCATE PREPARE backpro_stmt;

View File

@@ -5,6 +5,8 @@ namespace App\Controllers;
use App\Core\Auth;
use App\Core\Controller;
use App\Models\Article;
use App\Services\WordPressService;
use App\Helpers\Logger;
class ArticleController extends Controller
{
@@ -41,4 +43,77 @@ class ArticleController extends Controller
$this->view('articles/show', ['article' => $article]);
}
public function replaceImage(string $id): void
{
Auth::requireLogin();
$article = Article::findWithRelations((int) $id);
if (!$article) {
$this->json(['success' => false, 'message' => 'Artykuł nie znaleziony.']);
return;
}
if (empty($article['wp_post_id'])) {
$this->json(['success' => false, 'message' => 'Artykuł nie jest opublikowany w WordPress.']);
return;
}
$site = [
'url' => $article['site_url'],
'api_user' => $article['site_api_user'],
'api_token' => $article['site_api_token'],
];
// Validate uploaded file
if (empty($_FILES['image']) || $_FILES['image']['error'] !== UPLOAD_ERR_OK) {
$this->json(['success' => false, 'message' => 'Nie przesłano pliku lub wystąpił błąd uploadu.']);
return;
}
$file = $_FILES['image'];
$allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mimeType, $allowedTypes)) {
$this->json(['success' => false, 'message' => 'Niedozwolony format pliku. Dozwolone: JPG, PNG, GIF, WebP.']);
return;
}
$imageData = file_get_contents($file['tmp_name']);
$filename = $file['name'] ?: ('article-' . time() . '.jpg');
$wp = new WordPressService();
// 1. Delete old featured image from WP
$oldMediaId = $wp->getPostFeaturedMedia($site, $article['wp_post_id']);
if ($oldMediaId) {
$wp->deleteMedia($site, $oldMediaId);
Logger::info("Deleted old media ID: {$oldMediaId} for article {$id}", 'image');
}
// 2. Upload new image to WP
$newMediaId = $wp->uploadMedia($site, $imageData, $filename);
if (!$newMediaId) {
$this->json(['success' => false, 'message' => 'Nie udało się wgrać zdjęcia do WordPress.']);
return;
}
// 3. Set as featured image on the post
$updated = $wp->updatePostFeaturedMedia($site, $article['wp_post_id'], $newMediaId);
if (!$updated) {
$this->json(['success' => false, 'message' => 'Nie udało się ustawić zdjęcia jako wyróżniającego.']);
return;
}
Logger::info("Replaced image for article {$id}: old={$oldMediaId}, new={$newMediaId}", 'image');
$this->json([
'success' => true,
'message' => 'Zdjęcie zostało podmienione.',
'media_id' => $newMediaId,
]);
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Controllers;
use App\Core\Auth;
use App\Core\Controller;
use App\Models\Site;
use App\Models\Topic;
use App\Services\WordPressService;
class CategoryController extends Controller
@@ -23,9 +24,19 @@ class CategoryController extends Controller
$wp = new WordPressService();
$categories = $wp->getCategories($site);
$topics = Topic::findBySiteWithGlobal((int) $id);
$hasTopicsWithoutCategory = false;
foreach ($topics as $t) {
if (!empty($t['global_topic_id']) && empty($t['wp_category_id'])) {
$hasTopicsWithoutCategory = true;
break;
}
}
$this->view('categories/index', [
'site' => $site,
'categories' => $categories,
'hasTopicsWithoutCategory' => $hasTopicsWithoutCategory,
]);
}
@@ -51,4 +62,157 @@ class CategoryController extends Controller
$this->redirect("/sites/{$id}/categories");
}
public function create(string $id): void
{
Auth::requireLogin();
$site = Site::find((int) $id);
if (!$site) {
$this->json(['success' => false, 'message' => 'Strona nie znaleziona.']);
return;
}
$name = trim($this->input('name', ''));
if ($name === '') {
$this->json(['success' => false, 'message' => 'Nazwa kategorii jest wymagana.']);
return;
}
$parent = (int) $this->input('parent', 0);
$wp = new WordPressService();
$result = $wp->createCategory($site, $name, $parent);
if (!$result || !isset($result['id'])) {
$this->json(['success' => false, 'message' => 'Nie udało się utworzyć kategorii w WordPress.']);
return;
}
$this->json([
'success' => true,
'message' => "Utworzono kategorię \"{$result['name']}\" (ID: {$result['id']})",
'category' => [
'id' => $result['id'],
'name' => $result['name'],
'slug' => $result['slug'],
'parent' => $result['parent'],
],
]);
}
public function createFromTopics(string $id): void
{
Auth::requireLogin();
$site = Site::find((int) $id);
if (!$site) {
$this->json(['success' => false, 'message' => 'Strona nie znaleziona.']);
return;
}
$wp = new WordPressService();
// Get existing WP categories
$existingCategories = $wp->getCategories($site);
if ($existingCategories === false) {
$this->json(['success' => false, 'message' => 'Nie udało się pobrać kategorii z WordPress.']);
return;
}
// Index existing by name (lowercase) for duplicate detection
$existingByName = [];
foreach ($existingCategories as $cat) {
$existingByName[mb_strtolower($cat['name'])] = $cat;
}
// Get site topics with global topic info
$topics = Topic::findBySiteWithGlobal((int) $id);
// Group topics by parent category
$groups = [];
foreach ($topics as $topic) {
if (empty($topic['global_topic_id'])) {
continue; // skip custom topics without global link
}
$parentName = $topic['global_category_name'] ?? null;
$childName = $topic['global_topic_name'] ?? $topic['name'];
if (!$parentName) {
// Topic is linked to a top-level global topic (it IS a category, not a child)
// Create as standalone category
$parentName = $childName;
$childName = null;
}
if (!isset($groups[$parentName])) {
$groups[$parentName] = [];
}
if ($childName) {
$groups[$parentName][] = [
'topic_id' => $topic['id'],
'name' => $childName,
];
}
}
$created = 0;
$skipped = 0;
$assigned = 0;
$errors = [];
foreach ($groups as $parentName => $children) {
// Find or create parent category in WP
$parentKey = mb_strtolower($parentName);
if (isset($existingByName[$parentKey])) {
$parentWpId = $existingByName[$parentKey]['id'];
$skipped++;
} else {
$parentResult = $wp->createCategory($site, $parentName, 0);
if (!$parentResult || !isset($parentResult['id'])) {
$errors[] = "Nie udało się utworzyć kategorii \"{$parentName}\"";
continue;
}
$parentWpId = $parentResult['id'];
$existingByName[$parentKey] = $parentResult;
$created++;
}
// Create child categories
foreach ($children as $child) {
$childKey = mb_strtolower($child['name']);
if (isset($existingByName[$childKey])) {
$childWpId = $existingByName[$childKey]['id'];
$skipped++;
} else {
$childResult = $wp->createCategory($site, $child['name'], $parentWpId);
if (!$childResult || !isset($childResult['id'])) {
$errors[] = "Nie udało się utworzyć podkategorii \"{$child['name']}\"";
continue;
}
$childWpId = $childResult['id'];
$existingByName[$childKey] = $childResult;
$created++;
}
// Assign wp_category_id to the topic
Topic::update($child['topic_id'], ['wp_category_id' => $childWpId]);
$assigned++;
}
}
$message = "Utworzono {$created} kategorii, pominięto {$skipped} istniejących, przypisano {$assigned} tematów.";
if (!empty($errors)) {
$message .= ' Błędy: ' . implode('; ', $errors);
}
$this->json([
'success' => true,
'message' => $message,
'created' => $created,
'skipped' => $skipped,
'assigned' => $assigned,
'errors' => $errors,
]);
}
}

View File

@@ -95,8 +95,16 @@ class GlobalTopicController extends Controller
{
Auth::requireLogin();
$isAjax = !empty($_SERVER['HTTP_X_REQUESTED_WITH'])
&& strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest';
GlobalTopic::delete((int) $id);
if ($isAjax) {
$this->json(['success' => true, 'message' => 'Usunięto.']);
return;
}
$this->flash('success', 'Temat został usunięty.');
$this->redirect('/global-topics');
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Auth;
use App\Core\Controller;
use App\Helpers\Validator;
use App\Services\InstallerService;
class InstallerController extends Controller
{
public function index(): void
{
Auth::requireLogin();
$this->view('installer/index', []);
}
public function install(): void
{
Auth::requireLogin();
$validator = new Validator();
// FTP
$validator
->required('ftp_host', $this->input('ftp_host'), 'Host FTP')
->required('ftp_user', $this->input('ftp_user'), 'Użytkownik FTP')
->required('ftp_pass', $this->input('ftp_pass'), 'Hasło FTP')
->required('ftp_path', $this->input('ftp_path'), 'Ścieżka FTP');
// Database
$validator
->required('db_host', $this->input('db_host'), 'Host bazy danych')
->required('db_name', $this->input('db_name'), 'Nazwa bazy danych')
->required('db_user', $this->input('db_user'), 'Użytkownik bazy danych')
->required('db_pass', $this->input('db_pass'), 'Hasło bazy danych');
// WordPress admin
$validator
->required('site_url', $this->input('site_url'), 'URL strony')
->url('site_url', $this->input('site_url'), 'URL strony')
->required('site_title', $this->input('site_title'), 'Tytuł strony')
->required('admin_user', $this->input('admin_user'), 'Login administratora')
->required('admin_pass', $this->input('admin_pass'), 'Hasło administratora')
->minLength('admin_pass', $this->input('admin_pass'), 8, 'Hasło administratora')
->required('admin_email', $this->input('admin_email'), 'E-mail administratora')
->email('admin_email', $this->input('admin_email'), 'E-mail administratora');
if (!$validator->isValid()) {
$this->json(['success' => false, 'message' => $validator->getFirstError()]);
return;
}
$progressId = $this->input('progress_id', '');
if (empty($progressId) || !preg_match('/^[a-zA-Z0-9]{10,30}$/', $progressId)) {
$this->json(['success' => false, 'message' => 'Nieprawidłowy identyfikator postępu.']);
return;
}
$config = [
'ftp_host' => $this->input('ftp_host'),
'ftp_user' => $this->input('ftp_user'),
'ftp_pass' => $this->input('ftp_pass'),
'ftp_path' => rtrim($this->input('ftp_path'), '/'),
'ftp_port' => (int) ($this->input('ftp_port', '21')),
'ftp_ssl' => (bool) $this->input('ftp_ssl'),
'db_host' => $this->input('db_host'),
'db_name' => $this->input('db_name'),
'db_user' => $this->input('db_user'),
'db_pass' => $this->input('db_pass'),
'db_prefix' => $this->input('db_prefix', 'wp_'),
'site_url' => rtrim($this->input('site_url'), '/'),
'site_title' => $this->input('site_title'),
'admin_user' => $this->input('admin_user'),
'admin_pass' => $this->input('admin_pass'),
'admin_email' => $this->input('admin_email'),
'language' => $this->input('language', 'pl_PL'),
];
$installer = new InstallerService();
$result = $installer->install($config, $progressId);
// Cleanup progress file after completion
InstallerService::cleanupProgress($progressId);
$this->json($result);
}
public function status(string $id): void
{
Auth::requireLogin();
if (!preg_match('/^[a-zA-Z0-9]{10,30}$/', $id)) {
$this->json(['percent' => 0, 'message' => 'Nieprawidłowy ID', 'status' => 'failed']);
return;
}
$progress = InstallerService::getProgress($id);
if ($progress === null) {
$this->json(['percent' => 0, 'message' => 'Oczekiwanie na start...', 'status' => 'waiting']);
return;
}
$this->json($progress);
}
}

View File

@@ -3,12 +3,42 @@
namespace App\Controllers;
use App\Core\Auth;
use App\Core\Config;
use App\Core\Controller;
use App\Helpers\Logger;
use App\Models\Site;
use App\Services\PublisherService;
class PublishController extends Controller
{
public function runByToken(): void
{
$configuredToken = (string) Config::get('PUBLISH_TRIGGER_TOKEN', '');
$providedToken = (string) $this->input('token', '');
if ($providedToken === '') {
$providedToken = (string) ($_SERVER['HTTP_X_PUBLISH_TOKEN'] ?? '');
}
if ($configuredToken === '') {
Logger::warning('Token publish trigger called, but PUBLISH_TRIGGER_TOKEN is not configured.', 'publish');
$this->json(['success' => false, 'message' => 'Token trigger is disabled.'], 503);
return;
}
if ($providedToken === '' || !hash_equals($configuredToken, $providedToken)) {
$ip = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
Logger::warning("Invalid publish token attempt from {$ip}", 'publish');
$this->json(['success' => false, 'message' => 'Forbidden'], 403);
return;
}
$publisher = new PublisherService();
$result = $publisher->publishNext();
$this->json($result, 200);
}
public function run(): void
{
Auth::requireLogin();
@@ -29,8 +59,15 @@ class PublishController extends Controller
{
Auth::requireLogin();
$isAjax = !empty($_SERVER['HTTP_X_REQUESTED_WITH'])
&& strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest';
$site = Site::find((int) $id);
if (!$site) {
if ($isAjax) {
$this->json(['success' => false, 'message' => 'Strona nie znaleziona.']);
return;
}
$this->flash('danger', 'Strona nie znaleziona.');
$this->redirect('/sites');
return;
@@ -39,6 +76,11 @@ class PublishController extends Controller
$publisher = new PublisherService();
$result = $publisher->publishForSite($site);
if ($isAjax) {
$this->json($result);
return;
}
if ($result['success']) {
$this->flash('success', $result['message']);
} else {

View File

@@ -5,6 +5,8 @@ namespace App\Controllers;
use App\Core\Auth;
use App\Core\Config;
use App\Core\Controller;
use App\Services\ImageService;
use App\Services\OpenAIService;
class SettingsController extends Controller
{
@@ -17,6 +19,17 @@ class SettingsController extends Controller
'image_provider',
'article_min_words',
'article_max_words',
'article_generation_prompt',
'image_generation_prompt',
];
private array $settingDefaults = [
'openai_model' => 'gpt-4o',
'image_provider' => 'freepik',
'article_min_words' => '800',
'article_max_words' => '1200',
'article_generation_prompt' => OpenAIService::DEFAULT_ARTICLE_PROMPT_TEMPLATE,
'image_generation_prompt' => ImageService::DEFAULT_FREEPIK_PROMPT_TEMPLATE,
];
public function index(): void
@@ -25,7 +38,7 @@ class SettingsController extends Controller
$settings = [];
foreach ($this->settingKeys as $key) {
$settings[$key] = Config::getDbSetting($key, '');
$settings[$key] = Config::getDbSetting($key, $this->settingDefaults[$key] ?? '');
}
$this->view('settings/index', ['settings' => $settings]);

View File

@@ -56,7 +56,7 @@ class SiteController extends Controller
'url' => rtrim($this->input('url'), '/'),
'api_user' => $this->input('api_user'),
'api_token' => $this->input('api_token'),
'publish_interval_days' => (int) ($this->input('publish_interval_days', 3)),
'publish_interval_hours' => (int) ($this->input('publish_interval_hours', 24)),
'is_active' => $this->input('is_active') ? 1 : 0,
'is_multisite' => $this->input('is_multisite') ? 1 : 0,
]);
@@ -128,9 +128,22 @@ class SiteController extends Controller
'url' => rtrim($this->input('url'), '/'),
'api_user' => $this->input('api_user'),
'api_token' => $this->input('api_token'),
'publish_interval_days' => (int) ($this->input('publish_interval_days', 3)),
'publish_interval_hours' => (int) ($this->input('publish_interval_hours', 24)),
'is_active' => $this->input('is_active') ? 1 : 0,
'is_multisite' => $this->input('is_multisite') ? 1 : 0,
'ftp_host' => $this->input('ftp_host') ?: null,
'ftp_port' => $this->input('ftp_port') ? (int) $this->input('ftp_port') : null,
'ftp_user' => $this->input('ftp_user') ?: null,
'ftp_pass' => $this->input('ftp_pass') ?: null,
'ftp_path' => $this->input('ftp_path') ?: null,
'db_host' => $this->input('db_host') ?: null,
'db_name' => $this->input('db_name') ?: null,
'db_user' => $this->input('db_user') ?: null,
'db_pass' => $this->input('db_pass') ?: null,
'db_prefix' => $this->input('db_prefix') ?: null,
'wp_admin_user' => $this->input('wp_admin_user') ?: null,
'wp_admin_pass' => $this->input('wp_admin_pass') ?: null,
'wp_admin_email' => $this->input('wp_admin_email') ?: null,
]);
$this->flash('success', 'Strona została zaktualizowana.');

View File

@@ -81,8 +81,15 @@ class TopicController extends Controller
{
Auth::requireLogin();
$isAjax = !empty($_SERVER['HTTP_X_REQUESTED_WITH'])
&& strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest';
$topic = Topic::find((int) $id);
if (!$topic) {
if ($isAjax) {
$this->json(['success' => false, 'message' => 'Temat nie znaleziony.']);
return;
}
$this->flash('danger', 'Temat nie znaleziony.');
$this->redirect('/sites');
return;
@@ -91,6 +98,11 @@ class TopicController extends Controller
$siteId = $topic['site_id'];
Topic::delete((int) $id);
if ($isAjax) {
$this->json(['success' => true, 'message' => 'Temat został usunięty.']);
return;
}
$this->flash('success', 'Temat został usunięty.');
$this->redirect("/sites/{$siteId}/topics");
}

View File

@@ -38,6 +38,14 @@ class Validator
return $this;
}
public function email(string $field, mixed $value, string $label = ''): self
{
if (!empty($value) && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
$this->errors[$field] = ($label ?: $field) . ' musi być prawidłowym adresem e-mail.';
}
return $this;
}
public function isValid(): bool
{
return empty($this->errors);

View File

@@ -42,7 +42,8 @@ class Article extends Model
public static function findWithRelations(int $id): ?array
{
$stmt = self::db()->prepare(
"SELECT a.*, t.name as topic_name, s.name as site_name, s.url as site_url
"SELECT a.*, t.name as topic_name, s.name as site_name, s.url as site_url,
s.api_user as site_api_user, s.api_token as site_api_token
FROM articles a
JOIN topics t ON a.topic_id = t.id
JOIN sites s ON a.site_id = s.id

View File

@@ -19,7 +19,7 @@ class Site extends Model
WHERE is_active = 1
AND (
last_published_at IS NULL
OR DATE_ADD(last_published_at, INTERVAL publish_interval_days DAY) <= NOW()
OR DATE_ADD(last_published_at, INTERVAL publish_interval_hours HOUR) <= NOW()
)
ORDER BY last_published_at ASC";
$stmt = self::db()->query($sql);

View File

@@ -22,7 +22,7 @@ class Topic extends Model
LEFT JOIN global_topics g ON t.global_topic_id = g.id
LEFT JOIN global_topics gp ON g.parent_id = gp.id
WHERE t.site_id = :site_id
ORDER BY t.name ASC"
ORDER BY COALESCE(gp.name, g.name, t.name) ASC, t.name ASC"
);
$stmt->execute(['site_id' => $siteId]);
return $stmt->fetchAll();

140
src/Services/FtpService.php Normal file
View File

@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Helpers\Logger;
class FtpService
{
private $connection = null;
private string $host;
private string $user;
private string $pass;
private int $port;
private bool $ssl;
private int $uploadedFiles = 0;
private int $totalFiles = 0;
/** @var callable|null */
private $progressCallback = null;
public function __construct(string $host, string $user, string $pass, int $port = 21, bool $ssl = false)
{
$this->host = $host;
$this->user = $user;
$this->pass = $pass;
$this->port = $port;
$this->ssl = $ssl;
}
public function setProgressCallback(callable $callback): void
{
$this->progressCallback = $callback;
}
public function connect(): void
{
if ($this->ssl) {
$this->connection = @ftp_ssl_connect($this->host, $this->port, 30);
} else {
$this->connection = @ftp_connect($this->host, $this->port, 30);
}
if (!$this->connection) {
throw new \RuntimeException("Nie można połączyć z FTP: {$this->host}:{$this->port}");
}
if (!@ftp_login($this->connection, $this->user, $this->pass)) {
throw new \RuntimeException("Logowanie FTP nieudane dla użytkownika: {$this->user}");
}
ftp_pasv($this->connection, true);
Logger::info("FTP connected to {$this->host}", 'installer');
}
public function uploadDirectory(string $localDir, string $remoteDir): void
{
// Count total files before starting (only on first/top-level call)
if ($this->totalFiles === 0) {
$this->totalFiles = $this->countFiles($localDir);
$this->uploadedFiles = 0;
}
$this->ensureDirectory($remoteDir);
$items = scandir($localDir);
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$localPath = $localDir . '/' . $item;
$remotePath = $remoteDir . '/' . $item;
if (is_dir($localPath)) {
$this->uploadDirectory($localPath, $remotePath);
} else {
$this->uploadFile($localPath, $remotePath);
$this->uploadedFiles++;
// Report progress every 50 files to avoid excessive writes
if ($this->progressCallback && $this->uploadedFiles % 50 === 0) {
($this->progressCallback)($this->uploadedFiles, $this->totalFiles);
}
}
}
}
public function uploadFile(string $localPath, string $remotePath): void
{
if (!@ftp_put($this->connection, $remotePath, $localPath, FTP_BINARY)) {
throw new \RuntimeException("FTP upload failed: {$remotePath}");
}
}
public function ensureDirectory(string $path): void
{
$parts = explode('/', trim($path, '/'));
$current = '';
foreach ($parts as $part) {
$current .= '/' . $part;
@ftp_mkdir($this->connection, $current);
}
}
public function disconnect(): void
{
if ($this->connection) {
@ftp_close($this->connection);
$this->connection = null;
Logger::info("FTP disconnected", 'installer');
}
}
public function __destruct()
{
$this->disconnect();
}
private function countFiles(string $dir): int
{
$count = 0;
$items = scandir($dir);
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir . '/' . $item;
if (is_dir($path)) {
$count += $this->countFiles($path);
} else {
$count++;
}
}
return $count;
}
}

View File

@@ -9,6 +9,8 @@ use App\Helpers\Logger;
class ImageService
{
public const DEFAULT_FREEPIK_PROMPT_TEMPLATE = 'Professional blog header image about {topic_name}: {article_title}, high quality, photorealistic';
private Client $client;
public function __construct()
@@ -31,6 +33,11 @@ class ImageService
private function generateFreepik(string $articleTitle, string $topicName): ?array
{
$apiKey = Config::getDbSetting('freepik_api_key', Config::get('FREEPIK_API_KEY'));
$promptTemplate = Config::getDbSetting('image_generation_prompt', self::DEFAULT_FREEPIK_PROMPT_TEMPLATE);
if (!is_string($promptTemplate) || trim($promptTemplate) === '') {
$promptTemplate = self::DEFAULT_FREEPIK_PROMPT_TEMPLATE;
}
if (empty($apiKey)) {
Logger::warning('Freepik API key not configured, falling back to Pexels', 'image');
@@ -38,7 +45,10 @@ class ImageService
}
try {
$prompt = "Professional blog header image about {$topicName}: {$articleTitle}, high quality, photorealistic";
$prompt = strtr($promptTemplate, [
'{topic_name}' => $topicName,
'{article_title}' => $articleTitle,
]);
$response = $this->client->post('https://api.freepik.com/v1/ai/text-to-image', [
'headers' => [

View File

@@ -0,0 +1,442 @@
<?php
declare(strict_types=1);
namespace App\Services;
use GuzzleHttp\Client;
use GuzzleHttp\Cookie\CookieJar;
use GuzzleHttp\Exception\GuzzleException;
use App\Helpers\Logger;
use App\Models\Site;
class InstallerService
{
private Client $http;
private string $tempDir = '';
private string $progressId = '';
public function __construct()
{
$this->http = new Client(['timeout' => 60, 'verify' => false]);
}
private static function progressFilePath(string $id): string
{
return sys_get_temp_dir() . '/backpro_progress_' . $id . '.json';
}
private function updateProgress(int $percent, string $message, string $status = 'in_progress'): void
{
if (empty($this->progressId)) {
return;
}
$data = [
'percent' => min($percent, 100),
'message' => $message,
'status' => $status,
'time' => date('H:i:s'),
];
@file_put_contents(self::progressFilePath($this->progressId), json_encode($data), LOCK_EX);
}
public static function getProgress(string $id): ?array
{
$file = self::progressFilePath($id);
if (!file_exists($file)) {
return null;
}
$data = @file_get_contents($file);
return $data ? json_decode($data, true) : null;
}
public static function cleanupProgress(string $id): void
{
@unlink(self::progressFilePath($id));
}
/**
* @return array{success: bool, message: string, site_id: int|null}
*/
public function install(array $config, string $progressId = ''): array
{
set_time_limit(600);
ini_set('memory_limit', '256M');
$this->progressId = $progressId;
Logger::info("Starting WordPress installation for {$config['site_url']}", 'installer');
$this->updateProgress(5, 'Pobieranie WordPress...');
try {
// Step 1: Download WordPress
$zipPath = $this->downloadWordPress($config['language']);
$this->updateProgress(15, 'Rozpakowywanie archiwum...');
// Step 2: Extract ZIP
$wpSourceDir = $this->extractZip($zipPath);
$this->updateProgress(25, 'Generowanie wp-config.php...');
// Step 3: Generate wp-config.php
$this->generateWpConfig($wpSourceDir, $config);
$this->updateProgress(30, 'Łączenie z serwerem FTP...');
// Step 4: Upload via FTP
$this->uploadViaFtp($wpSourceDir, $config);
$this->updateProgress(85, 'Uruchamianie instalacji WordPress...');
// Step 5: Trigger WordPress installation
$this->triggerInstallation($config);
$this->updateProgress(92, 'Tworzenie Application Password...');
// Step 6: Create Application Password
$appPassword = $this->createApplicationPassword($config);
$this->updateProgress(97, 'Rejestracja strony w BackPRO...');
// Step 7: Register site in BackPRO (with all credentials)
$siteId = Site::create([
'name' => $config['site_title'],
'url' => $config['site_url'],
'api_user' => $config['admin_user'],
'api_token' => $appPassword,
'publish_interval_hours' => 24,
'is_active' => 1,
'is_multisite' => 0,
'ftp_host' => $config['ftp_host'],
'ftp_port' => $config['ftp_port'],
'ftp_user' => $config['ftp_user'],
'ftp_pass' => $config['ftp_pass'],
'ftp_path' => $config['ftp_path'],
'db_host' => $config['db_host'],
'db_name' => $config['db_name'],
'db_user' => $config['db_user'],
'db_pass' => $config['db_pass'],
'db_prefix' => $config['db_prefix'],
'wp_admin_user' => $config['admin_user'],
'wp_admin_pass' => $config['admin_pass'],
'wp_admin_email' => $config['admin_email'],
]);
Logger::info("WordPress installed and site registered (ID: {$siteId})", 'installer');
$this->updateProgress(100, 'Instalacja zakończona pomyślnie!', 'completed');
$this->cleanup();
return [
'success' => true,
'message' => "WordPress zainstalowany pomyślnie! Strona \"{$config['site_title']}\" została dodana do BackPRO.",
'site_id' => $siteId,
];
} catch (\Throwable $e) {
Logger::error("Installation failed: " . $e->getMessage(), 'installer');
$this->updateProgress(0, 'Błąd: ' . $e->getMessage(), 'failed');
$this->cleanup();
return [
'success' => false,
'message' => 'Błąd instalacji: ' . $e->getMessage(),
'site_id' => null,
];
}
}
private function downloadWordPress(string $language): string
{
Logger::info("Downloading WordPress ({$language})", 'installer');
$url = ($language === 'pl_PL')
? 'https://pl.wordpress.org/latest-pl_PL.zip'
: 'https://wordpress.org/latest.zip';
$this->tempDir = sys_get_temp_dir() . '/backpro_wp_' . uniqid();
if (!mkdir($this->tempDir, 0755, true)) {
throw new \RuntimeException('Nie można utworzyć katalogu tymczasowego');
}
$zipPath = $this->tempDir . '/wordpress.zip';
$this->http->get($url, [
'sink' => $zipPath,
'timeout' => 120,
'headers' => [
'User-Agent' => 'BackPRO/1.0 (WordPress Installer)',
],
]);
if (!file_exists($zipPath) || filesize($zipPath) < 1000000) {
throw new \RuntimeException('Pobieranie WordPress nie powiodło się');
}
Logger::info("Downloaded WordPress (" . round(filesize($zipPath) / 1048576, 1) . " MB)", 'installer');
return $zipPath;
}
private function extractZip(string $zipPath): string
{
Logger::info("Extracting WordPress ZIP", 'installer');
$zip = new \ZipArchive();
$result = $zip->open($zipPath);
if ($result !== true) {
throw new \RuntimeException("Nie można otworzyć pliku ZIP (kod błędu: {$result})");
}
$extractDir = $this->tempDir . '/extracted';
$zip->extractTo($extractDir);
$zip->close();
$wpDir = $extractDir . '/wordpress';
if (!is_dir($wpDir)) {
throw new \RuntimeException('Rozpakowany archiwum nie zawiera katalogu wordpress/');
}
@unlink($zipPath);
Logger::info("Extracted to {$wpDir}", 'installer');
return $wpDir;
}
private function generateWpConfig(string $wpDir, array $config): void
{
Logger::info("Generating wp-config.php", 'installer');
$salts = $this->fetchSalts();
$dbHost = addcslashes($config['db_host'], "'\\");
$dbName = addcslashes($config['db_name'], "'\\");
$dbUser = addcslashes($config['db_user'], "'\\");
$dbPass = addcslashes($config['db_pass'], "'\\");
$dbPrefix = addcslashes($config['db_prefix'], "'\\");
$wpConfig = <<<PHP
<?php
/**
* WordPress configuration - generated by BackPRO Installer
*/
// Database settings
define( 'DB_NAME', '{$dbName}' );
define( 'DB_USER', '{$dbUser}' );
define( 'DB_PASSWORD', '{$dbPass}' );
define( 'DB_HOST', '{$dbHost}' );
define( 'DB_CHARSET', 'utf8mb4' );
define( 'DB_COLLATE', '' );
// Authentication unique keys and salts
{$salts}
// Table prefix
\$table_prefix = '{$dbPrefix}';
// Debug mode
define( 'WP_DEBUG', false );
// Absolute path to the WordPress directory
if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', __DIR__ . '/' );
}
// Load WordPress
require_once ABSPATH . 'wp-settings.php';
PHP;
$configPath = $wpDir . '/wp-config.php';
if (file_put_contents($configPath, $wpConfig) === false) {
throw new \RuntimeException('Nie można zapisać wp-config.php');
}
Logger::info("wp-config.php generated", 'installer');
}
private function fetchSalts(): string
{
try {
$response = $this->http->get('https://api.wordpress.org/secret-key/1.1/salt/', ['timeout' => 10]);
$salts = $response->getBody()->getContents();
if (str_contains($salts, 'define(')) {
return $salts;
}
} catch (GuzzleException $e) {
Logger::warning("Cannot fetch salts from API, generating locally", 'installer');
}
$keys = [
'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY',
'AUTH_SALT', 'SECURE_AUTH_SALT', 'LOGGED_IN_SALT', 'NONCE_SALT',
];
$lines = [];
foreach ($keys as $key) {
$salt = bin2hex(random_bytes(32));
$lines[] = "define( '{$key}', '{$salt}' );";
}
return implode("\n", $lines);
}
private function uploadViaFtp(string $wpDir, array $config): void
{
Logger::info("Starting FTP upload to {$config['ftp_host']}:{$config['ftp_path']}", 'installer');
$ftp = new FtpService(
$config['ftp_host'],
$config['ftp_user'],
$config['ftp_pass'],
$config['ftp_port'],
$config['ftp_ssl']
);
// FTP progress callback: maps file count to 35-85% range
$ftp->setProgressCallback(function (int $uploaded, int $total) {
$ftpPercent = ($total > 0) ? ($uploaded / $total) : 0;
$percent = 35 + (int) ($ftpPercent * 50); // 35% to 85%
$this->updateProgress($percent, "Wgrywanie plików FTP... ({$uploaded}/{$total})");
});
try {
$ftp->connect();
$this->updateProgress(35, 'Wgrywanie plików na serwer FTP...');
$ftp->uploadDirectory($wpDir, $config['ftp_path']);
Logger::info("FTP upload completed", 'installer');
} finally {
$ftp->disconnect();
}
}
private function triggerInstallation(array $config): void
{
Logger::info("Triggering WordPress installation at {$config['site_url']}", 'installer');
$installUrl = $config['site_url'] . '/wp-admin/install.php?step=2';
try {
$response = $this->http->post($installUrl, [
'form_params' => [
'weblog_title' => $config['site_title'],
'user_name' => $config['admin_user'],
'admin_password' => $config['admin_pass'],
'admin_password2' => $config['admin_pass'],
'admin_email' => $config['admin_email'],
'blog_public' => 0,
],
'timeout' => 60,
'allow_redirects' => true,
]);
$body = $response->getBody()->getContents();
if (
str_contains($body, 'wp-login.php') ||
str_contains($body, 'Success') ||
str_contains($body, 'Udane') ||
str_contains($body, 'install-success')
) {
Logger::info("WordPress installation triggered successfully", 'installer');
return;
}
Logger::warning("WordPress install response unclear, HTTP " . $response->getStatusCode(), 'installer');
} catch (GuzzleException $e) {
throw new \RuntimeException("Instalacja WordPress nie powiodła się: " . $e->getMessage());
}
}
private function createApplicationPassword(array $config): string
{
Logger::info("Creating Application Password via WP cookie auth", 'installer');
sleep(3);
$jar = new CookieJar();
$siteUrl = $config['site_url'];
try {
// Step 1: Login via wp-login.php to get auth cookies
Logger::info("Logging in to WordPress admin", 'installer');
$this->http->post($siteUrl . '/wp-login.php', [
'form_params' => [
'log' => $config['admin_user'],
'pwd' => $config['admin_pass'],
'wp-submit' => 'Log In',
'redirect_to' => $siteUrl . '/wp-admin/',
'testcookie' => '1',
],
'cookies' => $jar,
'allow_redirects' => true,
'timeout' => 30,
'headers' => ['User-Agent' => 'BackPRO/1.0'],
]);
// Step 2: Get REST API nonce via admin-ajax
Logger::info("Fetching REST API nonce", 'installer');
$nonceResponse = $this->http->get($siteUrl . '/wp-admin/admin-ajax.php?action=rest-nonce', [
'cookies' => $jar,
'timeout' => 15,
'headers' => ['User-Agent' => 'BackPRO/1.0'],
]);
$nonce = trim($nonceResponse->getBody()->getContents());
if (empty($nonce) || $nonce === '0' || $nonce === '-1') {
throw new \RuntimeException('Nie udało się pobrać nonce REST API (logowanie nieudane?)');
}
Logger::info("Got REST nonce, creating Application Password", 'installer');
// Step 3: Create Application Password with cookie auth + nonce
$apiUrl = $siteUrl . '/?rest_route=/wp/v2/users/me/application-passwords';
$response = $this->http->post($apiUrl, [
'cookies' => $jar,
'headers' => [
'X-WP-Nonce' => $nonce,
'User-Agent' => 'BackPRO/1.0',
],
'json' => [
'name' => 'BackPRO ' . date('Y-m-d H:i'),
],
'timeout' => 30,
]);
$data = json_decode($response->getBody()->getContents(), true);
if (!isset($data['password'])) {
throw new \RuntimeException('Odpowiedź API nie zawiera hasła aplikacji');
}
Logger::info("Application Password created successfully", 'installer');
return $data['password'];
} catch (GuzzleException $e) {
throw new \RuntimeException("Nie można utworzyć Application Password: " . $e->getMessage());
}
}
private function cleanup(): void
{
if (!empty($this->tempDir) && is_dir($this->tempDir)) {
$this->deleteDirectory($this->tempDir);
Logger::info("Cleaned up temp directory", 'installer');
}
}
private function deleteDirectory(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$items = scandir($dir);
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir . '/' . $item;
if (is_dir($path)) {
$this->deleteDirectory($path);
} else {
@unlink($path);
}
}
@rmdir($dir);
}
}

View File

@@ -9,6 +9,8 @@ use App\Helpers\Logger;
class OpenAIService
{
public const DEFAULT_ARTICLE_PROMPT_TEMPLATE = 'Jesteś doświadczonym copywriterem SEO. Pisz artykuły w języku polskim, optymalizowane pod SEO. Artykuł powinien mieć {min_words}-{max_words} słów, zawierać nagłówki H2 i H3, być angażujący i merytoryczny. Formatuj treść w HTML (bez tagów <html>, <body>, <head>). Zwróć odpowiedź WYŁĄCZNIE w formacie JSON: {"title": "tytuł artykułu", "content": "treść HTML artykułu"}';
private Client $client;
public function __construct()
@@ -25,6 +27,11 @@ class OpenAIService
$model = Config::getDbSetting('openai_model', Config::get('OPENAI_MODEL', 'gpt-4o'));
$minWords = Config::getDbSetting('article_min_words', '800');
$maxWords = Config::getDbSetting('article_max_words', '1200');
$systemPromptTemplate = Config::getDbSetting('article_generation_prompt', self::DEFAULT_ARTICLE_PROMPT_TEMPLATE);
if (!is_string($systemPromptTemplate) || trim($systemPromptTemplate) === '') {
$systemPromptTemplate = self::DEFAULT_ARTICLE_PROMPT_TEMPLATE;
}
if (empty($apiKey)) {
Logger::error('OpenAI API key not configured', 'openai');
@@ -35,11 +42,10 @@ class OpenAIService
? implode("\n- ", $existingTitles)
: '(brak - to pierwszy artykuł z tego tematu)';
$systemPrompt = "Jesteś doświadczonym copywriterem SEO. Pisz artykuły w języku polskim, "
. "optymalizowane pod SEO. Artykuł powinien mieć {$minWords}-{$maxWords} słów, "
. "zawierać nagłówki H2 i H3, być angażujący i merytoryczny. "
. "Formatuj treść w HTML (bez tagów <html>, <body>, <head>). "
. "Zwróć odpowiedź WYŁĄCZNIE w formacie JSON: {\"title\": \"tytuł artykułu\", \"content\": \"treść HTML artykułu\"}";
$systemPrompt = strtr($systemPromptTemplate, [
'{min_words}' => (string) $minWords,
'{max_words}' => (string) $maxWords,
]);
$userPrompt = "Napisz artykuł na temat: {$topicName}\n";
if (!empty($topicDescription)) {

View File

@@ -49,6 +49,24 @@ class WordPressService
}
}
public function createCategory(array $site, string $name, int $parent = 0): ?array
{
try {
$response = $this->client->post($site['url'] . '/wp-json/wp/v2/categories', [
'auth' => [$site['api_user'], $site['api_token']],
'json' => [
'name' => $name,
'parent' => $parent,
],
]);
return json_decode($response->getBody()->getContents(), true);
} catch (GuzzleException $e) {
Logger::error("WP createCategory failed for {$site['url']}: " . $e->getMessage(), 'wordpress');
return null;
}
}
public function uploadMedia(array $site, string $imageData, string $filename): ?int
{
try {
@@ -104,6 +122,51 @@ class WordPressService
}
}
public function getPostFeaturedMedia(array $site, int $wpPostId): ?int
{
try {
$response = $this->client->get($site['url'] . '/wp-json/wp/v2/posts/' . $wpPostId, [
'auth' => [$site['api_user'], $site['api_token']],
'query' => ['_fields' => 'featured_media'],
]);
$data = json_decode($response->getBody()->getContents(), true);
$mediaId = $data['featured_media'] ?? 0;
return $mediaId > 0 ? $mediaId : null;
} catch (GuzzleException $e) {
Logger::error("WP getPostFeaturedMedia failed: " . $e->getMessage(), 'wordpress');
return null;
}
}
public function updatePostFeaturedMedia(array $site, int $wpPostId, int $mediaId): bool
{
try {
$this->client->post($site['url'] . '/wp-json/wp/v2/posts/' . $wpPostId, [
'auth' => [$site['api_user'], $site['api_token']],
'json' => ['featured_media' => $mediaId],
]);
return true;
} catch (GuzzleException $e) {
Logger::error("WP updatePostFeaturedMedia failed: " . $e->getMessage(), 'wordpress');
return false;
}
}
public function deleteMedia(array $site, int $mediaId): bool
{
try {
$this->client->delete($site['url'] . '/wp-json/wp/v2/media/' . $mediaId, [
'auth' => [$site['api_user'], $site['api_token']],
'query' => ['force' => true],
]);
return true;
} catch (GuzzleException $e) {
Logger::error("WP deleteMedia failed: " . $e->getMessage(), 'wordpress');
return false;
}
}
private function getMimeType(string $filename): string
{
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));

View File

@@ -23,10 +23,18 @@
<?php endif; ?>
<?php if ($article['wp_post_id'] && !empty($article['site_url'])): ?>
<div class="alert alert-info">
<i class="bi bi-wordpress me-1"></i>
Post WordPress ID: <strong><?= $article['wp_post_id'] ?></strong>
| <a href="<?= htmlspecialchars($article['site_url']) ?>/?p=<?= $article['wp_post_id'] ?>" target="_blank">Zobacz na stronie</a>
<div class="alert alert-info d-flex justify-content-between align-items-center">
<div>
<i class="bi bi-wordpress me-1"></i>
Post WordPress ID: <strong><?= $article['wp_post_id'] ?></strong>
| <a href="<?= htmlspecialchars($article['site_url']) ?>/?p=<?= $article['wp_post_id'] ?>" target="_blank">Zobacz na stronie</a>
</div>
<div class="d-flex align-items-center gap-2">
<input type="file" id="imageFile" accept="image/jpeg,image/png,image/gif,image/webp" class="form-control form-control-sm" style="max-width: 250px;">
<button class="btn btn-sm btn-outline-warning" id="btnReplaceImage" onclick="replaceImage()" disabled>
<i class="bi bi-upload me-1"></i>Podmień
</button>
</div>
</div>
<?php endif; ?>
@@ -49,3 +57,55 @@
</div>
</div>
<?php endif; ?>
<?php if ($article['wp_post_id']): ?>
<script>
document.getElementById('imageFile').addEventListener('change', function() {
document.getElementById('btnReplaceImage').disabled = !this.files.length;
});
function replaceImage() {
var fileInput = document.getElementById('imageFile');
if (!fileInput.files.length) return;
if (!confirm('Podmienić zdjęcie wyróżniające na WordPressie?\n\nStare zdjęcie zostanie usunięte.')) return;
var btn = document.getElementById('btnReplaceImage');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Wgrywanie...';
var formData = new FormData();
formData.append('image', fileInput.files[0]);
fetch('/articles/<?= $article['id'] ?>/replace-image', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: formData
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
btn.classList.remove('btn-outline-warning');
btn.classList.add('btn-outline-success');
btn.innerHTML = '<i class="bi bi-check-lg me-1"></i>Podmieniono!';
fileInput.value = '';
setTimeout(function() {
btn.classList.remove('btn-outline-success');
btn.classList.add('btn-outline-warning');
btn.innerHTML = '<i class="bi bi-upload me-1"></i>Podmień';
btn.disabled = true;
}, 3000);
} else {
alert(data.message || 'Błąd podmiany zdjęcia');
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-upload me-1"></i>Podmień';
}
})
.catch(function() {
alert('Błąd połączenia');
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-upload me-1"></i>Podmień';
});
}
</script>
<?php endif; ?>

View File

@@ -1,45 +1,285 @@
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2>Kategorie WordPress: <?= htmlspecialchars($site['name']) ?></h2>
<a href="/sites" class="text-muted small">&larr; Powrót do stron</a>
<a href="/sites/<?= $site['id'] ?>/edit" class="text-muted small">&larr; Powrót do strony</a>
</div>
<div class="d-flex gap-2">
<?php if ($hasTopicsWithoutCategory): ?>
<button class="btn btn-success" id="btnFromTopics" onclick="createFromTopics()">
<i class="bi bi-magic me-1"></i>Utwórz kategorie z tematyk
</button>
<?php endif; ?>
<form method="post" action="/sites/<?= $site['id'] ?>/categories/sync">
<button type="submit" class="btn btn-outline-primary">
<i class="bi bi-arrow-repeat me-1"></i>Synchronizuj
</button>
</form>
</div>
<form method="post" action="/sites/<?= $site['id'] ?>/categories/sync">
<button type="submit" class="btn btn-primary">
<i class="bi bi-arrow-repeat me-1"></i>Synchronizuj z WordPress
</button>
</form>
</div>
<div class="card" style="max-width: 700px;">
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>ID</th>
<th>Nazwa</th>
<th>Slug</th>
<th>Ilość postów</th>
</tr>
</thead>
<tbody>
<?php if (empty($categories) || $categories === false): ?>
<tr><td colspan="4" class="text-center text-muted py-4">Kliknij "Synchronizuj" aby pobrać kategorie z WordPressa</td></tr>
<?php else: ?>
<?php foreach ($categories as $cat): ?>
<div class="row g-4">
<div class="col-lg-8">
<!-- Alert for auto-create results -->
<div class="alert alert-success d-none" id="resultAlert">
<i class="bi bi-check-circle me-1"></i><span id="resultMessage"></span>
</div>
<div class="card">
<div class="card-body p-0">
<table class="table table-hover mb-0" id="categoriesTable">
<thead>
<tr>
<td><code><?= $cat['id'] ?></code></td>
<td><?= htmlspecialchars($cat['name']) ?></td>
<td class="text-muted"><?= htmlspecialchars($cat['slug']) ?></td>
<td><?= $cat['count'] ?? 0 ?></td>
<th>ID</th>
<th>Nazwa</th>
<th>Slug</th>
<th>Parent</th>
<th>Posty</th>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</thead>
<tbody>
<?php if (empty($categories) || $categories === false): ?>
<tr id="emptyRow"><td colspan="5" class="text-center text-muted py-4">Kliknij "Synchronizuj" aby pobrać kategorie z WordPressa</td></tr>
<?php else: ?>
<?php
// Sort: parents first (alphabetically), then children under their parent
$catNames = [];
$parents = [];
$children = [];
foreach ($categories as $cat) {
$catNames[$cat['id']] = $cat['name'];
if (empty($cat['parent'])) {
$parents[$cat['id']] = $cat;
} else {
$children[$cat['parent']][] = $cat;
}
}
// Sort parents by name
uasort($parents, fn($a, $b) => strcasecmp($a['name'], $b['name']));
// Sort children by name within each group
foreach ($children as &$group) {
usort($group, fn($a, $b) => strcasecmp($a['name'], $b['name']));
}
unset($group);
// Build ordered list: parent -> its children -> next parent...
$sorted = [];
foreach ($parents as $p) {
$sorted[] = $p;
if (isset($children[$p['id']])) {
foreach ($children[$p['id']] as $c) {
$sorted[] = $c;
}
}
}
// Orphan children (parent not in list)
foreach ($children as $pid => $group) {
if (!isset($parents[$pid])) {
foreach ($group as $c) {
$sorted[] = $c;
}
}
}
?>
<?php foreach ($sorted as $cat): ?>
<tr>
<td><code><?= $cat['id'] ?></code></td>
<td>
<?php if (!empty($cat['parent'])): ?>
<span class="text-muted ms-3">└ </span>
<?php else: ?>
<strong>
<?php endif; ?>
<?= htmlspecialchars($cat['name']) ?>
<?php if (empty($cat['parent'])): ?>
</strong>
<?php endif; ?>
</td>
<td class="text-muted small"><?= htmlspecialchars($cat['slug']) ?></td>
<td class="text-muted small"><?= !empty($cat['parent']) ? htmlspecialchars($catNames[$cat['parent']] ?? $cat['parent']) : '-' ?></td>
<td><?= $cat['count'] ?? 0 ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<div class="alert alert-info mt-3">
<i class="bi bi-info-circle me-1"></i>
Użyj <strong>ID kategorii</strong> przy konfiguracji tematów, aby artykuły trafiały do odpowiednich kategorii w WordPressie.
</div>
</div>
<div class="col-lg-4">
<!-- Create single category -->
<div class="card">
<div class="card-header"><h5 class="mb-0">Utwórz kategorię</h5></div>
<div class="card-body">
<form id="createCategoryForm" onsubmit="return createCategory(event)">
<div class="mb-3">
<label for="cat_name" class="form-label">Nazwa</label>
<input type="text" class="form-control" id="cat_name" name="name" required placeholder="np. Technologia">
</div>
<div class="mb-3">
<label for="cat_parent" class="form-label">Kategoria nadrzędna</label>
<select class="form-select" id="cat_parent" name="parent">
<option value="0">-- brak (główna) --</option>
<?php if (!empty($categories) && $categories !== false): ?>
<?php foreach ($categories as $cat): ?>
<option value="<?= $cat['id'] ?>"><?= htmlspecialchars($cat['name']) ?></option>
<?php endforeach; ?>
<?php endif; ?>
</select>
</div>
<button type="submit" class="btn btn-primary w-100" id="btnCreateCategory">
<i class="bi bi-plus-lg me-1"></i>Utwórz w WordPress
</button>
</form>
<div class="alert alert-success d-none mt-3 mb-0 py-2" id="createResult"></div>
<div class="alert alert-danger d-none mt-3 mb-0 py-2" id="createError"></div>
</div>
</div>
<?php if ($hasTopicsWithoutCategory): ?>
<!-- Auto-create info -->
<div class="card mt-3">
<div class="card-header"><h5 class="mb-0">Auto-tworzenie z tematyk</h5></div>
<div class="card-body">
<p class="small text-muted mb-2">
Automatycznie utworzy kategorie w WordPress na podstawie tematyk przypisanych do tej strony.
Struktura parent/child zostanie odtworzona z biblioteki tematów.
</p>
<p class="small text-muted mb-0">
Istniejące kategorie (o tej samej nazwie) nie będą duplikowane.
Po utworzeniu, <code>wp_category_id</code> zostanie przypisane do odpowiednich tematów.
</p>
</div>
</div>
<?php endif; ?>
</div>
</div>
<div class="alert alert-info mt-3" style="max-width: 700px;">
<i class="bi bi-info-circle me-1"></i>
Użyj <strong>ID kategorii</strong> przy konfiguracji tematów, aby artykuły trafiały do odpowiednich kategorii w WordPressie.
</div>
<script>
var siteId = <?= $site['id'] ?>;
function createCategory(e) {
e.preventDefault();
var form = document.getElementById('createCategoryForm');
var btn = document.getElementById('btnCreateCategory');
var resultEl = document.getElementById('createResult');
var errorEl = document.getElementById('createError');
resultEl.classList.add('d-none');
errorEl.classList.add('d-none');
var name = form.name.value.trim();
if (!name) return false;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Tworzenie...';
var data = new FormData(form);
fetch('/sites/' + siteId + '/categories/create', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: data
})
.then(function(r) { return r.json(); })
.then(function(data) {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-plus-lg me-1"></i>Utwórz w WordPress';
if (data.success) {
resultEl.textContent = data.message;
resultEl.classList.remove('d-none');
form.name.value = '';
// Add row to table
var cat = data.category;
var emptyRow = document.getElementById('emptyRow');
if (emptyRow) emptyRow.remove();
var tbody = document.querySelector('#categoriesTable tbody');
var tr = document.createElement('tr');
tr.style.opacity = '0';
tr.style.transition = 'opacity .3s';
var parentText = cat.parent ? (document.querySelector('#cat_parent option[value="' + cat.parent + '"]')?.textContent || cat.parent) : '-';
tr.innerHTML = '<td><code>' + cat.id + '</code></td>'
+ '<td>' + (cat.parent ? '<span class="text-muted">└ </span>' : '') + escapeHtml(cat.name) + '</td>'
+ '<td class="text-muted small">' + escapeHtml(cat.slug) + '</td>'
+ '<td class="text-muted small">' + escapeHtml(parentText.trim()) + '</td>'
+ '<td>0</td>';
tbody.appendChild(tr);
setTimeout(function() { tr.style.opacity = '1'; }, 10);
// Add to parent select
var opt = document.createElement('option');
opt.value = cat.id;
opt.textContent = cat.name;
document.getElementById('cat_parent').appendChild(opt);
} else {
errorEl.textContent = data.message;
errorEl.classList.remove('d-none');
}
})
.catch(function() {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-plus-lg me-1"></i>Utwórz w WordPress';
errorEl.textContent = 'Błąd połączenia';
errorEl.classList.remove('d-none');
});
return false;
}
function createFromTopics() {
if (!confirm('Utworzyć kategorie w WordPress na podstawie przypisanych tematyk?\n\nIstniejące kategorie nie będą duplikowane.')) return;
var btn = document.getElementById('btnFromTopics');
var resultAlert = document.getElementById('resultAlert');
var resultMessage = document.getElementById('resultMessage');
resultAlert.classList.add('d-none');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Tworzenie kategorii...';
fetch('/sites/' + siteId + '/categories/from-topics', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(r) { return r.json(); })
.then(function(data) {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-magic me-1"></i>Utwórz kategorie z tematyk';
if (data.success) {
resultMessage.textContent = data.message;
resultAlert.classList.remove('d-none');
if (data.errors && data.errors.length > 0) {
resultAlert.classList.remove('alert-success');
resultAlert.classList.add('alert-warning');
}
// Reload page after short delay to refresh table
setTimeout(function() { location.reload(); }, 1500);
} else {
resultAlert.classList.remove('alert-success');
resultAlert.classList.add('alert-danger');
resultMessage.textContent = data.message;
resultAlert.classList.remove('d-none');
}
})
.catch(function() {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-magic me-1"></i>Utwórz kategorie z tematyk';
alert('Błąd połączenia');
});
}
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
</script>

View File

@@ -49,11 +49,12 @@
<th>Nazwa</th>
<th>Status</th>
<th>Ostatnia publikacja</th>
<th></th>
</tr>
</thead>
<tbody>
<?php if (empty($sites)): ?>
<tr><td colspan="3" class="text-muted text-center py-3">Brak stron. <a href="/sites/create">Dodaj pierwszą</a></td></tr>
<tr><td colspan="4" class="text-muted text-center py-3">Brak stron. <a href="/sites/create">Dodaj pierwszą</a></td></tr>
<?php else: ?>
<?php foreach ($sites as $site): ?>
<tr>
@@ -68,6 +69,13 @@
<td>
<?= $site['last_published_at'] ? date('d.m.Y H:i', strtotime($site['last_published_at'])) : '<span class="text-muted">-</span>' ?>
</td>
<td>
<?php if ($site['is_active']): ?>
<button class="btn btn-sm btn-outline-success py-0 px-2 btn-force-publish" data-site-id="<?= $site['id'] ?>" data-site-name="<?= htmlspecialchars($site['name']) ?>">
<i class="bi bi-play-fill"></i>
</button>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
@@ -118,3 +126,39 @@
</div>
</div>
</div>
<script>
document.querySelectorAll('.btn-force-publish').forEach(function(btn) {
btn.addEventListener('click', function() {
var siteId = this.dataset.siteId;
var siteName = this.dataset.siteName;
if (!confirm('Wymusić publikację artykułu na "' + siteName + '"?\n\nArtykuł zostanie wygenerowany i opublikowany natychmiast.')) return;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
fetch('/publish/site/' + siteId, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
btn.classList.remove('btn-outline-success');
btn.classList.add('btn-success');
btn.innerHTML = '<i class="bi bi-check-lg"></i>';
setTimeout(function() { location.reload(); }, 2000);
} else {
alert(data.message || 'Błąd publikacji');
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-play-fill"></i>';
}
})
.catch(function() {
alert('Błąd połączenia');
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-play-fill"></i>';
});
});
});
</script>

View File

@@ -31,12 +31,9 @@
data-description="<?= htmlspecialchars($cat['description'] ?? '') ?>">
<i class="bi bi-pencil me-1"></i>Edytuj kategorię
</button>
<form method="post" action="/global-topics/<?= $cat['id'] ?>/delete" class="d-inline"
onsubmit="return confirm('Usunąć kategorię i wszystkie jej tematy?')">
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash me-1"></i>Usuń
</button>
</form>
<button class="btn btn-sm btn-outline-danger btn-delete-global" data-id="<?= $cat['id'] ?>" data-type="category">
<i class="bi bi-trash me-1"></i>Usuń
</button>
</div>
<button class="btn btn-sm btn-success btn-add-subtopic" data-parent-id="<?= $cat['id'] ?>" data-parent-name="<?= htmlspecialchars($cat['name']) ?>">
<i class="bi bi-plus-lg me-1"></i>Dodaj temat
@@ -65,10 +62,9 @@
data-description="<?= htmlspecialchars($child['description'] ?? '') ?>">
<i class="bi bi-pencil"></i>
</button>
<form method="post" action="/global-topics/<?= $child['id'] ?>/delete" class="d-inline"
onsubmit="return confirm('Usunąć ten temat?')">
<button type="submit" class="btn btn-outline-danger"><i class="bi bi-trash"></i></button>
</form>
<button class="btn btn-outline-danger btn-delete-global" data-id="<?= $child['id'] ?>" data-type="topic">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
@@ -188,5 +184,47 @@ document.addEventListener('DOMContentLoaded', function() {
new bootstrap.Modal(document.getElementById('editGlobalModal')).show();
});
});
// AJAX delete
document.querySelectorAll('.btn-delete-global').forEach(function(btn) {
btn.addEventListener('click', function() {
var id = this.dataset.id;
var isCategory = this.dataset.type === 'category';
var msg = isCategory ? 'Usunąć kategorię i wszystkie jej tematy?' : 'Usunąć ten temat?';
if (!confirm(msg)) return;
var el = isCategory ? this.closest('.accordion-item') : this.closest('tr');
var origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
fetch('/global-topics/' + id + '/delete', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
el.style.transition = 'opacity .3s';
el.style.opacity = '0';
setTimeout(function() {
el.remove();
if (!isCategory) {
var tbody = el.closest && document.querySelector('table tbody');
}
}, 300);
} else {
alert(data.message || 'Błąd usuwania');
btn.disabled = false;
btn.innerHTML = origHtml;
}
})
.catch(function() {
alert('Błąd połączenia');
btn.disabled = false;
btn.innerHTML = origHtml;
});
});
});
});
</script>

View File

@@ -0,0 +1,372 @@
<h2 class="mb-4"><i class="bi bi-cloud-upload me-2"></i>Instalator WordPress</h2>
<div class="row g-4">
<div class="col-lg-7">
<form method="post" action="/installer" id="installerForm">
<!-- FTP -->
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-hdd-network me-2"></i>Dane FTP</h5></div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-8">
<label for="ftp_host" class="form-label">Host FTP</label>
<input type="text" class="form-control" id="ftp_host" name="ftp_host" required placeholder="ftp.example.com">
</div>
<div class="col-md-4">
<label for="ftp_port" class="form-label">Port</label>
<input type="number" class="form-control" id="ftp_port" name="ftp_port" value="21">
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label for="ftp_user" class="form-label">Użytkownik FTP</label>
<input type="text" class="form-control" id="ftp_user" name="ftp_user" required>
</div>
<div class="col-md-6">
<label for="ftp_pass" class="form-label">Hasło FTP</label>
<input type="password" class="form-control" id="ftp_pass" name="ftp_pass" required>
</div>
</div>
<div class="mb-3">
<label for="ftp_path" class="form-label">Ścieżka docelowa</label>
<input type="text" class="form-control" id="ftp_path" name="ftp_path" required placeholder="/public_html" value="/public_html">
<div class="form-text">Ścieżka na serwerze, gdzie mają być wgrane pliki WordPress</div>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="ftp_ssl" name="ftp_ssl" value="1">
<label class="form-check-label" for="ftp_ssl">Użyj FTPS (SSL)</label>
</div>
</div>
</div>
<!-- Database -->
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-database me-2"></i>Baza danych</h5></div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-6">
<label for="db_host" class="form-label">Host bazy danych</label>
<input type="text" class="form-control" id="db_host" name="db_host" required value="localhost">
</div>
<div class="col-md-6">
<label for="db_name" class="form-label">Nazwa bazy danych</label>
<input type="text" class="form-control" id="db_name" name="db_name" required>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label for="db_user" class="form-label">Użytkownik bazy</label>
<input type="text" class="form-control" id="db_user" name="db_user" required>
</div>
<div class="col-md-6">
<label for="db_pass" class="form-label">Hasło bazy</label>
<input type="password" class="form-control" id="db_pass" name="db_pass" required>
</div>
</div>
<div class="mb-0">
<label for="db_prefix" class="form-label">Prefix tabel</label>
<input type="text" class="form-control" id="db_prefix" name="db_prefix" value="wp_" style="max-width: 150px;">
<div class="form-text">Zmień domyślny "wp_" dla lepszego bezpieczeństwa</div>
</div>
</div>
</div>
<!-- WordPress Admin -->
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0"><i class="bi bi-wordpress me-2"></i>Administrator WordPress</h5></div>
<div class="card-body">
<div class="mb-3">
<label for="site_url" class="form-label">URL strony</label>
<input type="url" class="form-control" id="site_url" name="site_url" required placeholder="https://example.com">
<div class="form-text">Pełny URL strony (bez końcowego /)</div>
</div>
<div class="mb-3">
<label for="site_title" class="form-label">Tytuł strony</label>
<input type="text" class="form-control" id="site_title" name="site_title" required placeholder="np. Blog o Ogrodnictwie">
</div>
<div class="row mb-3">
<div class="col-md-6">
<label for="admin_user" class="form-label">Login administratora</label>
<input type="text" class="form-control" id="admin_user" name="admin_user" required value="admin">
</div>
<div class="col-md-6">
<label for="admin_pass" class="form-label">Hasło administratora</label>
<input type="password" class="form-control" id="admin_pass" name="admin_pass" required>
<div class="form-text">Min. 8 znaków, użyj silnego hasła</div>
</div>
</div>
<div class="mb-3">
<label for="admin_email" class="form-label">E-mail administratora</label>
<input type="email" class="form-control" id="admin_email" name="admin_email" required placeholder="admin@example.com">
</div>
<div class="mb-0">
<label for="language" class="form-label">Wersja językowa</label>
<select class="form-select" id="language" name="language" style="max-width: 250px;">
<option value="pl_PL">Polski (pl_PL)</option>
<option value="en_US">English (en_US)</option>
</select>
</div>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary btn-lg" id="installBtn">
<i class="bi bi-cloud-upload me-1"></i>Zainstaluj WordPress
</button>
<a href="/" class="btn btn-outline-secondary btn-lg" id="cancelBtn">Anuluj</a>
</div>
</form>
<!-- Progress panel (hidden by default) -->
<div id="progressPanel" class="mt-4" style="display: none;">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-hourglass-split me-2"></i>Postęp instalacji</h5>
</div>
<div class="card-body">
<div class="progress mb-3" style="height: 25px;">
<div id="progressBar" class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
0%
</div>
</div>
<div id="progressMessage" class="text-muted">
<span class="spinner-border spinner-border-sm me-1"></span>
Oczekiwanie na start...
</div>
<div id="progressSteps" class="mt-3 small">
<div class="d-flex align-items-center mb-1" data-step="download">
<i class="bi bi-circle me-2 text-muted" id="stepIcon1"></i>
<span>Pobieranie WordPress</span>
</div>
<div class="d-flex align-items-center mb-1" data-step="extract">
<i class="bi bi-circle me-2 text-muted" id="stepIcon2"></i>
<span>Rozpakowywanie archiwum</span>
</div>
<div class="d-flex align-items-center mb-1" data-step="config">
<i class="bi bi-circle me-2 text-muted" id="stepIcon3"></i>
<span>Generowanie wp-config.php</span>
</div>
<div class="d-flex align-items-center mb-1" data-step="ftp">
<i class="bi bi-circle me-2 text-muted" id="stepIcon4"></i>
<span>Wgrywanie plików FTP</span>
</div>
<div class="d-flex align-items-center mb-1" data-step="install">
<i class="bi bi-circle me-2 text-muted" id="stepIcon5"></i>
<span>Instalacja WordPress</span>
</div>
<div class="d-flex align-items-center mb-1" data-step="apppass">
<i class="bi bi-circle me-2 text-muted" id="stepIcon6"></i>
<span>Tworzenie Application Password</span>
</div>
<div class="d-flex align-items-center mb-1" data-step="register">
<i class="bi bi-circle me-2 text-muted" id="stepIcon7"></i>
<span>Rejestracja w BackPRO</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Right column -->
<div class="col-lg-5">
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0">Jak to działa?</h5></div>
<div class="card-body">
<ol class="mb-0">
<li class="mb-2">System pobiera najnowszą wersję WordPress</li>
<li class="mb-2">Generuje <code>wp-config.php</code> z danymi bazy</li>
<li class="mb-2">Wgrywa pliki na serwer przez FTP</li>
<li class="mb-2">Uruchamia instalację WordPress</li>
<li class="mb-2">Tworzy Application Password do API</li>
<li class="mb-0">Rejestruje stronę w BackPRO</li>
</ol>
</div>
</div>
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0">Wymagania</h5></div>
<div class="card-body">
<ul class="mb-0 small">
<li class="mb-1">Serwer z PHP 7.4+ i MySQL 5.7+</li>
<li class="mb-1">Konto FTP z uprawnieniami zapisu</li>
<li class="mb-1">Pusta baza danych (lub z unikalnym prefixem tabel)</li>
<li class="mb-1">Domena skierowana na serwer docelowy</li>
<li class="mb-0">Proces trwa 2-5 minut</li>
</ul>
</div>
</div>
<div class="alert alert-warning small mb-0">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Uwaga:</strong> Instalacja może potrwać kilka minut ze względu na przesyłanie ~1500 plików przez FTP.
Nie zamykaj przeglądarki do momentu zakończenia procesu.
</div>
</div>
</div>
<script>
(function() {
var form = document.getElementById('installerForm');
var btn = document.getElementById('installBtn');
var cancelBtn = document.getElementById('cancelBtn');
var progressPanel = document.getElementById('progressPanel');
var progressBar = document.getElementById('progressBar');
var progressMessage = document.getElementById('progressMessage');
var pollInterval = null;
var progressId = null;
// Step thresholds: [minPercent, iconId]
var steps = [
[5, 'stepIcon1'], // download
[20, 'stepIcon2'], // extract
[28, 'stepIcon3'], // config
[33, 'stepIcon4'], // ftp
[86, 'stepIcon5'], // install
[93, 'stepIcon6'], // apppass
[97, 'stepIcon7'], // register
];
function generateId() {
var chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
var id = '';
for (var i = 0; i < 20; i++) {
id += chars.charAt(Math.floor(Math.random() * chars.length));
}
return id;
}
function updateStepIcons(percent) {
for (var i = 0; i < steps.length; i++) {
var icon = document.getElementById(steps[i][1]);
if (!icon) continue;
var threshold = steps[i][0];
var nextThreshold = (i + 1 < steps.length) ? steps[i + 1][0] : 101;
if (percent >= nextThreshold) {
// Completed
icon.className = 'bi bi-check-circle-fill me-2 text-success';
} else if (percent >= threshold) {
// In progress
icon.className = 'bi bi-arrow-right-circle-fill me-2 text-primary';
} else {
// Pending
icon.className = 'bi bi-circle me-2 text-muted';
}
}
}
function setProgress(percent, message) {
progressBar.style.width = percent + '%';
progressBar.setAttribute('aria-valuenow', percent);
progressBar.textContent = percent + '%';
progressMessage.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> ' + message;
updateStepIcons(percent);
}
function pollStatus() {
fetch('/installer/status/' + progressId)
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.status === 'waiting') return;
setProgress(data.percent, data.message);
if (data.status === 'completed' || data.status === 'failed') {
clearInterval(pollInterval);
progressBar.classList.remove('progress-bar-animated');
if (data.status === 'failed') {
progressBar.classList.remove('bg-primary');
progressBar.classList.add('bg-danger');
progressMessage.innerHTML = '<i class="bi bi-x-circle me-1 text-danger"></i> ' + data.message;
}
}
})
.catch(function() { /* ignore polling errors */ });
}
form.addEventListener('submit', function(e) {
e.preventDefault();
// Generate progress ID and collect form data BEFORE disabling inputs
progressId = generateId();
var formData = new FormData(form);
formData.append('progress_id', progressId);
// Show progress panel, disable form
progressPanel.style.display = 'block';
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Instalowanie...';
cancelBtn.style.display = 'none';
// Disable all form inputs
var inputs = form.querySelectorAll('input, select, button');
for (var i = 0; i < inputs.length; i++) {
inputs[i].disabled = true;
}
// Scroll to progress panel
progressPanel.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Start polling before the AJAX request
pollInterval = setInterval(pollStatus, 2000);
// Send AJAX install request
fetch('/installer', {
method: 'POST',
body: formData
})
.then(function(r) { return r.json(); })
.then(function(result) {
clearInterval(pollInterval);
progressBar.classList.remove('progress-bar-animated');
if (result.success) {
setProgress(100, result.message);
progressBar.classList.remove('bg-primary');
progressBar.classList.add('bg-success');
progressMessage.innerHTML = '<i class="bi bi-check-circle me-1 text-success"></i> ' + result.message;
// Mark all steps as completed
updateStepIcons(100);
// Redirect after 2 seconds
setTimeout(function() {
window.location.href = '/sites/' + result.site_id + '/edit';
}, 2000);
} else {
progressBar.classList.remove('bg-primary');
progressBar.classList.add('bg-danger');
progressMessage.innerHTML = '<i class="bi bi-x-circle me-1 text-danger"></i> ' + result.message;
// Re-enable form for retry
var inputs = form.querySelectorAll('input, select');
for (var i = 0; i < inputs.length; i++) {
inputs[i].disabled = false;
}
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-clockwise me-1"></i>Sprobuj ponownie';
cancelBtn.style.display = '';
}
})
.catch(function(err) {
clearInterval(pollInterval);
progressBar.classList.remove('progress-bar-animated', 'bg-primary');
progressBar.classList.add('bg-danger');
progressMessage.innerHTML = '<i class="bi bi-x-circle me-1 text-danger"></i> Blad polaczenia z serwerem.';
var inputs = form.querySelectorAll('input, select');
for (var i = 0; i < inputs.length; i++) {
inputs[i].disabled = false;
}
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-arrow-clockwise me-1"></i>Sprobuj ponownie';
cancelBtn.style.display = '';
});
});
})();
</script>

View File

@@ -24,6 +24,11 @@
<i class="bi bi-file-earmark-text me-2"></i>Artykuły
</a>
</li>
<li class="nav-item">
<a class="nav-link text-white" href="/installer">
<i class="bi bi-cloud-upload me-2"></i>Instalator WP
</a>
</li>
<li class="nav-item">
<a class="nav-link text-white" href="/settings">
<i class="bi bi-gear me-2"></i>Ustawienia

View File

@@ -44,6 +44,12 @@
</div>
</div>
<div class="mb-3">
<label for="article_generation_prompt" class="form-label">Prompt do generowania artykułu</label>
<textarea class="form-control" id="article_generation_prompt" name="article_generation_prompt" rows="6"><?= htmlspecialchars($settings['article_generation_prompt']) ?></textarea>
<small class="text-muted">Dostępne placeholdery: {min_words}, {max_words}</small>
</div>
<h5 class="mb-3 mt-4 border-bottom pb-2">Obrazki</h5>
<div class="mb-3">
@@ -55,6 +61,12 @@
</select>
</div>
<div class="mb-3">
<label for="image_generation_prompt" class="form-label">Prompt do generowania zdjęcia (Freepik)</label>
<textarea class="form-control" id="image_generation_prompt" name="image_generation_prompt" rows="3"><?= htmlspecialchars($settings['image_generation_prompt']) ?></textarea>
<small class="text-muted">Dostępne placeholdery: {topic_name}, {article_title}</small>
</div>
<div class="mb-3">
<label for="freepik_api_key" class="form-label">Klucz API Freepik</label>
<input type="password" class="form-control" id="freepik_api_key" name="freepik_api_key"

View File

@@ -30,9 +30,9 @@
</div>
<div class="mb-3">
<label for="publish_interval_days" class="form-label">Interwał publikacji (dni)</label>
<input type="number" class="form-control" id="publish_interval_days" name="publish_interval_days" value="3" min="1" max="30">
<div class="form-text">Co ile dni publikować nowy artykuł na tej stronie</div>
<label for="publish_interval_hours" class="form-label">Interwał publikacji (godziny)</label>
<input type="number" class="form-control" id="publish_interval_hours" name="publish_interval_hours" value="24" min="1" max="720">
<div class="form-text">Co ile godzin publikować nowy artykuł na tej stronie</div>
</div>
<div class="mb-3">

View File

@@ -36,8 +36,8 @@
</div>
<div class="mb-3">
<label for="publish_interval_days" class="form-label">Interwał publikacji (dni)</label>
<input type="number" class="form-control" id="publish_interval_days" name="publish_interval_days" value="<?= $site['publish_interval_days'] ?>" min="1" max="30">
<label for="publish_interval_hours" class="form-label">Interwał publikacji (godziny)</label>
<input type="number" class="form-control" id="publish_interval_hours" name="publish_interval_hours" value="<?= $site['publish_interval_hours'] ?>" min="1" max="720">
</div>
<div class="mb-3">
@@ -54,6 +54,111 @@
</div>
</div>
<!-- Credentials accordion -->
<?php $hasCredentials = !empty($site['ftp_host']) || !empty($site['db_host']) || !empty($site['wp_admin_user']); ?>
<div class="accordion mb-3" id="credentialsAccordion">
<div class="accordion-item border-0">
<h2 class="accordion-header">
<button class="accordion-button collapsed bg-light px-0" type="button" data-bs-toggle="collapse" data-bs-target="#credentialsPanel">
<i class="bi bi-key me-2"></i>Dane dostępowe (FTP, baza danych, panel WP)
<?php if ($hasCredentials): ?>
<span class="badge bg-success ms-2">zapisane</span>
<?php else: ?>
<span class="badge bg-secondary ms-2">brak</span>
<?php endif; ?>
</button>
</h2>
<div id="credentialsPanel" class="accordion-collapse collapse">
<div class="accordion-body px-0 pb-0">
<!-- FTP -->
<fieldset class="border rounded p-3 mb-3">
<legend class="float-none w-auto px-2 fs-6 fw-bold mb-0">
<i class="bi bi-hdd-network me-1"></i>FTP
</legend>
<div class="row mb-2">
<div class="col-md-8">
<label for="ftp_host" class="form-label small">Host</label>
<input type="text" class="form-control form-control-sm" id="ftp_host" name="ftp_host" value="<?= htmlspecialchars($site['ftp_host'] ?? '') ?>" placeholder="ftp.example.com">
</div>
<div class="col-md-4">
<label for="ftp_port" class="form-label small">Port</label>
<input type="number" class="form-control form-control-sm" id="ftp_port" name="ftp_port" value="<?= htmlspecialchars($site['ftp_port'] ?? '21') ?>">
</div>
</div>
<div class="row mb-2">
<div class="col-md-6">
<label for="ftp_user" class="form-label small">Użytkownik</label>
<input type="text" class="form-control form-control-sm" id="ftp_user" name="ftp_user" value="<?= htmlspecialchars($site['ftp_user'] ?? '') ?>">
</div>
<div class="col-md-6">
<label for="ftp_pass" class="form-label small">Hasło</label>
<input type="password" class="form-control form-control-sm" id="ftp_pass" name="ftp_pass" value="<?= htmlspecialchars($site['ftp_pass'] ?? '') ?>">
</div>
</div>
<div>
<label for="ftp_path" class="form-label small">Ścieżka</label>
<input type="text" class="form-control form-control-sm" id="ftp_path" name="ftp_path" value="<?= htmlspecialchars($site['ftp_path'] ?? '') ?>" placeholder="/public_html">
</div>
</fieldset>
<!-- Database -->
<fieldset class="border rounded p-3 mb-3">
<legend class="float-none w-auto px-2 fs-6 fw-bold mb-0">
<i class="bi bi-database me-1"></i>Baza danych
</legend>
<div class="row mb-2">
<div class="col-md-6">
<label for="db_host" class="form-label small">Host</label>
<input type="text" class="form-control form-control-sm" id="db_host" name="db_host" value="<?= htmlspecialchars($site['db_host'] ?? '') ?>" placeholder="localhost">
</div>
<div class="col-md-6">
<label for="db_name" class="form-label small">Nazwa bazy</label>
<input type="text" class="form-control form-control-sm" id="db_name" name="db_name" value="<?= htmlspecialchars($site['db_name'] ?? '') ?>">
</div>
</div>
<div class="row mb-2">
<div class="col-md-4">
<label for="db_user" class="form-label small">Użytkownik</label>
<input type="text" class="form-control form-control-sm" id="db_user" name="db_user" value="<?= htmlspecialchars($site['db_user'] ?? '') ?>">
</div>
<div class="col-md-4">
<label for="db_pass" class="form-label small">Hasło</label>
<input type="password" class="form-control form-control-sm" id="db_pass" name="db_pass" value="<?= htmlspecialchars($site['db_pass'] ?? '') ?>">
</div>
<div class="col-md-4">
<label for="db_prefix" class="form-label small">Prefix</label>
<input type="text" class="form-control form-control-sm" id="db_prefix" name="db_prefix" value="<?= htmlspecialchars($site['db_prefix'] ?? 'wp_') ?>">
</div>
</div>
</fieldset>
<!-- WP Admin -->
<fieldset class="border rounded p-3">
<legend class="float-none w-auto px-2 fs-6 fw-bold mb-0">
<i class="bi bi-wordpress me-1"></i>Panel administratora
</legend>
<div class="row mb-2">
<div class="col-md-6">
<label for="wp_admin_user" class="form-label small">Login</label>
<input type="text" class="form-control form-control-sm" id="wp_admin_user" name="wp_admin_user" value="<?= htmlspecialchars($site['wp_admin_user'] ?? '') ?>">
</div>
<div class="col-md-6">
<label for="wp_admin_pass" class="form-label small">Hasło</label>
<input type="password" class="form-control form-control-sm" id="wp_admin_pass" name="wp_admin_pass" value="<?= htmlspecialchars($site['wp_admin_pass'] ?? '') ?>">
</div>
</div>
<div>
<label for="wp_admin_email" class="form-label small">E-mail</label>
<input type="email" class="form-control form-control-sm" id="wp_admin_email" name="wp_admin_email" value="<?= htmlspecialchars($site['wp_admin_email'] ?? '') ?>">
</div>
</fieldset>
</div>
</div>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Zapisz zmiany</button>
<a href="/sites" class="btn btn-outline-secondary">Anuluj</a>
@@ -100,10 +205,9 @@
<td class="small text-muted"><?= htmlspecialchars($topic['global_category_name'] ?? '') ?></td>
<td><span class="badge bg-primary"><?= $topic['article_count'] ?></span></td>
<td>
<form method="post" action="/topics/<?= $topic['id'] ?>/delete" class="d-inline"
onsubmit="return confirm('Usunąć ten temat?')">
<button type="submit" class="btn btn-sm btn-outline-danger py-0 px-1"><i class="bi bi-x"></i></button>
</form>
<button class="btn btn-sm btn-outline-danger py-0 px-1 btn-delete-topic" data-id="<?= $topic['id'] ?>" data-name="<?= htmlspecialchars($topic['name']) ?>">
<i class="bi bi-x"></i>
</button>
</td>
</tr>
<?php endforeach; ?>
@@ -154,4 +258,35 @@ function quickFillTopic(select) {
document.getElementById('quick_topic_name').value = opt.dataset.name || '';
document.getElementById('quick_topic_desc').value = opt.dataset.desc || '';
}
document.querySelectorAll('.btn-delete-topic').forEach(function(btn) {
btn.addEventListener('click', function() {
var id = this.dataset.id;
var name = this.dataset.name;
if (!confirm('Usunąć temat "' + name + '"?')) return;
var row = this.closest('tr');
btn.disabled = true;
fetch('/topics/' + id + '/delete', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
row.style.transition = 'opacity .3s';
row.style.opacity = '0';
setTimeout(function() { row.remove(); }, 300);
} else {
alert(data.message || 'Błąd usuwania');
btn.disabled = false;
}
})
.catch(function() {
alert('Błąd połączenia');
btn.disabled = false;
});
});
});
</script>

View File

@@ -37,7 +37,7 @@
<?= $site['topic_count'] ?> tematów
</a>
</td>
<td>co <?= $site['publish_interval_days'] ?> dni</td>
<td>co <?= $site['publish_interval_hours'] ?> h</td>
<td><?= $site['last_published_at'] ? date('d.m.Y H:i', strtotime($site['last_published_at'])) : '-' ?></td>
<td>
<?php if ($site['is_active']): ?>

View File

@@ -24,44 +24,64 @@
<?php if (empty($topics)): ?>
<tr><td colspan="6" class="text-center text-muted py-4">Brak tematów. Dodaj tematy z biblioteki lub utwórz własny.</td></tr>
<?php else: ?>
<?php foreach ($topics as $topic): ?>
<tr>
<td><strong><?= htmlspecialchars($topic['name']) ?></strong></td>
<td class="small text-muted">
<?php if ($topic['global_category_name']): ?>
<?= htmlspecialchars($topic['global_category_name']) ?>
<?php elseif ($topic['global_topic_name']): ?>
<?= htmlspecialchars($topic['global_topic_name']) ?>
<?php else: ?>
<em>własny</em>
<?php endif; ?>
</td>
<td><?= $topic['wp_category_id'] ?: '-' ?></td>
<td><span class="badge bg-primary"><?= $topic['article_count'] ?></span></td>
<td>
<?php if ($topic['is_active']): ?>
<span class="badge bg-success">Aktywny</span>
<?php else: ?>
<span class="badge bg-secondary">Nieaktywny</span>
<?php endif; ?>
</td>
<td>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary btn-edit-topic"
data-id="<?= $topic['id'] ?>"
data-name="<?= htmlspecialchars($topic['name']) ?>"
data-description="<?= htmlspecialchars($topic['description'] ?? '') ?>"
data-wp-category="<?= $topic['wp_category_id'] ?>"
data-global-topic="<?= $topic['global_topic_id'] ?>"
data-active="<?= $topic['is_active'] ?>">
<i class="bi bi-pencil"></i>
</button>
<form method="post" action="/topics/<?= $topic['id'] ?>/delete" class="d-inline" onsubmit="return confirm('Na pewno usunąć ten temat?')">
<button type="submit" class="btn btn-outline-danger"><i class="bi bi-trash"></i></button>
</form>
</div>
<?php
// Group topics by parent category
$grouped = [];
foreach ($topics as $topic) {
$group = $topic['global_category_name']
?? ($topic['global_topic_name'] && !$topic['global_category_name'] ? $topic['global_topic_name'] : null)
?? '__custom__';
$grouped[$group][] = $topic;
}
?>
<?php foreach ($grouped as $groupName => $groupTopics): ?>
<tr class="table-light">
<td colspan="6" class="py-1 px-3">
<strong class="small text-uppercase text-muted">
<?= $groupName === '__custom__' ? 'Własne tematy' : htmlspecialchars($groupName) ?>
</strong>
<span class="badge bg-secondary ms-1"><?= count($groupTopics) ?></span>
</td>
</tr>
<?php foreach ($groupTopics as $topic): ?>
<tr>
<td class="ps-4"><strong><?= htmlspecialchars($topic['name']) ?></strong></td>
<td class="small text-muted">
<?php if ($topic['global_category_name']): ?>
<?= htmlspecialchars($topic['global_category_name']) ?>
<?php elseif ($topic['global_topic_name']): ?>
<?= htmlspecialchars($topic['global_topic_name']) ?>
<?php else: ?>
<em>własny</em>
<?php endif; ?>
</td>
<td><?= $topic['wp_category_id'] ?: '-' ?></td>
<td><span class="badge bg-primary"><?= $topic['article_count'] ?></span></td>
<td>
<?php if ($topic['is_active']): ?>
<span class="badge bg-success">Aktywny</span>
<?php else: ?>
<span class="badge bg-secondary">Nieaktywny</span>
<?php endif; ?>
</td>
<td>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary btn-edit-topic"
data-id="<?= $topic['id'] ?>"
data-name="<?= htmlspecialchars($topic['name']) ?>"
data-description="<?= htmlspecialchars($topic['description'] ?? '') ?>"
data-wp-category="<?= $topic['wp_category_id'] ?>"
data-global-topic="<?= $topic['global_topic_id'] ?>"
data-active="<?= $topic['is_active'] ?>">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-outline-danger btn-delete-topic" data-id="<?= $topic['id'] ?>" data-name="<?= htmlspecialchars($topic['name']) ?>">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
@@ -138,4 +158,44 @@ function fillFromLibrary(select) {
document.getElementById('topic_description').value = opt.dataset.desc || '';
}
}
document.querySelectorAll('.btn-delete-topic').forEach(function(btn) {
btn.addEventListener('click', function() {
var id = this.dataset.id;
var name = this.dataset.name;
if (!confirm('Na pewno usunąć temat "' + name + '"?')) return;
var row = this.closest('tr');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
fetch('/topics/' + id + '/delete', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
row.style.transition = 'opacity .3s';
row.style.opacity = '0';
setTimeout(function() {
row.remove();
var tbody = document.querySelector('table tbody');
if (!tbody.querySelector('tr')) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-4">Brak tematów. Dodaj tematy z biblioteki lub utwórz własny.</td></tr>';
}
}, 300);
} else {
alert(data.message || 'Błąd usuwania');
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-trash"></i>';
}
})
.catch(function() {
alert('Błąd połączenia');
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-trash"></i>';
});
});
});
</script>