Enhance Article editor
This commit is contained in:
@@ -262,3 +262,27 @@ class KnowledgeBase(db.Model):
|
||||
|
||||
def __repr__(self):
|
||||
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
@@ -1,10 +1,13 @@
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
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 werkzeug.utils import secure_filename
|
||||
from app import db
|
||||
from app.models import (User, Ticket, Comment, ActivityLog, KnowledgeBase,
|
||||
UserRole, TicketStatus)
|
||||
KBAttachment, UserRole, TicketStatus)
|
||||
from app.services.log_service import log_action
|
||||
|
||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
@@ -193,6 +196,94 @@ def kb_list():
|
||||
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'])
|
||||
@login_required
|
||||
@it_required
|
||||
@@ -204,18 +295,30 @@ def kb_new():
|
||||
category = request.form.get('category', ''),
|
||||
tags = request.form.get('tags', ''),
|
||||
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.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()
|
||||
log_action(current_user.id, 'kb_create', 'knowledge_base', article.id,
|
||||
f'title={article.title}')
|
||||
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 render_template('admin/kb_edit.html', article=None)
|
||||
|
||||
|
||||
# ── Edit article ───────────────────────────────────────────────────────────────
|
||||
|
||||
@admin_bp.route('/kb/<int:article_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@it_required
|
||||
@@ -226,15 +329,40 @@ def kb_edit(article_id):
|
||||
article.body = request.form.get('body', article.body).strip()
|
||||
article.category = request.form.get('category', article.category)
|
||||
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()
|
||||
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}')
|
||||
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 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'])
|
||||
@login_required
|
||||
@it_required
|
||||
|
||||
@@ -2,63 +2,474 @@
|
||||
{% 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 justify-content-center">
|
||||
<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(--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-header">
|
||||
<i class="bi bi-{% if article %}pencil{% else %}plus-circle{% endif %} me-2"></i>
|
||||
{% if article %}Edit: {{ article.title[:40] }}{% else %}Create New Article{% endif %}
|
||||
<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">
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<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 VPN"/>
|
||||
<form method="POST" enctype="multipart/form-data" id="kb-form">
|
||||
<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" 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 & drop files here</p>
|
||||
<p style="font-size:11px;margin-top:4px;">Max 16 MB per file</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Category</label>
|
||||
<select class="form-select" name="category">
|
||||
<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="col-md-6">
|
||||
<label class="form-label">Tags <span style="color:var(--muted);font-weight:400;">(comma-separated)</span></label>
|
||||
<input type="text" class="form-control" name="tags"
|
||||
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>
|
||||
<!-- 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>
|
||||
<div class="d-flex gap-2 mt-4">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{% if article %}
|
||||
{% 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 %}
|
||||
</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>
|
||||
</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.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">×</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,'&').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>
|
||||
{% endblock %}
|
||||
@@ -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.updated_at.strftime('%b %d, %Y') }}</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('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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,52 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% 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 class="card mt-3">
|
||||
|
||||
Reference in New Issue
Block a user