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) {
 | 
					 | 
				
			||||||
                titleField.value = title;
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            textField.value = contents;
 | 
					 | 
				
			||||||
        } 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) {
 | 
					    if (titleField) {
 | 
				
			||||||
        titleField.addEventListener('input', () => updateContentCache());
 | 
					        const { clear: clearTitle } = autosaveContent({
 | 
				
			||||||
 | 
					            inputEl: titleField,
 | 
				
			||||||
 | 
					            storageKey: `post-title/${window.location.host}${window.location.pathname}`,
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        clearFuncs.push(clearTitle);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					    const { clear: clearContent } = autosaveContent({
 | 
				
			||||||
    form.addEventListener('submit', e => {
 | 
					        inputEl: textField,
 | 
				
			||||||
        window.localStorage.removeItem(storageKey);
 | 
					        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