PHP Classes

File: templates/admin/ads.twig

Recommend this page to a friend!
  Packages of Matthew Asham   Binkterm PHP   templates/admin/ads.twig   Download  
File: templates/admin/ads.twig
Role: Auxiliary data
Content type: text/plain
Description: Auxiliary data
Class: Binkterm PHP
Bulletin board system based on the Web
Author: By
Last change:
Date: 5 days ago
Size: 36,098 bytes
 

Contents

Class file image Download
{% extends "base.twig" %} {% block title %}{{ t('ui.admin.ads.page_title') }}{% endblock %} {% block content %} <div class="container-fluid"> <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"> <h1 class="h2">{{ t('ui.admin.ads.heading') }}</h1> <a class="btn btn-outline-secondary" href="/admin/ad-campaigns">{{ t('ui.admin.ads.manage_campaigns') }}</a> </div> <div class="alert alert-info"> <i class="fas fa-info-circle"></i> {{ t('ui.admin.ads.library_info') }} </div> <div id="adsStatusAlert"></div> <div class="card shadow mb-4"> <div class="card-header"> <h6 class="mb-0">{{ t('ui.admin.ads.upload_new') }}</h6> </div> <div class="card-body"> <form id="adsUploadForm"> <div class="row g-3"> <div class="col-md-5"> <label class="form-label" for="adFile">{{ t('ui.admin.ads.ansi_file') }}</label> <input class="form-control" type="file" id="adFile" name="ad_file" accept=".ans,.rip,.six,.txt"> </div> <div class="col-md-4"> <label class="form-label" for="adContentCommand">{{ t('ui.admin.ads.content_command') }}</label> <select class="form-select font-monospace" id="adContentCommand" name="content_command"> <option value="">{{ t('ui.admin.ads.content_command_none') }}</option> </select> <div class="form-text">{{ t('ui.admin.ads.content_command_upload_help') }}</div> </div> <div class="col-md-3"> <label class="form-label" for="adContentCommandArgs">{{ t('ui.admin.ads.content_command_args') }}</label> <input class="form-control font-monospace" type="text" id="adContentCommandArgs" placeholder="--flag=value"> </div> <div id="adNewfilesHint" class="col-12 d-none"> <div class="alert alert-info py-2 px-3 mb-0 small"> <i class="fas fa-info-circle me-1"></i> Consider limiting which file areas are included using <code>--freq</code>, <code>--network</code>, or <code>--public</code> to control the scope of this report. </div> </div> <div id="adWeatherHint" class="col-12 d-none"> <div class="alert alert-{{ weather_configured ? 'info' : 'warning' }} py-2 px-3 mb-0 small"> <i class="fas fa-{{ weather_configured ? 'info-circle' : 'exclamation-triangle' }} me-1"></i> {% if weather_configured %} Weather report is configured and ready. {% else %} Weather report requires API configuration. <a href="/admin/weather-config">Configure weather settings</a> before using this command. {% endif %} </div> </div> <div class="col-md-3"> <label class="form-label" for="adTitle">{{ t('ui.common.title') }}</label> <input class="form-control" type="text" id="adTitle" name="title"> </div> <div class="col-md-3"> <label class="form-label" for="contentSlug">{{ t('ui.admin.ads.slug_optional') }}</label> <input class="form-control" type="text" id="contentSlug" name="slug" placeholder="retro-sale"> </div> <div class="col-md-4"> <label class="form-label" for="adTags">{{ t('ui.admin.ads.tags') }}</label> <input class="form-control" type="text" id="adTags" name="tags" placeholder="{{ t('ui.admin.ads.tags_placeholder') }}"> </div> <div class="col-md-2"> <label class="form-label" for="adWeight">{{ t('ui.admin.ads.dashboard_weight') }}</label> <input class="form-control" type="number" id="adWeight" name="dashboard_weight" min="1" value="1"> </div> <div class="col-md-2"> <label class="form-label" for="adPriority">{{ t('ui.admin.ads.dashboard_priority') }}</label> <input class="form-control" type="number" id="adPriority" name="dashboard_priority" value="0"> </div> <div class="col-md-4"> <label class="form-label" for="adDescription">{{ t('ui.common.description') }}</label> <input class="form-control" type="text" id="adDescription" name="description"> </div> <div class="col-12"> <label class="form-label" for="adClickUrl">{{ t('ui.admin.ads.click_url') }}</label> <input class="form-control" type="url" id="adClickUrl" name="click_url" placeholder="https://example.com"> <div class="form-text">{{ t('ui.admin.ads.click_url_help') }}</div> </div> <div class="col-12"> <div class="form-check form-check-inline"> <input class="form-check-input" type="checkbox" id="adIsActive" name="is_active" checked> <label class="form-check-label" for="adIsActive">{{ t('ui.common.enabled') }}</label> </div> <div class="form-check form-check-inline"> <input class="form-check-input" type="checkbox" id="adShowOnDashboard" name="show_on_dashboard"> <label class="form-check-label" for="adShowOnDashboard">{{ t('ui.admin.ads.show_on_dashboard') }}</label> </div> <div class="form-check form-check-inline"> <input class="form-check-input" type="checkbox" id="adAllowAutoPost" name="allow_auto_post"> <label class="form-check-label" for="adAllowAutoPost">{{ t('ui.admin.ads.allow_auto_post') }}</label> </div> </div> <div class="col-12 d-flex justify-content-end"> <button type="submit" class="btn btn-primary"> <i class="fas fa-upload"></i> {{ t('ui.admin.ads.upload') }} </button> </div> </div> </form> </div> </div> <div class="card shadow mb-4"> <div class="card-header d-flex justify-content-between align-items-center"> <h6 class="mb-0">{{ t('ui.admin.ads.library_list') }}</h6> <button class="btn btn-sm btn-outline-secondary" type="button" onclick="loadAds()"> {{ t('ui.common.refresh') }} </button> </div> <div class="card-body p-0"> <div class="table-responsive"> <table class="table table-striped mb-0"> <thead> <tr> <th>{{ t('ui.common.title') }}</th> <th>{{ t('ui.admin.ads.slug') }}</th> <th>{{ t('ui.admin.ads.tags') }}</th> <th>{{ t('ui.admin.ads.dashboard') }}</th> <th>{{ t('ui.admin.ads.auto_post') }}</th> <th>{{ t('ui.admin.ads.stats') }}</th> <th>{{ t('ui.admin.ads.updated') }}</th> <th class="text-end">{{ t('ui.admin.ads.actions') }}</th> </tr> </thead> <tbody id="adsTableBody"> <tr> <td colspan="8" class="text-center text-muted py-4">{{ t('ui.admin.ads.loading_ads') }}</td> </tr> </tbody> </table> </div> </div> </div> <div class="card shadow d-none" id="adEditorCard"> <div class="card-header d-flex justify-content-between align-items-center"> <h6 class="mb-0">{{ t('ui.admin.ads.edit_advertisement') }}</h6> <button class="btn btn-sm btn-outline-secondary" type="button" onclick="closeEditor()">{{ t('ui.common.cancel') }}</button> </div> <div class="card-body"> <form id="adEditorForm"> <input type="hidden" id="editAdId"> <div class="row g-3"> <div class="col-md-4"> <label class="form-label" for="editAdTitle">{{ t('ui.common.title') }}</label> <input class="form-control" type="text" id="editAdTitle"> </div> <div class="col-md-4"> <label class="form-label" for="editAdSlug">{{ t('ui.admin.ads.slug') }}</label> <input class="form-control" type="text" id="editAdSlug"> </div> <div class="col-md-4"> <label class="form-label" for="editAdLegacyFilename">{{ t('ui.admin.ads.legacy_filename') }}</label> <input class="form-control" type="text" id="editAdLegacyFilename"> </div> <div class="col-md-6"> <label class="form-label" for="editAdTags">{{ t('ui.admin.ads.tags') }}</label> <input class="form-control" type="text" id="editAdTags"> </div> <div class="col-md-3"> <label class="form-label" for="editAdWeight">{{ t('ui.admin.ads.dashboard_weight') }}</label> <input class="form-control" type="number" id="editAdWeight" min="1"> </div> <div class="col-md-3"> <label class="form-label" for="editAdPriority">{{ t('ui.admin.ads.dashboard_priority') }}</label> <input class="form-control" type="number" id="editAdPriority"> </div> <div class="col-12"> <label class="form-label" for="editAdDescription">{{ t('ui.common.description') }}</label> <input class="form-control" type="text" id="editAdDescription"> </div> <div class="col-12"> <label class="form-label" for="editAdClickUrl">{{ t('ui.admin.ads.click_url') }}</label> <input class="form-control" type="url" id="editAdClickUrl" placeholder="https://example.com"> <div class="form-text">{{ t('ui.admin.ads.click_url_help') }}</div> </div> <div class="col-md-6"> <label class="form-label" for="editAdContentCommand">{{ t('ui.admin.ads.content_command') }}</label> <select class="form-select font-monospace" id="editAdContentCommand"> <option value="">{{ t('ui.admin.ads.content_command_none') }}</option> </select> <div class="form-text">{{ t('ui.admin.ads.content_command_help') }}</div> </div> <div class="col-md-6"> <label class="form-label" for="editAdContentCommandArgs">{{ t('ui.admin.ads.content_command_args') }}</label> <input class="form-control font-monospace" type="text" id="editAdContentCommandArgs" placeholder="--flag=value"> </div> <div id="editAdNewfilesHint" class="col-12 d-none"> <div class="alert alert-info py-2 px-3 mb-0 small"> <i class="fas fa-info-circle me-1"></i> Consider limiting which file areas are included using <code>--freq</code>, <code>--network</code>, or <code>--public</code> to control the scope of this report. </div> </div> <div id="editAdWeatherHint" class="col-12 d-none"> <div class="alert alert-{{ weather_configured ? 'info' : 'warning' }} py-2 px-3 mb-0 small"> <i class="fas fa-{{ weather_configured ? 'info-circle' : 'exclamation-triangle' }} me-1"></i> {% if weather_configured %} Weather report is configured and ready. {% else %} Weather report requires API configuration. <a href="/admin/weather-config">Configure weather settings</a> before using this command. {% endif %} </div> </div> <div class="col-12"> <label class="form-label" for="editAdContent">{{ t('ui.admin.ads.ansi_content') }}</label> <div class="form-text mb-2">{{ t('ui.admin.ads.escape_helper_help') }}</div> <div id="editAdContentEditor"> <textarea class="form-control font-monospace" id="editAdContent" rows="14"></textarea> </div> </div> <div class="col-12"> <div class="form-check form-check-inline"> <input class="form-check-input" type="checkbox" id="editAdIsActive"> <label class="form-check-label" for="editAdIsActive">{{ t('ui.common.enabled') }}</label> </div> <div class="form-check form-check-inline"> <input class="form-check-input" type="checkbox" id="editAdShowOnDashboard"> <label class="form-check-label" for="editAdShowOnDashboard">{{ t('ui.admin.ads.show_on_dashboard') }}</label> </div> <div class="form-check form-check-inline"> <input class="form-check-input" type="checkbox" id="editAdAllowAutoPost"> <label class="form-check-label" for="editAdAllowAutoPost">{{ t('ui.admin.ads.allow_auto_post') }}</label> </div> </div> <div class="col-12 d-flex justify-content-end gap-2"> <button type="button" class="btn btn-outline-secondary" onclick="closeEditor()">{{ t('ui.common.cancel') }}</button> <button type="submit" class="btn btn-primary">{{ t('ui.common.save') }}</button> </div> </div> </form> </div> </div> </div> {% endblock %} {% block scripts %} <script src="/js/ansisys.js"></script> <script src="/js/sixel.js"></script> <script src="/js/pcboard.js"></script> <script src="/js/ansi-editor.js"></script> <script> let currentAds = []; let adAnsiEditor = null; function uiT(key, fallback, params = {}) { if (window.t) { return window.t(key, params, fallback); } return fallback; } document.addEventListener('DOMContentLoaded', function() { adAnsiEditor = window.AnsiEditor ? window.AnsiEditor.create(document.getElementById('editAdContentEditor'), { previewTitleResolver: function() { return document.getElementById('editAdTitle').value.trim() || uiT('ui.common.preview', 'Preview'); } }) : null; loadI18nNamespaces(['common', 'errors']).then(function() { loadAds(); loadContentCommands(); }); document.getElementById('adsUploadForm').addEventListener('submit', function(event) { event.preventDefault(); uploadAd(); }); document.getElementById('adFile').addEventListener('change', function() { const file = this.files && this.files[0]; if (!file) { return; } if (!document.getElementById('adTitle').value.trim()) { document.getElementById('adTitle').value = file.name.replace(/\.[^.]+$/, ''); } }); document.getElementById('adEditorForm').addEventListener('submit', function(event) { event.preventDefault(); saveAd(); }); window.setTimeout(detectHiddenUploadFields, 50); }); function detectHiddenUploadFields() { const slugInput = document.getElementById('contentSlug'); if (!slugInput) { return; } const style = window.getComputedStyle(slugInput); const hidden = style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0' || slugInput.hidden || slugInput.offsetParent === null; if (hidden) { showAdsAlert( uiT( 'ui.admin.ads.form_hidden_warning', 'A browser extension appears to be hiding parts of this form. If fields are missing, disable content blocking for this admin page.' ), 'warning' ); } } function updateCommandHints(selId, newfilesHintId, weatherHintId) { const sel = document.getElementById(selId); if (!sel) return; const val = sel.value; const newfilesHint = document.getElementById(newfilesHintId); const weatherHint = document.getElementById(weatherHintId); if (newfilesHint) newfilesHint.classList.toggle('d-none', !val.includes('report_newfiles')); if (weatherHint) weatherHint.classList.toggle('d-none', !val.includes('weather_report')); } // Keep the old name as an alias so the edit-modal open path can call it directly. function updateNewfilesHint(selId, hintId) { updateCommandHints(selId, hintId, selId === 'adContentCommand' ? 'adWeatherHint' : 'editAdWeatherHint'); } function loadContentCommands() { fetch('/admin/api/ads/content-commands') .then(r => r.json()) .then(data => { const cmds = Array.isArray(data.commands) ? data.commands : []; [ { selId: 'adContentCommand', newfilesHintId: 'adNewfilesHint', weatherHintId: 'adWeatherHint' }, { selId: 'editAdContentCommand', newfilesHintId: 'editAdNewfilesHint', weatherHintId: 'editAdWeatherHint' }, ].forEach(({ selId, newfilesHintId, weatherHintId }) => { const sel = document.getElementById(selId); if (!sel) return; const current = sel.value; while (sel.options.length > 1) sel.remove(1); cmds.forEach(cmd => { const opt = document.createElement('option'); opt.value = cmd.value; opt.textContent = cmd.label; sel.appendChild(opt); }); if (current) sel.value = current; updateCommandHints(selId, newfilesHintId, weatherHintId); sel.addEventListener('change', () => updateCommandHints(selId, newfilesHintId, weatherHintId)); }); }) .catch(() => {}); } function loadAds() { fetch('/admin/api/ads') .then(response => readJsonResponse(response)) .then(({ response, data }) => { if (!response.ok || data['error_code'] || ('error' in data)) { throw new Error(apiError(data, uiT('ui.admin.ads.load_failed_with_status', `Failed to load ads (${response.status})`, { status: response.status }))); } currentAds = Array.isArray(data.ads) ? data.ads : []; renderAdsTable(currentAds); }) .catch(error => { currentAds = []; renderAdsTable([]); showAdsAlert(error.message || uiT('ui.admin.ads.load_failed', 'Failed to load ads'), 'danger'); }); } function renderAdsTable(ads) { const tbody = document.getElementById('adsTableBody'); if (!ads.length) { tbody.innerHTML = ` <tr> <td colspan="8" class="text-center text-muted py-4">${uiT('ui.admin.ads.none_uploaded', 'No advertisements uploaded.')}</td> </tr> `; return; } tbody.innerHTML = ads.map(ad => { const title = ad.title || ad.name || ''; const updated = ad.updated_at ? new Date(ad.updated_at).toLocaleString() : '-'; const previewTarget = encodeURIComponent(ad.slug || ad.name || ad.id); const tags = Array.isArray(ad.tags) && ad.tags.length ? ad.tags.map(tag => `<span class="badge bg-secondary me-1">${escapeHtml(tag)}</span>`).join('') : '<span class="text-muted">-</span>'; const dashboard = ad.show_on_dashboard ? `<span class="badge bg-success">${uiT('ui.common.enabled', 'Enabled')}</span>` : `<span class="badge bg-secondary">${uiT('ui.common.disabled', 'Disabled')}</span>`; const autoPost = ad.allow_auto_post ? `<span class="badge bg-success">${uiT('ui.common.enabled', 'Enabled')}</span>` : `<span class="badge bg-secondary">${uiT('ui.common.disabled', 'Disabled')}</span>`; const impressions = ad.impression_count !== null && ad.impression_count !== undefined ? Number(ad.impression_count) : 0; const clicks = ad.click_count !== null && ad.click_count !== undefined ? Number(ad.click_count) : 0; const ctr = impressions > 0 ? ((clicks / impressions) * 100).toFixed(1) + '%' : '-'; const stats = `<span class="text-muted small">${impressions.toLocaleString()} ${uiT('ui.admin.ads.impressions', 'imp')} / ${clicks.toLocaleString()} ${uiT('ui.admin.ads.clicks', 'clicks')}</span><br><span class="small">CTR: ${ctr}</span>`; return ` <tr> <td> <div class="fw-semibold">${escapeHtml(title)}</div> <div class="small text-muted">${escapeHtml(ad.legacy_filename || '')}</div> ${ad.click_url ? `<div class="small"><a href="${escapeHtml(ad.click_url)}" target="_blank" rel="noopener" class="text-truncate d-inline-block" style="max-width:180px">${escapeHtml(ad.click_url)}</a></div>` : ''} </td> <td><code>${escapeHtml(ad.slug || '')}</code></td> <td>${tags}</td> <td>${dashboard}</td> <td>${autoPost}</td> <td>${stats}</td> <td>${escapeHtml(updated)}</td> <td class="text-end"> <button class="btn btn-sm btn-outline-primary me-2" type="button" onclick="previewAd(${Number(ad.id)})"> ${uiT('ui.admin.ads.view', 'View')} </button> <button class="btn btn-sm btn-outline-secondary me-2" type="button" onclick="openEditor(${Number(ad.id)})"> ${uiT('ui.common.edit', 'Edit')} </button> <button class="btn btn-sm btn-outline-danger" type="button" onclick="deleteAd(${Number(ad.id)}, '${escapeJs(title)}')"> ${uiT('ui.common.delete', 'Delete')} </button> </td> </tr> `; }).join(''); } function previewAd(id) { fetch(`/admin/api/ads/${id}/preview`) .then(response => readJsonResponse(response)) .then(({ response, data }) => { if (!response.ok || data['error_code'] || ('error' in data)) { throw new Error(apiError(data, uiT('ui.admin.ads.load_one_failed', `Failed to load advertisement (${response.status})`, { status: response.status }))); } const ad = data.ad || null; if (!ad) { throw new Error(uiT('ui.admin.ads.not_found', 'Advertisement not found')); } if (window.AnsiEditor) { window.AnsiEditor.openPreview({ title: ad.title || uiT('ui.admin.ads.view', 'View'), content: ad.content || '' }); } }) .catch(error => { showAdsAlert(error.message || uiT('ui.admin.ads.load_one_failed', 'Failed to load advertisement'), 'danger'); }); } function previewEditedAd() { if (!adAnsiEditor) { return; } adAnsiEditor.openPreview(); } function uploadAd() { const fileInput = document.getElementById('adFile'); const cmdScript = document.getElementById('adContentCommand').value.trim(); const cmdArgs = document.getElementById('adContentCommandArgs').value.trim(); const contentCommand = cmdScript ? (cmdArgs ? cmdScript + ' ' + cmdArgs : cmdScript) : ''; const hasFile = fileInput.files && fileInput.files.length > 0; if (!hasFile && contentCommand === '') { showAdsAlert(uiT('ui.admin.ads.select_file_to_upload', 'Select an advertisement file to upload, or enter a content command.'), 'warning'); return; } const formData = new FormData(); if (hasFile) { formData.append('ad_file', fileInput.files[0]); } if (contentCommand !== '') { formData.append('content_command', contentCommand); } formData.append('title', document.getElementById('adTitle').value.trim()); formData.append('slug', document.getElementById('contentSlug').value.trim()); formData.append('description', document.getElementById('adDescription').value.trim()); formData.append('tags', document.getElementById('adTags').value.trim()); formData.append('dashboard_weight', document.getElementById('adWeight').value || '1'); formData.append('dashboard_priority', document.getElementById('adPriority').value || '0'); formData.append('click_url', document.getElementById('adClickUrl').value.trim()); formData.append('is_active', document.getElementById('adIsActive').checked ? '1' : '0'); if (document.getElementById('adShowOnDashboard').checked) { formData.append('show_on_dashboard', '1'); } if (document.getElementById('adAllowAutoPost').checked) { formData.append('allow_auto_post', '1'); } fetch('/admin/api/ads/upload', { method: 'POST', body: formData }) .then(response => readJsonResponse(response)) .then(({ response, data }) => { if (!response.ok || !data.success) { throw new Error(apiError(data, uiT('ui.admin.ads.upload_failed_with_status', `Upload failed (${response.status})`, { status: response.status }))); } document.getElementById('adsUploadForm').reset(); const duplicateMessage = buildDuplicateWarning(data.duplicates); const successMessage = window.getApiMessage ? window.getApiMessage(data, uiT('ui.admin.ads.uploaded', 'Advertisement uploaded.')) : uiT('ui.admin.ads.uploaded', 'Advertisement uploaded.'); showAdsAlert(successMessage + duplicateMessage, duplicateMessage ? 'warning' : 'success'); loadAds(); }) .catch(error => { showAdsAlert(error.message || uiT('ui.admin.ads.upload_failed', 'Upload failed'), 'danger'); }); } function openEditor(id) { fetch(`/admin/api/ads/${id}`) .then(response => readJsonResponse(response)) .then(({ response, data }) => { if (!response.ok || data['error_code'] || ('error' in data)) { throw new Error(apiError(data, uiT('ui.admin.ads.load_one_failed', `Failed to load advertisement (${response.status})`, { status: response.status }))); } const ad = data.ad || null; if (!ad) { throw new Error(uiT('ui.admin.ads.not_found', 'Advertisement not found')); } document.getElementById('editAdId').value = String(ad.id || ''); document.getElementById('editAdTitle').value = ad.title || ''; document.getElementById('editAdSlug').value = ad.slug || ''; document.getElementById('editAdLegacyFilename').value = ad.legacy_filename || ''; document.getElementById('editAdTags').value = Array.isArray(ad.tags) ? ad.tags.join(', ') : ''; document.getElementById('editAdWeight').value = String(ad.dashboard_weight || 1); document.getElementById('editAdPriority').value = String(ad.dashboard_priority || 0); document.getElementById('editAdDescription').value = ad.description || ''; document.getElementById('editAdClickUrl').value = ad.click_url || ''; const editCmdSel = document.getElementById('editAdContentCommand'); const editCmdArgs = document.getElementById('editAdContentCommandArgs'); const storedCmd = (ad.content_command || '').trim(); const cmdParts = storedCmd.split(/\s+/); const storedScript = cmdParts[0] || ''; editCmdSel.value = storedScript; editCmdArgs.value = cmdParts.slice(1).join(' '); updateCommandHints('editAdContentCommand', 'editAdNewfilesHint', 'editAdWeatherHint'); // If the stored script isn't in the list (e.g. legacy entry), show a warning if (storedScript && editCmdSel.value !== storedScript) { showAdsAlert(uiT('ui.admin.ads.content_command_invalid_stored', 'This ad has a content command that is no longer allowed. Please select a valid command or clear it.'), 'warning'); } document.getElementById('editAdContent').value = ad.stored_content || ''; adAnsiEditor?.refreshPreview(); document.getElementById('editAdIsActive').checked = !!ad.is_active; document.getElementById('editAdShowOnDashboard').checked = !!ad.show_on_dashboard; document.getElementById('editAdAllowAutoPost').checked = !!ad.allow_auto_post; const card = document.getElementById('adEditorCard'); card.classList.remove('d-none'); adAnsiEditor?.syncRulerMetrics(); card.scrollIntoView({ behavior: 'smooth', block: 'start' }); }) .catch(error => { showAdsAlert(error.message || uiT('ui.admin.ads.load_one_failed', 'Failed to load advertisement'), 'danger'); }); } function closeEditor() { document.getElementById('adEditorCard').classList.add('d-none'); document.getElementById('adEditorForm').reset(); document.getElementById('editAdId').value = ''; adAnsiEditor?.refreshPreview(); } function saveAd() { const id = document.getElementById('editAdId').value; if (!id) { return; } const payload = { title: document.getElementById('editAdTitle').value.trim(), slug: document.getElementById('editAdSlug').value.trim(), legacy_filename: document.getElementById('editAdLegacyFilename').value.trim(), tags: document.getElementById('editAdTags').value.trim(), description: document.getElementById('editAdDescription').value.trim(), click_url: document.getElementById('editAdClickUrl').value.trim(), content_command: (() => { const s = document.getElementById('editAdContentCommand').value.trim(); const a = document.getElementById('editAdContentCommandArgs').value.trim(); return s ? (a ? s + ' ' + a : s) : ''; })(), content: document.getElementById('editAdContent').value, is_active: document.getElementById('editAdIsActive').checked, show_on_dashboard: document.getElementById('editAdShowOnDashboard').checked, allow_auto_post: document.getElementById('editAdAllowAutoPost').checked, dashboard_weight: Number(document.getElementById('editAdWeight').value || 1), dashboard_priority: Number(document.getElementById('editAdPriority').value || 0) }; fetch(`/admin/api/ads/${encodeURIComponent(id)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) .then(response => readJsonResponse(response)) .then(({ response, data }) => { if (!response.ok || !data.success) { throw new Error(apiError(data, uiT('ui.admin.ads.save_failed_with_status', `Save failed (${response.status})`, { status: response.status }))); } const duplicateMessage = buildDuplicateWarning(data.duplicates); const successMessage = window.getApiMessage ? window.getApiMessage(data, uiT('ui.admin.ads.saved', 'Advertisement saved.')) : uiT('ui.admin.ads.saved', 'Advertisement saved.'); showAdsAlert(successMessage + duplicateMessage, duplicateMessage ? 'warning' : 'success'); closeEditor(); loadAds(); }) .catch(error => { showAdsAlert(error.message || uiT('ui.admin.ads.save_failed', 'Save failed'), 'danger'); }); } function deleteAd(id, displayName) { if (!confirm(uiT('ui.admin.ads.delete_confirm', `Delete ${displayName}?`, { name: displayName }))) { return; } fetch(`/admin/api/ads/${encodeURIComponent(id)}`, { method: 'DELETE' }) .then(response => readJsonResponse(response)) .then(({ response, data }) => { if (!response.ok || !data.success) { throw new Error(apiError(data, uiT('ui.admin.ads.delete_failed_with_status', `Delete failed (${response.status})`, { status: response.status }))); } const successMessage = window.getApiMessage ? window.getApiMessage(data, uiT('ui.admin.ads.deleted', 'Advertisement deleted.')) : uiT('ui.admin.ads.deleted', 'Advertisement deleted.'); showAdsAlert(successMessage, 'success'); closeEditor(); loadAds(); }) .catch(error => { showAdsAlert(error.message || uiT('ui.admin.ads.delete_failed', 'Delete failed'), 'danger'); }); } function buildDuplicateWarning(duplicates) { if (!Array.isArray(duplicates) || duplicates.length === 0) { return ''; } const names = duplicates.map(item => escapeHtml(item.title || item.slug || item.id)).join(', '); return ' ' + uiT('ui.admin.ads.duplicate_warning', 'Matching ANSI content already exists: {items}', { items: names }); } function showAdsAlert(message, type) { const container = document.getElementById('adsStatusAlert'); container.innerHTML = ` <div class="alert alert-${type} alert-dismissible fade show" role="alert"> ${message} <button type="button" class="btn-close" data-bs-dismiss="alert"></button> </div> `; } function escapeHtml(value) { return String(value) .replaceAll('&', '&amp;') .replaceAll('<', '&lt;') .replaceAll('>', '&gt;') .replaceAll('"', '&quot;') .replaceAll("'", '&#039;'); } function escapeJs(value) { return String(value) .replaceAll('\\', '\\\\') .replaceAll("'", "\\'"); } function apiError(payload, fallback) { if (window.getApiErrorMessage) { return window.getApiErrorMessage(payload, fallback); } if (payload && payload.error) { return String(payload.error); } return fallback; } function readJsonResponse(response) { return response.text().then(text => { if (!text) { return { response, data: {} }; } try { return { response, data: JSON.parse(text) }; } catch (error) { throw new Error(uiT('ui.admin.ads.unexpected_response', `Unexpected response (${response.status})`, { status: response.status })); } }); } </script> {% endblock %}