Add user management pages

This commit is contained in:
Matt Baer 2018-11-08 01:23:08 -05:00
parent ebeacff43c
commit 5e53a1788d
14 changed files with 1183 additions and 0 deletions

18
static/js/ace.js Normal file

File diff suppressed because one or more lines are too long

9
static/js/mode-css.js Normal file

File diff suppressed because one or more lines are too long

105
static/js/postactions.js Normal file
View file

@ -0,0 +1,105 @@
var postActions = function() {
var $container = He.get('moving');
var MultiMove = function(el, id) {
var lbl = el.options[el.selectedIndex].textContent;
var collAlias = el.options[el.selectedIndex].value;
var $lbl = He.$('label[for=move-'+id+']')[0];
$lbl.textContent = "moving to "+lbl+"...";
var params;
if (collAlias == '|anonymous|') {
params = [id];
} else {
params = [{
id: id
}];
}
var callback = function(code, resp) {
if (code == 200) {
for (var i=0; i<resp.data.length; i++) {
if (resp.data[i].code == 200) {
$lbl.innerHTML = "moved to <strong>"+lbl+"</strong>";
var newPostURL = "/"+collAlias+"/"+resp.data[i].post.slug;
try {
// Posts page
He.$('#post-'+resp.data[i].post.id+' > h3 > a')[0].href = newPostURL;
} catch (e) {
// Blog index
var $article = He.get('post-'+resp.data[i].post.id);
$article.className = 'norm moved';
if (collAlias == '|anonymous|') {
$article.innerHTML = '<p><a href="/'+resp.data[i].post.id+'">Unpublished post</a>.</p>';
} else {
$article.innerHTML = '<p>Moved to <a style="font-weight:bold" href="'+newPostURL+'">'+lbl+'</a>.</p>';
}
}
} else {
$lbl.innerHTML = "unable to move: "+resp.data[i].error_msg;
}
}
}
};
if (collAlias == '|anonymous|') {
He.postJSON("/api/posts/disperse", params, callback);
} else {
He.postJSON("/api/collections/"+collAlias+"/collect", params, callback);
}
};
var Move = function(el, id, collAlias) {
var lbl = el.textContent;
try {
var m = lbl.match(/move to (.*)/);
lbl = m[1];
} catch (e) {
if (collAlias == '|anonymous|') {
lbl = "draft";
}
}
el.textContent = "moving to "+lbl+"...";
if (collAlias == '|anonymous|') {
params = [id];
} else {
params = [{
id: id
}];
}
var callback = function(code, resp) {
if (code == 200) {
for (var i=0; i<resp.data.length; i++) {
if (resp.data[i].code == 200) {
el.innerHTML = "moved to <strong>"+lbl+"</strong>";
el.onclick = null;
var newPostURL = "/"+collAlias+"/"+resp.data[i].post.slug;
el.href = newPostURL;
el.title = "View on "+lbl;
try {
// Posts page
He.$('#post-'+resp.data[i].post.id+' > h3 > a')[0].href = newPostURL;
} catch (e) {
// Blog index
var $article = He.get('post-'+resp.data[i].post.id);
$article.className = 'norm moved';
if (collAlias == '|anonymous|') {
$article.innerHTML = '<p><a href="/'+resp.data[i].post.id+'">Unpublished post</a>.</p>';
} else {
$article.innerHTML = '<p>Moved to <a style="font-weight:bold" href="'+newPostURL+'">'+lbl+'</a>.</p>';
}
}
} else {
el.innerHTML = "unable to move: "+resp.data[i].error_msg;
}
}
}
}
if (collAlias == '|anonymous|') {
He.postJSON("/api/posts/disperse", params, callback);
} else {
He.postJSON("/api/collections/"+collAlias+"/collect", params, callback);
}
};
return {
move: Move,
multiMove: MultiMove,
};
}();

315
static/js/posts.js Normal file
View file

@ -0,0 +1,315 @@
/**
* Functionality for managing local Write.as posts.
*
* Dependencies:
* h.js
*/
function toggleTheme() {
var btns;
try {
btns = Array.prototype.slice.call(document.getElementById('belt').querySelectorAll('.tool img'));
} catch (e) {}
if (document.body.className == 'light') {
document.body.className = 'dark';
try {
for (var i=0; i<btns.length; i++) {
btns[i].src = btns[i].src.replace('_dark@2x.png', '@2x.png');
}
} catch (e) {}
} else if (document.body.className == 'dark') {
document.body.className = 'light';
try {
for (var i=0; i<btns.length; i++) {
btns[i].src = btns[i].src.replace('@2x.png', '_dark@2x.png');
}
} catch (e) {}
} else {
// Don't alter the theme
return;
}
H.set('padTheme', document.body.className);
}
if (H.get('padTheme', 'light') != 'light') {
toggleTheme();
}
var deleting = false;
function delPost(e, id, owned) {
e.preventDefault();
if (deleting) {
return;
}
// TODO: UNDO!
if (window.confirm('Are you sure you want to delete this post?')) {
var token;
for (var i=0; i<posts.length; i++) {
if (posts[i].id == id) {
token = posts[i].token;
break;
}
}
if (owned || token) {
// AJAX
deletePost(id, token, function() {
// Remove post from list
var $postEl = document.getElementById('post-' + id);
$postEl.parentNode.removeChild($postEl);
if (posts.length == 0) {
displayNoPosts();
return;
}
// Fill in full page of posts
var $postsChildren = $posts.el.getElementsByClassName('post');
if ($postsChildren.length < postsPerPage && $postsChildren.length < posts.length) {
var lastVisiblePostID = $postsChildren[$postsChildren.length-1].id;
lastVisiblePostID = lastVisiblePostID.substr(lastVisiblePostID.indexOf('-')+1);
for (var i=0; i<posts.length-1; i++) {
if (posts[i].id == lastVisiblePostID) {
var $moreBtn = document.getElementById('more-posts');
if ($moreBtn) {
// Should always land here (?)
$posts.el.insertBefore(createPostEl(posts[i-1]), $moreBtn);
} else {
$posts.el.appendChild(createPostEl(posts[i-1]));
}
}
}
}
});
} else {
alert('Something went seriously wrong. Try refreshing.');
}
}
}
var getFormattedDate = function(d) {
var mos = [
"January", "February", "March",
"April", "May", "June", "July",
"August", "September", "October",
"November", "December"
];
var day = d.getDate();
var mo = d.getMonth();
var yr = d.getFullYear();
return mos[mo] + ' ' + day + ', ' + yr;
};
var posts = JSON.parse(H.get('posts', '[]'));
var initialListPop = function() {
pages = Math.ceil(posts.length / postsPerPage);
loadPage(page, true);
};
var $posts = H.getEl("posts");
if ($posts.el == null) {
$posts = H.getEl("unsynced-posts");
}
$posts.el.innerHTML = '<p class="status">Reading...</p>';
var createMorePostsEl = function() {
var $more = document.createElement('div');
var nextPage = page+1;
$more.id = 'more-posts';
$more.innerHTML = '<p><a href="#' + nextPage + '">More...</a></p>';
return $more;
};
var localPosts = function() {
var $delPost, lastDelPost, lastInfoHTML;
var $info = He.get('unsynced-posts-info');
var findPostIdx = function(id) {
for (var i=0; i<posts.length; i++) {
if (posts[i].id == id) {
return i;
}
}
return -1;
};
var DismissError = function(e, el) {
e.preventDefault();
var $errorMsg = el.parentNode.previousElementSibling;
$errorMsg.parentNode.removeChild($errorMsg);
var $errorMsgNav = el.parentNode;
$errorMsgNav.parentNode.removeChild($errorMsgNav);
};
var DeletePostLocal = function(e, el, id) {
e.preventDefault();
if (!window.confirm('Are you sure you want to delete this post?')) {
return;
}
var i = findPostIdx(id);
if (i > -1) {
lastDelPost = posts.splice(i, 1)[0];
$delPost = H.getEl('post-'+id);
$delPost.setClass('del-undo');
var $unsyncPosts = document.getElementById('unsynced-posts');
var visible = $unsyncPosts.children.length;
for (var i=0; i < $unsyncPosts.children.length; i++) { // NOTE: *.children support in IE9+
if ($unsyncPosts.children[i].className.indexOf('del-undo') !== -1) {
visible--;
}
}
if (visible == 0) {
H.getEl('unsynced-posts-header').hide();
// TODO: fix undo functionality and don't do the following:
H.getEl('unsynced-posts-info').hide();
}
H.set('posts', JSON.stringify(posts));
// TODO: fix undo functionality and re-add
//lastInfoHTML = $info.innerHTML;
//$info.innerHTML = 'Unsynced entry deleted. <a href="#" onclick="localPosts.undoDelete()">Undo</a>.';
}
};
var UndoDelete = function() {
// TODO: fix this header reappearing
H.getEl('unsynced-posts-header').show();
$delPost.removeClass('del-undo');
$info.innerHTML = lastInfoHTML;
};
return {
dismissError: DismissError,
deletePost: DeletePostLocal,
undoDelete: UndoDelete,
};
}();
var createPostEl = function(post) {
var $post = document.createElement('div');
var title = (post.title || post.id);
title = title.replace(/</g, "&lt;");
$post.id = 'post-' + post.id;
$post.className = 'post';
$post.innerHTML = '<h3><a href="/' + post.id + '">' + title + '</a></h3>';
var posted = "";
if (post.created) {
posted = getFormattedDate(new Date(post.created))
}
var hasDraft = H.exists('draft' + post.id);
$post.innerHTML += '<h4><date>' + posted + '</date> <a class="action" href="/pad/' + post.id + '">edit' + (hasDraft ? 'ed' : '') + '</a> <a class="delete action" href="/' + post.id + '" onclick="delPost(event, \'' + post.id + '\')">delete</a></h4>';
if (post.error) {
$post.innerHTML += '<p class="error"><strong>Sync error:</strong> ' + post.error + ' <nav><a href="#" onclick="localPosts.dismissError(event, this)">dismiss</a> <a href="#" onclick="localPosts.deletePost(event, this, \''+post.id+'\')">remove post</a></nav></p>';
}
if (post.summary) {
$post.innerHTML += '<p>' + post.summary.replace(/</g, "&lt;") + '</p>';
}
return $post;
};
var loadPage = function(p, loadAll) {
if (loadAll) {
$posts.el.innerHTML = '';
}
var startPost = posts.length - 1 - (loadAll ? 0 : ((p-1)*postsPerPage));
var endPost = posts.length - 1 - (p*postsPerPage);
for (var i=startPost; i>=0 && i>endPost; i--) {
$posts.el.appendChild(createPostEl(posts[i]));
}
if (loadAll) {
if (p < pages) {
$posts.el.appendChild(createMorePostsEl());
}
} else {
var $moreEl = document.getElementById('more-posts');
$moreEl.parentNode.removeChild($moreEl);
}
try {
postsLoaded(posts.length);
} catch (e) {}
};
var getPageNum = function(url) {
var hash;
if (url) {
hash = url.substr(url.indexOf('#')+1);
} else {
hash = window.location.hash.substr(1);
}
var page = hash || 1;
page = parseInt(page);
if (isNaN(page)) {
page = 1;
}
return page;
};
var postsPerPage = 10;
var pages = 0;
var page = getPageNum();
window.addEventListener('hashchange', function(e) {
var newPage = getPageNum();
var didPageIncrement = newPage == getPageNum(e.oldURL) + 1;
loadPage(newPage, !didPageIncrement);
});
var deletePost = function(postID, token, callback) {
deleting = true;
var $delBtn = document.getElementById('post-' + postID).getElementsByClassName('delete action')[0];
$delBtn.innerHTML = '...';
var http = new XMLHttpRequest();
var url = "/api/posts/" + postID + (typeof token !== 'undefined' ? "?token=" + encodeURIComponent(token) : '');
http.open("DELETE", url, true);
http.onreadystatechange = function() {
if (http.readyState == 4) {
deleting = false;
if (http.status == 204 || http.status == 404) {
for (var i=0; i<posts.length; i++) {
if (posts[i].id == postID) {
// TODO: use this return value, along will full content, for restoring post
posts.splice(i, 1);
break;
}
}
H.set('posts', JSON.stringify(posts));
callback();
} else if (http.status == 409) {
$delBtn.innerHTML = 'delete';
alert("Post is synced to another account. Delete the post from that account instead.");
// TODO: show "remove" button instead of "delete" now
// Persist that state.
// Have it remove the post locally only.
} else {
$delBtn.innerHTML = 'delete';
alert("Failed to delete. Please try again.");
}
}
}
http.send();
};
var hasWritten = H.get('lastDoc', '') !== '';
var displayNoPosts = function() {
if (auth) {
$posts.el.innerHTML = '';
return;
}
var cta = '<a href="/pad">Create a post</a> and it\'ll appear here.';
if (hasWritten) {
cta = '<a href="/pad">Finish your post</a> and it\'ll appear here.';
}
H.getEl("posts").el.innerHTML = '<p class="status">No posts created yet.</p><p class="status">' + cta + '</p>';
};
if (posts.length == 0) {
displayNoPosts();
} else {
initialListPop();
}

View file

@ -0,0 +1,9 @@
ace.define("ace/theme/chrome",["require","exports","module","ace/lib/dom"],function(e,t,n){t.isDark=!1,t.cssClass="ace-chrome",t.cssText='.ace-chrome .ace_gutter {background: #ebebeb;color: #333;overflow : hidden;}.ace-chrome .ace_print-margin {width: 1px;background: #e8e8e8;}.ace-chrome {background-color: #FFFFFF;color: black;}.ace-chrome .ace_cursor {color: black;}.ace-chrome .ace_invisible {color: rgb(191, 191, 191);}.ace-chrome .ace_constant.ace_buildin {color: rgb(88, 72, 246);}.ace-chrome .ace_constant.ace_language {color: rgb(88, 92, 246);}.ace-chrome .ace_constant.ace_library {color: rgb(6, 150, 14);}.ace-chrome .ace_invalid {background-color: rgb(153, 0, 0);color: white;}.ace-chrome .ace_fold {}.ace-chrome .ace_support.ace_function {color: rgb(60, 76, 114);}.ace-chrome .ace_support.ace_constant {color: rgb(6, 150, 14);}.ace-chrome .ace_support.ace_type,.ace-chrome .ace_support.ace_class.ace-chrome .ace_support.ace_other {color: rgb(109, 121, 222);}.ace-chrome .ace_variable.ace_parameter {font-style:italic;color:#FD971F;}.ace-chrome .ace_keyword.ace_operator {color: rgb(104, 118, 135);}.ace-chrome .ace_comment {color: #236e24;}.ace-chrome .ace_comment.ace_doc {color: #236e24;}.ace-chrome .ace_comment.ace_doc.ace_tag {color: #236e24;}.ace-chrome .ace_constant.ace_numeric {color: rgb(0, 0, 205);}.ace-chrome .ace_variable {color: rgb(49, 132, 149);}.ace-chrome .ace_xml-pe {color: rgb(104, 104, 91);}.ace-chrome .ace_entity.ace_name.ace_function {color: #0000A2;}.ace-chrome .ace_heading {color: rgb(12, 7, 255);}.ace-chrome .ace_list {color:rgb(185, 6, 144);}.ace-chrome .ace_marker-layer .ace_selection {background: rgb(181, 213, 255);}.ace-chrome .ace_marker-layer .ace_step {background: rgb(252, 255, 0);}.ace-chrome .ace_marker-layer .ace_stack {background: rgb(164, 229, 101);}.ace-chrome .ace_marker-layer .ace_bracket {margin: -1px 0 0 -1px;border: 1px solid rgb(192, 192, 192);}.ace-chrome .ace_marker-layer .ace_active-line {background: rgba(0, 0, 0, 0.07);}.ace-chrome .ace_gutter-active-line {background-color : #dcdcdc;}.ace-chrome .ace_marker-layer .ace_selected-word {background: rgb(250, 250, 255);border: 1px solid rgb(200, 200, 250);}.ace-chrome .ace_storage,.ace-chrome .ace_keyword,.ace-chrome .ace_meta.ace_tag {color: rgb(147, 15, 128);}.ace-chrome .ace_string.ace_regex {color: rgb(255, 0, 0)}.ace-chrome .ace_string {color: #1A1AA6;}.ace-chrome .ace_entity.ace_other.ace_attribute-name {color: #994409;}.ace-chrome .ace_indent-guide {background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAE0lEQVQImWP4////f4bLly//BwAmVgd1/w11/gAAAABJRU5ErkJggg==") right repeat-y;}';var r=e("../lib/dom");r.importCssString(t.cssText,t.cssClass)});
(function() {
ace.require(["ace/theme/chrome"], function(m) {
if (typeof module == "object" && typeof exports == "object" && module) {
module.exports = m;
}
});
})();

1
static/js/worker-css.js Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,145 @@
{{define "articles"}}
{{template "header" .}}
<div class="snug content-container">
{{if .Flashes}}<ul class="errors">
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>{{end}}
<h2 id="posts-header">drafts</h2>
{{ if .AnonymousPosts }}<div class="atoms posts">
{{ range $el := .AnonymousPosts }}<div id="post-{{.ID}}" class="post">
<h3><a href="/{{.ID}}" itemprop="url">{{.DisplayTitle}}</a></h3>
<h4>
<date datetime="{{.Created}}" pubdate itemprop="datePublished" content="{{.Created}}">{{.DisplayDate}}</date>
<a class="action" href="/{{.ID}}/edit">edit</a>
<a class="delete action" href="/{{.ID}}" onclick="delPost(event, '{{.ID}}', true)">delete</a>
{{ if $.Collections }}
{{if gt (len $.Collections) 1}}<div class="action flat-select">
<select id="move-{{.ID}}" onchange="postActions.multiMove(this, '{{.ID}}')" title="Move this post to one of your blogs">
<option style="display:none"></option>
{{range $.Collections}}<option value="{{.Alias}}">{{.DisplayTitle}}</option>{{end}}
</select>
<label for="move-{{.ID}}">move to...</label>
<img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" />
</div>{{else}}
{{range $.Collections}}
<a class="action" href="/{{$el.ID}}" title="Publish this post to your blog '{{.DisplayTitle}}'" onclick="postActions.move(this, '{{$el.ID}}', '{{.Alias}}');return false">move to {{.DisplayTitle}}</a>
{{end}}
{{end}}
{{ end }}
</h4>
{{if .Summary}}<p>{{.Summary}}</p>{{end}}
</div>{{end}}
</div>{{ else }}<div id="no-posts-published"><p>You haven't saved any drafts yet.</p>
<p>They'll show up here once you do. Find your blog posts from the <a href="/me/c/">Blogs</a> page.</p>
<p class="text-cta"><a href="/">Start writing</a></p></div>{{ end }}
<div id="moving"></div>
<h2 id="unsynced-posts-header" style="display: none">unsynced posts</h2>
<div id="unsynced-posts-info" style="margin-top: 1em"></div>
<div id="unsynced-posts" class="atoms"></div>
</div>
<script src="/js/h.js"></script>
<script src="/js/postactions.js"></script>
<script>
var auth = true;
function postsLoaded(n) {
if (n == 0) {
return;
}
document.getElementById('unsynced-posts-header').style.display = 'block';
var syncing = false;
var $pInfo = document.getElementById('unsynced-posts-info');
$pInfo.className = 'alert info';
var plural = n != 1;
$pInfo.innerHTML = '<p>You have <strong>'+n+'</strong> post'+(plural?'s that aren\'t':' that isn\'t')+' synced to your account yet. <a href="#" id="btn-sync">Sync '+(plural?'them':'it')+' now</a>.</p>';
var $noPosts = document.getElementById('no-posts-published');
if ($noPosts != null) {
$noPosts.style.display = 'none';
document.getElementById('posts-header').style.display = 'none';
}
H.getEl('btn-sync').on('click', function(e) {
e.preventDefault();
if (syncing) {
return;
}
var http = new XMLHttpRequest();
var params = [];
var posts = JSON.parse(H.get('posts', '[]'));
if (posts.length > 0) {
for (var i=0; i<posts.length; i++) {
params.push({id: posts[i].id, token: posts[i].token});
}
}
this.style.fontWeight = 'bold';
this.innerText = 'Syncing '+(plural?'them':'it')+' now...';
http.open("POST", "/api/posts/claim", true);
// Send the proper header information along with the request
http.setRequestHeader("Content-type", "application/json");
http.onreadystatechange = function() {
if (http.readyState == 4) {
syncing = false;
this.innerText = 'Importing '+(plural?'them':'it')+' now...';
if (http.status == 200) {
var res = JSON.parse(http.responseText);
if (res.data.length > 0) {
if (res.data.length != posts.length) {
// TODO: handle something that royally fucked up
console.error("Request and result array length didn't match!");
return;
}
for (var i=0; i<res.data.length; i++) {
if (res.data[i].code == 200) {
// Post successfully claimed.
for (var j=0; j<posts.length; j++) {
// Find post in local store
if (posts[j].id == res.data[i].post.id) {
// Remove this post
posts.splice(j, 1);
break;
}
}
} else {
for (var j=0; j<posts.length; j++) {
// Find post in local store
if (posts[j].id == res.data[i].id) {
// Note the error in the local post
posts[j].error = res.data[i].error_msg;
break;
}
}
}
}
H.set('posts', JSON.stringify(posts));
location.reload();
}
} else {
// TODO: handle error visually (option to retry)
console.error("Didn't work at all, man.");
this.style.fontWeight = 'normal';
this.innerText = 'Sync '+(plural?'them':'it')+' now';
}
}
}
http.send(JSON.stringify(params));
syncing = true;
});
}
</script>
<script src="/js/posts.js"></script>
{{template "footer" .}}
{{end}}

View file

@ -0,0 +1,234 @@
{{define "upgrade"}}
<p><a href="/me/plan?to=/me/c/{{.Alias}}">Upgrade</a> for <span>$40 / year</span> to edit.</p>
{{end}}
{{define "collection"}}
{{template "header" .}}
<div class="content-container snug">
<div id="overlay"></div>
<h2>Customize {{.DisplayTitle}} <a href="/{{.Alias}}/">view blog</a></h2>
{{if .Flashes}}<ul class="errors">
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>{{end}}
<form name="customize-form" action="/api/collections/{{.Alias}}" method="post" onsubmit="return disableSubmit()">
<div id="collection-options">
<div style="text-align:center">
<h1><input type="text" name="title" id="title" value="{{.DisplayTitle}}" placeholder="Title" /></h1>
<p><input type="text" name="description" id="description" value="{{.Description}}" placeholder="Description" /></p>
</div>
<div class="option">
<h2><a name="preferred-url"></a>URL</h2>
<div class="section">
{{if eq .Alias .Username}}<p style="font-size: 0.8em">This blog uses your username in its URL{{if .Federation}} and fediverse handle{{end}}. You can change it in your <a href="/me/settings">Account Settings</a>.</p>{{end}}
<ul style="list-style:none">
<li>
{{.FriendlyHost}}/<strong>{{.Alias}}</strong>/
</li>
<li>
<strong id="normal-handle-env" class="fedi-handle" {{if or (not .Federation) .SingleUser}}style="display:none"{{end}}>@<span id="fedi-handle">{{.Alias}}</span>@<span id="fedi-domain">{{.FriendlyHost}}</span></strong>
</li>
</ul>
</div>
</div>
<div class="option">
<h2>Publicity</h2>
<div class="section">
<ul style="list-style:none">
<li>
<label><input type="radio" name="visibility" id="visibility-unlisted" value="0" {{if .IsUnlisted}}checked="checked"{{end}} />
Unlisted
</label>
<p>This blog is visible to anyone with its link.</p>
</li>
<li>
<label class="option-text"><input type="radio" name="visibility" id="visibility-private" value="2" {{if .IsPrivate}}checked="checked"{{end}} />
Private
</label>
<p>Only you may read this blog (while you're logged in).</p>
</li>
<li>
<label class="option-text"><input type="radio" name="visibility" id="visibility-protected" value="4" {{if .IsProtected}}checked="checked"{{end}} />
Password-protected: <input type="password" class="low-profile" name="password" id="collection-pass" autocomplete="new-password" placeholder="{{if .IsProtected}}xxxxxxxxxxxxxxxx{{else}}a memorable password{{end}}" />
</label>
<p>A password is required to read this blog.</p>
</li>
</ul>
</div>
</div>
<div class="option">
<h2>Display Format</h2>
<div class="section">
<p class="explain">Customize how your posts display on your page.
</p>
<ul style="list-style:none">
<li>
<label><input type="radio" name="format" id="format-blog" value="blog" {{if or (not .Format) (eq .Format "blog")}}checked="checked"{{end}} />
Blog
</label>
<p>Dates are shown. Latest posts listed first.</p>
</li>
<li>
<label class="option-text"><input type="radio" name="format" id="format-novel" value="novel" {{if eq .Format "novel"}}checked="checked"{{end}} />
Novel
</label>
<p>No dates shown. Oldest posts first.</p>
</li>
<li>
<label class="option-text"><input type="radio" name="format" id="format-notebook" value="notebook" {{if eq .Format "notebook"}}checked="checked"{{end}} />
Notebook
</label>
<p>No dates shown. Latest posts first.</p>
</li>
</ul>
</div>
</div>
<div class="option">
<h2>Text Rendering</h2>
<div class="section">
<p class="explain">Customize how plain text renders on your blog.</p>
<ul style="list-style:none">
<li>
<label class="option-text disabled"><input type="checkbox" name="markdown" checked="checked" disabled />
Markdown
</label>
</li>
<li>
<label><input type="checkbox" name="mathjax" {{if .RenderMathJax}}checked="checked"{{end}} />
MathJax
</label>
</li>
</ul>
</div>
</div>
<div class="option">
<h2>Custom CSS</h2>
<div class="section">
<textarea id="css-editor" class="section codable" name="style_sheet">{{.StyleSheet}}</textarea>
<p class="explain">See our guide on <a href="https://guides.write.as/customizing/#custom-css">customization</a>.</p>
</div>
</div>
<div class="option" style="text-align: center; margin-top: 4em;">
<input type="submit" id="save-changes" value="Save changes" />
<p><a href="/{{.Alias}}/">View Blog</a></p>
{{if ne .Alias .Username}}<p><a class="danger" href="#modal-delete" onclick="promptDelete();">Delete Blog...</a></p>{{end}}
</div>
</div>
</form>
</div>
<div id="modal-delete" class="modal">
<h2>Are you sure you want to delete this blog?</h2>
<div class="body short">
<p style="text-align:left">This will permanently erase <strong>{{.DisplayTitle}}</strong> ({{.FriendlyHost}}/{{.Alias}}) from the internet. Any posts on this blog will be saved and made into drafts (found on your <a href="/me/posts/">Drafts</a> page).</p>
<p>If you're sure you want to delete this blog, enter its name in the box below and press <strong>Delete</strong>.</p>
<ul id="delete-errors" class="errors"></ul>
<input id="confirm-text" placeholder="{{.Alias}}" type="text" class="boxy" style="margin-top: 0.5em;" />
<div style="text-align:right; margin-top: 1em;">
<a id="cancel-delete" style="margin-right:2em" href="#">Cancel</a>
<button id="btn-delete" class="danger" onclick="deleteBlog(); return false;">Delete</button>
</div>
</div>
</div>
<script src="/js/h.js"></script>
<script src="/js/ace.js" type="text/javascript" charset="utf-8"></script>
<script>
// Begin shared modal code
function showModal(id) {
document.getElementById('overlay').style.display = 'block';
document.getElementById('modal-'+id).style.display = 'block';
}
var closeModals = function(e) {
e.preventDefault();
document.getElementById('overlay').style.display = 'none';
var modals = document.querySelectorAll('.modal');
for (var i=0; i<modals.length; i++) {
modals[i].style.display = 'none';
}
};
H.getEl('overlay').on('click', closeModals);
H.getEl('cancel-delete').on('click', closeModals);
// end
var deleteBlog = function(e) {
if (document.getElementById('confirm-text').value != '{{.Alias}}') {
document.getElementById('delete-errors').innerHTML = '<li class="urgent">Enter <strong>{{.Alias}}</strong> in the box below.</li>';
return;
}
// Clear errors
document.getElementById('delete-errors').innerHTML = '';
document.getElementById('btn-delete').innerHTML = 'Deleting...';
var http = new XMLHttpRequest();
var url = "/api/collections/{{.Alias}}?web=1";
http.open("DELETE", url, true);
http.setRequestHeader("Content-type", "application/json");
http.onreadystatechange = function() {
if (http.readyState == 4) {
if (http.status == 204) {
window.location = '/me/c/';
} else {
var data = JSON.parse(http.responseText);
document.getElementById('delete-errors').innerHTML = '<li class="urgent">'+data.error_msg+'</li>';
document.getElementById('btn-delete').innerHTML = 'Delete';
}
}
};
http.send(null);
};
function createHidden(theForm, key, value) {
var input = document.createElement('input');
input.type = 'hidden';
input.name = key;
input.value = value;
theForm.appendChild(input);
}
function disableSubmit() {
var $form = document.forms['customize-form'];
createHidden($form, 'style_sheet', cssEditor.getSession().getValue());
createHidden($form, 'script', jsEditor.getSession().getValue());
var $btn = document.getElementById("save-changes");
$btn.value = "Saving changes...";
$btn.disabled = true;
return true;
}
function promptDelete() {
showModal("delete");
}
var $fediDomain = document.getElementById('fedi-domain');
var $fediCustomDomain = document.getElementById('fedi-custom-domain');
var $customDomain = document.getElementById('domain-alias');
var $customHandleEnv = document.getElementById('custom-handle-env');
var $normalHandleEnv = document.getElementById('normal-handle-env');
var opt = {
showLineNumbers: false,
showPrintMargin: 0,
};
var theme = "ace/theme/chrome";
var cssEditor = ace.edit("css-editor");
cssEditor.setTheme(theme);
cssEditor.session.setMode("ace/mode/css");
cssEditor.setOptions(opt);
var jsEditor = ace.edit("js-editor");
jsEditor.setTheme(theme);
jsEditor.session.setMode("ace/mode/javascript");
jsEditor.setOptions(opt);
</script>
{{template "footer" .}}
{{end}}

View file

@ -0,0 +1,111 @@
{{define "collections"}}
{{template "header" .}}
<div class="snug content-container">
{{if .Flashes}}<ul class="errors">
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>{{end}}
<h2>blogs</h2>
<ul class="atoms collections">
{{range $i, $el := .Collections}}<li class="collection"><h3>
<a class="title" href="/{{.Alias}}/">{{if .Title}}{{.Title}}{{else}}{{.Alias}}{{end}}</a>
</h3>
<h4>
<a class="action new-post" href="/#{{.Alias}}">new post</a>
<a class="action" href="/me/c/{{.Alias}}">customize</a>
<a class="action" href="/me/c/{{.Alias}}/stats">stats</a>
</h4>
{{if .Description}}<p class="description">{{.Description}}</p>{{end}}
</li>{{end}}
<li id="create-collection">
{{if not .NewBlogsDisabled}}
<form method="POST" action="/api/collections" id="new-collection-form" onsubmit="return createCollection()">
<h4>
<input type="text" name="title" placeholder="Blog name" id="blog-name">
<input type="hidden" name="web" value="true" />
<input type="submit" value="Create" id="create-collection-btn">
</h4>
</form>
{{end}}
</li>
</ul>
{{if not .NewBlogsDisabled}}<p style="margin-top:0"><a id="new-collection" href="#new-collection">New blog</a></p>{{end}}
</div>
{{template "foot" .}}
<script src="/js/h.js"></script>
<script>
function createCollection() {
var input = He.get('blog-name');
if (input.value == "") {
return false;
}
var form = He.get('new-collection-form');
var submit = He.get('create-collection-btn');
submit.value = "Creating...";
submit.disabled = "disabled";
He.postJSON("/api/collections", {
title: input.value,
web: true
}, function(code, data) {
if (data.code == 201) {
location.reload();
} else {
var $createColl = document.getElementById('create-collection');
var $submit = $createColl.querySelector('input[type=submit]');
$submit.value = "Create";
$submit.disabled = "";
var $err = $createColl.querySelector('.error');
if (data.code == 409) {
if ($err === null) {
var url = He.create('span');
url.className = "error";
url.innerText = "This name is taken.";
$createColl.appendChild(url);
} else {
$err.innerText = "This name is taken.";
}
} else {
if ($err === null) {
var url = He.create('span');
url.className = "error";
url.innerText = data.error_msg;
$createColl.appendChild(url);
} else {
$err.innerText = "This name is taken.";
}
}
}
});
return false;
};
(function() {
H.getEl('new-collection').on('click', function(e) {
e.preventDefault();
var collForm = He.get('create-collection');
if (collForm.style.display == '' || collForm.style.display == 'none') {
// Show form
this.textContent = "Cancel";
collForm.style.display = 'list-item';
collForm.querySelector('input[type=text]').focus();
return;
}
// Hide form
this.textContent = "New blog";
collForm.style.display = 'none';
});
if (location.hash == '#new-collection' || location.hash == '#new') {
var collForm = He.get('create-collection');
collForm.style.display = 'list-item';
collForm.querySelector('input[type=text]').focus();
He.get('new-collection').textContent = "Cancel";
}
}());
</script>
{{template "body-end" .}}
{{end}}

View file

@ -0,0 +1,28 @@
{{define "export"}}
{{template "header" .}}
<div class="snug content-container">
<h2 id="posts-header">Export</h2>
<p>Your data on {{.SiteName}} is always free. Download and back-up your work any time.</p>
<table class="classy export">
<tr>
<th style="width: 40%">Export</th>
<th colspan="2">Format</th>
</tr>
<tr>
<th>Posts</th>
<td><p class="text-cta"><a href="/me/posts/export.csv">CSV</a></p></td>
<td><p class="text-cta"><a href="/me/posts/export.zip">ZIP</a></p></td>
</tr>
<tr>
<th>User + Blogs + Posts</th>
<td><p class="text-cta"><a href="/me/export.json">JSON</a></p></td>
<td><p class="text-cta"><a href="/me/export.json?pretty=1">Prettified</a></p></td>
</tr>
</table>
</div>
{{template "footer" .}}
{{end}}

View file

@ -0,0 +1,35 @@
{{define "footer"}}
{{template "foot" .}}
{{template "body-end" .}}
{{end}}
{{define "foot"}}
</div>
<footer>
<hr />
<nav>
<a class="home" href="/">{{.SiteName}}</a>
<a href="/about">about</a>
<a href="https://writefreely.org/guide" target="guides">guide</a>
<a href="/privacy">privacy</a>
<a>{{.Version}}</a>
</nav>
</footer>
<script type="text/javascript">
try { // Google Fonts
WebFontConfig = {
custom: { families: [ 'Lora:400,700:latin' ], urls: [ '/css/fonts.css' ] }
};
(function() {
var wf = document.createElement('script');
wf.src = '/js/webfont.js';
wf.type = 'text/javascript';
wf.async = 'true';
var s = document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(wf, s);
})();
} catch (e) { /* ¯\_(ツ)_/¯ */ }
</script>
{{end}}
{{define "body-end"}}</body>
</html>{{end}}

View file

@ -0,0 +1,37 @@
{{define "header"}}<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<title>{{.PageTitle}} {{if .Separator}}{{.Separator}}{{else}}&mdash;{{end}} {{.SiteName}}</title>
<link rel="stylesheet" type="text/css" href="/css/write.css" />
<link rel="shortcut icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#888888" />
<meta name="apple-mobile-web-app-title" content="{{.SiteName}}">
<link rel="apple-touch-icon" sizes="152x152" href="/img/touch-icon-152.png">
<link rel="apple-touch-icon" sizes="167x167" href="/img/touch-icon-167.png">
<link rel="apple-touch-icon" sizes="180x180" href="/img/touch-icon-180.png">
</head>
<body id="me">
<header>
<h1><a href="/" title="Return to editor">{{.SiteName}}</a></h1>
<nav id="user-nav">
<nav class="dropdown-nav">
<ul><li><a>{{.Username}}</a> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /><ul>
<li><a href="/me/settings">Account settings</a></li>
<li><a href="/me/export">Export</a></li>
<li class="separator"><hr /></li>
<li><a href="/me/logout">Log out</a></li>
</ul></li>
</ul>
</nav>
<nav class="tabs">
<a href="/me/c/"{{if eq .Path "/me/c/"}} class="selected"{{end}}>Blogs</a>
<a href="/me/posts/"{{if eq .Path "/me/posts/"}} class="selected"{{end}}>Drafts</a>
</nav>
</nav>
</header>
<div id="official-writing">
{{end}}

View file

@ -0,0 +1,83 @@
{{define "settings"}}
{{template "header" .}}
<style type="text/css">
.option { margin: 2em 0em; }
h3 { font-weight: normal; }
.section > *:not(input) { font-size: 0.86em; }
</style>
<div class="content-container snug regular">
<h2>{{if .IsLogOut}}Before you go...{{else}}Account Settings{{end}}</h2>
{{if .Flashes}}<ul class="errors">
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
</ul>{{end}}
{{ if .IsLogOut }}
<div class="alert info">
<p class="introduction">Please add an <strong>email address</strong> and/or <strong>passphrase</strong> so you can log in again later.</p>
</div>
{{ else }}
<div class="option">
<p>Change your account settings here.</p>
</div>
<form method="post" action="/api/me/self" autocomplete="false">
<div class="option">
<h3>Username</h3>
<div class="section">
<input type="text" name="username" value="{{.Username}}" tabindex="1" />
<input type="submit" value="Update" style="margin-left: 1em;" />
</div>
</div>
</form>
{{ end }}
<form method="post" action="/api/me/self" autocomplete="false">
<input type="hidden" name="logout" value="{{.IsLogOut}}" />
<div class="option">
<h3>Passphrase</h3>
<div class="section">
{{if and (not .HasPass) (not .IsLogOut)}}<div class="alert info"><p>Add a passphrase to easily log in to your account.</p></div>{{end}}
{{if .HasPass}}<p>Current passphrase</p>
<input type="password" name="current-pass" placeholder="Current passphrase" tabindex="1" /> <input class="show" type="checkbox" id="show-cur-pass" /><label for="show-cur-pass"> Show</label>
<p>New passphrase</p>
{{end}}
{{if .IsLogOut}}<input type="text" value="{{.Username}}" style="display:none" />{{end}}
<input type="password" name="new-pass" autocomplete="new-password" placeholder="New passphrase" tabindex="{{if .IsLogOut}}1{{else}}2{{end}}" /> <input class="show" type="checkbox" id="show-new-pass" /><label for="show-new-pass"> Show</label>
</div>
</div>
<div class="option">
<h3>Email</h3>
<div class="section">
{{if and (not .Email) (not .IsLogOut)}}<div class="alert info"><p>Add your email to get:</p>
<ul>
<li>No-passphrase login</li>
<li>Account recovery if you forget your passphrase</li>
</ul></div>{{end}}
<input type="email" name="email" style="letter-spacing: 1px" placeholder="Email address" value="{{.Email}}" size="40" tabindex="{{if .IsLogOut}}2{{else}}3{{end}}" />
</div>
</div>
<div class="option" style="text-align: center; margin-top: 4em;">
<input type="submit" value="Save changes" tabindex="4" />
</div>
</form>
</div>
<script>
var showChecks = document.querySelectorAll('input.show');
for (var i=0; i<showChecks.length; i++) {
showChecks[i].addEventListener('click', function() {
var prevEl = this.previousElementSibling;
if (prevEl.type == "password") {
prevEl.type = "text";
} else {
prevEl.type = "password";
}
});
}
</script>
{{template "footer" .}}
{{end}}

53
templates/user/stats.tmpl Normal file
View file

@ -0,0 +1,53 @@
{{define "stats"}}
{{template "header" .}}
<style>
table.classy th { text-align: left }
table.classy th.num { text-align: right }
td + td {
padding-left: 0.5em;
padding-right: 0.5em;
}
td.num {
text-align: right;
}
table.classy.export a { text-transform: inherit; }
td.none {
font-style: italic;
}
</style>
<div class="content-container snug">
<h2 id="posts-header">{{if .Collection}}{{.Collection.DisplayTitle}} {{end}}Stats</h2>
<p>Stats for all time.</p>
{{if .Federation}}
<h3>Fediverse stats</h3>
<table id="fediverse" class="classy export">
<tr>
<th>Followers</th>
</tr>
<tr>
<td>{{.APFollowers}}</td>
</tr>
</table>
{{end}}
<h3>Top {{len .TopPosts}} posts</h3>
<table class="classy export">
<tr>
<th>Post</th>
{{if not .Collection}}<th>Blog</th>{{end}}
<th class="num">Total Views</th>
</tr>
{{range .TopPosts}}<tr>
<td style="word-break: break-all;"><a href="{{if .Collection}}{{.Collection.CanonicalURL}}{{.Slug.String}}{{else}}/{{.ID}}{{end}}">{{if ne .Title.String ""}}{{.Title.String}}{{else}}<em>{{.ID}}</em>{{end}}</a></td>
{{ if not $.Collection }}<td>{{if .Collection}}<a href="{{.Collection.CanonicalURL}}">{{.Collection.Title}}</a>{{else}}<em>Draft</em>{{end}}</td>{{ end }}
<td class="num">{{.ViewCount}}</td>
</tr>{{end}}
</table>
</div>
{{template "footer" .}}
{{end}}