WebOrbiton
v1.0.0.8

Publisium

1,878 lines · 115.6 KB
  1. <?php
  2. ​
  3. declare(strict_types=1);
  4. ​
  5. require_once __DIR__ . '/includes/config.php';
  6. require_once __DIR__ . '/includes/database.php';
  7. require_once __DIR__ . '/includes/auth.php';
  8. require_once __DIR__ . '/includes/csrf.php';
  9. require_once __DIR__ . '/includes/language.php';
  10. require_once __DIR__ . '/includes/font-library.php';
  11. require_once __DIR__ . '/includes/footer-links.php';
  12. require_once __DIR__ . '/includes/version.php';
  13. require_once __DIR__ . '/includes/ai.php';
  14. require_once __DIR__ . '/includes/read-aloud.php';
  15. require_once __DIR__ . '/includes/currency.php';
  16. ​
  17. Auth::boot();
  18. Auth::requireRoleAtLeast(Auth::ROLE_EDITOR_IN_CHIEF);
  19. ​
  20. $currentUser = Auth::user();
  21. $currentRole = Auth::role();
  22. $db = Database::site();
  23. $db->exec(
  24. 'CREATE TABLE IF NOT EXISTS site_settings (
  25. setting_key VARCHAR(120) NOT NULL PRIMARY KEY,
  26. setting_value LONGTEXT NULL,
  27. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
  28. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
  29. );
  30. ​
  31. $flashMessage = null;
  32. $flashType = 'success';
  33. ​
  34. if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  35. if (!Csrf::verify($_POST['csrf_token'] ?? null)) {
  36. $flashMessage = 'Your session expired. Please try again.';
  37. $flashType = 'error';
  38. } else {
  39. $action = $_POST['action'] ?? '';
  40. ​
  41. if ($action === 'save_settings') {
  42. $aiSaveError = null;
  43. require __DIR__ . '/dashboard-actions/save-settings.php';
  44. if ($aiSaveError !== null) {
  45. $flashMessage = $aiSaveError;
  46. $flashType = 'error';
  47. } else {
  48. $flashMessage = 'Settings saved.';
  49. }
  50. }
  51. ​
  52. if ($action === 'save_language') {
  53. require __DIR__ . '/dashboard-actions/save-language.php';
  54. $flashMessage = 'Texts saved.';
  55. }
  56. ​
  57. if ($action === 'save_footer') {
  58. require __DIR__ . '/dashboard-actions/save-footer.php';
  59. $flashMessage = 'Footer saved.';
  60. }
  61. ​
  62. if ($action === 'add_nav_item') {
  63. require __DIR__ . '/dashboard-actions/save-nav.php';
  64. $flashMessage = 'Added to the menu.';
  65. }
  66. ​
  67. if ($action === 'move_nav_item') {
  68. require __DIR__ . '/dashboard-actions/save-nav.php';
  69. }
  70. ​
  71. if ($action === 'delete_nav_item') {
  72. require __DIR__ . '/dashboard-actions/save-nav.php';
  73. $flashMessage = 'Removed from the menu.';
  74. }
  75. ​
  76. if ($action === 'update_nav_item_icon') {
  77. require __DIR__ . '/dashboard-actions/save-nav.php';
  78. $flashMessage = 'Icon saved.';
  79. }
  80. }
  81. }
  82. ​
  83. $settingsRows = $db->query('SELECT setting_key, setting_value FROM site_settings')->fetchAll();
  84. $settings = [];
  85. foreach ($settingsRows as $row) {
  86. $settings[$row['setting_key']] = $row['setting_value'];
  87. }
  88. ​
  89. $defaults = [
  90. 'site_name' => Config::get('APP_NAME', 'Publisium'),
  91. 'site_description' => '',
  92. 'seo_title' => '',
  93. 'seo_description' => '',
  94. 'seo_keywords' => '',
  95. 'seo_author' => '',
  96. 'seo_reply_to' => '',
  97. 'seo_application_name' => '',
  98. 'seo_robots_index' => '1',
  99. 'seo_robots_follow' => '1',
  100. 'seo_og_article_image_enabled' => '0',
  101. 'seo_article_schema_enabled' => '0',
  102. 'seo_social_tags_enabled' => '0',
  103. 'seo_twitter_handle' => '',
  104. 'clean_article_urls' => '0',
  105. 'clean_page_urls' => '0',
  106. 'clean_home_url' => '0',
  107. 'clean_author_urls' => '0',
  108. 'llms_txt_enabled' => '0',
  109. 'site_logo_url' => '',
  110. 'site_show_name_with_logo' => '0',
  111. 'site_short_name' => '',
  112. 'site_short_name_enabled' => '0',
  113. 'registration_enabled' => '1',
  114. 'login_enabled' => '1',
  115. 'custom_css' => '',
  116. 'custom_js' => '',
  117. 'custom_head_html' => '',
  118. 'custom_head_html_index' => '0',
  119. 'custom_head_html_article' => '0',
  120. 'custom_head_html_category' => '0',
  121. 'custom_head_html_pages' => '0',
  122. 'custom_body_html' => '',
  123. 'custom_body_html_index' => '0',
  124. 'custom_body_html_article' => '0',
  125. 'custom_body_html_category' => '0',
  126. 'custom_body_html_pages' => '0',
  127. 'menu_position' => 'top',
  128. 'banner_enabled' => '1',
  129. 'featured_banner_enabled' => '0',
  130. 'featured_banner_mode' => 'static',
  131. 'featured_banner_hide_duplicates' => '0',
  132. 'featured_banner_article_1' => '0',
  133. 'featured_banner_article_2' => '0',
  134. 'featured_banner_article_3' => '0',
  135. 'featured_banner_article_4' => '0',
  136. 'featured_banner_article_5' => '0',
  137. 'featured_banner_auto_window_value' => '24',
  138. 'featured_banner_auto_window_unit' => 'hours',
  139. 'featured_banner_auto_category_mode' => 'mixed',
  140. 'related_articles_enabled' => '1',
  141. 'article_reading_time_enabled' => '1',
  142. 'article_time_enabled' => '0',
  143. 'article_time_format' => '24',
  144. 'back_to_top_enabled' => '0',
  145. 'back_to_top_progress_enabled' => '0',
  146. 'toc_enabled' => '0',
  147. 'breadcrumbs_enabled' => '0',
  148. 'author_box_enabled' => '0',
  149. 'account_consents_enabled' => '1',
  150. 'author_pages_enabled' => '0',
  151. 'tags_enabled' => '0',
  152. 'read_aloud_enabled' => '0',
  153. 'read_aloud_intro' => '',
  154. 'read_aloud_outro' => '',
  155. 'activity_log_enabled' => '0',
  156. 'seo_preview_enabled' => '0',
  157. 'indexnow_enabled' => '0',
  158. 'indexnow_key' => '',
  159. 'sitemap_rss_enabled' => '1',
  160. 'rss_category_feeds_enabled' => '0',
  161. 'comments_enabled' => '0',
  162. 'comments_provider' => 'disqus',
  163. 'comments_shortname' => '',
  164. 'comments_server_url' => '',
  165. 'comments_site_id' => '',
  166. 'comments_repo' => '',
  167. 'comments_repo_id' => '',
  168. 'comments_category' => '',
  169. 'comments_category_id' => '',
  170. 'comments_mapping' => 'pathname',
  171. 'comments_custom_html' => '',
  172. 'comments_consent' => 'marketing',
  173. 'comments_load' => 'auto',
  174. 'comments_who' => 'users',
  175. 'comments_moderation' => 'all',
  176. 'comments_email' => 'off',
  177. 'subscription_name' => 'Full access',
  178. 'subscription_price_cents' => '0',
  179. 'subscription_interval' => 'month',
  180. 'subscription_currency' => 'USD',
  181. 'subscription_description' => '',
  182. 'subscription_checkout_url' => '',
  183. 'payment_provider' => 'stripe',
  184. 'payment_webhook_secret' => '',
  185. 'heading_font' => 'Plus Jakarta Sans',
  186. 'body_font' => 'PT Serif',
  187. 'interface_font' => 'Plus Jakarta Sans',
  188. 'footer_columns' => '[]',
  189. 'footer_social_links' => '[]',
  190. 'article_loading_mode' => 'scroll',
  191. 'pagination_style' => 'numbered',
  192. 'articles_per_page_home' => '12',
  193. 'articles_per_page_category' => '12',
  194. 'search_enabled' => '0',
  195. 'search_match_title' => '1',
  196. 'search_match_excerpt' => '1',
  197. 'search_match_content' => '0',
  198. 'search_style' => 'icon',
  199. 'search_show_in_nav' => '0',
  200. 'search_show_in_footer' => '0',
  201. 'share_enabled' => '0',
  202. 'adblock_notice_enabled' => '0',
  203. 'adblock_notice_mode' => 'session',
  204. 'share_facebook' => '1',
  205. 'share_twitter' => '1',
  206. 'share_linkedin' => '1',
  207. 'share_whatsapp' => '1',
  208. 'share_telegram' => '0',
  209. 'share_email' => '1',
  210. 'share_copy_link' => '1',
  211. 'pwa_enabled' => '0',
  212. 'pwa_name' => '',
  213. 'pwa_short_name' => '',
  214. 'pwa_description' => '',
  215. 'pwa_icon_url' => '',
  216. 'pwa_theme_color' => '#ffffff',
  217. 'pwa_background_color' => '#ffffff',
  218. 'app_banner_enabled' => '0',
  219. 'app_android_url' => '',
  220. 'app_ios_url' => '',
  221. ];
  222. $missingSettings = array_diff_key($defaults, $settings);
  223. if (!empty($missingSettings)) {
  224. $insertMissing = $db->prepare(
  225. 'INSERT INTO site_settings (setting_key, setting_value) VALUES (:key, :value) ON DUPLICATE KEY UPDATE setting_value = setting_value'
  226. );
  227. foreach ($missingSettings as $missingKey => $missingValue) {
  228. $insertMissing->execute(['key' => $missingKey, 'value' => $missingValue]);
  229. }
  230. }
  231. ​
  232. $settings = array_merge($defaults, $settings);
  233. ​
  234. $featuredArticleOptions = $db->query(
  235. "SELECT id, title, published_at FROM articles WHERE status = 'published' ORDER BY published_at DESC, id DESC"
  236. )->fetchAll();
  237. ​
  238. Language::ensureKeys(Language::ADDED_DEFAULTS);
  239. $languageStrings = Language::base();
  240. $availableFonts = FontLibrary::available();
  241. $activeTab = $_GET['tab'] ?? 'general';
  242. $allowedTabs = ['general', 'seo', 'layout', 'navigation', 'search', 'comments', 'footer', 'apps', 'language', 'custom-code', 'ai'];
  243. if (!in_array($activeTab, $allowedTabs, true)) {
  244. $activeTab = 'general';
  245. }
  246. ​
  247. $navItemsRaw = [];
  248. $navAvailableCategories = [];
  249. $navAvailablePages = [];
  250. if ($activeTab === 'navigation') {
  251. try {
  252. $navItemsRaw = $db->query('SELECT * FROM nav_items ORDER BY sort_order ASC, id ASC')->fetchAll();
  253. } catch (PDOException $e) {
  254. $navItemsRaw = [];
  255. }
  256. ​
  257. $allCategoryNames = [];
  258. foreach ($db->query('SELECT id, name FROM categories')->fetchAll() as $category) {
  259. $allCategoryNames[(int) $category['id']] = $category['name'];
  260. }
  261. $allPageTitles = [];
  262. foreach ($db->query('SELECT id, title FROM pages')->fetchAll() as $page) {
  263. $allPageTitles[(int) $page['id']] = $page['title'];
  264. }
  265. ​
  266. $usedCategoryIds = [];
  267. $usedPageIds = [];
  268. foreach ($navItemsRaw as $navRow) {
  269. if ($navRow['item_type'] === 'category') {
  270. $usedCategoryIds[] = (int) $navRow['ref_id'];
  271. }
  272. if ($navRow['item_type'] === 'page') {
  273. $usedPageIds[] = (int) $navRow['ref_id'];
  274. }
  275. }
  276. ​
  277. $allCategories = $db->query('SELECT id, name FROM categories ORDER BY name ASC')->fetchAll();
  278. foreach ($allCategories as $category) {
  279. if (!in_array((int) $category['id'], $usedCategoryIds, true)) {
  280. $navAvailableCategories[] = $category;
  281. }
  282. }
  283. ​
  284. $allPages = $db->query('SELECT id, title FROM pages ORDER BY title ASC')->fetchAll();
  285. foreach ($allPages as $page) {
  286. if (!in_array((int) $page['id'], $usedPageIds, true)) {
  287. $navAvailablePages[] = $page;
  288. }
  289. }
  290. }
  291. ​
  292. $dashActivePage = 'settings';
  293. $dashPageTitle = 'Settings';
  294. ​
  295. require __DIR__ . '/includes/dash-header.php';
  296. ​
  297. ?>
  298. ​
  299. <?php if ($flashMessage !== null): ?>
  300. <div class="dash-flash dash-flash-<?= htmlspecialchars($flashType) ?>"><?= Icons::icon($flashType === 'error' ? 'x' : 'check', 'icon icon-sm') ?><?= htmlspecialchars($flashMessage) ?></div>
  301. <?php endif; ?>
  302. ​
  303. <h1 class="dash-title"><?= Icons::icon("settings", "icon icon-lg") ?>Settings</h1>
  304. ​
  305. <div class="settings-tabs">
  306. <a href="settings.php?tab=general" class="<?= $activeTab === 'general' ? 'active' : '' ?>"><?= Icons::icon("sliders", "icon icon-sm") ?>General</a>
  307. <a href="settings.php?tab=seo" class="<?= $activeTab === 'seo' ? 'active' : '' ?>"><?= Icons::icon("robot", "icon icon-sm") ?>SEO</a>
  308. <a href="settings.php?tab=layout" class="<?= $activeTab === 'layout' ? 'active' : '' ?>"><?= Icons::icon("layout", "icon icon-sm") ?>Layout</a>
  309. <a href="settings.php?tab=navigation" class="<?= $activeTab === 'navigation' ? 'active' : '' ?>"><?= Icons::icon("compass", "icon icon-sm") ?>Navigation</a>
  310. <a href="settings.php?tab=search" class="<?= $activeTab === 'search' ? 'active' : '' ?>"><?= Icons::icon("search", "icon icon-sm") ?>Search</a>
  311. <a href="settings.php?tab=comments" class="<?= $activeTab === 'comments' ? 'active' : '' ?>"><?= Icons::icon("message", "icon icon-sm") ?>Comments</a>
  312. <a href="settings.php?tab=footer" class="<?= $activeTab === 'footer' ? 'active' : '' ?>"><?= Icons::icon("footer", "icon icon-sm") ?>Footer</a>
  313. <a href="settings.php?tab=apps" class="<?= $activeTab === 'apps' ? 'active' : '' ?>"><?= Icons::icon("smartphone", "icon icon-sm") ?>Apps</a>
  314. <a href="settings.php?tab=language" class="<?= $activeTab === 'language' ? 'active' : '' ?>"><?= Icons::icon("translate", "icon icon-sm") ?>Language</a>
  315. <a href="settings.php?tab=custom-code" class="<?= $activeTab === 'custom-code' ? 'active' : '' ?>"><?= Icons::icon("code", "icon icon-sm") ?>Custom code</a>
  316. <a href="settings.php?tab=ai" class="<?= $activeTab === 'ai' ? 'active' : '' ?>"><?= Icons::icon("sparkles", "icon icon-sm") ?>AI</a>
  317. </div>
  318. ​
  319. <?php if ($activeTab === 'general'): ?>
  320. <div class="sidebar-box updater-card">
  321. <div class="updater-card-info">
  322. <?= Icons::icon('refresh', 'icon icon-lg') ?>
  323. <div>
  324. <div class="updater-card-title">Updates</div>
  325. <div class="updater-card-version"><?= htmlspecialchars(AppVersion::current($db)) ?></div>
  326. </div>
  327. </div>
  328. <a href="updater.php" class="dash-btn dash-btn-primary"><?= Icons::icon('download', 'icon icon-sm') ?>Check for updates</a>
  329. </div>
  330. ​
  331. <form method="post" class="settings-section">
  332. <?= Csrf::field() ?>
  333. <input type="hidden" name="action" value="save_settings">
  334. <input type="hidden" name="settings_tab" value="general">
  335. ​
  336. <label>Site name</label>
  337. <input type="text" name="site_name" value="<?= htmlspecialchars($settings['site_name']) ?>">
  338. ​
  339. <label>Site description</label>
  340. <textarea name="site_description" rows="2"><?= htmlspecialchars($settings['site_description']) ?></textarea>
  341. ​
  342. <label>Logo (optional, without one the site name is shown as text)</label>
  343. <input type="text" name="site_logo_url" data-image-upload value="<?= htmlspecialchars($settings['site_logo_url']) ?>" placeholder="media/img/logo.png">
  344. ​
  345. <label><input type="checkbox" name="site_show_name_with_logo" <?= $settings['site_show_name_with_logo'] === '1' ? 'checked' : '' ?>> Show the site name next to the logo</label>
  346. ​
  347. <label>Short site name</label>
  348. <input type="text" name="site_short_name" maxlength="30" value="<?= htmlspecialchars($settings['site_short_name']) ?>" placeholder="<?= htmlspecialchars($settings['site_name']) ?>">
  349. ​
  350. <label><input type="checkbox" name="site_short_name_enabled" <?= $settings['site_short_name_enabled'] === '1' ? 'checked' : '' ?>> Use the short name when the full name doesn’t fit on small screens</label>
  351. ​
  352. <label><input type="checkbox" name="registration_enabled" <?= $settings['registration_enabled'] === '1' ? 'checked' : '' ?>> Let visitors create reader accounts (untick to close sign-ups, existing readers can still log in)</label>
  353. ​
  354. <label><input type="checkbox" name="activity_log_enabled" <?= $settings['activity_log_enabled'] === '1' ? 'checked' : '' ?>> Keep an activity log (see who changed articles, categories, pages and settings, under Activity log in the menu)</label>
  355. ​
  356. <label><input type="checkbox" name="login_enabled" <?= $settings['login_enabled'] === '1' ? 'checked' : '' ?>> Let readers log in (untick to hide the login form, for example during maintenance)</label>
  357. ​
  358. <h2 class="dash-subtitle"><?= Icons::icon('credit-card', 'icon icon-sm') ?>Subscription</h2>
  359. <label>Plan name</label>
  360. <input type="text" name="subscription_name" value="<?= htmlspecialchars($settings['subscription_name']) ?>">
  361. ​
  362. <label>Currency (start typing a code or name, e.g. PLN, zloty, dollar)</label>
  363. <?php $subscriptionCurrency = Currency::normalize((string) $settings['subscription_currency']); ?>
  364. <input type="text" name="subscription_currency" id="subscription-currency" list="subscription-currency-list" value="<?= htmlspecialchars($subscriptionCurrency) ?>" maxlength="40" autocomplete="off" spellcheck="false" style="text-transform:uppercase;">
  365. <datalist id="subscription-currency-list">
  366. <?php foreach (Currency::all() as $currencyCode => $currencyName): ?>
  367. <option value="<?= htmlspecialchars($currencyCode) ?>"><?= htmlspecialchars(Currency::searchLabel($currencyCode)) ?></option>
  368. <?php endforeach; ?>
  369. </datalist>
  370. ​
  371. <?php [$subscriptionMajor, $subscriptionFraction] = Currency::split((int) $settings['subscription_price_cents'], $subscriptionCurrency); ?>
  372. <div class="settings-grid">
  373. <div>
  374. <label id="subscription-major-label">Price</label>
  375. <input type="number" name="subscription_price_major" id="subscription-major" min="0" max="100000000" step="1" value="<?= (int) $subscriptionMajor ?>">
  376. </div>
  377. <div id="subscription-minor-wrap">
  378. <label id="subscription-minor-label">Fraction</label>
  379. <input type="number" name="subscription_price_minor" id="subscription-minor" min="0" step="1" value="<?= (int) $subscriptionFraction ?>">
  380. </div>
  381. </div>
  382. <script>
  383. (function() {
  384. var map = <?= json_encode(Currency::clientMap(), JSON_UNESCAPED_UNICODE) ?>;
  385. var currency = document.getElementById('subscription-currency');
  386. var major = document.getElementById('subscription-major');
  387. var minor = document.getElementById('subscription-minor');
  388. var majorLabel = document.getElementById('subscription-major-label');
  389. var minorLabel = document.getElementById('subscription-minor-label');
  390. var minorWrap = document.getElementById('subscription-minor-wrap');
  391. var lastValid = currency.value;
  392. ​
  393. function capitalize(text) {
  394. return text.charAt(0).toUpperCase() + text.slice(1);
  395. }
  396. ​
  397. function apply(code) {
  398. var info = map[code];
  399. if (!info) { return; }
  400. majorLabel.textContent = capitalize(info.major);
  401. minorLabel.textContent = capitalize(info.minor || 'fraction');
  402. minorWrap.style.display = info.d === 0 ? 'none' : '';
  403. minor.max = String(Math.pow(10, info.d) - 1);
  404. if (Number(minor.value) > Number(minor.max)) { minor.value = minor.max; }
  405. if (info.d === 0) { minor.value = '0'; }
  406. lastValid = code;
  407. }
  408. ​
  409. currency.addEventListener('input', function() {
  410. var code = currency.value.trim().toUpperCase();
  411. if (map[code]) { apply(code); }
  412. });
  413. ​
  414. currency.addEventListener('blur', function() {
  415. var code = currency.value.trim().toUpperCase();
  416. currency.value = map[code] ? code : lastValid;
  417. apply(currency.value);
  418. });
  419. ​
  420. apply(lastValid);
  421. })();
  422. </script>
  423. ​
  424. <label>Billing interval</label>
  425. <select name="subscription_interval">
  426. <option value="month" <?= $settings['subscription_interval'] === 'month' ? 'selected' : '' ?>>Monthly</option>
  427. <option value="year" <?= $settings['subscription_interval'] === 'year' ? 'selected' : '' ?>>Yearly</option>
  428. <option value="lifetime" <?= $settings['subscription_interval'] === 'lifetime' ? 'selected' : '' ?>>One-time payment (lifetime access)</option>
  429. </select>
  430. ​
  431. <label>Description shown to readers</label>
  432. <textarea name="subscription_description" rows="2"><?= htmlspecialchars($settings['subscription_description']) ?></textarea>
  433. ​
  434. <label>Payment link (the Stripe or Polar page where readers pay)</label>
  435. <input type="text" name="subscription_checkout_url" value="<?= htmlspecialchars($settings['subscription_checkout_url']) ?>" placeholder="https://checkout.stripe.com/...">
  436. ​
  437. <label>Payment provider</label>
  438. <select name="payment_provider">
  439. <option value="stripe" <?= $settings['payment_provider'] === 'stripe' ? 'selected' : '' ?>>Stripe</option>
  440. <option value="polar" <?= $settings['payment_provider'] === 'polar' ? 'selected' : '' ?>>Polar.sh</option>
  441. </select>
  442. ​
  443. <label>Webhook secret (from your payment provider)</label>
  444. <input type="password" name="payment_webhook_secret" value="<?= htmlspecialchars($settings['payment_webhook_secret']) ?>" autocomplete="new-password">
  445. <p style="font-size:12px;color:var(--muted);margin-top:6px;">Paste this address into your payment provider’s webhook settings: <code><?= htmlspecialchars(rtrim(Config::get('APP_URL', ''), '/') . Config::get('APP_BASE_PATH', '')) ?>/webhook.php</code><br>For Stripe, send these events: <code>checkout.session.completed</code>, <code>customer.subscription.created</code>, <code>customer.subscription.updated</code>, <code>customer.subscription.deleted</code>. For Polar: <code>subscription.*</code>, <code>order.paid</code>, <code>order.refunded</code>. Readers need to pay with the same email they use on the site.</p>
  446. ​
  447. <?php
  448. require_once __DIR__ . '/includes/contact.php';
  449. $contactSettings = ContactForm::settings();
  450. ?>
  451. <h2 class="dash-subtitle" id="contact-system"><?= Icons::icon('mail', 'icon icon-sm') ?>Contact form</h2>
  452. <p style="font-size:12.5px;color:var(--muted);margin:0 0 6px;">Let visitors send you messages. Once it’s on, add the "Contact form" block to any page in Pages (one per page). Messages without the correct security code are blocked.</p>
  453. ​
  454. <label><input type="checkbox" name="contact_enabled" <?= $contactSettings['contact_enabled'] === '1' ? 'checked' : '' ?>> Turn on the contact form</label>
  455. ​
  456. <label>Send messages to</label>
  457. <input type="email" name="contact_recipient" value="<?= htmlspecialchars($contactSettings['contact_recipient']) ?>" placeholder="you@example.com">
  458. ​
  459. <label>Email service</label>
  460. <select name="contact_provider" id="contact-provider">
  461. <?php foreach (ContactForm::PROVIDERS as $providerKey => $providerLabel): ?>
  462. <option value="<?= htmlspecialchars($providerKey) ?>" <?= $contactSettings['contact_provider'] === $providerKey ? 'selected' : '' ?>><?= htmlspecialchars($providerLabel) ?></option>
  463. <?php endforeach; ?>
  464. </select>
  465. ​
  466. <label>Send from this address</label>
  467. <input type="email" name="contact_from_email" value="<?= htmlspecialchars($contactSettings['contact_from_email']) ?>" placeholder="<?= htmlspecialchars(ContactForm::defaultFromEmail()) ?>">
  468. <p style="font-size:12px;color:var(--muted);margin-top:6px;">Use an address on your own domain. Resend and Brevo need it to be verified first. When you hit reply, your answer goes straight to the visitor.</p>
  469. ​
  470. <label>Sender name (optional)</label>
  471. <input type="text" name="contact_from_name" maxlength="100" value="<?= htmlspecialchars($contactSettings['contact_from_name']) ?>" placeholder="<?= htmlspecialchars($settings['site_name']) ?>">
  472. ​
  473. <label>Subject</label>
  474. <select name="contact_subject_mode" id="contact-subject-mode">
  475. <?php foreach (ContactForm::SUBJECT_MODES as $subjectMode => $subjectModeLabel): ?>
  476. <option value="<?= htmlspecialchars($subjectMode) ?>" <?= ContactForm::subjectMode() === $subjectMode ? 'selected' : '' ?>><?= htmlspecialchars($subjectModeLabel) ?></option>
  477. <?php endforeach; ?>
  478. </select>
  479. ​
  480. <div id="contact-topics-section">
  481. <label>Topics</label>
  482. <p style="font-size:12px;color:var(--muted);margin:0 0 8px;">Visitors pick a topic when they write to you. Give a topic its own email to send those messages to a different inbox, like support or billing. These addresses are never shown on the site. With no topics, visitors type their own subject. You can add up to 30.</p>
  483. <input type="hidden" name="contact_topics_submitted" value="1">
  484. <div class="contact-topics" id="contact-topics">
  485. <?php foreach (ContactForm::topics() as $contactTopic): ?>
  486. <div class="contact-topic-row">
  487. <input type="text" name="contact_topic_label[]" maxlength="150" value="<?= htmlspecialchars($contactTopic['label']) ?>" placeholder="Topic, e.g. Technical support">
  488. <input type="email" name="contact_topic_email[]" maxlength="190" value="<?= htmlspecialchars($contactTopic['recipient']) ?>" placeholder="Email (optional)">
  489. <button type="button" class="dash-btn-small dash-btn-danger contact-topic-remove" title="Remove topic"><?= Icons::icon('trash', 'icon icon-sm') ?></button>
  490. </div>
  491. <?php endforeach; ?>
  492. </div>
  493. <p class="contact-topics-empty" id="contact-topics-empty" style="font-size:12.5px;color:var(--muted);margin:4px 0 8px;">No topics yet, so visitors type their own subject.</p>
  494. <button type="button" class="dash-btn-small" id="contact-topic-add"><?= Icons::icon('plus', 'icon icon-sm') ?>Add topic</button>
  495. <template id="contact-topic-template">
  496. <div class="contact-topic-row">
  497. <input type="text" name="contact_topic_label[]" maxlength="150" placeholder="Topic, e.g. Technical support">
  498. <input type="email" name="contact_topic_email[]" maxlength="190" placeholder="Email (optional)">
  499. <button type="button" class="dash-btn-small dash-btn-danger contact-topic-remove" title="Remove topic"><?= Icons::icon('trash', 'icon icon-sm') ?></button>
  500. </div>
  501. </template>
  502. <script>
  503. (function () {
  504. var list = document.getElementById('contact-topics');
  505. var addButton = document.getElementById('contact-topic-add');
  506. var template = document.getElementById('contact-topic-template');
  507. var empty = document.getElementById('contact-topics-empty');
  508. if (!list || !addButton || !template) {
  509. return;
  510. }
  511. function sync() {
  512. var count = list.querySelectorAll('.contact-topic-row').length;
  513. empty.style.display = count === 0 ? '' : 'none';
  514. addButton.disabled = count >= 30;
  515. }
  516. addButton.addEventListener('click', function () {
  517. list.appendChild(template.content.cloneNode(true));
  518. var inputs = list.querySelectorAll('.contact-topic-row input[type="text"]');
  519. inputs[inputs.length - 1].focus();
  520. sync();
  521. });
  522. list.addEventListener('click', function (event) {
  523. var remove = event.target.closest('.contact-topic-remove');
  524. if (remove) {
  525. remove.closest('.contact-topic-row').remove();
  526. sync();
  527. }
  528. });
  529. sync();
  530. ​
  531. var mode = document.getElementById('contact-subject-mode');
  532. var section = document.getElementById('contact-topics-section');
  533. if (mode && section) {
  534. var syncMode = function () {
  535. section.hidden = mode.value === 'free';
  536. };
  537. mode.addEventListener('change', syncMode);
  538. syncMode();
  539. }
  540. })();
  541. </script>
  542. </div>
  543. ​
  544. <label><input type="checkbox" name="contact_include_ip" <?= $contactSettings['contact_include_ip'] === '1' ? 'checked' : '' ?>> Add the sender’s IP address to each message</label>
  545. ​
  546. <?php foreach (['resend' => 'Resend', 'brevo' => 'Brevo'] as $apiProvider => $apiLabel): ?>
  547. <?php $contactKeyState = ContactForm::keyState($apiProvider); ?>
  548. <div class="contact-provider-key" data-contact-provider="<?= htmlspecialchars($apiProvider) ?>">
  549. <label><?= htmlspecialchars($apiLabel) ?> API key</label>
  550. <input type="password" name="contact_key_<?= htmlspecialchars($apiProvider) ?>" value="" autocomplete="new-password" placeholder="<?= $contactKeyState['hint'] !== null ? 'Saved (ends with ' . htmlspecialchars($contactKeyState['hint']) . '), leave empty to keep it' : 'Paste your API key' ?>">
  551. <?php if ($contactKeyState['hint'] !== null): ?>
  552. <label><input type="checkbox" name="contact_key_<?= htmlspecialchars($apiProvider) ?>_remove"> Remove the saved <?= htmlspecialchars($apiLabel) ?> key</label>
  553. <?php endif; ?>
  554. <p style="font-size:12px;color:var(--muted);margin-top:6px;">The key is stored safely and won’t be shown again.</p>
  555. </div>
  556. <?php endforeach; ?>
  557. <script>
  558. (function () {
  559. var select = document.getElementById('contact-provider');
  560. if (!select) {
  561. return;
  562. }
  563. function sync() {
  564. document.querySelectorAll('.contact-provider-key').forEach(function (box) {
  565. box.style.display = box.getAttribute('data-contact-provider') === select.value ? '' : 'none';
  566. });
  567. }
  568. select.addEventListener('change', sync);
  569. sync();
  570. })();
  571. </script>
  572. ​
  573. <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:20px;"><?= Icons::icon('check', 'icon icon-sm') ?>Save</button>
  574. </form>
  575. <?php endif; ?>
  576. ​
  577. <?php if ($activeTab === 'seo'): ?>
  578. <form method="post" class="settings-section">
  579. <?= Csrf::field() ?>
  580. <input type="hidden" name="action" value="save_settings">
  581. <input type="hidden" name="settings_tab" value="seo">
  582. ​
  583. <label>Title for search engines</label>
  584. <input type="text" name="seo_title" value="<?= htmlspecialchars($settings['seo_title']) ?>">
  585. ​
  586. <label>Description for search engines</label>
  587. <textarea name="seo_description" rows="2"><?= htmlspecialchars($settings['seo_description']) ?></textarea>
  588. ​
  589. <label>Keywords (separate with commas)</label>
  590. <input type="text" name="seo_keywords" value="<?= htmlspecialchars($settings['seo_keywords']) ?>" placeholder="news, technology, business">
  591. ​
  592. <label>Author</label>
  593. <input type="text" name="seo_author" value="<?= htmlspecialchars($settings['seo_author']) ?>">
  594. ​
  595. <label>Reply-to email</label>
  596. <input type="email" name="seo_reply_to" value="<?= htmlspecialchars($settings['seo_reply_to']) ?>">
  597. ​
  598. <label>Application name</label>
  599. <input type="text" name="seo_application_name" value="<?= htmlspecialchars($settings['seo_application_name']) ?>">
  600. ​
  601. <h2 class="dash-subtitle"><?= Icons::icon('robot', 'icon icon-sm') ?>Search engines</h2>
  602. <label><input type="checkbox" name="seo_robots_index" <?= $settings['seo_robots_index'] === '1' ? 'checked' : '' ?>> Let search engines show this site in results</label>
  603. <label><input type="checkbox" name="seo_robots_follow" <?= $settings['seo_robots_follow'] === '1' ? 'checked' : '' ?>> Let search engines follow links on this site</label>
  604. ​
  605. <h2 class="dash-subtitle"><?= Icons::icon('share', 'icon icon-sm') ?>Link previews on social media</h2>
  606. <label><input type="checkbox" name="seo_og_article_image_enabled" <?= $settings['seo_og_article_image_enabled'] === '1' ? 'checked' : '' ?>> Use the article’s cover image in link previews (the site logo is used when this is off or there’s no cover)</label>
  607. <label><input type="checkbox" name="seo_social_tags_enabled" <?= $settings['seo_social_tags_enabled'] === '1' ? 'checked' : '' ?>> Add extra tags for X (Twitter), LinkedIn and other apps (large image card, publish date and author)</label>
  608. <label>X (Twitter) account of the site (optional)</label>
  609. <input type="text" name="seo_twitter_handle" value="<?= htmlspecialchars($settings['seo_twitter_handle']) ?>" placeholder="@yoursite" maxlength="16">
  610. ​
  611. <h2 class="dash-subtitle"><?= Icons::icon('robot', 'icon icon-sm') ?>Structured data</h2>
  612. <label><input type="checkbox" name="seo_article_schema_enabled" <?= $settings['seo_article_schema_enabled'] === '1' ? 'checked' : '' ?>> Add article details for search engines (NewsArticle): title, cover, dates and author</label>
  613. <p style="font-size:12px;color:var(--muted);margin:-4px 0 16px;">Authors can set in Artist whether they are a person or an organization. Anyone who hasn’t set it counts as a person.</p>
  614. <h2 class="dash-subtitle"><?= Icons::icon('link', 'icon icon-sm') ?>Short links</h2>
  615. <label><input type="checkbox" name="clean_home_url" <?= ($settings['clean_home_url'] ?? '0') === '1' ? 'checked' : '' ?>> Home page (/ instead of /index.php)</label>
  616. <label><input type="checkbox" name="clean_article_urls" <?= $settings['clean_article_urls'] === '1' ? 'checked' : '' ?>> Articles (/my-article instead of article.php?slug=my-article-3fa2b1)</label>
  617. <label><input type="checkbox" name="clean_page_urls" <?= ($settings['clean_page_urls'] ?? '0') === '1' ? 'checked' : '' ?>> Pages (/about instead of page.php?slug=about)</label>
  618. <label><input type="checkbox" name="clean_author_urls" <?= ($settings['clean_author_urls'] ?? '0') === '1' ? 'checked' : '' ?>> Author pages (/jan-kowalski instead of artist.php?slug=jan-kowalski)</label>
  619. <p style="font-size:12px;color:var(--muted);margin:-4px 0 16px;">Articles, pages and author pages share these short links, so each name can only be used once. If several have the same name, the oldest keeps it and the next ones get -2, -3 and so on. Once a link is given out it doesn’t change, and old links keep working and point to the new ones.</p>
  620. <label><input type="checkbox" name="seo_preview_enabled" <?= $settings['seo_preview_enabled'] === '1' ? 'checked' : '' ?>> Show search and social media previews in the article editor</label>
  621. <label><input type="checkbox" name="indexnow_enabled" <?= $settings['indexnow_enabled'] === '1' ? 'checked' : '' ?>> Tell search engines right away when an article is published, changed or deleted (IndexNow)</label>
  622. <?php if ($settings['indexnow_enabled'] === '1' && $settings['indexnow_key'] !== ''): ?>
  623. <p style="font-size:12px;color:var(--muted);margin-top:6px;">Key: <code><?= htmlspecialchars($settings['indexnow_key']) ?></code> &middot; Check file: <code><?= htmlspecialchars(rtrim(Config::get('APP_URL', ''), '/') . rtrim(Config::get('APP_BASE_PATH', ''), '/') . '/' . $settings['indexnow_key'] . '.txt') ?></code></p>
  624. <?php endif; ?>
  625. <label><input type="checkbox" name="llms_txt_enabled" <?= $settings['llms_txt_enabled'] === '1' ? 'checked' : '' ?>> Publish /llms.txt, a short summary of the site for AI tools</label>
  626. ​
  627. <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:20px;"><?= Icons::icon('check', 'icon icon-sm') ?>Save</button>
  628. </form>
  629. <?php endif; ?>
  630. ​
  631. <?php if ($activeTab === 'layout'): ?>
  632. <form method="post" class="settings-section">
  633. <?= Csrf::field() ?>
  634. <input type="hidden" name="action" value="save_settings">
  635. <input type="hidden" name="settings_tab" value="layout">
  636. ​
  637. <label>Site menu position</label>
  638. <select name="menu_position">
  639. <option value="top" <?= $settings['menu_position'] === 'top' ? 'selected' : '' ?>>Top</option>
  640. <option value="bottom" <?= $settings['menu_position'] === 'bottom' ? 'selected' : '' ?>>Bottom</option>
  641. <option value="side" <?= $settings['menu_position'] === 'side' ? 'selected' : '' ?>>Side</option>
  642. </select>
  643. ​
  644. <label><input type="checkbox" name="banner_enabled" <?= $settings['banner_enabled'] === '1' ? 'checked' : '' ?>> Show a strip with the latest articles at the top</label>
  645. <label><input type="checkbox" name="featured_banner_enabled" <?= $settings['featured_banner_enabled'] === '1' ? 'checked' : '' ?>> Show the Featured banner on the home page</label>
  646. ​
  647. <h2 class="dash-subtitle"><?= Icons::icon('sparkles', 'icon icon-sm') ?>Featured banner</h2>
  648. <label>Article selection</label>
  649. <select name="featured_banner_mode" id="featured_banner_mode_select">
  650. <option value="static" <?= $settings['featured_banner_mode'] === 'static' ? 'selected' : '' ?>>I’ll pick the articles</option>
  651. <option value="automatic" <?= $settings['featured_banner_mode'] === 'automatic' ? 'selected' : '' ?>>Pick them automatically</option>
  652. </select>
  653. ​
  654. <label><input type="checkbox" name="featured_banner_hide_duplicates" <?= $settings['featured_banner_hide_duplicates'] === '1' ? 'checked' : '' ?>> Don’t repeat Featured articles in the latest articles list</label>
  655. ​
  656. <div id="featured-banner-static-fields">
  657. <div class="settings-grid">
  658. <?php for ($featuredSlot = 1; $featuredSlot <= 5; $featuredSlot++): ?>
  659. <div>
  660. <label>Featured article <?= $featuredSlot ?></label>
  661. <select name="featured_banner_article_<?= $featuredSlot ?>">
  662. <option value="0">None</option>
  663. <?php foreach ($featuredArticleOptions as $featuredOption): ?>
  664. <option value="<?= (int) $featuredOption['id'] ?>" <?= (string) $settings['featured_banner_article_' . $featuredSlot] === (string) $featuredOption['id'] ? 'selected' : '' ?>><?= htmlspecialchars($featuredOption['title']) ?></option>
  665. <?php endforeach; ?>
  666. </select>
  667. </div>
  668. <?php endfor; ?>
  669. </div>
  670. </div>
  671. ​
  672. <div id="featured-banner-auto-fields">
  673. <label>Pick from articles published in the last</label>
  674. <div class="settings-grid">
  675. <input type="number" name="featured_banner_auto_window_value" min="1" max="720" value="<?= (int) $settings['featured_banner_auto_window_value'] ?>">
  676. <select name="featured_banner_auto_window_unit">
  677. <option value="minutes" <?= $settings['featured_banner_auto_window_unit'] === 'minutes' ? 'selected' : '' ?>>Minutes</option>
  678. <option value="hours" <?= $settings['featured_banner_auto_window_unit'] === 'hours' ? 'selected' : '' ?>>Hours</option>
  679. </select>
  680. </div>
  681. ​
  682. <label>Mix of categories</label>
  683. <select name="featured_banner_auto_category_mode">
  684. <option value="mixed" <?= $settings['featured_banner_auto_category_mode'] === 'mixed' ? 'selected' : '' ?>>Mix all categories</option>
  685. <option value="one_per_category" <?= $settings['featured_banner_auto_category_mode'] === 'one_per_category' ? 'selected' : '' ?>>One article per category</option>
  686. <option value="no_limit" <?= $settings['featured_banner_auto_category_mode'] === 'no_limit' ? 'selected' : '' ?>>No category limit</option>
  687. </select>
  688. </div>
  689. ​
  690. <script>
  691. (function() {
  692. var modeSelect = document.getElementById('featured_banner_mode_select');
  693. var staticFields = document.getElementById('featured-banner-static-fields');
  694. var autoFields = document.getElementById('featured-banner-auto-fields');
  695. ​
  696. function apply() {
  697. var isAutomatic = modeSelect.value === 'automatic';
  698. staticFields.style.display = isAutomatic ? 'none' : '';
  699. autoFields.style.display = isAutomatic ? '' : 'none';
  700. }
  701. ​
  702. modeSelect.addEventListener('change', apply);
  703. apply();
  704. })();
  705. </script>
  706. ​
  707. <label><input type="checkbox" name="back_to_top_enabled" <?= $settings['back_to_top_enabled'] === '1' ? 'checked' : '' ?>> Show a "Back to top" button after scrolling down</label>
  708. <label><input type="checkbox" name="back_to_top_progress_enabled" <?= $settings['back_to_top_progress_enabled'] === '1' ? 'checked' : '' ?>> Show how much of the article is left (in %) on the "Back to top" button</label>
  709. <label><input type="checkbox" name="toc_enabled" <?= $settings['toc_enabled'] === '1' ? 'checked' : '' ?>> Show a table of contents at the top of articles (when they have 2 or more headings)</label>
  710. <label><input type="checkbox" name="breadcrumbs_enabled" <?= $settings['breadcrumbs_enabled'] === '1' ? 'checked' : '' ?>> Show breadcrumbs above the article title (Home › Category › Article)</label>
  711. <label><input type="checkbox" name="author_box_enabled" <?= $settings['author_box_enabled'] === '1' ? 'checked' : '' ?>> Show an author box below each article</label>
  712. <label><input type="checkbox" name="account_consents_enabled" <?= $settings['account_consents_enabled'] === '1' ? 'checked' : '' ?>> Show “Privacy consents” on the reader’s account page (untick if you don’t use them)</label>
  713. <label><input type="checkbox" name="author_pages_enabled" <?= $settings['author_pages_enabled'] === '1' ? 'checked' : '' ?>> Turn on author pages. Writers set up their own page, an editor approves it, and author names link to it</label>
  714. <label><input type="checkbox" name="tags_enabled" <?= $settings['tags_enabled'] === '1' ? 'checked' : '' ?>> Turn on tags. Writers add them in the editor, readers see them under articles and can browse by tag</label>
  715. <label><input type="checkbox" name="article_reading_time_enabled" <?= $settings['article_reading_time_enabled'] === '1' ? 'checked' : '' ?>> Show reading time (like "4 min read") at the top of articles</label>
  716. <label><input type="checkbox" name="article_time_enabled" <?= $settings['article_time_enabled'] === '1' ? 'checked' : '' ?>> Show the time next to the publish date</label>
  717. <label>Time format</label>
  718. <select name="article_time_format">
  719. <option value="24" <?= $settings['article_time_format'] !== '12' ? 'selected' : '' ?>>24-hour (14:30)</option>
  720. <option value="12" <?= $settings['article_time_format'] === '12' ? 'selected' : '' ?>>12-hour (2:30 PM)</option>
  721. </select>
  722. <label><input type="checkbox" name="related_articles_enabled" <?= $settings['related_articles_enabled'] === '1' ? 'checked' : '' ?>> Show related articles below each article</label>
  723. <label><input type="checkbox" name="sitemap_rss_enabled" <?= $settings['sitemap_rss_enabled'] === '1' ? 'checked' : '' ?>> Create a sitemap for search engines and an RSS feed</label>
  724. <label><input type="checkbox" name="rss_category_feeds_enabled" <?= $settings['rss_category_feeds_enabled'] === '1' ? 'checked' : '' ?>> Also give each category its own RSS feed, linked on the category page</label>
  725. ​
  726. <?php $readAloud = ReadAloud::settings(); ?>
  727. <h2 class="dash-subtitle"><?= Icons::icon('headphones', 'icon icon-sm') ?>Read aloud</h2>
  728. <label><input type="checkbox" name="read_aloud_enabled" id="read-aloud-enabled" <?= $readAloud['enabled'] ? 'checked' : '' ?>> Turn on read aloud. Articles you mark in the editor get an audio player above the text</label>
  729. <div id="read-aloud-fields" data-csrf="<?= htmlspecialchars(Csrf::token(), ENT_QUOTES) ?>" style="<?= $readAloud['enabled'] ? '' : 'display:none;' ?>">
  730. <p style="font-size:12.5px;color:var(--muted);margin:0 0 12px;">The player plays an intro, the article, then an outro. Intro and outro are optional MP3 or WAV files (up to <?= (int) (ReadAloud::MAX_FRAME_AUDIO_BYTES / 1048576) ?> MB). The article audio is created automatically when you publish, using the voice service set in the AI tab. You can also upload your own recording in the editor.</p>
  731. <?php foreach (['intro' => 'Intro', 'outro' => 'Outro'] as $readAloudSlot => $readAloudSlotLabel): ?>
  732. <div class="sidebar-box read-aloud-slot" data-kind="<?= $readAloudSlot ?>" style="margin-bottom:12px;">
  733. <label style="margin-top:0;"><?= $readAloudSlotLabel ?> (MP3 / WAV)</label>
  734. <input type="hidden" name="read_aloud_<?= $readAloudSlot ?>" value="<?= htmlspecialchars($readAloud[$readAloudSlot]) ?>">
  735. <audio controls preload="none" class="read-aloud-preview" style="width:100%;margin:8px 0;<?= $readAloud[$readAloudSlot] === '' ? 'display:none;' : '' ?>" <?= $readAloud[$readAloudSlot] !== '' ? 'src="' . htmlspecialchars(ReadAloud::publicUrl($readAloud[$readAloudSlot])) . '"' : '' ?>></audio>
  736. <input type="file" class="read-aloud-file" accept=".mp3,.wav,audio/mpeg,audio/wav">
  737. <div class="read-aloud-status" style="font-size:12px;color:var(--muted);margin-top:6px;"></div>
  738. <label class="read-aloud-remove" style="<?= $readAloud[$readAloudSlot] === '' ? 'display:none;' : '' ?>"><input type="checkbox" name="read_aloud_<?= $readAloudSlot ?>_remove"> Remove the <?= strtolower($readAloudSlotLabel) ?></label>
  739. </div>
  740. <?php endforeach; ?>
  741. </div>
  742. <script>
  743. (function() {
  744. var toggle = document.getElementById('read-aloud-enabled');
  745. var fields = document.getElementById('read-aloud-fields');
  746. if (!toggle || !fields) { return; }
  747. ​
  748. toggle.addEventListener('change', function() {
  749. fields.style.display = toggle.checked ? '' : 'none';
  750. });
  751. ​
  752. fields.querySelectorAll('.read-aloud-slot').forEach(function(slot) {
  753. var input = slot.querySelector('.read-aloud-file');
  754. var hidden = slot.querySelector('input[type="hidden"]');
  755. var preview = slot.querySelector('.read-aloud-preview');
  756. var status = slot.querySelector('.read-aloud-status');
  757. var removeRow = slot.querySelector('.read-aloud-remove');
  758. ​
  759. input.addEventListener('change', function() {
  760. if (!input.files.length) { return; }
  761. ​
  762. var data = new FormData();
  763. data.append('file', input.files[0]);
  764. data.append('kind', slot.getAttribute('data-kind'));
  765. data.append('csrf_token', fields.getAttribute('data-csrf'));
  766. status.textContent = 'Uploading…';
  767. ​
  768. fetch('audio-upload.php', { method: 'POST', body: data, credentials: 'same-origin' })
  769. .then(function(response) { return response.json(); })
  770. .then(function(result) {
  771. if (!result.success) { throw new Error(result.error || 'Upload failed.'); }
  772. hidden.value = result.path;
  773. preview.src = result.url;
  774. preview.style.display = '';
  775. removeRow.style.display = '';
  776. status.textContent = 'Uploaded. Press Save to apply it.';
  777. })
  778. .catch(function(error) {
  779. status.textContent = error.message;
  780. input.value = '';
  781. });
  782. });
  783. });
  784. })();
  785. </script>
  786. ​
  787. <h2 class="dash-subtitle"><?= Icons::icon('more', 'icon icon-sm') ?>Pagination</h2>
  788. <label>Article loading</label>
  789. <select name="article_loading_mode">
  790. <option value="scroll" <?= $settings['article_loading_mode'] === 'scroll' ? 'selected' : '' ?>>Load them page by page</option>
  791. <option value="static" <?= $settings['article_loading_mode'] === 'static' ? 'selected' : '' ?>>Show all articles at once</option>
  792. </select>
  793. <label>Style</label>
  794. <select name="pagination_style">
  795. <option value="numbered" <?= $settings['pagination_style'] === 'numbered' ? 'selected' : '' ?>>Numbered pages (1 2 3 … Next)</option>
  796. <option value="load_more" <?= $settings['pagination_style'] === 'load_more' ? 'selected' : '' ?>>"Load more" button</option>
  797. <option value="infinite_scroll" <?= $settings['pagination_style'] === 'infinite_scroll' ? 'selected' : '' ?>>Infinite scroll</option>
  798. </select>
  799. ​
  800. <label>Articles per page on the home page</label>
  801. <input type="number" name="articles_per_page_home" min="1" max="100" value="<?= (int) $settings['articles_per_page_home'] ?>">
  802. ​
  803. <label>Articles per page in categories</label>
  804. <input type="number" name="articles_per_page_category" min="1" max="100" value="<?= (int) $settings['articles_per_page_category'] ?>">
  805. ​
  806. <label>Heading font (h1, h2, h3)</label>
  807. <select name="heading_font">
  808. <?php if (empty($availableFonts)): ?>
  809. <option value="<?= htmlspecialchars($settings['heading_font']) ?>"><?= htmlspecialchars($settings['heading_font']) ?> (no fonts found, add them to assets/fonts/)</option>
  810. <?php endif; ?>
  811. <?php foreach ($availableFonts as $font): ?>
  812. <option value="<?= htmlspecialchars($font['display_name']) ?>" <?= $settings['heading_font'] === $font['display_name'] ? 'selected' : '' ?>><?= htmlspecialchars($font['display_name']) ?></option>
  813. <?php endforeach; ?>
  814. </select>
  815. ​
  816. <label>Body text font</label>
  817. <select name="body_font">
  818. <?php if (empty($availableFonts)): ?>
  819. <option value="<?= htmlspecialchars($settings['body_font']) ?>"><?= htmlspecialchars($settings['body_font']) ?> (no fonts found, add them to assets/fonts/)</option>
  820. <?php endif; ?>
  821. <?php foreach ($availableFonts as $font): ?>
  822. <option value="<?= htmlspecialchars($font['display_name']) ?>" <?= $settings['body_font'] === $font['display_name'] ? 'selected' : '' ?>><?= htmlspecialchars($font['display_name']) ?></option>
  823. <?php endforeach; ?>
  824. </select>
  825. ​
  826. <label>Interface font (menus, buttons and labels)</label>
  827. <select name="interface_font">
  828. <?php if (empty($availableFonts)): ?>
  829. <option value="<?= htmlspecialchars($settings['interface_font']) ?>"><?= htmlspecialchars($settings['interface_font']) ?> (no fonts found, add them to assets/fonts/)</option>
  830. <?php endif; ?>
  831. <?php foreach ($availableFonts as $font): ?>
  832. <option value="<?= htmlspecialchars($font['display_name']) ?>" <?= $settings['interface_font'] === $font['display_name'] ? 'selected' : '' ?>><?= htmlspecialchars($font['display_name']) ?></option>
  833. <?php endforeach; ?>
  834. </select>
  835. ​
  836. <h2 class="dash-subtitle"><?= Icons::icon('share', 'icon icon-sm') ?>Share on social media</h2>
  837. <p style="font-size:12.5px;color:var(--muted);margin:-6px 0 16px;">Adds a Share button to articles so readers can pass them on.</p>
  838. <label><input type="checkbox" name="share_enabled" <?= $settings['share_enabled'] === '1' ? 'checked' : '' ?>> Show the Share button on articles</label>
  839. ​
  840. <div class="settings-grid">
  841. <label class="share-settings-platform"><input type="checkbox" name="share_facebook" <?= $settings['share_facebook'] === '1' ? 'checked' : '' ?>> <span class="share-settings-platform-icon"><?= SocialIcons::builtinSvg('facebook') ?></span> Facebook</label>
  842. <label class="share-settings-platform"><input type="checkbox" name="share_twitter" <?= $settings['share_twitter'] === '1' ? 'checked' : '' ?>> <span class="share-settings-platform-icon"><?= SocialIcons::builtinSvg('twitter') ?></span> Twitter / X</label>
  843. <label class="share-settings-platform"><input type="checkbox" name="share_linkedin" <?= $settings['share_linkedin'] === '1' ? 'checked' : '' ?>> <span class="share-settings-platform-icon"><?= SocialIcons::builtinSvg('linkedin') ?></span> LinkedIn</label>
  844. <label class="share-settings-platform"><input type="checkbox" name="share_whatsapp" <?= $settings['share_whatsapp'] === '1' ? 'checked' : '' ?>> <span class="share-settings-platform-icon"><svg viewBox="0 0 24 24"><path d="M12.04 2c-5.52 0-10 4.48-10 10 0 1.77.46 3.5 1.34 5.02L2 22l5.13-1.35a9.96 9.96 0 0 0 4.91 1.29h.01c5.52 0 10-4.48 10-10S17.56 2 12.04 2zm5.87 14.24c-.25.7-1.45 1.34-2 1.42-.51.08-1.16.12-1.87-.12-.43-.14-.98-.32-1.69-.63-2.97-1.28-4.9-4.27-5.05-4.47-.15-.2-1.21-1.61-1.21-3.07s.76-2.18 1.03-2.48c.27-.3.59-.37.79-.37.2 0 .4 0 .57.01.18.01.43-.07.67.51.25.6.85 2.08.92 2.23.07.15.12.33.02.53-.1.2-.15.33-.3.5-.15.18-.31.4-.45.54-.15.15-.3.31-.13.61.17.3.77 1.27 1.65 2.06 1.13 1.01 2.09 1.32 2.39 1.47.3.15.48.13.65-.08.18-.2.75-.87.95-1.17.2-.3.4-.25.67-.15.27.1 1.73.82 2.03.97.3.15.5.22.57.35.08.13.08.73-.17 1.43z"/></svg></span> WhatsApp</label>
  845. <label class="share-settings-platform"><input type="checkbox" name="share_telegram" <?= $settings['share_telegram'] === '1' ? 'checked' : '' ?>> <span class="share-settings-platform-icon"><svg viewBox="0 0 24 24"><path d="M21.9 4.3 18.8 19.5c-.23 1.05-.85 1.3-1.72.81l-4.75-3.5-2.29 2.2c-.25.25-.47.47-.96.47l.34-4.85 8.8-7.95c.38-.34-.09-.53-.6-.19L6.9 12.6l-4.7-1.47c-1.02-.32-1.04-1.02.21-1.5L20.6 2.9c.85-.32 1.6.2 1.3 1.4z"/></svg></span> Telegram</label>
  846. <label class="share-settings-platform"><input type="checkbox" name="share_email" <?= $settings['share_email'] === '1' ? 'checked' : '' ?>> <span class="share-settings-platform-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"></rect><path d="M3 7l9 6 9-6"></path></svg></span> Email</label>
  847. <label class="share-settings-platform"><input type="checkbox" name="share_copy_link" <?= $settings['share_copy_link'] === '1' ? 'checked' : '' ?>> <span class="share-settings-platform-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.07 0l2.83-2.83a5 5 0 0 0-7.07-7.07l-1.42 1.41"></path><path d="M14 11a5 5 0 0 0-7.07 0L4.1 13.83a5 5 0 0 0 7.07 7.07l1.41-1.41"></path></svg></span> Copy link</label>
  848. </div>
  849. ​
  850. <h2 class="dash-subtitle"><?= Icons::icon('shield', 'icon icon-sm') ?>Ad blocker notice</h2>
  851. <p style="font-size:12.5px;color:var(--muted);margin:-6px 0 16px;">Shows a message to visitors who use an ad blocker. The check runs only in the visitor’s browser, nothing is sent or saved on the server. Paying subscribers never see it. You can change the texts in the Language tab.</p>
  852. <label><input type="checkbox" name="adblock_notice_enabled" <?= ($settings['adblock_notice_enabled'] ?? '0') === '1' ? 'checked' : '' ?>> Show a message when an ad blocker is detected</label>
  853. <label>How it behaves</label>
  854. <select name="adblock_notice_mode">
  855. <?php foreach (SiteFront::ADBLOCK_MODES as $adblockModeKey => $adblockModeLabel): ?>
  856. <option value="<?= htmlspecialchars($adblockModeKey, ENT_QUOTES) ?>" <?= SiteFront::adblockMode() === $adblockModeKey ? 'selected' : '' ?>><?= htmlspecialchars($adblockModeLabel) ?></option>
  857. <?php endforeach; ?>
  858. </select>
  859. <p style="font-size:12.5px;color:var(--muted);margin:6px 0 0;">User-Friendly and Session remember that the message was shown only in the visitor’s own browser. Nothing is stored on the server.</p>
  860. ​
  861. <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:20px;"><?= Icons::icon('check', 'icon icon-sm') ?>Save</button>
  862. </form>
  863. <?php endif; ?>
  864. ​
  865. <?php if ($activeTab === 'navigation'): ?>
  866. <?php
  867. function renderNavIconPickerFields(string $selectedKey = '', string $selectedSvg = ''): void
  868. {
  869. $previewKey = Icons::isNavIcon($selectedKey) ? $selectedKey : 'home';
  870. ?>
  871. <label>Icon (optional)</label>
  872. <div class="nav-icon-picker-row">
  873. <span class="nav-icon-picker-preview"><?= Icons::icon($previewKey, 'icon js-nav-icon-preview') ?></span>
  874. <select name="icon_key" class="js-nav-icon-select">
  875. <option value="" <?= $selectedKey === '' ? 'selected' : '' ?>>No icon</option>
  876. <?php foreach (Icons::navIconKeys() as $navIconKeyOption => $navIconLabel): ?>
  877. <option value="<?= htmlspecialchars($navIconKeyOption) ?>" <?= $selectedKey === $navIconKeyOption ? 'selected' : '' ?>><?= htmlspecialchars($navIconLabel) ?></option>
  878. <?php endforeach; ?>
  879. <option value="custom" <?= $selectedKey === 'custom' ? 'selected' : '' ?>>Custom SVG…</option>
  880. </select>
  881. </div>
  882. <div class="js-nav-icon-custom-wrap" style="<?= $selectedKey === 'custom' ? '' : 'display:none;' ?>">
  883. <textarea name="icon_svg" class="be-code js-nav-icon-svg" rows="2" placeholder='<svg viewBox="0 0 24 24">...</svg>'><?= htmlspecialchars($selectedSvg) ?></textarea>
  884. </div>
  885. <?php
  886. }
  887. ?>
  888. <div class="settings-section settings-section-wide">
  889. <p style="font-size:12.5px;color:var(--muted);margin:-4px 0 20px;">Choose what goes in the site’s main menu and in what order. If the list is empty, the menu shows every category and page marked "Show in site menu".</p>
  890. ​
  891. <?php if (empty($navItemsRaw)): ?>
  892. <p style="color:var(--muted);font-size:13.5px;">You haven’t set up a menu yet, so the site shows your categories and then your pages. Add an item below to build your own.</p>
  893. <?php else: ?>
  894. <table class="dash-table nav-items-table">
  895. <thead>
  896. <tr>
  897. <th></th>
  898. <th><?= Icons::icon('heading', 'icon icon-sm') ?>Label</th>
  899. <th><?= Icons::icon('tag', 'icon icon-sm') ?>Type</th>
  900. <th><?= Icons::icon('link', 'icon icon-sm') ?>Opens in</th>
  901. <th></th>
  902. </tr>
  903. </thead>
  904. <tbody>
  905. <?php foreach ($navItemsRaw as $navIndex => $navRow): ?>
  906. <?php
  907. if ($navRow['item_type'] === 'category') {
  908. $navDisplayLabel = $navRow['label'] !== null && $navRow['label'] !== '' ? $navRow['label'] : ($allCategoryNames[(int) $navRow['ref_id']] ?? '(deleted category)');
  909. $navTypeLabel = 'Category';
  910. } elseif ($navRow['item_type'] === 'page') {
  911. $navDisplayLabel = $navRow['label'] !== null && $navRow['label'] !== '' ? $navRow['label'] : ($allPageTitles[(int) $navRow['ref_id']] ?? '(deleted page)');
  912. $navTypeLabel = 'Page';
  913. } else {
  914. $navDisplayLabel = $navRow['label'] ?? '';
  915. $navTypeLabel = 'Custom link';
  916. }
  917. $navRowIconKey = (string) ($navRow['icon_key'] ?? '');
  918. $navRowIconSvg = (string) ($navRow['icon_svg'] ?? '');
  919. ?>
  920. <tr>
  921. <td data-label="Icon">
  922. <?php if ($navRowIconKey === 'custom' && $navRowIconSvg !== ''): ?>
  923. <span class="nav-item-icon" aria-hidden="true"><?= $navRowIconSvg ?></span>
  924. <?php elseif ($navRowIconKey !== '' && Icons::isNavIcon($navRowIconKey)): ?>
  925. <?= Icons::icon($navRowIconKey, 'icon icon-sm nav-item-icon') ?>
  926. <?php else: ?>
  927. <span style="color:var(--muted);">None</span>
  928. <?php endif; ?>
  929. </td>
  930. <td data-label="Label"><div class="nav-item-name"><?= htmlspecialchars($navDisplayLabel) ?><?php if ($navRow['item_type'] === 'custom'): ?><div class="nav-item-url"><?= htmlspecialchars($navRow['url']) ?></div><?php endif; ?></div></td>
  931. <td data-label="Type"><?= htmlspecialchars($navTypeLabel) ?></td>
  932. <td data-label="Opens in"><?= $navRow['target'] === '_blank' ? 'New tab' : 'Same tab' ?></td>
  933. <td>
  934. <div class="dash-table-actions">
  935. <?php if ($navIndex > 0): ?>
  936. <form method="post" style="display:inline;">
  937. <?= Csrf::field() ?>
  938. <input type="hidden" name="action" value="move_nav_item">
  939. <input type="hidden" name="nav_item_id" value="<?= (int) $navRow['id'] ?>">
  940. <input type="hidden" name="direction" value="up">
  941. <button type="submit" class="dash-btn-small" aria-label="Move up"><?= Icons::icon('arrow-up', 'icon icon-sm') ?></button>
  942. </form>
  943. <?php else: ?>
  944. <span class="dash-btn-small nav-move-placeholder" aria-hidden="true"><?= Icons::icon('arrow-up', 'icon icon-sm') ?></span>
  945. <?php endif; ?>
  946. <?php if ($navIndex < count($navItemsRaw) - 1): ?>
  947. <form method="post" style="display:inline;">
  948. <?= Csrf::field() ?>
  949. <input type="hidden" name="action" value="move_nav_item">
  950. <input type="hidden" name="nav_item_id" value="<?= (int) $navRow['id'] ?>">
  951. <input type="hidden" name="direction" value="down">
  952. <button type="submit" class="dash-btn-small" aria-label="Move down"><?= Icons::icon('arrow-down', 'icon icon-sm') ?></button>
  953. </form>
  954. <?php else: ?>
  955. <span class="dash-btn-small nav-move-placeholder" aria-hidden="true"><?= Icons::icon('arrow-down', 'icon icon-sm') ?></span>
  956. <?php endif; ?>
  957. <button type="button" class="dash-btn-small js-edit-nav-icon"
  958. data-id="<?= (int) $navRow['id'] ?>"
  959. data-icon-key="<?= htmlspecialchars($navRowIconKey, ENT_QUOTES) ?>"
  960. data-icon-svg="<?= htmlspecialchars($navRowIconSvg, ENT_QUOTES) ?>"><?= Icons::icon('edit', 'icon icon-sm') ?>Icon</button>
  961. <form method="post" style="display:inline;" onsubmit="return confirm('Remove this menu item?');">
  962. <?= Csrf::field() ?>
  963. <input type="hidden" name="action" value="delete_nav_item">
  964. <input type="hidden" name="nav_item_id" value="<?= (int) $navRow['id'] ?>">
  965. <button type="submit" class="dash-btn-small dash-btn-danger"><?= Icons::icon('trash', 'icon icon-sm') ?>Remove</button>
  966. </form>
  967. </div>
  968. </td>
  969. </tr>
  970. <?php endforeach; ?>
  971. </tbody>
  972. </table>
  973. <?php endif; ?>
  974. </div>
  975. ​
  976. <div class="sidebar-box" style="max-width:520px;margin-top:20px;display:none;" id="nav-icon-form-wrapper">
  977. <h2 class="dash-subtitle" style="margin-top:0;"><?= Icons::icon('star', 'icon icon-sm') ?>Menu item icon</h2>
  978. <form method="post" id="nav-icon-form">
  979. <?= Csrf::field() ?>
  980. <input type="hidden" name="action" value="update_nav_item_icon">
  981. <input type="hidden" name="nav_item_id" id="nav_icon_item_id_input" value="">
  982. <?php renderNavIconPickerFields(); ?>
  983. <div class="publish-actions" style="margin-top:12px;">
  984. <button type="submit" class="dash-btn dash-btn-primary"><?= Icons::icon('check', 'icon icon-sm') ?>Save icon</button>
  985. <button type="button" class="dash-btn" id="nav-icon-cancel-btn"><?= Icons::icon('x', 'icon icon-sm') ?>Cancel</button>
  986. </div>
  987. </form>
  988. </div>
  989. ​
  990. <div class="sidebar-box" style="max-width:520px;margin-top:20px;">
  991. <h2 class="dash-subtitle" style="margin-top:0;"><?= Icons::icon('plus', 'icon icon-sm') ?>Add menu item</h2>
  992. ​
  993. <div class="settings-tabs" id="nav-add-type-tabs" style="margin-bottom:16px;">
  994. <a href="#" class="active" data-nav-type="category"><?= Icons::icon("categories", "icon icon-sm") ?>Category</a>
  995. <a href="#" data-nav-type="page"><?= Icons::icon("pages", "icon icon-sm") ?>Page</a>
  996. <a href="#" data-nav-type="custom"><?= Icons::icon("link", "icon icon-sm") ?>Custom link</a>
  997. </div>
  998. ​
  999. <form method="post" id="nav-add-form">
  1000. <?= Csrf::field() ?>
  1001. <input type="hidden" name="action" value="add_nav_item">
  1002. <input type="hidden" name="item_type" id="nav_item_type_input" value="category">
  1003. ​
  1004. <div id="nav-add-category-fields">
  1005. <label>Category</label>
  1006. <select name="category_ref_id">
  1007. <?php if (empty($navAvailableCategories)): ?>
  1008. <option value="">No categories yet</option>
  1009. <?php endif; ?>
  1010. <?php foreach ($navAvailableCategories as $navCategoryOption): ?>
  1011. <option value="<?= (int) $navCategoryOption['id'] ?>"><?= htmlspecialchars($navCategoryOption['name']) ?></option>
  1012. <?php endforeach; ?>
  1013. </select>
  1014. </div>
  1015. ​
  1016. <div id="nav-add-page-fields" style="display:none;">
  1017. <label>Page</label>
  1018. <select name="page_ref_id">
  1019. <?php if (empty($navAvailablePages)): ?>
  1020. <option value="">No pages yet</option>
  1021. <?php endif; ?>
  1022. <?php foreach ($navAvailablePages as $navPageOption): ?>
  1023. <option value="<?= (int) $navPageOption['id'] ?>"><?= htmlspecialchars($navPageOption['title']) ?></option>
  1024. <?php endforeach; ?>
  1025. </select>
  1026. </div>
  1027. ​
  1028. <div id="nav-add-custom-fields" style="display:none;">
  1029. <label>Label</label>
  1030. <input type="text" name="custom_label">
  1031. ​
  1032. <label>URL</label>
  1033. <input type="text" name="custom_url" placeholder="https://example.com or /about">
  1034. ​
  1035. <label><input type="checkbox" name="custom_new_tab"> Open in a new tab</label>
  1036. </div>
  1037. ​
  1038. <label id="nav-label-override-wrap" style="display:none;">Custom label (optional, leave empty to use the category or page name)</label>
  1039. <input type="text" name="label_override" id="nav_label_override_input" style="display:none;">
  1040. ​
  1041. <?php renderNavIconPickerFields(); ?>
  1042. ​
  1043. <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:16px;"><?= Icons::icon('plus', 'icon icon-sm') ?>Add to menu</button>
  1044. </form>
  1045. </div>
  1046. ​
  1047. <script>
  1048. (function() {
  1049. function wireIconPicker(form) {
  1050. var select = form.querySelector('.js-nav-icon-select');
  1051. var preview = form.querySelector('.js-nav-icon-preview use');
  1052. var customWrap = form.querySelector('.js-nav-icon-custom-wrap');
  1053. ​
  1054. function apply() {
  1055. var isCustom = select.value === 'custom';
  1056. customWrap.style.display = isCustom ? '' : 'none';
  1057. if (!isCustom && select.value !== '') {
  1058. preview.setAttribute('href', '#icon-' + select.value);
  1059. }
  1060. form.querySelector('.js-nav-icon-preview').style.visibility = (select.value === '' || isCustom) ? 'hidden' : 'visible';
  1061. }
  1062. ​
  1063. select.addEventListener('change', apply);
  1064. apply();
  1065. }
  1066. ​
  1067. document.querySelectorAll('#nav-icon-form, #nav-add-form').forEach(wireIconPicker);
  1068. ​
  1069. var iconWrapper = document.getElementById('nav-icon-form-wrapper');
  1070. var iconForm = document.getElementById('nav-icon-form');
  1071. ​
  1072. document.querySelectorAll('.js-edit-nav-icon').forEach(function(button) {
  1073. button.addEventListener('click', function() {
  1074. document.getElementById('nav_icon_item_id_input').value = this.dataset.id;
  1075. var select = iconForm.querySelector('.js-nav-icon-select');
  1076. var svgTextarea = iconForm.querySelector('.js-nav-icon-svg');
  1077. select.value = this.dataset.iconKey || '';
  1078. svgTextarea.value = this.dataset.iconSvg || '';
  1079. select.dispatchEvent(new Event('change'));
  1080. iconWrapper.style.display = '';
  1081. iconWrapper.scrollIntoView({
  1082. behavior: 'smooth',
  1083. block: 'nearest'
  1084. });
  1085. });
  1086. });
  1087. ​
  1088. document.getElementById('nav-icon-cancel-btn').addEventListener('click', function() {
  1089. iconWrapper.style.display = 'none';
  1090. });
  1091. })();
  1092. </script>
  1093. ​
  1094. <script>
  1095. (function() {
  1096. var typeTabs = document.querySelectorAll('#nav-add-type-tabs a');
  1097. var typeInput = document.getElementById('nav_item_type_input');
  1098. var categoryFields = document.getElementById('nav-add-category-fields');
  1099. var pageFields = document.getElementById('nav-add-page-fields');
  1100. var customFields = document.getElementById('nav-add-custom-fields');
  1101. var labelOverrideWrap = document.getElementById('nav-label-override-wrap');
  1102. var labelOverrideInput = document.getElementById('nav_label_override_input');
  1103. ​
  1104. function setType(type) {
  1105. typeInput.value = type;
  1106. categoryFields.style.display = type === 'category' ? '' : 'none';
  1107. pageFields.style.display = type === 'page' ? '' : 'none';
  1108. customFields.style.display = type === 'custom' ? '' : 'none';
  1109. var showOverride = type === 'category' || type === 'page';
  1110. labelOverrideWrap.style.display = showOverride ? '' : 'none';
  1111. labelOverrideInput.style.display = showOverride ? '' : 'none';
  1112. ​
  1113. typeTabs.forEach(function(tab) {
  1114. tab.classList.toggle('active', tab.dataset.navType === type);
  1115. });
  1116. }
  1117. ​
  1118. typeTabs.forEach(function(tab) {
  1119. tab.addEventListener('click', function(event) {
  1120. event.preventDefault();
  1121. setType(this.dataset.navType);
  1122. });
  1123. });
  1124. })();
  1125. </script>
  1126. <?php endif; ?>
  1127. ​
  1128. <?php if ($activeTab === 'search'): ?>
  1129. <form method="post" class="settings-section">
  1130. <?= Csrf::field() ?>
  1131. <input type="hidden" name="action" value="save_settings">
  1132. <input type="hidden" name="settings_tab" value="search">
  1133. ​
  1134. <label><input type="checkbox" name="search_enabled" <?= $settings['search_enabled'] === '1' ? 'checked' : '' ?>> Turn on site search</label>
  1135. <p style="font-size:12px;color:var(--muted);margin:-6px 0 16px;">When it’s off, the search page and search box disappear from the site.</p>
  1136. ​
  1137. <h2 class="dash-subtitle"><?= Icons::icon('search', 'icon icon-sm') ?>Search in</h2>
  1138. <label><input type="checkbox" name="search_match_title" <?= $settings['search_match_title'] === '1' ? 'checked' : '' ?>> Article titles</label>
  1139. <label><input type="checkbox" name="search_match_excerpt" <?= $settings['search_match_excerpt'] === '1' ? 'checked' : '' ?>> Article summaries</label>
  1140. <label><input type="checkbox" name="search_match_content" <?= $settings['search_match_content'] === '1' ? 'checked' : '' ?>> The full article text</label>
  1141. <p style="font-size:12px;color:var(--muted);margin:-6px 0 16px;">Keep at least one ticked, or search won’t find anything.</p>
  1142. ​
  1143. <h2 class="dash-subtitle"><?= Icons::icon('palette', 'icon icon-sm') ?>Appearance</h2>
  1144. <label>Search style</label>
  1145. <select name="search_style">
  1146. <option value="icon" <?= $settings['search_style'] === 'icon' ? 'selected' : '' ?>>Magnifying glass icon that opens the search page</option>
  1147. <option value="field" <?= $settings['search_style'] === 'field' ? 'selected' : '' ?>>Search box people can type in right away</option>
  1148. </select>
  1149. ​
  1150. <h2 class="dash-subtitle"><?= Icons::icon('layout', 'icon icon-sm') ?>Where to show it</h2>
  1151. <label><input type="checkbox" name="search_show_in_nav" <?= $settings['search_show_in_nav'] === '1' ? 'checked' : '' ?>> In the top menu</label>
  1152. <label><input type="checkbox" name="search_show_in_footer" <?= $settings['search_show_in_footer'] === '1' ? 'checked' : '' ?>> In the footer</label>
  1153. <p style="font-size:12px;color:var(--muted);margin:-6px 0 16px;">Both are off to start with. Pick one or both.</p>
  1154. ​
  1155. <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:20px;"><?= Icons::icon('check', 'icon icon-sm') ?>Save</button>
  1156. </form>
  1157. <?php endif; ?>
  1158. ​
  1159. <?php if ($activeTab === 'comments'): ?>
  1160. <?php require_once __DIR__ . '/includes/comments.php'; ?>
  1161. <?php $commentsFieldsFor = static fn(string $field): string => implode(' ', array_keys(array_filter(Comments::FIELDS, static fn(array $fields): bool => in_array($field, $fields, true)))); ?>
  1162. <form method="post" class="settings-section" id="comments-settings-form">
  1163. <?= Csrf::field() ?>
  1164. <input type="hidden" name="action" value="save_settings">
  1165. <input type="hidden" name="settings_tab" value="comments">
  1166. ​
  1167. <label><input type="checkbox" name="comments_enabled" <?= $settings['comments_enabled'] === '1' ? 'checked' : '' ?>> Turn on comments under articles</label>
  1168. ​
  1169. <label>Comment service</label>
  1170. <select name="comments_provider" id="comments_provider_input">
  1171. <?php foreach (Comments::PROVIDERS as $providerKey => $providerLabel): ?>
  1172. <option value="<?= htmlspecialchars($providerKey, ENT_QUOTES) ?>" <?= $settings['comments_provider'] === $providerKey ? 'selected' : '' ?>><?= htmlspecialchars($providerLabel) ?></option>
  1173. <?php endforeach; ?>
  1174. </select>
  1175. ​
  1176. <div class="comments-field" data-for="<?= $commentsFieldsFor('comments_who') ?>">
  1177. <label>Who can comment</label>
  1178. <select name="comments_who">
  1179. <?php foreach (SiteComments::WHO as $whoKey => $whoLabel): ?>
  1180. <option value="<?= htmlspecialchars($whoKey, ENT_QUOTES) ?>" <?= $settings['comments_who'] === $whoKey ? 'selected' : '' ?>><?= htmlspecialchars($whoLabel) ?></option>
  1181. <?php endforeach; ?>
  1182. </select>
  1183. <label>Moderation</label>
  1184. <select name="comments_moderation">
  1185. <?php foreach (SiteComments::MODERATION as $moderationKey => $moderationLabel): ?>
  1186. <option value="<?= htmlspecialchars($moderationKey, ENT_QUOTES) ?>" <?= $settings['comments_moderation'] === $moderationKey ? 'selected' : '' ?>><?= htmlspecialchars($moderationLabel) ?></option>
  1187. <?php endforeach; ?>
  1188. </select>
  1189. <label>Email address</label>
  1190. <select name="comments_email">
  1191. <?php foreach (SiteComments::EMAIL as $emailKey => $emailLabel): ?>
  1192. <option value="<?= htmlspecialchars($emailKey, ENT_QUOTES) ?>" <?= $settings['comments_email'] === $emailKey ? 'selected' : '' ?>><?= htmlspecialchars($emailLabel) ?></option>
  1193. <?php endforeach; ?>
  1194. </select>
  1195. <p class="comments-hint">Comments are stored on your own server and shown as plain text. Only the name and the comment are saved, plus the email when you require it. Emails are never shown on the site, only on the Comments page. The IP address isn’t stored with the comment (it’s only kept briefly to stop spam). Comments with more than two links always wait for approval. You approve, hide and delete comments on the Comments page in the menu.</p>
  1196. </div>
  1197. ​
  1198. <div class="comments-field" data-for="<?= $commentsFieldsFor('comments_shortname') ?>">
  1199. <label>Disqus shortname</label>
  1200. <input type="text" name="comments_shortname" value="<?= htmlspecialchars($settings['comments_shortname']) ?>" placeholder="my-site" maxlength="64">
  1201. <p class="comments-hint">The name you picked in Disqus, the part before .disqus.com.</p>
  1202. </div>
  1203. ​
  1204. <div class="comments-field" data-for="<?= $commentsFieldsFor('comments_server_url') ?>">
  1205. <label>Server address</label>
  1206. <input type="url" name="comments_server_url" value="<?= htmlspecialchars($settings['comments_server_url']) ?>" placeholder="https://comments.example.com" maxlength="255">
  1207. <p class="comments-hint">For self-hosted services, the address of your comment server. Leave it empty to use the service’s own cloud where there is one (Commento, Cusdis, Giscus).</p>
  1208. </div>
  1209. ​
  1210. <div class="comments-field" data-for="<?= $commentsFieldsFor('comments_site_id') ?>">
  1211. <label>Site ID</label>
  1212. <input type="text" name="comments_site_id" value="<?= htmlspecialchars($settings['comments_site_id']) ?>" maxlength="100">
  1213. <p class="comments-hint">Remark42: your site_id. Cusdis: the App ID. Hyvor Talk: the website ID.</p>
  1214. </div>
  1215. ​
  1216. <div class="comments-field" data-for="<?= $commentsFieldsFor('comments_repo') ?>">
  1217. <label>GitHub repository</label>
  1218. <input type="text" name="comments_repo" value="<?= htmlspecialchars($settings['comments_repo']) ?>" placeholder="owner/repository" maxlength="140">
  1219. </div>
  1220. ​
  1221. <div class="comments-field" data-for="<?= $commentsFieldsFor('comments_repo_id') ?>">
  1222. <div class="settings-grid">
  1223. <div>
  1224. <label>Repository ID</label>
  1225. <input type="text" name="comments_repo_id" value="<?= htmlspecialchars($settings['comments_repo_id']) ?>" maxlength="64">
  1226. </div>
  1227. <div>
  1228. <label>Category ID</label>
  1229. <input type="text" name="comments_category_id" value="<?= htmlspecialchars($settings['comments_category_id']) ?>" maxlength="64">
  1230. </div>
  1231. </div>
  1232. <label>Discussion category</label>
  1233. <input type="text" name="comments_category" value="<?= htmlspecialchars($settings['comments_category']) ?>" placeholder="Announcements" maxlength="100">
  1234. <p class="comments-hint">Copy these values from giscus.app after picking your repository.</p>
  1235. </div>
  1236. ​
  1237. <div class="comments-field" data-for="<?= $commentsFieldsFor('comments_mapping') ?>">
  1238. <label>Match comments to articles by</label>
  1239. <select name="comments_mapping">
  1240. <?php foreach (['pathname' => 'Page address path (recommended)', 'url' => 'Full page address', 'title' => 'Page title', 'og:title' => 'Social media title'] as $mappingKey => $mappingLabel): ?>
  1241. <option value="<?= htmlspecialchars($mappingKey, ENT_QUOTES) ?>" <?= $settings['comments_mapping'] === $mappingKey ? 'selected' : '' ?>><?= htmlspecialchars($mappingLabel) ?></option>
  1242. <?php endforeach; ?>
  1243. </select>
  1244. </div>
  1245. ​
  1246. <div class="comments-field" data-for="<?= $commentsFieldsFor('comments_custom_html') ?>">
  1247. <label>Embed code</label>
  1248. <textarea name="comments_custom_html" rows="8" class="be-code" spellcheck="false" placeholder="&lt;div id=&quot;comments&quot;&gt;&lt;/div&gt;&#10;&lt;script src=&quot;https://comments.example.com/embed.js&quot; data-page=&quot;{{id}}&quot;&gt;&lt;/script&gt;"><?= htmlspecialchars($settings['comments_custom_html']) ?></textarea>
  1249. <p class="comments-hint">Paste the code your comment service gives you. You can use {{url}}, {{id}}, {{title}}, {{slug}} and {{lang}}. They’re filled in for each article and made safe automatically.</p>
  1250. </div>
  1251. ​
  1252. <div class="comments-field" data-for="<?= htmlspecialchars(implode(' ', array_diff(array_keys(Comments::PROVIDERS), ['builtin'])), ENT_QUOTES) ?>">
  1253. <h2 class="dash-subtitle">Privacy and loading</h2>
  1254. <label>Load the comments</label>
  1255. <select name="comments_consent">
  1256. <option value="marketing" <?= $settings['comments_consent'] === 'marketing' ? 'selected' : '' ?>>After the visitor allows marketing cookies (recommended for Disqus and Hyvor)</option>
  1257. <option value="analytics" <?= $settings['comments_consent'] === 'analytics' ? 'selected' : '' ?>>After the visitor allows analytics cookies</option>
  1258. <option value="none" <?= $settings['comments_consent'] === 'none' ? 'selected' : '' ?>>Without asking (fine for self-hosted services that don’t track)</option>
  1259. </select>
  1260. <p class="comments-hint">This only applies when cookie consent is turned on in Analytics.</p>
  1261. ​
  1262. <label><input type="checkbox" name="comments_load" value="click" <?= $settings['comments_load'] === 'click' ? 'checked' : '' ?>> Load comments only after the reader clicks “Show comments” (faster pages, nothing is sent until then)</label>
  1263. </div>
  1264. ​
  1265. <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:20px;"><?= Icons::icon('check', 'icon icon-sm') ?>Save</button>
  1266. </form>
  1267. <script>
  1268. (function () {
  1269. var select = document.getElementById('comments_provider_input');
  1270. function sync() {
  1271. document.querySelectorAll('#comments-settings-form .comments-field').forEach(function (field) {
  1272. field.style.display = (' ' + field.getAttribute('data-for') + ' ').indexOf(' ' + select.value + ' ') !== -1 ? '' : 'none';
  1273. });
  1274. }
  1275. select.addEventListener('change', sync);
  1276. sync();
  1277. })();
  1278. </script>
  1279. <style>
  1280. .comments-hint { font-size: 12px; color: var(--muted); margin: 6px 0 14px; }
  1281. </style>
  1282. <?php endif; ?>
  1283. ​
  1284. <?php if ($activeTab === 'custom-code'): ?>
  1285. <form method="post" class="settings-section">
  1286. <?= Csrf::field() ?>
  1287. <input type="hidden" name="action" value="save_settings">
  1288. <input type="hidden" name="settings_tab" value="custom-code">
  1289. ​
  1290. <label>Custom CSS</label>
  1291. <textarea name="custom_css" rows="8" class="be-code"><?= htmlspecialchars($settings['custom_css']) ?></textarea>
  1292. ​
  1293. <label>Custom JavaScript</label>
  1294. <textarea name="custom_js" rows="8" class="be-code"><?= htmlspecialchars($settings['custom_js']) ?></textarea>
  1295. ​
  1296. <h2 class="dash-subtitle"><?= Icons::icon('code', 'icon icon-sm') ?>Code for the page head</h2>
  1297. <p style="font-size:12.5px;color:var(--muted);margin:-4px 0 12px;">Added at the end of &lt;head&gt;. Use it for verification codes and tracking code that has to load early.</p>
  1298. <textarea name="custom_head_html" rows="8" class="be-code"><?= htmlspecialchars($settings['custom_head_html']) ?></textarea>
  1299. ​
  1300. <label style="margin-top:12px;">Show on</label>
  1301. <label><input type="checkbox" name="custom_head_html_index" <?= $settings['custom_head_html_index'] === '1' ? 'checked' : '' ?>> Home page</label>
  1302. <label><input type="checkbox" name="custom_head_html_article" <?= $settings['custom_head_html_article'] === '1' ? 'checked' : '' ?>> Articles</label>
  1303. <label><input type="checkbox" name="custom_head_html_category" <?= $settings['custom_head_html_category'] === '1' ? 'checked' : '' ?>> Categories</label>
  1304. <label><input type="checkbox" name="custom_head_html_pages" <?= $settings['custom_head_html_pages'] === '1' ? 'checked' : '' ?>> Pages</label>
  1305. ​
  1306. <h2 class="dash-subtitle"><?= Icons::icon('code', 'icon icon-sm') ?>Code for the end of the page</h2>
  1307. <p style="font-size:12.5px;color:var(--muted);margin:-4px 0 12px;">Added at the end of &lt;body&gt;. Good for chat widgets and other scripts that can load last.</p>
  1308. <textarea name="custom_body_html" rows="8" class="be-code"><?= htmlspecialchars($settings['custom_body_html']) ?></textarea>
  1309. ​
  1310. <label style="margin-top:12px;">Show on</label>
  1311. <label><input type="checkbox" name="custom_body_html_index" <?= $settings['custom_body_html_index'] === '1' ? 'checked' : '' ?>> Home page</label>
  1312. <label><input type="checkbox" name="custom_body_html_article" <?= $settings['custom_body_html_article'] === '1' ? 'checked' : '' ?>> Articles</label>
  1313. <label><input type="checkbox" name="custom_body_html_category" <?= $settings['custom_body_html_category'] === '1' ? 'checked' : '' ?>> Categories</label>
  1314. <label><input type="checkbox" name="custom_body_html_pages" <?= $settings['custom_body_html_pages'] === '1' ? 'checked' : '' ?>> Pages</label>
  1315. ​
  1316. <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:20px;"><?= Icons::icon('check', 'icon icon-sm') ?>Save</button>
  1317. </form>
  1318. <?php endif; ?>
  1319. ​
  1320. <?php if ($activeTab === 'footer'): ?>
  1321. <form method="post" class="settings-section" id="footer-form">
  1322. <?= Csrf::field() ?>
  1323. <input type="hidden" name="action" value="save_footer">
  1324. <input type="hidden" name="footer_columns_json" id="footer_columns_json_input">
  1325. <input type="hidden" name="footer_social_json" id="footer_social_json_input">
  1326. ​
  1327. <h2 class="dash-subtitle" style="margin-top:0;"><?= Icons::icon('menu-list', 'icon icon-sm') ?>Link columns</h2>
  1328. <p style="font-size:12.5px;color:var(--muted);margin:-6px 0 16px;">Up to six columns of links at the bottom of the site, each with its own title. They show three per row, so columns four to six go in a second row.</p>
  1329. <div id="footer-columns-root"></div>
  1330. <button type="button" id="footer-add-column" class="dash-btn dash-btn-small" style="margin-top:4px;"><?= Icons::icon('plus', 'icon icon-sm') ?>Add column</button>
  1331. ​
  1332. <h2 class="dash-subtitle"><?= Icons::icon('globe', 'icon icon-sm') ?>Social media icons</h2>
  1333. <p style="font-size:12.5px;color:var(--muted);margin:-6px 0 16px;">Pick one of the ready-made icons or upload your own SVG.</p>
  1334. <div id="footer-social-root"></div>
  1335. <div class="publish-actions" style="margin-top:4px;">
  1336. <select id="footer-add-social-select" class="dash-input-inline" style="width:auto;">
  1337. <?php foreach (SocialIcons::builtinLabels() as $key => $label): ?>
  1338. <option value="<?= htmlspecialchars($key) ?>"><?= htmlspecialchars($label) ?></option>
  1339. <?php endforeach; ?>
  1340. </select>
  1341. <button type="button" id="footer-add-social-builtin" class="dash-btn dash-btn-small"><?= Icons::icon('plus', 'icon icon-sm') ?>Add icon</button>
  1342. <button type="button" id="footer-add-social-custom" class="dash-btn dash-btn-small"><?= Icons::icon('plus', 'icon icon-sm') ?>Add your own SVG icon</button>
  1343. </div>
  1344. ​
  1345. <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:20px;"><?= Icons::icon('check', 'icon icon-sm') ?>Save</button>
  1346. </form>
  1347. ​
  1348. <script>
  1349. (function() {
  1350. var initialColumns = <?= str_replace('<', '\u003C', FooterLinks::sanitizeColumns($settings['footer_columns'])) ?>;
  1351. var initialSocial = <?= str_replace('<', '\u003C', FooterLinks::sanitizeSocialLinks($settings['footer_social_links'])) ?>;
  1352. ​
  1353. var columnsRoot = document.getElementById('footer-columns-root');
  1354. var socialRoot = document.getElementById('footer-social-root');
  1355. ​
  1356. function el(tag, cls, text) {
  1357. var e = document.createElement(tag);
  1358. if (cls) e.className = cls;
  1359. if (text !== undefined) e.textContent = text;
  1360. return e;
  1361. }
  1362. ​
  1363. function renderColumns() {
  1364. columnsRoot.innerHTML = '';
  1365. initialColumns.forEach(function(column, colIndex) {
  1366. var box = el('div', 'sidebar-box');
  1367. box.style.marginBottom = '12px';
  1368. ​
  1369. var headerRow = el('div', 'be-list-row');
  1370. headerRow.style.marginBottom = '0';
  1371. ​
  1372. var titleLabel = el('label', null, 'Column title');
  1373. titleLabel.style.marginTop = '0';
  1374. titleLabel.style.marginBottom = '0';
  1375. titleLabel.style.flex = '1';
  1376. headerRow.appendChild(titleLabel);
  1377. ​
  1378. var moveBtnGroup = el('div', 'be-list-row');
  1379. moveBtnGroup.style.marginBottom = '0';
  1380. moveBtnGroup.style.gap = '8px';
  1381. moveBtnGroup.style.flexShrink = '0';
  1382. ​
  1383. if (colIndex > 0) {
  1384. var moveUpBtn = document.createElement('button');
  1385. moveUpBtn.type = 'button';
  1386. moveUpBtn.className = 'dash-btn-small';
  1387. moveUpBtn.setAttribute('aria-label', 'Move column up');
  1388. moveUpBtn.innerHTML = '<svg class="icon icon-sm" aria-hidden="true"><use href="#icon-arrow-up"></use></svg>';
  1389. moveUpBtn.addEventListener('click', function() {
  1390. var tmp = initialColumns[colIndex - 1];
  1391. initialColumns[colIndex - 1] = initialColumns[colIndex];
  1392. initialColumns[colIndex] = tmp;
  1393. renderColumns();
  1394. });
  1395. moveBtnGroup.appendChild(moveUpBtn);
  1396. }
  1397. ​
  1398. if (colIndex < initialColumns.length - 1) {
  1399. var moveDownBtn = document.createElement('button');
  1400. moveDownBtn.type = 'button';
  1401. moveDownBtn.className = 'dash-btn-small';
  1402. moveDownBtn.setAttribute('aria-label', 'Move column down');
  1403. moveDownBtn.innerHTML = '<svg class="icon icon-sm" aria-hidden="true"><use href="#icon-arrow-down"></use></svg>';
  1404. moveDownBtn.addEventListener('click', function() {
  1405. var tmp = initialColumns[colIndex + 1];
  1406. initialColumns[colIndex + 1] = initialColumns[colIndex];
  1407. initialColumns[colIndex] = tmp;
  1408. renderColumns();
  1409. });
  1410. moveBtnGroup.appendChild(moveDownBtn);
  1411. }
  1412. ​
  1413. headerRow.appendChild(moveBtnGroup);
  1414. box.appendChild(headerRow);
  1415. ​
  1416. var titleInput = document.createElement('input');
  1417. titleInput.type = 'text';
  1418. titleInput.value = column.title || '';
  1419. titleInput.placeholder = 'TITLE';
  1420. titleInput.addEventListener('input', function() {
  1421. column.title = this.value;
  1422. });
  1423. box.appendChild(titleInput);
  1424. ​
  1425. var linksLabel = el('label', null, 'Links');
  1426. box.appendChild(linksLabel);
  1427. ​
  1428. (column.links || []).forEach(function(link, linkIndex) {
  1429. var row = el('div', 'be-list-row');
  1430. ​
  1431. var labelInput = document.createElement('input');
  1432. labelInput.type = 'text';
  1433. labelInput.placeholder = 'Label';
  1434. labelInput.value = link.label || '';
  1435. labelInput.style.flex = '1';
  1436. labelInput.addEventListener('input', function() {
  1437. link.label = this.value;
  1438. });
  1439. ​
  1440. var urlInput = document.createElement('input');
  1441. urlInput.type = 'text';
  1442. urlInput.placeholder = 'https:// or /page';
  1443. urlInput.value = link.url || '';
  1444. urlInput.style.flex = '1';
  1445. urlInput.addEventListener('input', function() {
  1446. link.url = this.value;
  1447. });
  1448. ​
  1449. var linkMoveBtnGroup = el('div', 'be-list-row');
  1450. linkMoveBtnGroup.style.marginBottom = '0';
  1451. linkMoveBtnGroup.style.gap = '8px';
  1452. linkMoveBtnGroup.style.flexShrink = '0';
  1453. ​
  1454. if (linkIndex > 0) {
  1455. var linkMoveUpBtn = document.createElement('button');
  1456. linkMoveUpBtn.type = 'button';
  1457. linkMoveUpBtn.className = 'dash-btn-small';
  1458. linkMoveUpBtn.setAttribute('aria-label', 'Move link up');
  1459. linkMoveUpBtn.innerHTML = '<svg class="icon icon-sm" aria-hidden="true"><use href="#icon-arrow-up"></use></svg>';
  1460. linkMoveUpBtn.addEventListener('click', function() {
  1461. var tmp = column.links[linkIndex - 1];
  1462. column.links[linkIndex - 1] = column.links[linkIndex];
  1463. column.links[linkIndex] = tmp;
  1464. renderColumns();
  1465. });
  1466. linkMoveBtnGroup.appendChild(linkMoveUpBtn);
  1467. }
  1468. ​
  1469. if (linkIndex < column.links.length - 1) {
  1470. var linkMoveDownBtn = document.createElement('button');
  1471. linkMoveDownBtn.type = 'button';
  1472. linkMoveDownBtn.className = 'dash-btn-small';
  1473. linkMoveDownBtn.setAttribute('aria-label', 'Move link down');
  1474. linkMoveDownBtn.innerHTML = '<svg class="icon icon-sm" aria-hidden="true"><use href="#icon-arrow-down"></use></svg>';
  1475. linkMoveDownBtn.addEventListener('click', function() {
  1476. var tmp = column.links[linkIndex + 1];
  1477. column.links[linkIndex + 1] = column.links[linkIndex];
  1478. column.links[linkIndex] = tmp;
  1479. renderColumns();
  1480. });
  1481. linkMoveBtnGroup.appendChild(linkMoveDownBtn);
  1482. }
  1483. ​
  1484. var removeBtn = document.createElement('button');
  1485. removeBtn.type = 'button';
  1486. removeBtn.className = 'dash-btn-small dash-btn-danger';
  1487. removeBtn.textContent = '×';
  1488. removeBtn.addEventListener('click', function() {
  1489. column.links.splice(linkIndex, 1);
  1490. renderColumns();
  1491. });
  1492. ​
  1493. row.appendChild(labelInput);
  1494. row.appendChild(urlInput);
  1495. row.appendChild(linkMoveBtnGroup);
  1496. row.appendChild(removeBtn);
  1497. box.appendChild(row);
  1498. });
  1499. ​
  1500. var addLinkBtn = document.createElement('button');
  1501. addLinkBtn.type = 'button';
  1502. addLinkBtn.className = 'dash-btn-small';
  1503. addLinkBtn.textContent = 'Add link';
  1504. addLinkBtn.addEventListener('click', function() {
  1505. column.links = column.links || [];
  1506. column.links.push({
  1507. label: '',
  1508. url: ''
  1509. });
  1510. renderColumns();
  1511. });
  1512. box.appendChild(addLinkBtn);
  1513. ​
  1514. var removeColumnBtn = document.createElement('button');
  1515. removeColumnBtn.type = 'button';
  1516. removeColumnBtn.className = 'dash-btn-small dash-btn-danger';
  1517. removeColumnBtn.textContent = 'Remove column';
  1518. removeColumnBtn.style.marginLeft = '8px';
  1519. removeColumnBtn.addEventListener('click', function() {
  1520. initialColumns.splice(colIndex, 1);
  1521. renderColumns();
  1522. });
  1523. box.appendChild(removeColumnBtn);
  1524. ​
  1525. columnsRoot.appendChild(box);
  1526. });
  1527. }
  1528. ​
  1529. function renderSocial() {
  1530. socialRoot.innerHTML = '';
  1531. initialSocial.forEach(function(item, index) {
  1532. var row = el('div', 'be-list-row');
  1533. ​
  1534. var iconLabel = el('span', 'be-block-type', item.key === 'custom' ? 'CUSTOM' : item.key.toUpperCase());
  1535. iconLabel.style.width = '90px';
  1536. iconLabel.style.flexShrink = '0';
  1537. ​
  1538. if (item.key === 'custom') {
  1539. var nameInput = document.createElement('input');
  1540. nameInput.type = 'text';
  1541. nameInput.placeholder = 'Label';
  1542. nameInput.value = item.label || '';
  1543. nameInput.style.flex = '1';
  1544. nameInput.addEventListener('input', function() {
  1545. item.label = this.value;
  1546. });
  1547. row.appendChild(iconLabel);
  1548. row.appendChild(nameInput);
  1549. } else {
  1550. row.appendChild(iconLabel);
  1551. }
  1552. ​
  1553. var urlInput = document.createElement('input');
  1554. urlInput.type = 'text';
  1555. urlInput.placeholder = 'https://...';
  1556. urlInput.value = item.url || '';
  1557. urlInput.style.flex = '1';
  1558. urlInput.addEventListener('input', function() {
  1559. item.url = this.value;
  1560. });
  1561. row.appendChild(urlInput);
  1562. ​
  1563. var removeBtn = document.createElement('button');
  1564. removeBtn.type = 'button';
  1565. removeBtn.className = 'dash-btn-small dash-btn-danger';
  1566. removeBtn.textContent = '×';
  1567. removeBtn.addEventListener('click', function() {
  1568. initialSocial.splice(index, 1);
  1569. renderSocial();
  1570. });
  1571. row.appendChild(removeBtn);
  1572. ​
  1573. socialRoot.appendChild(row);
  1574. ​
  1575. if (item.key === 'custom') {
  1576. var svgLabel = el('label', null, 'SVG code');
  1577. svgLabel.style.margin = '2px 0 4px';
  1578. var svgTextarea = document.createElement('textarea');
  1579. svgTextarea.rows = 2;
  1580. svgTextarea.className = 'be-code';
  1581. svgTextarea.style.minHeight = '50px';
  1582. svgTextarea.value = item.svg || '';
  1583. svgTextarea.placeholder = '<svg viewBox="0 0 24 24">...</svg>';
  1584. svgTextarea.addEventListener('input', function() {
  1585. item.svg = this.value;
  1586. });
  1587. socialRoot.appendChild(svgLabel);
  1588. socialRoot.appendChild(svgTextarea);
  1589. }
  1590. });
  1591. }
  1592. ​
  1593. document.getElementById('footer-add-column').addEventListener('click', function() {
  1594. if (initialColumns.length >= <?= FooterLinks::MAX_COLUMNS ?>) {
  1595. alert('The footer can have up to six columns.');
  1596. return;
  1597. }
  1598. initialColumns.push({
  1599. title: '',
  1600. links: [{
  1601. label: '',
  1602. url: ''
  1603. }]
  1604. });
  1605. renderColumns();
  1606. });
  1607. ​
  1608. document.getElementById('footer-add-social-builtin').addEventListener('click', function() {
  1609. var select = document.getElementById('footer-add-social-select');
  1610. initialSocial.push({
  1611. key: select.value,
  1612. label: select.options[select.selectedIndex].text,
  1613. url: ''
  1614. });
  1615. renderSocial();
  1616. });
  1617. ​
  1618. document.getElementById('footer-add-social-custom').addEventListener('click', function() {
  1619. initialSocial.push({
  1620. key: 'custom',
  1621. label: '',
  1622. url: '',
  1623. svg: ''
  1624. });
  1625. renderSocial();
  1626. });
  1627. ​
  1628. document.getElementById('footer-form').addEventListener('submit', function() {
  1629. document.getElementById('footer_columns_json_input').value = JSON.stringify(initialColumns);
  1630. document.getElementById('footer_social_json_input').value = JSON.stringify(initialSocial);
  1631. });
  1632. ​
  1633. renderColumns();
  1634. renderSocial();
  1635. })();
  1636. </script>
  1637. <?php endif; ?>
  1638. ​
  1639. <?php if ($activeTab === 'apps'): ?>
  1640. <form method="post" class="settings-section">
  1641. <?= Csrf::field() ?>
  1642. <input type="hidden" name="action" value="save_settings">
  1643. <input type="hidden" name="settings_tab" value="apps">
  1644. ​
  1645. <h2 class="dash-subtitle" style="margin-top:0;"><?= Icons::icon('smartphone', 'icon icon-sm') ?>Progressive Web App</h2>
  1646. <label><input type="checkbox" name="pwa_enabled" <?= $settings['pwa_enabled'] === '1' ? 'checked' : '' ?>> Let visitors install the site like an app</label>
  1647. <p style="font-size:12.5px;color:var(--muted);margin:-6px 0 16px;">Visitors can add the site to their phone’s home screen and open it like an app.</p>
  1648. ​
  1649. <label>App name (leave empty to use the site name)</label>
  1650. <input type="text" name="pwa_name" value="<?= htmlspecialchars($settings['pwa_name']) ?>" placeholder="<?= htmlspecialchars($settings['site_name']) ?>">
  1651. ​
  1652. <label>Short name (shown under the home screen icon, leave empty to use the app name)</label>
  1653. <input type="text" name="pwa_short_name" value="<?= htmlspecialchars($settings['pwa_short_name']) ?>" maxlength="12" placeholder="<?= htmlspecialchars($settings['site_name']) ?>">
  1654. ​
  1655. <label>Description (leave empty to use the site description)</label>
  1656. <textarea name="pwa_description" rows="2" placeholder="<?= htmlspecialchars($settings['site_description']) ?>"><?= htmlspecialchars($settings['pwa_description']) ?></textarea>
  1657. ​
  1658. <label>Icon (leave empty to use the site logo)</label>
  1659. <input type="text" name="pwa_icon_url" data-image-upload value="<?= htmlspecialchars($settings['pwa_icon_url']) ?>" placeholder="media/img/pwa-icon.png">
  1660. <p style="font-size:12px;color:var(--muted);margin-top:6px;">A square image of at least 512×512 pixels looks best.</p>
  1661. ​
  1662. <div class="settings-grid">
  1663. <div>
  1664. <label>Theme color</label>
  1665. <input type="color" name="pwa_theme_color" value="<?= htmlspecialchars($settings['pwa_theme_color']) ?>">
  1666. </div>
  1667. <div>
  1668. <label>Background color</label>
  1669. <input type="color" name="pwa_background_color" value="<?= htmlspecialchars($settings['pwa_background_color']) ?>">
  1670. </div>
  1671. </div>
  1672. ​
  1673. <h2 class="dash-subtitle"><?= Icons::icon('link', 'icon icon-sm') ?>Mobile app links</h2>
  1674. <label><input type="checkbox" name="app_banner_enabled" <?= $settings['app_banner_enabled'] === '1' ? 'checked' : '' ?>> Show phone visitors a banner with a link to your app</label>
  1675. <p style="font-size:12.5px;color:var(--muted);margin:-6px 0 16px;">It links Android phones to Google Play and iPhones to the App Store. It’s hidden on computers and when the store link is missing.</p>
  1676. ​
  1677. <label>Google Play link (Android)</label>
  1678. <input type="text" name="app_android_url" value="<?= htmlspecialchars($settings['app_android_url']) ?>" placeholder="https://play.google.com/store/apps/details?id=...">
  1679. ​
  1680. <label>App Store link (iPhone)</label>
  1681. <input type="text" name="app_ios_url" value="<?= htmlspecialchars($settings['app_ios_url']) ?>" placeholder="https://apps.apple.com/app/id...">
  1682. ​
  1683. <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:20px;"><?= Icons::icon('check', 'icon icon-sm') ?>Save</button>
  1684. </form>
  1685. <?php endif; ?>
  1686. ​
  1687. <?php if ($activeTab === 'ai'): ?>
  1688. <?php
  1689. $aiSettings = Ai::settings();
  1690. $aiRoleLabels = [];
  1691. foreach (Ai::roleKeys() as $aiRoleKey) {
  1692. $aiRoleLabels[$aiRoleKey] = Auth::roleLabel($aiRoleKey);
  1693. }
  1694. ?>
  1695. <form method="post" class="settings-section" id="ai-settings-form">
  1696. <?= Csrf::field() ?>
  1697. <input type="hidden" name="action" value="save_settings">
  1698. <input type="hidden" name="settings_tab" value="ai">
  1699. ​
  1700. <p style="font-size:12.5px;color:var(--muted);margin:-4px 0 20px;">An AI assistant in the article and page editors. It’s off until you turn it on. When someone uses it, the text they’re editing is sent to the AI service you pick below. It never changes anything by itself. You see each suggestion first, decide whether to apply it, and can undo it afterwards.</p>
  1701. ​
  1702. <label><input type="checkbox" name="ai_enabled" <?= $aiSettings['enabled'] ? 'checked' : '' ?>> Turn on the AI assistant</label>
  1703. ​
  1704. <label>AI service</label>
  1705. <select name="ai_provider" id="ai-provider-select">
  1706. <?php foreach (Ai::PROVIDERS as $aiProviderKey => $aiProviderLabel): ?>
  1707. <option value="<?= htmlspecialchars($aiProviderKey) ?>" <?= $aiSettings['provider'] === $aiProviderKey ? 'selected' : '' ?>><?= htmlspecialchars($aiProviderLabel) ?></option>
  1708. <?php endforeach; ?>
  1709. </select>
  1710. ​
  1711. <?php foreach (Ai::PROVIDERS as $aiProviderKey => $aiProviderLabel): ?>
  1712. <?php
  1713. $aiKeyState = Ai::keyState($aiProviderKey);
  1714. $aiCurrentModel = $aiSettings['models'][$aiProviderKey];
  1715. $aiIsPreset = isset(Ai::MODELS[$aiProviderKey][$aiCurrentModel]);
  1716. ?>
  1717. <div class="sidebar-box ai-provider-panel" data-provider="<?= htmlspecialchars($aiProviderKey) ?>" style="margin-top:16px;">
  1718. <h2 class="dash-subtitle" style="margin-top:0;"><?= Icons::icon('sparkles', 'icon icon-sm') ?><?= htmlspecialchars($aiProviderLabel) ?></h2>
  1719. ​
  1720. <label>Model</label>
  1721. <select name="ai_model_<?= htmlspecialchars($aiProviderKey) ?>" class="ai-model-select">
  1722. <?php foreach (Ai::MODELS[$aiProviderKey] as $aiModelId => $aiModelLabel): ?>
  1723. <option value="<?= htmlspecialchars($aiModelId) ?>" <?= $aiCurrentModel === $aiModelId ? 'selected' : '' ?>><?= htmlspecialchars($aiModelLabel) ?> (<?= htmlspecialchars($aiModelId) ?>)</option>
  1724. <?php endforeach; ?>
  1725. <option value="custom" <?= $aiIsPreset ? '' : 'selected' ?>>Another model…</option>
  1726. </select>
  1727. <input type="text" name="ai_model_<?= htmlspecialchars($aiProviderKey) ?>_custom" class="ai-model-custom" value="<?= $aiIsPreset ? '' : htmlspecialchars($aiCurrentModel) ?>" placeholder="model-id" maxlength="80" style="margin-top:8px;<?= $aiIsPreset ? 'display:none;' : '' ?>">
  1728. ​
  1729. <label>API key</label>
  1730. <input type="password" name="ai_key_<?= htmlspecialchars($aiProviderKey) ?>" autocomplete="new-password" spellcheck="false" placeholder="<?= $aiKeyState['hint'] !== null ? 'Saved key ending in ' . htmlspecialchars($aiKeyState['hint']) . ', leave empty to keep it' : 'Paste your API key' ?>">
  1731. <?php if ($aiKeyState['environment']): ?>
  1732. <p style="font-size:12px;color:var(--muted);margin:6px 0 0;">There’s also a key in the server’s .env file, and that one is used first (AI_KEY_<?= strtoupper(htmlspecialchars($aiProviderKey)) ?>).</p>
  1733. <?php endif; ?>
  1734. <?php if ($aiKeyState['hint'] !== null): ?>
  1735. <label><input type="checkbox" name="ai_key_remove_<?= htmlspecialchars($aiProviderKey) ?>"> Remove the saved key</label>
  1736. <?php endif; ?>
  1737. <p style="font-size:12px;color:var(--muted);margin:6px 0 0;">The key is stored safely and won’t be shown again.</p>
  1738. </div>
  1739. <?php endforeach; ?>
  1740. ​
  1741. <h2 class="dash-subtitle"><?= Icons::icon('toggle', 'icon icon-sm') ?>What it can do</h2>
  1742. <p style="font-size:12.5px;color:var(--muted);margin:-6px 0 12px;">The assistant only does what’s ticked here, even if someone asks for more.</p>
  1743. <?php foreach (Ai::FEATURES as $aiFeatureKey => [$aiFeatureLabel, $aiFeatureHelp]): ?>
  1744. <label><input type="checkbox" name="ai_features[]" value="<?= htmlspecialchars($aiFeatureKey) ?>" <?= in_array($aiFeatureKey, $aiSettings['features'], true) ? 'checked' : '' ?>> <span><strong><?= htmlspecialchars($aiFeatureLabel) ?></strong>: <?= htmlspecialchars($aiFeatureHelp) ?></span></label>
  1745. <?php endforeach; ?>
  1746. ​
  1747. <h2 class="dash-subtitle"><?= Icons::icon('users', 'icon icon-sm') ?>Who can use it</h2>
  1748. <p style="font-size:12.5px;color:var(--muted);margin:-6px 0 12px;">The Super Admin can always use it. Anyone can hide it for themselves in Interface.</p>
  1749. <?php foreach ($aiRoleLabels as $aiRoleKey => $aiRoleLabel): ?>
  1750. <label><input type="checkbox" name="ai_roles[]" value="<?= htmlspecialchars($aiRoleKey) ?>" <?= in_array($aiRoleKey, $aiSettings['roles'], true) ? 'checked' : '' ?>> <?= htmlspecialchars($aiRoleLabel) ?></label>
  1751. <?php endforeach; ?>
  1752. ​
  1753. <?php $readAloudAi = ReadAloud::settings(); ?>
  1754. <h2 class="dash-subtitle"><?= Icons::icon('headphones', 'icon icon-sm') ?>Voice for read aloud</h2>
  1755. <p style="font-size:12.5px;color:var(--muted);margin:-6px 0 12px;">Creates the audio when you publish an article marked "Read aloud" (read aloud must be on in Layout). The title and text are sent to the service you pick. The audio is only made again when the text or the voice changes.</p>
  1756. ​
  1757. <label>AI service</label>
  1758. <select name="read_aloud_provider" id="read-aloud-provider-select">
  1759. <?php foreach (ReadAloud::PROVIDERS as $readAloudProviderKey => $readAloudProviderLabel): ?>
  1760. <option value="<?= htmlspecialchars($readAloudProviderKey) ?>" <?= $readAloudAi['provider'] === $readAloudProviderKey ? 'selected' : '' ?>><?= htmlspecialchars($readAloudProviderLabel) ?></option>
  1761. <?php endforeach; ?>
  1762. </select>
  1763. ​
  1764. <?php foreach (ReadAloud::PROVIDERS as $readAloudProviderKey => $readAloudProviderLabel): ?>
  1765. <?php $readAloudKeyState = ReadAloud::keyState($readAloudProviderKey); ?>
  1766. <div class="sidebar-box read-aloud-provider-panel" data-provider="<?= htmlspecialchars($readAloudProviderKey) ?>" style="margin-top:16px;">
  1767. <h2 class="dash-subtitle" style="margin-top:0;"><?= Icons::icon('headphones', 'icon icon-sm') ?><?= htmlspecialchars($readAloudProviderLabel) ?></h2>
  1768. ​
  1769. <label>Model</label>
  1770. <select name="read_aloud_<?= htmlspecialchars($readAloudProviderKey) ?>_model">
  1771. <?php foreach ($readAloudProviderKey === 'openai' ? ReadAloud::OPENAI_MODELS : ReadAloud::ELEVENLABS_MODELS as $readAloudModelId => $readAloudModelLabel): ?>
  1772. <option value="<?= htmlspecialchars($readAloudModelId) ?>" <?= $readAloudAi[$readAloudProviderKey . '_model'] === $readAloudModelId ? 'selected' : '' ?>><?= htmlspecialchars($readAloudModelLabel) ?> (<?= htmlspecialchars($readAloudModelId) ?>)</option>
  1773. <?php endforeach; ?>
  1774. </select>
  1775. ​
  1776. <?php if ($readAloudProviderKey === 'openai'): ?>
  1777. <label>Voice</label>
  1778. <select name="read_aloud_openai_voice">
  1779. <?php foreach (ReadAloud::OPENAI_VOICES as $readAloudVoice): ?>
  1780. <option value="<?= htmlspecialchars($readAloudVoice) ?>" <?= $readAloudAi['openai_voice'] === $readAloudVoice ? 'selected' : '' ?>><?= htmlspecialchars(ucfirst($readAloudVoice)) ?></option>
  1781. <?php endforeach; ?>
  1782. </select>
  1783. <?php else: ?>
  1784. <label>Voice ID</label>
  1785. <input type="text" name="read_aloud_elevenlabs_voice" value="<?= htmlspecialchars($readAloudAi['elevenlabs_voice']) ?>" maxlength="40" spellcheck="false" placeholder="Voice ID from ElevenLabs">
  1786. <p style="font-size:12px;color:var(--muted);margin:6px 0 0;">Copy it from your ElevenLabs voice library. If you leave it empty, the "George" voice is used.</p>
  1787. <?php endif; ?>
  1788. ​
  1789. <label>API key</label>
  1790. <input type="password" name="read_aloud_key_<?= htmlspecialchars($readAloudProviderKey) ?>" autocomplete="new-password" spellcheck="false" placeholder="<?= $readAloudKeyState['hint'] !== null ? 'Saved key ending in ' . htmlspecialchars($readAloudKeyState['hint']) . ', leave empty to keep it' : 'Paste your API key' ?>">
  1791. <?php if ($readAloudKeyState['shared']): ?>
  1792. <p style="font-size:12px;color:var(--muted);margin:6px 0 0;">No key saved here, so the OpenAI key from the AI assistant is used. Paste a key if you want to use a different one.</p>
  1793. <?php endif; ?>
  1794. <?php if ($readAloudKeyState['environment']): ?>
  1795. <p style="font-size:12px;color:var(--muted);margin:6px 0 0;">There’s also a key in the server’s .env file, and that one is used first (AI_KEY_TTS_<?= strtoupper(htmlspecialchars($readAloudProviderKey)) ?>).</p>
  1796. <?php endif; ?>
  1797. <?php if ($readAloudKeyState['hint'] !== null): ?>
  1798. <label><input type="checkbox" name="read_aloud_key_remove_<?= htmlspecialchars($readAloudProviderKey) ?>"> Remove the saved key</label>
  1799. <?php endif; ?>
  1800. <p style="font-size:12px;color:var(--muted);margin:6px 0 0;">The key is stored safely and won’t be shown again.</p>
  1801. </div>
  1802. <?php endforeach; ?>
  1803. ​
  1804. <h2 class="dash-subtitle"><?= Icons::icon('shield', 'icon icon-sm') ?>Limits</h2>
  1805. <div class="settings-grid">
  1806. <div>
  1807. <label>Requests per person per hour</label>
  1808. <input type="number" name="ai_rate_limit" min="1" max="1000" value="<?= (int) $aiSettings['rate_limit'] ?>">
  1809. </div>
  1810. <div>
  1811. <label>Maximum answer length (tokens)</label>
  1812. <input type="number" name="ai_max_tokens" min="256" max="32000" value="<?= (int) $aiSettings['max_tokens'] ?>">
  1813. </div>
  1814. </div>
  1815. ​
  1816. <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:20px;"><?= Icons::icon('check', 'icon icon-sm') ?>Save</button>
  1817. </form>
  1818. ​
  1819. <script>
  1820. (function() {
  1821. var select = document.getElementById('read-aloud-provider-select');
  1822. var panels = document.querySelectorAll('.read-aloud-provider-panel');
  1823. if (!select) { return; }
  1824. ​
  1825. function showPanel() {
  1826. panels.forEach(function(panel) {
  1827. panel.style.display = panel.dataset.provider === select.value ? '' : 'none';
  1828. });
  1829. }
  1830. ​
  1831. select.addEventListener('change', showPanel);
  1832. showPanel();
  1833. })();
  1834. </script>
  1835. ​
  1836. <script>
  1837. (function() {
  1838. var providerSelect = document.getElementById('ai-provider-select');
  1839. var panels = document.querySelectorAll('.ai-provider-panel');
  1840. ​
  1841. function showProvider() {
  1842. panels.forEach(function(panel) {
  1843. panel.style.display = panel.dataset.provider === providerSelect.value ? '' : 'none';
  1844. });
  1845. }
  1846. ​
  1847. providerSelect.addEventListener('change', showProvider);
  1848. showProvider();
  1849. ​
  1850. document.querySelectorAll('.ai-model-select').forEach(function(select) {
  1851. var custom = select.parentNode.querySelector('.ai-model-custom');
  1852. ​
  1853. function toggleCustom() {
  1854. custom.style.display = select.value === 'custom' ? '' : 'none';
  1855. }
  1856. ​
  1857. select.addEventListener('change', toggleCustom);
  1858. toggleCustom();
  1859. });
  1860. })();
  1861. </script>
  1862. <?php endif; ?>
  1863. ​
  1864. <?php if ($activeTab === 'language'): ?>
  1865. <form method="post" class="settings-section">
  1866. <?= Csrf::field() ?>
  1867. <input type="hidden" name="action" value="save_language">
  1868. <?php foreach ($languageStrings as $key => $value): ?>
  1869. <div class="lang-row">
  1870. <div class="lang-row-key"><?= htmlspecialchars($key) ?></div>
  1871. <input type="text" name="lang_<?= htmlspecialchars($key) ?>" value="<?= htmlspecialchars($value) ?>">
  1872. </div>
  1873. <?php endforeach; ?>
  1874. <button type="submit" class="dash-btn dash-btn-primary" style="margin-top:16px;"><?= Icons::icon('check', 'icon icon-sm') ?>Save texts</button>
  1875. </form>
  1876. <?php endif; ?>
  1877. ​
  1878. <?php require __DIR__ . '/includes/dash-footer.php'; ?>