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:
2
.env
2
.env
@@ -12,3 +12,5 @@ PEXELS_API_KEY=
|
||||
|
||||
APP_URL=https://backpro.projectpro.pl
|
||||
APP_SECRET=bP7x9kR3mW2vN5qT8sY1
|
||||
|
||||
PUBLISH_TRIGGER_TOKEN=bP7x9kR3mW2vN5qT8sY1bP7x9kR3mW2vN5qT8sY1
|
||||
@@ -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
570
.vscode/ftp-kr.sync.cache.json
vendored
Normal 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
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
18
migrations/003_site_credentials.sql
Normal file
18
migrations/003_site_credentials.sql
Normal 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;
|
||||
5
migrations/004_prompt_templates.sql
Normal file
5
migrations/004_prompt_templates.sql
Normal 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;
|
||||
21
migrations/005_publish_interval_hours.sql
Normal file
21
migrations/005_publish_interval_hours.sql
Normal 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;
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
109
src/Controllers/InstallerController.php
Normal file
109
src/Controllers/InstallerController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
140
src/Services/FtpService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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' => [
|
||||
|
||||
442
src/Services/InstallerService.php
Normal file
442
src/Services/InstallerService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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; ?>
|
||||
|
||||
@@ -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">← Powrót do stron</a>
|
||||
<a href="/sites/<?= $site['id'] ?>/edit" class="text-muted small">← 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
372
templates/installer/index.php
Normal file
372
templates/installer/index.php
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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']): ?>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user