553 lines
28 KiB
HTML
553 lines
28 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}{% if article %}Edit Article{% else %}New Article{% endif %}{% endblock %}
|
|
{% block page_title %}{% if article %}Edit Article{% else %}New KB Article{% endif %}{% endblock %}
|
|
|
|
{% block head %}
|
|
<script src="https://cdn.jsdelivr.net/npm/tinymce@6/tinymce.min.js" referrerpolicy="origin"></script>
|
|
<style>
|
|
.tox .tox-toolbar,.tox .tox-toolbar__overflow,.tox .tox-toolbar-overlord{background:#f8fafc!important;}
|
|
.tox .tox-edit-area__iframe{background:#fff!important;}
|
|
.tox-tinymce{border:1px solid var(--border)!important;border-radius:0 0 8px 8px!important;box-shadow:none!important;}
|
|
.tox .tox-menubar{background:#f8fafc!important;border-bottom:1px solid var(--border)!important;}
|
|
.att-item{display:flex;align-items:center;gap:10px;padding:9px 14px;background:var(--surface2);border:1px solid var(--border);border-radius:8px;font-size:13px;}
|
|
.att-item .att-icon{font-size:18px;flex-shrink:0;color:var(--accent);}
|
|
.att-item .att-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
.att-item .att-size{font-size:11px;color:var(--muted);flex-shrink:0;font-family:'Space Mono',monospace;}
|
|
#drop-zone{border:2px dashed var(--border2);border-radius:10px;padding:24px;text-align:center;cursor:pointer;transition:border-color .2s,background .2s;background:var(--surface2);}
|
|
#drop-zone.dragover{border-color:var(--accent);background:#eff6ff;}
|
|
#drop-zone i{font-size:28px;color:var(--muted2);display:block;margin-bottom:8px;}
|
|
#drop-zone p{font-size:13px;color:var(--muted);margin:0;}
|
|
#drop-zone strong{color:var(--accent);}
|
|
#file-list{display:flex;flex-direction:column;gap:6px;margin-top:10px;}
|
|
.pending-file{display:flex;align-items:center;gap:8px;padding:7px 12px;background:#fff;border:1px solid var(--border);border-radius:7px;font-size:13px;}
|
|
.pending-file .pf-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
.pending-file .pf-size{font-size:11px;color:var(--muted);font-family:'Space Mono',monospace;}
|
|
.pending-file .pf-remove{background:none;border:none;color:var(--danger);cursor:pointer;padding:0 4px;font-size:15px;line-height:1;}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="row g-4">
|
|
<div class="col-lg-8">
|
|
<div class="mb-3">
|
|
<a href="{{ url_for('admin.kb_list') }}" style="font-size:13px;color:var(--accent);">
|
|
<i class="bi bi-arrow-left me-1"></i>Back to Knowledge Base
|
|
</a>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<i class="bi bi-{% if article %}pencil{% else %}file-earmark-plus{% endif %} me-2"></i>
|
|
{% if article %}Edit: {{ article.title[:50] }}{% else %}Create New Article{% endif %}
|
|
</div>
|
|
<div class="card-body">
|
|
<form method="POST" enctype="multipart/form-data" id="kb-form"
|
|
data-article-id="{{ article.id if article else '' }}"
|
|
data-atts-url="{{ url_for('admin.kb_get_attachments', article_id=article.id) if article else '' }}">
|
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
|
<div class="mb-3">
|
|
<label class="form-label">Title *</label>
|
|
<input type="text" class="form-control" name="title" required
|
|
value="{{ article.title if article else '' }}"
|
|
placeholder="e.g. How to connect to the corporate VPN"
|
|
style="font-size:16px;font-weight:600;"/>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Content *</label>
|
|
<textarea name="body" id="kb-body">{{ article.body if article else '' }}</textarea>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">
|
|
<i class="bi bi-paperclip me-1"></i>Attachments
|
|
<span style="font-weight:400;color:var(--muted);">(PDF, DOCX, XLSX, ZIP, images, etc.)</span>
|
|
</label>
|
|
<div id="drop-zone" onclick="document.getElementById('att-input').click()">
|
|
<i class="bi bi-cloud-arrow-up"></i>
|
|
<p><strong>Click to browse</strong> or drag & drop files here</p>
|
|
<p style="font-size:11px;margin-top:4px;">Max 16 MB per file</p>
|
|
</div>
|
|
<!-- Single picker — only used to trigger the OS dialog.
|
|
Actual files are held in the JS stagedFiles DataTransfer object
|
|
and injected into FormData on submit (fixes multi-file browser bug). -->
|
|
<input type="file" id="att-input" multiple hidden
|
|
accept=".png,.jpg,.jpeg,.gif,.webp,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.zip,.log"/>
|
|
<div id="file-list"></div>
|
|
<!-- Progress bar shown during fetch upload -->
|
|
<div id="upload-progress" style="display:none;margin-top:10px;">
|
|
<div style="display:flex;align-items:center;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:4px;">
|
|
<span>Uploading…</span><span id="upload-pct">0%</span>
|
|
</div>
|
|
<div style="height:5px;background:var(--border);border-radius:3px;">
|
|
<div id="upload-bar" style="height:100%;background:var(--accent);border-radius:3px;width:0%;transition:width .2s;"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% if article %}
|
|
<!-- Existing attachments — rendered dynamically by JS so they update
|
|
after save/delete without a full page reload -->
|
|
<div class="mb-3" id="existing-atts-wrap">
|
|
<label class="form-label">Existing Attachments</label>
|
|
<div id="existing-atts-list" style="display:flex;flex-direction:column;gap:6px;">
|
|
<!-- Populated by renderExistingAtts() on page load and after each save/delete -->
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
<div class="mb-4">
|
|
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;">
|
|
<input type="checkbox" name="is_published"
|
|
style="accent-color:var(--accent);width:16px;height:16px;"
|
|
{% if not article or article.is_published %}checked{% endif %}/>
|
|
<span style="font-size:14px;font-weight:500;">
|
|
Publish immediately
|
|
<span style="font-size:12px;color:var(--muted);font-weight:400;">(visible to all employees)</span>
|
|
</span>
|
|
</label>
|
|
</div>
|
|
<div class="d-flex gap-2" style="border-top:1px solid var(--border);padding-top:16px;">
|
|
<button type="submit" class="btn btn-primary" id="save-btn">
|
|
<i class="bi bi-check2 me-2"></i>{% if article %}Save Changes{% else %}Publish Article{% endif %}
|
|
</button>
|
|
<button type="button" class="btn btn-secondary" onclick="saveDraft()">
|
|
<i class="bi bi-floppy me-2"></i>Save as Draft
|
|
</button>
|
|
<a href="{{ url_for('admin.kb_list') }}" class="btn btn-secondary ms-auto">Cancel</a>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-lg-4">
|
|
<div class="card mb-3">
|
|
<div class="card-header"><i class="bi bi-sliders me-2"></i>Article Settings</div>
|
|
<div class="card-body">
|
|
<div class="mb-3">
|
|
<label class="form-label">Category</label>
|
|
<select class="form-select" name="category" form="kb-form">
|
|
<option value="">— Select Category —</option>
|
|
{% for cat in ['hardware','software','network','access','email','printer','phone','security','other'] %}
|
|
<option value="{{ cat }}" {% if article and article.category == cat %}selected{% endif %}>
|
|
{{ cat.replace('_',' ').title() }}
|
|
</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div class="mb-0">
|
|
<label class="form-label">Tags <span style="font-weight:400;color:var(--muted);">(comma-separated)</span></label>
|
|
<input type="text" class="form-control" name="tags" form="kb-form"
|
|
value="{{ article.tags if article else '' }}" placeholder="vpn, remote, windows"/>
|
|
<div style="font-size:11px;color:var(--muted);margin-top:5px;">Tags improve search and discoverability.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="card mb-3">
|
|
<div class="card-header"><i class="bi bi-keyboard me-2"></i>Editor Tips</div>
|
|
<div class="card-body" style="font-size:13px;color:var(--text2);">
|
|
<div style="display:flex;flex-direction:column;gap:8px;">
|
|
<div><kbd style="background:var(--surface2);border:1px solid var(--border);border-radius:4px;padding:1px 6px;font-size:11px;">Ctrl+B</kbd> Bold</div>
|
|
<div><kbd style="background:var(--surface2);border:1px solid var(--border);border-radius:4px;padding:1px 6px;font-size:11px;">Ctrl+I</kbd> Italic</div>
|
|
<div><kbd style="background:var(--surface2);border:1px solid var(--border);border-radius:4px;padding:1px 6px;font-size:11px;">Ctrl+K</kbd> Insert link</div>
|
|
<div><kbd style="background:var(--surface2);border:1px solid var(--border);border-radius:4px;padding:1px 6px;font-size:11px;">Ctrl+Z</kbd> Undo</div>
|
|
<div style="color:var(--muted);">Use <strong>Insert → Table</strong> for tables</div>
|
|
<div style="color:var(--muted);">Drag & drop images directly into the editor</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% if article %}
|
|
<div class="card">
|
|
<div class="card-header"><i class="bi bi-info-circle me-2"></i>Article Info</div>
|
|
<div class="card-body" style="font-size:13px;">
|
|
<div style="display:flex;flex-direction:column;gap:8px;color:var(--text2);">
|
|
<div style="display:flex;justify-content:space-between;">
|
|
<span style="color:var(--muted);">Created</span><span>{{ article.created_at.strftime('%b %d, %Y') }}</span>
|
|
</div>
|
|
<div style="display:flex;justify-content:space-between;">
|
|
<span style="color:var(--muted);">Last updated</span><span>{{ article.updated_at.strftime('%b %d, %Y') }}</span>
|
|
</div>
|
|
<div style="display:flex;justify-content:space-between;">
|
|
<span style="color:var(--muted);">Views</span><span>{{ article.view_count }}</span>
|
|
</div>
|
|
<div style="display:flex;justify-content:space-between;">
|
|
<span style="color:var(--muted);">Author</span><span>{{ article.author.full_name if article.author else '—' }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="mt-3 pt-3" style="border-top:1px solid var(--border);">
|
|
<a href="{{ url_for('tickets.kb_article', article_id=article.id) }}" target="_blank" class="btn btn-secondary btn-sm w-100">
|
|
<i class="bi bi-eye me-2"></i>Preview Article
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
// ── TinyMCE 6 ─────────────────────────────────────────────────────────────────
|
|
tinymce.init({
|
|
selector: '#kb-body',
|
|
height: 520,
|
|
menubar: true,
|
|
branding: false,
|
|
resize: true,
|
|
plugins: [
|
|
'advlist','autolink','lists','link','image','charmap',
|
|
'preview','anchor','searchreplace','visualblocks','code',
|
|
'fullscreen','insertdatetime','media','table','wordcount',
|
|
'emoticons','codesample','help',
|
|
],
|
|
toolbar:
|
|
'undo redo | styles | ' +
|
|
'bold italic underline strikethrough | forecolor backcolor | ' +
|
|
'alignleft aligncenter alignright alignjustify | ' +
|
|
'bullist numlist outdent indent | ' +
|
|
'table image media link anchor | ' +
|
|
'codesample blockquote hr | ' +
|
|
'removeformat | fullscreen code | help',
|
|
style_formats: [
|
|
{ title: 'Headings', items: [
|
|
{ title: 'Heading 1', format: 'h1' },
|
|
{ title: 'Heading 2', format: 'h2' },
|
|
{ title: 'Heading 3', format: 'h3' },
|
|
]},
|
|
{ title: 'Inline', items: [
|
|
{ title: 'Bold', format: 'bold' },
|
|
{ title: 'Italic', format: 'italic' },
|
|
{ title: 'Underline', format: 'underline' },
|
|
{ title: 'Code', inline: 'code' },
|
|
]},
|
|
{ title: 'Callout', items: [
|
|
{ title: 'Info box', block: 'div', classes: 'callout-info', wrapper: true },
|
|
{ title: 'Warning box', block: 'div', classes: 'callout-warning', wrapper: true },
|
|
{ title: 'Tip box', block: 'div', classes: 'callout-tip', wrapper: true },
|
|
]},
|
|
],
|
|
content_style: `
|
|
body { font-family: 'DM Sans',system-ui,sans-serif; font-size:14px; line-height:1.75; color:#0f172a; padding:16px 20px; max-width:860px; margin:0 auto; }
|
|
h1,h2,h3,h4 { font-weight:700; margin:1.5em 0 .5em; color:#0f172a; }
|
|
h1{font-size:1.6em;} h2{font-size:1.35em;} h3{font-size:1.15em;}
|
|
p { margin:0 0 1em; }
|
|
a { color:#2563eb; }
|
|
code { background:#f1f5f9; border:1px solid #e2e8f0; border-radius:4px; padding:1px 5px; font-size:.88em; }
|
|
pre { background:#1e293b; color:#e2e8f0; padding:14px 18px; border-radius:8px; overflow-x:auto; }
|
|
pre code { background:none; border:none; padding:0; color:inherit; }
|
|
table { border-collapse:collapse; width:100%; margin:1em 0; }
|
|
table th { background:#f1f5f9; font-weight:600; }
|
|
table th, table td { border:1px solid #e2e8f0; padding:8px 12px; font-size:13px; }
|
|
table tr:nth-child(even) td { background:#f8fafc; }
|
|
img { max-width:100%; height:auto; border-radius:6px; }
|
|
blockquote { border-left:4px solid #2563eb; margin:1em 0; padding:10px 16px; background:#eff6ff; border-radius:0 6px 6px 0; color:#1e40af; }
|
|
.callout-info { background:#eff6ff; border:1px solid #bfdbfe; border-radius:8px; padding:14px 16px; margin:1em 0; }
|
|
.callout-warning { background:#fffbeb; border:1px solid #fde68a; border-radius:8px; padding:14px 16px; margin:1em 0; }
|
|
.callout-tip { background:#ecfdf5; border:1px solid #a7f3d0; border-radius:8px; padding:14px 16px; margin:1em 0; }
|
|
hr { border:none; border-top:1px solid #e2e8f0; margin:2em 0; }
|
|
`,
|
|
images_upload_handler: (blobInfo, progress) => new Promise((resolve, reject) => {
|
|
const fd = new FormData();
|
|
fd.append('file', blobInfo.blob(), blobInfo.filename());
|
|
fetch('/admin/kb/upload-image', { method: 'POST', credentials: 'same-origin', headers: { 'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').content }, body: fd })
|
|
.then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
|
.then(j => { if (j.location) resolve(j.location); else reject({ message: j.error || 'Upload failed', remove: true }); })
|
|
.catch(err => reject({ message: String(err), remove: true }));
|
|
}),
|
|
paste_data_images: true,
|
|
// Prevent TinyMCE from converting absolute image URLs to relative paths.
|
|
// Without this, '/admin/kb/files/abc.png' gets stored as a relative path
|
|
// like '../../kb/files/abc.png' which breaks on any other page.
|
|
convert_urls: false,
|
|
relative_urls: false,
|
|
remove_script_host: false,
|
|
image_advtab: true,
|
|
image_caption: true,
|
|
table_default_attributes: { border: '0' },
|
|
table_default_styles: { 'border-collapse': 'collapse', width: '100%' },
|
|
link_default_target: '_blank',
|
|
link_assume_external_targets: true,
|
|
codesample_languages: [
|
|
{ text: 'HTML/XML', value: 'markup' },
|
|
{ text: 'JavaScript', value: 'javascript' },
|
|
{ text: 'CSS', value: 'css' },
|
|
{ text: 'Python', value: 'python' },
|
|
{ text: 'Bash/Shell', value: 'bash' },
|
|
{ text: 'SQL', value: 'sql' },
|
|
{ text: 'PowerShell', value: 'powershell' },
|
|
{ text: 'Plain text', value: 'none' },
|
|
],
|
|
setup: editor => {
|
|
editor.on('change keyup undo redo', () => editor.save());
|
|
},
|
|
});
|
|
|
|
// ── Article state ─────────────────────────────────────────────────────────────
|
|
const form = document.getElementById('kb-form');
|
|
// article_id is '' for new articles, a number string for existing ones
|
|
let articleId = form.dataset.articleId || '';
|
|
let attsUrl = form.dataset.attsUrl || '';
|
|
|
|
// ── File staging (pending new uploads) ───────────────────────────────────────
|
|
const dropZone = document.getElementById('drop-zone');
|
|
const attInput = document.getElementById('att-input');
|
|
const fileList = document.getElementById('file-list');
|
|
let pendingFiles = [];
|
|
|
|
function renderPendingList() {
|
|
fileList.innerHTML = '';
|
|
pendingFiles.forEach((f, i) => {
|
|
const div = document.createElement('div');
|
|
div.className = 'pending-file';
|
|
div.innerHTML =
|
|
'<i class="bi bi-' + getFileIcon(f.name) + '" style="color:var(--accent);font-size:16px;flex-shrink:0;"></i>' +
|
|
'<span class="pf-name">' + escHtml(f.name) + '</span>' +
|
|
'<span class="pf-size">' + formatSize(f.size) + '</span>' +
|
|
'<button type="button" class="pf-remove" onclick="removePending(' + i + ')" title="Remove">×</button>';
|
|
fileList.appendChild(div);
|
|
});
|
|
}
|
|
|
|
function removePending(idx) { pendingFiles.splice(idx, 1); renderPendingList(); }
|
|
|
|
function addFiles(list) {
|
|
Array.from(list).forEach(f => {
|
|
if (f.size > 16 * 1024 * 1024) { alert('"' + f.name + '" exceeds 16 MB and was skipped.'); return; }
|
|
if (!pendingFiles.some(p => p.name === f.name && p.size === f.size)) pendingFiles.push(f);
|
|
});
|
|
attInput.value = '';
|
|
renderPendingList();
|
|
}
|
|
|
|
attInput.addEventListener('change', () => addFiles(attInput.files));
|
|
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); });
|
|
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
|
|
dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('dragover'); addFiles(e.dataTransfer.files); });
|
|
|
|
// ── Existing attachments: render + async delete ───────────────────────────────
|
|
|
|
function attFileIcon(mimeType) {
|
|
if (!mimeType) return 'file-earmark';
|
|
if (mimeType.startsWith('image/')) return 'image';
|
|
if (mimeType.includes('pdf')) return 'file-earmark-pdf';
|
|
if (mimeType.includes('word')) return 'file-earmark-word';
|
|
if (mimeType.includes('excel') || mimeType.includes('spreadsheet')) return 'file-earmark-excel';
|
|
if (mimeType.includes('zip')) return 'file-earmark-zip';
|
|
return 'file-earmark';
|
|
}
|
|
|
|
function renderExistingAtts(atts) {
|
|
const wrap = document.getElementById('existing-atts-wrap');
|
|
const list = document.getElementById('existing-atts-list');
|
|
if (!wrap || !list) return;
|
|
|
|
if (!atts || atts.length === 0) {
|
|
wrap.style.display = 'none';
|
|
return;
|
|
}
|
|
wrap.style.display = 'block';
|
|
list.innerHTML = '';
|
|
|
|
atts.forEach(att => {
|
|
const row = document.createElement('div');
|
|
row.className = 'att-item';
|
|
row.id = 'att-row-' + att.id;
|
|
row.innerHTML =
|
|
'<i class="bi bi-' + attFileIcon(att.mime_type) + ' att-icon"></i>' +
|
|
'<span class="att-name"><a href="' + att.url + '" target="_blank" style="color:var(--accent);">' + escHtml(att.filename) + '</a></span>' +
|
|
'<span class="att-size">' + formatSize(att.file_size) + '</span>' +
|
|
'<button type="button" class="btn btn-sm" onclick="deleteAtt(' + att.id + ',\'' + att.delete_url + '\',\'' + escHtml(att.filename) + '\')" ' +
|
|
'style="background:none;border:none;color:var(--danger);padding:2px 6px;" title="Delete attachment">' +
|
|
'<i class="bi bi-trash"></i></button>';
|
|
list.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function loadExistingAtts() {
|
|
if (!attsUrl) return;
|
|
fetch(attsUrl, { credentials: 'same-origin' })
|
|
.then(r => r.json())
|
|
.then(data => renderExistingAtts(data.attachments))
|
|
.catch(() => {});
|
|
}
|
|
|
|
function deleteAtt(attId, deleteUrl, filename) {
|
|
confirmModal('Delete Attachment', 'Delete "' + filename + '"? This cannot be undone.', 'Delete', 'btn-danger', () => {
|
|
const row = document.getElementById('att-row-' + attId);
|
|
if (row) { row.style.opacity = '0.4'; row.style.pointerEvents = 'none'; }
|
|
fetch(deleteUrl, { method: 'POST', credentials: 'same-origin', headers: { 'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').content } })
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.ok) {
|
|
if (row) row.remove();
|
|
// Hide the section header if no attachments remain
|
|
const list = document.getElementById('existing-atts-list');
|
|
if (list && list.children.length === 0) {
|
|
const wrap = document.getElementById('existing-atts-wrap');
|
|
if (wrap) wrap.style.display = 'none';
|
|
}
|
|
showToast('Attachment deleted.', 'success');
|
|
} else {
|
|
if (row) { row.style.opacity = ''; row.style.pointerEvents = ''; }
|
|
alert('Delete failed. Please try again.');
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (row) { row.style.opacity = ''; row.style.pointerEvents = ''; }
|
|
alert('Network error. Please try again.');
|
|
});
|
|
});
|
|
}
|
|
|
|
// Load existing attachments on page load (only for edit mode)
|
|
if (articleId) loadExistingAtts();
|
|
|
|
// ── Save handler ──────────────────────────────────────────────────────────────
|
|
function doSave(asDraft) {
|
|
// Flush TinyMCE content to textarea first
|
|
const editor = tinymce.get('kb-body');
|
|
if (editor) editor.save();
|
|
|
|
// Validate
|
|
const titleEl = document.querySelector('#kb-form [name="title"]');
|
|
const bodyEl = document.getElementById('kb-body');
|
|
const categoryEl = document.querySelector('[name="category"]');
|
|
const tagsEl = document.querySelector('[name="tags"]');
|
|
const publishEl = document.querySelector('[name="is_published"]');
|
|
|
|
const title = titleEl ? titleEl.value.trim() : '';
|
|
const body = bodyEl ? bodyEl.value.trim() : '';
|
|
const category = categoryEl ? categoryEl.value : '';
|
|
const tags = tagsEl ? tagsEl.value.trim() : '';
|
|
|
|
if (!title) { alert('Please enter a title.'); if (titleEl) titleEl.focus(); return; }
|
|
const emptyBody = !body || body === '<p></p>' || body === '<p><br></p>' || body === '<p><br data-mce-bogus="1"></p>';
|
|
if (emptyBody) { alert('Please add some content to the article body.'); return; }
|
|
|
|
// Build FormData explicitly — avoids hidden-element issues with FormData(form)
|
|
const fd = new FormData();
|
|
fd.append('title', title);
|
|
fd.append('body', body);
|
|
fd.append('category', category);
|
|
fd.append('tags', tags);
|
|
if (!asDraft && publishEl && publishEl.checked) fd.append('is_published', 'on');
|
|
if (asDraft) fd.append('_save_as_draft', '1');
|
|
pendingFiles.forEach(f => fd.append('attachments', f, f.name));
|
|
|
|
// UI feedback
|
|
const btn = document.getElementById('save-btn');
|
|
const origTxt = btn.innerHTML;
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status"></span>Saving\u2026';
|
|
|
|
const progressWrap = document.getElementById('upload-progress');
|
|
const progressBar = document.getElementById('upload-bar');
|
|
const progressPct = document.getElementById('upload-pct');
|
|
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open('POST', window.location.pathname);
|
|
xhr.withCredentials = true;
|
|
xhr.setRequestHeader('X-CSRFToken', document.querySelector('meta[name="csrf-token"]').content);
|
|
|
|
xhr.upload.onprogress = ev => {
|
|
if (ev.lengthComputable && pendingFiles.length > 0) {
|
|
progressWrap.style.display = 'block';
|
|
const pct = Math.round(ev.loaded / ev.total * 100);
|
|
progressBar.style.width = pct + '%';
|
|
progressPct.textContent = pct + '%';
|
|
}
|
|
};
|
|
|
|
xhr.onload = () => {
|
|
btn.disabled = false;
|
|
btn.innerHTML = origTxt;
|
|
progressWrap.style.display = 'none';
|
|
progressBar.style.width = '0%';
|
|
|
|
if (xhr.status >= 200 && xhr.status < 400) {
|
|
try {
|
|
const json = JSON.parse(xhr.responseText);
|
|
if (json.error) { alert('Server error: ' + json.error); return; }
|
|
|
|
// ── Issue 3 fix: stay on the page ─────────────────────────────────
|
|
showToast(asDraft ? 'Saved as draft.' : 'Changes saved!', 'success');
|
|
|
|
// Update article ID + attachments URL if this was a new article
|
|
if (json.article_id && !articleId) {
|
|
articleId = String(json.article_id);
|
|
form.dataset.articleId = articleId;
|
|
attsUrl = '/admin/kb/' + articleId + '/attachments';
|
|
form.dataset.attsUrl = attsUrl;
|
|
// Update browser URL to the edit URL without reloading
|
|
history.replaceState(null, '', '/admin/kb/' + articleId + '/edit');
|
|
// Update page title and header
|
|
const header = document.querySelector('.card-header');
|
|
if (header) header.innerHTML = '<i class="bi bi-pencil me-2"></i>Edit Article';
|
|
const saveBtn2 = document.getElementById('save-btn');
|
|
if (saveBtn2) saveBtn2.innerHTML = '<i class="bi bi-check2 me-2"></i>Save Changes';
|
|
}
|
|
|
|
// Clear pending files and refresh existing list
|
|
pendingFiles = [];
|
|
renderPendingList();
|
|
if (articleId) loadExistingAtts();
|
|
|
|
} catch (_) {
|
|
// Non-JSON response — still treat as success
|
|
showToast('Saved!', 'success');
|
|
pendingFiles = [];
|
|
renderPendingList();
|
|
if (articleId) loadExistingAtts();
|
|
}
|
|
} else {
|
|
alert('Save failed (HTTP ' + xhr.status + '). Please try again.');
|
|
}
|
|
};
|
|
|
|
xhr.onerror = () => {
|
|
btn.disabled = false;
|
|
btn.innerHTML = origTxt;
|
|
progressWrap.style.display = 'none';
|
|
alert('Network error. Please check your connection and try again.');
|
|
};
|
|
|
|
xhr.send(fd);
|
|
}
|
|
|
|
// Intercept native form submit
|
|
document.getElementById('kb-form').addEventListener('submit', e => { e.preventDefault(); doSave(false); });
|
|
document.getElementById('save-btn').onclick = e => { e.preventDefault(); doSave(false); };
|
|
function saveDraft() { doSave(true); }
|
|
|
|
// ── Toast notification ────────────────────────────────────────────────────────
|
|
function showToast(message, type) {
|
|
const t = document.createElement('div');
|
|
const bg = type === 'success' ? 'var(--success-bg)' : 'var(--danger-bg)';
|
|
const border = type === 'success' ? 'var(--success)' : 'var(--danger)';
|
|
const color = type === 'success' ? 'var(--success)' : 'var(--danger)';
|
|
t.style.cssText =
|
|
'position:fixed;bottom:90px;right:28px;z-index:9999;' +
|
|
'background:' + bg + ';border:1px solid ' + border + ';color:' + color + ';' +
|
|
'border-radius:8px;padding:12px 20px;font-size:14px;font-weight:600;' +
|
|
'box-shadow:0 4px 16px rgba(0,0,0,.1);animation:slideInRight .2s ease;';
|
|
t.textContent = message;
|
|
document.body.appendChild(t);
|
|
setTimeout(() => {
|
|
t.style.animation = 'fadeOut .2s ease forwards';
|
|
setTimeout(() => t.remove(), 200);
|
|
}, 2500);
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
function formatSize(b) { return b<1024?b+' B':b<1048576?(b/1024).toFixed(1)+' KB':(b/1048576).toFixed(1)+' MB'; }
|
|
function escHtml(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
function getFileIcon(n) {
|
|
const e = n.split('.').pop().toLowerCase();
|
|
return {pdf:'file-earmark-pdf',png:'image',jpg:'image',jpeg:'image',gif:'image',webp:'image',
|
|
doc:'file-earmark-word',docx:'file-earmark-word',xls:'file-earmark-excel',xlsx:'file-earmark-excel',
|
|
ppt:'file-earmark-ppt',pptx:'file-earmark-ppt',zip:'file-earmark-zip',
|
|
csv:'file-earmark-spreadsheet',txt:'file-earmark-text',log:'file-earmark-text'}[e]||'file-earmark';
|
|
}
|
|
</script>
|
|
<style>
|
|
@keyframes slideInRight { from { transform:translateX(60px); opacity:0; } to { transform:translateX(0); opacity:1; } }
|
|
@keyframes fadeOut { from { opacity:1; } to { opacity:0; } }
|
|
</style>
|
|
{% endblock %} |