Get the article editor working

This commit is contained in:
Tim Van Baak 2021-10-10 20:10:22 -07:00
parent 33459925ce
commit 675e42cfa3
7 changed files with 324 additions and 284 deletions

View File

@ -4,9 +4,8 @@ html, body {
margin: 0px; margin: 0px;
} }
div#wrapper { div#wrapper {
max-width: 1128px;
height: 100%; height: 100%;
display:flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: stretch; align-items: stretch;
} }

View File

@ -1,126 +1,152 @@
// Reduce unnecessary requests by checking for no further changes being made (function(){
// before updating in response to a change. /** Article submission state. */
var nonce = 0; const ArticleState = {
DRAFT: 0,
SUBMITTED: 1,
APPROVED: 2
};
function ifNoFurtherChanges(callback, timeout=2000) { /** Article state to be tracked in addition to the editable content. */
var nonce_local = Math.random(); var article = {
state: undefined,
ersatz: false
};
/** Article content as last received from the server. */
var preview = {
title: undefined,
rendered: undefined,
citations: [],
errors: []
}
/** The nonce of the last-made update request. */
let nonce = 0;
/**
* Update request debounce wrapper that executes the callback if no further
* calls are made during the timeout period. If a new call is made, any
* previous calls are skipped.
*/
function ifNoFurtherChanges(callback, timeout)
{
// Stake a claim on the nonce, potentially overwriting a previous
// nonce value.
const nonce_local = 1 + Math.random();
nonce = nonce_local; nonce = nonce_local;
// Wait to see if this call is overwritten in turn.
setTimeout(() => { setTimeout(() => {
if (nonce == nonce_local) { if (nonce == nonce_local)
{
callback(); callback();
nonce = 0; nonce = 0;
} }
}, timeout); }, timeout);
}
// Read data out of params and initialize editor
window.onload = function() {
// Kill noscript message first
document.getElementById("preview").innerHTML = "<p>&nbsp;</p>";
if (document.body.contains(document.getElementById("editor-content"))) {
onContentChange(0);
} }
};
function buildArticleObject() { /** Update the editor controls and preview to match the current state. */
var title = document.getElementById("editor-title").value; function refreshEditor()
var contents = document.getElementById("editor-content").value; {
return { // Enable or disable controls
aid: params.article.aid, const isEditable = article.state == ArticleState.DRAFT;
title: title, const blocked = preview.errors.filter(err => err.severity == 2).length > 0;
status: params.article.status, document.getElementById("editor-title").disabled = !isEditable;
contents: contents document.getElementById("editor-content").disabled = !isEditable;
}; document.getElementById("button-submit").innerText = isEditable ? "Submit article" : "Edit article";
} document.getElementById("button-submit").disabled = blocked;
function update(article) { // Update the preview
var req = new XMLHttpRequest(); const previewHtml = "<h1>" + preview.title + "</h1>\n" + preview.rendered;
req.open("POST", params.updateURL, true);
req.setRequestHeader("Content-type", "application/json");
req.responseType = "json";
req.onreadystatechange = function () {
if (req.readyState == 4 && req.status == 200) {
// Update internal state with the returned article object
params.status = req.response.status;
params.errors = req.response.error.length;
document.getElementById("editor-title").value = req.response.title;
// Set editor editability based on article status
updateEditorStatus();
// Update the preview with the parse information
updatePreview(req.response);
}
};
var payload = { article: article };
req.send(JSON.stringify(payload));
}
function updateEditorStatus() {
var ready = !!params.status.ready || !!params.status.approved;
document.getElementById("editor-title").disabled = ready;
document.getElementById("editor-content").disabled = ready;
var hasErrors = params.errors > 0;
var submitButton = document.getElementById("button-submit");
submitButton.innerText = ready ? "Edit article" : "Submit article";
submitButton.disabled = hasErrors;
}
function updatePreview(response) {
var previewHtml = "<h1>" + response.title + "</h1>\n" + response.rendered;
document.getElementById("preview").innerHTML = previewHtml; document.getElementById("preview").innerHTML = previewHtml;
var citations = "<ol>"; // Fill in the citation block
for (var i = 0; i < response.citations.length; i++) { let citations = "<ol>";
citations += "<li>" + response.citations[i] + "</li>"; preview.citations.forEach(cit => citations += "<li>" + JSON.stringify(cit) + "</li>");
}
citations += "</ol>"; citations += "</ol>";
document.getElementById("preview-citations").innerHTML = citations; document.getElementById("preview-citations").innerHTML = citations;
var info = ""; // Fill in the status message block
for (var i = 0; i < response.info.length; i++) { let statuses = "<ol>";
info += "<span class=\"message-info\">" + response.info[i] + "</span><br>"; preview.errors.forEach(err => statuses += "<li>" + JSON.stringify(err) + "</li>");
statuses += "<ol>";
document.getElementById("preview-control").innerHTML = statuses;
} }
var warning = "";
for (var i = 0; i < response.warning.length; i++) {
warning += "<span class=\"message-warning\">" +
response.warning[i] + "</span><br>";
}
var error = "";
for (var i = 0; i < response.error.length; i++) {
error += "<span class=\"message-error\">" + response.error[i] + "</span><br>";
}
var control = info + warning + error;
document.getElementById("preview-control").innerHTML = control;
}
function onContentChange(timeout=2000) { /** Update the current state with the given data and refresh the editor. */
function updateState(data)
{
article.state = data.state;
article.ersatz = data.ersatz;
preview.title = data.title;
preview.rendered = data.rendered;
preview.citations = data.citations;
preview.errors = data.errors;
refreshEditor();
}
/** Send the article's current content to the server. */
function update()
{
const updateUrl = document.body.dataset.amanuensisUpdateUrl;
const data = {
title: document.getElementById("editor-title").value,
body: document.getElementById("editor-content").value,
state: article.state
};
fetch(updateUrl, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(data)
}).then(response => response.json()).then(updateState);
}
function onContentChange(e, timeout=2000)
{
ifNoFurtherChanges(update, timeout);
}
function submitArticle()
{
ifNoFurtherChanges(() => { ifNoFurtherChanges(() => {
var article = buildArticleObject(); article.state = ArticleState.SUBMITTED;
update(article); update();
}, timeout); },
} /* timeout: */ 0);
}
function submitArticle() { /** Initialize the editor on page load. */
ifNoFurtherChanges(() => { function initializeEditor()
params.article.status.ready = !params.article.status.ready; {
var article = buildArticleObject(); // Kill the noscript message
update(article); document.getElementById("preview").innerHTML = "<p>Loading...</p>";
}, 0); document.getElementById("preview-citations").innerHTML = "<p>Loading...</p>";
} document.getElementById("preview-control").innerHTML = "<p>Loading...</p>";
window.addEventListener("beforeunload", function(e) { // Hook up the controls
if (nonce != 0) { document.getElementById("button-submit").onclick = submitArticle;
document.getElementById("editor-title").oninput = onContentChange;
document.getElementById("editor-content").oninput = onContentChange;
window.addEventListener("beforeunload", e =>
{
if (nonce > 0)
{
e.returnValue = "Are you sure?"; e.returnValue = "Are you sure?";
} }
}); });
window.addEventListener("keydown", e =>
{
if (e.ctrlKey && e.key == 's')
{
e.preventDefault();
onContentChange(e, 0);
}
});
window.addEventListener("keydown", function(event) { // Get the article status information.
if (event.ctrlKey || event.metaKey) const updateUrl = document.body.dataset.amanuensisUpdateUrl;
{ fetch(updateUrl).then(response => response.json()).then(updateState);
if (String.fromCharCode(event.which).toLowerCase() == 's')
{
event.preventDefault();
onContentChange(0);
} }
} window.onload = initializeEditor;
}); })();

View File

@ -15,6 +15,9 @@
{% if current_page == "contents" %}class="current-page" {% if current_page == "contents" %}class="current-page"
{% else %}href="{{ url_for('lexicon.contents', lexicon_name=g.lexicon.name) }}" {% else %}href="{{ url_for('lexicon.contents', lexicon_name=g.lexicon.name) }}"
{% endif %}>Contents</a>{% endblock %} {% endif %}>Contents</a>{% endblock %}
{% block sb_editor %}<a
href="{{ url_for('lexicon.editor.editor', lexicon_name=g.lexicon.name) }}"
>Editor</a>{% endblock %}
{% block sb_posts %}<a {% block sb_posts %}<a
{% if current_page == "posts" %}class="current-page" {% if current_page == "posts" %}class="current-page"
{% else %}href="{{ url_for('lexicon.posts.list', lexicon_name=g.lexicon.name) }}" {% else %}href="{{ url_for('lexicon.posts.list', lexicon_name=g.lexicon.name) }}"
@ -35,6 +38,7 @@
{% set template_sidebar_rows = [ {% set template_sidebar_rows = [
self.sb_characters(), self.sb_characters(),
self.sb_contents(), self.sb_contents(),
self.sb_editor(),
self.sb_posts(), self.sb_posts(),
self.sb_rules(), self.sb_rules(),
self.sb_settings(), self.sb_settings(),

View File

@ -7,6 +7,7 @@ from amanuensis.errors import ArgumentError
from amanuensis.server.helpers import lexicon_param, player_required_if_not_public from amanuensis.server.helpers import lexicon_param, player_required_if_not_public
from .characters import bp as characters_bp from .characters import bp as characters_bp
from .editor import bp as editor_bp
from .forms import LexiconJoinForm from .forms import LexiconJoinForm
from .posts import bp as posts_bp from .posts import bp as posts_bp
from .settings import bp as settings_bp from .settings import bp as settings_bp
@ -16,6 +17,7 @@ bp = Blueprint(
"lexicon", __name__, url_prefix="/lexicon/<lexicon_name>", template_folder="." "lexicon", __name__, url_prefix="/lexicon/<lexicon_name>", template_folder="."
) )
bp.register_blueprint(characters_bp) bp.register_blueprint(characters_bp)
bp.register_blueprint(editor_bp)
bp.register_blueprint(posts_bp) bp.register_blueprint(posts_bp)
bp.register_blueprint(settings_bp) bp.register_blueprint(settings_bp)

View File

@ -0,0 +1,74 @@
from flask import Blueprint, render_template, g
from sqlalchemy import select
from amanuensis.backend import *
from amanuensis.db import *
from amanuensis.parser.core import *
from amanuensis.server.helpers import lexicon_param, player_required
bp = Blueprint("editor", __name__, url_prefix="/editor", template_folder=".")
@bp.get("/<uuid:article_id>")
@lexicon_param
@player_required
def open(lexicon_name, article_id):
db: DbContext = g.db
article: Article = db(
select(Article).where(Article.public_id == article_id)
).scalar_one()
return render_template(
"session.editor.jinja",
lexicon_name=lexicon_name,
article=article,
)
@bp.get("/<uuid:article_id>/update")
@lexicon_param
@player_required
def load(lexicon_name, article_id):
db: DbContext = g.db
article: Article = db(
select(Article).where(Article.public_id == article_id)
).scalar_one()
citations = [
{'title': 'Citation Title', 'type': 'phantom'}
]
errors = [
{'severity': 0, 'message': "OK"},
{'severity': 1, 'message': "Warning"},
{'severity': 2, 'message': "Error"},
]
return {
'title': article.title,
'rendered': article.body,
'state': article.state.value,
'ersatz': article.ersatz,
'citations': citations,
'errors': errors,
}
@bp.post("/<uuid:article_id>/update")
def update(lexicon_name, article_id):
db: DbContext = g.db
article: Article = db(
select(Article).where(Article.public_id == article_id)
).scalar_one()
citations = [
{'title': 'Citation Title', 'type': 'phantom'}
]
errors = [
{'severity': 0, 'message': "OK"},
{'severity': 1, 'message': "Warning"},
]
return {
'title': article.title,
'rendered': article.body,
'state': article.state.value,
'ersatz': article.ersatz,
'citations': citations,
'errors': errors,
}

View File

@ -1,6 +1,3 @@
{% if character and not article %}
{% set characters = [character] %}
{% endif %}
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
@ -10,85 +7,31 @@
<link rel="icon" type="image/png" href="{{ url_for('static', filename='amanuensis.png') }}"> <link rel="icon" type="image/png" href="{{ url_for('static', filename='amanuensis.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='page.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='page.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='editor.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='editor.css') }}">
<script>
params = {
updateURL: "{{ url_for('session.editor_update', name=g.lexicon.cfg.name) }}",
{% if character %}
character: {{ jsonfmt(character) }},
{% else %}
character: null,
{% endif %}
{% if article %}
article: {
aid: {{ jsonfmt(article.aid) }},
status: {{ jsonfmt(article.status) }},
errors: 1,
}
{% else %}
article: null
{% endif %}
};
</script>
<script type="text/javascript" src="{{ url_for('static', filename='editor.js') }}"></script> <script type="text/javascript" src="{{ url_for('static', filename='editor.js') }}"></script>
</head> </head>
<body> <body
data-amanuensis-update-url="{{ url_for('lexicon.editor.load', lexicon_name=lexicon_name, article_id=article.public_id) }}"
>
<div id="wrapper"> <div id="wrapper">
<div id="editor-left" class="column"> <div id="editor-left" class="column">
<section> <section>
{# Thin header bar #} {# Thin header bar #}
<div id="editor-header"> <div id="editor-header">
{# Header always includes backlink to lexicon #} {# Header always includes backlink to lexicon #}
<a href="{{ url_for('session.session', name=g.lexicon.cfg.name) }}"> <a href="{{ url_for('lexicon.contents', lexicon_name=lexicon_name) }}">
{{ g.lexicon.title }} {{ g.lexicon.full_title }}
</a> </a>
{# If article is not finalized, show button to submit and retract #} {# If article is not finalized, show button to submit and retract #}
{% if article and not article.status.approved %} {# {% if article and not article.status.approved %} #}
<button id="button-submit" onclick="submitArticle()" disabled>Submit article</button> {% if article %}
<button
id="button-submit"
disabled
>Submit article</button>
{% endif %} {% endif %}
{# Header always includes character/player info #} {# Header always includes character/player info #}
<span> <span>{{ article.character.name }}</span>
<b>
{% if character %}
{{ character.name }} /
{% endif %}
{{ current_user.cfg.username }}
</b>
</span>
</div> </div>
{# In load mode, `characters` is specified and `article` is #}
{# not, and the main body of the editor column contains a #}
{# list of articles that can be loaded. #}
{% for char in characters %}
<div id="editor-charselect">
<b>{{ char.name }}</b>
<ul>
{% for article in articles %}
{% if article.character == char.cid %}
<li>
<a href="{{ url_for('session.editor', name=g.lexicon.cfg.name, aid=article.aid) }}">
{{ article.title if article.title.strip() else "Untitled" }}</a>
<span>
{% if not article.status.ready %}
[Draft]
{% elif not article.status.approved %}
[Pending]
{% else %}
[Approved]
{% endif %}
</span>
</li>
{% endif %}
{% endfor %}
<li>
<a href="{{ url_for('session.editor_new', name=g.lexicon.cfg.name, cid=char.cid) }}">
New
</a>
</li>
</ul>
</div>
{% endfor %}
{# In edit mode, `article` is specified and `characters` is #}
{# not, and the editor pane contains the article editor. #}
{% if article %} {% if article %}
{# <div id="editor-buttons"> {# <div id="editor-buttons">
Character literals: Character literals:
@ -98,23 +41,23 @@
<button>]</button> <button>]</button>
<button>~</button> <button>~</button>
</div> #} </div> #}
<input id="editor-title" placeholder="Title" oninput="onContentChange()" disabled value="{{ article.title }}"> <input id="editor-title" placeholder="Title" disabled value="{{ article.title }}">
<textarea id="editor-content" class="fullwidth" oninput="onContentChange()" disabled> <textarea id="editor-content" class="fullwidth" disabled>
{# #}{{ article.contents }}{# {{- article.body -}}
#}</textarea> </textarea>
{% endif %} {% endif %}
</section> </section>
</div> </div>
<div id="editor-right" class="column"> <div id="editor-right" class="column">
<section id="preview"> <section id="preview">
<p>This editor requires Javascript to function.</p> <p>This editor requires Javascript to function.</p>
</div> </section>
<section id="preview-citations"> <section id="preview-citations">
<p>&nbsp;</p> <p>&nbsp;</p>
</div> </section>
<section id="preview-control"> <section id="preview-control">
<p>&nbsp;</p> <p>&nbsp;</p>
</div> </fieldset>
</div> </div>
</div> </div>
</body> </body>

View File

@ -8,17 +8,9 @@
<span style="color:#ff0000">{{ message }}</span><br> <span style="color:#ff0000">{{ message }}</span><br>
{% endfor %} {% endfor %}
{% for index in indexed %} {% for article in current_lexicon.articles %}
<b>{{ index }}</b> <p>{{ article.title }} - {{ article.public_id }}</p>
{% if indexed[index] %} <p>{{ article.body }}</p>
<ul>
{% for article in indexed[index] %}
<li><a href="{{ article.title|articlelink }}" class="{{ 'phantom' if not article.character else '' }}">
{{ article.title }}
</a></li>
{% endfor %}
</ul>
{% endif %}
{% endfor %} {% endfor %}
</section> </section>
{% endblock %} {% endblock %}