Jul 22 - Update - add Rich-text editor, draft/publish, drag-to-reorder

This commit is contained in:
2026-07-22 17:13:26 -04:00
parent a39fa091fa
commit 69b97d51fe
15 changed files with 1295 additions and 23 deletions
+33 -3
View File
@@ -40,10 +40,11 @@ Ubuntu 24.04. Fonts via Google Fonts (Bricolage Grotesque, IBM Plex Sans/Mono).
- `section(id, num UNIQUE, title, subtitle, sort_order)` - `section(id, num UNIQUE, title, subtitle, sort_order)`
- `topic(id, section_id FK cascade, slug UNIQUE, title, body_html, link_url, - `topic(id, section_id FK cascade, slug UNIQUE, title, body_html, link_url,
link_label, media_type[none|image|video|embed], media_url, media_caption, link_label, media_type[none|image|video|embed], media_url, media_caption,
sort_order)` sort_order, is_published)`
- `audit_log(id, actor, action, entity, entity_id, detail, created_at)` - `audit_log(id, actor, action, entity, entity_id, detail, created_at)`
Public page orders sections by `sort_order, num`; topics by `sort_order`. Public page orders sections by `sort_order, num`; topics by `sort_order`, and
shows only `is_published` topics (sections with no published topics are hidden).
## Conventions (follow every change) ## Conventions (follow every change)
@@ -103,12 +104,41 @@ Public page orders sections by `sort_order, num`; topics by `sort_order`.
- CSRF-less POST floods get HTTP 400 before the view, so they don't reach the - CSRF-less POST floods get HTTP 400 before the view, so they don't reach the
auth log — add nginx `limit_req` on `/admin/login` if that traffic matters. auth log — add nginx `limit_req` on `/admin/login` if that traffic matters.
## Content editing (rich text / publish / reorder)
- **Rich text:** Quill 1.3.7 vendored at `static/vendor/` (no CDN — survives a
locked-down server or strict CSP). It's a *progressive enhancement over a real
`<textarea name="body_html" id="body_src">`*: `body.quill-on` is added only
after `new Quill()` succeeds (hides the textarea, shows the editor). If Quill
fails to load, the textarea stays usable and a save never wipes the body.
- **Sanitize on save:** `sanitize_html()` (bleach) runs on every `body_html`
write — allowlist `ALLOWED_TAGS`/`ALLOWED_ATTRS`, `strip=True`, and bleach
restricts link protocols to http/https/mailto (blocks `javascript:`). Empty /
`<p><br></p>` editor content is stored as NULL. The public page still renders
`body_html` via `Markup`, but the content is now sanitized at the source.
- **Draft/publish:** `topic.is_published` (default 1, so existing rows stay
live). Public route filters to `Section.published_topics` and drops sections
with none. Admin: per-topic `/topic/<id>/toggle` (quick button) + a Published
checkbox on the form; dashboard shows a `draft` badge. New topics default
published (least surprise); uncheck to stage a draft.
- **Drag reorder:** SortableJS 1.15.2 vendored. Dashboard drags post JSON to
`/admin/reorder` with the CSRF token from `<meta name="csrf-token">` sent as
the `X-CSRFToken` header (Flask-WTF accepts that header for AJAX). Payloads:
`{type:'topic',section_id,order:[ids]}` (within-section only) or
`{type:'section',order:[ids]}`; sort values rewritten 10,20,30…. The topic
branch filters `Topic.section_id == section_id` so a spoofed cross-section id
is ignored. Cross-section moves are done via the section dropdown on the form.
- **CDN caveat (testing):** the build sandbox proxy 403s all external CDNs incl.
Google Fonts — that's why libs are vendored. Fonts still load fine on the real
server; they degrade to system fonts if blocked.
## Deploy delta cheatsheet ## Deploy delta cheatsheet
```bash ```bash
sudo mysql < add_admin.sql # audit_log (existing DBs) sudo mysql < add_admin.sql # audit_log (existing DBs)
sudo mysql < add_publish.sql # topic.is_published (existing DBs)
# .env: SECRET_KEY, ADMIN_USERNAME, ADMIN_PASSWORD_HASH, SESSION_COOKIE_SECURE=1 # .env: SECRET_KEY, ADMIN_USERNAME, ADMIN_PASSWORD_HASH, SESSION_COOKIE_SECURE=1
sudo ./venv/bin/pip install -r requirements.txt sudo ./venv/bin/pip install -r requirements.txt # includes bleach
sudo systemctl restart jqc-features sudo systemctl restart jqc-features
# fail2ban: # fail2ban:
sudo cp deploy/fail2ban/filter.d/jqc-admin.conf /etc/fail2ban/filter.d/ sudo cp deploy/fail2ban/filter.d/jqc-admin.conf /etc/fail2ban/filter.d/
+20 -5
View File
@@ -138,11 +138,12 @@ CSRF-protected forms, and every create/update/delete is written to `audit_log`.
### One-time setup ### One-time setup
1. **Already-deployed DB?** add the audit table: 1. **Already-deployed DB?** add the audit table and the publish column:
```bash ```bash
sudo mysql < add_admin.sql sudo mysql < add_admin.sql # audit_log (admin panel)
sudo mysql < add_publish.sql # topic.is_published (draft/publish)
``` ```
(New deploys skip this — `schema.sql` already includes `audit_log`.) (New deploys skip this — `schema.sql` already includes both.)
2. Set these in `/opt/jqc-features/.env`: 2. Set these in `/opt/jqc-features/.env`:
```bash ```bash
@@ -167,14 +168,28 @@ Then visit `https://your-domain/admin`, sign in, and manage content.
### What you can do ### What you can do
- **Dashboard** — every section with its topics; edit or delete inline. - **Dashboard** — every section with its topics; edit or delete inline.
- **Add / edit topic** — section, title, slug (auto if blank), body HTML, media **Drag the ⠿ handles** to reorder topics within a section, or reorder whole
(image / video / embed), caption, an optional link button, and sort order. sections; the new order saves instantly (no page reload).
- **Publish / Unpublish** — each topic has a one-click toggle, and a Published
checkbox on its edit form. Drafts show a `draft` badge and are hidden from the
public site. A section whose topics are all drafts is hidden entirely.
- **Add / edit topic** — section, title, slug (auto if blank), a **rich-text
body editor** (bold/italic/underline, H2/H3, lists, blockquote, links — no HTML
knowledge needed), media (image / video / embed) + caption, an optional link
button, and sort order.
- **Add / edit section** — number (`§NN`, unique), title, subtitle, sort order. - **Add / edit section** — number (`§NN`, unique), title, subtitle, sort order.
- Deleting a section cascades to its topics (with a confirm prompt). - Deleting a section cascades to its topics (with a confirm prompt).
- **Audit log** (`/admin/audit`, "Audit" in the nav) — read-only view of every - **Audit log** (`/admin/audit`, "Audit" in the nav) — read-only view of every
change, newest first, filterable by action and type, paginated 50/page. Times change, newest first, filterable by action and type, paginated 50/page. Times
are UTC. are UTC.
The editor (Quill) and drag library (SortableJS) are **vendored locally** under
`static/vendor/` — no CDN dependency, so they work on a locked-down server and
survive a strict CSP. Rich-text HTML is sanitized on save (`bleach`) against a
tag allowlist, so a paste can't inject markup or `javascript:` links into the
public page. If the editor ever fails to load, the body field degrades to a
plain textarea — a save never wipes content.
### Notes ### Notes
- `SESSION_COOKIE_SECURE=1` means the login cookie only sends over HTTPS. For a - `SESSION_COOKIE_SECURE=1` means the login cookie only sends over HTTPS. For a
+20
View File
@@ -0,0 +1,20 @@
-- Additive migration for the draft/publish feature.
-- Adds topic.is_published to an ALREADY-DEPLOYED jqc_features database.
-- Safe to re-run: guarded by an information_schema existence check (MySQL has
-- no ADD COLUMN IF NOT EXISTS). New deploys don't need this — schema.sql already
-- includes the column.
USE jqc_features;
SET @col := (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'topic'
AND column_name = 'is_published'
);
SET @sql := IF(@col = 0,
'ALTER TABLE topic ADD COLUMN is_published TINYINT(1) NOT NULL DEFAULT 1 AFTER sort_order',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
+64 -3
View File
@@ -4,12 +4,12 @@ import re
from functools import wraps from functools import wraps
from flask import ( from flask import (
Blueprint, current_app, flash, redirect, render_template, Blueprint, abort, current_app, flash, jsonify, redirect, render_template,
request, session, url_for, request, session, url_for,
) )
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
from app import db, log_action, AuditLog, Section, Topic from app import db, log_action, sanitize_html, AuditLog, Section, Topic
admin_bp = Blueprint("admin", __name__, url_prefix="/admin") admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
@@ -171,13 +171,14 @@ def topic_form(topic_id=None):
topic.section_id = section_id topic.section_id = section_id
topic.slug = slug topic.slug = slug
topic.title = title topic.title = title
topic.body_html = f.get("body_html", "").strip() or None topic.body_html = sanitize_html(f.get("body_html", ""))
topic.link_url = f.get("link_url", "").strip() or None topic.link_url = f.get("link_url", "").strip() or None
topic.link_label = f.get("link_label", "").strip() or None topic.link_label = f.get("link_label", "").strip() or None
topic.media_type = media_type topic.media_type = media_type
topic.media_url = f.get("media_url", "").strip() or None topic.media_url = f.get("media_url", "").strip() or None
topic.media_caption = f.get("media_caption", "").strip() or None topic.media_caption = f.get("media_caption", "").strip() or None
topic.sort_order = _int(f.get("sort_order"), 0) topic.sort_order = _int(f.get("sort_order"), 0)
topic.is_published = f.get("is_published") == "1"
if is_new: if is_new:
db.session.add(topic) db.session.add(topic)
@@ -207,6 +208,66 @@ def topic_delete(topic_id):
return redirect(url_for("admin.dashboard")) return redirect(url_for("admin.dashboard"))
@admin_bp.route("/topic/<int:topic_id>/toggle", methods=["POST"])
@login_required
def topic_toggle(topic_id):
topic = Topic.query.get_or_404(topic_id)
topic.is_published = not topic.is_published
db.session.commit()
state = "published" if topic.is_published else "unpublished (draft)"
log_action(session.get("admin"), "update", "topic", topic.id,
f"{state}: {topic.title}")
flash(f"'{topic.title}' is now {state}.", "ok")
return redirect(url_for("admin.dashboard"))
# ------------------------------------------------------------------ reorder
@admin_bp.route("/reorder", methods=["POST"])
@login_required
def reorder():
"""Persist drag-and-drop order. JSON body:
{"type":"topic","section_id":N,"order":[id,...]} reorder within a section
{"type":"section","order":[id,...]} reorder sections
Sort values are rewritten to 10,20,30,... in the given order."""
data = request.get_json(silent=True) or {}
kind = data.get("type")
order = data.get("order") or []
if kind not in ("topic", "section") or not isinstance(order, list):
abort(400)
ids = []
for v in order:
iv = _int(v, None)
if iv is None:
abort(400)
ids.append(iv)
if kind == "topic":
section_id = _int(data.get("section_id"), None)
if section_id is None:
abort(400)
# Only reorder topics that actually belong to this section.
rows = {t.id: t for t in Topic.query.filter(
Topic.section_id == section_id, Topic.id.in_(ids)
).all()}
for i, tid in enumerate(ids):
if tid in rows:
rows[tid].sort_order = (i + 1) * 10
detail = f"reordered {len(rows)} topics in section {section_id}"
entity = "topic"
else:
rows = {s.id: s for s in Section.query.filter(Section.id.in_(ids)).all()}
for i, sid in enumerate(ids):
if sid in rows:
rows[sid].sort_order = (i + 1) * 10
detail = f"reordered {len(rows)} sections"
entity = "section"
db.session.commit()
log_action(session.get("admin"), "update", entity, None, detail)
return jsonify(ok=True)
# ------------------------------------------------------------------ sections # ------------------------------------------------------------------ sections
@admin_bp.route("/section/new", methods=["GET", "POST"]) @admin_bp.route("/section/new", methods=["GET", "POST"])
@admin_bp.route("/section/<int:section_id>", methods=["GET", "POST"]) @admin_bp.route("/section/<int:section_id>", methods=["GET", "POST"])
+34 -1
View File
@@ -3,6 +3,7 @@ import os
from datetime import datetime from datetime import datetime
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
import bleach
from flask import Flask, render_template from flask import Flask, render_template
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_wtf import CSRFProtect from flask_wtf import CSRFProtect
@@ -29,6 +30,11 @@ class Section(db.Model):
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
@property
def published_topics(self):
# Topics are already ordered by sort_order via the relationship.
return [t for t in self.topics if t.is_published]
class Topic(db.Model): class Topic(db.Model):
__tablename__ = "topic" __tablename__ = "topic"
@@ -45,6 +51,7 @@ class Topic(db.Model):
media_url = db.Column(db.String(500)) media_url = db.Column(db.String(500))
media_caption = db.Column(db.String(255)) media_caption = db.Column(db.String(255))
sort_order = db.Column(db.Integer, nullable=False, default=0) sort_order = db.Column(db.Integer, nullable=False, default=0)
is_published = db.Column(db.Boolean, nullable=False, default=True)
@property @property
def body(self): def body(self):
@@ -79,6 +86,30 @@ def log_action(actor, action, entity, entity_id=None, detail=None):
db.session.commit() db.session.commit()
# Tags/attributes the rich-text editor (Quill) can emit. Everything else is
# stripped on save so a WYSIWYG paste can't inject markup into the public page.
ALLOWED_TAGS = [
"p", "br", "strong", "b", "em", "i", "u", "s", "strike",
"ul", "ol", "li", "a", "h2", "h3", "blockquote",
]
ALLOWED_ATTRS = {"a": ["href", "title", "target", "rel"]}
def sanitize_html(raw):
"""Clean editor HTML against the allowlist. Returns None for empty content
so blank bodies stay NULL. bleach also restricts link protocols to
http/https/mailto, blocking javascript: URLs."""
if not raw:
return None
cleaned = bleach.clean(
raw, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS, strip=True
).strip()
# Quill leaves an empty paragraph for a blank editor.
if cleaned in ("", "<p></p>", "<p><br></p>"):
return None
return cleaned
def _configure_auth_logger(app): def _configure_auth_logger(app):
"""A dedicated 'jqc.auth' logger writing one line per login attempt to a """A dedicated 'jqc.auth' logger writing one line per login attempt to a
file fail2ban watches. Kept separate from the app log so the filter regex file fail2ban watches. Kept separate from the app log so the filter regex
@@ -123,9 +154,11 @@ def create_app():
@app.route("/") @app.route("/")
def index(): def index():
sections = ( all_sections = (
Section.query.order_by(Section.sort_order, Section.num).all() Section.query.order_by(Section.sort_order, Section.num).all()
) )
# Hide sections whose topics are all drafts (or which have no topics).
sections = [s for s in all_sections if s.published_topics]
return render_template( return render_template(
"index.html", "index.html",
sections=sections, sections=sections,
+1
View File
@@ -1,5 +1,6 @@
Flask==3.0.3 Flask==3.0.3
Flask-SQLAlchemy==3.1.1 Flask-SQLAlchemy==3.1.1
Flask-WTF==1.2.1 Flask-WTF==1.2.1
bleach==6.1.0
PyMySQL==1.1.1 PyMySQL==1.1.1
gunicorn==22.0.0 gunicorn==22.0.0
+1
View File
@@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS topic (
media_url VARCHAR(500) NULL, -- image src, video src, or embed URL media_url VARCHAR(500) NULL, -- image src, video src, or embed URL
media_caption VARCHAR(255) NULL, media_caption VARCHAR(255) NULL,
sort_order INT NOT NULL DEFAULT 0, sort_order INT NOT NULL DEFAULT 0,
is_published TINYINT(1) NOT NULL DEFAULT 1,
UNIQUE KEY uq_topic_slug (slug), UNIQUE KEY uq_topic_slug (slug),
KEY idx_topic_section (section_id), KEY idx_topic_section (section_id),
CONSTRAINT fk_topic_section FOREIGN KEY (section_id) CONSTRAINT fk_topic_section FOREIGN KEY (section_id)
+43
View File
@@ -220,3 +220,46 @@ form{display:inline}
font-family:"IBM Plex Mono",monospace;font-size:.8rem;color:var(--muted); font-family:"IBM Plex Mono",monospace;font-size:.8rem;color:var(--muted);
} }
.btn.is-disabled{opacity:.4;cursor:default;pointer-events:none} .btn.is-disabled{opacity:.4;cursor:default;pointer-events:none}
/* drag-to-reorder */
.drag-handle{
border:0;background:none;cursor:grab;color:var(--muted);
font-size:1.1rem;line-height:1;padding:2px 4px;border-radius:6px;
touch-action:none;transition:color .15s,background .15s;
}
.drag-handle:hover{color:var(--ink);background:var(--hair-2)}
.drag-handle:active{cursor:grabbing}
.sec-drag{margin-right:2px}
.sortable-ghost{opacity:.45}
.sortable-chosen{background:var(--hair-2)}
.sec-card.sortable-ghost{outline:2px dashed var(--aqua);outline-offset:-2px}
/* draft state */
.topic-row.is-draft{background:repeating-linear-gradient(
-45deg,transparent,transparent 8px,rgba(232,150,58,.05) 8px,rgba(232,150,58,.05) 16px)}
.topic-row.is-draft .topic-row__title{color:var(--muted)}
.chip--draft{color:#8A5A12;border-color:#F0D9B3;background:#FBF0E1}
/* publish checkbox on the form */
.field--check{flex-direction:row;align-items:center;gap:10px}
.field--check input[type=checkbox]{width:18px;height:18px;accent-color:var(--aqua);flex:none;margin:0}
.field--check>span{font-weight:600;font-size:.9rem}
/* Quill rich-text editor (progressive enhancement over #body_src textarea) */
#editor-toolbar.ql-tools, #editor{display:none} /* hidden until Quill loads */
body.quill-on #editor-toolbar.ql-tools,
body.quill-on #editor{display:block}
body.quill-on #body_src{display:none} /* hide raw textarea once enhanced */
#editor-toolbar.ql-toolbar{
border:1px solid var(--hair);border-bottom:0;border-radius:9px 9px 0 0;background:#fff;
}
#editor.ql-container{
border:1px solid var(--hair);border-radius:0 0 9px 9px;background:#fff;
font-family:"IBM Plex Sans",sans-serif;font-size:.98rem;min-height:180px;
}
#editor .ql-editor{min-height:180px}
.ql-editor:focus{outline:none}
#editor-toolbar.ql-toolbar .ql-stroke{stroke:var(--ink-soft)}
#editor-toolbar.ql-toolbar button:hover .ql-stroke{stroke:var(--aqua-deep)}
#editor-toolbar.ql-toolbar button.ql-active .ql-stroke{stroke:var(--aqua-deep)}
+2
View File
File diff suppressed because one or more lines are too long
+8
View File
File diff suppressed because one or more lines are too long
+945
View File
@@ -0,0 +1,945 @@
/*!
* Quill Editor v1.3.7
* https://quilljs.com/
* Copyright (c) 2014, Jason Chen
* Copyright (c) 2013, salesforce.com
*/
.ql-container {
box-sizing: border-box;
font-family: Helvetica, Arial, sans-serif;
font-size: 13px;
height: 100%;
margin: 0px;
position: relative;
}
.ql-container.ql-disabled .ql-tooltip {
visibility: hidden;
}
.ql-container.ql-disabled .ql-editor ul[data-checked] > li::before {
pointer-events: none;
}
.ql-clipboard {
left: -100000px;
height: 1px;
overflow-y: hidden;
position: absolute;
top: 50%;
}
.ql-clipboard p {
margin: 0;
padding: 0;
}
.ql-editor {
box-sizing: border-box;
line-height: 1.42;
height: 100%;
outline: none;
overflow-y: auto;
padding: 12px 15px;
tab-size: 4;
-moz-tab-size: 4;
text-align: left;
white-space: pre-wrap;
word-wrap: break-word;
}
.ql-editor > * {
cursor: text;
}
.ql-editor p,
.ql-editor ol,
.ql-editor ul,
.ql-editor pre,
.ql-editor blockquote,
.ql-editor h1,
.ql-editor h2,
.ql-editor h3,
.ql-editor h4,
.ql-editor h5,
.ql-editor h6 {
margin: 0;
padding: 0;
counter-reset: list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9;
}
.ql-editor ol,
.ql-editor ul {
padding-left: 1.5em;
}
.ql-editor ol > li,
.ql-editor ul > li {
list-style-type: none;
}
.ql-editor ul > li::before {
content: '\2022';
}
.ql-editor ul[data-checked=true],
.ql-editor ul[data-checked=false] {
pointer-events: none;
}
.ql-editor ul[data-checked=true] > li *,
.ql-editor ul[data-checked=false] > li * {
pointer-events: all;
}
.ql-editor ul[data-checked=true] > li::before,
.ql-editor ul[data-checked=false] > li::before {
color: #777;
cursor: pointer;
pointer-events: all;
}
.ql-editor ul[data-checked=true] > li::before {
content: '\2611';
}
.ql-editor ul[data-checked=false] > li::before {
content: '\2610';
}
.ql-editor li::before {
display: inline-block;
white-space: nowrap;
width: 1.2em;
}
.ql-editor li:not(.ql-direction-rtl)::before {
margin-left: -1.5em;
margin-right: 0.3em;
text-align: right;
}
.ql-editor li.ql-direction-rtl::before {
margin-left: 0.3em;
margin-right: -1.5em;
}
.ql-editor ol li:not(.ql-direction-rtl),
.ql-editor ul li:not(.ql-direction-rtl) {
padding-left: 1.5em;
}
.ql-editor ol li.ql-direction-rtl,
.ql-editor ul li.ql-direction-rtl {
padding-right: 1.5em;
}
.ql-editor ol li {
counter-reset: list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9;
counter-increment: list-0;
}
.ql-editor ol li:before {
content: counter(list-0, decimal) '. ';
}
.ql-editor ol li.ql-indent-1 {
counter-increment: list-1;
}
.ql-editor ol li.ql-indent-1:before {
content: counter(list-1, lower-alpha) '. ';
}
.ql-editor ol li.ql-indent-1 {
counter-reset: list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9;
}
.ql-editor ol li.ql-indent-2 {
counter-increment: list-2;
}
.ql-editor ol li.ql-indent-2:before {
content: counter(list-2, lower-roman) '. ';
}
.ql-editor ol li.ql-indent-2 {
counter-reset: list-3 list-4 list-5 list-6 list-7 list-8 list-9;
}
.ql-editor ol li.ql-indent-3 {
counter-increment: list-3;
}
.ql-editor ol li.ql-indent-3:before {
content: counter(list-3, decimal) '. ';
}
.ql-editor ol li.ql-indent-3 {
counter-reset: list-4 list-5 list-6 list-7 list-8 list-9;
}
.ql-editor ol li.ql-indent-4 {
counter-increment: list-4;
}
.ql-editor ol li.ql-indent-4:before {
content: counter(list-4, lower-alpha) '. ';
}
.ql-editor ol li.ql-indent-4 {
counter-reset: list-5 list-6 list-7 list-8 list-9;
}
.ql-editor ol li.ql-indent-5 {
counter-increment: list-5;
}
.ql-editor ol li.ql-indent-5:before {
content: counter(list-5, lower-roman) '. ';
}
.ql-editor ol li.ql-indent-5 {
counter-reset: list-6 list-7 list-8 list-9;
}
.ql-editor ol li.ql-indent-6 {
counter-increment: list-6;
}
.ql-editor ol li.ql-indent-6:before {
content: counter(list-6, decimal) '. ';
}
.ql-editor ol li.ql-indent-6 {
counter-reset: list-7 list-8 list-9;
}
.ql-editor ol li.ql-indent-7 {
counter-increment: list-7;
}
.ql-editor ol li.ql-indent-7:before {
content: counter(list-7, lower-alpha) '. ';
}
.ql-editor ol li.ql-indent-7 {
counter-reset: list-8 list-9;
}
.ql-editor ol li.ql-indent-8 {
counter-increment: list-8;
}
.ql-editor ol li.ql-indent-8:before {
content: counter(list-8, lower-roman) '. ';
}
.ql-editor ol li.ql-indent-8 {
counter-reset: list-9;
}
.ql-editor ol li.ql-indent-9 {
counter-increment: list-9;
}
.ql-editor ol li.ql-indent-9:before {
content: counter(list-9, decimal) '. ';
}
.ql-editor .ql-indent-1:not(.ql-direction-rtl) {
padding-left: 3em;
}
.ql-editor li.ql-indent-1:not(.ql-direction-rtl) {
padding-left: 4.5em;
}
.ql-editor .ql-indent-1.ql-direction-rtl.ql-align-right {
padding-right: 3em;
}
.ql-editor li.ql-indent-1.ql-direction-rtl.ql-align-right {
padding-right: 4.5em;
}
.ql-editor .ql-indent-2:not(.ql-direction-rtl) {
padding-left: 6em;
}
.ql-editor li.ql-indent-2:not(.ql-direction-rtl) {
padding-left: 7.5em;
}
.ql-editor .ql-indent-2.ql-direction-rtl.ql-align-right {
padding-right: 6em;
}
.ql-editor li.ql-indent-2.ql-direction-rtl.ql-align-right {
padding-right: 7.5em;
}
.ql-editor .ql-indent-3:not(.ql-direction-rtl) {
padding-left: 9em;
}
.ql-editor li.ql-indent-3:not(.ql-direction-rtl) {
padding-left: 10.5em;
}
.ql-editor .ql-indent-3.ql-direction-rtl.ql-align-right {
padding-right: 9em;
}
.ql-editor li.ql-indent-3.ql-direction-rtl.ql-align-right {
padding-right: 10.5em;
}
.ql-editor .ql-indent-4:not(.ql-direction-rtl) {
padding-left: 12em;
}
.ql-editor li.ql-indent-4:not(.ql-direction-rtl) {
padding-left: 13.5em;
}
.ql-editor .ql-indent-4.ql-direction-rtl.ql-align-right {
padding-right: 12em;
}
.ql-editor li.ql-indent-4.ql-direction-rtl.ql-align-right {
padding-right: 13.5em;
}
.ql-editor .ql-indent-5:not(.ql-direction-rtl) {
padding-left: 15em;
}
.ql-editor li.ql-indent-5:not(.ql-direction-rtl) {
padding-left: 16.5em;
}
.ql-editor .ql-indent-5.ql-direction-rtl.ql-align-right {
padding-right: 15em;
}
.ql-editor li.ql-indent-5.ql-direction-rtl.ql-align-right {
padding-right: 16.5em;
}
.ql-editor .ql-indent-6:not(.ql-direction-rtl) {
padding-left: 18em;
}
.ql-editor li.ql-indent-6:not(.ql-direction-rtl) {
padding-left: 19.5em;
}
.ql-editor .ql-indent-6.ql-direction-rtl.ql-align-right {
padding-right: 18em;
}
.ql-editor li.ql-indent-6.ql-direction-rtl.ql-align-right {
padding-right: 19.5em;
}
.ql-editor .ql-indent-7:not(.ql-direction-rtl) {
padding-left: 21em;
}
.ql-editor li.ql-indent-7:not(.ql-direction-rtl) {
padding-left: 22.5em;
}
.ql-editor .ql-indent-7.ql-direction-rtl.ql-align-right {
padding-right: 21em;
}
.ql-editor li.ql-indent-7.ql-direction-rtl.ql-align-right {
padding-right: 22.5em;
}
.ql-editor .ql-indent-8:not(.ql-direction-rtl) {
padding-left: 24em;
}
.ql-editor li.ql-indent-8:not(.ql-direction-rtl) {
padding-left: 25.5em;
}
.ql-editor .ql-indent-8.ql-direction-rtl.ql-align-right {
padding-right: 24em;
}
.ql-editor li.ql-indent-8.ql-direction-rtl.ql-align-right {
padding-right: 25.5em;
}
.ql-editor .ql-indent-9:not(.ql-direction-rtl) {
padding-left: 27em;
}
.ql-editor li.ql-indent-9:not(.ql-direction-rtl) {
padding-left: 28.5em;
}
.ql-editor .ql-indent-9.ql-direction-rtl.ql-align-right {
padding-right: 27em;
}
.ql-editor li.ql-indent-9.ql-direction-rtl.ql-align-right {
padding-right: 28.5em;
}
.ql-editor .ql-video {
display: block;
max-width: 100%;
}
.ql-editor .ql-video.ql-align-center {
margin: 0 auto;
}
.ql-editor .ql-video.ql-align-right {
margin: 0 0 0 auto;
}
.ql-editor .ql-bg-black {
background-color: #000;
}
.ql-editor .ql-bg-red {
background-color: #e60000;
}
.ql-editor .ql-bg-orange {
background-color: #f90;
}
.ql-editor .ql-bg-yellow {
background-color: #ff0;
}
.ql-editor .ql-bg-green {
background-color: #008a00;
}
.ql-editor .ql-bg-blue {
background-color: #06c;
}
.ql-editor .ql-bg-purple {
background-color: #93f;
}
.ql-editor .ql-color-white {
color: #fff;
}
.ql-editor .ql-color-red {
color: #e60000;
}
.ql-editor .ql-color-orange {
color: #f90;
}
.ql-editor .ql-color-yellow {
color: #ff0;
}
.ql-editor .ql-color-green {
color: #008a00;
}
.ql-editor .ql-color-blue {
color: #06c;
}
.ql-editor .ql-color-purple {
color: #93f;
}
.ql-editor .ql-font-serif {
font-family: Georgia, Times New Roman, serif;
}
.ql-editor .ql-font-monospace {
font-family: Monaco, Courier New, monospace;
}
.ql-editor .ql-size-small {
font-size: 0.75em;
}
.ql-editor .ql-size-large {
font-size: 1.5em;
}
.ql-editor .ql-size-huge {
font-size: 2.5em;
}
.ql-editor .ql-direction-rtl {
direction: rtl;
text-align: inherit;
}
.ql-editor .ql-align-center {
text-align: center;
}
.ql-editor .ql-align-justify {
text-align: justify;
}
.ql-editor .ql-align-right {
text-align: right;
}
.ql-editor.ql-blank::before {
color: rgba(0,0,0,0.6);
content: attr(data-placeholder);
font-style: italic;
left: 15px;
pointer-events: none;
position: absolute;
right: 15px;
}
.ql-snow.ql-toolbar:after,
.ql-snow .ql-toolbar:after {
clear: both;
content: '';
display: table;
}
.ql-snow.ql-toolbar button,
.ql-snow .ql-toolbar button {
background: none;
border: none;
cursor: pointer;
display: inline-block;
float: left;
height: 24px;
padding: 3px 5px;
width: 28px;
}
.ql-snow.ql-toolbar button svg,
.ql-snow .ql-toolbar button svg {
float: left;
height: 100%;
}
.ql-snow.ql-toolbar button:active:hover,
.ql-snow .ql-toolbar button:active:hover {
outline: none;
}
.ql-snow.ql-toolbar input.ql-image[type=file],
.ql-snow .ql-toolbar input.ql-image[type=file] {
display: none;
}
.ql-snow.ql-toolbar button:hover,
.ql-snow .ql-toolbar button:hover,
.ql-snow.ql-toolbar button:focus,
.ql-snow .ql-toolbar button:focus,
.ql-snow.ql-toolbar button.ql-active,
.ql-snow .ql-toolbar button.ql-active,
.ql-snow.ql-toolbar .ql-picker-label:hover,
.ql-snow .ql-toolbar .ql-picker-label:hover,
.ql-snow.ql-toolbar .ql-picker-label.ql-active,
.ql-snow .ql-toolbar .ql-picker-label.ql-active,
.ql-snow.ql-toolbar .ql-picker-item:hover,
.ql-snow .ql-toolbar .ql-picker-item:hover,
.ql-snow.ql-toolbar .ql-picker-item.ql-selected,
.ql-snow .ql-toolbar .ql-picker-item.ql-selected {
color: #06c;
}
.ql-snow.ql-toolbar button:hover .ql-fill,
.ql-snow .ql-toolbar button:hover .ql-fill,
.ql-snow.ql-toolbar button:focus .ql-fill,
.ql-snow .ql-toolbar button:focus .ql-fill,
.ql-snow.ql-toolbar button.ql-active .ql-fill,
.ql-snow .ql-toolbar button.ql-active .ql-fill,
.ql-snow.ql-toolbar .ql-picker-label:hover .ql-fill,
.ql-snow .ql-toolbar .ql-picker-label:hover .ql-fill,
.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-fill,
.ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-fill,
.ql-snow.ql-toolbar .ql-picker-item:hover .ql-fill,
.ql-snow .ql-toolbar .ql-picker-item:hover .ql-fill,
.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-fill,
.ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-fill,
.ql-snow.ql-toolbar button:hover .ql-stroke.ql-fill,
.ql-snow .ql-toolbar button:hover .ql-stroke.ql-fill,
.ql-snow.ql-toolbar button:focus .ql-stroke.ql-fill,
.ql-snow .ql-toolbar button:focus .ql-stroke.ql-fill,
.ql-snow.ql-toolbar button.ql-active .ql-stroke.ql-fill,
.ql-snow .ql-toolbar button.ql-active .ql-stroke.ql-fill,
.ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke.ql-fill,
.ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke.ql-fill,
.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke.ql-fill,
.ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke.ql-fill,
.ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke.ql-fill,
.ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke.ql-fill,
.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke.ql-fill,
.ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke.ql-fill {
fill: #06c;
}
.ql-snow.ql-toolbar button:hover .ql-stroke,
.ql-snow .ql-toolbar button:hover .ql-stroke,
.ql-snow.ql-toolbar button:focus .ql-stroke,
.ql-snow .ql-toolbar button:focus .ql-stroke,
.ql-snow.ql-toolbar button.ql-active .ql-stroke,
.ql-snow .ql-toolbar button.ql-active .ql-stroke,
.ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke,
.ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke,
.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke,
.ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke,
.ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke,
.ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke,
.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke,
.ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke,
.ql-snow.ql-toolbar button:hover .ql-stroke-miter,
.ql-snow .ql-toolbar button:hover .ql-stroke-miter,
.ql-snow.ql-toolbar button:focus .ql-stroke-miter,
.ql-snow .ql-toolbar button:focus .ql-stroke-miter,
.ql-snow.ql-toolbar button.ql-active .ql-stroke-miter,
.ql-snow .ql-toolbar button.ql-active .ql-stroke-miter,
.ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke-miter,
.ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke-miter,
.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke-miter,
.ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke-miter,
.ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke-miter,
.ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke-miter,
.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke-miter,
.ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke-miter {
stroke: #06c;
}
@media (pointer: coarse) {
.ql-snow.ql-toolbar button:hover:not(.ql-active),
.ql-snow .ql-toolbar button:hover:not(.ql-active) {
color: #444;
}
.ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-fill,
.ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-fill,
.ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-stroke.ql-fill,
.ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-stroke.ql-fill {
fill: #444;
}
.ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-stroke,
.ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-stroke,
.ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-stroke-miter,
.ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-stroke-miter {
stroke: #444;
}
}
.ql-snow {
box-sizing: border-box;
}
.ql-snow * {
box-sizing: border-box;
}
.ql-snow .ql-hidden {
display: none;
}
.ql-snow .ql-out-bottom,
.ql-snow .ql-out-top {
visibility: hidden;
}
.ql-snow .ql-tooltip {
position: absolute;
transform: translateY(10px);
}
.ql-snow .ql-tooltip a {
cursor: pointer;
text-decoration: none;
}
.ql-snow .ql-tooltip.ql-flip {
transform: translateY(-10px);
}
.ql-snow .ql-formats {
display: inline-block;
vertical-align: middle;
}
.ql-snow .ql-formats:after {
clear: both;
content: '';
display: table;
}
.ql-snow .ql-stroke {
fill: none;
stroke: #444;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 2;
}
.ql-snow .ql-stroke-miter {
fill: none;
stroke: #444;
stroke-miterlimit: 10;
stroke-width: 2;
}
.ql-snow .ql-fill,
.ql-snow .ql-stroke.ql-fill {
fill: #444;
}
.ql-snow .ql-empty {
fill: none;
}
.ql-snow .ql-even {
fill-rule: evenodd;
}
.ql-snow .ql-thin,
.ql-snow .ql-stroke.ql-thin {
stroke-width: 1;
}
.ql-snow .ql-transparent {
opacity: 0.4;
}
.ql-snow .ql-direction svg:last-child {
display: none;
}
.ql-snow .ql-direction.ql-active svg:last-child {
display: inline;
}
.ql-snow .ql-direction.ql-active svg:first-child {
display: none;
}
.ql-snow .ql-editor h1 {
font-size: 2em;
}
.ql-snow .ql-editor h2 {
font-size: 1.5em;
}
.ql-snow .ql-editor h3 {
font-size: 1.17em;
}
.ql-snow .ql-editor h4 {
font-size: 1em;
}
.ql-snow .ql-editor h5 {
font-size: 0.83em;
}
.ql-snow .ql-editor h6 {
font-size: 0.67em;
}
.ql-snow .ql-editor a {
text-decoration: underline;
}
.ql-snow .ql-editor blockquote {
border-left: 4px solid #ccc;
margin-bottom: 5px;
margin-top: 5px;
padding-left: 16px;
}
.ql-snow .ql-editor code,
.ql-snow .ql-editor pre {
background-color: #f0f0f0;
border-radius: 3px;
}
.ql-snow .ql-editor pre {
white-space: pre-wrap;
margin-bottom: 5px;
margin-top: 5px;
padding: 5px 10px;
}
.ql-snow .ql-editor code {
font-size: 85%;
padding: 2px 4px;
}
.ql-snow .ql-editor pre.ql-syntax {
background-color: #23241f;
color: #f8f8f2;
overflow: visible;
}
.ql-snow .ql-editor img {
max-width: 100%;
}
.ql-snow .ql-picker {
color: #444;
display: inline-block;
float: left;
font-size: 14px;
font-weight: 500;
height: 24px;
position: relative;
vertical-align: middle;
}
.ql-snow .ql-picker-label {
cursor: pointer;
display: inline-block;
height: 100%;
padding-left: 8px;
padding-right: 2px;
position: relative;
width: 100%;
}
.ql-snow .ql-picker-label::before {
display: inline-block;
line-height: 22px;
}
.ql-snow .ql-picker-options {
background-color: #fff;
display: none;
min-width: 100%;
padding: 4px 8px;
position: absolute;
white-space: nowrap;
}
.ql-snow .ql-picker-options .ql-picker-item {
cursor: pointer;
display: block;
padding-bottom: 5px;
padding-top: 5px;
}
.ql-snow .ql-picker.ql-expanded .ql-picker-label {
color: #ccc;
z-index: 2;
}
.ql-snow .ql-picker.ql-expanded .ql-picker-label .ql-fill {
fill: #ccc;
}
.ql-snow .ql-picker.ql-expanded .ql-picker-label .ql-stroke {
stroke: #ccc;
}
.ql-snow .ql-picker.ql-expanded .ql-picker-options {
display: block;
margin-top: -1px;
top: 100%;
z-index: 1;
}
.ql-snow .ql-color-picker,
.ql-snow .ql-icon-picker {
width: 28px;
}
.ql-snow .ql-color-picker .ql-picker-label,
.ql-snow .ql-icon-picker .ql-picker-label {
padding: 2px 4px;
}
.ql-snow .ql-color-picker .ql-picker-label svg,
.ql-snow .ql-icon-picker .ql-picker-label svg {
right: 4px;
}
.ql-snow .ql-icon-picker .ql-picker-options {
padding: 4px 0px;
}
.ql-snow .ql-icon-picker .ql-picker-item {
height: 24px;
width: 24px;
padding: 2px 4px;
}
.ql-snow .ql-color-picker .ql-picker-options {
padding: 3px 5px;
width: 152px;
}
.ql-snow .ql-color-picker .ql-picker-item {
border: 1px solid transparent;
float: left;
height: 16px;
margin: 2px;
padding: 0px;
width: 16px;
}
.ql-snow .ql-picker:not(.ql-color-picker):not(.ql-icon-picker) svg {
position: absolute;
margin-top: -9px;
right: 0;
top: 50%;
width: 18px;
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-label]:not([data-label=''])::before,
.ql-snow .ql-picker.ql-font .ql-picker-label[data-label]:not([data-label=''])::before,
.ql-snow .ql-picker.ql-size .ql-picker-label[data-label]:not([data-label=''])::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-label]:not([data-label=''])::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-label]:not([data-label=''])::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-label]:not([data-label=''])::before {
content: attr(data-label);
}
.ql-snow .ql-picker.ql-header {
width: 98px;
}
.ql-snow .ql-picker.ql-header .ql-picker-label::before,
.ql-snow .ql-picker.ql-header .ql-picker-item::before {
content: 'Normal';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="1"]::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="1"]::before {
content: 'Heading 1';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="2"]::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="2"]::before {
content: 'Heading 2';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="3"]::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="3"]::before {
content: 'Heading 3';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="4"]::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="4"]::before {
content: 'Heading 4';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="5"]::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="5"]::before {
content: 'Heading 5';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="6"]::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="6"]::before {
content: 'Heading 6';
}
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="1"]::before {
font-size: 2em;
}
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="2"]::before {
font-size: 1.5em;
}
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="3"]::before {
font-size: 1.17em;
}
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="4"]::before {
font-size: 1em;
}
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="5"]::before {
font-size: 0.83em;
}
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="6"]::before {
font-size: 0.67em;
}
.ql-snow .ql-picker.ql-font {
width: 108px;
}
.ql-snow .ql-picker.ql-font .ql-picker-label::before,
.ql-snow .ql-picker.ql-font .ql-picker-item::before {
content: 'Sans Serif';
}
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value=serif]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=serif]::before {
content: 'Serif';
}
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value=monospace]::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=monospace]::before {
content: 'Monospace';
}
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=serif]::before {
font-family: Georgia, Times New Roman, serif;
}
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=monospace]::before {
font-family: Monaco, Courier New, monospace;
}
.ql-snow .ql-picker.ql-size {
width: 98px;
}
.ql-snow .ql-picker.ql-size .ql-picker-label::before,
.ql-snow .ql-picker.ql-size .ql-picker-item::before {
content: 'Normal';
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value=small]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=small]::before {
content: 'Small';
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value=large]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=large]::before {
content: 'Large';
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value=huge]::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=huge]::before {
content: 'Huge';
}
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=small]::before {
font-size: 10px;
}
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=large]::before {
font-size: 18px;
}
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=huge]::before {
font-size: 32px;
}
.ql-snow .ql-color-picker.ql-background .ql-picker-item {
background-color: #fff;
}
.ql-snow .ql-color-picker.ql-color .ql-picker-item {
background-color: #000;
}
.ql-toolbar.ql-snow {
border: 1px solid #ccc;
box-sizing: border-box;
font-family: 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;
padding: 8px;
}
.ql-toolbar.ql-snow .ql-formats {
margin-right: 15px;
}
.ql-toolbar.ql-snow .ql-picker-label {
border: 1px solid transparent;
}
.ql-toolbar.ql-snow .ql-picker-options {
border: 1px solid transparent;
box-shadow: rgba(0,0,0,0.2) 0 2px 8px;
}
.ql-toolbar.ql-snow .ql-picker.ql-expanded .ql-picker-label {
border-color: #ccc;
}
.ql-toolbar.ql-snow .ql-picker.ql-expanded .ql-picker-options {
border-color: #ccc;
}
.ql-toolbar.ql-snow .ql-color-picker .ql-picker-item.ql-selected,
.ql-toolbar.ql-snow .ql-color-picker .ql-picker-item:hover {
border-color: #000;
}
.ql-toolbar.ql-snow + .ql-container.ql-snow {
border-top: 0px;
}
.ql-snow .ql-tooltip {
background-color: #fff;
border: 1px solid #ccc;
box-shadow: 0px 0px 5px #ddd;
color: #444;
padding: 5px 12px;
white-space: nowrap;
}
.ql-snow .ql-tooltip::before {
content: "Visit URL:";
line-height: 26px;
margin-right: 8px;
}
.ql-snow .ql-tooltip input[type=text] {
display: none;
border: 1px solid #ccc;
font-size: 13px;
height: 26px;
margin: 0px;
padding: 3px 5px;
width: 170px;
}
.ql-snow .ql-tooltip a.ql-preview {
display: inline-block;
max-width: 200px;
overflow-x: hidden;
text-overflow: ellipsis;
vertical-align: top;
}
.ql-snow .ql-tooltip a.ql-action::after {
border-right: 1px solid #ccc;
content: 'Edit';
margin-left: 16px;
padding-right: 8px;
}
.ql-snow .ql-tooltip a.ql-remove::before {
content: 'Remove';
margin-left: 8px;
}
.ql-snow .ql-tooltip a {
line-height: 26px;
}
.ql-snow .ql-tooltip.ql-editing a.ql-preview,
.ql-snow .ql-tooltip.ql-editing a.ql-remove {
display: none;
}
.ql-snow .ql-tooltip.ql-editing input[type=text] {
display: inline-block;
}
.ql-snow .ql-tooltip.ql-editing a.ql-action::after {
border-right: 0px;
content: 'Save';
padding-right: 0px;
}
.ql-snow .ql-tooltip[data-mode=link]::before {
content: "Enter link:";
}
.ql-snow .ql-tooltip[data-mode=formula]::before {
content: "Enter formula:";
}
.ql-snow .ql-tooltip[data-mode=video]::before {
content: "Enter video:";
}
.ql-snow a {
color: #06c;
}
.ql-container.ql-snow {
border: 1px solid #ccc;
}
+3
View File
@@ -4,10 +4,12 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Admin{% endblock %} · JQC</title> <title>{% block title %}Admin{% endblock %} · JQC</title>
<meta name="csrf-token" content="{{ csrf_token() }}">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,600;12..96,700&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,600;12..96,700&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/admin.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/admin.css') }}">
{% block head_extra %}{% endblock %}
</head> </head>
<body class="{% block body_class %}{% endblock %}"> <body class="{% block body_class %}{% endblock %}">
{% if session.get('admin') %} {% if session.get('admin') %}
@@ -38,5 +40,6 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
{% block scripts %}{% endblock %}
</body> </body>
</html> </html>
+62 -7
View File
@@ -4,7 +4,7 @@
<header class="page-head"> <header class="page-head">
<div> <div>
<h1>Content</h1> <h1>Content</h1>
<p class="muted">Edit the sections and topics shown on the public site.</p> <p class="muted">Drag the handles to reorder. Toggle Publish to show or hide on the public site.</p>
</div> </div>
<div class="page-head__actions"> <div class="page-head__actions">
<a class="btn btn--ghost" href="{{ url_for('admin.section_form') }}">+ Section</a> <a class="btn btn--ghost" href="{{ url_for('admin.section_form') }}">+ Section</a>
@@ -16,11 +16,13 @@
<div class="empty">No sections yet. Start by adding one.</div> <div class="empty">No sections yet. Start by adding one.</div>
{% endif %} {% endif %}
<div id="sections">
{% for section in sections %} {% for section in sections %}
<section class="sec-card"> <section class="sec-card" data-section-id="{{ section.id }}">
<div class="sec-card__head"> <div class="sec-card__head">
<div class="sec-card__meta"> <div class="sec-card__meta">
<span class="pill">§{{ '%02d' % section.num }}</span> <button type="button" class="drag-handle sec-drag" title="Drag to reorder sections" aria-label="Reorder section">&#10247;</button>
<span class="pill">&sect;{{ '%02d' % section.num }}</span>
<h2>{{ section.title }}</h2> <h2>{{ section.title }}</h2>
{% if section.subtitle %}<span class="muted">{{ section.subtitle }}</span>{% endif %} {% if section.subtitle %}<span class="muted">{{ section.subtitle }}</span>{% endif %}
</div> </div>
@@ -28,7 +30,7 @@
<a class="btn btn--ghost btn--sm" href="{{ url_for('admin.section_form', section_id=section.id) }}">Edit</a> <a class="btn btn--ghost btn--sm" href="{{ url_for('admin.section_form', section_id=section.id) }}">Edit</a>
<a class="btn btn--ghost btn--sm" href="{{ url_for('admin.topic_form') }}?section={{ section.id }}">+ Topic</a> <a class="btn btn--ghost btn--sm" href="{{ url_for('admin.topic_form') }}?section={{ section.id }}">+ Topic</a>
<form method="post" action="{{ url_for('admin.section_delete', section_id=section.id) }}" <form method="post" action="{{ url_for('admin.section_delete', section_id=section.id) }}"
onsubmit="return confirm('Delete section {{ section.title }} and ALL its topics?');"> onsubmit="return confirm('Delete section &ldquo;{{ section.title }}&rdquo; and ALL its topics?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn--danger btn--sm" type="submit">Delete</button> <button class="btn btn--danger btn--sm" type="submit">Delete</button>
</form> </form>
@@ -36,19 +38,25 @@
</div> </div>
{% if section.topics %} {% if section.topics %}
<ul class="topic-rows"> <ul class="topic-rows" data-section-id="{{ section.id }}">
{% for topic in section.topics %} {% for topic in section.topics %}
<li class="topic-row"> <li class="topic-row {{ 'is-draft' if not topic.is_published }}" data-topic-id="{{ topic.id }}">
<div class="topic-row__main"> <div class="topic-row__main">
<button type="button" class="drag-handle row-drag" title="Drag to reorder" aria-label="Reorder topic">&#10247;</button>
<span class="topic-row__order">{{ topic.sort_order }}</span> <span class="topic-row__order">{{ topic.sort_order }}</span>
<a class="topic-row__title" href="{{ url_for('admin.topic_form', topic_id=topic.id) }}">{{ topic.title | safe }}</a> <a class="topic-row__title" href="{{ url_for('admin.topic_form', topic_id=topic.id) }}">{{ topic.title | safe }}</a>
{% if not topic.is_published %}<span class="chip chip--draft">draft</span>{% endif %}
{% if topic.media_type and topic.media_type != 'none' %}<span class="chip">{{ topic.media_type }}</span>{% endif %} {% if topic.media_type and topic.media_type != 'none' %}<span class="chip">{{ topic.media_type }}</span>{% endif %}
{% if topic.link_url %}<span class="chip chip--link">link</span>{% endif %} {% if topic.link_url %}<span class="chip chip--link">link</span>{% endif %}
</div> </div>
<div class="topic-row__actions"> <div class="topic-row__actions">
<form method="post" action="{{ url_for('admin.topic_toggle', topic_id=topic.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn--ghost btn--sm" type="submit">{{ 'Unpublish' if topic.is_published else 'Publish' }}</button>
</form>
<a class="btn btn--ghost btn--sm" href="{{ url_for('admin.topic_form', topic_id=topic.id) }}">Edit</a> <a class="btn btn--ghost btn--sm" href="{{ url_for('admin.topic_form', topic_id=topic.id) }}">Edit</a>
<form method="post" action="{{ url_for('admin.topic_delete', topic_id=topic.id) }}" <form method="post" action="{{ url_for('admin.topic_delete', topic_id=topic.id) }}"
onsubmit="return confirm('Delete topic {{ topic.title }}?');"> onsubmit="return confirm('Delete topic &ldquo;{{ topic.title }}&rdquo;?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn--danger btn--sm" type="submit">Delete</button> <button class="btn btn--danger btn--sm" type="submit">Delete</button>
</form> </form>
@@ -61,4 +69,51 @@
{% endif %} {% endif %}
</section> </section>
{% endfor %} {% endfor %}
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='vendor/Sortable.min.js') }}"></script>
<script>
(function () {
var token = document.querySelector('meta[name="csrf-token"]').content;
function post(payload, rows) {
fetch('{{ url_for('admin.reorder') }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': token },
body: JSON.stringify(payload)
}).then(function (r) {
if (!r.ok) { console.error('reorder failed', r.status); return; }
if (rows) rows.forEach(function (el, i) {
var n = el.querySelector('.topic-row__order');
if (n) n.textContent = (i + 1) * 10;
});
}).catch(function (e) { console.error(e); });
}
var secWrap = document.getElementById('sections');
if (secWrap && window.Sortable) {
Sortable.create(secWrap, {
handle: '.sec-drag', animation: 150, draggable: '.sec-card',
onEnd: function () {
var order = Array.from(secWrap.querySelectorAll(':scope > .sec-card'))
.map(function (el) { return parseInt(el.dataset.sectionId, 10); });
post({ type: 'section', order: order });
}
});
}
document.querySelectorAll('.topic-rows').forEach(function (ul) {
Sortable.create(ul, {
handle: '.row-drag', animation: 150, draggable: '.topic-row',
onEnd: function () {
var lis = Array.from(ul.querySelectorAll(':scope > .topic-row'));
var order = lis.map(function (el) { return parseInt(el.dataset.topicId, 10); });
post({ type: 'topic', section_id: parseInt(ul.dataset.sectionId, 10), order: order }, lis);
}
});
});
})();
</script>
{% endblock %} {% endblock %}
+58 -3
View File
@@ -1,6 +1,10 @@
{% extends "admin/base.html" %} {% extends "admin/base.html" %}
{% block title %}{{ 'Edit topic' if topic else 'New topic' }}{% endblock %} {% block title %}{{ 'Edit topic' if topic else 'New topic' }}{% endblock %}
{% block head_extra %}
<link href="{{ url_for('static', filename='vendor/quill.snow.css') }}" rel="stylesheet">
{% endblock %}
{% macro val(field, default='') -%} {% macro val(field, default='') -%}
{%- if form is not none -%}{{ form.get(field, default) }} {%- if form is not none -%}{{ form.get(field, default) }}
{%- elif topic is not none -%}{{ topic[field] if topic[field] is not none else default }} {%- elif topic is not none -%}{{ topic[field] if topic[field] is not none else default }}
@@ -50,9 +54,39 @@
<input type="text" name="slug" value="{{ val('slug') }}" placeholder="auto"> <input type="text" name="slug" value="{{ val('slug') }}" placeholder="auto">
</label> </label>
<label class="field"> <div class="field">
<span>Body <small class="muted">(HTML — use &lt;p&gt;, &lt;strong&gt;, &lt;ul&gt;&lt;li&gt;)</small></span> <span>Body</span>
<textarea name="body_html" rows="7">{{ val('body_html') }}</textarea> <div id="editor-toolbar" class="ql-tools">
<span class="ql-formats">
<button class="ql-bold"></button>
<button class="ql-italic"></button>
<button class="ql-underline"></button>
<button class="ql-strike"></button>
</span>
<span class="ql-formats">
<button class="ql-header" value="2"></button>
<button class="ql-header" value="3"></button>
</span>
<span class="ql-formats">
<button class="ql-list" value="ordered"></button>
<button class="ql-list" value="bullet"></button>
<button class="ql-blockquote"></button>
</span>
<span class="ql-formats">
<button class="ql-link"></button>
<button class="ql-clean"></button>
</span>
</div>
<div id="editor"></div>
<!-- Real field. Quill enhances it; if Quill fails to load, this stays a
usable raw-HTML textarea so a save never wipes the body. -->
<textarea name="body_html" id="body_src" rows="7">{{ val('body_html') }}</textarea>
</div>
{% set pub = (form.get('is_published') == '1') if form is not none else (topic.is_published if topic else True) %}
<label class="field field--check">
<input type="checkbox" name="is_published" value="1" {{ 'checked' if pub }}>
<span>Published <small class="muted">(uncheck to keep as a draft — hidden from the public site)</small></span>
</label> </label>
<fieldset class="fieldset"> <fieldset class="fieldset">
@@ -99,3 +133,24 @@
</div> </div>
</form> </form>
{% endblock %} {% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='vendor/quill.min.js') }}"></script>
<script>
(function () {
if (typeof Quill === 'undefined') return; // textarea stays usable
var ta = document.getElementById('body_src');
var toolbar = document.getElementById('editor-toolbar');
var editorEl = document.getElementById('editor');
var quill = new Quill(editorEl, { theme: 'snow', modules: { toolbar: toolbar } });
if (ta.value.trim()) quill.clipboard.dangerouslyPasteHTML(ta.value);
// Switch the UI from textarea to Quill only once it's ready.
document.body.classList.add('quill-on');
ta.form.addEventListener('submit', function () {
ta.value = quill.getText().trim().length ? quill.root.innerHTML : '';
});
})();
</script>
{% endblock %}
+1 -1
View File
@@ -45,7 +45,7 @@
</div> </div>
<ul class="topics" role="list"> <ul class="topics" role="list">
{% for topic in section.topics %} {% for topic in section.published_topics %}
<li class="topic" data-topic> <li class="topic" data-topic>
<button class="topic__head" type="button" <button class="topic__head" type="button"
aria-expanded="false" aria-expanded="false"