WebOrbiton
v3.0.0.0

FlatlyPage

2,403 lines · 151.2 KB
  1. <?php
  2. require_once __DIR__ . '/../config.php';
  3. require_once __DIR__ . '/sidebar.php';
  4. require_once __DIR__ . '/ai-helper-lib.php';
  5. ​
  6. $sitemap_file = __DIR__ . '/../data/sitemap-config.php';
  7. if (!file_exists($sitemap_file)) {
  8. $sitemap_file = __DIR__ . '/../data/default-sitemap-config.php';
  9. }
  10. if (file_exists($sitemap_file)) {
  11. $sitemap_settings = include $sitemap_file;
  12. } else {
  13. $sitemap_settings = [
  14. 'sitemap' => false,
  15. 'website_domain' => '',
  16. ];
  17. }
  18. ​
  19. require_login();
  20. ​
  21. if (session_status() === PHP_SESSION_NONE) {
  22. session_start();
  23. }
  24. ​
  25. require_once __DIR__ . '/publish-helpers.php';
  26. ​
  27. $message = $_SESSION['admin_msg'] ?? '';
  28. $message_type = $_SESSION['admin_msg_type'] ?? '';
  29. unset($_SESSION['admin_msg'], $_SESSION['admin_msg_type']);
  30. ​
  31. $current_tab = $_GET['tab'] ?? 'index';
  32. ​
  33. $extra_langs = flatly_extra_languages();
  34. $requested_lang = strtolower(trim((string) ($_POST['lang'] ?? $_GET['lang'] ?? '')));
  35. $edit_lang = isset($extra_langs[$requested_lang]) ? $requested_lang : null;
  36. ​
  37. if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  38. $token = $_POST['csrf_token'] ?? '';
  39. ​
  40. if (!verify_csrf_token($token)) {
  41. $_SESSION['admin_msg'] = 'Invalid request (CSRF). Please try again.';
  42. $_SESSION['admin_msg_type'] = 'error';
  43. } else {
  44. $action = $_POST['action'] ?? '';
  45. $redirect_tab = $_POST['current_tab'] ?? $current_tab;
  46. $redirect_id = null;
  47. ​
  48. if ($edit_lang !== null && in_array($action, ['save_index', 'save_settings', 'save_product'], true)) {
  49. $action .= '_lang';
  50. }
  51. ​
  52. switch ($action) {
  53. case 'save_index_lang':
  54. $blocks_decoded = json_decode($_POST['blocks_data'] ?? '[]', true);
  55. $data = [
  56. 'title' => $_POST['page_title'] ?? '',
  57. 'description' => $_POST['page_description'] ?? '',
  58. 'blocks' => is_array($blocks_decoded) ? $blocks_decoded : [],
  59. 'updated_at' => date('Y-m-d H:i:s'),
  60. ];
  61. ​
  62. if (save_page_lang('index', $edit_lang, $data)) {
  63. $_SESSION['admin_msg'] = 'Translated main page has been saved!';
  64. $_SESSION['admin_msg_type'] = 'success';
  65. } else {
  66. $_SESSION['admin_msg'] = 'Error saving translated main page.';
  67. $_SESSION['admin_msg_type'] = 'error';
  68. }
  69. break;
  70. ​
  71. case 'save_settings_lang':
  72. $nav_links_decoded = json_decode($_POST['nav_links'] ?? '[]', true);
  73. $nav_buttons_decoded = json_decode($_POST['nav_buttons'] ?? '[]', true);
  74. $footer_decoded = json_decode($_POST['footer_data'] ?? '{}', true);
  75. $footer_decoded = is_array($footer_decoded) ? $footer_decoded : [];
  76. unset($footer_decoded['languages']);
  77. ​
  78. $settings = [
  79. 'site_name' => trim((string) ($_POST['site_name'] ?? '')),
  80. 'site_description' => trim((string) ($_POST['site_description'] ?? '')),
  81. 'logo_text' => trim((string) ($_POST['logo_text'] ?? '')),
  82. 'site_language' => $edit_lang,
  83. 'nav_links' => is_array($nav_links_decoded) ? $nav_links_decoded : [],
  84. 'nav_buttons' => is_array($nav_buttons_decoded) ? $nav_buttons_decoded : [],
  85. 'footer' => $footer_decoded,
  86. ];
  87. ​
  88. if (save_page_lang('settings', $edit_lang, $settings)) {
  89. $_SESSION['admin_msg'] = 'Translated settings have been updated!';
  90. $_SESSION['admin_msg_type'] = 'success';
  91. } else {
  92. $_SESSION['admin_msg'] = 'Error saving translated settings.';
  93. $_SESSION['admin_msg_type'] = 'error';
  94. }
  95. break;
  96. ​
  97. case 'save_product_lang':
  98. $base_id = (string) ($_POST['product_id'] ?? '');
  99. $redirect_tab = 'edit';
  100. $redirect_id = $base_id;
  101. $main_product = preg_match('/^product-[A-Za-z0-9_-]+$/', $base_id) ? load_page($base_id) : null;
  102. ​
  103. if (!$main_product) {
  104. $_SESSION['admin_msg'] = 'Invalid page ID.';
  105. $_SESSION['admin_msg_type'] = 'error';
  106. $redirect_tab = 'products';
  107. $redirect_id = null;
  108. break;
  109. }
  110. ​
  111. $file_id = flatly_lang_file_id($edit_lang, $base_id);
  112. $slug_input = trim((string) ($_POST['product_slug'] ?? ''));
  113. $slug = $slug_input !== '' ? slugify($slug_input) : (string) ($main_product['slug'] ?? '');
  114. ​
  115. $slug_taken = false;
  116. foreach (get_all_products($edit_lang) as $other) {
  117. if (($other['slug'] ?? '') === $slug && ($other['id'] ?? '') !== $file_id) {
  118. $slug_taken = true;
  119. break;
  120. }
  121. }
  122. ​
  123. if ($slug === '' || $slug_taken) {
  124. $_SESSION['admin_msg'] = 'This URL slug is empty or already used by another page in this language.';
  125. $_SESSION['admin_msg_type'] = 'error';
  126. break;
  127. }
  128. ​
  129. $blocks_decoded = json_decode($_POST['blocks_data'] ?? '[]', true);
  130. $data = [
  131. 'id' => $file_id,
  132. 'slug' => $slug,
  133. 'title' => $_POST['product_title'] ?? '',
  134. 'description' => $_POST['product_description'] ?? '',
  135. 'blocks' => is_array($blocks_decoded) ? $blocks_decoded : [],
  136. 'updated_at' => date('Y-m-d H:i:s'),
  137. 'show_in_latest' => $slug !== 'homepage' && isset($_POST['show_in_latest']) && $_POST['show_in_latest'] == '1',
  138. ];
  139. ​
  140. $data = array_merge($data, build_publish_fields_from_post($_POST));
  141. ​
  142. $existing = load_page_lang($base_id, $edit_lang);
  143. $data['created_at'] = $existing['created_at'] ?? date('Y-m-d H:i:s');
  144. ​
  145. if (save_page_lang($base_id, $edit_lang, $data)) {
  146. $_SESSION['admin_msg'] = 'Translated page has been saved!';
  147. $_SESSION['admin_msg_type'] = 'success';
  148. } else {
  149. $_SESSION['admin_msg'] = 'Error saving translated page.';
  150. $_SESSION['admin_msg_type'] = 'error';
  151. }
  152. break;
  153. ​
  154. case 'delete_translation':
  155. if ($edit_lang === null) {
  156. $_SESSION['admin_msg'] = 'Choose a language first.';
  157. $_SESSION['admin_msg_type'] = 'error';
  158. break;
  159. }
  160. ​
  161. $target = $_POST['target'] ?? '';
  162. $base_id = null;
  163. if ($target === 'settings' || $target === 'index') {
  164. $base_id = $target;
  165. $redirect_tab = $target;
  166. } elseif ($target === 'product') {
  167. $candidate = (string) ($_POST['product_id'] ?? '');
  168. $base_id = preg_match('/^product-[A-Za-z0-9_-]+$/', $candidate) ? $candidate : null;
  169. $redirect_tab = 'products';
  170. }
  171. ​
  172. $filepath = $base_id !== null ? DATA_DIR . flatly_lang_file_id($edit_lang, $base_id) . '.php' : '';
  173. if ($filepath !== '' && is_file($filepath) && unlink($filepath)) {
  174. unset($GLOBALS['_products_cache']);
  175. invalidate_php_cache($filepath);
  176. $_SESSION['admin_msg'] = 'Translation has been deleted. The main language content is used instead.';
  177. $_SESSION['admin_msg_type'] = 'success';
  178. } else {
  179. $_SESSION['admin_msg'] = 'Error deleting translation.';
  180. $_SESSION['admin_msg_type'] = 'error';
  181. }
  182. break;
  183. ​
  184. case 'sync_from_main':
  185. if ($edit_lang === null) {
  186. $_SESSION['admin_msg'] = 'Choose a language first.';
  187. $_SESSION['admin_msg_type'] = 'error';
  188. break;
  189. }
  190. ​
  191. $target = $_POST['target'] ?? '';
  192. $added = 0;
  193. $saved = true;
  194. ​
  195. if ($target === 'settings') {
  196. $main = load_page('settings') ?? get_default_site_settings();
  197. $translated = load_page_lang('settings', $edit_lang) ?? [];
  198. [$merged, $added] = flatly_sync_settings($main, $translated);
  199. $merged['site_language'] = $edit_lang;
  200. if ($added > 0) {
  201. $saved = save_page_lang('settings', $edit_lang, $merged);
  202. }
  203. $redirect_tab = 'settings';
  204. } elseif ($target === 'index') {
  205. $main = load_page('index');
  206. if (is_array($main)) {
  207. [$merged, $added] = flatly_sync_page_blocks($main, load_page_lang('index', $edit_lang), flatly_lang_file_id($edit_lang, 'index'));
  208. if ($added > 0) {
  209. $saved = save_page_lang('index', $edit_lang, $merged);
  210. }
  211. }
  212. $redirect_tab = 'index';
  213. } elseif ($target === 'product') {
  214. $base_id = (string) ($_POST['product_id'] ?? '');
  215. $main = preg_match('/^product-[A-Za-z0-9_-]+$/', $base_id) ? load_page($base_id) : null;
  216. if (is_array($main)) {
  217. [$merged, $added] = flatly_sync_page_blocks($main, load_page_lang($base_id, $edit_lang), flatly_lang_file_id($edit_lang, $base_id));
  218. if ($added > 0) {
  219. $saved = save_page_lang($base_id, $edit_lang, $merged);
  220. }
  221. $redirect_tab = 'edit';
  222. $redirect_id = $base_id;
  223. }
  224. } elseif ($target === 'pages') {
  225. foreach (get_all_products() as $main_product) {
  226. $base_id = (string) ($main_product['id'] ?? ('product-' . ($main_product['slug'] ?? '')));
  227. if (!preg_match('/^product-[A-Za-z0-9_-]+$/', $base_id) || load_page_lang($base_id, $edit_lang) !== null) {
  228. continue;
  229. }
  230. $copy = $main_product;
  231. $copy['id'] = flatly_lang_file_id($edit_lang, $base_id);
  232. $copy['updated_at'] = date('Y-m-d H:i:s');
  233. if (save_page_lang($base_id, $edit_lang, $copy)) {
  234. $added++;
  235. } else {
  236. $saved = false;
  237. }
  238. }
  239. $redirect_tab = 'products';
  240. }
  241. ​
  242. if (!$saved) {
  243. $_SESSION['admin_msg'] = 'Error while copying from the main language.';
  244. $_SESSION['admin_msg_type'] = 'error';
  245. } elseif ($added > 0) {
  246. $_SESSION['admin_msg'] = "Copied $added missing item(s) from the main language. Existing translations were not changed.";
  247. $_SESSION['admin_msg_type'] = 'success';
  248. } else {
  249. $_SESSION['admin_msg'] = 'Nothing to copy - everything from the main language is already present.';
  250. $_SESSION['admin_msg_type'] = 'success';
  251. }
  252. break;
  253. ​
  254. case 'save_index':
  255. $data = [
  256. 'title' => $_POST['page_title'] ?? SITE_NAME,
  257. 'description' => $_POST['page_description'] ?? '',
  258. 'blocks' => json_decode($_POST['blocks_data'] ?? '[]', true),
  259. 'updated_at' => date('Y-m-d H:i:s'),
  260. ];
  261. ​
  262. if (save_page('index', $data)) {
  263. $_SESSION['admin_msg'] = 'Main page has been saved!';
  264. $_SESSION['admin_msg_type'] = 'success';
  265. } else {
  266. $_SESSION['admin_msg'] = 'Error saving main page.';
  267. $_SESSION['admin_msg_type'] = 'error';
  268. }
  269. break;
  270. ​
  271. case 'save_settings':
  272. $settings = [
  273. 'site_name' => $_POST['site_name'] ?? SITE_NAME,
  274. 'site_description' => $_POST['site_description'] ?? '',
  275. 'logo_text' => $_POST['logo_text'] ?? SITE_NAME,
  276. 'site_language' => $_POST['site_language'] ?? "en",
  277. 'logo_image' => $_POST['logo_image'] ?? '',
  278. 'favicon' => $_POST['favicon'] ?? '',
  279. 'website_font' => resolve_font($_POST['website_font'] ?? null, 'EB Garamond'),
  280. 'interface_font' => resolve_font($_POST['interface_font'] ?? null, 'Space Grotesk'),
  281. 'headings_font' => ($_POST['headings_font'] ?? '') === '' ? '' : resolve_font($_POST['headings_font'], 'EB Garamond'),
  282. 'logo_font' => ($_POST['logo_font'] ?? '') === '' ? '' : resolve_font($_POST['logo_font'], 'EB Garamond'),
  283. 'price_font' => ($_POST['price_font'] ?? '') === '' ? '' : resolve_font($_POST['price_font'], 'EB Garamond'),
  284. 'primary_color' => $_POST['primary_color'] ?? '#ffffff',
  285. 'nav_links' => json_decode($_POST['nav_links'] ?? '[]', true),
  286. 'nav_buttons' => json_decode($_POST['nav_buttons'] ?? '[]', true),
  287. 'footer' => json_decode($_POST['footer_data'] ?? '{}', true),
  288. 'recent_pages_enabled' => isset($_POST['recent_pages']) && $_POST['recent_pages'] === 'true',
  289. 'contact_page_enabled' => isset($_POST['contact_page_enabled']) && $_POST['contact_page_enabled'] === 'true',
  290. 'contact_email' => $_POST['contact_email'] ?? '',
  291. 'languages' => isset($_POST['languages_data'])
  292. ? flatly_sanitize_languages($_POST['languages_data'])
  293. : (flatly_main_settings()['languages'] ?? []),
  294. 'site_language_name' => isset($_POST['languages_data'])
  295. ? flatly_limit_text(trim((string) ($_POST['site_language_name'] ?? '')), 50)
  296. : (string) (flatly_main_settings()['site_language_name'] ?? ''),
  297. 'language_autodetect' => isset($_POST['languages_data'])
  298. ? ($_POST['language_autodetect'] ?? '') === '1'
  299. : !empty(flatly_main_settings()['language_autodetect']),
  300. ];
  301. ​
  302. if (is_array($settings['footer'])) {
  303. unset($settings['footer']['languages']);
  304. }
  305. ​
  306. if ($edit_lang === null && (isset($_POST['ai_settings_present']) || isset($_POST['custom_js_present']) || isset($_POST['custom_html_present']))) {
  307. $system_current = get_system_settings();
  308. $system_new = $system_current;
  309. if (isset($_POST['ai_settings_present'])) {
  310. $system_new = array_merge($system_new, AiHelper::sanitizeSettings($_POST, $system_current));
  311. }
  312. if (isset($_POST['custom_js_present'])) {
  313. $system_new['custom_js'] = flatly_clean_custom_code(flatly_posted_code('custom_js') ?? '');
  314. }
  315. if (isset($_POST['custom_html_present'])) {
  316. $system_new['custom_html'] = flatly_clean_custom_html($_POST['custom_html_location'] ?? [], flatly_posted_code('custom_html_code') ?? []);
  317. }
  318. save_page('system', $system_new);
  319. }
  320. ​
  321. $sitemap_config = [
  322. 'sitemap' => ($_POST['website_sitemap'] ?? 'false') === 'true',
  323. 'website_domain' => trim($_POST['website_domain'] ?? ''),
  324. ];
  325. $sitemap_file = __DIR__ . '/../data/sitemap-config.php';
  326. $sitemap_content = "<?php\nreturn " . var_export($sitemap_config, true) . ";\n";
  327. @file_put_contents($sitemap_file, $sitemap_content);
  328. invalidate_php_cache($sitemap_file);
  329. ​
  330. if (save_page('settings', $settings)) {
  331. $_SESSION['admin_msg'] = 'Settings have been updated!';
  332. $_SESSION['admin_msg_type'] = 'success';
  333. } else {
  334. $_SESSION['admin_msg'] = 'Error saving settings.';
  335. $_SESSION['admin_msg_type'] = 'error';
  336. }
  337. break;
  338. ​
  339. case 'add_product':
  340. $title = $_POST['product_title'] ?? '';
  341. $slug = !empty($_POST['product_slug']) ? slugify($_POST['product_slug']) : slugify($title);
  342. ​
  343. if (empty($title)) {
  344. $_SESSION['admin_msg'] = 'Title is required.';
  345. $_SESSION['admin_msg_type'] = 'error';
  346. } else {
  347. if (find_product_by_slug($slug)) {
  348. $slug = $slug . '-' . time();
  349. }
  350. $product_id = 'product-' . $slug;
  351. $data = [
  352. 'id' => $product_id,
  353. 'slug' => $slug,
  354. 'title' => $title,
  355. 'description' => $_POST['product_description'] ?? '',
  356. 'blocks' => json_decode($_POST['blocks_data'] ?? '[]', true),
  357. 'created_at' => date('Y-m-d H:i:s'),
  358. 'updated_at' => date('Y-m-d H:i:s'),
  359. 'show_in_latest' => isset($_POST['show_in_latest']) && $_POST['show_in_latest'] == '1',
  360. ];
  361. ​
  362. $data = array_merge($data, build_publish_fields_from_post($_POST));
  363. ​
  364. if (save_page($product_id, $data)) {
  365. $_SESSION['admin_msg'] = "Page \"$title\" has been created!";
  366. $_SESSION['admin_msg_type'] = 'success';
  367. $redirect_tab = 'products';
  368. } else {
  369. $_SESSION['admin_msg'] = 'Error creating page.';
  370. $_SESSION['admin_msg_type'] = 'error';
  371. }
  372. }
  373. break;
  374. ​
  375. case 'save_product':
  376. $product_id = $_POST['product_id'] ?? '';
  377. if (empty($product_id)) {
  378. $_SESSION['admin_msg'] = 'Invalid page ID.';
  379. $_SESSION['admin_msg_type'] = 'error';
  380. } else {
  381. $data = [
  382. 'id' => $product_id,
  383. 'slug' => $_POST['product_slug'] ?? '',
  384. 'title' => $_POST['product_title'] ?? '',
  385. 'description' => $_POST['product_description'] ?? '',
  386. 'blocks' => json_decode($_POST['blocks_data'] ?? '[]', true),
  387. 'updated_at' => date('Y-m-d H:i:s'),
  388. ];
  389. ​
  390. $slug = $_POST['product_slug'] ?? '';
  391. if ($slug !== 'homepage' && $product_id !== 'index') {
  392. $data['show_in_latest'] = isset($_POST['show_in_latest']) && $_POST['show_in_latest'] == '1';
  393. } else {
  394. $data['show_in_latest'] = false;
  395. }
  396. ​
  397. $data = array_merge($data, build_publish_fields_from_post($_POST));
  398. ​
  399. $existing = load_page($product_id);
  400. if ($existing && isset($existing['created_at'])) {
  401. $data['created_at'] = $existing['created_at'];
  402. }
  403. if (save_page($product_id, $data)) {
  404. $_SESSION['admin_msg'] = 'Page has been updated!';
  405. $_SESSION['admin_msg_type'] = 'success';
  406. } else {
  407. $_SESSION['admin_msg'] = 'Error saving page.';
  408. $_SESSION['admin_msg_type'] = 'error';
  409. }
  410. }
  411. break;
  412. ​
  413. case 'delete_product':
  414. $product_id = $_POST['product_id'] ?? '';
  415. $filepath = DATA_DIR . '/' . $product_id . '.php';
  416. ​
  417. if (file_exists($filepath) && unlink($filepath)) {
  418. unset($GLOBALS['_products_cache']);
  419. $_SESSION['admin_msg'] = 'Page has been deleted.';
  420. $_SESSION['admin_msg_type'] = 'success';
  421. } else {
  422. $_SESSION['admin_msg'] = 'Error deleting page.';
  423. $_SESSION['admin_msg_type'] = 'error';
  424. }
  425. $redirect_tab = 'products';
  426. break;
  427. }
  428. ​
  429. $redirect_url = "dashboard.php?tab=" . $redirect_tab;
  430. if ($edit_lang !== null) {
  431. $redirect_url .= '&lang=' . urlencode($edit_lang);
  432. }
  433. if ($redirect_id !== null && preg_match('/^[A-Za-z0-9_-]+$/', $redirect_id)) {
  434. $redirect_url .= '&id=' . urlencode($redirect_id);
  435. }
  436. header("Location: " . $redirect_url);
  437. exit;
  438. }
  439. }
  440. ​
  441. $adminFile = DATA_DIR . '/admin.json';
  442. $adminData = json_decode(file_get_contents($adminFile), true);
  443. $index_data = load_page('index');
  444. $site_settings = get_site_settings();
  445. $products = get_all_products();
  446. $all_pages = get_all_pages();
  447. $csrf_token = generate_csrf_token();
  448. ​
  449. $translated_pages = [];
  450. $edit_lang_name = '';
  451. if ($edit_lang !== null) {
  452. $edit_lang_name = $extra_langs[$edit_lang];
  453. $lang_raw = load_page_lang('settings', $edit_lang) ?? [];
  454. foreach (['site_name', 'site_description', 'logo_text'] as $lang_key) {
  455. $site_settings[$lang_key] = (string) ($lang_raw[$lang_key] ?? '');
  456. }
  457. foreach (['nav_links', 'nav_buttons'] as $lang_key) {
  458. $site_settings[$lang_key] = is_array($lang_raw[$lang_key] ?? null) ? $lang_raw[$lang_key] : [];
  459. }
  460. $site_settings['footer'] = is_array($lang_raw['footer'] ?? null) ? $lang_raw['footer'] : [];
  461. $site_settings['site_language'] = $edit_lang;
  462. $index_data = load_page_lang('index', $edit_lang) ?? ['title' => '', 'description' => '', 'blocks' => []];
  463. $all_pages = get_all_pages($edit_lang);
  464. foreach (get_all_products($edit_lang) as $translated_page) {
  465. $translated_pages[flatly_split_lang_file_id((string) ($translated_page['id'] ?? ''))[1]] = $translated_page;
  466. }
  467. }
  468. ​
  469. $tab = $_GET['tab'] ?? 'index';
  470. $edit_product = null;
  471. $edit_is_new_translation = false;
  472. if ($tab === 'edit' && isset($_GET['id'])) {
  473. if ($edit_lang !== null) {
  474. $base_id = (string) $_GET['id'];
  475. if (preg_match('/^product-[A-Za-z0-9_-]+$/', $base_id)) {
  476. $edit_product = load_page_lang($base_id, $edit_lang);
  477. if (!$edit_product) {
  478. $edit_product = load_page($base_id);
  479. $edit_is_new_translation = (bool) $edit_product;
  480. }
  481. if ($edit_product) {
  482. $edit_product['id'] = $base_id;
  483. }
  484. }
  485. } else {
  486. $edit_product = load_page($_GET['id']);
  487. }
  488. if (!$edit_product) {
  489. $tab = 'products';
  490. }
  491. }
  492. ​
  493. $default_index_blocks = [
  494. [
  495. 'id' => 'hero',
  496. 'type' => 'hero',
  497. 'data' => [
  498. 'badge' => 'Customize it for yourself',
  499. 'title' => 'Build faster and easier',
  500. 'subtitle' => 'Save time and create a website with FlatlyPage CMS.',
  501. 'button_primary' => 'Start Now',
  502. 'button_primary_url' => 'admin/',
  503. 'button_secondary' => 'Learn more',
  504. 'button_secondary_url' => 'https://www.flatlypage.com/',
  505. ]
  506. ],
  507. [
  508. 'id' => 'features',
  509. 'type' => 'features',
  510. 'data' => [
  511. 'title' => 'Powerful Features',
  512. 'subtitle' => 'Everything you need to build and scale your web applications.',
  513. 'items' => [
  514. ['icon' => 'bolt', 'title' => 'Fast & Lightweight', 'description' => 'Optimized for speed, so your pages load instantly.'],
  515. ['icon' => 'layout', 'title' => 'Block-Based Editing', 'description' => 'Simply add, move, and edit blocks to create your pages.'],
  516. ['icon' => 'code', 'title' => 'No Coding Required', 'description' => 'Build beautiful websites without any programming skills.'],
  517. ]
  518. ]
  519. ],
  520. [
  521. 'id' => 'cta',
  522. 'type' => 'cta',
  523. 'data' => [
  524. 'title' => 'Ready to Get Started?',
  525. 'subtitle' => 'Create your website quickly and easily.',
  526. 'button_primary' => 'Launch the dashboard',
  527. 'button_primary_url' => '/admin',
  528. 'button_secondary' => 'Learn more',
  529. 'button_secondary_url' => 'https://www.flatlypage.com/',
  530. ]
  531. ],
  532. ];
  533. ​
  534. $default_product_blocks = [];
  535. ?>
  536. <!DOCTYPE html>
  537. <html lang="en">
  538. ​
  539. <head>
  540. <meta charset="UTF-8">
  541. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  542. <title>Dashboard - <?= e(($site_settings['site_name'] ?? '') ?: SITE_NAME) ?></title>
  543. <link rel="icon" href="admin.ico?v=<?= filemtime(__DIR__ . '/../css/admin.css') ?>" type="image/x-icon">
  544. <?= admin_font_head() ?>
  545. <link rel="stylesheet" href="/css/admin.css?v=<?= filemtime(__DIR__ . '/../css/admin.css') ?>">
  546. <script src="/assets/js/admin-theme.js?v=5"></script>
  547. <style>
  548. .editor-with-preview {
  549. display: grid;
  550. grid-template-columns: 1fr 400px;
  551. gap: 24px;
  552. align-items: start;
  553. }
  554. ​
  555. @media (max-width: 1400px) {
  556. .editor-with-preview {
  557. grid-template-columns: 1fr 350px;
  558. }
  559. }
  560. ​
  561. @media (max-width: 1200px) {
  562. .editor-with-preview {
  563. grid-template-columns: 1fr;
  564. }
  565. ​
  566. .preview-panel {
  567. display: none;
  568. }
  569. }
  570. ​
  571. .editor-panel {
  572. min-width: 0;
  573. }
  574. ​
  575. .preview-panel {
  576. position: sticky;
  577. top: 24px;
  578. background: var(--bg-card);
  579. border: 1px solid var(--border);
  580. border-radius: 12px;
  581. overflow: hidden;
  582. height: calc(100vh - 180px);
  583. min-height: 500px;
  584. max-height: 800px;
  585. display: flex;
  586. flex-direction: column;
  587. }
  588. ​
  589. .preview-toolbar {
  590. display: flex;
  591. align-items: center;
  592. justify-content: space-between;
  593. padding: 12px 16px;
  594. border-bottom: 1px solid var(--border);
  595. background: var(--bg);
  596. flex-shrink: 0;
  597. }
  598. ​
  599. .preview-toolbar-left {
  600. display: flex;
  601. align-items: center;
  602. gap: 8px;
  603. font-size: 0.8125rem;
  604. font-weight: 500;
  605. color: var(--text-muted);
  606. }
  607. ​
  608. .preview-toolbar-left svg {
  609. opacity: 0.7;
  610. }
  611. ​
  612. .preview-toolbar-right {
  613. display: flex;
  614. align-items: center;
  615. gap: 4px;
  616. }
  617. ​
  618. .preview-device-btn {
  619. display: none;
  620. padding: 6px !important;
  621. opacity: 0.5;
  622. transition: opacity 0.2s;
  623. }
  624. ​
  625. .preview-device-btn:hover {
  626. opacity: 0.8;
  627. }
  628. ​
  629. .preview-device-btn.active {
  630. opacity: 1;
  631. background: var(--border);
  632. }
  633. ​
  634. .preview-container {
  635. flex: 1;
  636. overflow: hidden;
  637. background: #060607;
  638. display: flex;
  639. align-items: flex-start;
  640. justify-content: center;
  641. }
  642. ​
  643. .preview-frame-wrapper {
  644. width: 100%;
  645. height: 100%;
  646. border-radius: 8px;
  647. overflow: hidden;
  648. box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
  649. transition: width 0.3s ease, max-width 0.3s ease;
  650. }
  651. ​
  652. .preview-frame-wrapper.tablet {
  653. max-width: 768px;
  654. }
  655. ​
  656. .preview-frame-wrapper.mobile {
  657. max-width: 375px;
  658. }
  659. ​
  660. .preview-frame-wrapper iframe {
  661. width: 100%;
  662. height: 100%;
  663. border: none;
  664. transform-origin: top left;
  665. }
  666. ​
  667. .preview-frame-wrapper.loading::after {
  668. content: '';
  669. position: absolute;
  670. top: 50%;
  671. left: 50%;
  672. width: 24px;
  673. height: 24px;
  674. margin: -12px 0 0 -12px;
  675. border: 2px solid var(--border);
  676. border-top-color: var(--primary);
  677. border-radius: 50%;
  678. animation: spin 0.8s linear infinite;
  679. }
  680. ​
  681. @keyframes spin {
  682. to {
  683. transform: rotate(360deg);
  684. }
  685. }
  686. </style>
  687. ​
  688. </head>
  689. ​
  690. <body>
  691. <div class="app">
  692. <?php
  693. admin_sidebar($tab === 'edit' ? 'products' : $tab);
  694. ?>
  695. ​
  696. <main class="main">
  697. <header class="main-header">
  698. ​
  699. <button class="mobile-nav-toggle" onclick="document.querySelector('.sidebar').classList.add('open')">
  700. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  701. <line x1="3" y1="6" x2="21" y2="6" />
  702. <line x1="3" y1="12" x2="21" y2="12" />
  703. <line x1="3" y1="18" x2="21" y2="18" />
  704. </svg>
  705. </button>
  706. ​
  707. <div class="main-header-inner">
  708. <h1>
  709. <?php if ($tab === 'index'): ?>Edit Homepage
  710. <?php elseif ($tab === 'products'): ?>All Pages
  711. <?php elseif ($tab === 'new'): ?>Create New Page
  712. <?php elseif ($tab === 'settings'): ?>Site Settings
  713. <?php elseif ($tab === 'edit' && $edit_product): ?>Edit: <?= e($edit_product['title']) ?>
  714. <?php endif; ?>
  715. </h1>
  716. <div class="header-actions">
  717. <?php if ($extra_langs && in_array($tab, ['index', 'products', 'settings', 'edit'], true)): ?>
  718. <select class="form-input" id="editLangSwitch" aria-label="Editing language" style="width: auto; min-width: 150px; padding-top: 6px; padding-bottom: 6px;" onchange="switchEditLang(this.value)">
  719. <option value="" data-short="<?= e(strtoupper(flatly_default_lang())) ?>"><?= e(flatly_default_language_name()) ?> (<?= e(flatly_default_lang()) ?>) - main</option>
  720. <?php foreach ($extra_langs as $lang_code => $lang_label): ?>
  721. <option value="<?= e($lang_code) ?>" data-short="<?= e(strtoupper($lang_code)) ?>" <?= $edit_lang === $lang_code ? 'selected' : '' ?>><?= e($lang_label) ?> (<?= e($lang_code) ?>)</option>
  722. <?php endforeach; ?>
  723. </select>
  724. <script>
  725. (function() {
  726. var select = document.getElementById('editLangSwitch');
  727. var mobile = window.matchMedia('(max-width: 768px)');
  728. Array.prototype.forEach.call(select.options, function(option) { option.dataset.full = option.textContent; });
  729. function apply() {
  730. Array.prototype.forEach.call(select.options, function(option) {
  731. option.textContent = mobile.matches ? option.dataset.short : option.dataset.full;
  732. });
  733. select.classList.toggle('lang-switch-short', mobile.matches);
  734. }
  735. apply();
  736. if (mobile.addEventListener) mobile.addEventListener('change', apply); else mobile.addListener(apply);
  737. })();
  738. </script>
  739. <?php endif; ?>
  740. <a class="btn btn-secondary btn-sm" href="javascript:void(0)" onclick="toggleTheme()" id="theme-toggle-btn">
  741. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16">
  742. <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
  743. </svg>
  744. <span>Theme</span>
  745. </a>
  746. ​
  747. <?php if ($tab === 'index'): ?>
  748. <a href="<?= e(flatly_lang_url($edit_lang, '/')) ?>" target="_blank" class="btn btn-secondary btn-sm">
  749. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  750. <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
  751. <circle cx="12" cy="12" r="3" />
  752. </svg>
  753. Preview
  754. </a>
  755. <?php elseif ($tab === 'products'): ?>
  756. <a href="?tab=new" class="btn btn-primary btn-sm">
  757. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  758. <line x1="12" y1="5" x2="12" y2="19" />
  759. <line x1="5" y1="12" x2="19" y2="12" />
  760. </svg>
  761. New Page
  762. </a>
  763. <?php elseif ($tab === 'edit' && $edit_product): ?>
  764. <a href="<?= e(flatly_lang_url($edit_lang, '/' . $edit_product['slug'])) ?>" target="_blank" class="btn btn-secondary btn-sm">
  765. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  766. <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
  767. <circle cx="12" cy="12" r="3" />
  768. </svg>
  769. Preview
  770. </a>
  771. <?php endif; ?>
  772. </div>
  773. </div>
  774. </header>
  775. ​
  776. <div class="main-content">
  777. <?php if ($message): ?>
  778. <div class="message <?= $message_type ?>">
  779. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="20" height="20">
  780. <?php if ($message_type === 'success'): ?>
  781. <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
  782. <polyline points="22 4 12 14.01 9 11.01" />
  783. <?php else: ?>
  784. <circle cx="12" cy="12" r="10" />
  785. <line x1="12" y1="8" x2="12" y2="12" />
  786. <line x1="12" y1="16" x2="12.01" y2="16" />
  787. <?php endif; ?>
  788. </svg>
  789. <?= e($message) ?>
  790. </div>
  791. <?php endif; ?>
  792. ​
  793. <?php
  794. $sync_target = ['settings' => 'settings', 'index' => 'index', 'products' => 'pages', 'edit' => 'product'][$tab] ?? null;
  795. if ($edit_lang !== null && $sync_target !== null): ?>
  796. <div class="card" style="margin-bottom: 24px;">
  797. <div class="card-body" style="display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap;">
  798. <div>
  799. <strong>Editing translation: <?= e($edit_lang_name) ?> (<?= e($edit_lang) ?>)</strong>
  800. <p class="form-hint" style="margin: 4px 0 0;">
  801. <?php if ($tab === 'settings'): ?>
  802. Only names, menu and footer are translated. Fonts, colors, logo and other options are shared with the main language.
  803. <?php elseif ($edit_is_new_translation): ?>
  804. This page has no translation yet. The fields below are filled from the main language and will be saved as the translation.
  805. <?php else: ?>
  806. Empty translated settings fall back to the main language on the website.
  807. <?php endif; ?>
  808. </p>
  809. </div>
  810. <?php
  811. $translation_exists = match ($tab) {
  812. 'settings' => is_file(DATA_DIR . flatly_lang_file_id($edit_lang, 'settings') . '.php'),
  813. 'index' => is_file(DATA_DIR . flatly_lang_file_id($edit_lang, 'index') . '.php'),
  814. 'edit' => !$edit_is_new_translation && $edit_product,
  815. default => false,
  816. };
  817. ?>
  818. <?php if ($translation_exists): ?>
  819. <form method="POST" action="" onsubmit="return confirm('Delete this translation? The main language content will be shown for this language instead. This cannot be undone.');">
  820. <input type="hidden" name="csrf_token" value="<?= e($csrf_token) ?>">
  821. <input type="hidden" name="action" value="delete_translation">
  822. <input type="hidden" name="lang" value="<?= e($edit_lang) ?>">
  823. <input type="hidden" name="target" value="<?= e($sync_target) ?>">
  824. <?php if ($tab === 'edit' && $edit_product): ?>
  825. <input type="hidden" name="product_id" value="<?= e($edit_product['id']) ?>">
  826. <?php endif; ?>
  827. <button type="submit" class="btn btn-ghost btn-sm" style="color: var(--error);">Delete translation</button>
  828. </form>
  829. <?php endif; ?>
  830. <?php if (!$edit_is_new_translation): ?>
  831. <form method="POST" action="" onsubmit="return confirm('Copy items that are missing in this translation from the main language? Existing translated content will not be changed.');">
  832. <input type="hidden" name="csrf_token" value="<?= e($csrf_token) ?>">
  833. <input type="hidden" name="action" value="sync_from_main">
  834. <input type="hidden" name="lang" value="<?= e($edit_lang) ?>">
  835. <input type="hidden" name="target" value="<?= e($sync_target) ?>">
  836. <?php if ($tab === 'edit' && $edit_product): ?>
  837. <input type="hidden" name="product_id" value="<?= e($edit_product['id']) ?>">
  838. <?php endif; ?>
  839. <button type="submit" class="btn btn-secondary btn-sm">Copy missing from main language</button>
  840. </form>
  841. <?php endif; ?>
  842. </div>
  843. </div>
  844. <?php endif; ?>
  845. ​
  846. <?php if ($tab === 'settings'): ?>
  847. <form method="POST" action="" id="settingsForm">
  848. <input type="hidden" name="csrf_token" value="<?= e($csrf_token) ?>">
  849. <input type="hidden" name="action" value="save_settings">
  850. <input type="hidden" name="languages_data" id="languagesDataField" value="">
  851. <?php if ($edit_lang !== null): ?><input type="hidden" name="lang" value="<?= e($edit_lang) ?>"><?php endif; ?>
  852. <input type="hidden" name="nav_links" id="navLinksData" value="">
  853. <input type="hidden" name="nav_buttons" id="navButtonsData" value="">
  854. <input type="hidden" name="footer_data" id="footerData" value="">
  855. ​
  856. <div class="settings-tabs">
  857. <button type="button" class="settings-tab active" data-tab="general">General</button>
  858. <button type="button" class="settings-tab" data-tab="navigation">Navigation</button>
  859. <button type="button" class="settings-tab" data-tab="footer">Footer</button>
  860. <?php if ($edit_lang === null): ?>
  861. <button type="button" class="settings-tab" data-tab="languages">Other languages</button>
  862. <button type="button" class="settings-tab" data-tab="ai">AI Helper</button>
  863. <button type="button" class="settings-tab" data-tab="analytics">Analytics</button>
  864. <button type="button" class="settings-tab" data-tab="custom">Custom</button>
  865. <?php endif; ?>
  866. ​
  867. </div>
  868. ​
  869. <div class="settings-panel active" id="panel-general">
  870. <div class="card">
  871. <div class="card-header">
  872. <h3 class="card-title">General Settings</h3>
  873. </div>
  874. <div class="card-body">
  875. <div class="form-row">
  876. <div class="form-group">
  877. <label class="form-label">Site Name</label>
  878. <input type="text" name="site_name" class="form-input" value="<?= e($site_settings['site_name'] ?? '') ?>">
  879. </div>
  880. <div class="form-group">
  881. <label class="form-label">Logo Text</label>
  882. <input type="text" name="logo_text" class="form-input" value="<?= e($site_settings['logo_text'] ?? '') ?>">
  883. <p class="form-hint">Displayed in header and footer</p>
  884. </div>
  885. </div>
  886. <div class="form-group">
  887. <label class="form-label">Site Description</label>
  888. <textarea name="site_description" class="form-textarea"><?= e($site_settings['site_description'] ?? '') ?></textarea>
  889. <p class="form-hint">Used for SEO meta description</p>
  890. </div>
  891. <div class="lang-shared"<?= $edit_lang !== null ? ' style="display: none;"' : '' ?>>
  892. <div class="form-group">
  893. <label class="form-label">Site Language</label>
  894. <input type="text" name="site_language" class="form-input" value="<?= e($site_settings['site_language'] ?? 'en') ?>">
  895. <p class="form-hint">Used for SEO (e.g. en, pl, de)</p>
  896. </div>
  897. <div class="form-row">
  898. <div class="form-group">
  899. <label class="form-label">Logo Image URL (optional)</label>
  900. <input type="text" data-media="image" name="logo_image" class="form-input" value="<?= e($site_settings['logo_image'] ?? '') ?>" placeholder="https://...">
  901. </div>
  902. <div class="form-group">
  903. <label class="form-label">Favicon URL (optional)</label>
  904. <input type="text" data-media="image" name="favicon" class="form-input" value="<?= e($site_settings['favicon'] ?? '') ?>" placeholder="https://...">
  905. </div>
  906. </div>
  907. <div class="form-row">
  908. <div class="form-group">
  909. <label class="form-label">Text Font</label>
  910. <?php $current_text_font = get_text_font($site_settings); ?>
  911. <select name="website_font" class="form-input">
  912. <?php foreach (array_keys(get_available_fonts()) as $font_name): ?>
  913. <option value="<?= e($font_name) ?>" <?= $current_text_font === $font_name ? 'selected' : '' ?>><?= e($font_name) ?></option>
  914. <?php endforeach; ?>
  915. </select>
  916. <p class="form-hint">Content of pages (blocks) shown for reading</p>
  917. </div>
  918. <div class="form-group">
  919. <label class="form-label">Contact Email</label>
  920. <input type="email" name="contact_email" class="form-input" value="<?= e($site_settings['contact_email'] ?? '') ?>" placeholder="contact@example.com">
  921. </div>
  922. </div>
  923. <div class="form-row">
  924. <div class="form-group">
  925. <label class="form-label">Headings Font (H1–H6)</label>
  926. <?php $current_headings_font = get_headings_font($site_settings); ?>
  927. <select name="headings_font" class="form-input">
  928. <option value="" <?= $current_headings_font === '' ? 'selected' : '' ?>>Same as text font</option>
  929. <?php foreach (array_keys(get_available_fonts()) as $font_name): ?>
  930. <option value="<?= e($font_name) ?>" <?= $current_headings_font === $font_name ? 'selected' : '' ?>><?= e($font_name) ?></option>
  931. <?php endforeach; ?>
  932. </select>
  933. <p class="form-hint">Font for h1, h2, h3, h4, h5 and h6 on the website</p>
  934. </div>
  935. <div class="form-group">
  936. <label class="form-label">Interface Font</label>
  937. <?php $current_interface_font = get_site_interface_font($site_settings); ?>
  938. <select name="interface_font" class="form-input">
  939. <?php foreach (array_keys(get_available_fonts()) as $font_name): ?>
  940. <option value="<?= e($font_name) ?>" <?= $current_interface_font === $font_name ? 'selected' : '' ?>><?= e($font_name) ?></option>
  941. <?php endforeach; ?>
  942. </select>
  943. <p class="form-hint">Navigation, buttons, footer and forms on the website</p>
  944. </div>
  945. </div>
  946. <div class="form-row">
  947. <div class="form-group">
  948. <label class="form-label">Logo Text Font</label>
  949. <?php $current_logo_font = get_optional_font($site_settings, 'logo_font'); ?>
  950. <select name="logo_font" class="form-input">
  951. <option value="" <?= $current_logo_font === '' ? 'selected' : '' ?>>Same as text font</option>
  952. <?php foreach (array_keys(get_available_fonts()) as $font_name): ?>
  953. <option value="<?= e($font_name) ?>" <?= $current_logo_font === $font_name ? 'selected' : '' ?>><?= e($font_name) ?></option>
  954. <?php endforeach; ?>
  955. </select>
  956. <p class="form-hint">Font of the logo text in the header</p>
  957. </div>
  958. <div class="form-group">
  959. <label class="form-label">Prices Font</label>
  960. <?php $current_price_font = get_optional_font($site_settings, 'price_font'); ?>
  961. <select name="price_font" class="form-input">
  962. <option value="" <?= $current_price_font === '' ? 'selected' : '' ?>>Same as text font</option>
  963. <?php foreach (array_keys(get_available_fonts()) as $font_name): ?>
  964. <option value="<?= e($font_name) ?>" <?= $current_price_font === $font_name ? 'selected' : '' ?>><?= e($font_name) ?></option>
  965. <?php endforeach; ?>
  966. </select>
  967. <p class="form-hint">Font of prices in Pricing blocks</p>
  968. </div>
  969. </div>
  970. <div class="form-row">
  971. <div class="form-group">
  972. <label class="form-label">Website Sitemap</label>
  973. <select name="website_sitemap" class="form-input">
  974. <option value="true" <?= ($sitemap_settings['sitemap'] ?? false) ? 'selected' : '' ?>>Enabled</option>
  975. <option value="false" <?= !($sitemap_settings['sitemap'] ?? false) ? 'selected' : '' ?>>Disabled</option>
  976. </select>
  977. </div>
  978. <div class="form-group">
  979. <label class="form-label">Website URL</label>
  980. <input type="text" name="website_domain" class="form-input" value="<?= e($sitemap_settings['website_domain'] ?? '') ?>" placeholder="example.com">
  981. <p class="form-hint">Leave empty to detect the address automatically. When set (e.g. example.com or www.example.com), it is used for hreflang, structured data and the sitemap, and the site sends noindex when opened from any other host.</p>
  982. </div>
  983. </div>
  984. <br>
  985. <div class="form-row">
  986. <div class="form-group">
  987. <label class="form-label">Show Recent Pages</label>
  988. <div class="form-switch">
  989. <input type="checkbox" name="recent_pages" id="recent_pages" value="true" <?= ($site_settings['recent_pages_enabled'] ?? false) ? 'checked' : '' ?>>
  990. <label for="recent_pages"></label>
  991. </div>
  992. <p class="form-hint">Display recently updated pages in the footer/navigation</p>
  993. </div>
  994. <div class="form-group">
  995. <label class="form-label">Contact Page</label>
  996. <div class="form-switch">
  997. <input type="checkbox" name="contact_page_enabled" id="contact_page_enabled" value="true" <?= ($site_settings['contact_page_enabled'] ?? false) ? 'checked' : '' ?>>
  998. <label for="contact_page_enabled"></label>
  999. </div>
  1000. <p class="form-hint">Enable the contact page</p>
  1001. </div>
  1002. </div>
  1003. </div>
  1004. </div>
  1005. </div>
  1006. </div>
  1007. ​
  1008. <div class="settings-panel" id="panel-navigation">
  1009. <div class="card" style="margin-bottom: 24px;">
  1010. <div class="card-header">
  1011. <h3 class="card-title">Navigation Links</h3>
  1012. </div>
  1013. <div class="card-body">
  1014. <div id="navLinksContainer"></div>
  1015. <button type="button" class="repeater-add" onclick="addNavLink()">
  1016. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16">
  1017. <line x1="12" y1="5" x2="12" y2="19" />
  1018. <line x1="5" y1="12" x2="19" y2="12" />
  1019. </svg>
  1020. Add Link
  1021. </button>
  1022. </div>
  1023. </div>
  1024. ​
  1025. <div class="card">
  1026. <div class="card-header">
  1027. <h3 class="card-title">Navigation Buttons</h3>
  1028. </div>
  1029. <div class="card-body">
  1030. <div id="navButtonsContainer"></div>
  1031. <button type="button" class="repeater-add" onclick="addNavButton()">
  1032. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16">
  1033. <line x1="12" y1="5" x2="12" y2="19" />
  1034. <line x1="5" y1="12" x2="19" y2="12" />
  1035. </svg>
  1036. Add Button
  1037. </button>
  1038. </div>
  1039. </div>
  1040. </div>
  1041. ​
  1042. <div class="settings-panel" id="panel-footer">
  1043. <div class="card" style="margin-bottom: 24px;">
  1044. <div class="card-header">
  1045. <h3 class="card-title">Footer Brand</h3>
  1046. </div>
  1047. <div class="card-body">
  1048. <div class="form-group">
  1049. <label class="form-label">Brand Description</label>
  1050. <textarea class="form-textarea" id="footerBrandDesc" oninput="updateFooterData()"><?= e($site_settings['footer']['brand_description'] ?? '') ?></textarea>
  1051. </div>
  1052. <div class="form-group">
  1053. <label class="form-label">Copyright Text</label>
  1054. <input type="text" class="form-input" id="footerCopyright" value="<?= e($site_settings['footer']['copyright'] ?? '') ?>" oninput="updateFooterData()">
  1055. </div>
  1056. </div>
  1057. ​
  1058. </div>
  1059. ​
  1060. <div class="card" style="margin-bottom: 24px;">
  1061. <div class="card-header">
  1062. <h3 class="card-title">Footer Columns</h3>
  1063. </div>
  1064. <div class="card-body">
  1065. <div id="footerColumnsContainer"></div>
  1066. <button type="button" class="repeater-add" onclick="addFooterColumn()">
  1067. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16">
  1068. <line x1="12" y1="5" x2="12" y2="19" />
  1069. <line x1="5" y1="12" x2="19" y2="12" />
  1070. </svg>
  1071. Add Column
  1072. </button>
  1073. </div>
  1074. </div>
  1075. ​
  1076. <div class="card" style="margin-bottom: 24px;">
  1077. <div class="card-header">
  1078. <h3 class="card-title">Social Links</h3>
  1079. </div>
  1080. <div class="card-body">
  1081. <div id="socialLinksContainer"></div>
  1082. <button type="button" class="repeater-add" onclick="addSocialLink()">
  1083. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16">
  1084. <line x1="12" y1="5" x2="12" y2="19" />
  1085. <line x1="5" y1="12" x2="19" y2="12" />
  1086. </svg>
  1087. Add Social Link
  1088. </button>
  1089. </div>
  1090. </div>
  1091. ​
  1092. <div class="card">
  1093. <div class="card-header">
  1094. <h3 class="card-title">Bottom Links</h3>
  1095. </div>
  1096. <div class="card-body">
  1097. <div id="bottomLinksContainer"></div>
  1098. <button type="button" class="repeater-add" onclick="addBottomLink()">
  1099. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16">
  1100. <line x1="12" y1="5" x2="12" y2="19" />
  1101. <line x1="5" y1="12" x2="19" y2="12" />
  1102. </svg>
  1103. Add Link
  1104. </button>
  1105. </div>
  1106. </div>
  1107. ​
  1108. ​
  1109. </div>
  1110. ​
  1111. ​
  1112. <?php if ($edit_lang === null): ?>
  1113. <div class="settings-panel" id="panel-languages">
  1114. <div class="card">
  1115. <div class="card-header">
  1116. <h3 class="card-title">Other languages</h3>
  1117. <p style="font-size: 0.875rem; color: var(--text-muted); margin-top: 8px;">
  1118. Add the languages of your website: a full name in any script (English, Polski, 中文, العربية) and a short code (en, pl, de, zh-tw). Add your main language (<?= e(flatly_default_lang()) ?>) too, so it has a proper name in the switcher. The footer shows a language switcher when at least one language other than the main one is added. After saving, use the language selector at the top of Homepage, Pages and Settings to edit translations.
  1119. </p>
  1120. </div>
  1121. <div class="card-body">
  1122. <?php if (!flatly_lang_prefix_supported()): ?>
  1123. <div class="message error" style="display: block; margin-bottom: 16px;">
  1124. <strong>Pretty language URLs (/pl/page) are not enabled in your .htaccess.</strong> Until then language versions are available as /page?lang=pl and work normally. To enable /pl/page URLs, add these lines to .htaccess right before the "Page slug routing" section:
  1125. <pre style="margin-top: 8px; white-space: pre-wrap; user-select: all;"><?= e(FLATLY_LANG_HTACCESS_MARKER . "\nRewriteRule ^([a-z]{2,3}(?:-[a-z0-9]{2,8})?)/?$ index.php?lang=$1 [L,QSA]\nRewriteRule ^([a-z]{2,3}(?:-[a-z0-9]{2,8})?)/contact/?$ contact.php?lang=$1 [L,QSA]\nRewriteRule ^([a-z]{2,3}(?:-[a-z0-9]{2,8})?)/([a-zA-Z0-9\\-]+)/?$ page.php?lang=$1&slug=$2 [L,QSA]") ?></pre>
  1126. </div>
  1127. <?php endif; ?>
  1128. <div class="form-group" style="margin-bottom: 24px;">
  1129. <label class="form-label">Main language name (<?= e(flatly_default_lang()) ?>)</label>
  1130. <input type="text" name="site_language_name" class="form-input" maxlength="50" value="<?= e((string) (flatly_main_settings()['site_language_name'] ?? '')) ?>" placeholder="<?= e(flatly_default_language_name()) ?>">
  1131. <p class="form-hint">Shown in the footer language switcher and in the admin language selector, e.g. English, Polski, Deutsch. Leave empty to use the name from the list below or the language code.</p>
  1132. </div>
  1133. <div id="languagesContainer"></div>
  1134. <button type="button" class="repeater-add" onclick="addLanguage()">
  1135. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16">
  1136. <line x1="12" y1="5" x2="12" y2="19" />
  1137. <line x1="5" y1="12" x2="19" y2="12" />
  1138. </svg>
  1139. Add Language
  1140. </button>
  1141. <div class="form-group" style="margin-top: 24px;">
  1142. <label class="form-label">Detect visitor language</label>
  1143. <div class="form-switch">
  1144. <input type="checkbox" name="language_autodetect" id="language_autodetect" value="1" <?= !empty(flatly_main_settings()['language_autodetect']) ? 'checked' : '' ?>>
  1145. <label for="language_autodetect"></label>
  1146. </div>
  1147. <p class="form-hint">Privacy first: when a new visitor opens the main-language version of the homepage or of a translated page, the browser's language is compared with your languages and the visitor is sent to the matching version. It runs only in the visitor's browser: no cookies, no server logs, no data sent anywhere. After a visitor picks a language in the footer, only that language code is remembered on their own device. Off by default.</p>
  1148. </div>
  1149. </div>
  1150. </div>
  1151. </div>
  1152. <?php endif; ?>
  1153. ​
  1154. <?php if ($edit_lang === null): ?>
  1155. <?php if ($edit_lang === null):
  1156. $ai = AiHelper::settings();
  1157. $ai_provider_models = [];
  1158. foreach (AiHelper::PROVIDERS as $provider_id => $provider_info) {
  1159. $ai_provider_models[$provider_id] = ['default' => $provider_info['default'], 'models' => $provider_info['models']];
  1160. }
  1161. $ai_is_custom_model = !isset(AiHelper::PROVIDERS[$ai['provider']]['models'][$ai['model']]);
  1162. ?>
  1163. <div class="settings-panel" id="panel-ai">
  1164. <input type="hidden" name="ai_settings_present" value="1">
  1165. <div class="card">
  1166. <div class="card-header">
  1167. <h3 class="card-title">AI Helper</h3>
  1168. </div>
  1169. <div class="card-body">
  1170. <div class="form-group">
  1171. <label class="form-label">Enable AI Helper</label>
  1172. <div class="form-switch">
  1173. <input type="checkbox" name="ai_enabled" id="ai_enabled" value="true" <?= $ai['enabled'] ? 'checked' : '' ?>>
  1174. <label for="ai_enabled"></label>
  1175. </div>
  1176. <p class="form-hint">Shows the AI button in the homepage and page editors. The AI only proposes changes (write, improve, fix, translate, add or reorder blocks); nothing is applied until you press Apply, and every change can be undone.</p>
  1177. </div>
  1178. ​
  1179. <div class="form-row">
  1180. <div class="form-group">
  1181. <label class="form-label">Provider</label>
  1182. <select name="ai_provider" id="aiProvider" class="form-input">
  1183. <?php foreach (AiHelper::PROVIDERS as $provider_id => $provider_info): ?>
  1184. <option value="<?= e($provider_id) ?>" <?= $ai['provider'] === $provider_id ? 'selected' : '' ?>><?= e($provider_info['name']) ?></option>
  1185. <?php endforeach; ?>
  1186. </select>
  1187. </div>
  1188. <div class="form-group">
  1189. <label class="form-label">Model</label>
  1190. <select name="ai_model" id="aiModel" class="form-input" data-current="<?= e($ai_is_custom_model ? '__custom' : $ai['model']) ?>"></select>
  1191. <input type="text" name="ai_model_custom" id="aiModelCustom" class="form-input" style="margin-top: 8px; display: none;" maxlength="80" placeholder="Model ID" value="<?= e($ai_is_custom_model ? $ai['model'] : '') ?>">
  1192. </div>
  1193. </div>
  1194. ​
  1195. <div class="form-group">
  1196. <label class="form-label">API Key</label>
  1197. <input type="password" name="ai_api_key" class="form-input" autocomplete="new-password" maxlength="300" placeholder="<?= $ai['key'] !== '' ? 'Saved - leave empty to keep' : 'Paste your API key' ?>">
  1198. <p class="form-hint">Stored on your server only and never sent to the browser. Get a key at <a href="https://aistudio.google.com/apikey" target="_blank" rel="noopener noreferrer">Google AI Studio</a> (Gemini) or <a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noopener noreferrer">the Anthropic Console</a> (Claude).</p>
  1199. <?php if ($ai['key'] !== ''): ?>
  1200. <p class="form-hint"><label><input type="checkbox" name="ai_api_key_clear" value="1"> Remove saved key</label></p>
  1201. <?php endif; ?>
  1202. </div>
  1203. ​
  1204. <div class="form-group">
  1205. <button type="button" class="btn btn-secondary btn-sm" id="aiTestButton" <?= $ai['key'] === '' ? 'disabled' : '' ?>>Test saved connection</button>
  1206. <span class="form-hint" id="aiTestResult" style="margin-left: 8px;"></span>
  1207. <p class="form-hint">Your page text is sent to the selected provider when you use the AI helper. The test uses the saved settings, so save first after changing them.</p>
  1208. </div>
  1209. </div>
  1210. </div>
  1211. </div>
  1212. <script>
  1213. (function() {
  1214. var models = <?= json_encode($ai_provider_models, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG) ?>;
  1215. var provider = document.getElementById('aiProvider');
  1216. var model = document.getElementById('aiModel');
  1217. var custom = document.getElementById('aiModelCustom');
  1218. ​
  1219. function fill(selected) {
  1220. var info = models[provider.value];
  1221. model.innerHTML = '';
  1222. Object.keys(info.models).forEach(function(id) {
  1223. var option = document.createElement('option');
  1224. option.value = id;
  1225. option.textContent = info.models[id];
  1226. model.appendChild(option);
  1227. });
  1228. var other = document.createElement('option');
  1229. other.value = '__custom';
  1230. other.textContent = 'Other (enter model ID)';
  1231. model.appendChild(other);
  1232. model.value = info.models[selected] || selected === '__custom' ? selected : info.default;
  1233. custom.style.display = model.value === '__custom' ? '' : 'none';
  1234. }
  1235. ​
  1236. provider.addEventListener('change', function() { fill(''); });
  1237. model.addEventListener('change', function() { custom.style.display = model.value === '__custom' ? '' : 'none'; });
  1238. fill(model.dataset.current);
  1239. ​
  1240. var button = document.getElementById('aiTestButton');
  1241. var result = document.getElementById('aiTestResult');
  1242. button.addEventListener('click', function() {
  1243. button.disabled = true;
  1244. result.textContent = 'Testing...';
  1245. fetch(flatlyUrl('/api/ai-helper'), {
  1246. method: 'POST',
  1247. credentials: 'same-origin',
  1248. headers: {'Content-Type': 'application/json', 'X-CSRF-Token': <?= json_encode($csrf_token) ?>},
  1249. body: JSON.stringify({action: 'test'})
  1250. }).then(function(response) { return response.json(); }).then(function(data) {
  1251. result.textContent = data.ok ? 'Connection works.' : (data.error || 'The test failed.');
  1252. }).catch(function() {
  1253. result.textContent = 'Could not reach the server.';
  1254. }).finally(function() { button.disabled = false; });
  1255. });
  1256. })();
  1257. </script>
  1258. <?php endif; ?>
  1259. ​
  1260. <?php $analytics = flatly_analytics_settings(); ?>
  1261. <div class="settings-panel" id="panel-analytics">
  1262. <div class="card">
  1263. <div class="card-header">
  1264. <h3 class="card-title">Consent manager</h3>
  1265. </div>
  1266. <div class="card-body">
  1267. <div class="form-group analytics-switch">
  1268. <div class="form-switch">
  1269. <input type="checkbox" id="analyticsConsent" <?= $analytics['consent_enabled'] ? 'checked' : '' ?>>
  1270. <label for="analyticsConsent"></label>
  1271. </div>
  1272. <label for="analyticsConsent">Enable consent manager (scripts that require consent run only after the visitor accepts)</label>
  1273. </div>
  1274. <div class="form-group analytics-switch">
  1275. <div class="form-switch">
  1276. <input type="checkbox" id="analyticsCookieIcon" <?= $analytics['cookie_icon'] ? 'checked' : '' ?>>
  1277. <label for="analyticsCookieIcon"></label>
  1278. </div>
  1279. <label for="analyticsCookieIcon">Show cookie icon in footer (lets visitors reopen the consent manager)</label>
  1280. </div>
  1281. ​
  1282. <div class="form-group">
  1283. <label class="form-label" for="analyticsBannerText">Banner text</label>
  1284. <textarea id="analyticsBannerText" class="form-textarea" maxlength="600"><?= e((string) $analytics['banner_text']) ?></textarea>
  1285. </div>
  1286. <div class="form-row">
  1287. <div class="form-group">
  1288. <label class="form-label" for="analyticsAccept">Accept button</label>
  1289. <input type="text" id="analyticsAccept" class="form-input" maxlength="40" value="<?= e((string) $analytics['accept_label']) ?>">
  1290. </div>
  1291. <div class="form-group">
  1292. <label class="form-label" for="analyticsReject">Reject button</label>
  1293. <input type="text" id="analyticsReject" class="form-input" maxlength="40" value="<?= e((string) $analytics['reject_label']) ?>">
  1294. </div>
  1295. </div>
  1296. <div class="form-group">
  1297. <label class="form-label" for="analyticsPrivacy">Privacy policy URL (optional)</label>
  1298. <input type="text" id="analyticsPrivacy" class="form-input" maxlength="500" placeholder="/privacy-policy" value="<?= e((string) $analytics['privacy_url']) ?>">
  1299. <p class="form-hint">When the consent manager is off, every active script runs right away. The visitor's choice is remembered for 180 days.</p>
  1300. </div>
  1301. <div class="analytics-actions">
  1302. <button type="button" class="btn btn-primary btn-sm" id="analyticsConsentSave">Save</button>
  1303. <span class="analytics-status" id="analyticsConsentStatus" role="status"></span>
  1304. </div>
  1305. </div>
  1306. </div>
  1307. ​
  1308. <div class="card" style="margin-top: 24px;">
  1309. <table class="table analytics-table">
  1310. <thead>
  1311. <tr>
  1312. <th>Name</th>
  1313. <th>Placement</th>
  1314. <th>Requires consent</th>
  1315. <th>Active</th>
  1316. <th></th>
  1317. </tr>
  1318. </thead>
  1319. <tbody id="analyticsRows"></tbody>
  1320. </table>
  1321. </div>
  1322. ​
  1323. <div class="card" style="margin-top: 24px;">
  1324. <div class="card-header">
  1325. <h3 class="card-title" id="analyticsFormTitle">New script</h3>
  1326. </div>
  1327. <div class="card-body">
  1328. <div class="form-group">
  1329. <label class="form-label" for="analyticsName">Name</label>
  1330. <input type="text" id="analyticsName" class="form-input" maxlength="100" autocomplete="off" placeholder="For example: PriviMetrics">
  1331. </div>
  1332. <div class="form-group">
  1333. <label class="form-label" for="analyticsCode">Script code (raw HTML / &lt;style&gt; / &lt;script&gt;)</label>
  1334. <textarea id="analyticsCode" class="form-textarea form-code" rows="10" maxlength="<?= CUSTOM_CODE_MAX_LENGTH ?>" spellcheck="false" autocomplete="off" autocapitalize="off"></textarea>
  1335. </div>
  1336. <div class="form-group">
  1337. <label class="form-label" for="analyticsPlacement">Placement</label>
  1338. <select id="analyticsPlacement" class="form-input">
  1339. <?php foreach (ANALYTICS_PLACEMENTS as $placement_id => $placement_label): ?>
  1340. <option value="<?= e($placement_id) ?>"><?= e($placement_label) ?></option>
  1341. <?php endforeach; ?>
  1342. </select>
  1343. </div>
  1344. <div class="form-group analytics-switch">
  1345. <div class="form-switch">
  1346. <input type="checkbox" id="analyticsRequiresConsent" checked>
  1347. <label for="analyticsRequiresConsent"></label>
  1348. </div>
  1349. <label for="analyticsRequiresConsent">Requires visitor consent before running</label>
  1350. </div>
  1351. <div class="form-group analytics-switch">
  1352. <div class="form-switch">
  1353. <input type="checkbox" id="analyticsActive" checked>
  1354. <label for="analyticsActive"></label>
  1355. </div>
  1356. <label for="analyticsActive">Active</label>
  1357. </div>
  1358. <div class="analytics-actions">
  1359. <button type="button" class="btn btn-primary btn-sm" id="analyticsSave">Save script</button>
  1360. <button type="button" class="btn btn-ghost btn-sm" id="analyticsCancel" hidden>Cancel</button>
  1361. <span class="analytics-status" id="analyticsStatus" role="status"></span>
  1362. </div>
  1363. </div>
  1364. </div>
  1365. <script>
  1366. (function() {
  1367. var endpoint = '/api/analytics';
  1368. var csrf = <?= json_encode($csrf_token) ?>;
  1369. var placements = <?= json_encode(ANALYTICS_PLACEMENTS, JSON_HEX_TAG | JSON_HEX_AMP) ?>;
  1370. var max = <?= ANALYTICS_MAX_SCRIPTS ?>;
  1371. var scripts = <?= json_encode(array_values((array) $analytics['scripts']), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_INVALID_UTF8_SUBSTITUTE) ?>;
  1372. var editing = -1;
  1373. var busy = false;
  1374. ​
  1375. var $ = function(id) { return document.getElementById(id); };
  1376. var rows = $('analyticsRows');
  1377. var fields = {
  1378. name: $('analyticsName'),
  1379. code: $('analyticsCode'),
  1380. placement: $('analyticsPlacement'),
  1381. consent: $('analyticsRequiresConsent'),
  1382. active: $('analyticsActive')
  1383. };
  1384. var saveButton = $('analyticsSave');
  1385. var cancel = $('analyticsCancel');
  1386. ​
  1387. function toBase64(text) {
  1388. var bytes = new TextEncoder().encode(text);
  1389. var binary = '';
  1390. for (var i = 0; i < bytes.length; i += 0x8000) {
  1391. binary += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
  1392. }
  1393. return btoa(binary);
  1394. }
  1395. ​
  1396. function setStatus(el, message, ok) {
  1397. el.textContent = message || '';
  1398. el.classList.toggle('is-error', !ok);
  1399. }
  1400. ​
  1401. // Saves the consent settings together with the given script list; nothing else on the page is submitted.
  1402. function persist(next, statusEl) {
  1403. if (busy) return Promise.resolve(false);
  1404. busy = true;
  1405. setStatus(statusEl, 'Saving…', true);
  1406. var payload = {
  1407. consent_enabled: $('analyticsConsent').checked,
  1408. cookie_icon: $('analyticsCookieIcon').checked,
  1409. banner_text: $('analyticsBannerText').value,
  1410. accept_label: $('analyticsAccept').value,
  1411. reject_label: $('analyticsReject').value,
  1412. privacy_url: $('analyticsPrivacy').value,
  1413. scripts: next
  1414. };
  1415. return fetch(flatlyUrl(endpoint), {
  1416. method: 'POST',
  1417. credentials: 'same-origin',
  1418. headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
  1419. body: JSON.stringify({ payload: toBase64(JSON.stringify(payload)) })
  1420. }).then(function(response) {
  1421. return response.json().catch(function() { return null; }).then(function(data) {
  1422. if (!data || !data.ok) throw new Error((data && data.error) || 'Saving failed (HTTP ' + response.status + ').');
  1423. return data.analytics;
  1424. });
  1425. }).then(function(saved) {
  1426. scripts = saved.scripts || [];
  1427. $('analyticsBannerText').value = saved.banner_text;
  1428. $('analyticsAccept').value = saved.accept_label;
  1429. $('analyticsReject').value = saved.reject_label;
  1430. $('analyticsPrivacy').value = saved.privacy_url;
  1431. render();
  1432. setStatus(statusEl, 'Saved.', true);
  1433. return true;
  1434. }).catch(function(error) {
  1435. setStatus(statusEl, error.message || 'Could not reach the server.', false);
  1436. return false;
  1437. }).finally(function() {
  1438. busy = false;
  1439. });
  1440. }
  1441. ​
  1442. function cell(text) {
  1443. var td = document.createElement('td');
  1444. td.textContent = text;
  1445. return td;
  1446. }
  1447. ​
  1448. function render() {
  1449. rows.innerHTML = '';
  1450. if (!scripts.length) {
  1451. var tr = document.createElement('tr');
  1452. var td = cell('No analytics scripts yet.');
  1453. td.colSpan = 5;
  1454. td.className = 'analytics-empty';
  1455. tr.appendChild(td);
  1456. rows.appendChild(tr);
  1457. return;
  1458. }
  1459. scripts.forEach(function(script, index) {
  1460. var tr = document.createElement('tr');
  1461. var name = cell(script.name);
  1462. name.className = 'analytics-name';
  1463. tr.append(name, cell(placements[script.placement] || script.placement), cell(script.consent ? 'Yes' : 'No'));
  1464. ​
  1465. var activeCell = document.createElement('td');
  1466. var toggle = document.createElement('div');
  1467. toggle.className = 'form-switch';
  1468. var input = document.createElement('input');
  1469. input.type = 'checkbox';
  1470. input.id = 'analyticsActive' + index;
  1471. input.checked = !!script.active;
  1472. input.addEventListener('change', function() {
  1473. var next = scripts.slice();
  1474. next[index] = Object.assign({}, script, { active: input.checked });
  1475. persist(next, $('analyticsStatus')).then(function(ok) { if (!ok) input.checked = !input.checked; });
  1476. });
  1477. var label = document.createElement('label');
  1478. label.htmlFor = input.id;
  1479. label.setAttribute('aria-label', 'Active');
  1480. toggle.append(input, label);
  1481. activeCell.appendChild(toggle);
  1482. ​
  1483. var actions = document.createElement('td');
  1484. actions.className = 'analytics-row-actions';
  1485. var edit = document.createElement('button');
  1486. edit.type = 'button';
  1487. edit.className = 'btn btn-ghost btn-sm';
  1488. edit.textContent = 'Edit';
  1489. edit.addEventListener('click', function() { startEdit(index); });
  1490. var remove = document.createElement('button');
  1491. remove.type = 'button';
  1492. remove.className = 'btn btn-danger btn-sm';
  1493. remove.textContent = 'Delete';
  1494. remove.addEventListener('click', function() {
  1495. if (!confirm('Delete the script "' + script.name + '"?')) return;
  1496. persist(scripts.filter(function(item, i) { return i !== index; }), $('analyticsStatus')).then(function(ok) {
  1497. if (ok && editing === index) resetForm();
  1498. });
  1499. });
  1500. actions.append(edit, remove);
  1501. ​
  1502. tr.append(activeCell, actions);
  1503. rows.appendChild(tr);
  1504. });
  1505. }
  1506. ​
  1507. function resetForm() {
  1508. editing = -1;
  1509. $('analyticsFormTitle').textContent = 'New script';
  1510. cancel.hidden = true;
  1511. fields.name.value = '';
  1512. fields.code.value = '';
  1513. fields.placement.value = 'head';
  1514. fields.consent.checked = true;
  1515. fields.active.checked = true;
  1516. }
  1517. ​
  1518. function startEdit(index) {
  1519. var script = scripts[index];
  1520. editing = index;
  1521. $('analyticsFormTitle').textContent = 'Edit script';
  1522. cancel.hidden = false;
  1523. setStatus($('analyticsStatus'), '', true);
  1524. fields.name.value = script.name;
  1525. fields.code.value = script.code;
  1526. fields.placement.value = script.placement;
  1527. fields.consent.checked = !!script.consent;
  1528. fields.active.checked = !!script.active;
  1529. fields.name.focus();
  1530. fields.name.scrollIntoView({ block: 'center', behavior: 'smooth' });
  1531. }
  1532. ​
  1533. saveButton.addEventListener('click', function() {
  1534. var status = $('analyticsStatus');
  1535. var entry = {
  1536. id: editing >= 0 ? scripts[editing].id : '',
  1537. name: fields.name.value.trim(),
  1538. code: fields.code.value.trim(),
  1539. placement: fields.placement.value,
  1540. consent: fields.consent.checked,
  1541. active: fields.active.checked
  1542. };
  1543. var problem = !entry.name ? 'Enter a name.' : !entry.code ? 'Paste the script code.' : (editing < 0 && scripts.length >= max ? 'You can add up to ' + max + ' scripts.' : '');
  1544. if (problem) {
  1545. setStatus(status, problem, false);
  1546. return;
  1547. }
  1548. var next = scripts.slice();
  1549. if (editing >= 0) next[editing] = entry; else next.push(entry);
  1550. persist(next, status).then(function(ok) { if (ok) resetForm(); });
  1551. });
  1552. $('analyticsConsentSave').addEventListener('click', function() {
  1553. persist(scripts, $('analyticsConsentStatus'));
  1554. });
  1555. cancel.addEventListener('click', function() {
  1556. resetForm();
  1557. setStatus($('analyticsStatus'), '', true);
  1558. });
  1559. fields.name.addEventListener('keydown', function(event) {
  1560. if (event.key === 'Enter') {
  1561. event.preventDefault();
  1562. saveButton.click();
  1563. }
  1564. });
  1565. ​
  1566. render();
  1567. })();
  1568. </script>
  1569. </div>
  1570. ​
  1571. <div class="settings-panel" id="panel-custom">
  1572. <input type="hidden" name="custom_js_present" value="1">
  1573. <div class="card">
  1574. <div class="card-header">
  1575. <h3 class="card-title">Custom JavaScript</h3>
  1576. </div>
  1577. <div class="card-body">
  1578. <div class="form-group">
  1579. <label class="form-label" for="customJs">Code</label>
  1580. <textarea name="custom_js" id="customJs" class="form-textarea form-code" data-encode rows="14" maxlength="<?= CUSTOM_CODE_MAX_LENGTH ?>" spellcheck="false" autocomplete="off" autocapitalize="off" placeholder="console.log('Hello from FlatlyPage');"><?= e((string) (get_system_settings()['custom_js'] ?? '')) ?></textarea>
  1581. <p class="form-hint">Runs on every public page (all languages), at the end of the page. Paste plain JavaScript, or a full snippet with its own &lt;script&gt; tags (e.g. analytics). It does not run in the admin panel or the editor preview.</p>
  1582. <p class="form-hint">Only add code you trust: it runs for every visitor, and a broken script can break parts of your site.</p>
  1583. </div>
  1584. </div>
  1585. </div>
  1586. ​
  1587. <input type="hidden" name="custom_html_present" value="1">
  1588. <div class="card" style="margin-top: 24px;">
  1589. <div class="card-header">
  1590. <h3 class="card-title">Custom HTML</h3>
  1591. <button type="button" class="btn btn-secondary btn-sm" id="customHtmlAdd">Add HTML</button>
  1592. </div>
  1593. <div class="card-body">
  1594. <p class="form-hint" style="margin-top: 0;">Added as-is to every public page (all languages). Use it for meta tags, fonts, banners, badges or embed codes. Empty snippets are removed when you save.</p>
  1595. <div id="customHtmlList">
  1596. <?php
  1597. $custom_html_rows = array_values(array_filter((array) (get_system_settings()['custom_html'] ?? []), 'is_array'));
  1598. foreach ($custom_html_rows as $snippet): ?>
  1599. <div class="custom-html-item">
  1600. <div class="custom-html-head">
  1601. <select name="custom_html_location[]" class="form-input">
  1602. <?php foreach (CUSTOM_HTML_LOCATIONS as $location_id => $location_label): ?>
  1603. <option value="<?= e($location_id) ?>" <?= ($snippet['location'] ?? '') === $location_id ? 'selected' : '' ?>><?= e($location_label) ?></option>
  1604. <?php endforeach; ?>
  1605. </select>
  1606. <button type="button" class="btn btn-ghost btn-sm custom-html-remove">Remove</button>
  1607. </div>
  1608. <textarea name="custom_html_code[]" class="form-textarea form-code" data-encode rows="6" maxlength="<?= CUSTOM_CODE_MAX_LENGTH ?>" spellcheck="false" autocomplete="off" autocapitalize="off" placeholder="<meta name=&quot;example&quot; content=&quot;...&quot;>"><?= e((string) ($snippet['code'] ?? '')) ?></textarea>
  1609. </div>
  1610. <?php endforeach; ?>
  1611. </div>
  1612. <p class="form-hint" id="customHtmlEmpty" <?= $custom_html_rows !== [] ? 'hidden' : '' ?>>No custom HTML yet.</p>
  1613. <template id="customHtmlTemplate">
  1614. <div class="custom-html-item">
  1615. <div class="custom-html-head">
  1616. <select name="custom_html_location[]" class="form-input">
  1617. <?php foreach (CUSTOM_HTML_LOCATIONS as $location_id => $location_label): ?>
  1618. <option value="<?= e($location_id) ?>"><?= e($location_label) ?></option>
  1619. <?php endforeach; ?>
  1620. </select>
  1621. <button type="button" class="btn btn-ghost btn-sm custom-html-remove">Remove</button>
  1622. </div>
  1623. <textarea name="custom_html_code[]" class="form-textarea form-code" data-encode rows="6" maxlength="<?= CUSTOM_CODE_MAX_LENGTH ?>" spellcheck="false" autocomplete="off" autocapitalize="off" placeholder="<meta name=&quot;example&quot; content=&quot;...&quot;>"></textarea>
  1624. </div>
  1625. </template>
  1626. </div>
  1627. </div>
  1628. <script>
  1629. (function() {
  1630. var list = document.getElementById('customHtmlList');
  1631. var empty = document.getElementById('customHtmlEmpty');
  1632. var add = document.getElementById('customHtmlAdd');
  1633. var template = document.getElementById('customHtmlTemplate');
  1634. var max = <?= CUSTOM_HTML_MAX_SNIPPETS ?>;
  1635. ​
  1636. function sync() {
  1637. var count = list.querySelectorAll('.custom-html-item').length;
  1638. empty.hidden = count > 0;
  1639. add.disabled = count >= max;
  1640. }
  1641. ​
  1642. add.addEventListener('click', function() {
  1643. if (list.querySelectorAll('.custom-html-item').length >= max) return;
  1644. list.appendChild(template.content.cloneNode(true));
  1645. sync();
  1646. var areas = list.querySelectorAll('textarea');
  1647. areas[areas.length - 1].focus();
  1648. });
  1649. list.addEventListener('click', function(event) {
  1650. var button = event.target.closest('.custom-html-remove');
  1651. if (!button) return;
  1652. button.closest('.custom-html-item').remove();
  1653. sync();
  1654. });
  1655. sync();
  1656. })();
  1657. </script>
  1658. </div>
  1659. ​
  1660. <?php endif; ?>
  1661. ​
  1662. <div style="margin-top: 24px; display: flex; justify-content: flex-end;">
  1663. <button type="submit" class="btn btn-primary">
  1664. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  1665. <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
  1666. <polyline points="17 21 17 13 7 13 7 21" />
  1667. <polyline points="7 3 7 8 15 8" />
  1668. </svg>
  1669. Save Settings
  1670. </button>
  1671. </div>
  1672. </form>
  1673. ​
  1674. <?php elseif ($tab === 'index'): ?>
  1675. <form method="POST" action="" id="pageForm">
  1676. <input type="hidden" name="csrf_token" value="<?= e($csrf_token) ?>">
  1677. <input type="hidden" name="action" value="save_index">
  1678. <?php if ($edit_lang !== null): ?><input type="hidden" name="lang" value="<?= e($edit_lang) ?>"><?php endif; ?>
  1679. <input type="hidden" name="blocks_data" id="blocksData" value="">
  1680. ​
  1681. <div class="card" style="margin-bottom: 24px;">
  1682. <div class="card-body">
  1683. <div class="form-row">
  1684. <div class="form-group">
  1685. <label class="form-label">Page Title</label>
  1686. <input type="text" name="page_title" class="form-input" value="<?= e($index_data['title'] ?? SITE_NAME) ?>">
  1687. </div>
  1688. <div class="form-group">
  1689. <label class="form-label">Meta Description</label>
  1690. <input type="text" name="page_description" class="form-input" value="<?= e($index_data['description'] ?? '') ?>" placeholder="SEO description...">
  1691. </div>
  1692. </div>
  1693. </div>
  1694. </div>
  1695. ​
  1696. <div class="editor-with-preview">
  1697. <div class="editor-panel">
  1698. <div class="editor-wrapper">
  1699. <div class="editor-toolbar">
  1700. <div class="editor-toolbar-left">
  1701. <span style="font-size: 0.8125rem; color: var(--text-muted);">
  1702. <span id="blockCount">0</span> blocks
  1703. </span>
  1704. </div>
  1705. <div class="editor-toolbar-right">
  1706. <button type="button" class="btn btn-ghost btn-sm" onclick="expandAllBlocks()">Expand All</button>
  1707. <button type="button" class="btn btn-ghost btn-sm" onclick="collapseAllBlocks()">Collapse All</button>
  1708. <button type="submit" class="btn btn-primary btn-sm">
  1709. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  1710. <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
  1711. <polyline points="17 21 17 13 7 13 7 21" />
  1712. <polyline points="7 3 7 8 15 8" />
  1713. </svg>
  1714. Save Changes
  1715. </button>
  1716. </div>
  1717. </div>
  1718. ​
  1719. <div class="blocks-container" id="blocksContainer"></div>
  1720. ​
  1721. <div style="padding: 0 24px 24px;">
  1722. <button type="button" class="add-block-btn" onclick="showAddBlockModal()">
  1723. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="20" height="20">
  1724. <line x1="12" y1="5" x2="12" y2="19" />
  1725. <line x1="5" y1="12" x2="19" y2="12" />
  1726. </svg>
  1727. Add Block
  1728. </button>
  1729. </div>
  1730. </div>
  1731. </div>
  1732. ​
  1733. <div class="preview-panel">
  1734. <div class="preview-toolbar">
  1735. <div class="preview-toolbar-left">
  1736. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16">
  1737. <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
  1738. <circle cx="12" cy="12" r="3" />
  1739. </svg>
  1740. <span>Mini Preview</span>
  1741. </div>
  1742. <div class="preview-toolbar-right">
  1743. <button type="button" class="btn btn-ghost btn-sm preview-device-btn active" data-device="desktop" onclick="setPreviewDevice('desktop')" title="Desktop">
  1744. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16">
  1745. <rect x="2" y="3" width="20" height="14" rx="2" />
  1746. <line x1="8" y1="21" x2="16" y2="21" />
  1747. <line x1="12" y1="17" x2="12" y2="21" />
  1748. </svg>
  1749. </button>
  1750. <button type="button" class="btn btn-ghost btn-sm preview-device-btn" data-device="tablet" onclick="setPreviewDevice('tablet')" title="Tablet">
  1751. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16">
  1752. <rect x="4" y="2" width="16" height="20" rx="2" />
  1753. <line x1="12" y1="18" x2="12.01" y2="18" />
  1754. </svg>
  1755. </button>
  1756. <button type="button" class="btn btn-ghost btn-sm preview-device-btn" data-device="mobile" onclick="setPreviewDevice('mobile')" title="Mobile">
  1757. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16">
  1758. <rect x="5" y="2" width="14" height="20" rx="2" />
  1759. <line x1="12" y1="18" x2="12.01" y2="18" />
  1760. </svg>
  1761. </button>
  1762. <a href="/" target="_blank" class="btn btn-ghost btn-sm" title="Open in New Tab">
  1763. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16">
  1764. <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
  1765. <polyline points="15 3 21 3 21 9" />
  1766. <line x1="10" y1="14" x2="21" y2="3" />
  1767. </svg>
  1768. </a>
  1769. </div>
  1770. </div>
  1771. <div class="preview-container" id="previewContainer">
  1772. <div class="preview-frame-wrapper" id="previewFrameWrapper">
  1773. <iframe id="previewFrame" src="" title="Page Preview"></iframe>
  1774. </div>
  1775. </div>
  1776. </div>
  1777. </div>
  1778. </form>
  1779. ​
  1780. <?php elseif ($tab === 'products'): ?>
  1781. <div class="card">
  1782. <?php if (empty($products)): ?>
  1783. <div class="empty-state">
  1784. <svg class="empty-state-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  1785. <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
  1786. </svg>
  1787. <h3>No pages yet</h3>
  1788. <p>Create your first page to get started.</p>
  1789. <a href="?tab=new" class="btn btn-primary">Create Page</a>
  1790. </div>
  1791. <?php else: ?>
  1792. <table class="table">
  1793. <thead>
  1794. <tr>
  1795. <th>Title</th>
  1796. <th>URL</th>
  1797. <th>Updated</th>
  1798. <th>Publish Status</th>
  1799. <th style="width: 150px;">Actions</th>
  1800. </tr>
  1801. </thead>
  1802. <tbody>
  1803. <?php foreach ($products as $product):
  1804. $row_translation = $edit_lang !== null ? ($translated_pages[$product['id'] ?? ''] ?? null) : null;
  1805. $row_page = $row_translation ?? $product;
  1806. ?>
  1807. <tr>
  1808. <td>
  1809. <div class="product-title"><?= e($row_page['title']) ?></div>
  1810. <?php if ($edit_lang !== null && $row_translation === null): ?>
  1811. <small style="color: var(--text-muted);">Not translated yet (main: <?= e($product['title']) ?>)</small>
  1812. <?php endif; ?>
  1813. </td>
  1814. <td>
  1815. <span class="product-slug"><?= e(flatly_lang_url($row_translation !== null ? $edit_lang : null, '/' . $row_page['slug'])) ?></span>
  1816. </td>
  1817. <td>
  1818. <span class="product-date"><?= date('M j, Y', strtotime($product['updated_at'] ?? $product['created_at'] ?? 'now')) ?></span>
  1819. </td>
  1820. <td>
  1821. <?php if (!empty($product['publish_enabled']) && !empty($product['publish_at_utc'])): ?>
  1822. <?php if (is_page_published($product)): ?>
  1823. <span class="badge badge-success" style="display:inline-block; padding:3px 10px; border-radius:12px; font-size:0.75rem; background:rgba(34,197,94,0.15); color:#16a34a;" title="<?= e(format_publish_datetime($product) ?? '') ?>">Published</span>
  1824. <?php else: ?>
  1825. <span class="badge badge-warning" style="display:inline-block; padding:3px 10px; border-radius:12px; font-size:0.75rem; background:rgba(245,158,11,0.15); color:#d97706; white-space:nowrap;" title="Scheduled for <?= e(format_publish_datetime($product) ?? '') ?>">Scheduled: <?= e(format_publish_datetime($product) ?? '') ?></span>
  1826. <?php endif; ?>
  1827. <?php else: ?>
  1828. <span class="badge badge-success" style="display:inline-block; padding:3px 10px; border-radius:12px; font-size:0.75rem; background:rgba(34,197,94,0.15); color:#16a34a;">Published</span>
  1829. <?php endif; ?>
  1830. </td>
  1831. <td>
  1832. <div class="product-actions">
  1833. <a href="?tab=edit&id=<?= e($product['id']) ?><?= $edit_lang !== null ? '&lang=' . e($edit_lang) : '' ?>" class="btn btn-secondary btn-sm"><?= $edit_lang !== null && $row_translation === null ? 'Translate' : 'Edit' ?></a>
  1834. <?php if ($edit_lang === null || $row_translation !== null): ?>
  1835. <a href="<?= e(flatly_lang_url($edit_lang, '/' . $row_page['slug'])) ?>" target="_blank" class="btn btn-ghost btn-sm">View</a>
  1836. <?php endif; ?>
  1837. <?php if ($edit_lang === null): ?>
  1838. <button type="button" class="btn btn-ghost btn-sm" onclick="confirmDelete('<?= e($product['id']) ?>', '<?= e(addslashes($product['title'])) ?>')" style="color: var(--error);">Delete</button>
  1839. <?php elseif ($row_translation !== null): ?>
  1840. <form method="POST" action="" style="display: inline;" onsubmit="return confirm('Delete this translation? The main language page will not be affected.');">
  1841. <input type="hidden" name="csrf_token" value="<?= e($csrf_token) ?>">
  1842. <input type="hidden" name="action" value="delete_translation">
  1843. <input type="hidden" name="lang" value="<?= e($edit_lang) ?>">
  1844. <input type="hidden" name="target" value="product">
  1845. <input type="hidden" name="product_id" value="<?= e($product['id']) ?>">
  1846. <button type="submit" class="btn btn-ghost btn-sm" style="color: var(--error);">Delete</button>
  1847. </form>
  1848. <?php endif; ?>
  1849. </div>
  1850. </td>
  1851. </tr>
  1852. <?php endforeach; ?>
  1853. </tbody>
  1854. </table>
  1855. <?php endif; ?>
  1856. </div>
  1857. ​
  1858. <?php elseif ($tab === 'new'): ?>
  1859. <form method="POST" action="" id="pageForm">
  1860. <input type="hidden" name="csrf_token" value="<?= e($csrf_token) ?>">
  1861. <input type="hidden" name="action" value="add_product">
  1862. <input type="hidden" name="blocks_data" id="blocksData" value="">
  1863. ​
  1864. <div class="card" style="margin-bottom: 24px;">
  1865. <div class="card-body">
  1866. <div class="form-row-3">
  1867. <div class="form-group">
  1868. <label class="form-label">Page Title *</label>
  1869. <input type="text" name="product_title" class="form-input" required placeholder="My Awesome Page">
  1870. </div>
  1871. <div class="form-group">
  1872. <label class="form-label">URL Slug</label>
  1873. <input type="text" name="product_slug" class="form-input" placeholder="auto-generated">
  1874. <p class="form-hint">Leave empty to auto-generate from title</p>
  1875. </div>
  1876. <div class="form-group">
  1877. <label class="form-label">Meta Description</label>
  1878. <input type="text" name="product_description" class="form-input" placeholder="SEO description...">
  1879. </div>
  1880. </div>
  1881. <br>
  1882. <?php if (($edit_product['slug'] ?? '') !== 'homepage'): ?>
  1883. <div class="form-group">
  1884. <label class="checkbox-label">
  1885. <input
  1886. type="checkbox"
  1887. name="show_in_latest"
  1888. value="1"
  1889. <?= ($edit_product['show_in_latest'] ?? false) ? 'checked' : '' ?>>
  1890. <span>Make this page a blog post</span>
  1891. </label>
  1892. <p class="form-hint">When enabled, this page will appear in the recent pages grid on the homepage</p>
  1893. </div>
  1894. <?php endif; ?>
  1895. ​
  1896. <?php include __DIR__ . '/publish-schedule-fields.php'; ?>
  1897. ​
  1898. </div>
  1899. </div>
  1900. ​
  1901. <div class="editor-wrapper">
  1902. <div class="editor-toolbar">
  1903. <div class="editor-toolbar-left">
  1904. <span style="font-size: 0.8125rem; color: var(--text-muted);">
  1905. <span id="blockCount">0</span> blocks
  1906. </span>
  1907. </div>
  1908. <div class="editor-toolbar-right">
  1909. <button type="button" class="btn btn-ghost btn-sm" onclick="expandAllBlocks()">Expand All</button>
  1910. <button type="button" class="btn btn-ghost btn-sm" onclick="collapseAllBlocks()">Collapse All</button>
  1911. <button type="submit" class="btn btn-primary btn-sm">
  1912. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  1913. <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
  1914. <polyline points="17 21 17 13 7 13 7 21" />
  1915. <polyline points="7 3 7 8 15 8" />
  1916. </svg>
  1917. Create Page
  1918. </button>
  1919. </div>
  1920. </div>
  1921. ​
  1922. <div class="blocks-container" id="blocksContainer"></div>
  1923. ​
  1924. <div style="padding: 0 24px 24px;">
  1925. <button type="button" class="add-block-btn" onclick="showAddBlockModal()">
  1926. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="20" height="20">
  1927. <line x1="12" y1="5" x2="12" y2="19" />
  1928. <line x1="5" y1="12" x2="19" y2="12" />
  1929. </svg>
  1930. Add Block
  1931. </button>
  1932. </div>
  1933. </div>
  1934. </form>
  1935. ​
  1936. <?php elseif ($tab === 'edit' && $edit_product): ?>
  1937. <form method="POST" action="" id="pageForm">
  1938. <input type="hidden" name="csrf_token" value="<?= e($csrf_token) ?>">
  1939. <input type="hidden" name="action" value="save_product">
  1940. <?php if ($edit_lang !== null): ?><input type="hidden" name="lang" value="<?= e($edit_lang) ?>"><?php endif; ?>
  1941. <input type="hidden" name="product_id" value="<?= e($edit_product['id']) ?>">
  1942. <input type="hidden" name="blocks_data" id="blocksData" value="">
  1943. ​
  1944. <div class="card" style="margin-bottom: 24px;">
  1945. <div class="card-body">
  1946. <div class="form-row-3">
  1947. <div class="form-group">
  1948. <label class="form-label">Page Title *</label>
  1949. <input type="text" name="product_title" class="form-input" value="<?= e($edit_product['title']) ?>" required>
  1950. </div>
  1951. <div class="form-group">
  1952. <label class="form-label">URL Slug</label>
  1953. <input type="text" name="product_slug" class="form-input" value="<?= e($edit_product['slug']) ?>">
  1954. <p class="form-hint">Cannot be empty</p>
  1955. </div>
  1956. <div class="form-group">
  1957. <label class="form-label">Meta Description</label>
  1958. <input type="text" name="product_description" class="form-input" value="<?= e($edit_product['description'] ?? '') ?>">
  1959. </div>
  1960. </div>
  1961. <br>
  1962. <?php if (($edit_product['slug'] ?? '') !== 'homepage'): ?>
  1963. <div class="form-group">
  1964. <label class="checkbox-label">
  1965. <input
  1966. type="checkbox"
  1967. name="show_in_latest"
  1968. value="1"
  1969. <?= ($edit_product['show_in_latest'] ?? false) ? 'checked' : '' ?>>
  1970. <span>Make this page a blog post</span>
  1971. </label>
  1972. <p class="form-hint">When enabled, this page will appear in the recent pages grid on the homepage</p>
  1973. </div>
  1974. <?php endif; ?>
  1975. ​
  1976. <?php include __DIR__ . '/publish-schedule-fields.php'; ?>
  1977. ​
  1978. </div>
  1979. </div>
  1980. ​
  1981. <div class="editor-with-preview">
  1982. <div class="editor-panel">
  1983. <div class="editor-wrapper">
  1984. <div class="editor-toolbar">
  1985. <div class="editor-toolbar-left">
  1986. <span style="font-size: 0.8125rem; color: var(--text-muted);">
  1987. <span id="blockCount">0</span> blocks
  1988. </span>
  1989. </div>
  1990. <div class="editor-toolbar-right">
  1991. <button type="button" class="btn btn-ghost btn-sm" onclick="expandAllBlocks()">Expand All</button>
  1992. <button type="button" class="btn btn-ghost btn-sm" onclick="collapseAllBlocks()">Collapse All</button>
  1993. <button type="submit" class="btn btn-primary btn-sm">
  1994. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  1995. <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
  1996. <polyline points="17 21 17 13 7 13 7 21" />
  1997. <polyline points="7 3 7 8 15 8" />
  1998. </svg>
  1999. Save Changes
  2000. </button>
  2001. </div>
  2002. </div>
  2003. ​
  2004. <div class="blocks-container" id="blocksContainer"></div>
  2005. ​
  2006. <div style="padding: 0 24px 24px;">
  2007. <button type="button" class="add-block-btn" onclick="showAddBlockModal()">
  2008. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="20" height="20">
  2009. <line x1="12" y1="5" x2="12" y2="19" />
  2010. <line x1="5" y1="12" x2="19" y2="12" />
  2011. </svg>
  2012. Add Block
  2013. </button>
  2014. </div>
  2015. </div>
  2016. </div>
  2017. ​
  2018. <div class="preview-panel">
  2019. <div class="preview-toolbar">
  2020. <div class="preview-toolbar-left">
  2021. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16">
  2022. <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
  2023. <circle cx="12" cy="12" r="3" />
  2024. </svg>
  2025. <span>Mini Preview</span>
  2026. </div>
  2027. <div class="preview-toolbar-right">
  2028. <button type="button" class="btn btn-ghost btn-sm preview-device-btn active" data-device="desktop" onclick="setPreviewDevice('desktop')" title="Desktop">
  2029. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16">
  2030. <rect x="2" y="3" width="20" height="14" rx="2" />
  2031. <line x1="8" y1="21" x2="16" y2="21" />
  2032. <line x1="12" y1="17" x2="12" y2="21" />
  2033. </svg>
  2034. </button>
  2035. <button type="button" class="btn btn-ghost btn-sm preview-device-btn" data-device="tablet" onclick="setPreviewDevice('tablet')" title="Tablet">
  2036. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16">
  2037. <rect x="4" y="2" width="16" height="20" rx="2" />
  2038. <line x1="12" y1="18" x2="12.01" y2="18" />
  2039. </svg>
  2040. </button>
  2041. <button type="button" class="btn btn-ghost btn-sm preview-device-btn" data-device="mobile" onclick="setPreviewDevice('mobile')" title="Mobile">
  2042. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16">
  2043. <rect x="5" y="2" width="14" height="20" rx="2" />
  2044. <line x1="12" y1="18" x2="12.01" y2="18" />
  2045. </svg>
  2046. </button>
  2047. <a href="/<?= e($edit_product['slug']) ?>" target="_blank" class="btn btn-ghost btn-sm" title="Open in New Tab">
  2048. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16">
  2049. <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
  2050. <polyline points="15 3 21 3 21 9" />
  2051. <line x1="10" y1="14" x2="21" y2="3" />
  2052. </svg>
  2053. </a>
  2054. </div>
  2055. </div>
  2056. <div class="preview-container" id="previewContainer">
  2057. <div class="preview-frame-wrapper" id="previewFrameWrapper">
  2058. <iframe id="previewFrame" src="" title="Page Preview"></iframe>
  2059. </div>
  2060. </div>
  2061. </div>
  2062. </div>
  2063. </form>
  2064. <?php endif; ?>
  2065. </div>
  2066. ​
  2067. </main>
  2068. </div>
  2069. ​
  2070. <div class="modal-overlay" id="addBlockModal">
  2071. <div class="modal">
  2072. <div class="modal-header">
  2073. <h3 class="modal-title">Add Block</h3>
  2074. <button type="button" class="modal-close" onclick="hideAddBlockModal()">
  2075. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="20" height="20">
  2076. <line x1="18" y1="6" x2="6" y2="18" />
  2077. <line x1="6" y1="6" x2="18" y2="18" />
  2078. </svg>
  2079. </button>
  2080. </div>
  2081. <div class="modal-body">
  2082. <div class="block-types-grid">
  2083. <div class="block-type-card" onclick="addBlock('hero')">
  2084. <svg fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">
  2085. <rect x="2" y="3" width="20" height="18" rx="2" />
  2086. <rect x="3" y="4" width="18" height="6" fill="currentColor" opacity="0.1" stroke="none" />
  2087. <line x1="6" y1="14" x2="14" y2="14" />
  2088. <line x1="6" y1="17" x2="11" y2="17" />
  2089. </svg>
  2090. <h4>Hero</h4>
  2091. <p>Full-width header section</p>
  2092. </div>
  2093. <div class="block-type-card" onclick="addBlock('blog-hero')">
  2094. <svg fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">
  2095. <rect x="2" y="3" width="20" height="14" rx="2" />
  2096. <path d="M8 21h8" />
  2097. <path d="M12 17v4" />
  2098. <rect x="6" y="5" width="8" height="4" rx="1" fill="currentColor" opacity="0.2" stroke="currentColor" stroke-width="1" />
  2099. <circle cx="7.5" cy="6" r="0.0002" fill="currentColor" opacity="0.2" />
  2100. <path d="M7 8.5 L9 7 L11.5 8 L13.2 6.5" stroke="currentColor" opacity="0.5" stroke-width="0.8" />
  2101. <rect x="6" y="10" width="5" height="1" fill="currentColor" opacity="0.5" stroke="none" />
  2102. <rect x="6" y="12" width="12" height="1" fill="currentColor" opacity="0.1" stroke="none" />
  2103. <rect x="6" y="14" width="12" height="1" fill="currentColor" opacity="0.1" stroke="none" />
  2104. </svg>
  2105. <h4>Blog Hero</h4>
  2106. <p>Featured article header with title, excerpt and image</p>
  2107. </div>
  2108. <div class="block-type-card" onclick="addBlock('stats')">
  2109. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2110. <line x1="18" y1="20" x2="18" y2="10" />
  2111. <line x1="12" y1="20" x2="12" y2="4" />
  2112. <line x1="6" y1="20" x2="6" y2="14" />
  2113. </svg>
  2114. <h4>Stats</h4>
  2115. <p>Numbers and metrics</p>
  2116. </div>
  2117. <div class="block-type-card" onclick="addBlock('features')">
  2118. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2119. <rect x="3" y="3" width="7" height="7" />
  2120. <rect x="14" y="3" width="7" height="7" />
  2121. <rect x="14" y="14" width="7" height="7" />
  2122. <rect x="3" y="14" width="7" height="7" />
  2123. </svg>
  2124. <h4>Features</h4>
  2125. <p>Feature cards grid</p>
  2126. </div>
  2127. <div class="block-type-card" onclick="addBlock('testimonials')">
  2128. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2129. <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
  2130. </svg>
  2131. <h4>Testimonials</h4>
  2132. <p>Customer reviews</p>
  2133. </div>
  2134. <div class="block-type-card" onclick="addBlock('pricing')">
  2135. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2136. <line x1="12" y1="1" x2="12" y2="23" />
  2137. <path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
  2138. </svg>
  2139. <h4>Pricing</h4>
  2140. <p>Pricing table</p>
  2141. </div>
  2142. <div class="block-type-card" onclick="addBlock('cta')">
  2143. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2144. <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
  2145. <polyline points="22 4 12 14.01 9 11.01" />
  2146. </svg>
  2147. <h4>CTA</h4>
  2148. <p>Call to action</p>
  2149. </div>
  2150. <div class="block-type-card" onclick="addBlock('text')">
  2151. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2152. <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
  2153. <polyline points="14 2 14 8 20 8" />
  2154. <line x1="16" y1="13" x2="8" y2="13" />
  2155. <line x1="16" y1="17" x2="8" y2="17" />
  2156. </svg>
  2157. <h4>Text</h4>
  2158. <p>Rich text content</p>
  2159. </div>
  2160. <div class="block-type-card" onclick="addBlock('image')">
  2161. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2162. <rect x="3" y="3" width="18" height="18" rx="2" />
  2163. <circle cx="8.5" cy="8.5" r="1.5" />
  2164. <polyline points="21 15 16 10 5 21" />
  2165. </svg>
  2166. <h4>Image</h4>
  2167. <p>Full-width image</p>
  2168. </div>
  2169. <div class="block-type-card" onclick="addBlock('image-text')">
  2170. <svg class="w-6 h-6 flex-shrink-0 mt-1" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2171. <rect x="3" y="3" width="18" height="10" rx="2" />
  2172. <path d="M3 10l4-3 4 3 6-4 4 3" />
  2173. <line x1="5" y1="16" x2="19" y2="16" />
  2174. <line x1="5" y1="19" x2="15" y2="19" />
  2175. </svg>
  2176. <h4>Image + Text</h4>
  2177. <p>Image with text content</p>
  2178. </div>
  2179. <div class="block-type-card" onclick="addBlock('product-cards')">
  2180. <svg class="w-6 h-6 flex-shrink-0 mt-1" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2181. <path d="M3 7v10c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2z" />
  2182. <path d="M3 9h18" />
  2183. <circle cx="8" cy="14" r="1" fill="currentColor" />
  2184. <line x1="11" y1="14" x2="16" y2="14" />
  2185. </svg>
  2186. <h4>Product Cards</h4>
  2187. <p>Product showcase grid</p>
  2188. </div>
  2189. <div class="block-type-card" onclick="addBlock('video')">
  2190. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2191. <polygon points="23 7 16 12 23 17 23 7" />
  2192. <rect x="1" y="5" width="15" height="14" rx="2" />
  2193. </svg>
  2194. <h4>Video</h4>
  2195. <p>Video player (URL, YouTube, Facebook)</p>
  2196. </div>
  2197. <div class="block-type-card" onclick="addBlock('gallery')">
  2198. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2199. <rect x="3" y="3" width="7" height="9" rx="1" />
  2200. <rect x="14" y="3" width="7" height="5" rx="1" />
  2201. <rect x="14" y="12" width="7" height="9" rx="1" />
  2202. <rect x="3" y="16" width="7" height="5" rx="1" />
  2203. </svg>
  2204. <h4>Gallery</h4>
  2205. <p>Image gallery grid</p>
  2206. </div>
  2207. <div class="block-type-card" onclick="addBlock('faq')">
  2208. <svg class="w-6 h-6 flex-shrink-0 mt-1" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" viewBox="0 0 24 24">
  2209. <circle cx="12" cy="12" r="10" />
  2210. <path d="M10 10a2 2 0 1 1 4 0c0 2-2 2-2 3.5" />
  2211. <line x1="12" y1="17" x2="12" y2="17" />
  2212. </svg>
  2213. <h4>FAQ</h4>
  2214. <p>Frequently Asked Questions</p>
  2215. </div>
  2216. <div class="block-type-card" onclick="addBlock('team')">
  2217. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2218. <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
  2219. <circle cx="9" cy="7" r="4" />
  2220. <path d="M23 21v-2a4 4 0 0 0-3-3.87" />
  2221. <path d="M16 3.13a4 4 0 0 1 0 7.75" />
  2222. </svg>
  2223. <h4>Team</h4>
  2224. <p>Team members showcase</p>
  2225. </div>
  2226. <div class="block-type-card" onclick="addBlock('audio')">
  2227. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2228. <path d="M9 18V5l12-2v13" />
  2229. <circle cx="6" cy="18" r="3" />
  2230. <circle cx="18" cy="16" r="3" />
  2231. </svg>
  2232. <h4>Audio</h4>
  2233. <p>Audio player with music link</p>
  2234. </div>
  2235. <div class="block-type-card" onclick="addBlock('countdown')">
  2236. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2237. <circle cx="12" cy="12" r="10" />
  2238. <polyline points="12 6 12 12 16 14" />
  2239. </svg>
  2240. <h4>Countdown</h4>
  2241. <p>Countdown timer to date</p>
  2242. </div>
  2243. <div class="block-type-card" onclick="addBlock('newsletter')">
  2244. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2245. <path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
  2246. <polyline points="22,6 12,13 2,6" />
  2247. </svg>
  2248. <h4>Newsletter</h4>
  2249. <p>Email subscription form</p>
  2250. </div>
  2251. <div class="block-type-card" onclick="addBlock('html')">
  2252. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2253. <polyline points="16 18 22 12 16 6" />
  2254. <polyline points="8 6 2 12 8 18" />
  2255. </svg>
  2256. <h4>Custom HTML</h4>
  2257. <p>Raw HTML code</p>
  2258. </div>
  2259. </div>
  2260. </div>
  2261. </div>
  2262. </div>
  2263. ​
  2264. <div class="modal-overlay" id="deleteModal">
  2265. <div class="modal" style="max-width: 400px;">
  2266. <div class="modal-header">
  2267. <h3 class="modal-title">Delete Page</h3>
  2268. <button type="button" class="modal-close" onclick="hideDeleteModal()">
  2269. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="20" height="20">
  2270. <line x1="18" y1="6" x2="6" y2="18" />
  2271. <line x1="6" y1="6" x2="18" y2="18" />
  2272. </svg>
  2273. </button>
  2274. </div>
  2275. <div class="modal-body">
  2276. <p style="color: var(--text-muted);">Are you sure you want to delete "<span id="deleteProductName"></span>"? This action cannot be undone.</p>
  2277. <form method="POST" action="" id="deleteForm">
  2278. <input type="hidden" name="csrf_token" value="<?= e($csrf_token) ?>">
  2279. <input type="hidden" name="action" value="delete_product">
  2280. <input type="hidden" name="product_id" id="deleteProductId" value="">
  2281. <div class="modal-actions">
  2282. <button type="button" class="btn btn-secondary" onclick="hideDeleteModal()">Cancel</button>
  2283. <button type="submit" class="btn btn-danger">Delete</button>
  2284. </div>
  2285. </form>
  2286. </div>
  2287. </div>
  2288. </div>
  2289. ​
  2290. <div class="modal-overlay" id="linkSelectorModal">
  2291. <div class="modal" style="max-width: 500px;">
  2292. <div class="modal-header">
  2293. <h3 class="modal-title">Select Link</h3>
  2294. <button type="button" class="modal-close" onclick="hideLinkSelector()">
  2295. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="20" height="20">
  2296. <line x1="18" y1="6" x2="6" y2="18" />
  2297. <line x1="6" y1="6" x2="18" y2="18" />
  2298. </svg>
  2299. </button>
  2300. </div>
  2301. <div class="modal-body">
  2302. <div class="form-group">
  2303. <label class="form-label">Internal Pages</label>
  2304. <div id="internalPages"></div>
  2305. </div>
  2306. <div class="form-group" style="margin-top: 16px;">
  2307. <label class="form-label">Anchors</label>
  2308. <div id="anchorsList" style="display: flex; flex-wrap: wrap; gap: 8px;">
  2309. </div>
  2310. </div>
  2311. <div class="form-group" style="margin-top: 16px;">
  2312. <label class="form-label">Custom URL</label>
  2313. <input type="text" class="form-input" id="customLinkUrl" placeholder="https://example.com">
  2314. <button type="button" class="btn btn-primary btn-sm" style="margin-top: 8px;" onclick="selectLink(document.getElementById('customLinkUrl').value)">Use Custom URL</button>
  2315. </div>
  2316. </div>
  2317. </div>
  2318. </div>
  2319. ​
  2320. <?php include __DIR__ . '/scripts.php'; ?>
  2321. <script>
  2322. document.addEventListener('DOMContentLoaded', function() {
  2323. setTimeout(function() {
  2324. if (typeof updateLivePreview === "function") {
  2325. updateLivePreview();
  2326. }
  2327. }, 1000);
  2328. });
  2329. </script>
  2330. ​
  2331. <script src="/assets/js/admin-editor.js?v=<?= filemtime(__DIR__ . '/../assets/js/admin-editor.js') ?>"></script>
  2332. <?php if (!empty(get_system_settings()['translator_enabled'])): ?>
  2333. <script src="/assets/js/translate.js?v=<?= filemtime(__DIR__ . '/../assets/js/translate.js') ?>"></script>
  2334. <?php endif; ?>
  2335. <?php if (AiHelper::configured() && (in_array($tab, ['index', 'new', 'settings'], true) || ($tab === 'edit' && $edit_product))):
  2336. $ai_ui = AiHelper::settings();
  2337. $ai_language = null;
  2338. $ai_source = null;
  2339. $ai_source_settings = null;
  2340. if ($tab === 'settings' && $edit_lang !== null) {
  2341. $main_settings = flatly_main_settings();
  2342. $main_footer = is_array($main_settings['footer'] ?? null) ? $main_settings['footer'] : [];
  2343. $ai_labels = static fn($items) => array_values(array_map(static fn($item) => ['label' => (string) ($item['label'] ?? '')], array_filter((array) $items, 'is_array')));
  2344. $ai_source_settings = [
  2345. 'general' => [
  2346. 'site_name' => (string) ($main_settings['site_name'] ?? ''),
  2347. 'site_description' => (string) ($main_settings['site_description'] ?? ''),
  2348. 'logo_text' => (string) ($main_settings['logo_text'] ?? ''),
  2349. ],
  2350. 'navigation' => [
  2351. 'links' => $ai_labels($main_settings['nav_links'] ?? []),
  2352. 'buttons' => $ai_labels($main_settings['nav_buttons'] ?? []),
  2353. ],
  2354. 'footer' => [
  2355. 'brand_description' => (string) ($main_footer['brand_description'] ?? ''),
  2356. 'copyright' => (string) ($main_footer['copyright'] ?? ''),
  2357. 'columns' => array_values(array_map(static fn($column) => [
  2358. 'title' => (string) ($column['title'] ?? ''),
  2359. 'links' => $ai_labels($column['links'] ?? []),
  2360. ], array_filter((array) ($main_footer['columns'] ?? []), 'is_array'))),
  2361. 'bottom_links' => $ai_labels($main_footer['bottom_links'] ?? []),
  2362. ],
  2363. ];
  2364. }
  2365. if ($edit_lang !== null) {
  2366. $ai_language = [
  2367. 'code' => $edit_lang,
  2368. 'name' => $edit_lang_name,
  2369. 'main_code' => flatly_default_lang(),
  2370. 'main_name' => flatly_default_language_name(),
  2371. ];
  2372. $ai_main_page = $tab === 'index' ? load_page('index') : ($tab === 'edit' && $edit_product ? load_page((string) $edit_product['id']) : null);
  2373. if (is_array($ai_main_page)) {
  2374. $ai_source = [
  2375. 'title' => (string) ($ai_main_page['title'] ?? ''),
  2376. 'description' => (string) ($ai_main_page['description'] ?? ''),
  2377. 'blocks' => array_map(static fn($block) => [
  2378. 'id' => (string) ($block['id'] ?? ''),
  2379. 'type' => (string) ($block['type'] ?? ''),
  2380. 'data' => ($block['type'] ?? '') === 'html' ? [] : ($block['data'] ?? []),
  2381. ], array_values(array_filter((array) ($ai_main_page['blocks'] ?? []), 'is_array'))),
  2382. ];
  2383. }
  2384. } ?>
  2385. <script>
  2386. window.FLATLY_AI = <?= json_encode([
  2387. 'endpoint' => '/api/ai-helper',
  2388. 'csrf' => $csrf_token,
  2389. 'provider' => AiHelper::PROVIDERS[$ai_ui['provider']]['name'],
  2390. 'model' => $ai_ui['model'],
  2391. 'language' => $ai_language,
  2392. 'source' => $ai_source,
  2393. 'mode' => $tab === 'settings' ? 'settings' : 'blocks',
  2394. 'sourceSettings' => $ai_source_settings,
  2395. ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) ?>;
  2396. </script>
  2397. <script src="/assets/js/ai-helper.js?v=<?= filemtime(__DIR__ . '/../assets/js/ai-helper.js') ?>"></script>
  2398. <?php endif; ?>
  2399. <script src="easyedit.js?v=<?= date('YmdH') ?>"></script>
  2400. ​
  2401. </body>
  2402. ​
  2403. </html>