WebOrbiton
v1.0.0.9

Publisium

339 lines · 18.2 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/activity-log.php';
  10. require_once __DIR__ . '/includes/site-front.php';
  11. ​
  12. Auth::boot();
  13. Auth::requireRoleAtLeast(Auth::ROLE_EDITOR_IN_CHIEF);
  14. ​
  15. $currentUser = Auth::user();
  16. $currentRole = Auth::role();
  17. $db = Database::site();
  18. ​
  19. $flashMessage = null;
  20. $flashType = 'success';
  21. ​
  22. if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  23. if (!Csrf::verify($_POST['csrf_token'] ?? null)) {
  24. $flashMessage = 'Your session expired. Please try again.';
  25. $flashType = 'error';
  26. } else {
  27. require __DIR__ . '/dashboard-actions/manage-ads.php';
  28. ActivityLog::record('ads.' . (string) ($_POST['action'] ?? ''));
  29. $flashMessage = 'Saved.';
  30. }
  31. }
  32. ​
  33. $editId = (int) ($_GET['edit'] ?? 0) ?: null;
  34. $editingAd = null;
  35. if ($editId !== null) {
  36. $statement = $db->prepare('SELECT * FROM ads WHERE id = :id LIMIT 1');
  37. $statement->execute(['id' => $editId]);
  38. $editingAd = $statement->fetch() ?: null;
  39. }
  40. ​
  41. $ads = $db->query('SELECT * FROM ads ORDER BY created_at DESC')->fetchAll();
  42. $exclusionArticles = $db->query('SELECT id, title, status, published_at FROM articles WHERE deleted_at IS NULL ORDER BY COALESCE(published_at, created_at) DESC, id DESC')->fetchAll();
  43. $exclusionPages = $db->query('SELECT id, title, slug FROM pages ORDER BY title ASC')->fetchAll();
  44. $editingExclusions = SiteFront::adExclusions($editingAd ?? []);
  45. ​
  46. $adAudienceSummary = static function (array $ad): string {
  47. $parts = [SiteFront::AD_AUDIENCES[$ad['audience'] ?? 'all'] ?? SiteFront::AD_AUDIENCES['all']];
  48. if ((int) ($ad['hide_for_subscribers'] ?? 0) === 1 && ($ad['audience'] ?? 'all') !== 'paid') {
  49. $parts[] = 'not subscribers';
  50. }
  51. $consent = (string) ($ad['consent_category'] ?? 'none');
  52. if ($consent !== 'none') {
  53. $parts[] = 'after ' . (['analytics' => 'analytics', 'marketing' => 'marketing', 'both' => 'analytics and marketing'][$consent] ?? 'marketing') . ' consent';
  54. }
  55. $excluded = SiteFront::adExclusions($ad);
  56. $excludedCount = count($excluded['articles']) + count($excluded['pages']) + ($excluded['home'] ? 1 : 0);
  57. if ($excludedCount > 0) {
  58. $parts[] = $excludedCount . ' excluded';
  59. }
  60. ​
  61. return implode(', ', $parts);
  62. };
  63. ​
  64. $dashActivePage = 'ads';
  65. $dashPageTitle = 'Ads';
  66. ​
  67. require __DIR__ . '/includes/dash-header.php';
  68. ​
  69. ?>
  70. ​
  71. <?php if ($flashMessage !== null): ?>
  72. <div class="dash-flash dash-flash-<?= htmlspecialchars($flashType) ?>"><?= Icons::icon($flashType === 'error' ? 'x' : 'check', 'icon icon-sm') ?><?= htmlspecialchars($flashMessage) ?></div>
  73. <?php endif; ?>
  74. ​
  75. <div class="dash-header-row">
  76. <h1 class="dash-title"><?= Icons::icon("ads", "icon icon-lg") ?>Ads</h1>
  77. <button type="button" class="dash-btn dash-btn-primary" id="ad-new-btn"><?= Icons::icon('plus', 'icon icon-sm') ?>New ad</button>
  78. </div>
  79. ​
  80. <table class="dash-table">
  81. <thead>
  82. <tr>
  83. <th><?= Icons::icon('heading', 'icon icon-sm') ?>Name</th>
  84. <th><?= Icons::icon('ads', 'icon icon-sm') ?>Type</th>
  85. <th><?= Icons::icon('flag', 'icon icon-sm') ?>Where</th>
  86. <th><?= Icons::icon('eye', 'icon icon-sm') ?>Active</th>
  87. <th><?= Icons::icon('users', 'icon icon-sm') ?>Who sees it</th>
  88. <th></th>
  89. </tr>
  90. </thead>
  91. <tbody>
  92. <?php foreach ($ads as $ad): ?>
  93. <tr>
  94. <td><?= htmlspecialchars($ad['name']) ?></td>
  95. <td><?= htmlspecialchars(['static_text' => 'Text', 'static_image' => 'Image', 'script' => 'Code'][$ad['type']] ?? $ad['type']) ?></td>
  96. <td><?= htmlspecialchars(SiteFront::AD_PLACEMENTS[$ad['placement']] ?? $ad['placement']) ?></td>
  97. <td><?= $ad['is_active'] ? 'Yes' : 'No' ?></td>
  98. <td><?= htmlspecialchars($adAudienceSummary($ad)) ?></td>
  99. <td class="dash-table-actions">
  100. <button type="button" class="dash-btn-small js-edit-ad"
  101. data-id="<?= (int) $ad['id'] ?>"
  102. data-name="<?= htmlspecialchars($ad['name'], ENT_QUOTES) ?>"
  103. data-type="<?= htmlspecialchars($ad['type'], ENT_QUOTES) ?>"
  104. data-placement="<?= htmlspecialchars($ad['placement'], ENT_QUOTES) ?>"
  105. data-content="<?= htmlspecialchars($ad['content'], ENT_QUOTES) ?>"
  106. data-link-url="<?= htmlspecialchars((string) $ad['link_url'], ENT_QUOTES) ?>"
  107. data-is-active="<?= (int) $ad['is_active'] ?>"
  108. data-hide-for-subscribers="<?= (int) ($ad['hide_for_subscribers'] ?? 0) ?>"
  109. data-audience="<?= htmlspecialchars((string) ($ad['audience'] ?? 'all'), ENT_QUOTES) ?>"
  110. data-consent="<?= htmlspecialchars((string) ($ad['consent_category'] ?? 'none'), ENT_QUOTES) ?>"
  111. data-excluded="<?= htmlspecialchars((string) json_encode(SiteFront::adExclusions($ad)), ENT_QUOTES) ?>"><?= Icons::icon('edit', 'icon icon-sm') ?>Edit</button>
  112. <form method="post" style="display:inline;" onsubmit="return confirm('Delete this ad? It disappears from the site right away.');">
  113. <?= Csrf::field() ?>
  114. <input type="hidden" name="action" value="delete_ad">
  115. <input type="hidden" name="ad_id" value="<?= (int) $ad['id'] ?>">
  116. <button type="submit" class="dash-btn-small dash-btn-danger"><?= Icons::icon('trash', 'icon icon-sm') ?>Delete</button>
  117. </form>
  118. </td>
  119. </tr>
  120. <?php endforeach; ?>
  121. <?php if (empty($ads)): ?>
  122. <tr>
  123. <td colspan="6">No ads yet. Click New ad to add one.</td>
  124. </tr>
  125. <?php endif; ?>
  126. </tbody>
  127. </table>
  128. ​
  129. <div id="ad-form-wrapper" class="sidebar-box" style="max-width:560px;margin-top:20px;<?= $editingAd ? '' : 'display:none;' ?>">
  130. <h2 class="dash-subtitle" style="margin-top:0;"><?= Icons::icon('megaphone', 'icon icon-sm') ?><span id="ad-form-title"><?= $editingAd ? 'Edit ad' : 'New ad' ?></span></h2>
  131. <form method="post" id="ad-form">
  132. <?= Csrf::field() ?>
  133. <input type="hidden" name="action" value="save_ad">
  134. <input type="hidden" name="ad_id" id="ad_id_input" value="<?= $editingAd ? (int) $editingAd['id'] : '' ?>">
  135. ​
  136. <label><?= Icons::icon('heading', 'icon icon-sm') ?>Name</label>
  137. <input type="text" name="name" id="ad_name_input" value="<?= htmlspecialchars($editingAd['name'] ?? '') ?>" required>
  138. ​
  139. <label><?= Icons::icon('ads', 'icon icon-sm') ?>Type</label>
  140. <select name="type" id="ad_type_select">
  141. <option value="static_text" <?= ($editingAd['type'] ?? '') === 'static_text' ? 'selected' : '' ?>>Text</option>
  142. <option value="static_image" <?= ($editingAd['type'] ?? '') === 'static_image' ? 'selected' : '' ?>>Image</option>
  143. <option value="script" <?= ($editingAd['type'] ?? '') === 'script' ? 'selected' : '' ?>>Code from an ad network (HTML or JavaScript)</option>
  144. </select>
  145. ​
  146. <label><?= Icons::icon('flag', 'icon icon-sm') ?>Where it shows up on the site</label>
  147. <select name="placement" id="ad_placement_input" required>
  148. <?php foreach (SiteFront::AD_PLACEMENTS as $placementKey => $placementLabel): ?>
  149. <option value="<?= htmlspecialchars($placementKey, ENT_QUOTES) ?>" <?= ($editingAd['placement'] ?? 'header') === $placementKey ? 'selected' : '' ?>><?= htmlspecialchars($placementLabel) ?></option>
  150. <?php endforeach; ?>
  151. </select>
  152. ​
  153. <label id="content_label"><?= Icons::icon('quote', 'icon icon-sm') ?>Content (the text, a link to the image, or the ad code)</label>
  154. <textarea name="content" id="ad_content_input" rows="6" class="be-code"><?= htmlspecialchars($editingAd['content'] ?? '') ?></textarea>
  155. ​
  156. <label><?= Icons::icon('external', 'icon icon-sm') ?>Link (optional, where people go when they click, not used for code ads)</label>
  157. <input type="text" name="link_url" id="ad_link_url_input" value="<?= htmlspecialchars($editingAd['link_url'] ?? '') ?>">
  158. ​
  159. <label><input type="checkbox" name="is_active" id="ad_is_active_input" <?= ($editingAd['is_active'] ?? 1) ? 'checked' : '' ?>> Active</label>
  160. ​
  161. <label><?= Icons::icon('users', 'icon icon-sm') ?>Who sees it</label>
  162. <select name="audience" id="ad_audience_input">
  163. <?php foreach (SiteFront::AD_AUDIENCES as $audienceKey => $audienceLabel): ?>
  164. <option value="<?= htmlspecialchars($audienceKey, ENT_QUOTES) ?>" <?= ($editingAd['audience'] ?? 'all') === $audienceKey ? 'selected' : '' ?>><?= htmlspecialchars($audienceLabel) ?></option>
  165. <?php endforeach; ?>
  166. </select>
  167. ​
  168. <label><?= Icons::icon('shield', 'icon icon-sm') ?>Cookie consent</label>
  169. <select name="consent_category" id="ad_consent_input">
  170. <?php foreach (SiteFront::AD_CONSENTS as $consentKey => $consentLabel): ?>
  171. <option value="<?= htmlspecialchars($consentKey, ENT_QUOTES) ?>" <?= ($editingAd['consent_category'] ?? 'none') === $consentKey ? 'selected' : '' ?>><?= htmlspecialchars($consentLabel) ?></option>
  172. <?php endforeach; ?>
  173. </select>
  174. <p style="font-size:12.5px;color:var(--muted);margin:-4px 0 12px;">Ad networks that use cookies usually need marketing consent. Until the visitor agrees, the ad space stays empty. This only applies when cookie consent is turned on in Analytics.</p>
  175. ​
  176. <label><input type="checkbox" name="hide_for_subscribers" id="ad_hide_for_subscribers_input" <?= ($editingAd['hide_for_subscribers'] ?? 1) ? 'checked' : '' ?>> Hide from paying subscribers</label>
  177. <p style="font-size:12.5px;color:var(--muted);margin:-4px 0 12px;">Readers who are logged in with an active subscription won’t see this ad.</p>
  178. ​
  179. <label><?= Icons::icon('x', 'icon icon-sm') ?>Don’t show it on <span id="ad_excluded_count" style="color:var(--muted);font-weight:400;"></span></label>
  180. <p style="font-size:12.5px;color:var(--muted);margin:0 0 8px;">Tick the home page, pages or articles where this ad should never appear. It still shows everywhere else.</p>
  181. <div class="ad-exclusions">
  182. <label class="ad-exclusion-item"><input type="checkbox" name="exclude_home" class="js-ad-exclusion" data-kind="home" <?= $editingExclusions['home'] ? 'checked' : '' ?>> Home page</label>
  183. <?php if (!empty($exclusionPages)): ?>
  184. <div class="ad-exclusion-heading">Pages</div>
  185. <div class="ad-exclusion-list">
  186. <?php foreach ($exclusionPages as $exclusionPage): ?>
  187. <label class="ad-exclusion-item"><input type="checkbox" name="excluded_pages[]" value="<?= (int) $exclusionPage['id'] ?>" class="js-ad-exclusion" data-kind="pages" <?= in_array((int) $exclusionPage['id'], $editingExclusions['pages'], true) ? 'checked' : '' ?>> <?= htmlspecialchars($exclusionPage['title']) ?> <span class="ad-exclusion-status">/<?= htmlspecialchars((string) $exclusionPage['slug']) ?></span></label>
  188. <?php endforeach; ?>
  189. </div>
  190. <?php endif; ?>
  191. <?php if (!empty($exclusionArticles)): ?>
  192. <div class="ad-exclusion-heading">Articles</div>
  193. <input type="search" id="ad_exclusion_filter" placeholder="Find an article" autocomplete="off">
  194. <div class="ad-exclusion-list" id="ad_exclusion_articles">
  195. <?php foreach ($exclusionArticles as $exclusionArticle): ?>
  196. <label class="ad-exclusion-item" data-title="<?= htmlspecialchars(mb_strtolower((string) $exclusionArticle['title']), ENT_QUOTES) ?>"><input type="checkbox" name="excluded_articles[]" value="<?= (int) $exclusionArticle['id'] ?>" class="js-ad-exclusion" data-kind="articles" <?= in_array((int) $exclusionArticle['id'], $editingExclusions['articles'], true) ? 'checked' : '' ?>> <?= htmlspecialchars($exclusionArticle['title']) ?><span class="ad-exclusion-status"><?= htmlspecialchars($exclusionArticle['status'] === 'published' && !empty($exclusionArticle['published_at']) ? date('Y-m-d', strtotime((string) $exclusionArticle['published_at'])) : dashStatusLabel((string) $exclusionArticle['status'])) ?></span></label>
  197. <?php endforeach; ?>
  198. </div>
  199. <?php endif; ?>
  200. </div>
  201. ​
  202. <div class="publish-actions">
  203. <button type="submit" class="dash-btn dash-btn-primary"><?= Icons::icon('check', 'icon icon-sm') ?>Save ad</button>
  204. <button type="button" class="dash-btn" id="ad-cancel-btn"><?= Icons::icon('x', 'icon icon-sm') ?>Cancel</button>
  205. </div>
  206. </form>
  207. </div>
  208. ​
  209. <script>
  210. (function() {
  211. var wrapper = document.getElementById('ad-form-wrapper');
  212. var formTitle = document.getElementById('ad-form-title');
  213. var form = document.getElementById('ad-form');
  214. var exclusionBoxes = form.querySelectorAll('.js-ad-exclusion');
  215. var exclusionFilter = document.getElementById('ad_exclusion_filter');
  216. var exclusionCount = document.getElementById('ad_excluded_count');
  217. ​
  218. function updateExclusionCount() {
  219. var count = 0;
  220. exclusionBoxes.forEach(function(box) {
  221. if (box.checked) {
  222. count++;
  223. }
  224. });
  225. exclusionCount.textContent = count > 0 ? '(' + count + ' selected)' : '';
  226. }
  227. ​
  228. function setExclusions(excluded) {
  229. exclusionBoxes.forEach(function(box) {
  230. var kind = box.dataset.kind;
  231. if (kind === 'home') {
  232. box.checked = !!excluded.home;
  233. } else {
  234. box.checked = (excluded[kind] || []).indexOf(parseInt(box.value, 10)) !== -1;
  235. }
  236. });
  237. updateExclusionCount();
  238. }
  239. ​
  240. function filterArticles() {
  241. if (!exclusionFilter) {
  242. return;
  243. }
  244. var needle = exclusionFilter.value.trim().toLowerCase();
  245. document.querySelectorAll('#ad_exclusion_articles .ad-exclusion-item').forEach(function(item) {
  246. item.style.display = needle === '' || item.dataset.title.indexOf(needle) !== -1 ? '' : 'none';
  247. });
  248. }
  249. ​
  250. var audienceInput = document.getElementById('ad_audience_input');
  251. var hideForSubscribersInput = document.getElementById('ad_hide_for_subscribers_input');
  252. ​
  253. // An ad meant only for subscribers can't also be hidden from them.
  254. function syncHideForSubscribers() {
  255. var onlySubscribers = audienceInput.value === 'paid';
  256. if (onlySubscribers) {
  257. hideForSubscribersInput.checked = false;
  258. }
  259. hideForSubscribersInput.disabled = onlySubscribers;
  260. }
  261. audienceInput.addEventListener('change', syncHideForSubscribers);
  262. syncHideForSubscribers();
  263. ​
  264. exclusionBoxes.forEach(function(box) {
  265. box.addEventListener('change', updateExclusionCount);
  266. });
  267. if (exclusionFilter) {
  268. exclusionFilter.addEventListener('input', filterArticles);
  269. exclusionFilter.addEventListener('keydown', function(event) {
  270. if (event.key === 'Enter') {
  271. event.preventDefault();
  272. }
  273. });
  274. }
  275. updateExclusionCount();
  276. ​
  277. function resetForm() {
  278. form.reset();
  279. document.getElementById('ad_id_input').value = '';
  280. document.getElementById('ad_placement_input').value = 'header';
  281. document.getElementById('ad_is_active_input').checked = true;
  282. document.getElementById('ad_hide_for_subscribers_input').checked = true;
  283. document.getElementById('ad_audience_input').value = 'all';
  284. document.getElementById('ad_consent_input').value = 'none';
  285. syncHideForSubscribers();
  286. setExclusions({});
  287. if (exclusionFilter) {
  288. exclusionFilter.value = '';
  289. filterArticles();
  290. }
  291. formTitle.textContent = 'New ad';
  292. }
  293. ​
  294. function showForm() {
  295. wrapper.style.display = '';
  296. wrapper.scrollIntoView({
  297. behavior: 'smooth',
  298. block: 'nearest'
  299. });
  300. }
  301. ​
  302. document.getElementById('ad-new-btn').addEventListener('click', function() {
  303. resetForm();
  304. showForm();
  305. });
  306. ​
  307. document.getElementById('ad-cancel-btn').addEventListener('click', function() {
  308. wrapper.style.display = 'none';
  309. resetForm();
  310. });
  311. ​
  312. document.querySelectorAll('.js-edit-ad').forEach(function(button) {
  313. button.addEventListener('click', function() {
  314. document.getElementById('ad_id_input').value = this.dataset.id;
  315. document.getElementById('ad_name_input').value = this.dataset.name;
  316. document.getElementById('ad_type_select').value = this.dataset.type;
  317. document.getElementById('ad_placement_input').value = this.dataset.placement;
  318. document.getElementById('ad_content_input').value = this.dataset.content;
  319. document.getElementById('ad_link_url_input').value = this.dataset.linkUrl;
  320. document.getElementById('ad_is_active_input').checked = this.dataset.isActive === '1';
  321. document.getElementById('ad_hide_for_subscribers_input').checked = this.dataset.hideForSubscribers === '1';
  322. document.getElementById('ad_audience_input').value = this.dataset.audience || 'all';
  323. document.getElementById('ad_consent_input').value = this.dataset.consent || 'none';
  324. syncHideForSubscribers();
  325. var excluded = {};
  326. try {
  327. excluded = JSON.parse(this.dataset.excluded || '{}') || {};
  328. } catch (error) {
  329. excluded = {};
  330. }
  331. setExclusions(excluded);
  332. formTitle.textContent = 'Edit ad';
  333. showForm();
  334. });
  335. });
  336. })();
  337. </script>
  338. ​
  339. <?php require __DIR__ . '/includes/dash-footer.php'; ?>