Add live markdown preview to the description editor
This commit is contained in:
parent
cf46e16df5
commit
f5ed6ec896
|
@ -14,9 +14,9 @@ type QueryBuilder struct {
|
||||||
Adds the given SQL and arguments to the query. Any occurrences
|
Adds the given SQL and arguments to the query. Any occurrences
|
||||||
of `$?` will be replaced with the correct argument number.
|
of `$?` will be replaced with the correct argument number.
|
||||||
|
|
||||||
foo $? bar $? baz $?
|
foo $? bar $? baz $?
|
||||||
foo ARG1 bar ARG2 baz $?
|
foo ARG1 bar ARG2 baz $?
|
||||||
foo ARG1 bar ARG2 baz ARG3
|
foo ARG1 bar ARG2 baz ARG3
|
||||||
*/
|
*/
|
||||||
func (qb *QueryBuilder) Add(sql string, args ...interface{}) {
|
func (qb *QueryBuilder) Add(sql string, args ...interface{}) {
|
||||||
numPlaceholders := strings.Count(sql, "$?")
|
numPlaceholders := strings.Count(sql, "$?")
|
||||||
|
|
|
@ -298,12 +298,12 @@ func TestProjectCSS(t *testing.T) {
|
||||||
AssertRegexMatch(t, BuildProjectCSS("000000"), RegexProjectCSS, nil)
|
AssertRegexMatch(t, BuildProjectCSS("000000"), RegexProjectCSS, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEditorPreviewsJS(t *testing.T) {
|
func TestMarkdownWorkerJS(t *testing.T) {
|
||||||
AssertRegexMatch(t, BuildEditorPreviewsJS(), RegexEditorPreviewsJS, nil)
|
AssertRegexMatch(t, BuildMarkdownWorkerJS(), RegexMarkdownWorkerJS, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAPICheckUsername(t *testing.T) {
|
func TestAPICheckUsername(t *testing.T) {
|
||||||
AssertRegexmatch(t, BuildAPICheckUsername(), RegexAPICheckUsername, nil)
|
AssertRegexMatch(t, BuildAPICheckUsername(), RegexAPICheckUsername, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPublic(t *testing.T) {
|
func TestPublic(t *testing.T) {
|
||||||
|
|
|
@ -702,11 +702,11 @@ func BuildProjectCSS(color string) string {
|
||||||
return Url("/assets/project.css", []Q{{"color", color}})
|
return Url("/assets/project.css", []Q{{"color", color}})
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexEditorPreviewsJS = regexp.MustCompile("^/assets/editorpreviews.js$")
|
var RegexMarkdownWorkerJS = regexp.MustCompile("^/assets/markdown_worker.js$")
|
||||||
|
|
||||||
func BuildEditorPreviewsJS() string {
|
func BuildMarkdownWorkerJS() string {
|
||||||
defer CatchPanic()
|
defer CatchPanic()
|
||||||
return Url("/assets/editorpreviews.js", nil)
|
return Url("/assets/markdown_worker.js", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexS3Asset *regexp.Regexp
|
var RegexS3Asset *regexp.Regexp
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
{{ template "base.html" . }}
|
{{ template "base.html" . }}
|
||||||
|
|
||||||
{{ define "extrahead" }}
|
{{ define "extrahead" }}
|
||||||
<script src="{{ static "go_wasm_exec.js" }}"></script>
|
{{ template "markdown_previews.html" . }}
|
||||||
|
|
||||||
<script src="{{ static "js/base64.js" }}"></script>
|
<script src="{{ static "js/base64.js" }}"></script>
|
||||||
<script>
|
|
||||||
const previewWorker = new Worker('/assets/editorpreviews.js');
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
#editor {
|
#editor {
|
||||||
|
@ -74,42 +72,12 @@
|
||||||
</span>
|
</span>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{/* TODO: Sticky threads
|
|
||||||
{% if user.is_staff and post and post.depth == 0 %}
|
|
||||||
<div class="checkbox sticky">
|
|
||||||
<input type="checkbox" name="sticky" id="sticky" {% if thread.sticky %}checked{% endif%} />
|
|
||||||
<label for="sticky">Sticky thread</label>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
*/}}
|
|
||||||
|
|
||||||
{{ with .PostReplyingTo }}
|
{{ with .PostReplyingTo }}
|
||||||
<h4 class="mt3">The post you're replying to:</h4>
|
<h4 class="mt3">The post you're replying to:</h4>
|
||||||
<div class="mh-6 overflow-y-auto">
|
<div class="mh-6 overflow-y-auto">
|
||||||
{{ template "forum_post_standalone.html" . }}
|
{{ template "forum_post_standalone.html" . }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{/*
|
|
||||||
|
|
||||||
{% if context_newer %}
|
|
||||||
<h4>Replies since then:</h4>
|
|
||||||
<div class="recent-posts">
|
|
||||||
{% for post in posts_newer %}
|
|
||||||
{% include "forum_thread_single_post.html" %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if context_older %}
|
|
||||||
<h4>Replies before then:</h4>
|
|
||||||
<div class="recent-posts">
|
|
||||||
{% for post in posts_older %}
|
|
||||||
{% include "forum_thread_single_post.html" %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
*/}}
|
|
||||||
</form>
|
</form>
|
||||||
<div id="preview-container" class="post post-preview mathjax flex-fair-ns overflow-auto mv3 mv0-ns ml3-ns pa3 br3 bg--dim">
|
<div id="preview-container" class="post post-preview mathjax flex-fair-ns overflow-auto mv3 mv0-ns ml3-ns pa3 br3 bg--dim">
|
||||||
<div id="preview" class="post-content"></div>
|
<div id="preview" class="post-content"></div>
|
||||||
|
@ -127,74 +95,28 @@
|
||||||
const textField = document.querySelector('#editor');
|
const textField = document.querySelector('#editor');
|
||||||
const preview = document.querySelector('#preview');
|
const preview = document.querySelector('#preview');
|
||||||
|
|
||||||
const storagePrefix = 'post-contents';
|
// Save content on change, clear on submit
|
||||||
|
const clearFuncs = [];
|
||||||
// Delete old irrelevant local post contents
|
|
||||||
const aWeekAgo = new Date().getTime() - (7 * 24 * 60 * 60 * 1000);
|
|
||||||
for (const key in window.localStorage) {
|
|
||||||
if (!window.localStorage.hasOwnProperty(key)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.startsWith(storagePrefix)) {
|
|
||||||
try {
|
|
||||||
const { when } = JSON.parse(window.localStorage.getItem(key));
|
|
||||||
if (when <= aWeekAgo) {
|
|
||||||
window.localStorage.removeItem(key);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load any stored content from localStorage
|
|
||||||
const storageKey = `${storagePrefix}/${window.location.host}${window.location.pathname}`;
|
|
||||||
const storedContents = window.localStorage.getItem(storageKey);
|
|
||||||
if (storedContents && !textField.value) {
|
|
||||||
try {
|
|
||||||
const { title, contents } = JSON.parse(storedContents);
|
|
||||||
if (titleField) {
|
if (titleField) {
|
||||||
titleField.value = title;
|
const { clear: clearTitle } = autosaveContent({
|
||||||
}
|
inputEl: titleField,
|
||||||
textField.value = contents;
|
storageKey: `post-title/${window.location.host}${window.location.pathname}`,
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePreview(previewHtml) {
|
|
||||||
preview.innerHTML = previewHtml;
|
|
||||||
MathJax.typeset();
|
|
||||||
}
|
|
||||||
|
|
||||||
previewWorker.onmessage = ({ data }) => {
|
|
||||||
updatePreview(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
function doMarkdown() {
|
|
||||||
const md = textField.value;
|
|
||||||
previewWorker.postMessage(md);
|
|
||||||
updateContentCache();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateContentCache() {
|
|
||||||
window.localStorage.setItem(storageKey, JSON.stringify({
|
|
||||||
when: new Date().getTime(),
|
|
||||||
title: titleField ? titleField.value : '',
|
|
||||||
contents: textField.value,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
doMarkdown();
|
|
||||||
textField.addEventListener('input', () => doMarkdown());
|
|
||||||
if (titleField) {
|
|
||||||
titleField.addEventListener('input', () => updateContentCache());
|
|
||||||
}
|
|
||||||
|
|
||||||
form.addEventListener('submit', e => {
|
|
||||||
window.localStorage.removeItem(storageKey);
|
|
||||||
});
|
});
|
||||||
|
clearFuncs.push(clearTitle);
|
||||||
|
}
|
||||||
|
const { clear: clearContent } = autosaveContent({
|
||||||
|
inputEl: textField,
|
||||||
|
storageKey: `post-content/${window.location.host}${window.location.pathname}`,
|
||||||
|
});
|
||||||
|
clearFuncs.push(clearContent);
|
||||||
|
form.addEventListener('submit', e => {
|
||||||
|
for (const clear of clearFuncs) {
|
||||||
|
clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Do live Markdown previews
|
||||||
|
initLiveMarkdown({ inputEl: textField, previewEl: preview });
|
||||||
|
|
||||||
/*
|
/*
|
||||||
/ Asset upload
|
/ Asset upload
|
||||||
|
|
|
@ -54,7 +54,7 @@
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ if .CanEdit }}
|
{{ if .CanEdit }}
|
||||||
<div class="flex-grow-1"></div>
|
<div class="flex-grow-1 dn db-ns"></div>
|
||||||
<div class="root-item flex-shrink-0">
|
<div class="root-item flex-shrink-0">
|
||||||
<a href="{{ .EditUrl }}">Edit Project</a>
|
<a href="{{ .EditUrl }}">Edit Project</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -89,8 +89,8 @@
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
<div class="dn flex-ns items-center f3">
|
<div class="dn flex-ns items-center f3">
|
||||||
<a class="svgicon" href="https://twitter.com/handmade_net/" target="_blank">{{ svg "twitter" }}</a>
|
<a class="svgicon svgicon-nofix" href="https://twitter.com/handmade_net/" target="_blank">{{ svg "twitter" }}</a>
|
||||||
<a class="svgicon ml2" href="https://discord.gg/hxWxDee" target="_blank">{{ svg "discord" }}</a>
|
<a class="svgicon svgicon-nofix ml2" href="https://discord.gg/hxWxDee" target="_blank">{{ svg "discord" }}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
@ -0,0 +1,106 @@
|
||||||
|
<script src="{{ static "go_wasm_exec.js" }}"></script>
|
||||||
|
<script>
|
||||||
|
const previewWorker = new Worker('/assets/markdown_worker.js');
|
||||||
|
|
||||||
|
/*
|
||||||
|
Automatically save and restore content from a text field on change.
|
||||||
|
|
||||||
|
Return type:
|
||||||
|
|
||||||
|
{
|
||||||
|
// Call this function when you submit the form or otherwise want
|
||||||
|
// to delete the work-in-progress user content.
|
||||||
|
clearStorage: () => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
|
function autosaveContent({
|
||||||
|
// HTML input or textarea
|
||||||
|
inputEl,
|
||||||
|
// Unique string identifying this field across the site.
|
||||||
|
storageKey,
|
||||||
|
}) {
|
||||||
|
const storagePrefix = 'saved-content';
|
||||||
|
|
||||||
|
// Delete old irrelevant local contents
|
||||||
|
const aWeekAgo = new Date().getTime() - (7 * 24 * 60 * 60 * 1000);
|
||||||
|
for (const key in window.localStorage) {
|
||||||
|
if (!window.localStorage.hasOwnProperty(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.startsWith(storagePrefix)) {
|
||||||
|
try {
|
||||||
|
const { when } = JSON.parse(window.localStorage.getItem(key));
|
||||||
|
if (when <= aWeekAgo) {
|
||||||
|
window.localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load any stored content from localStorage
|
||||||
|
const storageKeyFull = `${storagePrefix}/${storageKey}`;
|
||||||
|
const storedContents = window.localStorage.getItem(storageKeyFull);
|
||||||
|
if (storedContents && !inputEl.value) {
|
||||||
|
try {
|
||||||
|
const { contents } = JSON.parse(storedContents);
|
||||||
|
inputEl.value = contents;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateContentCache() {
|
||||||
|
window.localStorage.setItem(storageKeyFull, JSON.stringify({
|
||||||
|
when: new Date().getTime(),
|
||||||
|
contents: inputEl.value,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
inputEl.addEventListener('input', () => updateContentCache());
|
||||||
|
|
||||||
|
return {
|
||||||
|
clear() {
|
||||||
|
window.localStorage.removeItem(storageKeyFull);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdownIds = [];
|
||||||
|
|
||||||
|
/*
|
||||||
|
Initialize live Markdown rendering.
|
||||||
|
*/
|
||||||
|
function initLiveMarkdown({
|
||||||
|
// HTML input or textarea
|
||||||
|
inputEl,
|
||||||
|
// HTML element in which to render markdown
|
||||||
|
previewEl,
|
||||||
|
}) {
|
||||||
|
if (markdownIds.includes(inputEl.id)) {
|
||||||
|
console.warn(`Multiple elements with ID "${inputEl.id}" are being used for Markdown. Results will be very confusing!`);
|
||||||
|
}
|
||||||
|
markdownIds.push(inputEl.id);
|
||||||
|
|
||||||
|
previewWorker.onmessage = ({ data }) => {
|
||||||
|
const { elementID, html } = data;
|
||||||
|
if (elementID === inputEl.id) {
|
||||||
|
previewEl.innerHTML = html;
|
||||||
|
MathJax.typeset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function doMarkdown() {
|
||||||
|
previewWorker.postMessage({
|
||||||
|
elementID: inputEl.id,
|
||||||
|
markdown: inputEl.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
doMarkdown();
|
||||||
|
inputEl.addEventListener('input', () => doMarkdown());
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -5,11 +5,12 @@ NOTE(ben): The structure here is a little funny but allows for some debouncing.
|
||||||
that got queued up can run all at once, then it can process the latest one.
|
that got queued up can run all at once, then it can process the latest one.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
let ready = false;
|
let wasmLoaded = false;
|
||||||
let inputData = null;
|
let jobs = {};
|
||||||
|
|
||||||
onmessage = ({ data }) => {
|
onmessage = ({ data }) => {
|
||||||
inputData = data;
|
const { elementID, markdown } = data;
|
||||||
|
jobs[elementID] = markdown;
|
||||||
setTimeout(doPreview, 0);
|
setTimeout(doPreview, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -17,17 +18,21 @@ const go = new Go();
|
||||||
WebAssembly.instantiateStreaming(fetch('/public/parsing.wasm'), go.importObject)
|
WebAssembly.instantiateStreaming(fetch('/public/parsing.wasm'), go.importObject)
|
||||||
.then(result => {
|
.then(result => {
|
||||||
go.run(result.instance); // don't await this; we want it to be continuously running
|
go.run(result.instance); // don't await this; we want it to be continuously running
|
||||||
ready = true;
|
wasmLoaded = true;
|
||||||
setTimeout(doPreview, 0);
|
setTimeout(doPreview, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
const doPreview = () => {
|
const doPreview = () => {
|
||||||
if (!ready || inputData === null) {
|
if (!wasmLoaded) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = parseMarkdown(inputData);
|
for (const [elementID, markdown] of Object.entries(jobs)) {
|
||||||
inputData = null;
|
const html = parseMarkdown(markdown);
|
||||||
|
postMessage({
|
||||||
postMessage(result);
|
elementID: elementID,
|
||||||
|
html: html,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
jobs = {};
|
||||||
}
|
}
|
|
@ -1,13 +1,22 @@
|
||||||
{{ template "base.html" . }}
|
{{ template "base.html" . }}
|
||||||
|
|
||||||
{{ define "extrahead" }}
|
{{ define "extrahead" }}
|
||||||
|
{{ template "markdown_previews.html" . }}
|
||||||
<script src="{{ static "js/tabs.js" }}"></script>
|
<script src="{{ static "js/tabs.js" }}"></script>
|
||||||
<script src="{{ static "js/image_selector.js" }}"></script>
|
<script src="{{ static "js/image_selector.js" }}"></script>
|
||||||
<script src="{{ static "js/templates.js" }}"></script>
|
<script src="{{ static "js/templates.js" }}"></script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#desc-preview:empty::after {
|
||||||
|
content: 'A preview of your description will appear here.';
|
||||||
|
color: var(--dimmer-color);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
{{ define "content" }}
|
{{ define "content" }}
|
||||||
<div class="content-block">
|
<div class="ph3 ph0-ns">
|
||||||
{{ if .Editing }}
|
{{ if .Editing }}
|
||||||
<h1>Edit {{ .ProjectSettings.Name }}</h1>
|
<h1>Edit {{ .ProjectSettings.Name }}</h1>
|
||||||
{{ else }}
|
{{ else }}
|
||||||
|
@ -28,20 +37,12 @@
|
||||||
<div>
|
<div>
|
||||||
<select name="lifecycle">
|
<select name="lifecycle">
|
||||||
<option value="active" {{ if eq .ProjectSettings.Lifecycle "active" }}selected{{ end }}>Active</option>
|
<option value="active" {{ if eq .ProjectSettings.Lifecycle "active" }}selected{{ end }}>Active</option>
|
||||||
<option value="hiatus" {{ if eq .ProjectSettings.Lifecycle "hiatus" }}selected{{ end }}>Hiatus</option>
|
<option value="hiatus" {{ if eq .ProjectSettings.Lifecycle "hiatus" }}selected{{ end }}>On Hiatus</option>
|
||||||
<option value="done" {{ if eq .ProjectSettings.Lifecycle "done" }}selected{{ end }}>Completed</option>
|
<option value="done" {{ if eq .ProjectSettings.Lifecycle "done" }}selected{{ end }}>Completed</option>
|
||||||
<option value="dead" {{ if eq .ProjectSettings.Lifecycle "dead" }}selected{{ end }}>Abandoned</option>
|
<option value="dead" {{ if eq .ProjectSettings.Lifecycle "dead" }}selected{{ end }}>Abandoned</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="edit-form-row">
|
|
||||||
{{/* TODO(asaf): Should this be admin only??*/}}
|
|
||||||
<div>Hidden:</div>
|
|
||||||
<div>
|
|
||||||
<input id="hidden" type="checkbox" name="hidden" {{ if .ProjectSettings.Hidden }}checked{{ end }} />
|
|
||||||
<label for="hidden">Hide</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="edit-form-row">
|
<div class="edit-form-row">
|
||||||
<div class="pt-input-ns">Owners:</div>
|
<div class="pt-input-ns">Owners:</div>
|
||||||
<div>
|
<div>
|
||||||
|
@ -72,12 +73,6 @@
|
||||||
<div class="edit-form-row">
|
<div class="edit-form-row">
|
||||||
<div class="pt-input-ns">Admin settings</div>
|
<div class="pt-input-ns">Admin settings</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="edit-form-row">
|
|
||||||
<div class="pt-input-ns">Slug:</div>
|
|
||||||
<div>
|
|
||||||
<input type="text" name="slug" maxlength="255" class="textbox" value="{{ .ProjectSettings.Slug }}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="edit-form-row">
|
<div class="edit-form-row">
|
||||||
<div>Official:</div>
|
<div>Official:</div>
|
||||||
<div>
|
<div>
|
||||||
|
@ -85,12 +80,27 @@
|
||||||
<label for="official">Official HMN project</label>
|
<label for="official">Official HMN project</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="edit-form-row">
|
||||||
|
<div>Hidden:</div>
|
||||||
|
<div>
|
||||||
|
<input id="hidden" type="checkbox" name="hidden" {{ if .ProjectSettings.Hidden }}checked{{ end }} />
|
||||||
|
<label for="hidden">Hide</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="edit-form-row">
|
||||||
|
<div class="pt-input-ns">Slug:</div>
|
||||||
|
<div>
|
||||||
|
<input type="text" name="slug" maxlength="255" class="textbox" value="{{ .ProjectSettings.Slug }}">
|
||||||
|
<div class="c--dim f7">Has no effect for personal projects. Personal projects have a slug derived from the title.</div>
|
||||||
|
<div class="c--dim f7">If you change this, make sure to change DNS too!</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="edit-form-row">
|
<div class="edit-form-row">
|
||||||
<div>Featured:</div>
|
<div>Featured:</div>
|
||||||
<div>
|
<div>
|
||||||
<input id="featured" type="checkbox" name="featured" {{ if .ProjectSettings.Featured }}checked{{ end }} />
|
<input id="featured" type="checkbox" name="featured" {{ if .ProjectSettings.Featured }}checked{{ end }} />
|
||||||
<label for="featured">Featured</label>
|
<label for="featured">Featured</label>
|
||||||
<div class="c--dim f7">Bump to the top of the project index and show in the carousel.</div>
|
<div class="c--dim f7">Bump to the top of the project index and show in the carousel. Has no effect for personal projects.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
@ -118,10 +128,11 @@
|
||||||
<div class="edit-form-row">
|
<div class="edit-form-row">
|
||||||
<div class="pt-input-ns">Full description:</div>
|
<div class="pt-input-ns">Full description:</div>
|
||||||
<div>
|
<div>
|
||||||
<textarea class="w-100 h6 minh-6 mono lh-copy" name="description">
|
<textarea id="description" class="w-100 h5 minh-5 mono lh-copy" name="description">
|
||||||
{{- .ProjectSettings.Description -}}
|
{{- .ProjectSettings.Description -}}
|
||||||
</textarea>
|
</textarea>
|
||||||
{{/* TODO(asaf): Replace with the full editor */}}
|
<div class="b mt3 mb2">Preview:</div>
|
||||||
|
<div id="desc-preview" class="w-100"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="edit-form-row">
|
<div class="edit-form-row">
|
||||||
|
@ -164,7 +175,7 @@
|
||||||
<script>
|
<script>
|
||||||
let csrf = JSON.parse({{ csrftokenjs .Session }});
|
let csrf = JSON.parse({{ csrftokenjs .Session }});
|
||||||
|
|
||||||
let projectForm = document.querySelector("#project_form")
|
let projectForm = document.querySelector("#project_form");
|
||||||
|
|
||||||
projectForm.addEventListener("invalid", function(ev) {
|
projectForm.addEventListener("invalid", function(ev) {
|
||||||
switchToTabOfElement(document.body, ev.target);
|
switchToTabOfElement(document.body, ev.target);
|
||||||
|
@ -174,6 +185,25 @@
|
||||||
switchTab(document.body, tabName);
|
switchTab(document.body, tabName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
////////////////////////////
|
||||||
|
// Description management //
|
||||||
|
////////////////////////////
|
||||||
|
|
||||||
|
{{ if .Editing }}
|
||||||
|
const projectName = "new-project";
|
||||||
|
{{ else }}
|
||||||
|
const projectName = "{{ .Project.Name }}";
|
||||||
|
{{ end }}
|
||||||
|
const description = document.querySelector('#description');
|
||||||
|
const descPreview = document.querySelector('#desc-preview');
|
||||||
|
const { clear: clearDescription } = autosaveContent({
|
||||||
|
inputEl: description,
|
||||||
|
storageKey: `project-description/${projectName}`,
|
||||||
|
});
|
||||||
|
projectForm.addEventListener('submit', () => clearDescription());
|
||||||
|
|
||||||
|
initLiveMarkdown({ inputEl: description, previewEl: descPreview });
|
||||||
|
|
||||||
//////////////////////
|
//////////////////////
|
||||||
// Owner management //
|
// Owner management //
|
||||||
//////////////////////
|
//////////////////////
|
||||||
|
|
|
@ -432,7 +432,7 @@ func ProjectNewSubmit(c *RequestContext) ResponseData {
|
||||||
|
|
||||||
formResult.Payload.ProjectID = projectId
|
formResult.Payload.ProjectID = projectId
|
||||||
|
|
||||||
err = UpdateProject(c.Context(), tx, c.CurrentUser, &formResult.Payload)
|
err = updateProject(c.Context(), tx, c.CurrentUser, &formResult.Payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, err)
|
return c.ErrorResponse(http.StatusInternalServerError, err)
|
||||||
}
|
}
|
||||||
|
@ -492,7 +492,7 @@ func ProjectEditSubmit(c *RequestContext) ResponseData {
|
||||||
|
|
||||||
formResult.Payload.ProjectID = c.CurrentProject.ID
|
formResult.Payload.ProjectID = c.CurrentProject.ID
|
||||||
|
|
||||||
err = UpdateProject(c.Context(), tx, c.CurrentUser, &formResult.Payload)
|
err = updateProject(c.Context(), tx, c.CurrentUser, &formResult.Payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, err)
|
return c.ErrorResponse(http.StatusInternalServerError, err)
|
||||||
}
|
}
|
||||||
|
@ -609,7 +609,7 @@ func ParseProjectEditForm(c *RequestContext) ProjectEditFormResult {
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
func UpdateProject(ctx context.Context, conn db.ConnOrTx, user *models.User, payload *ProjectPayload) error {
|
func updateProject(ctx context.Context, conn db.ConnOrTx, user *models.User, payload *ProjectPayload) error {
|
||||||
var lightLogoUUID *uuid.UUID
|
var lightLogoUUID *uuid.UUID
|
||||||
if payload.LightLogo.Exists {
|
if payload.LightLogo.Exists {
|
||||||
lightLogo := &payload.LightLogo
|
lightLogo := &payload.LightLogo
|
||||||
|
@ -657,26 +657,28 @@ func UpdateProject(ctx context.Context, conn db.ConnOrTx, user *models.User, pay
|
||||||
payload.OwnerUsernames = append(payload.OwnerUsernames, selfUsername)
|
payload.OwnerUsernames = append(payload.OwnerUsernames, selfUsername)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := conn.Exec(ctx,
|
var qb db.QueryBuilder
|
||||||
|
qb.Add(
|
||||||
`
|
`
|
||||||
UPDATE handmade_project SET
|
UPDATE handmade_project SET
|
||||||
name = $2,
|
name = $?,
|
||||||
blurb = $3,
|
blurb = $?,
|
||||||
description = $4,
|
description = $?,
|
||||||
descparsed = $5,
|
descparsed = $?,
|
||||||
lifecycle = $6,
|
lifecycle = $?,
|
||||||
hidden = $7
|
|
||||||
WHERE
|
|
||||||
id = $1
|
|
||||||
`,
|
`,
|
||||||
payload.ProjectID,
|
|
||||||
payload.Name,
|
payload.Name,
|
||||||
payload.Blurb,
|
payload.Blurb,
|
||||||
payload.Description,
|
payload.Description,
|
||||||
payload.ParsedDescription,
|
payload.ParsedDescription,
|
||||||
payload.Lifecycle,
|
payload.Lifecycle,
|
||||||
payload.Hidden,
|
|
||||||
)
|
)
|
||||||
|
if user.IsStaff {
|
||||||
|
qb.Add(`hidden = $?`, payload.Hidden)
|
||||||
|
}
|
||||||
|
qb.Add(`WHERE id = $?`, payload.ProjectID)
|
||||||
|
|
||||||
|
_, err := conn.Exec(ctx, qb.String(), qb.Args()...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return oops.New(err, "Failed to update project")
|
return oops.New(err, "Failed to update project")
|
||||||
}
|
}
|
||||||
|
|
|
@ -350,9 +350,9 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool) ht
|
||||||
anyProject.GET(hmnurl.RegexCineraIndex, CineraIndex)
|
anyProject.GET(hmnurl.RegexCineraIndex, CineraIndex)
|
||||||
|
|
||||||
anyProject.GET(hmnurl.RegexProjectCSS, ProjectCSS)
|
anyProject.GET(hmnurl.RegexProjectCSS, ProjectCSS)
|
||||||
anyProject.GET(hmnurl.RegexEditorPreviewsJS, func(c *RequestContext) ResponseData {
|
anyProject.GET(hmnurl.RegexMarkdownWorkerJS, func(c *RequestContext) ResponseData {
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
res.MustWriteTemplate("editorpreviews.js", nil, c.Perf)
|
res.MustWriteTemplate("markdown_worker.js", nil, c.Perf)
|
||||||
res.Header().Add("Content-Type", "application/javascript")
|
res.Header().Add("Content-Type", "application/javascript")
|
||||||
return res
|
return res
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in New Issue