Enhance Article editor 2
This commit is contained in:
+1
-1
@@ -39,7 +39,7 @@ def create_app(config_name=None):
|
|||||||
login_manager.login_message_category = 'info'
|
login_manager.login_message_category = 'info'
|
||||||
|
|
||||||
# ── Upload directory ──────────────────────────────────────────────────────
|
# ── Upload directory ──────────────────────────────────────────────────────
|
||||||
upload_dir = app.config.get('UPLOAD_FOLDER', 'app/static/uploads')
|
upload_dir = app.config['UPLOAD_FOLDER'] # always absolute — set in config.py
|
||||||
os.makedirs(upload_dir, exist_ok=True)
|
os.makedirs(upload_dir, exist_ok=True)
|
||||||
|
|
||||||
# ── Logging ───────────────────────────────────────────────────────────────
|
# ── Logging ───────────────────────────────────────────────────────────────
|
||||||
|
|||||||
+44
-16
@@ -212,7 +212,7 @@ def _save_kb_file(file, article_id):
|
|||||||
filename = secure_filename(file.filename)
|
filename = secure_filename(file.filename)
|
||||||
ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else 'bin'
|
ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else 'bin'
|
||||||
stored_name = f"{uuid.uuid4().hex}.{ext}"
|
stored_name = f"{uuid.uuid4().hex}.{ext}"
|
||||||
upload_dir = current_app.config.get('UPLOAD_FOLDER', 'app/static/uploads')
|
upload_dir = current_app.config['UPLOAD_FOLDER']
|
||||||
os.makedirs(upload_dir, exist_ok=True)
|
os.makedirs(upload_dir, exist_ok=True)
|
||||||
filepath = os.path.join(upload_dir, stored_name)
|
filepath = os.path.join(upload_dir, stored_name)
|
||||||
file.save(filepath)
|
file.save(filepath)
|
||||||
@@ -245,11 +245,12 @@ def kb_upload_image():
|
|||||||
return jsonify({'error': 'Only image files are accepted (PNG, JPG, GIF, WEBP)'}), 400
|
return jsonify({'error': 'Only image files are accepted (PNG, JPG, GIF, WEBP)'}), 400
|
||||||
|
|
||||||
stored_name = f"{uuid.uuid4().hex}.{ext}"
|
stored_name = f"{uuid.uuid4().hex}.{ext}"
|
||||||
upload_dir = current_app.config.get('UPLOAD_FOLDER', 'app/static/uploads')
|
upload_dir = current_app.config['UPLOAD_FOLDER']
|
||||||
os.makedirs(upload_dir, exist_ok=True)
|
os.makedirs(upload_dir, exist_ok=True)
|
||||||
f.save(os.path.join(upload_dir, stored_name))
|
f.save(os.path.join(upload_dir, stored_name))
|
||||||
logger.info(f'[KB IMAGE UPLOAD] stored_name={stored_name} by user_id={current_user.id}')
|
logger.info(f'[KB IMAGE UPLOAD] stored_name={stored_name} by user_id={current_user.id}')
|
||||||
return jsonify({'location': f'/admin/kb/files/{stored_name}'})
|
location = url_for('admin.kb_serve_file', stored_name=stored_name)
|
||||||
|
return jsonify({'location': location})
|
||||||
|
|
||||||
|
|
||||||
# ── File-serve route (images embedded in articles + attachment downloads) ─────
|
# ── File-serve route (images embedded in articles + attachment downloads) ─────
|
||||||
@@ -258,7 +259,7 @@ def kb_upload_image():
|
|||||||
@login_required
|
@login_required
|
||||||
def kb_serve_file(stored_name):
|
def kb_serve_file(stored_name):
|
||||||
"""Serve a KB attachment file. Login required — no public access."""
|
"""Serve a KB attachment file. Login required — no public access."""
|
||||||
upload_dir = current_app.config.get('UPLOAD_FOLDER', 'app/static/uploads')
|
upload_dir = current_app.config['UPLOAD_FOLDER']
|
||||||
return send_from_directory(upload_dir, stored_name)
|
return send_from_directory(upload_dir, stored_name)
|
||||||
|
|
||||||
|
|
||||||
@@ -269,7 +270,7 @@ def kb_serve_file(stored_name):
|
|||||||
@it_required
|
@it_required
|
||||||
def kb_delete_attachment(article_id, att_id):
|
def kb_delete_attachment(article_id, att_id):
|
||||||
att = KBAttachment.query.filter_by(id=att_id, article_id=article_id).first_or_404()
|
att = KBAttachment.query.filter_by(id=att_id, article_id=article_id).first_or_404()
|
||||||
upload_dir = current_app.config.get('UPLOAD_FOLDER', 'app/static/uploads')
|
upload_dir = current_app.config['UPLOAD_FOLDER']
|
||||||
filepath = os.path.join(upload_dir, att.stored_name)
|
filepath = os.path.join(upload_dir, att.stored_name)
|
||||||
if os.path.exists(filepath):
|
if os.path.exists(filepath):
|
||||||
os.remove(filepath)
|
os.remove(filepath)
|
||||||
@@ -278,10 +279,33 @@ def kb_delete_attachment(article_id, att_id):
|
|||||||
logger.info(f'[KB ATTACHMENT DELETE] att_id={att.id} article_id={article_id} by user_id={current_user.id}')
|
logger.info(f'[KB ATTACHMENT DELETE] att_id={att.id} article_id={article_id} by user_id={current_user.id}')
|
||||||
db.session.delete(att)
|
db.session.delete(att)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(f'Attachment "{att.filename}" deleted.', 'success')
|
logger.info(f'[KB ATTACHMENT DELETE] att_id={att.id} article_id={article_id} completed')
|
||||||
return redirect(url_for('admin.kb_edit', article_id=article_id))
|
# Return JSON so the edit page can remove the row without a full reload
|
||||||
|
return jsonify({'ok': True, 'att_id': att.id})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/kb/<int:article_id>/attachments', methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
@it_required
|
||||||
|
def kb_get_attachments(article_id):
|
||||||
|
"""Return current attachment list as JSON for dynamic UI refresh."""
|
||||||
|
from app.models import KBAttachment
|
||||||
|
atts = KBAttachment.query.filter_by(article_id=article_id).all()
|
||||||
|
return jsonify({'attachments': [
|
||||||
|
{
|
||||||
|
'id': a.id,
|
||||||
|
'filename': a.filename,
|
||||||
|
'stored_name': a.stored_name,
|
||||||
|
'file_size': a.file_size,
|
||||||
|
'mime_type': a.mime_type or '',
|
||||||
|
'url': url_for('admin.kb_serve_file', stored_name=a.stored_name),
|
||||||
|
'delete_url': url_for('admin.kb_delete_attachment',
|
||||||
|
article_id=article_id, att_id=a.id),
|
||||||
|
}
|
||||||
|
for a in atts
|
||||||
|
]})
|
||||||
|
|
||||||
# ── Create article ─────────────────────────────────────────────────────────────
|
# ── Create article ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@admin_bp.route('/kb/new', methods=['GET', 'POST'])
|
@admin_bp.route('/kb/new', methods=['GET', 'POST'])
|
||||||
@@ -289,6 +313,7 @@ def kb_delete_attachment(article_id, att_id):
|
|||||||
@it_required
|
@it_required
|
||||||
def kb_new():
|
def kb_new():
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
|
try:
|
||||||
article = KnowledgeBase(
|
article = KnowledgeBase(
|
||||||
title = request.form.get('title', '').strip(),
|
title = request.form.get('title', '').strip(),
|
||||||
body = request.form.get('body', '').strip(),
|
body = request.form.get('body', '').strip(),
|
||||||
@@ -298,7 +323,7 @@ def kb_new():
|
|||||||
is_published= bool(request.form.get('is_published')) and not bool(request.form.get('_save_as_draft')),
|
is_published= bool(request.form.get('is_published')) and not bool(request.form.get('_save_as_draft')),
|
||||||
)
|
)
|
||||||
db.session.add(article)
|
db.session.add(article)
|
||||||
db.session.flush() # get article.id before saving attachments
|
db.session.flush()
|
||||||
|
|
||||||
for f in request.files.getlist('attachments'):
|
for f in request.files.getlist('attachments'):
|
||||||
if f and f.filename and _kb_allowed(f.filename):
|
if f and f.filename and _kb_allowed(f.filename):
|
||||||
@@ -310,10 +335,11 @@ def kb_new():
|
|||||||
f'title={article.title}')
|
f'title={article.title}')
|
||||||
logger.info(f'[KB CREATE] article_id={article.id} by user_id={current_user.id}')
|
logger.info(f'[KB CREATE] article_id={article.id} by user_id={current_user.id}')
|
||||||
flash('Article created successfully.', 'success')
|
flash('Article created successfully.', 'success')
|
||||||
# XHR fetch submit: return JSON so JS can redirect to the final URL
|
return jsonify({'ok': True, 'article_id': article.id, 'redirect': url_for('admin.kb_list')})
|
||||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or 'multipart/form-data' in request.content_type:
|
except Exception as exc:
|
||||||
return jsonify({'redirect': url_for('admin.kb_list')})
|
db.session.rollback()
|
||||||
return redirect(url_for('admin.kb_list'))
|
logger.error(f'[KB CREATE ERROR] {exc}')
|
||||||
|
return jsonify({'error': str(exc)}), 500
|
||||||
return render_template('admin/kb_edit.html', article=None)
|
return render_template('admin/kb_edit.html', article=None)
|
||||||
|
|
||||||
|
|
||||||
@@ -325,6 +351,7 @@ def kb_new():
|
|||||||
def kb_edit(article_id):
|
def kb_edit(article_id):
|
||||||
article = KnowledgeBase.query.get_or_404(article_id)
|
article = KnowledgeBase.query.get_or_404(article_id)
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
|
try:
|
||||||
article.title = request.form.get('title', article.title).strip()
|
article.title = request.form.get('title', article.title).strip()
|
||||||
article.body = request.form.get('body', article.body).strip()
|
article.body = request.form.get('body', article.body).strip()
|
||||||
article.category = request.form.get('category', article.category)
|
article.category = request.form.get('category', article.category)
|
||||||
@@ -340,10 +367,11 @@ def kb_edit(article_id):
|
|||||||
log_action(current_user.id, 'kb_edit', 'knowledge_base', article.id)
|
log_action(current_user.id, 'kb_edit', 'knowledge_base', article.id)
|
||||||
logger.info(f'[KB EDIT] article_id={article.id} by user_id={current_user.id}')
|
logger.info(f'[KB EDIT] article_id={article.id} by user_id={current_user.id}')
|
||||||
flash('Article updated successfully.', 'success')
|
flash('Article updated successfully.', 'success')
|
||||||
# XHR fetch submit: return JSON so JS can redirect to the final URL
|
return jsonify({'ok': True, 'article_id': article.id, 'redirect': url_for('admin.kb_list')})
|
||||||
if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or 'multipart/form-data' in request.content_type:
|
except Exception as exc:
|
||||||
return jsonify({'redirect': url_for('admin.kb_list')})
|
db.session.rollback()
|
||||||
return redirect(url_for('admin.kb_list'))
|
logger.error(f'[KB EDIT ERROR] {exc}')
|
||||||
|
return jsonify({'error': str(exc)}), 500
|
||||||
return render_template('admin/kb_edit.html', article=article)
|
return render_template('admin/kb_edit.html', article=article)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+242
-165
@@ -40,7 +40,9 @@
|
|||||||
{% if article %}Edit: {{ article.title[:50] }}{% else %}Create New Article{% endif %}
|
{% if article %}Edit: {{ article.title[:50] }}{% else %}Create New Article{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="POST" enctype="multipart/form-data" id="kb-form">
|
<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 '' }}">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Title *</label>
|
<label class="form-label">Title *</label>
|
||||||
<input type="text" class="form-control" name="title" required
|
<input type="text" class="form-control" name="title" required
|
||||||
@@ -50,7 +52,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Content *</label>
|
<label class="form-label">Content *</label>
|
||||||
<textarea name="body" id="kb-body" required>{{ article.body if article else '' }}</textarea>
|
<textarea name="body" id="kb-body">{{ article.body if article else '' }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">
|
<label class="form-label">
|
||||||
@@ -79,33 +81,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if article %}
|
{% if article %}
|
||||||
{% set existing = article.attachments.all() %}
|
<!-- Existing attachments — rendered dynamically by JS so they update
|
||||||
{% if existing %}
|
after save/delete without a full page reload -->
|
||||||
<div class="mb-3">
|
<div class="mb-3" id="existing-atts-wrap">
|
||||||
<label class="form-label">Existing Attachments</label>
|
<label class="form-label">Existing Attachments</label>
|
||||||
<div style="display:flex;flex-direction:column;gap:6px;">
|
<div id="existing-atts-list" style="display:flex;flex-direction:column;gap:6px;">
|
||||||
{% for att in existing %}
|
<!-- Populated by renderExistingAtts() on page load and after each save/delete -->
|
||||||
<div class="att-item">
|
|
||||||
<i class="bi bi-{% if att.mime_type and 'image' in att.mime_type %}image{% elif att.mime_type and 'pdf' in att.mime_type %}file-earmark-pdf{% else %}file-earmark{% endif %} att-icon"></i>
|
|
||||||
<span class="att-name">
|
|
||||||
<a href="{{ url_for('admin.kb_serve_file', stored_name=att.stored_name) }}"
|
|
||||||
target="_blank" style="color:var(--accent);">{{ att.filename }}</a>
|
|
||||||
</span>
|
|
||||||
<span class="att-size">{{ (att.file_size / 1024)|int }} KB</span>
|
|
||||||
<form method="POST"
|
|
||||||
action="{{ url_for('admin.kb_delete_attachment', article_id=article.id, att_id=att.id) }}"
|
|
||||||
onsubmit="return confirm('Delete {{ att.filename }}?');" style="margin:0;">
|
|
||||||
<button type="submit" class="btn btn-sm"
|
|
||||||
style="background:none;border:none;color:var(--danger);padding:2px 6px;" title="Delete">
|
|
||||||
<i class="bi bi-trash"></i>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;">
|
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;">
|
||||||
<input type="checkbox" name="is_published"
|
<input type="checkbox" name="is_published"
|
||||||
@@ -199,6 +183,7 @@
|
|||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
|
// ── TinyMCE 6 ─────────────────────────────────────────────────────────────────
|
||||||
tinymce.init({
|
tinymce.init({
|
||||||
selector: '#kb-body',
|
selector: '#kb-body',
|
||||||
height: 520,
|
height: 520,
|
||||||
@@ -257,32 +242,25 @@ tinymce.init({
|
|||||||
.callout-tip { background:#ecfdf5; border:1px solid #a7f3d0; 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; }
|
hr { border:none; border-top:1px solid #e2e8f0; margin:2em 0; }
|
||||||
`,
|
`,
|
||||||
images_upload_url: '/admin/kb/upload-image',
|
images_upload_handler: (blobInfo, progress) => new Promise((resolve, reject) => {
|
||||||
images_upload_handler: async (blobInfo, progress) => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const xhr = new XMLHttpRequest();
|
|
||||||
xhr.open('POST', '/admin/kb/upload-image');
|
|
||||||
xhr.upload.onprogress = (e) => { if (e.lengthComputable) progress(e.loaded / e.total * 100); };
|
|
||||||
xhr.onload = () => {
|
|
||||||
if (xhr.status !== 200) { reject({ message: `Upload failed (HTTP ${xhr.status})`, remove: true }); return; }
|
|
||||||
try {
|
|
||||||
const json = JSON.parse(xhr.responseText);
|
|
||||||
if (json.location) resolve(json.location);
|
|
||||||
else reject({ message: json.error || 'Upload failed', remove: true });
|
|
||||||
} catch { reject({ message: 'Invalid server response', remove: true }); }
|
|
||||||
};
|
|
||||||
xhr.onerror = () => reject({ message: 'Network error during upload', remove: true });
|
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('file', blobInfo.blob(), blobInfo.filename());
|
fd.append('file', blobInfo.blob(), blobInfo.filename());
|
||||||
xhr.send(fd);
|
fetch('/admin/kb/upload-image', { method: 'POST', credentials: 'same-origin', 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,
|
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_advtab: true,
|
||||||
image_caption: true,
|
image_caption: true,
|
||||||
table_default_attributes: { border: '0' },
|
table_default_attributes: { border: '0' },
|
||||||
table_default_styles: { 'border-collapse': 'collapse', width: '100%' },
|
table_default_styles: { 'border-collapse': 'collapse', width: '100%' },
|
||||||
table_responsive_width: true,
|
|
||||||
link_default_target: '_blank',
|
link_default_target: '_blank',
|
||||||
link_assume_external_targets: true,
|
link_assume_external_targets: true,
|
||||||
codesample_languages: [
|
codesample_languages: [
|
||||||
@@ -295,117 +273,181 @@ tinymce.init({
|
|||||||
{ text: 'PowerShell', value: 'powershell' },
|
{ text: 'PowerShell', value: 'powershell' },
|
||||||
{ text: 'Plain text', value: 'none' },
|
{ text: 'Plain text', value: 'none' },
|
||||||
],
|
],
|
||||||
setup: (editor) => { editor.on('change', () => editor.save()); },
|
setup: editor => {
|
||||||
|
editor.on('change keyup undo redo', () => editor.save());
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function saveDraft() {
|
// ── Article state ─────────────────────────────────────────────────────────────
|
||||||
tinymce.triggerSave();
|
const form = document.getElementById('kb-form');
|
||||||
// Temporarily uncheck is_published, submit, then immediately restore so the
|
// article_id is '' for new articles, a number string for existing ones
|
||||||
// checkbox state is not left unchecked if the user stays on the page.
|
let articleId = form.dataset.articleId || '';
|
||||||
const cb = document.querySelector('input[name="is_published"]');
|
let attsUrl = form.dataset.attsUrl || '';
|
||||||
const wasChecked = cb ? cb.checked : false;
|
|
||||||
if (cb) cb.checked = false;
|
|
||||||
// Use a hidden input to explicitly send is_published=0 so the backend
|
|
||||||
// receives a definitive falsy value regardless of checkbox behaviour.
|
|
||||||
let hidden = document.getElementById('_draft_flag');
|
|
||||||
if (!hidden) {
|
|
||||||
hidden = document.createElement('input');
|
|
||||||
hidden.type = 'hidden';
|
|
||||||
hidden.id = '_draft_flag';
|
|
||||||
hidden.name = '_save_as_draft';
|
|
||||||
hidden.value = '1';
|
|
||||||
document.getElementById('kb-form').appendChild(hidden);
|
|
||||||
}
|
|
||||||
document.getElementById('kb-form').submit();
|
|
||||||
// Restore (runs if submit is cancelled by browser validation)
|
|
||||||
if (cb) cb.checked = wasChecked;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── File staging ─────────────────────────────────────────────────────────────
|
|
||||||
// Root cause of the multi-file bug: assigning to input.files via DataTransfer
|
|
||||||
// is unreliable across browsers (read-only in Firefox/Safari).
|
|
||||||
// Fix: keep ALL staged files only in the DataTransfer object; on submit build
|
|
||||||
// a FormData manually and POST via fetch — bypassing input.files entirely.
|
|
||||||
|
|
||||||
|
// ── File staging (pending new uploads) ───────────────────────────────────────
|
||||||
const dropZone = document.getElementById('drop-zone');
|
const dropZone = document.getElementById('drop-zone');
|
||||||
const attInput = document.getElementById('att-input');
|
const attInput = document.getElementById('att-input');
|
||||||
const fileList = document.getElementById('file-list');
|
const fileList = document.getElementById('file-list');
|
||||||
let stagedFiles = new DataTransfer();
|
let pendingFiles = [];
|
||||||
|
|
||||||
function renderFileList() {
|
function renderPendingList() {
|
||||||
fileList.innerHTML = '';
|
fileList.innerHTML = '';
|
||||||
const files = stagedFiles.files;
|
pendingFiles.forEach((f, i) => {
|
||||||
for (let i = 0; i < files.length; i++) {
|
|
||||||
const f = files[i];
|
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'pending-file';
|
div.className = 'pending-file';
|
||||||
div.innerHTML = `<i class="bi bi-${getFileIcon(f.name)}" style="color:var(--accent);font-size:16px;flex-shrink:0;"></i>
|
div.innerHTML =
|
||||||
<span class="pf-name">${escHtml(f.name)}</span>
|
'<i class="bi bi-' + getFileIcon(f.name) + '" style="color:var(--accent);font-size:16px;flex-shrink:0;"></i>' +
|
||||||
<span class="pf-size">${formatSize(f.size)}</span>
|
'<span class="pf-name">' + escHtml(f.name) + '</span>' +
|
||||||
<button type="button" class="pf-remove" onclick="removeFile(${i})" title="Remove">×</button>`;
|
'<span class="pf-size">' + formatSize(f.size) + '</span>' +
|
||||||
|
'<button type="button" class="pf-remove" onclick="removePending(' + i + ')" title="Remove">×</button>';
|
||||||
fileList.appendChild(div);
|
fileList.appendChild(div);
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeFile(idx) {
|
function removePending(idx) { pendingFiles.splice(idx, 1); renderPendingList(); }
|
||||||
const newDt = new DataTransfer();
|
|
||||||
const files = stagedFiles.files;
|
|
||||||
for (let i = 0; i < files.length; i++) { if (i !== idx) newDt.items.add(files[i]); }
|
|
||||||
stagedFiles = newDt;
|
|
||||||
renderFileList();
|
|
||||||
}
|
|
||||||
|
|
||||||
function addFiles(newFiles) {
|
function addFiles(list) {
|
||||||
for (const f of newFiles) {
|
Array.from(list).forEach(f => {
|
||||||
if (f.size > 16 * 1024 * 1024) { alert(`"${f.name}" exceeds the 16 MB limit and was skipped.`); continue; }
|
if (f.size > 16 * 1024 * 1024) { alert('"' + f.name + '" exceeds 16 MB and was skipped.'); return; }
|
||||||
stagedFiles.items.add(f);
|
if (!pendingFiles.some(p => p.name === f.name && p.size === f.size)) pendingFiles.push(f);
|
||||||
}
|
});
|
||||||
// Reset the picker so the same file can be picked again if needed
|
|
||||||
attInput.value = '';
|
attInput.value = '';
|
||||||
renderFileList();
|
renderPendingList();
|
||||||
}
|
}
|
||||||
|
|
||||||
attInput.addEventListener('change', () => addFiles(attInput.files));
|
attInput.addEventListener('change', () => addFiles(attInput.files));
|
||||||
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('dragover'); });
|
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); });
|
||||||
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
|
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
|
||||||
dropZone.addEventListener('drop', (e) => { e.preventDefault(); dropZone.classList.remove('dragover'); addFiles(e.dataTransfer.files); });
|
dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('dragover'); addFiles(e.dataTransfer.files); });
|
||||||
|
|
||||||
// ── Form submit via fetch (fixes multi-file upload) ───────────────────────────
|
// ── Existing attachments: render + async delete ───────────────────────────────
|
||||||
document.getElementById('kb-form').addEventListener('submit', async function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// Sync TinyMCE content into the hidden textarea before building FormData
|
function attFileIcon(mimeType) {
|
||||||
tinymce.triggerSave();
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
const form = this;
|
function renderExistingAtts(atts) {
|
||||||
const saveBtn = document.getElementById('save-btn');
|
const wrap = document.getElementById('existing-atts-wrap');
|
||||||
const origTxt = saveBtn.innerHTML;
|
const list = document.getElementById('existing-atts-list');
|
||||||
saveBtn.disabled = true;
|
if (!wrap || !list) return;
|
||||||
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Saving…';
|
|
||||||
|
|
||||||
// Build FormData from the form fields
|
if (!atts || atts.length === 0) {
|
||||||
const fd = new FormData(form);
|
wrap.style.display = 'none';
|
||||||
|
return;
|
||||||
// Append every staged file under the key "attachments"
|
|
||||||
// This is the reliable path — FormData.append() bypasses input.files entirely
|
|
||||||
const staged = stagedFiles.files;
|
|
||||||
for (let i = 0; i < staged.length; i++) {
|
|
||||||
fd.append('attachments', staged[i], staged[i].name);
|
|
||||||
}
|
}
|
||||||
|
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) {
|
||||||
|
if (!confirm('Delete "' + filename + '"?')) return;
|
||||||
|
|
||||||
|
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' })
|
||||||
|
.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';
|
||||||
|
|
||||||
// Show progress bar
|
|
||||||
const progressWrap = document.getElementById('upload-progress');
|
const progressWrap = document.getElementById('upload-progress');
|
||||||
const progressBar = document.getElementById('upload-bar');
|
const progressBar = document.getElementById('upload-bar');
|
||||||
const progressPct = document.getElementById('upload-pct');
|
const progressPct = document.getElementById('upload-pct');
|
||||||
if (staged.length > 0) progressWrap.style.display = 'block';
|
|
||||||
|
|
||||||
try {
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open('POST', form.action || window.location.href);
|
xhr.open('POST', window.location.pathname);
|
||||||
|
xhr.withCredentials = true;
|
||||||
|
|
||||||
xhr.upload.onprogress = (ev) => {
|
xhr.upload.onprogress = ev => {
|
||||||
if (ev.lengthComputable) {
|
if (ev.lengthComputable && pendingFiles.length > 0) {
|
||||||
|
progressWrap.style.display = 'block';
|
||||||
const pct = Math.round(ev.loaded / ev.total * 100);
|
const pct = Math.round(ev.loaded / ev.total * 100);
|
||||||
progressBar.style.width = pct + '%';
|
progressBar.style.width = pct + '%';
|
||||||
progressPct.textContent = pct + '%';
|
progressPct.textContent = pct + '%';
|
||||||
@@ -413,63 +455,98 @@ document.getElementById('kb-form').addEventListener('submit', async function (e)
|
|||||||
};
|
};
|
||||||
|
|
||||||
xhr.onload = () => {
|
xhr.onload = () => {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = origTxt;
|
||||||
|
progressWrap.style.display = 'none';
|
||||||
|
progressBar.style.width = '0%';
|
||||||
|
|
||||||
if (xhr.status >= 200 && xhr.status < 400) {
|
if (xhr.status >= 200 && xhr.status < 400) {
|
||||||
try {
|
try {
|
||||||
// Backend returns {redirect: "/admin/kb"} for fetch submissions
|
|
||||||
const json = JSON.parse(xhr.responseText);
|
const json = JSON.parse(xhr.responseText);
|
||||||
if (json.redirect) { resolve(json.redirect); return; }
|
if (json.error) { alert('Server error: ' + json.error); return; }
|
||||||
} catch (_) { /* not JSON — fall through */ }
|
|
||||||
// Fallback: use the final response URL (after any server-side redirect)
|
// ── Issue 3 fix: stay on the page ─────────────────────────────────
|
||||||
resolve(xhr.responseURL || window.location.href);
|
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 {
|
} else {
|
||||||
reject(new Error(`Server returned ${xhr.status}`));
|
alert('Save failed (HTTP ' + xhr.status + '). Please try again.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
xhr.onerror = () => reject(new Error('Network error'));
|
|
||||||
|
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);
|
xhr.send(fd);
|
||||||
|
|
||||||
// Store responseURL for redirect after onload
|
|
||||||
xhr._resolve = resolve;
|
|
||||||
}).then((redirectUrl) => {
|
|
||||||
window.location.href = redirectUrl;
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
progressWrap.style.display = 'none';
|
|
||||||
saveBtn.disabled = false;
|
|
||||||
saveBtn.innerHTML = origTxt;
|
|
||||||
alert('Upload failed: ' + err.message + '\nPlease try again.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// saveDraft submits via the same fetch path — just flips the checkbox first
|
|
||||||
function saveDraft() {
|
|
||||||
tinymce.triggerSave();
|
|
||||||
const cb = document.querySelector('input[name="is_published"]');
|
|
||||||
const wasChecked = cb ? cb.checked : false;
|
|
||||||
if (cb) cb.checked = false;
|
|
||||||
let hidden = document.getElementById('_draft_flag');
|
|
||||||
if (!hidden) {
|
|
||||||
hidden = document.createElement('input');
|
|
||||||
hidden.type = 'hidden';
|
|
||||||
hidden.id = '_draft_flag';
|
|
||||||
hidden.name = '_save_as_draft';
|
|
||||||
hidden.value = '1';
|
|
||||||
document.getElementById('kb-form').appendChild(hidden);
|
|
||||||
}
|
|
||||||
// Trigger the fetch-based submit handler
|
|
||||||
document.getElementById('kb-form').dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
|
||||||
if (cb) cb.checked = wasChecked;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSize(b) { if(b<1024) return b+' B'; if(b<1048576) return (b/1024).toFixed(1)+' KB'; return (b/1048576).toFixed(1)+' MB'; }
|
// 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 escHtml(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||||
function getFileIcon(n) {
|
function getFileIcon(n) {
|
||||||
const e = n.split('.').pop().toLowerCase();
|
const e = n.split('.').pop().toLowerCase();
|
||||||
return {pdf:'file-earmark-pdf',png:'image',jpg:'image',jpeg:'image',gif:'image',webp:'image',
|
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',
|
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',
|
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';
|
csv:'file-earmark-spreadsheet',txt:'file-earmark-text',log:'file-earmark-text'}[e]||'file-earmark';
|
||||||
}
|
}
|
||||||
</script>
|
</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 %}
|
{% endblock %}
|
||||||
+9
-1
@@ -31,7 +31,15 @@ class Config:
|
|||||||
APP_BASE_URL = os.environ.get('APP_BASE_URL', 'http://localhost:5000')
|
APP_BASE_URL = os.environ.get('APP_BASE_URL', 'http://localhost:5000')
|
||||||
|
|
||||||
# Uploads
|
# Uploads
|
||||||
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', 'app/static/uploads')
|
# UPLOAD_FOLDER must be an absolute path so that save (os.path.join) and
|
||||||
|
# serve (send_from_directory) always resolve to the same directory regardless
|
||||||
|
# of the process CWD. The default anchors to the project root via __file__.
|
||||||
|
_project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
_raw_upload = os.environ.get('UPLOAD_FOLDER', '')
|
||||||
|
UPLOAD_FOLDER = (
|
||||||
|
_raw_upload if (_raw_upload and os.path.isabs(_raw_upload))
|
||||||
|
else os.path.join(_project_root, 'app', 'static', 'uploads')
|
||||||
|
)
|
||||||
MAX_CONTENT_LENGTH = int(os.environ.get('MAX_CONTENT_LENGTH', 16 * 1024 * 1024))
|
MAX_CONTENT_LENGTH = int(os.environ.get('MAX_CONTENT_LENGTH', 16 * 1024 * 1024))
|
||||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf', 'doc', 'docx', 'txt', 'zip', 'log'}
|
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf', 'doc', 'docx', 'txt', 'zip', 'log'}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user