Jul 22 - Update - add Rich-text editor, draft/publish, drag-to-reorder
This commit is contained in:
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
Vendored
+2
File diff suppressed because one or more lines are too long
Vendored
+8
File diff suppressed because one or more lines are too long
Vendored
+945
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">⠇</button>
|
||||||
|
<span class="pill">§{{ '%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 “{{ section.title }}” 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">⠇</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 “{{ topic.title }}”?');">
|
||||||
<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 %}
|
||||||
|
|||||||
@@ -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 <p>, <strong>, <ul><li>)</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 %}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user