{% extends "base.twig" %}
{% block title %}{{ t('ui.admin.bulletins.title', {}, locale, ['common']) }} - {{ parent() }}{% endblock %}
{% block head %}
<link rel="stylesheet" href="/vendor/toastui-editor-3.2.2/toastui-editor.css">
<link rel="stylesheet" href="/vendor/toastui-editor-3.2.2/toastui-editor-dark.css">
<style>
#bulletinMarkdownEditorWrap .toastui-editor-defaultUI-toolbar,
#bulletinMarkdownEditorWrap .toastui-editor-toolbar {
display: none !important;
}
#bulletinTuiEditorEl .toastui-editor-dark.toastui-editor-defaultUI,
#bulletinTuiEditorEl .toastui-editor-dark .toastui-editor-ww-container,
#bulletinTuiEditorEl .toastui-editor-dark .toastui-editor-md-container,
#bulletinTuiEditorEl .toastui-editor-dark .toastui-editor-mode-switch,
#bulletinTuiEditorEl .toastui-editor-dark .toastui-editor-mode-switch .tab-item.active {
background-color: transparent !important;
background: transparent !important;
}
#bulletinTuiEditorEl .toastui-editor-dark.toastui-editor-defaultUI {
border-color: var(--bs-border-color) !important;
}
#bulletinTuiEditorEl .toastui-editor-contents code {
background-color: rgba(128,128,128,0.2) !important;
}
#bulletinPreview .bulletin-ansi-container {
overflow-x: auto;
overflow-y: hidden;
}
#bulletinPreview .bulletin-ansi-container .bulletin-ansi-body {
white-space: pre;
overflow: visible;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">{{ t('ui.admin.bulletins.title', {}, locale, ['common']) }}</h1>
<button type="button" class="btn btn-primary" onclick="newBulletin()">
<i class="fas fa-plus me-1"></i>{{ t('ui.admin.bulletins.create', {}, locale, ['common']) }}
</button>
</div>
<div id="bulletinsAlert"></div>
<div class="row g-3">
<div class="col-lg-5">
<div class="card">
<div class="card-header">{{ t('ui.admin.bulletins.list', {}, locale, ['common']) }}</div>
<div class="list-group list-group-flush" id="bulletinsList"></div>
</div>
</div>
<div class="col-lg-7">
<div class="card">
<div class="card-header" id="formTitle">{{ t('ui.admin.bulletins.create', {}, locale, ['common']) }}</div>
<div class="card-body">
<input type="hidden" id="bulletinId">
<div class="mb-3">
<label class="form-label" for="bulletinTitle">{{ t('ui.common.title', {}, locale, ['common']) }}</label>
<input type="text" class="form-control" id="bulletinTitle" maxlength="255">
</div>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label" for="bulletinFormat">{{ t('ui.admin.bulletins.format', {}, locale, ['common']) }}</label>
<select class="form-select" id="bulletinFormat" onchange="onBulletinFormatChange()">
<option value="plain">{{ t('ui.admin.bulletins.format_plain', {}, locale, ['common']) }}</option>
<option value="markdown">{{ t('ui.admin.bulletins.format_markdown', {}, locale, ['common']) }}</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label" for="bulletinSort">{{ t('ui.admin.bulletins.sort_order', {}, locale, ['common']) }}</label>
<input type="number" class="form-control" id="bulletinSort" value="0">
</div>
<div class="col-md-4 d-flex align-items-end">
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" id="bulletinActive" checked>
<label class="form-check-label" for="bulletinActive">{{ t('ui.admin.bulletins.active', {}, locale, ['common']) }}</label>
</div>
</div>
</div>
<div class="row g-3 mt-0">
<div class="col-md-6">
<label class="form-label" for="bulletinFrom">{{ t('ui.admin.bulletins.active_from', {}, locale, ['common']) }}</label>
<input type="datetime-local" class="form-control" id="bulletinFrom">
</div>
<div class="col-md-6">
<label class="form-label" for="bulletinUntil">{{ t('ui.admin.bulletins.active_until', {}, locale, ['common']) }}</label>
<input type="datetime-local" class="form-control" id="bulletinUntil">
</div>
</div>
<div class="mb-3 mt-3">
<label class="form-label" for="bulletinBody">{{ t('ui.admin.bulletins.body', {}, locale, ['common']) }}</label>
<div class="md-editor-wrapper">
<div class="md-editor-toolbar btn-toolbar mb-1" id="bulletinMarkdownToolbar" role="toolbar" aria-label="{{ t('ui.compose.markdown.toolbar_aria', {}, locale, ['common']) }}" style="display:none;">
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary" onclick="bulletinMdAction('bold')" title="{{ t('ui.compose.markdown.bold_title', {}, locale, ['common']) }}"><strong>B</strong></button>
<button type="button" class="btn btn-outline-secondary" onclick="bulletinMdAction('italic')" title="{{ t('ui.compose.markdown.italic_title', {}, locale, ['common']) }}"><em>I</em></button>
</div>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary" onclick="bulletinMdAction('h1')" title="{{ t('ui.compose.markdown.heading_1_title', {}, locale, ['common']) }}">H1</button>
<button type="button" class="btn btn-outline-secondary" onclick="bulletinMdAction('h2')" title="{{ t('ui.compose.markdown.heading_2_title', {}, locale, ['common']) }}">H2</button>
<button type="button" class="btn btn-outline-secondary" onclick="bulletinMdAction('h3')" title="{{ t('ui.compose.markdown.heading_3_title', {}, locale, ['common']) }}">H3</button>
</div>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary" onclick="bulletinMdAction('code')" title="{{ t('ui.compose.markdown.inline_code_title', {}, locale, ['common']) }}"><i class="fas fa-code"></i></button>
<button type="button" class="btn btn-outline-secondary" onclick="bulletinMdAction('codeblock')" title="{{ t('ui.compose.markdown.code_block_title', {}, locale, ['common']) }}"><i class="fas fa-file-code"></i></button>
</div>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary" onclick="bulletinMdAction('ul')" title="{{ t('ui.compose.markdown.unordered_list_title', {}, locale, ['common']) }}"><i class="fas fa-list-ul"></i></button>
<button type="button" class="btn btn-outline-secondary" onclick="bulletinMdAction('ol')" title="{{ t('ui.compose.markdown.ordered_list_title', {}, locale, ['common']) }}"><i class="fas fa-list-ol"></i></button>
<button type="button" class="btn btn-outline-secondary" onclick="bulletinMdAction('quote')" title="{{ t('ui.compose.markdown.blockquote_title', {}, locale, ['common']) }}"><i class="fas fa-quote-left"></i></button>
</div>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-outline-secondary" onclick="bulletinMdAction('hr')" title="{{ t('ui.compose.markdown.horizontal_rule_title', {}, locale, ['common']) }}">—</button>
</div>
</div>
<textarea class="form-control font-monospace" id="bulletinBody" rows="10" wrap="off" oninput="previewBulletin()"></textarea>
<div id="bulletinMarkdownEditorWrap" style="display:none;">
<div id="bulletinTuiEditorEl"></div>
</div>
</div>
</div>
<div class="d-flex justify-content-end gap-2">
<button type="button" class="btn btn-outline-secondary" onclick="previewBulletin()">{{ t('ui.admin.bulletins.preview', {}, locale, ['common']) }}</button>
<button type="button" class="btn btn-primary" onclick="saveBulletin()">{{ t('ui.common.save', {}, locale, ['common']) }}</button>
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-header">{{ t('ui.admin.bulletins.preview', {}, locale, ['common']) }}</div>
<div class="card-body" id="bulletinPreview"></div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/vendor/marked.min.js"></script>
<script src="/vendor/toastui-editor-3.2.2/toastui-editor-all.min.js"></script>
<script src="/js/markdown-editor.js"></script>
<script src="/js/ansisys.js"></script>
<script>
let bulletins = [];
let bulletinMarkdownEditor = null;
function uiT(key, fallback, params = {}) {
return window.t ? window.t(key, params, fallback) : fallback.replace(/\{([^}]+)\}/g, (_, k) => params[k] ?? '');
}
function escapeHtml(text) {
const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
return String(text).replace(/[&<>"']/g, m => map[m]);
}
function showAlert(message, type) {
document.getElementById('bulletinsAlert').innerHTML = `<div class="alert alert-${type} alert-dismissible fade show">${message}<button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>`;
}
function apiMessage(payload, fallback) {
return window.getApiMessage ? window.getApiMessage(payload, fallback) : fallback;
}
function loadBulletins() {
fetch('/admin/api/bulletins')
.then(r => r.json())
.then(data => {
bulletins = data.bulletins || [];
renderBulletins();
})
.catch(() => showAlert(uiT('ui.admin.bulletins.load_failed', 'Failed to load bulletins.'), 'danger'));
}
function renderBulletins() {
const list = document.getElementById('bulletinsList');
if (!bulletins.length) {
list.innerHTML = `<div class="list-group-item text-muted">${uiT('ui.admin.bulletins.none', 'No bulletins yet.')}</div>`;
return;
}
list.innerHTML = bulletins.map((b, index) => `
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start gap-2">
<div>
<div class="fw-semibold">${escapeHtml(b.title)}</div>
<div class="small text-muted">${escapeHtml(b.format)} · ${b.is_active ? uiT('ui.admin.bulletins.active', 'Active') : uiT('ui.admin.bulletins.inactive', 'Inactive')}</div>
</div>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary" onclick="moveBulletin(${index}, -1)" title="${uiT('ui.common.previous', 'Previous')}"><i class="fas fa-chevron-up"></i></button>
<button class="btn btn-outline-secondary" onclick="moveBulletin(${index}, 1)" title="${uiT('ui.common.next', 'Next')}"><i class="fas fa-chevron-down"></i></button>
<button class="btn btn-outline-primary" onclick="editBulletin(${b.id})" title="${uiT('ui.admin.bulletins.edit', 'Edit')}"><i class="fas fa-pen"></i></button>
<button class="btn btn-outline-danger" onclick="deleteBulletin(${b.id})" title="${uiT('ui.common.delete', 'Delete')}"><i class="fas fa-trash"></i></button>
</div>
</div>
</div>
`).join('');
}
function toLocalInputValue(value) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
const pad = n => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function syncBulletinMarkdownEditor() {
if (bulletinMarkdownEditor) {
bulletinMarkdownEditor.sync();
}
}
function bulletinMdAction(action) {
if (document.getElementById('bulletinFormat').value !== 'markdown') {
return;
}
if (bulletinMarkdownEditor) {
bulletinMarkdownEditor.exec(action);
}
}
function initBulletinMarkdownEditor() {
if (bulletinMarkdownEditor) {
return;
}
bulletinMarkdownEditor = window.BinkMarkdownEditor.mount({
textarea: '#bulletinBody',
editorContainer: '#bulletinMarkdownEditorWrap',
editorEl: '#bulletinTuiEditorEl',
height: '320px',
initialEditType: 'wysiwyg',
previewStyle: 'tab',
toolbarItems: [],
onChange: function() {
previewBulletin();
}
});
}
function destroyBulletinMarkdownEditor(syncContent = true) {
if (bulletinMarkdownEditor) {
bulletinMarkdownEditor.destroy({ sync: syncContent });
bulletinMarkdownEditor = null;
}
}
function refreshBulletinEditorMode() {
const toolbar = document.getElementById('bulletinMarkdownToolbar');
if (document.getElementById('bulletinFormat').value === 'markdown') {
if (toolbar) {
toolbar.style.display = '';
}
initBulletinMarkdownEditor();
if (bulletinMarkdownEditor) {
bulletinMarkdownEditor.setMarkdown(document.getElementById('bulletinBody').value || '', false);
}
return;
}
if (toolbar) {
toolbar.style.display = 'none';
}
destroyBulletinMarkdownEditor();
}
function onBulletinFormatChange() {
refreshBulletinEditorMode();
previewBulletin();
}
function newBulletin() {
destroyBulletinMarkdownEditor(false);
document.getElementById('formTitle').textContent = uiT('ui.admin.bulletins.create', 'Create Bulletin');
document.getElementById('bulletinId').value = '';
document.getElementById('bulletinTitle').value = '';
document.getElementById('bulletinFormat').value = 'plain';
document.getElementById('bulletinSort').value = String((bulletins.length + 1) * 10);
document.getElementById('bulletinActive').checked = true;
document.getElementById('bulletinFrom').value = '';
document.getElementById('bulletinUntil').value = '';
document.getElementById('bulletinBody').value = '';
refreshBulletinEditorMode();
previewBulletin();
}
function editBulletin(id) {
const b = bulletins.find(item => item.id === id);
if (!b) return;
destroyBulletinMarkdownEditor(false);
document.getElementById('formTitle').textContent = uiT('ui.admin.bulletins.edit', 'Edit Bulletin');
document.getElementById('bulletinId').value = b.id;
document.getElementById('bulletinTitle').value = b.title || '';
document.getElementById('bulletinFormat').value = b.format || 'plain';
document.getElementById('bulletinSort').value = b.sort_order || 0;
document.getElementById('bulletinActive').checked = !!b.is_active;
document.getElementById('bulletinFrom').value = toLocalInputValue(b.active_from);
document.getElementById('bulletinUntil').value = toLocalInputValue(b.active_until);
document.getElementById('bulletinBody').value = b.body || '';
refreshBulletinEditorMode();
previewBulletin();
}
function collectPayload() {
syncBulletinMarkdownEditor();
return {
title: document.getElementById('bulletinTitle').value.trim(),
body: document.getElementById('bulletinBody').value,
format: document.getElementById('bulletinFormat').value,
sort_order: parseInt(document.getElementById('bulletinSort').value || '0', 10),
is_active: document.getElementById('bulletinActive').checked,
active_from: document.getElementById('bulletinFrom').value || null,
active_until: document.getElementById('bulletinUntil').value || null
};
}
function saveBulletin() {
const id = document.getElementById('bulletinId').value;
const method = id ? 'PUT' : 'POST';
const url = id ? `/admin/api/bulletins/${id}` : '/admin/api/bulletins';
fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(collectPayload())
})
.then(r => r.json())
.then(data => {
if (!data.success) {
showAlert(data.error || uiT('ui.admin.bulletins.save_failed', 'Failed to save bulletin.'), 'danger');
return;
}
showAlert(apiMessage(data, uiT('ui.admin.bulletins.saved_success', 'Bulletin saved.')), 'success');
loadBulletins();
if (!id && data.id) {
document.getElementById('bulletinId').value = data.id;
}
})
.catch(() => showAlert(uiT('ui.admin.bulletins.save_failed', 'Failed to save bulletin.'), 'danger'));
}
function deleteBulletin(id) {
if (!confirm(uiT('ui.admin.bulletins.delete_confirm', 'Delete this bulletin?'))) return;
fetch(`/admin/api/bulletins/${id}`, { method: 'DELETE' })
.then(r => r.json())
.then(data => {
if (data.success) {
showAlert(apiMessage(data, uiT('ui.admin.bulletins.deleted_success', 'Bulletin deleted.')), 'success');
loadBulletins();
newBulletin();
}
});
}
function moveBulletin(index, delta) {
const target = index + delta;
if (target < 0 || target >= bulletins.length) return;
[bulletins[index], bulletins[target]] = [bulletins[target], bulletins[index]];
renderBulletins();
fetch('/admin/api/bulletins/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: bulletins.map(b => b.id) })
}).then(() => loadBulletins());
}
function previewBulletin() {
syncBulletinMarkdownEditor();
const payload = collectPayload();
const preview = document.getElementById('bulletinPreview');
if (payload.format === 'markdown' && window.marked) {
preview.innerHTML = marked.parse(payload.body || '');
return;
}
if (typeof renderAnsiTerminal === 'function') {
preview.innerHTML = `
<div class="ansi-art-container bulletin-ansi-container">
<pre class="ansi-art bulletin-ansi-body mb-0">${renderAnsiTerminal(payload.body || '', 80, 500)}</pre>
</div>`;
return;
}
const linked = escapeHtml(payload.body || '').replace(/\b(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
preview.innerHTML = `<pre class="mb-0 font-monospace" style="white-space: pre-wrap;">${linked}</pre>`;
}
document.addEventListener('DOMContentLoaded', function() {
loadI18nNamespaces(['common', 'errors']).then(function() {
loadBulletins();
newBulletin();
});
});
</script>
{% endblock %}
|