WebOrbiton
v3.0.0.2

FlatlyPage

2,402 lines · 151.1 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'] = 'Your session timed out while the page was open. 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'] = 'Homepage translation saved.';
  64. $_SESSION['admin_msg_type'] = 'success';
  65. } else {
  66. $_SESSION['admin_msg'] = "Couldn't save the homepage translation. Please try again.";
  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 saved.';
  90. $_SESSION['admin_msg_type'] = 'success';
  91. } else {
  92. $_SESSION['admin_msg'] = "Couldn't save the translated settings. Please try again.";
  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'] = "This page doesn't exist anymore.";
  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 page address is empty or another page in this language already uses it.';
  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'] = 'Page translation saved.';
  147. $_SESSION['admin_msg_type'] = 'success';
  148. } else {
  149. $_SESSION['admin_msg'] = "Couldn't save the page translation. Please try again.";
  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 deleted. Visitors now see the main language version.';
  177. $_SESSION['admin_msg_type'] = 'success';
  178. } else {
  179. $_SESSION['admin_msg'] = "Couldn't delete the translation. Please try again.";
  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'] = "Couldn't copy from the main language. Please try again.";
  244. $_SESSION['admin_msg_type'] = 'error';
  245. } elseif ($added > 0) {
  246. $_SESSION['admin_msg'] = "Added $added missing item(s) from the main language. Nothing you already translated was changed.";
  247. $_SESSION['admin_msg_type'] = 'success';
  248. } else {
  249. $_SESSION['admin_msg'] = 'Nothing to copy, this translation already has everything from the main language.';
  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'] = 'Homepage saved.';
  264. $_SESSION['admin_msg_type'] = 'success';
  265. } else {
  266. $_SESSION['admin_msg'] = "Couldn't save the homepage. Please try again.";
  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 saved.';
  332. $_SESSION['admin_msg_type'] = 'success';
  333. } else {
  334. $_SESSION['admin_msg'] = "Couldn't save the settings. Please try again.";
  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'] = 'Please give the page a title.';
  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\" created.";
  366. $_SESSION['admin_msg_type'] = 'success';
  367. $redirect_tab = 'products';
  368. } else {
  369. $_SESSION['admin_msg'] = "Couldn't create the page. Please try again.";
  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'] = "This page doesn't exist anymore.";
  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 saved.';
  405. $_SESSION['admin_msg_type'] = 'success';
  406. } else {
  407. $_SESSION['admin_msg'] = "Couldn't save the page. Please try again.";
  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 deleted.';
  420. $_SESSION['admin_msg_type'] = 'success';
  421. } else {
  422. $_SESSION['admin_msg'] = "Couldn't delete the page. Please try again.";
  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 the names, menu and footer are translated here. Fonts, colors, logo and other options are the same for every language.
  803. <?php elseif ($edit_is_new_translation): ?>
  804. This page isn't translated yet. The fields below show the main language text, so you can translate it and save.
  805. <?php else: ?>
  806. Anything you leave empty shows the main language text 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? Visitors will see the main language version instead. You can\'t undo this.');">
  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 anything missing from the main language? What you\'ve already translated stays as it is.');">
  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">Shown in the 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">A short description of your site for search engines</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">Language code for search engines, like en, pl or 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">Used for the text of your pages</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 to 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">Used for all headings on your site</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">Used for the menu, buttons, footer and forms</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">Used for 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">Used for 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">Your site's address, like example.com. Leave it empty and it's picked up automatically. Once it's set, search engines only index your site at this address.</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">Show your latest updated pages in the footer and menu</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">Turn on 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 every language your site is available in, with its name (English, Polski, 中文) and a short code (en, pl, zh-tw). Add your main language (<?= e(flatly_default_lang()) ?>) too, so it shows up with a proper name. As soon as you have a second language, a language switcher appears in the footer. After saving, pick a language at the top of Homepage, Pages or Settings to translate it.
  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>Short language links like /pl/page aren't turned on yet.</strong> Your translations still work, at addresses like /page?lang=pl. To get the short links, add these lines to your .htaccess file, just above 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 language switcher and here in the admin, like English, Polski or Deutsch. Leave it empty to use the name from the list below.</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">First-time visitors are sent to the version in their browser's language, if your site has it. This happens only in their browser, with no cookies and nothing sent to your server. If a visitor picks a language in the footer, their device remembers it. 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">Turn on the 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">Adds an AI button to the homepage and page editors. It can write, improve, fix and translate text, and add or reorder blocks. It only suggests changes, nothing happens until you click Apply, and you can always undo.</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 it' : 'Paste your API key' ?>">
  1198. <p class="form-hint">Kept on your server and never shown in the browser. You can get a key from <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"> Delete the 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 connection</button>
  1206. <span class="form-hint" id="aiTestResult" style="margin-left: 8px;"></span>
  1207. <p class="form-hint">When you use the AI helper, your page text is sent to the provider you picked. The test uses your saved settings, so save first if you changed anything.</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 didn't work.");
  1252. }).catch(function() {
  1253. result.textContent = "Couldn't 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">Turn on the consent manager (scripts that need consent wait until the visitor agrees)</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 a cookie icon in the footer, so visitors can change their choice later</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">With the consent manager off, every active script runs right away. A 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">Wait for the visitor's 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. function persist(next, statusEl) {
  1402. if (busy) return Promise.resolve(false);
  1403. busy = true;
  1404. setStatus(statusEl, 'Saving…', true);
  1405. var payload = {
  1406. consent_enabled: $('analyticsConsent').checked,
  1407. cookie_icon: $('analyticsCookieIcon').checked,
  1408. banner_text: $('analyticsBannerText').value,
  1409. accept_label: $('analyticsAccept').value,
  1410. reject_label: $('analyticsReject').value,
  1411. privacy_url: $('analyticsPrivacy').value,
  1412. scripts: next
  1413. };
  1414. return fetch(flatlyUrl(endpoint), {
  1415. method: 'POST',
  1416. credentials: 'same-origin',
  1417. headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
  1418. body: JSON.stringify({ payload: toBase64(JSON.stringify(payload)) })
  1419. }).then(function(response) {
  1420. return response.json().catch(function() { return null; }).then(function(data) {
  1421. if (!data || !data.ok) throw new Error((data && data.error) || "Couldn't save (error " + response.status + ').');
  1422. return data.analytics;
  1423. });
  1424. }).then(function(saved) {
  1425. scripts = saved.scripts || [];
  1426. $('analyticsBannerText').value = saved.banner_text;
  1427. $('analyticsAccept').value = saved.accept_label;
  1428. $('analyticsReject').value = saved.reject_label;
  1429. $('analyticsPrivacy').value = saved.privacy_url;
  1430. render();
  1431. setStatus(statusEl, 'Saved.', true);
  1432. return true;
  1433. }).catch(function(error) {
  1434. setStatus(statusEl, error.message || "Couldn't reach the server. Check your connection and try again.", false);
  1435. return false;
  1436. }).finally(function() {
  1437. busy = false;
  1438. });
  1439. }
  1440. ​
  1441. function cell(text) {
  1442. var td = document.createElement('td');
  1443. td.textContent = text;
  1444. return td;
  1445. }
  1446. ​
  1447. function render() {
  1448. rows.innerHTML = '';
  1449. if (!scripts.length) {
  1450. var tr = document.createElement('tr');
  1451. var td = cell('No analytics scripts yet.');
  1452. td.colSpan = 5;
  1453. td.className = 'analytics-empty';
  1454. tr.appendChild(td);
  1455. rows.appendChild(tr);
  1456. return;
  1457. }
  1458. scripts.forEach(function(script, index) {
  1459. var tr = document.createElement('tr');
  1460. var name = cell(script.name);
  1461. name.className = 'analytics-name';
  1462. tr.append(name, cell(placements[script.placement] || script.placement), cell(script.consent ? 'Yes' : 'No'));
  1463. ​
  1464. var activeCell = document.createElement('td');
  1465. var toggle = document.createElement('div');
  1466. toggle.className = 'form-switch';
  1467. var input = document.createElement('input');
  1468. input.type = 'checkbox';
  1469. input.id = 'analyticsActive' + index;
  1470. input.checked = !!script.active;
  1471. input.addEventListener('change', function() {
  1472. var next = scripts.slice();
  1473. next[index] = Object.assign({}, script, { active: input.checked });
  1474. persist(next, $('analyticsStatus')).then(function(ok) { if (!ok) input.checked = !input.checked; });
  1475. });
  1476. var label = document.createElement('label');
  1477. label.htmlFor = input.id;
  1478. label.setAttribute('aria-label', 'Active');
  1479. toggle.append(input, label);
  1480. activeCell.appendChild(toggle);
  1481. ​
  1482. var actions = document.createElement('td');
  1483. actions.className = 'analytics-row-actions';
  1484. var edit = document.createElement('button');
  1485. edit.type = 'button';
  1486. edit.className = 'btn btn-ghost btn-sm';
  1487. edit.textContent = 'Edit';
  1488. edit.addEventListener('click', function() { startEdit(index); });
  1489. var remove = document.createElement('button');
  1490. remove.type = 'button';
  1491. remove.className = 'btn btn-danger btn-sm';
  1492. remove.textContent = 'Delete';
  1493. remove.addEventListener('click', function() {
  1494. if (!confirm('Delete the script "' + script.name + '"?')) return;
  1495. persist(scripts.filter(function(item, i) { return i !== index; }), $('analyticsStatus')).then(function(ok) {
  1496. if (ok && editing === index) resetForm();
  1497. });
  1498. });
  1499. actions.append(edit, remove);
  1500. ​
  1501. tr.append(activeCell, actions);
  1502. rows.appendChild(tr);
  1503. });
  1504. }
  1505. ​
  1506. function resetForm() {
  1507. editing = -1;
  1508. $('analyticsFormTitle').textContent = 'New script';
  1509. cancel.hidden = true;
  1510. fields.name.value = '';
  1511. fields.code.value = '';
  1512. fields.placement.value = 'head';
  1513. fields.consent.checked = true;
  1514. fields.active.checked = true;
  1515. }
  1516. ​
  1517. function startEdit(index) {
  1518. var script = scripts[index];
  1519. editing = index;
  1520. $('analyticsFormTitle').textContent = 'Edit script';
  1521. cancel.hidden = false;
  1522. setStatus($('analyticsStatus'), '', true);
  1523. fields.name.value = script.name;
  1524. fields.code.value = script.code;
  1525. fields.placement.value = script.placement;
  1526. fields.consent.checked = !!script.consent;
  1527. fields.active.checked = !!script.active;
  1528. fields.name.focus();
  1529. fields.name.scrollIntoView({ block: 'center', behavior: 'smooth' });
  1530. }
  1531. ​
  1532. saveButton.addEventListener('click', function() {
  1533. var status = $('analyticsStatus');
  1534. var entry = {
  1535. id: editing >= 0 ? scripts[editing].id : '',
  1536. name: fields.name.value.trim(),
  1537. code: fields.code.value.trim(),
  1538. placement: fields.placement.value,
  1539. consent: fields.consent.checked,
  1540. active: fields.active.checked
  1541. };
  1542. 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.' : '');
  1543. if (problem) {
  1544. setStatus(status, problem, false);
  1545. return;
  1546. }
  1547. var next = scripts.slice();
  1548. if (editing >= 0) next[editing] = entry; else next.push(entry);
  1549. persist(next, status).then(function(ok) { if (ok) resetForm(); });
  1550. });
  1551. $('analyticsConsentSave').addEventListener('click', function() {
  1552. persist(scripts, $('analyticsConsentStatus'));
  1553. });
  1554. cancel.addEventListener('click', function() {
  1555. resetForm();
  1556. setStatus($('analyticsStatus'), '', true);
  1557. });
  1558. fields.name.addEventListener('keydown', function(event) {
  1559. if (event.key === 'Enter') {
  1560. event.preventDefault();
  1561. saveButton.click();
  1562. }
  1563. });
  1564. ​
  1565. render();
  1566. })();
  1567. </script>
  1568. </div>
  1569. ​
  1570. <div class="settings-panel" id="panel-custom">
  1571. <input type="hidden" name="custom_js_present" value="1">
  1572. <div class="card">
  1573. <div class="card-header">
  1574. <h3 class="card-title">Custom JavaScript</h3>
  1575. </div>
  1576. <div class="card-body">
  1577. <div class="form-group">
  1578. <label class="form-label" for="customJs">Code</label>
  1579. <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>
  1580. <p class="form-hint">Runs at the bottom of every page on your site, in every language. Paste plain JavaScript or a whole snippet with its own &lt;script&gt; tags, like an analytics code. It doesn't run in the admin panel or the editor preview.</p>
  1581. <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>
  1582. </div>
  1583. </div>
  1584. </div>
  1585. ​
  1586. <input type="hidden" name="custom_html_present" value="1">
  1587. <div class="card" style="margin-top: 24px;">
  1588. <div class="card-header">
  1589. <h3 class="card-title">Custom HTML</h3>
  1590. <button type="button" class="btn btn-secondary btn-sm" id="customHtmlAdd">Add HTML</button>
  1591. </div>
  1592. <div class="card-body">
  1593. <p class="form-hint" style="margin-top: 0;">Added to every page on your site exactly as you paste it. Handy for meta tags, fonts, banners or embed codes. Empty boxes are removed when you save.</p>
  1594. <div id="customHtmlList">
  1595. <?php
  1596. $custom_html_rows = array_values(array_filter((array) (get_system_settings()['custom_html'] ?? []), 'is_array'));
  1597. foreach ($custom_html_rows as $snippet): ?>
  1598. <div class="custom-html-item">
  1599. <div class="custom-html-head">
  1600. <select name="custom_html_location[]" class="form-input">
  1601. <?php foreach (CUSTOM_HTML_LOCATIONS as $location_id => $location_label): ?>
  1602. <option value="<?= e($location_id) ?>" <?= ($snippet['location'] ?? '') === $location_id ? 'selected' : '' ?>><?= e($location_label) ?></option>
  1603. <?php endforeach; ?>
  1604. </select>
  1605. <button type="button" class="btn btn-ghost btn-sm custom-html-remove">Remove</button>
  1606. </div>
  1607. <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>
  1608. </div>
  1609. <?php endforeach; ?>
  1610. </div>
  1611. <p class="form-hint" id="customHtmlEmpty" <?= $custom_html_rows !== [] ? 'hidden' : '' ?>>No custom HTML yet.</p>
  1612. <template id="customHtmlTemplate">
  1613. <div class="custom-html-item">
  1614. <div class="custom-html-head">
  1615. <select name="custom_html_location[]" class="form-input">
  1616. <?php foreach (CUSTOM_HTML_LOCATIONS as $location_id => $location_label): ?>
  1617. <option value="<?= e($location_id) ?>"><?= e($location_label) ?></option>
  1618. <?php endforeach; ?>
  1619. </select>
  1620. <button type="button" class="btn btn-ghost btn-sm custom-html-remove">Remove</button>
  1621. </div>
  1622. <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>
  1623. </div>
  1624. </template>
  1625. </div>
  1626. </div>
  1627. <script>
  1628. (function() {
  1629. var list = document.getElementById('customHtmlList');
  1630. var empty = document.getElementById('customHtmlEmpty');
  1631. var add = document.getElementById('customHtmlAdd');
  1632. var template = document.getElementById('customHtmlTemplate');
  1633. var max = <?= CUSTOM_HTML_MAX_SNIPPETS ?>;
  1634. ​
  1635. function sync() {
  1636. var count = list.querySelectorAll('.custom-html-item').length;
  1637. empty.hidden = count > 0;
  1638. add.disabled = count >= max;
  1639. }
  1640. ​
  1641. add.addEventListener('click', function() {
  1642. if (list.querySelectorAll('.custom-html-item').length >= max) return;
  1643. list.appendChild(template.content.cloneNode(true));
  1644. sync();
  1645. var areas = list.querySelectorAll('textarea');
  1646. areas[areas.length - 1].focus();
  1647. });
  1648. list.addEventListener('click', function(event) {
  1649. var button = event.target.closest('.custom-html-remove');
  1650. if (!button) return;
  1651. button.closest('.custom-html-item').remove();
  1652. sync();
  1653. });
  1654. sync();
  1655. })();
  1656. </script>
  1657. </div>
  1658. ​
  1659. <?php endif; ?>
  1660. ​
  1661. <div style="margin-top: 24px; display: flex; justify-content: flex-end;">
  1662. <button type="submit" class="btn btn-primary">
  1663. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  1664. <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
  1665. <polyline points="17 21 17 13 7 13 7 21" />
  1666. <polyline points="7 3 7 8 15 8" />
  1667. </svg>
  1668. Save Settings
  1669. </button>
  1670. </div>
  1671. </form>
  1672. ​
  1673. <?php elseif ($tab === 'index'): ?>
  1674. <form method="POST" action="" id="pageForm">
  1675. <input type="hidden" name="csrf_token" value="<?= e($csrf_token) ?>">
  1676. <input type="hidden" name="action" value="save_index">
  1677. <?php if ($edit_lang !== null): ?><input type="hidden" name="lang" value="<?= e($edit_lang) ?>"><?php endif; ?>
  1678. <input type="hidden" name="blocks_data" id="blocksData" value="">
  1679. ​
  1680. <div class="card" style="margin-bottom: 24px;">
  1681. <div class="card-body">
  1682. <div class="form-row">
  1683. <div class="form-group">
  1684. <label class="form-label">Page Title</label>
  1685. <input type="text" name="page_title" class="form-input" value="<?= e($index_data['title'] ?? SITE_NAME) ?>">
  1686. </div>
  1687. <div class="form-group">
  1688. <label class="form-label">Meta Description</label>
  1689. <input type="text" name="page_description" class="form-input" value="<?= e($index_data['description'] ?? '') ?>" placeholder="SEO description...">
  1690. </div>
  1691. </div>
  1692. </div>
  1693. </div>
  1694. ​
  1695. <div class="editor-with-preview">
  1696. <div class="editor-panel">
  1697. <div class="editor-wrapper">
  1698. <div class="editor-toolbar">
  1699. <div class="editor-toolbar-left">
  1700. <span style="font-size: 0.8125rem; color: var(--text-muted);">
  1701. <span id="blockCount">0</span> blocks
  1702. </span>
  1703. </div>
  1704. <div class="editor-toolbar-right">
  1705. <button type="button" class="btn btn-ghost btn-sm" onclick="expandAllBlocks()">Expand All</button>
  1706. <button type="button" class="btn btn-ghost btn-sm" onclick="collapseAllBlocks()">Collapse All</button>
  1707. <button type="submit" class="btn btn-primary btn-sm">
  1708. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  1709. <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
  1710. <polyline points="17 21 17 13 7 13 7 21" />
  1711. <polyline points="7 3 7 8 15 8" />
  1712. </svg>
  1713. Save Changes
  1714. </button>
  1715. </div>
  1716. </div>
  1717. ​
  1718. <div class="blocks-container" id="blocksContainer"></div>
  1719. ​
  1720. <div style="padding: 0 24px 24px;">
  1721. <button type="button" class="add-block-btn" onclick="showAddBlockModal()">
  1722. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="20" height="20">
  1723. <line x1="12" y1="5" x2="12" y2="19" />
  1724. <line x1="5" y1="12" x2="19" y2="12" />
  1725. </svg>
  1726. Add Block
  1727. </button>
  1728. </div>
  1729. </div>
  1730. </div>
  1731. ​
  1732. <div class="preview-panel">
  1733. <div class="preview-toolbar">
  1734. <div class="preview-toolbar-left">
  1735. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16">
  1736. <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
  1737. <circle cx="12" cy="12" r="3" />
  1738. </svg>
  1739. <span>Mini Preview</span>
  1740. </div>
  1741. <div class="preview-toolbar-right">
  1742. <button type="button" class="btn btn-ghost btn-sm preview-device-btn active" data-device="desktop" onclick="setPreviewDevice('desktop')" title="Desktop">
  1743. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16">
  1744. <rect x="2" y="3" width="20" height="14" rx="2" />
  1745. <line x1="8" y1="21" x2="16" y2="21" />
  1746. <line x1="12" y1="17" x2="12" y2="21" />
  1747. </svg>
  1748. </button>
  1749. <button type="button" class="btn btn-ghost btn-sm preview-device-btn" data-device="tablet" onclick="setPreviewDevice('tablet')" title="Tablet">
  1750. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16">
  1751. <rect x="4" y="2" width="16" height="20" rx="2" />
  1752. <line x1="12" y1="18" x2="12.01" y2="18" />
  1753. </svg>
  1754. </button>
  1755. <button type="button" class="btn btn-ghost btn-sm preview-device-btn" data-device="mobile" onclick="setPreviewDevice('mobile')" title="Mobile">
  1756. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16">
  1757. <rect x="5" y="2" width="14" height="20" rx="2" />
  1758. <line x1="12" y1="18" x2="12.01" y2="18" />
  1759. </svg>
  1760. </button>
  1761. <a href="/" target="_blank" class="btn btn-ghost btn-sm" title="Open in New Tab">
  1762. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16">
  1763. <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
  1764. <polyline points="15 3 21 3 21 9" />
  1765. <line x1="10" y1="14" x2="21" y2="3" />
  1766. </svg>
  1767. </a>
  1768. </div>
  1769. </div>
  1770. <div class="preview-container" id="previewContainer">
  1771. <div class="preview-frame-wrapper" id="previewFrameWrapper">
  1772. <iframe id="previewFrame" src="" title="Page Preview"></iframe>
  1773. </div>
  1774. </div>
  1775. </div>
  1776. </div>
  1777. </form>
  1778. ​
  1779. <?php elseif ($tab === 'products'): ?>
  1780. <div class="card">
  1781. <?php if (empty($products)): ?>
  1782. <div class="empty-state">
  1783. <svg class="empty-state-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  1784. <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" />
  1785. </svg>
  1786. <h3>No pages yet</h3>
  1787. <p>Create your first page to get started.</p>
  1788. <a href="?tab=new" class="btn btn-primary">Create Page</a>
  1789. </div>
  1790. <?php else: ?>
  1791. <table class="table">
  1792. <thead>
  1793. <tr>
  1794. <th>Title</th>
  1795. <th>URL</th>
  1796. <th>Updated</th>
  1797. <th>Publish Status</th>
  1798. <th style="width: 150px;">Actions</th>
  1799. </tr>
  1800. </thead>
  1801. <tbody>
  1802. <?php foreach ($products as $product):
  1803. $row_translation = $edit_lang !== null ? ($translated_pages[$product['id'] ?? ''] ?? null) : null;
  1804. $row_page = $row_translation ?? $product;
  1805. ?>
  1806. <tr>
  1807. <td>
  1808. <div class="product-title"><?= e($row_page['title']) ?></div>
  1809. <?php if ($edit_lang !== null && $row_translation === null): ?>
  1810. <small style="color: var(--text-muted);">Not translated yet (main: <?= e($product['title']) ?>)</small>
  1811. <?php endif; ?>
  1812. </td>
  1813. <td>
  1814. <span class="product-slug"><?= e(flatly_lang_url($row_translation !== null ? $edit_lang : null, '/' . $row_page['slug'])) ?></span>
  1815. </td>
  1816. <td>
  1817. <span class="product-date"><?= date('M j, Y', strtotime($product['updated_at'] ?? $product['created_at'] ?? 'now')) ?></span>
  1818. </td>
  1819. <td>
  1820. <?php if (!empty($product['publish_enabled']) && !empty($product['publish_at_utc'])): ?>
  1821. <?php if (is_page_published($product)): ?>
  1822. <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>
  1823. <?php else: ?>
  1824. <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>
  1825. <?php endif; ?>
  1826. <?php else: ?>
  1827. <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>
  1828. <?php endif; ?>
  1829. </td>
  1830. <td>
  1831. <div class="product-actions">
  1832. <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>
  1833. <?php if ($edit_lang === null || $row_translation !== null): ?>
  1834. <a href="<?= e(flatly_lang_url($edit_lang, '/' . $row_page['slug'])) ?>" target="_blank" class="btn btn-ghost btn-sm">View</a>
  1835. <?php endif; ?>
  1836. <?php if ($edit_lang === null): ?>
  1837. <button type="button" class="btn btn-ghost btn-sm" onclick="confirmDelete('<?= e($product['id']) ?>', '<?= e(addslashes($product['title'])) ?>')" style="color: var(--error);">Delete</button>
  1838. <?php elseif ($row_translation !== null): ?>
  1839. <form method="POST" action="" style="display: inline;" onsubmit="return confirm('Delete this translation? The main language page stays as it is.');">
  1840. <input type="hidden" name="csrf_token" value="<?= e($csrf_token) ?>">
  1841. <input type="hidden" name="action" value="delete_translation">
  1842. <input type="hidden" name="lang" value="<?= e($edit_lang) ?>">
  1843. <input type="hidden" name="target" value="product">
  1844. <input type="hidden" name="product_id" value="<?= e($product['id']) ?>">
  1845. <button type="submit" class="btn btn-ghost btn-sm" style="color: var(--error);">Delete</button>
  1846. </form>
  1847. <?php endif; ?>
  1848. </div>
  1849. </td>
  1850. </tr>
  1851. <?php endforeach; ?>
  1852. </tbody>
  1853. </table>
  1854. <?php endif; ?>
  1855. </div>
  1856. ​
  1857. <?php elseif ($tab === 'new'): ?>
  1858. <form method="POST" action="" id="pageForm">
  1859. <input type="hidden" name="csrf_token" value="<?= e($csrf_token) ?>">
  1860. <input type="hidden" name="action" value="add_product">
  1861. <input type="hidden" name="blocks_data" id="blocksData" value="">
  1862. ​
  1863. <div class="card" style="margin-bottom: 24px;">
  1864. <div class="card-body">
  1865. <div class="form-row-3">
  1866. <div class="form-group">
  1867. <label class="form-label">Page Title *</label>
  1868. <input type="text" name="product_title" class="form-input" required placeholder="My Awesome Page">
  1869. </div>
  1870. <div class="form-group">
  1871. <label class="form-label">URL Slug</label>
  1872. <input type="text" name="product_slug" class="form-input" placeholder="auto-generated">
  1873. <p class="form-hint">Leave it empty and it's made from the title</p>
  1874. </div>
  1875. <div class="form-group">
  1876. <label class="form-label">Meta Description</label>
  1877. <input type="text" name="product_description" class="form-input" placeholder="SEO description...">
  1878. </div>
  1879. </div>
  1880. <br>
  1881. <?php if (($edit_product['slug'] ?? '') !== 'homepage'): ?>
  1882. <div class="form-group">
  1883. <label class="checkbox-label">
  1884. <input
  1885. type="checkbox"
  1886. name="show_in_latest"
  1887. value="1"
  1888. <?= ($edit_product['show_in_latest'] ?? false) ? 'checked' : '' ?>>
  1889. <span>Make this page a blog post</span>
  1890. </label>
  1891. <p class="form-hint">Show this page in the recent pages section on the homepage</p>
  1892. </div>
  1893. <?php endif; ?>
  1894. ​
  1895. <?php include __DIR__ . '/publish-schedule-fields.php'; ?>
  1896. ​
  1897. </div>
  1898. </div>
  1899. ​
  1900. <div class="editor-wrapper">
  1901. <div class="editor-toolbar">
  1902. <div class="editor-toolbar-left">
  1903. <span style="font-size: 0.8125rem; color: var(--text-muted);">
  1904. <span id="blockCount">0</span> blocks
  1905. </span>
  1906. </div>
  1907. <div class="editor-toolbar-right">
  1908. <button type="button" class="btn btn-ghost btn-sm" onclick="expandAllBlocks()">Expand All</button>
  1909. <button type="button" class="btn btn-ghost btn-sm" onclick="collapseAllBlocks()">Collapse All</button>
  1910. <button type="submit" class="btn btn-primary btn-sm">
  1911. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  1912. <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
  1913. <polyline points="17 21 17 13 7 13 7 21" />
  1914. <polyline points="7 3 7 8 15 8" />
  1915. </svg>
  1916. Create Page
  1917. </button>
  1918. </div>
  1919. </div>
  1920. ​
  1921. <div class="blocks-container" id="blocksContainer"></div>
  1922. ​
  1923. <div style="padding: 0 24px 24px;">
  1924. <button type="button" class="add-block-btn" onclick="showAddBlockModal()">
  1925. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="20" height="20">
  1926. <line x1="12" y1="5" x2="12" y2="19" />
  1927. <line x1="5" y1="12" x2="19" y2="12" />
  1928. </svg>
  1929. Add Block
  1930. </button>
  1931. </div>
  1932. </div>
  1933. </form>
  1934. ​
  1935. <?php elseif ($tab === 'edit' && $edit_product): ?>
  1936. <form method="POST" action="" id="pageForm">
  1937. <input type="hidden" name="csrf_token" value="<?= e($csrf_token) ?>">
  1938. <input type="hidden" name="action" value="save_product">
  1939. <?php if ($edit_lang !== null): ?><input type="hidden" name="lang" value="<?= e($edit_lang) ?>"><?php endif; ?>
  1940. <input type="hidden" name="product_id" value="<?= e($edit_product['id']) ?>">
  1941. <input type="hidden" name="blocks_data" id="blocksData" value="">
  1942. ​
  1943. <div class="card" style="margin-bottom: 24px;">
  1944. <div class="card-body">
  1945. <div class="form-row-3">
  1946. <div class="form-group">
  1947. <label class="form-label">Page Title *</label>
  1948. <input type="text" name="product_title" class="form-input" value="<?= e($edit_product['title']) ?>" required>
  1949. </div>
  1950. <div class="form-group">
  1951. <label class="form-label">URL Slug</label>
  1952. <input type="text" name="product_slug" class="form-input" value="<?= e($edit_product['slug']) ?>">
  1953. <p class="form-hint">Can't be empty</p>
  1954. </div>
  1955. <div class="form-group">
  1956. <label class="form-label">Meta Description</label>
  1957. <input type="text" name="product_description" class="form-input" value="<?= e($edit_product['description'] ?? '') ?>">
  1958. </div>
  1959. </div>
  1960. <br>
  1961. <?php if (($edit_product['slug'] ?? '') !== 'homepage'): ?>
  1962. <div class="form-group">
  1963. <label class="checkbox-label">
  1964. <input
  1965. type="checkbox"
  1966. name="show_in_latest"
  1967. value="1"
  1968. <?= ($edit_product['show_in_latest'] ?? false) ? 'checked' : '' ?>>
  1969. <span>Make this page a blog post</span>
  1970. </label>
  1971. <p class="form-hint">Show this page in the recent pages section on the homepage</p>
  1972. </div>
  1973. <?php endif; ?>
  1974. ​
  1975. <?php include __DIR__ . '/publish-schedule-fields.php'; ?>
  1976. ​
  1977. </div>
  1978. </div>
  1979. ​
  1980. <div class="editor-with-preview">
  1981. <div class="editor-panel">
  1982. <div class="editor-wrapper">
  1983. <div class="editor-toolbar">
  1984. <div class="editor-toolbar-left">
  1985. <span style="font-size: 0.8125rem; color: var(--text-muted);">
  1986. <span id="blockCount">0</span> blocks
  1987. </span>
  1988. </div>
  1989. <div class="editor-toolbar-right">
  1990. <button type="button" class="btn btn-ghost btn-sm" onclick="expandAllBlocks()">Expand All</button>
  1991. <button type="button" class="btn btn-ghost btn-sm" onclick="collapseAllBlocks()">Collapse All</button>
  1992. <button type="submit" class="btn btn-primary btn-sm">
  1993. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  1994. <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
  1995. <polyline points="17 21 17 13 7 13 7 21" />
  1996. <polyline points="7 3 7 8 15 8" />
  1997. </svg>
  1998. Save Changes
  1999. </button>
  2000. </div>
  2001. </div>
  2002. ​
  2003. <div class="blocks-container" id="blocksContainer"></div>
  2004. ​
  2005. <div style="padding: 0 24px 24px;">
  2006. <button type="button" class="add-block-btn" onclick="showAddBlockModal()">
  2007. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="20" height="20">
  2008. <line x1="12" y1="5" x2="12" y2="19" />
  2009. <line x1="5" y1="12" x2="19" y2="12" />
  2010. </svg>
  2011. Add Block
  2012. </button>
  2013. </div>
  2014. </div>
  2015. </div>
  2016. ​
  2017. <div class="preview-panel">
  2018. <div class="preview-toolbar">
  2019. <div class="preview-toolbar-left">
  2020. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16">
  2021. <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
  2022. <circle cx="12" cy="12" r="3" />
  2023. </svg>
  2024. <span>Mini Preview</span>
  2025. </div>
  2026. <div class="preview-toolbar-right">
  2027. <button type="button" class="btn btn-ghost btn-sm preview-device-btn active" data-device="desktop" onclick="setPreviewDevice('desktop')" title="Desktop">
  2028. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16">
  2029. <rect x="2" y="3" width="20" height="14" rx="2" />
  2030. <line x1="8" y1="21" x2="16" y2="21" />
  2031. <line x1="12" y1="17" x2="12" y2="21" />
  2032. </svg>
  2033. </button>
  2034. <button type="button" class="btn btn-ghost btn-sm preview-device-btn" data-device="tablet" onclick="setPreviewDevice('tablet')" title="Tablet">
  2035. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16">
  2036. <rect x="4" y="2" width="16" height="20" rx="2" />
  2037. <line x1="12" y1="18" x2="12.01" y2="18" />
  2038. </svg>
  2039. </button>
  2040. <button type="button" class="btn btn-ghost btn-sm preview-device-btn" data-device="mobile" onclick="setPreviewDevice('mobile')" title="Mobile">
  2041. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16">
  2042. <rect x="5" y="2" width="14" height="20" rx="2" />
  2043. <line x1="12" y1="18" x2="12.01" y2="18" />
  2044. </svg>
  2045. </button>
  2046. <a href="/<?= e($edit_product['slug']) ?>" target="_blank" class="btn btn-ghost btn-sm" title="Open in New Tab">
  2047. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="16" height="16">
  2048. <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
  2049. <polyline points="15 3 21 3 21 9" />
  2050. <line x1="10" y1="14" x2="21" y2="3" />
  2051. </svg>
  2052. </a>
  2053. </div>
  2054. </div>
  2055. <div class="preview-container" id="previewContainer">
  2056. <div class="preview-frame-wrapper" id="previewFrameWrapper">
  2057. <iframe id="previewFrame" src="" title="Page Preview"></iframe>
  2058. </div>
  2059. </div>
  2060. </div>
  2061. </div>
  2062. </form>
  2063. <?php endif; ?>
  2064. </div>
  2065. ​
  2066. </main>
  2067. </div>
  2068. ​
  2069. <div class="modal-overlay" id="addBlockModal">
  2070. <div class="modal">
  2071. <div class="modal-header">
  2072. <h3 class="modal-title">Add Block</h3>
  2073. <button type="button" class="modal-close" onclick="hideAddBlockModal()">
  2074. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="20" height="20">
  2075. <line x1="18" y1="6" x2="6" y2="18" />
  2076. <line x1="6" y1="6" x2="18" y2="18" />
  2077. </svg>
  2078. </button>
  2079. </div>
  2080. <div class="modal-body">
  2081. <div class="block-types-grid">
  2082. <div class="block-type-card" onclick="addBlock('hero')">
  2083. <svg fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">
  2084. <rect x="2" y="3" width="20" height="18" rx="2" />
  2085. <rect x="3" y="4" width="18" height="6" fill="currentColor" opacity="0.1" stroke="none" />
  2086. <line x1="6" y1="14" x2="14" y2="14" />
  2087. <line x1="6" y1="17" x2="11" y2="17" />
  2088. </svg>
  2089. <h4>Hero</h4>
  2090. <p>A big intro at the top of the page</p>
  2091. </div>
  2092. <div class="block-type-card" onclick="addBlock('blog-hero')">
  2093. <svg fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">
  2094. <rect x="2" y="3" width="20" height="14" rx="2" />
  2095. <path d="M8 21h8" />
  2096. <path d="M12 17v4" />
  2097. <rect x="6" y="5" width="8" height="4" rx="1" fill="currentColor" opacity="0.2" stroke="currentColor" stroke-width="1" />
  2098. <circle cx="7.5" cy="6" r="0.0002" fill="currentColor" opacity="0.2" />
  2099. <path d="M7 8.5 L9 7 L11.5 8 L13.2 6.5" stroke="currentColor" opacity="0.5" stroke-width="0.8" />
  2100. <rect x="6" y="10" width="5" height="1" fill="currentColor" opacity="0.5" stroke="none" />
  2101. <rect x="6" y="12" width="12" height="1" fill="currentColor" opacity="0.1" stroke="none" />
  2102. <rect x="6" y="14" width="12" height="1" fill="currentColor" opacity="0.1" stroke="none" />
  2103. </svg>
  2104. <h4>Blog Hero</h4>
  2105. <p>An article header with a title, short intro and image</p>
  2106. </div>
  2107. <div class="block-type-card" onclick="addBlock('stats')">
  2108. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2109. <line x1="18" y1="20" x2="18" y2="10" />
  2110. <line x1="12" y1="20" x2="12" y2="4" />
  2111. <line x1="6" y1="20" x2="6" y2="14" />
  2112. </svg>
  2113. <h4>Stats</h4>
  2114. <p>A row of numbers, like results or facts</p>
  2115. </div>
  2116. <div class="block-type-card" onclick="addBlock('features')">
  2117. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2118. <rect x="3" y="3" width="7" height="7" />
  2119. <rect x="14" y="3" width="7" height="7" />
  2120. <rect x="14" y="14" width="7" height="7" />
  2121. <rect x="3" y="14" width="7" height="7" />
  2122. </svg>
  2123. <h4>Features</h4>
  2124. <p>Cards that show what you offer</p>
  2125. </div>
  2126. <div class="block-type-card" onclick="addBlock('testimonials')">
  2127. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2128. <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
  2129. </svg>
  2130. <h4>Testimonials</h4>
  2131. <p>What your customers say</p>
  2132. </div>
  2133. <div class="block-type-card" onclick="addBlock('pricing')">
  2134. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2135. <line x1="12" y1="1" x2="12" y2="23" />
  2136. <path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
  2137. </svg>
  2138. <h4>Pricing</h4>
  2139. <p>Your plans and prices</p>
  2140. </div>
  2141. <div class="block-type-card" onclick="addBlock('cta')">
  2142. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2143. <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
  2144. <polyline points="22 4 12 14.01 9 11.01" />
  2145. </svg>
  2146. <h4>CTA</h4>
  2147. <p>A short section with a button that asks visitors to act</p>
  2148. </div>
  2149. <div class="block-type-card" onclick="addBlock('text')">
  2150. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2151. <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
  2152. <polyline points="14 2 14 8 20 8" />
  2153. <line x1="16" y1="13" x2="8" y2="13" />
  2154. <line x1="16" y1="17" x2="8" y2="17" />
  2155. </svg>
  2156. <h4>Text</h4>
  2157. <p>Headings and paragraphs</p>
  2158. </div>
  2159. <div class="block-type-card" onclick="addBlock('image')">
  2160. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2161. <rect x="3" y="3" width="18" height="18" rx="2" />
  2162. <circle cx="8.5" cy="8.5" r="1.5" />
  2163. <polyline points="21 15 16 10 5 21" />
  2164. </svg>
  2165. <h4>Image</h4>
  2166. <p>One large image</p>
  2167. </div>
  2168. <div class="block-type-card" onclick="addBlock('image-text')">
  2169. <svg class="w-6 h-6 flex-shrink-0 mt-1" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2170. <rect x="3" y="3" width="18" height="10" rx="2" />
  2171. <path d="M3 10l4-3 4 3 6-4 4 3" />
  2172. <line x1="5" y1="16" x2="19" y2="16" />
  2173. <line x1="5" y1="19" x2="15" y2="19" />
  2174. </svg>
  2175. <h4>Image + Text</h4>
  2176. <p>An image next to some text</p>
  2177. </div>
  2178. <div class="block-type-card" onclick="addBlock('product-cards')">
  2179. <svg class="w-6 h-6 flex-shrink-0 mt-1" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2180. <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" />
  2181. <path d="M3 9h18" />
  2182. <circle cx="8" cy="14" r="1" fill="currentColor" />
  2183. <line x1="11" y1="14" x2="16" y2="14" />
  2184. </svg>
  2185. <h4>Product Cards</h4>
  2186. <p>A grid of your products</p>
  2187. </div>
  2188. <div class="block-type-card" onclick="addBlock('video')">
  2189. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2190. <polygon points="23 7 16 12 23 17 23 7" />
  2191. <rect x="1" y="5" width="15" height="14" rx="2" />
  2192. </svg>
  2193. <h4>Video</h4>
  2194. <p>A video from a link, YouTube or Facebook</p>
  2195. </div>
  2196. <div class="block-type-card" onclick="addBlock('gallery')">
  2197. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2198. <rect x="3" y="3" width="7" height="9" rx="1" />
  2199. <rect x="14" y="3" width="7" height="5" rx="1" />
  2200. <rect x="14" y="12" width="7" height="9" rx="1" />
  2201. <rect x="3" y="16" width="7" height="5" rx="1" />
  2202. </svg>
  2203. <h4>Gallery</h4>
  2204. <p>A grid of images</p>
  2205. </div>
  2206. <div class="block-type-card" onclick="addBlock('faq')">
  2207. <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">
  2208. <circle cx="12" cy="12" r="10" />
  2209. <path d="M10 10a2 2 0 1 1 4 0c0 2-2 2-2 3.5" />
  2210. <line x1="12" y1="17" x2="12" y2="17" />
  2211. </svg>
  2212. <h4>FAQ</h4>
  2213. <p>Common questions and answers</p>
  2214. </div>
  2215. <div class="block-type-card" onclick="addBlock('team')">
  2216. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2217. <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
  2218. <circle cx="9" cy="7" r="4" />
  2219. <path d="M23 21v-2a4 4 0 0 0-3-3.87" />
  2220. <path d="M16 3.13a4 4 0 0 1 0 7.75" />
  2221. </svg>
  2222. <h4>Team</h4>
  2223. <p>The people on your team</p>
  2224. </div>
  2225. <div class="block-type-card" onclick="addBlock('audio')">
  2226. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2227. <path d="M9 18V5l12-2v13" />
  2228. <circle cx="6" cy="18" r="3" />
  2229. <circle cx="18" cy="16" r="3" />
  2230. </svg>
  2231. <h4>Audio</h4>
  2232. <p>An audio player with a link to the music</p>
  2233. </div>
  2234. <div class="block-type-card" onclick="addBlock('countdown')">
  2235. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2236. <circle cx="12" cy="12" r="10" />
  2237. <polyline points="12 6 12 12 16 14" />
  2238. </svg>
  2239. <h4>Countdown</h4>
  2240. <p>A timer counting down to a date</p>
  2241. </div>
  2242. <div class="block-type-card" onclick="addBlock('newsletter')">
  2243. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2244. <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" />
  2245. <polyline points="22,6 12,13 2,6" />
  2246. </svg>
  2247. <h4>Newsletter</h4>
  2248. <p>A form to sign up by email</p>
  2249. </div>
  2250. <div class="block-type-card" onclick="addBlock('html')">
  2251. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
  2252. <polyline points="16 18 22 12 16 6" />
  2253. <polyline points="8 6 2 12 8 18" />
  2254. </svg>
  2255. <h4>Custom HTML</h4>
  2256. <p>Your own HTML code</p>
  2257. </div>
  2258. </div>
  2259. </div>
  2260. </div>
  2261. </div>
  2262. ​
  2263. <div class="modal-overlay" id="deleteModal">
  2264. <div class="modal" style="max-width: 400px;">
  2265. <div class="modal-header">
  2266. <h3 class="modal-title">Delete Page</h3>
  2267. <button type="button" class="modal-close" onclick="hideDeleteModal()">
  2268. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="20" height="20">
  2269. <line x1="18" y1="6" x2="6" y2="18" />
  2270. <line x1="6" y1="6" x2="18" y2="18" />
  2271. </svg>
  2272. </button>
  2273. </div>
  2274. <div class="modal-body">
  2275. <p style="color: var(--text-muted);">Delete "<span id="deleteProductName"></span>"? You can't undo this.</p>
  2276. <form method="POST" action="" id="deleteForm">
  2277. <input type="hidden" name="csrf_token" value="<?= e($csrf_token) ?>">
  2278. <input type="hidden" name="action" value="delete_product">
  2279. <input type="hidden" name="product_id" id="deleteProductId" value="">
  2280. <div class="modal-actions">
  2281. <button type="button" class="btn btn-secondary" onclick="hideDeleteModal()">Cancel</button>
  2282. <button type="submit" class="btn btn-danger">Delete</button>
  2283. </div>
  2284. </form>
  2285. </div>
  2286. </div>
  2287. </div>
  2288. ​
  2289. <div class="modal-overlay" id="linkSelectorModal">
  2290. <div class="modal" style="max-width: 500px;">
  2291. <div class="modal-header">
  2292. <h3 class="modal-title">Select Link</h3>
  2293. <button type="button" class="modal-close" onclick="hideLinkSelector()">
  2294. <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" width="20" height="20">
  2295. <line x1="18" y1="6" x2="6" y2="18" />
  2296. <line x1="6" y1="6" x2="18" y2="18" />
  2297. </svg>
  2298. </button>
  2299. </div>
  2300. <div class="modal-body">
  2301. <div class="form-group">
  2302. <label class="form-label">Internal Pages</label>
  2303. <div id="internalPages"></div>
  2304. </div>
  2305. <div class="form-group" style="margin-top: 16px;">
  2306. <label class="form-label">Anchors</label>
  2307. <div id="anchorsList" style="display: flex; flex-wrap: wrap; gap: 8px;">
  2308. </div>
  2309. </div>
  2310. <div class="form-group" style="margin-top: 16px;">
  2311. <label class="form-label">Custom URL</label>
  2312. <input type="text" class="form-input" id="customLinkUrl" placeholder="https://example.com">
  2313. <button type="button" class="btn btn-primary btn-sm" style="margin-top: 8px;" onclick="selectLink(document.getElementById('customLinkUrl').value)">Use Custom URL</button>
  2314. </div>
  2315. </div>
  2316. </div>
  2317. </div>
  2318. ​
  2319. <?php include __DIR__ . '/scripts.php'; ?>
  2320. <script>
  2321. document.addEventListener('DOMContentLoaded', function() {
  2322. setTimeout(function() {
  2323. if (typeof updateLivePreview === "function") {
  2324. updateLivePreview();
  2325. }
  2326. }, 1000);
  2327. });
  2328. </script>
  2329. ​
  2330. <script src="/assets/js/admin-editor.js?v=<?= filemtime(__DIR__ . '/../assets/js/admin-editor.js') ?>"></script>
  2331. <?php if (!empty(get_system_settings()['translator_enabled'])): ?>
  2332. <script src="/assets/js/translate.js?v=<?= filemtime(__DIR__ . '/../assets/js/translate.js') ?>"></script>
  2333. <?php endif; ?>
  2334. <?php if (AiHelper::configured() && (in_array($tab, ['index', 'new', 'settings'], true) || ($tab === 'edit' && $edit_product))):
  2335. $ai_ui = AiHelper::settings();
  2336. $ai_language = null;
  2337. $ai_source = null;
  2338. $ai_source_settings = null;
  2339. if ($tab === 'settings' && $edit_lang !== null) {
  2340. $main_settings = flatly_main_settings();
  2341. $main_footer = is_array($main_settings['footer'] ?? null) ? $main_settings['footer'] : [];
  2342. $ai_labels = static fn($items) => array_values(array_map(static fn($item) => ['label' => (string) ($item['label'] ?? '')], array_filter((array) $items, 'is_array')));
  2343. $ai_source_settings = [
  2344. 'general' => [
  2345. 'site_name' => (string) ($main_settings['site_name'] ?? ''),
  2346. 'site_description' => (string) ($main_settings['site_description'] ?? ''),
  2347. 'logo_text' => (string) ($main_settings['logo_text'] ?? ''),
  2348. ],
  2349. 'navigation' => [
  2350. 'links' => $ai_labels($main_settings['nav_links'] ?? []),
  2351. 'buttons' => $ai_labels($main_settings['nav_buttons'] ?? []),
  2352. ],
  2353. 'footer' => [
  2354. 'brand_description' => (string) ($main_footer['brand_description'] ?? ''),
  2355. 'copyright' => (string) ($main_footer['copyright'] ?? ''),
  2356. 'columns' => array_values(array_map(static fn($column) => [
  2357. 'title' => (string) ($column['title'] ?? ''),
  2358. 'links' => $ai_labels($column['links'] ?? []),
  2359. ], array_filter((array) ($main_footer['columns'] ?? []), 'is_array'))),
  2360. 'bottom_links' => $ai_labels($main_footer['bottom_links'] ?? []),
  2361. ],
  2362. ];
  2363. }
  2364. if ($edit_lang !== null) {
  2365. $ai_language = [
  2366. 'code' => $edit_lang,
  2367. 'name' => $edit_lang_name,
  2368. 'main_code' => flatly_default_lang(),
  2369. 'main_name' => flatly_default_language_name(),
  2370. ];
  2371. $ai_main_page = $tab === 'index' ? load_page('index') : ($tab === 'edit' && $edit_product ? load_page((string) $edit_product['id']) : null);
  2372. if (is_array($ai_main_page)) {
  2373. $ai_source = [
  2374. 'title' => (string) ($ai_main_page['title'] ?? ''),
  2375. 'description' => (string) ($ai_main_page['description'] ?? ''),
  2376. 'blocks' => array_map(static fn($block) => [
  2377. 'id' => (string) ($block['id'] ?? ''),
  2378. 'type' => (string) ($block['type'] ?? ''),
  2379. 'data' => ($block['type'] ?? '') === 'html' ? [] : ($block['data'] ?? []),
  2380. ], array_values(array_filter((array) ($ai_main_page['blocks'] ?? []), 'is_array'))),
  2381. ];
  2382. }
  2383. } ?>
  2384. <script>
  2385. window.FLATLY_AI = <?= json_encode([
  2386. 'endpoint' => '/api/ai-helper',
  2387. 'csrf' => $csrf_token,
  2388. 'provider' => AiHelper::PROVIDERS[$ai_ui['provider']]['name'],
  2389. 'model' => $ai_ui['model'],
  2390. 'language' => $ai_language,
  2391. 'source' => $ai_source,
  2392. 'mode' => $tab === 'settings' ? 'settings' : 'blocks',
  2393. 'sourceSettings' => $ai_source_settings,
  2394. ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) ?>;
  2395. </script>
  2396. <script src="/assets/js/ai-helper.js?v=<?= filemtime(__DIR__ . '/../assets/js/ai-helper.js') ?>"></script>
  2397. <?php endif; ?>
  2398. <script src="easyedit.js?v=<?= date('YmdH') ?>"></script>
  2399. ​
  2400. </body>
  2401. ​
  2402. </html>