{% 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('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
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 %}
|