Enhance Article editor

This commit is contained in:
2026-03-25 12:45:07 -04:00
parent dfdbb54875
commit 3ba7eae390
5 changed files with 677 additions and 53 deletions
+24
View File
@@ -262,3 +262,27 @@ class KnowledgeBase(db.Model):
def __repr__(self): def __repr__(self):
return f'<KnowledgeBase {self.title}>' return f'<KnowledgeBase {self.title}>'
class KBAttachment(db.Model):
"""Files and images attached to a Knowledge Base article."""
__tablename__ = 'kb_attachments'
id = db.Column(db.Integer, primary_key=True)
article_id = db.Column(db.Integer, db.ForeignKey('knowledge_base.id'), nullable=False)
filename = db.Column(db.String(256), nullable=False) # original name
stored_name = db.Column(db.String(256), nullable=False) # UUID-based on disk
mime_type = db.Column(db.String(100))
file_size = db.Column(db.Integer)
uploaded_by = db.Column(db.Integer, db.ForeignKey('users.id'))
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
article = db.relationship('KnowledgeBase', backref=db.backref('attachments', lazy='dynamic', cascade='all, delete-orphan'))
uploader = db.relationship('User', foreign_keys=[uploaded_by])
@property
def url(self):
return f'/admin/kb/files/{self.stored_name}'
def __repr__(self):
return f'<KBAttachment {self.filename}>'
+134 -6
View File
@@ -1,10 +1,13 @@
import logging import logging
import os
import uuid
from functools import wraps from functools import wraps
from flask import Blueprint, render_template, redirect, url_for, flash, request, abort from flask import Blueprint, render_template, redirect, url_for, flash, request, abort, jsonify, current_app, send_from_directory
from flask_login import login_required, current_user from flask_login import login_required, current_user
from werkzeug.utils import secure_filename
from app import db from app import db
from app.models import (User, Ticket, Comment, ActivityLog, KnowledgeBase, from app.models import (User, Ticket, Comment, ActivityLog, KnowledgeBase,
UserRole, TicketStatus) KBAttachment, UserRole, TicketStatus)
from app.services.log_service import log_action from app.services.log_service import log_action
admin_bp = Blueprint('admin', __name__, url_prefix='/admin') admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
@@ -193,6 +196,94 @@ def kb_list():
return render_template('admin/kb_list.html', articles=articles) return render_template('admin/kb_list.html', articles=articles)
# ── Helpers ──────────────────────────────────────────────────────────────────
_KB_ALLOWED_EXT = {
'png', 'jpg', 'jpeg', 'gif', 'webp', # images
'pdf', 'doc', 'docx', 'xls', 'xlsx', # documents
'ppt', 'pptx', 'txt', 'csv', 'zip', 'log', # misc
}
def _kb_allowed(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in _KB_ALLOWED_EXT
def _save_kb_file(file, article_id):
"""Save an uploaded file and return a KBAttachment (not yet committed)."""
filename = secure_filename(file.filename)
ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else 'bin'
stored_name = f"{uuid.uuid4().hex}.{ext}"
upload_dir = current_app.config.get('UPLOAD_FOLDER', 'app/static/uploads')
os.makedirs(upload_dir, exist_ok=True)
filepath = os.path.join(upload_dir, stored_name)
file.save(filepath)
return KBAttachment(
article_id = article_id,
filename = filename,
stored_name = stored_name,
mime_type = file.content_type,
file_size = os.path.getsize(filepath),
uploaded_by = current_user.id,
)
# ── TinyMCE image upload endpoint ─────────────────────────────────────────────
@admin_bp.route('/kb/upload-image', methods=['POST'])
@login_required
@it_required
def kb_upload_image():
"""
TinyMCE image upload handler.
Returns JSON: {"location": "<url>"} on success.
"""
f = request.files.get('file')
if not f or not f.filename:
return jsonify({'error': 'No file provided'}), 400
ext = f.filename.rsplit('.', 1)[-1].lower() if '.' in f.filename else ''
if ext not in {'png', 'jpg', 'jpeg', 'gif', 'webp'}:
return jsonify({'error': 'Only image files are accepted (PNG, JPG, GIF, WEBP)'}), 400
stored_name = f"{uuid.uuid4().hex}.{ext}"
upload_dir = current_app.config.get('UPLOAD_FOLDER', 'app/static/uploads')
os.makedirs(upload_dir, exist_ok=True)
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}')
return jsonify({'location': f'/admin/kb/files/{stored_name}'})
# ── File-serve route (images embedded in articles + attachment downloads) ─────
@admin_bp.route('/kb/files/<path:stored_name>')
@login_required
def kb_serve_file(stored_name):
"""Serve a KB attachment file. Login required — no public access."""
upload_dir = current_app.config.get('UPLOAD_FOLDER', 'app/static/uploads')
return send_from_directory(upload_dir, stored_name)
# ── Delete a single KB attachment ─────────────────────────────────────────────
@admin_bp.route('/kb/<int:article_id>/attachments/<int:att_id>/delete', methods=['POST'])
@login_required
@it_required
def kb_delete_attachment(article_id, att_id):
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')
filepath = os.path.join(upload_dir, att.stored_name)
if os.path.exists(filepath):
os.remove(filepath)
log_action(current_user.id, 'kb_attachment_delete', 'kb_attachment', att.id,
f'article_id={article_id} filename={att.filename}')
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.commit()
flash(f'Attachment "{att.filename}" deleted.', 'success')
return redirect(url_for('admin.kb_edit', article_id=article_id))
# ── Create article ─────────────────────────────────────────────────────────────
@admin_bp.route('/kb/new', methods=['GET', 'POST']) @admin_bp.route('/kb/new', methods=['GET', 'POST'])
@login_required @login_required
@it_required @it_required
@@ -204,18 +295,30 @@ def kb_new():
category = request.form.get('category', ''), category = request.form.get('category', ''),
tags = request.form.get('tags', ''), tags = request.form.get('tags', ''),
author_id = current_user.id, author_id = current_user.id,
is_published= bool(request.form.get('is_published')), 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
for f in request.files.getlist('attachments'):
if f and f.filename and _kb_allowed(f.filename):
att = _save_kb_file(f, article.id)
db.session.add(att)
db.session.commit() db.session.commit()
log_action(current_user.id, 'kb_create', 'knowledge_base', article.id, log_action(current_user.id, 'kb_create', 'knowledge_base', article.id,
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.', 'success') flash('Article created successfully.', 'success')
# XHR fetch submit: return JSON so JS can redirect to the final URL
if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or 'multipart/form-data' in request.content_type:
return jsonify({'redirect': url_for('admin.kb_list')})
return redirect(url_for('admin.kb_list')) return redirect(url_for('admin.kb_list'))
return render_template('admin/kb_edit.html', article=None) return render_template('admin/kb_edit.html', article=None)
# ── Edit article ───────────────────────────────────────────────────────────────
@admin_bp.route('/kb/<int:article_id>/edit', methods=['GET', 'POST']) @admin_bp.route('/kb/<int:article_id>/edit', methods=['GET', 'POST'])
@login_required @login_required
@it_required @it_required
@@ -226,15 +329,40 @@ def kb_edit(article_id):
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)
article.tags = request.form.get('tags', article.tags) article.tags = request.form.get('tags', article.tags)
article.is_published= bool(request.form.get('is_published')) article.is_published= bool(request.form.get('is_published')) and not bool(request.form.get('_save_as_draft'))
for f in request.files.getlist('attachments'):
if f and f.filename and _kb_allowed(f.filename):
att = _save_kb_file(f, article.id)
db.session.add(att)
db.session.commit() db.session.commit()
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.', 'success') flash('Article updated successfully.', 'success')
# XHR fetch submit: return JSON so JS can redirect to the final URL
if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or 'multipart/form-data' in request.content_type:
return jsonify({'redirect': url_for('admin.kb_list')})
return redirect(url_for('admin.kb_list')) return redirect(url_for('admin.kb_list'))
return render_template('admin/kb_edit.html', article=article) return render_template('admin/kb_edit.html', article=article)
@admin_bp.route('/kb/<int:article_id>/publish', methods=['POST'])
@login_required
@it_required
def kb_toggle_publish(article_id):
"""Quick publish/unpublish toggle — callable from the article list."""
article = KnowledgeBase.query.get_or_404(article_id)
article.is_published = not article.is_published
db.session.commit()
state = 'published' if article.is_published else 'unpublished'
log_action(current_user.id, f'kb_{state}', 'knowledge_base', article.id,
f'title={article.title}')
logger.info(f'[KB TOGGLE PUBLISH] article_id={article.id} is_published={article.is_published} by user_id={current_user.id}')
flash(f'Article "{article.title}" has been {state}.', 'success')
return redirect(url_for('admin.kb_list'))
@admin_bp.route('/kb/<int:article_id>/delete', methods=['POST']) @admin_bp.route('/kb/<int:article_id>/delete', methods=['POST'])
@login_required @login_required
@it_required @it_required
+453 -42
View File
@@ -2,63 +2,474 @@
{% block title %}{% if article %}Edit Article{% else %}New Article{% endif %}{% endblock %} {% 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 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 %} {% block content %}
<div class="row justify-content-center"> <div class="row g-4">
<div class="col-lg-8"> <div class="col-lg-8">
<div class="mb-3"><a href="{{ url_for('admin.kb_list') }}" style="font-size:13px;color:var(--accent3);">← Back to Knowledge Base</a></div> <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">
<div class="card-header"> <div class="card-header">
<i class="bi bi-{% if article %}pencil{% else %}plus-circle{% endif %} me-2"></i> <i class="bi bi-{% if article %}pencil{% else %}file-earmark-plus{% endif %} me-2"></i>
{% if article %}Edit: {{ article.title[:40] }}{% 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"> <form method="POST" enctype="multipart/form-data" id="kb-form">
<div class="row g-3"> <div class="mb-3">
<div class="col-12"> <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 value="{{ article.title if article else '' }}"
value="{{ article.title if article else '' }}" placeholder="e.g. How to connect to the corporate VPN"
placeholder="e.g. How to connect to the 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" required>{{ 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 &amp; drop files here</p>
<p style="font-size:11px;margin-top:4px;">Max 16 MB per file</p>
</div> </div>
<div class="col-md-6"> <!-- Single picker — only used to trigger the OS dialog.
<label class="form-label">Category</label> Actual files are held in the JS stagedFiles DataTransfer object
<select class="form-select" name="category"> and injected into FormData on submit (fixes multi-file browser bug). -->
<option value="">— Select Category —</option> <input type="file" id="att-input" multiple hidden
{% for cat in ['hardware','software','network','access','email','printer','phone','security','other'] %} accept=".png,.jpg,.jpeg,.gif,.webp,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.zip,.log"/>
<option value="{{ cat }}" {% if article and article.category == cat %}selected{% endif %}> <div id="file-list"></div>
{{ cat.replace('_',' ').title() }} <!-- Progress bar shown during fetch upload -->
</option> <div id="upload-progress" style="display:none;margin-top:10px;">
{% endfor %} <div style="display:flex;align-items:center;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:4px;">
</select> <span>Uploading…</span><span id="upload-pct">0%</span>
</div> </div>
<div class="col-md-6"> <div style="height:5px;background:var(--border);border-radius:3px;">
<label class="form-label">Tags <span style="color:var(--muted);font-weight:400;">(comma-separated)</span></label> <div id="upload-bar" style="height:100%;background:var(--accent);border-radius:3px;width:0%;transition:width .2s;"></div>
<input type="text" class="form-control" name="tags" </div>
value="{{ article.tags if article else '' }}"
placeholder="vpn, remote, access"/>
</div>
<div class="col-12">
<label class="form-label">Content *</label>
<textarea class="form-control" name="body" rows="16" required
placeholder="Write the article content here. You can use plain text with line breaks.">{{ article.body if article else '' }}</textarea>
</div>
<div class="col-12">
<label style="display:flex;align-items:center;gap:10px;cursor:pointer;">
<input type="checkbox" name="is_published" style="accent-color:var(--accent3);width:16px;height:16px;"
{% if not article or article.is_published %}checked{% endif %}/>
<span style="font-size:14px;">Publish immediately (visible to all employees)</span>
</label>
</div> </div>
</div> </div>
<div class="d-flex gap-2 mt-4"> {% if article %}
<button type="submit" class="btn btn-primary"> {% set existing = article.attachments.all() %}
{% if existing %}
<div class="mb-3">
<label class="form-label">Existing Attachments</label>
<div style="display:flex;flex-direction:column;gap:6px;">
{% for att in existing %}
<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>
{% endif %}
{% 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 %} <i class="bi bi-check2 me-2"></i>{% if article %}Save Changes{% else %}Publish Article{% endif %}
</button> </button>
<a href="{{ url_for('admin.kb_list') }}" class="btn btn-secondary">Cancel</a> <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> </div>
</form> </form>
</div> </div>
</div> </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 &amp; 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> </div>
{% endblock %} {% endblock %}
{% block scripts %}
<script>
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_url: '/admin/kb/upload-image',
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();
fd.append('file', blobInfo.blob(), blobInfo.filename());
xhr.send(fd);
});
},
paste_data_images: true,
image_advtab: true,
image_caption: true,
table_default_attributes: { border: '0' },
table_default_styles: { 'border-collapse': 'collapse', width: '100%' },
table_responsive_width: true,
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', () => editor.save()); },
});
function saveDraft() {
tinymce.triggerSave();
// Temporarily uncheck is_published, submit, then immediately restore so the
// checkbox state is not left unchecked if the user stays on the page.
const cb = document.querySelector('input[name="is_published"]');
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.
const dropZone = document.getElementById('drop-zone');
const attInput = document.getElementById('att-input');
const fileList = document.getElementById('file-list');
let stagedFiles = new DataTransfer();
function renderFileList() {
fileList.innerHTML = '';
const files = stagedFiles.files;
for (let i = 0; i < files.length; i++) {
const f = files[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="removeFile(${i})" title="Remove">&times;</button>`;
fileList.appendChild(div);
}
}
function removeFile(idx) {
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) {
for (const f of newFiles) {
if (f.size > 16 * 1024 * 1024) { alert(`"${f.name}" exceeds the 16 MB limit and was skipped.`); continue; }
stagedFiles.items.add(f);
}
// Reset the picker so the same file can be picked again if needed
attInput.value = '';
renderFileList();
}
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); });
// ── Form submit via fetch (fixes multi-file upload) ───────────────────────────
document.getElementById('kb-form').addEventListener('submit', async function (e) {
e.preventDefault();
// Sync TinyMCE content into the hidden textarea before building FormData
tinymce.triggerSave();
const form = this;
const saveBtn = document.getElementById('save-btn');
const origTxt = saveBtn.innerHTML;
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Saving…';
// Build FormData from the form fields
const fd = new FormData(form);
// 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);
}
// Show progress bar
const progressWrap = document.getElementById('upload-progress');
const progressBar = document.getElementById('upload-bar');
const progressPct = document.getElementById('upload-pct');
if (staged.length > 0) progressWrap.style.display = 'block';
try {
await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', form.action || window.location.href);
xhr.upload.onprogress = (ev) => {
if (ev.lengthComputable) {
const pct = Math.round(ev.loaded / ev.total * 100);
progressBar.style.width = pct + '%';
progressPct.textContent = pct + '%';
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 400) {
try {
// Backend returns {redirect: "/admin/kb"} for fetch submissions
const json = JSON.parse(xhr.responseText);
if (json.redirect) { resolve(json.redirect); return; }
} catch (_) { /* not JSON — fall through */ }
// Fallback: use the final response URL (after any server-side redirect)
resolve(xhr.responseURL || window.location.href);
} else {
reject(new Error(`Server returned ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error('Network error'));
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'; }
function escHtml(s) { return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
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>
{% endblock %}
+18 -2
View File
@@ -32,10 +32,26 @@
<td style="font-size:12px;color:var(--muted);">{{ art.view_count }}</td> <td style="font-size:12px;color:var(--muted);">{{ art.view_count }}</td>
<td style="font-size:12px;color:var(--muted);">{{ art.updated_at.strftime('%b %d, %Y') }}</td> <td style="font-size:12px;color:var(--muted);">{{ art.updated_at.strftime('%b %d, %Y') }}</td>
<td> <td>
<div class="d-flex gap-1"> <div class="d-flex gap-1 align-items-center">
<a href="{{ url_for('tickets.kb_article', article_id=art.id) }}" class="btn btn-secondary btn-sm" title="Preview"><i class="bi bi-eye"></i></a> <a href="{{ url_for('tickets.kb_article', article_id=art.id) }}" class="btn btn-secondary btn-sm" title="Preview"><i class="bi bi-eye"></i></a>
<a href="{{ url_for('admin.kb_edit', article_id=art.id) }}" class="btn btn-secondary btn-sm" title="Edit"><i class="bi bi-pencil"></i></a> <a href="{{ url_for('admin.kb_edit', article_id=art.id) }}" class="btn btn-secondary btn-sm" title="Edit"><i class="bi bi-pencil"></i></a>
<form method="POST" action="{{ url_for('admin.kb_delete', article_id=art.id) }}" onsubmit="return confirm('Delete this article?');"> <!-- Publish / Unpublish quick toggle -->
<form method="POST" action="{{ url_for('admin.kb_toggle_publish', article_id=art.id) }}" style="margin:0;">
{% if art.is_published %}
<button type="submit" class="btn btn-sm"
style="background:rgba(251,191,36,.1);border:1px solid rgba(251,191,36,.3);color:var(--warning);"
title="Unpublish (move to draft)">
<i class="bi bi-eye-slash"></i>
</button>
{% else %}
<button type="submit" class="btn btn-sm"
style="background:rgba(5,150,105,.1);border:1px solid rgba(5,150,105,.3);color:var(--success);"
title="Publish now">
<i class="bi bi-send-check"></i> Publish
</button>
{% endif %}
</form>
<form method="POST" action="{{ url_for('admin.kb_delete', article_id=art.id) }}" onsubmit="return confirm('Delete this article?');" style="margin:0;">
<button type="submit" class="btn btn-sm" style="background:rgba(248,113,113,.1);border:1px solid rgba(248,113,113,.2);color:var(--danger);" title="Delete"><i class="bi bi-trash"></i></button> <button type="submit" class="btn btn-sm" style="background:rgba(248,113,113,.1);border:1px solid rgba(248,113,113,.2);color:var(--danger);" title="Delete"><i class="bi bi-trash"></i></button>
</form> </form>
</div> </div>
+46 -1
View File
@@ -34,7 +34,52 @@
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
<div style="white-space:pre-wrap;font-size:14px;line-height:1.8;color:var(--text);">{{ article.body }}</div> <!-- Render rich HTML saved by TinyMCE -->
<style>
.kb-body { font-size:14px; line-height:1.8; color:var(--text); }
.kb-body h1,.kb-body h2,.kb-body h3,.kb-body h4 { font-weight:700; margin:1.4em 0 .5em; color:var(--text); }
.kb-body h1{font-size:1.55em;} .kb-body h2{font-size:1.3em;} .kb-body h3{font-size:1.12em;}
.kb-body p { margin:0 0 1em; }
.kb-body a { color:var(--accent); }
.kb-body code { background:var(--surface2); border:1px solid var(--border); border-radius:4px; padding:1px 5px; font-size:.88em; font-family:'Space Mono',monospace; }
.kb-body pre { background:#1e293b; color:#e2e8f0; padding:14px 18px; border-radius:8px; overflow-x:auto; margin:1em 0; }
.kb-body pre code { background:none; border:none; padding:0; color:inherit; }
.kb-body table { border-collapse:collapse; width:100%; margin:1em 0; }
.kb-body table th { background:var(--surface2); font-weight:600; }
.kb-body table th,.kb-body table td { border:1px solid var(--border); padding:8px 12px; font-size:13px; }
.kb-body table tr:nth-child(even) td { background:var(--bg); }
.kb-body img { max-width:100%; height:auto; border-radius:6px; margin:4px 0; }
.kb-body blockquote { border-left:4px solid var(--accent); margin:1em 0; padding:10px 16px; background:var(--info-bg); border-radius:0 6px 6px 0; color:var(--info); }
.kb-body ul,.kb-body ol { padding-left:24px; margin:0 0 1em; }
.kb-body li { margin-bottom:.3em; }
.kb-body hr { border:none; border-top:1px solid var(--border); margin:2em 0; }
.kb-body .callout-info { background:var(--info-bg); border:1px solid #bfdbfe; border-radius:8px; padding:14px 16px; margin:1em 0; }
.kb-body .callout-warning { background:var(--warning-bg); border:1px solid #fde68a; border-radius:8px; padding:14px 16px; margin:1em 0; }
.kb-body .callout-tip { background:var(--success-bg); border:1px solid #a7f3d0; border-radius:8px; padding:14px 16px; margin:1em 0; }
</style>
<div class="kb-body">{{ article.body | safe }}</div>
{% set existing_atts = article.attachments.all() %}
{% if existing_atts %}
<div style="margin-top:24px;padding-top:16px;border-top:1px solid var(--border);">
<div style="font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.8px;color:var(--muted);margin-bottom:10px;">
<i class="bi bi-paperclip me-1"></i>Attachments
</div>
<div style="display:flex;flex-direction:column;gap:6px;">
{% for att in existing_atts %}
<a href="{{ url_for('admin.kb_serve_file', stored_name=att.stored_name) }}"
target="_blank"
style="display:flex;align-items:center;gap:10px;padding:9px 14px;background:var(--surface2);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;">
<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 %}"
style="color:var(--accent);font-size:16px;flex-shrink:0;"></i>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ att.filename }}</span>
<span style="font-size:11px;color:var(--muted);font-family:'Space Mono',monospace;">{{ (att.file_size / 1024)|int }} KB</span>
<i class="bi bi-download" style="color:var(--muted);font-size:13px;"></i>
</a>
{% endfor %}
</div>
</div>
{% endif %}
</div> </div>
</div> </div>
<div class="card mt-3"> <div class="card mt-3">